Просмотр исходного кода

feat(dashboard): Add support for multiple currency prices

Michael Bromley 3 месяцев назад
Родитель
Сommit
34ace59f72

+ 49 - 0
packages/dashboard/src/app/routes/_authenticated/_product-variants/components/add-currency-dropdown.tsx

@@ -0,0 +1,49 @@
+import { useLingui } from '@lingui/react/macro';
+import { PlusIcon } from 'lucide-react';
+
+import { Button } from '@/vdb/components/ui/button';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu';
+import { useLocalFormat } from '@/vdb/hooks/use-local-format';
+
+interface AddCurrencyDropdownProps {
+    unusedCurrencies: string[];
+    onCurrencySelect: (currencyCode: string) => void;
+    placeholder?: string;
+}
+
+export function AddCurrencyDropdown({
+    unusedCurrencies,
+    onCurrencySelect,
+    placeholder,
+}: AddCurrencyDropdownProps) {
+    const { formatCurrencyName } = useLocalFormat();
+    const { t } = useLingui();
+
+    if (unusedCurrencies.length === 0) {
+        return null;
+    }
+
+    return (
+        <DropdownMenu>
+            <DropdownMenuTrigger asChild>
+                <Button variant="outline" className="gap-2">
+                    <PlusIcon className="size-4" />
+                    {placeholder || t`Add a price in another currency`}
+                </Button>
+            </DropdownMenuTrigger>
+            <DropdownMenuContent>
+                {unusedCurrencies.map(currencyCode => (
+                    <DropdownMenuItem key={currencyCode} onSelect={() => onCurrencySelect(currencyCode)}>
+                        <span className="uppercase text-muted-foreground">{currencyCode}</span>
+                        {formatCurrencyName(currencyCode)}
+                    </DropdownMenuItem>
+                ))}
+            </DropdownMenuContent>
+        </DropdownMenu>
+    );
+}

+ 114 - 22
packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx

@@ -25,10 +25,14 @@ import {
 import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
 import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 import { Trans, useLingui } from '@lingui/react/macro';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { VariablesOf } from 'gql.tada';
+import { Trash } from 'lucide-react';
 import { Fragment } from 'react/jsx-runtime';
 import { toast } from 'sonner';
+import { AddCurrencyDropdown } from './components/add-currency-dropdown.js';
 import { VariantPriceDetail } from './components/variant-price-detail.js';
 import {
     createProductVariantDocument,
@@ -57,12 +61,15 @@ export const Route = createFileRoute('/_authenticated/_product-variants/product-
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 
+type PriceInput = NonNullable<VariablesOf<typeof updateProductVariantDocument>['input']['prices']>[number];
+
 function ProductVariantDetailPage() {
     const params = Route.useParams();
     const navigate = useNavigate();
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { t } = useLingui();
     const { activeChannel } = useChannel();
+    const { formatCurrencyName } = useLocalFormat();
 
     const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
         pageId,
@@ -79,7 +86,7 @@ function ProductVariantDetailPage() {
                 facetValueIds: entity.facetValues.map(facetValue => facetValue.id),
                 taxCategoryId: entity.taxCategory.id,
                 price: entity.price,
-                prices: [],
+                prices: entity.prices,
                 trackInventory: entity.trackInventory,
                 outOfStockThreshold: entity.outOfStockThreshold,
                 stockLevels: entity.stockLevels.map(stockLevel => ({
@@ -117,7 +124,59 @@ function ProductVariantDetailPage() {
         },
     });
 
-    const [price, taxCategoryId] = form.watch(['price', 'taxCategoryId']);
+    const availableCurrencies = activeChannel?.availableCurrencyCodes ?? [];
+    const [prices, taxCategoryId] = form.watch(['prices', 'taxCategoryId']);
+
+    // Filter out deleted prices for display
+    const activePrices = prices?.filter(p => !p.delete) ?? [];
+
+    // Get currencies that are currently active (not deleted)
+    const usedCurrencies = activePrices.map(p => p.currencyCode);
+    const unusedCurrencies = availableCurrencies.filter(c => !usedCurrencies.includes(c));
+
+    const handleAddCurrency = (currencyCode: string) => {
+        const currentPrices = form.getValues('prices') || [];
+
+        // Check if this currency already exists (including deleted ones)
+        const existingPriceIndex = currentPrices.findIndex(p => p.currencyCode === currencyCode);
+
+        if (existingPriceIndex !== -1) {
+            // Currency exists, mark it as not deleted
+            const updatedPrices = [...currentPrices];
+            updatedPrices[existingPriceIndex] = {
+                ...updatedPrices[existingPriceIndex],
+                delete: false,
+            };
+            form.setValue('prices', updatedPrices, {
+                shouldDirty: true,
+                shouldValidate: true,
+            });
+        } else {
+            // Add new currency
+            const newPrice = {
+                currencyCode,
+                price: 0,
+                delete: false,
+            } as PriceInput;
+            form.setValue('prices', [...currentPrices, newPrice], {
+                shouldDirty: true,
+                shouldValidate: true,
+            });
+        }
+    };
+
+    const handleRemoveCurrency = (indexToRemove: number) => {
+        const currentPrices = form.getValues('prices') || [];
+        const updatedPrices = [...currentPrices];
+        updatedPrices[indexToRemove] = {
+            ...updatedPrices[indexToRemove],
+            delete: true,
+        };
+        form.setValue('prices', updatedPrices, {
+            shouldDirty: true,
+            shouldValidate: true,
+        });
+    };
 
     return (
         <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
@@ -168,7 +227,7 @@ function ProductVariantDetailPage() {
                 <CustomFieldsPageBlock column="main" entityType="ProductVariant" control={form.control} />
 
                 <PageBlock column="main" blockId="price-and-tax" title={<Trans>Price and tax</Trans>}>
-                    <div className="grid grid-cols-2 gap-4 items-start">
+                    <DetailFormGrid>
                         <FormFieldWrapper
                             control={form.control}
                             name="taxCategoryId"
@@ -177,26 +236,59 @@ function ProductVariantDetailPage() {
                                 <TaxCategorySelector value={field.value} onChange={field.onChange} />
                             )}
                         />
+                    </DetailFormGrid>
+                    {activePrices.map((price, displayIndex) => {
+                        // Find the actual index in the full prices array
+                        const actualIndex = prices?.findIndex(p => p === price) ?? displayIndex;
 
-                        <div>
-                            <FormFieldWrapper
-                                control={form.control}
-                                name="price"
-                                label={<Trans>Price</Trans>}
-                                render={({ field }) => (
-                                    <MoneyInput {...field} currency={entity?.currencyCode} />
-                                )}
-                            />
-                            <VariantPriceDetail
-                                priceIncludesTax={activeChannel?.pricesIncludeTax ?? false}
-                                price={price}
-                                currencyCode={
-                                    entity?.currencyCode ?? activeChannel?.defaultCurrencyCode ?? ''
-                                }
-                                taxCategoryId={taxCategoryId}
-                            />
-                        </div>
-                    </div>
+                        const currencyCodeLabel = (
+                            <div className="uppercase text-muted-foreground">{price.currencyCode}</div>
+                        );
+                        const priceLabel = (
+                            <div className="flex gap-1 items-center justify-between">
+                                <Trans>Price</Trans> {activePrices.length > 1 ? currencyCodeLabel : null}
+                            </div>
+                        );
+                        return (
+                            <DetailFormGrid key={price.currencyCode}>
+                                <div className="flex gap-1 items-end">
+                                    <FormFieldWrapper
+                                        control={form.control}
+                                        name={`prices.${actualIndex}.price`}
+                                        label={priceLabel}
+                                        render={({ field }) => (
+                                            <MoneyInput {...field} currency={price.currencyCode} />
+                                        )}
+                                    />
+                                    {activePrices.length > 1 && (
+                                        <Button
+                                            type="button"
+                                            variant="ghost"
+                                            size="sm"
+                                            onClick={() => handleRemoveCurrency(actualIndex)}
+                                            className="h-6 w-6 p-0 mb-2 hover:text-destructive hover:bg-destructive-100"
+                                        >
+                                            <Trash className="size-4" />
+                                        </Button>
+                                    )}
+                                </div>
+                                <VariantPriceDetail
+                                    priceIncludesTax={activeChannel?.pricesIncludeTax ?? false}
+                                    price={price.price}
+                                    currencyCode={
+                                        price.currencyCode ?? activeChannel?.defaultCurrencyCode ?? ''
+                                    }
+                                    taxCategoryId={taxCategoryId}
+                                />
+                            </DetailFormGrid>
+                        );
+                    })}
+                    {unusedCurrencies.length ? (
+                        <AddCurrencyDropdown
+                            onCurrencySelect={handleAddCurrency}
+                            unusedCurrencies={unusedCurrencies}
+                        />
+                    ) : null}
                 </PageBlock>
                 <PageBlock column="main" blockId="stock" title={<Trans>Stock</Trans>}>
                     <DetailFormGrid>

+ 1 - 0
packages/dashboard/src/lib/providers/channel-provider.tsx

@@ -16,6 +16,7 @@ const channelFragment = graphql(`
         defaultCurrencyCode
         pricesIncludeTax
         availableLanguageCodes
+        availableCurrencyCodes
     }
 `);