Quellcode durchsuchen

feat(dashboard): Add manage variants screen

Michael Bromley vor 4 Monaten
Ursprung
Commit
cdfd4ca63d

+ 127 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/add-option-group-dialog.tsx

@@ -0,0 +1,127 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+    DialogTrigger,
+} from '@/vdb/components/ui/dialog.js';
+import { Form } from '@/vdb/components/ui/form.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation } from '@tanstack/react-query';
+import { Plus, Save } from 'lucide-react';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import { addOptionGroupToProductDocument, createProductOptionGroupDocument } from '../products.graphql.js';
+import { OptionGroup, optionGroupSchema, SingleOptionGroupEditor } from './option-groups-editor.js';
+
+export function AddOptionGroupDialog({
+    productId,
+    onSuccess,
+}: Readonly<{
+    productId: string;
+    onSuccess?: () => void;
+}>) {
+    const [open, setOpen] = useState(false);
+    const { i18n } = useLingui();
+
+    const form = useForm<OptionGroup>({
+        resolver: zodResolver(optionGroupSchema),
+        defaultValues: {
+            name: '',
+            values: [],
+        },
+        mode: 'onChange',
+    });
+
+    const createOptionGroupMutation = useMutation({
+        mutationFn: api.mutate(createProductOptionGroupDocument),
+    });
+
+    const addOptionGroupToProductMutation = useMutation({
+        mutationFn: api.mutate(addOptionGroupToProductDocument),
+    });
+
+    const handleSave = async () => {
+        const formValue = form.getValues();
+        if (!formValue.name || formValue.values.length === 0) return;
+
+        try {
+            const createResult = await createOptionGroupMutation.mutateAsync({
+                input: {
+                    code: formValue.name.toLowerCase().replace(/\s+/g, '-'),
+                    translations: [
+                        {
+                            languageCode: 'en',
+                            name: formValue.name,
+                        },
+                    ],
+                    options: formValue.values.map(value => ({
+                        code: value.value.toLowerCase().replace(/\s+/g, '-'),
+                        translations: [
+                            {
+                                languageCode: 'en',
+                                name: value.value,
+                            },
+                        ],
+                    })),
+                },
+            });
+
+            if (createResult?.createProductOptionGroup) {
+                await addOptionGroupToProductMutation.mutateAsync({
+                    productId,
+                    optionGroupId: createResult.createProductOptionGroup.id,
+                });
+            }
+
+            toast.success(i18n.t('Successfully created option group'));
+            setOpen(false);
+            onSuccess?.();
+        } catch (error) {
+            toast.error(i18n.t('Failed to create option group'), {
+                description: error instanceof Error ? error.message : i18n.t('Unknown error'),
+            });
+        }
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={setOpen}>
+            <DialogTrigger asChild>
+                <Button variant="outline">
+                    <Plus className="mr-2 h-4 w-4" />
+                    <Trans>Add option group</Trans>
+                </Button>
+            </DialogTrigger>
+            <DialogContent className="max-w-2xl" aria-description={'Add option group'}>
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Add option group to product</Trans>
+                    </DialogTitle>
+                </DialogHeader>
+                <div className="space-y-4">
+                    <Form {...form}>
+                        <SingleOptionGroupEditor control={form.control} fieldArrayPath={''} />
+                    </Form>
+                </div>
+                <DialogFooter>
+                    <Button
+                        onClick={handleSave}
+                        disabled={
+                            !form.formState.isValid ||
+                            createOptionGroupMutation.isPending ||
+                            addOptionGroupToProductMutation.isPending
+                        }
+                    >
+                        <Save className="mr-2 h-4 w-4" />
+                        <Trans>Save option group</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 41 - 39
packages/dashboard/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx

@@ -22,6 +22,7 @@ import { useCallback, useEffect, useState } from 'react';
 import { useForm } from 'react-hook-form';
 import { toast } from 'sonner';
 import * as z from 'zod';
+import { createProductOptionDocument } from '../products.graphql.js';
 import { CreateProductOptionsDialog } from './create-product-options-dialog.js';
 import { ProductOptionSelect } from './product-option-select.js';
 
@@ -63,43 +64,6 @@ const createProductVariantDocument = graphql(`
     }
 `);
 
-const createProductOptionDocument = graphql(`
-    mutation CreateProductOption($input: CreateProductOptionInput!) {
-        createProductOption(input: $input) {
-            id
-            code
-            name
-            groupId
-        }
-    }
-`);
-
-const createProductOptionGroupDocument = graphql(`
-    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
-        createProductOptionGroup(input: $input) {
-            id
-        }
-    }
-`);
-
-const addOptionGroupToProductDocument = graphql(`
-    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
-        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
-            id
-            optionGroups {
-                id
-                code
-                name
-                options {
-                    id
-                    code
-                    name
-                }
-            }
-        }
-    }
-`);
-
 const formSchema = z.object({
     name: z.string().min(1, 'Name is required'),
     sku: z.string().min(1, 'SKU is required'),
@@ -256,8 +220,8 @@ export function AddProductVariantDialog({
         [createProductVariantMutation, productData?.product, duplicateVariantError, productId],
     );
 
-    // If there are no option groups, show the create options dialog instead
-    if (productData?.product?.optionGroups.length === 0) {
+    // If there are no option groups and no variants, show the create options dialog instead
+    if (productData?.product?.optionGroups.length === 0 && productData?.product?.variants.length === 0) {
         return (
             <CreateProductOptionsDialog
                 productId={productId}
@@ -269,6 +233,35 @@ export function AddProductVariantDialog({
         );
     }
 
+    // If there are no option groups but there are existing variants, show a different UI
+    if (productData?.product?.optionGroups.length === 0 && productData?.product?.variants.length > 0) {
+        return (
+            <Dialog open={open} onOpenChange={setOpen}>
+                <DialogTrigger asChild>
+                    <Button variant="outline">
+                        <Plus className="mr-2 h-4 w-4" />
+                        <Trans>Add variant</Trans>
+                    </Button>
+                </DialogTrigger>
+                <DialogContent>
+                    <DialogHeader>
+                        <DialogTitle>
+                            <Trans>Add product options first</Trans>
+                        </DialogTitle>
+                    </DialogHeader>
+                    <div className="space-y-4">
+                        <p className="text-sm text-muted-foreground">
+                            <Trans>
+                                This product has existing variants but no option groups defined. You need to
+                                add option groups before creating new variants.
+                            </Trans>
+                        </p>
+                    </div>
+                </DialogContent>
+            </Dialog>
+        );
+    }
+
     return (
         <Dialog open={open} onOpenChange={setOpen}>
             <DialogTrigger asChild>
@@ -291,6 +284,15 @@ export function AddProductVariantDialog({
                         }}
                         className="space-y-4"
                     >
+                        {productData?.product?.optionGroups.length && (
+                            <div className="flex flex-col gap-2">
+                                <div className="flex justify-between items-center">
+                                    <label className="text-sm font-medium">
+                                        <Trans>Product options</Trans>
+                                    </label>
+                                </div>
+                            </div>
+                        )}
                         <div className="grid grid-cols-2 gap-4">
                             {productData?.product?.optionGroups.map(group => (
                                 <ProductOptionSelect

+ 1 - 33
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-options-dialog.tsx

@@ -20,6 +20,7 @@ import { useState } from 'react';
 import { useForm } from 'react-hook-form';
 import { toast } from 'sonner';
 import * as z from 'zod';
+import { addOptionGroupToProductDocument, createProductOptionGroupDocument } from '../products.graphql.js';
 
 const getProductDocument = graphql(`
     query GetProduct($productId: ID!) {
@@ -51,39 +52,6 @@ const getProductDocument = graphql(`
     }
 `);
 
-const createProductOptionGroupDocument = graphql(`
-    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
-        createProductOptionGroup(input: $input) {
-            id
-            code
-            name
-            options {
-                id
-                code
-                name
-            }
-        }
-    }
-`);
-
-const addOptionGroupToProductDocument = graphql(`
-    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
-        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
-            id
-            optionGroups {
-                id
-                code
-                name
-                options {
-                    id
-                    code
-                    name
-                }
-            }
-        }
-    }
-`);
-
 const updateProductVariantDocument = graphql(`
     mutation UpdateProductVariant($input: UpdateProductVariantInput!) {
         updateProductVariant(input: $input) {

+ 7 - 42
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx

@@ -9,54 +9,19 @@ import {
     DialogTrigger,
 } from '@/vdb/components/ui/dialog.js';
 import { api } from '@/vdb/graphql/api.js';
-import { graphql } from '@/vdb/graphql/graphql.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
 import { Trans } from '@/vdb/lib/trans.js';
 import { normalizeString } from '@/vdb/lib/utils.js';
 import { useMutation } from '@tanstack/react-query';
 import { Plus } from 'lucide-react';
 import { useCallback, useState } from 'react';
+import {
+    addOptionGroupToProductDocument,
+    createProductOptionGroupDocument,
+    createProductVariantsDocument,
+} from '../products.graphql.js';
 import { CreateProductVariants, VariantConfiguration } from './create-product-variants.js';
 
-const createProductOptionsMutation = graphql(`
-    mutation CreateOptionGroups($input: CreateProductOptionGroupInput!) {
-        createProductOptionGroup(input: $input) {
-            id
-            name
-            options {
-                id
-                code
-                name
-            }
-        }
-    }
-`);
-
-export const addOptionGroupToProductDocument = graphql(`
-    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
-        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
-            id
-            optionGroups {
-                id
-                code
-                options {
-                    id
-                    code
-                }
-            }
-        }
-    }
-`);
-
-export const createProductVariantsDocument = graphql(`
-    mutation CreateProductVariants($input: [CreateProductVariantInput!]!) {
-        createProductVariants(input: $input) {
-            id
-            name
-        }
-    }
-`);
-
 export function CreateProductVariantsDialog({
     productId,
     productName,
@@ -71,7 +36,7 @@ export function CreateProductVariantsDialog({
     const [open, setOpen] = useState(false);
 
     const createOptionGroupMutation = useMutation({
-        mutationFn: api.mutate(createProductOptionsMutation),
+        mutationFn: api.mutate(createProductOptionGroupDocument),
     });
 
     const addOptionGroupToProductMutation = useMutation({
@@ -180,7 +145,7 @@ export function CreateProductVariantsDialog({
                     </Button>
                 </DialogTrigger>
 
-                <DialogContent>
+                <DialogContent className="max-w-90vw">
                     <DialogHeader>
                         <DialogTitle>
                             <Trans>Create Variants</Trans>

+ 38 - 134
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants.tsx

@@ -1,5 +1,4 @@
 import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
-import { Button } from '@/vdb/components/ui/button.js';
 import { Checkbox } from '@/vdb/components/ui/checkbox.js';
 import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/vdb/components/ui/form.js';
 import { Input } from '@/vdb/components/ui/input.js';
@@ -9,11 +8,10 @@ import { graphql } from '@/vdb/graphql/graphql.js';
 import { Trans } from '@/vdb/lib/trans.js';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { useQuery } from '@tanstack/react-query';
-import { Plus, Trash2 } from 'lucide-react';
-import { useEffect, useMemo } from 'react';
-import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
+import { useEffect, useMemo, useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
 import { z } from 'zod';
-import { OptionValueInput } from './option-value-input.js';
+import { OptionGroupConfiguration, optionGroupSchema, OptionGroupsEditor } from './option-groups-editor.js';
 
 const getStockLocationsDocument = graphql(`
     query GetStockLocations($options: StockLocationListOptions) {
@@ -27,17 +25,6 @@ const getStockLocationsDocument = graphql(`
     }
 `);
 
-// Define schemas for validation
-const optionValueSchema = z.object({
-    value: z.string().min(1, { message: 'Value cannot be empty' }),
-    id: z.string().min(1, { message: 'Value cannot be empty' }),
-});
-
-const optionGroupSchema = z.object({
-    name: z.string().min(1, { message: 'Option name is required' }),
-    values: z.array(optionValueSchema).min(1, { message: 'At least one value is required' }),
-});
-
 type VariantOption = {
     name: string;
     value: string;
@@ -88,9 +75,7 @@ const formSchema = z.object({
     variants: z.record(variantSchema),
 });
 
-type OptionGroupForm = z.infer<typeof optionGroupSchema>;
 type VariantForm = z.infer<typeof variantSchema>;
-type FormValues = z.infer<typeof formSchema>;
 
 interface CreateProductVariantsProps {
     currencyCode?: string;
@@ -107,80 +92,52 @@ export function CreateProductVariants({
     });
     const stockLocations = stockLocationsResult?.stockLocations.items ?? [];
 
-    const form = useForm<FormValues>({
-        resolver: zodResolver(formSchema),
+    const [optionGroups, setOptionGroups] = useState<OptionGroupConfiguration['optionGroups']>([]);
+
+    const form = useForm<{ variants: Record<string, VariantForm> }>({
+        resolver: zodResolver(z.object({ variants: z.record(variantSchema) })),
         defaultValues: {
-            optionGroups: [],
             variants: {},
         },
         mode: 'onChange',
     });
 
-    const { control, watch, setValue } = form;
-    const {
-        fields: optionGroups,
-        append: appendOptionGroup,
-        remove: removeOptionGroup,
-    } = useFieldArray({
-        control,
-        name: 'optionGroups',
-    });
+    const { setValue } = form;
 
-    const watchedOptionGroups = watch('optionGroups');
     // memoize the variants
-    const variants = useMemo(
-        () => generateVariants(watchedOptionGroups),
-        [JSON.stringify(watchedOptionGroups)],
-    );
+    const variants = useMemo(() => generateVariants(optionGroups), [JSON.stringify(optionGroups)]);
 
     // Use the handleSubmit approach for the entire form
     useEffect(() => {
-        const subscription = form.watch((value, { name, type }) => {
-            if (value?.optionGroups) {
-                const formVariants = value.variants || {};
-                const activeVariants: VariantConfiguration['variants'] = [];
-
-                variants.forEach(variant => {
-                    if (variant && typeof variant === 'object') {
-                        const formVariant = formVariants[variant.id];
-                        if (formVariant) {
-                            activeVariants.push({
-                                enabled: formVariant.enabled ?? true,
-                                sku: formVariant.sku ?? '',
-                                price: formVariant.price ?? '',
-                                stock: formVariant.stock ?? '',
-                                options: variant.options,
-                            });
-                        }
+        const subscription = form.watch(value => {
+            const formVariants = value?.variants || {};
+            const activeVariants: VariantConfiguration['variants'] = [];
+
+            variants.forEach(variant => {
+                if (variant && typeof variant === 'object') {
+                    const formVariant = formVariants[variant.id];
+                    if (formVariant) {
+                        activeVariants.push({
+                            enabled: formVariant.enabled ?? true,
+                            sku: formVariant.sku ?? '',
+                            price: formVariant.price ?? '',
+                            stock: formVariant.stock ?? '',
+                            options: variant.options,
+                        });
                     }
-                });
-
-                const validOptionGroups = value.optionGroups
-                    .filter((group): group is NonNullable<typeof group> => !!group)
-                    .filter(group => typeof group.name === 'string' && Array.isArray(group.values))
-                    .map(group => ({
-                        name: group.name,
-                        values: (group.values || [])
-                            .filter((v): v is NonNullable<typeof v> => !!v)
-                            .filter(v => typeof v.value === 'string' && typeof v.id === 'string')
-                            .map(v => ({
-                                value: v.value,
-                                id: v.id,
-                            })),
-                    }))
-                    .filter(group => group.values.length > 0) as VariantConfiguration['optionGroups'];
-
-                const filteredData: VariantConfiguration = {
-                    optionGroups: validOptionGroups,
-                    variants: activeVariants,
-                };
+                }
+            });
 
-                onChange?.({ data: filteredData });
-            }
+            const filteredData: VariantConfiguration = {
+                optionGroups,
+                variants: activeVariants,
+            };
+
+            onChange?.({ data: filteredData });
         });
 
         return () => subscription.unsubscribe();
-    }, [form, onChange, variants]);
+    }, [form, onChange, variants, optionGroups]);
 
     // Initialize variant form values when variants change
     useEffect(() => {
@@ -202,64 +159,11 @@ export function CreateProductVariants({
         setValue('variants', updatedVariants);
     }, [variants, form, setValue]);
 
-    const handleAddOptionGroup = () => {
-        appendOptionGroup({ name: '', values: [] });
-    };
-
     return (
         <FormProvider {...form}>
-            {optionGroups.map((group, index) => (
-                <div key={group.id} className="grid grid-cols-[1fr_2fr_auto] gap-4 mb-6 items-start">
-                    <div>
-                        <FormField
-                            control={form.control}
-                            name={`optionGroups.${index}.name`}
-                            render={({ field }) => (
-                                <FormItem>
-                                    <FormLabel>
-                                        <Trans>Option</Trans>
-                                    </FormLabel>
-                                    <FormControl>
-                                        <Input placeholder="e.g. Size" {...field} />
-                                    </FormControl>
-                                    <FormMessage />
-                                </FormItem>
-                            )}
-                        />
-                    </div>
-
-                    <div>
-                        <FormItem>
-                            <FormLabel>
-                                <Trans>Option Values</Trans>
-                            </FormLabel>
-                            <FormControl>
-                                <OptionValueInput
-                                    groupName={watch(`optionGroups.${index}.name`) || ''}
-                                    groupIndex={index}
-                                    disabled={!watch(`optionGroups.${index}.name`)}
-                                />
-                            </FormControl>
-                        </FormItem>
-                    </div>
-
-                    <div className="pt-8">
-                        <Button
-                            variant="ghost"
-                            size="icon"
-                            onClick={() => removeOptionGroup(index)}
-                            title="Remove Option"
-                        >
-                            <Trash2 className="h-4 w-4" />
-                        </Button>
-                    </div>
-                </div>
-            ))}
-
-            <Button type="button" variant="secondary" onClick={handleAddOptionGroup} className="mb-6">
-                <Plus className="mr-2 h-4 w-4" />
-                <Trans>Add Option</Trans>
-            </Button>
+            <div className="mb-6">
+                <OptionGroupsEditor onChange={data => setOptionGroups(data.optionGroups)} />
+            </div>
 
             {stockLocations.length === 0 ? (
                 <Alert variant="destructive">
@@ -405,7 +309,7 @@ export function CreateProductVariants({
 }
 
 // Generate all possible combinations of option values
-function generateVariants(groups: OptionGroupForm[]): GeneratedVariant[] {
+function generateVariants(groups: OptionGroupConfiguration['optionGroups']): GeneratedVariant[] {
     // If there are no groups, return a single variant with no options
     if (!groups.length)
         return [
@@ -427,7 +331,7 @@ function generateVariants(groups: OptionGroupForm[]): GeneratedVariant[] {
 
     // Generate combinations
     const generateCombinations = (
-        optionGroups: OptionGroupForm[],
+        optionGroups: OptionGroupConfiguration['optionGroups'],
         currentIndex: number,
         currentCombination: VariantOption[],
     ): GeneratedVariant[] => {

+ 180 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/option-groups-editor.tsx

@@ -0,0 +1,180 @@
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Form } from '@/vdb/components/ui/form.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Plus, Trash2 } from 'lucide-react';
+import { useEffect } from 'react';
+import { Control, useFieldArray, useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { OptionValueInput } from './option-value-input.js';
+
+export const optionValueSchema = z.object({
+    value: z.string().min(1, { message: 'Value cannot be empty' }),
+    id: z.string().min(1, { message: 'Value cannot be empty' }),
+});
+
+export const optionGroupSchema = z.object({
+    name: z.string().min(1, { message: 'Option name is required' }),
+    values: z.array(optionValueSchema).min(1, { message: 'At least one value is required' }),
+});
+
+const multiGroupFormSchema = z.object({
+    optionGroups: z.array(optionGroupSchema),
+});
+
+export type OptionGroup = z.infer<typeof optionGroupSchema>;
+export type MultiGroupForm = z.infer<typeof multiGroupFormSchema>;
+
+export interface SingleOptionGroup {
+    name: string;
+    values: Array<{
+        value: string;
+        id: string;
+    }>;
+}
+
+export interface OptionGroupConfiguration {
+    optionGroups: SingleOptionGroup[];
+}
+
+function validateOptionGroup(group: any): SingleOptionGroup | null {
+    if (!group || typeof group.name !== 'string' || !Array.isArray(group.values)) {
+        return null;
+    }
+
+    const validValues = group.values
+        .filter((v: any): v is NonNullable<typeof v> => !!v)
+        .filter((v: any) => typeof v.value === 'string' && typeof v.id === 'string')
+        .map((v: any) => ({
+            value: v.value,
+            id: v.id,
+        }));
+
+    return validValues.length > 0 ? { name: group.name, values: validValues } : null;
+}
+
+interface SingleOptionGroupEditorProps {
+    control: Control<any>;
+    fieldArrayPath: string;
+    disabled?: boolean;
+}
+
+export function SingleOptionGroupEditor({
+    control,
+    fieldArrayPath,
+    disabled,
+}: Readonly<SingleOptionGroupEditorProps>) {
+    const { fields, append, remove } = useFieldArray({
+        control,
+        name: [fieldArrayPath, 'values'].join('.'),
+    });
+
+    return (
+        <div className="space-y-4">
+            <div className="grid grid-cols-[1fr_2fr] gap-4 items-start">
+                <div>
+                    <FormFieldWrapper
+                        control={control}
+                        name={[fieldArrayPath, 'name'].join('.')}
+                        label={<Trans>Option Group Name</Trans>}
+                        render={({ field }) => <Input placeholder="e.g. Size" {...field} />}
+                    />
+                </div>
+
+                <div>
+                    <FormFieldWrapper
+                        control={control}
+                        name="values"
+                        label={<Trans>Option Values</Trans>}
+                        render={({ field }) => (
+                            <OptionValueInput
+                                fields={fields as any}
+                                onAdd={append}
+                                onRemove={remove}
+                                disabled={disabled}
+                            />
+                        )}
+                    />
+                </div>
+            </div>
+        </div>
+    );
+}
+
+// Multi Option Groups Editor - for use in create product variants
+interface OptionGroupsEditorProps {
+    onChange?: (data: OptionGroupConfiguration) => void;
+    initialGroups?: OptionGroupConfiguration['optionGroups'];
+}
+
+export function OptionGroupsEditor({ onChange, initialGroups = [] }: Readonly<OptionGroupsEditorProps>) {
+    const form = useForm<MultiGroupForm>({
+        resolver: zodResolver(multiGroupFormSchema),
+        defaultValues: {
+            optionGroups: initialGroups.length > 0 ? initialGroups : [],
+        },
+        mode: 'onChange',
+    });
+
+    const { control } = form;
+    const {
+        fields: optionGroups,
+        append: appendOptionGroup,
+        remove: removeOptionGroup,
+    } = useFieldArray({
+        control,
+        name: 'optionGroups',
+    });
+
+    // Watch for changes and notify parent
+    useEffect(() => {
+        const subscription = form.watch(value => {
+            if (value?.optionGroups) {
+                const validOptionGroups = value.optionGroups
+                    .map(validateOptionGroup)
+                    .filter((group): group is SingleOptionGroup => group !== null);
+
+                const filteredData: OptionGroupConfiguration = {
+                    optionGroups: validOptionGroups,
+                };
+
+                onChange?.(filteredData);
+            }
+        });
+
+        return () => subscription.unsubscribe();
+    }, [form, onChange]);
+
+    const handleAddOptionGroup = () => {
+        appendOptionGroup({ name: '', values: [] });
+    };
+
+    return (
+        <Form {...form}>
+            <div className="space-y-4">
+                {optionGroups.map((group, index) => (
+                    <div key={group.id} className="flex items-start">
+                        <SingleOptionGroupEditor control={control} fieldArrayPath={`optionGroups.${index}`} />
+                        <div className="shrink-0 mt-6">
+                            <Button
+                                variant="ghost"
+                                size="icon"
+                                onClick={() => removeOptionGroup(index)}
+                                title="Remove Option"
+                            >
+                                <Trash2 className="h-4 w-4" />
+                            </Button>
+                        </div>
+                    </div>
+                ))}
+
+                <Button type="button" variant="secondary" onClick={handleAddOptionGroup}>
+                    <Plus className="mr-2 h-4 w-4" />
+                    <Trans>Add Option</Trans>
+                </Button>
+            </div>
+        </Form>
+    );
+}

+ 9 - 39
packages/dashboard/src/app/routes/_authenticated/_products/components/option-value-input.tsx

@@ -1,53 +1,32 @@
 import { Badge } from '@/vdb/components/ui/badge.js';
 import { Button } from '@/vdb/components/ui/button.js';
 import { Input } from '@/vdb/components/ui/input.js';
-import { Plus, X } from 'lucide-react';
+import { X } from 'lucide-react';
 import { useState } from 'react';
-import { useFieldArray, useFormContext } from 'react-hook-form';
 
 interface OptionValue {
     value: string;
     id: string;
 }
 
-interface FormValues {
-    optionGroups: {
-        name: string;
-        values: OptionValue[];
-    }[];
-    variants: Record<
-        string,
-        {
-            enabled: boolean;
-            sku: string;
-            price: string;
-            stock: string;
-        }
-    >;
-}
-
 interface OptionValueInputProps {
-    groupName: string;
-    groupIndex: number;
+    fields: Array<OptionValue>;
+    onAdd: (value: OptionValue) => void;
+    onRemove: (index: number) => void;
     disabled?: boolean;
 }
 
 export function OptionValueInput({
-    groupName,
-    groupIndex,
+    fields,
+    onAdd,
+    onRemove,
     disabled = false,
 }: Readonly<OptionValueInputProps>) {
-    const { control } = useFormContext<FormValues>();
-    const { fields, append, remove } = useFieldArray({
-        control,
-        name: `optionGroups.${groupIndex}.values`,
-    });
-
     const [newValue, setNewValue] = useState('');
 
     const handleAddValue = () => {
         if (newValue.trim() && !fields.some(f => f.value === newValue.trim())) {
-            append({ value: newValue.trim(), id: Date.now().toString() });
+            onAdd({ value: newValue.trim(), id: Date.now().toString() });
             setNewValue('');
         }
     };
@@ -70,15 +49,6 @@ export function OptionValueInput({
                     disabled={disabled}
                     className="flex-1"
                 />
-                <Button
-                    type="button"
-                    variant="outline"
-                    size="sm"
-                    onClick={handleAddValue}
-                    disabled={disabled || !newValue.trim()}
-                >
-                    <Plus className="h-4 w-4" />
-                </Button>
             </div>
 
             <div className="flex flex-wrap gap-2">
@@ -90,7 +60,7 @@ export function OptionValueInput({
                             variant="ghost"
                             size="sm"
                             className="h-4 w-4 p-0 ml-1"
-                            onClick={() => remove(index)}
+                            onClick={() => onRemove(index)}
                         >
                             <X className="h-3 w-3" />
                         </Button>

+ 136 - 0
packages/dashboard/src/app/routes/_authenticated/_products/products.graphql.ts

@@ -95,6 +95,46 @@ export const productDetailDocument = graphql(
     [productDetailFragment],
 );
 
+export const productDetailWithVariantsDocument = graphql(
+    `
+        query ProductDetailWithVariants($id: ID!) {
+            product(id: $id) {
+                ...ProductDetail
+                variantList {
+                    totalItems
+                }
+                optionGroups {
+                    id
+                    code
+                    name
+                    options {
+                        id
+                        code
+                        name
+                    }
+                }
+                variants {
+                    id
+                    name
+                    sku
+                    price
+                    currencyCode
+                    priceWithTax
+                    createdAt
+                    updatedAt
+                    options {
+                        id
+                        code
+                        name
+                        groupId
+                    }
+                }
+            }
+        }
+    `,
+    [productDetailFragment],
+);
+
 export const createProductDocument = graphql(`
     mutation CreateProduct($input: CreateProductInput!) {
         createProduct(input: $input) {
@@ -187,3 +227,99 @@ export const getProductsWithFacetValuesByIdsDocument = graphql(`
         }
     }
 `);
+
+export const addOptionGroupToProductDocument = graphql(`
+    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
+        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
+            id
+            optionGroups {
+                id
+                code
+                name
+                options {
+                    id
+                    code
+                    name
+                }
+            }
+        }
+    }
+`);
+
+export const updateProductVariantDocument = graphql(`
+    mutation UpdateProductVariant($input: UpdateProductVariantInput!) {
+        updateProductVariant(input: $input) {
+            id
+            name
+            options {
+                id
+                code
+                name
+                groupId
+            }
+        }
+    }
+`);
+
+export const deleteProductVariantDocument = graphql(`
+    mutation DeleteProductVariant($id: ID!) {
+        deleteProductVariant(id: $id) {
+            result
+            message
+        }
+    }
+`);
+
+export const removeOptionGroupFromProductDocument = graphql(`
+    mutation RemoveOptionGroupFromProduct($productId: ID!, $optionGroupId: ID!) {
+        removeOptionGroupFromProduct(productId: $productId, optionGroupId: $optionGroupId) {
+            ... on Product {
+                id
+                optionGroups {
+                    id
+                    code
+                    name
+                }
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`);
+
+export const createProductOptionGroupDocument = graphql(`
+    mutation CreateOptionGroups($input: CreateProductOptionGroupInput!) {
+        createProductOptionGroup(input: $input) {
+            id
+            name
+            code
+            options {
+                id
+                code
+                name
+            }
+        }
+    }
+`);
+
+export const createProductOptionDocument = graphql(`
+    mutation CreateProductOption($input: CreateProductOptionInput!) {
+        createProductOption(input: $input) {
+            id
+            code
+            name
+            groupId
+        }
+    }
+`);
+
+export const createProductVariantsDocument = graphql(`
+    mutation CreateProductVariants($input: [CreateProductVariantInput!]!) {
+        createProductVariants(input: $input) {
+            id
+            name
+        }
+    }
+`);

+ 9 - 9
packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx

@@ -23,10 +23,10 @@ import {
 import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
 import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
 import { Trans, useLingui } from '@/vdb/lib/trans.js';
-import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
 import { useRef } from 'react';
 import { toast } from 'sonner';
-import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
 import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
@@ -154,13 +154,13 @@ function ProductDetailPage() {
                             }}
                             fromProductDetailPage={true}
                         />
-                        <div className="mt-4">
-                            <AddProductVariantDialog
-                                productId={params.id}
-                                onSuccess={() => {
-                                    refreshRef.current?.();
-                                }}
-                            />
+                        <div className="mt-4 flex gap-2">
+                            <Button asChild variant="outline">
+                                <Link to="./variants">
+                                    <PlusIcon className="mr-2 h-4 w-4" />
+                                    <Trans>Manage variants</Trans>
+                                </Link>
+                            </Button>
                         </div>
                     </PageBlock>
                 )}

+ 405 - 0
packages/dashboard/src/app/routes/_authenticated/_products/products_.$id_.variants.tsx

@@ -0,0 +1,405 @@
+import { ErrorPage } from '@/vdb/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+    DialogTrigger,
+} from '@/vdb/components/ui/dialog.js';
+import { Form } from '@/vdb/components/ui/form.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
+import { Page, PageBlock, PageLayout, PageTitle } from '@/vdb/framework/layout-engine/page-layout.js';
+import { api } from '@/vdb/graphql/api.js';
+import { ResultOf } from '@/vdb/graphql/graphql.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { Plus, Save, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import * as z from 'zod';
+import { AddOptionGroupDialog } from './components/add-option-group-dialog.js';
+import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
+import {
+    createProductOptionDocument,
+    deleteProductVariantDocument,
+    productDetailWithVariantsDocument,
+    removeOptionGroupFromProductDocument,
+    updateProductVariantDocument,
+} from './products.graphql.js';
+
+const pageId = 'manage-product-variants';
+const getQueryKey = (id: string) => ['DetailPage', 'product', id, 'manage-variants'];
+
+export const Route = createFileRoute('/_authenticated/_products/products_/$id_/variants')({
+    component: ManageProductVariants,
+    loader: async ({ context, params, location }) => {
+        if (!params.id) {
+            throw new Error('ID param is required');
+        }
+        const result = await context.queryClient.ensureQueryData({
+            queryKey: getQueryKey(params.id),
+            queryFn: () => api.query(productDetailWithVariantsDocument, { id: params.id }),
+        });
+        return {
+            breadcrumb: [
+                { path: '/products', label: 'Products' },
+                { path: `/products/${params.id}`, label: result.product?.name },
+                <Trans>Manage variants</Trans>,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+const optionGroupSchema = z.object({
+    name: z.string().min(1, 'Option group name is required'),
+    values: z.array(z.string()).min(1, 'At least one option value is required'),
+});
+
+const addOptionValueSchema = z.object({
+    name: z.string().min(1, 'Option value name is required'),
+});
+
+type AddOptionValueFormValues = z.infer<typeof addOptionValueSchema>;
+type Variant = NonNullable<ResultOf<typeof productDetailWithVariantsDocument>['product']>['variants'][0];
+
+function AddOptionValueDialog({
+    groupId,
+    groupName,
+    onSuccess,
+}: Readonly<{
+    groupId: string;
+    groupName: string;
+    onSuccess?: () => void;
+}>) {
+    const [open, setOpen] = useState(false);
+    const { i18n } = useLingui();
+
+    const form = useForm<AddOptionValueFormValues>({
+        resolver: zodResolver(addOptionValueSchema),
+        defaultValues: {
+            name: '',
+        },
+    });
+
+    const createOptionMutation = useMutation({
+        mutationFn: api.mutate(createProductOptionDocument),
+        onSuccess: () => {
+            toast.success(i18n.t('Successfully added option value'));
+            setOpen(false);
+            form.reset();
+            onSuccess?.();
+        },
+        onError: error => {
+            toast.error(i18n.t('Failed to add option value'), {
+                description: error instanceof Error ? error.message : i18n.t('Unknown error'),
+            });
+        },
+    });
+
+    const onSubmit = (values: AddOptionValueFormValues) => {
+        createOptionMutation.mutate({
+            input: {
+                productOptionGroupId: groupId,
+                code: values.name.toLowerCase().replace(/\s+/g, '-'),
+                translations: [
+                    {
+                        languageCode: 'en',
+                        name: values.name,
+                    },
+                ],
+            },
+        });
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={setOpen}>
+            <DialogTrigger asChild>
+                <Button size="icon" variant="ghost">
+                    <Plus className="h-3 w-3" />
+                </Button>
+            </DialogTrigger>
+            <DialogContent>
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Add option value to {groupName}</Trans>
+                    </DialogTitle>
+                </DialogHeader>
+                <Form {...form}>
+                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="name"
+                            label={<Trans>Option value name</Trans>}
+                            render={({ field }) => (
+                                <Input {...field} placeholder={i18n.t('e.g., Red, Large, Cotton')} />
+                            )}
+                        />
+                        <DialogFooter>
+                            <Button type="submit" disabled={createOptionMutation.isPending}>
+                                <Trans>Add option value</Trans>
+                            </Button>
+                        </DialogFooter>
+                    </form>
+                </Form>
+            </DialogContent>
+        </Dialog>
+    );
+}
+
+function ManageProductVariants() {
+    const { id } = Route.useParams();
+    const { i18n } = useLingui();
+    const [optionsToAddToVariant, setOptionsToAddToVariant] = useState<
+        Record<string, Record<string, string>>
+    >({});
+
+    const { data: productData, refetch } = useQuery({
+        queryFn: () => api.query(productDetailWithVariantsDocument, { id }),
+        queryKey: getQueryKey(id),
+    });
+
+    const updateVariantMutation = useMutation({
+        mutationFn: api.mutate(updateProductVariantDocument),
+        onSuccess: () => {
+            toast.success(i18n.t('Variant updated successfully'));
+            refetch();
+        },
+    });
+
+    const deleteVariantMutation = useMutation({
+        mutationFn: api.mutate(deleteProductVariantDocument),
+        onSuccess: () => {
+            toast.success(i18n.t('Variant deleted successfully'));
+            refetch();
+        },
+    });
+
+    const removeOptionGroupMutation = useMutation({
+        mutationFn: api.mutate(removeOptionGroupFromProductDocument),
+        onSuccess: () => {
+            toast.success(i18n.t('Option group removed'));
+            refetch();
+        },
+    });
+
+    const setOptionToAddToVariant = (variantId: string, groupId: string, optionId: string | undefined) => {
+        if (!optionId) {
+            const updated = { ...optionsToAddToVariant };
+            if (updated[variantId]) {
+                delete updated[variantId][groupId];
+            }
+            setOptionsToAddToVariant(updated);
+        } else {
+            setOptionsToAddToVariant(prev => ({
+                ...prev,
+                [variantId]: {
+                    ...prev[variantId],
+                    [groupId]: optionId,
+                },
+            }));
+        }
+    };
+
+    const addOptionToVariant = async (variant: Variant) => {
+        const optionsToAdd = optionsToAddToVariant[variant.id];
+        if (!optionsToAdd) return;
+
+        const existingOptionIds = variant.options.map(o => o.id);
+        const newOptionIds = Object.values(optionsToAdd).filter(Boolean);
+        const allOptionIds = [...existingOptionIds, ...newOptionIds];
+
+        await updateVariantMutation.mutateAsync({
+            input: {
+                id: variant.id,
+                optionIds: allOptionIds,
+            },
+        });
+
+        setOptionsToAddToVariant(prev => {
+            const updated = { ...prev };
+            delete updated[variant.id];
+            return updated;
+        });
+    };
+
+    const deleteVariant = async (variant: Variant) => {
+        if (confirm(i18n.t('Are you sure you want to delete this variant?'))) {
+            await deleteVariantMutation.mutateAsync({ id: variant.id });
+        }
+    };
+
+    const getOption = (variant: Variant, groupId: string) => {
+        return variant.options.find(o => o.groupId === groupId);
+    };
+
+    if (!productData?.product) {
+        return null;
+    }
+
+    return (
+        <Page pageId={pageId}>
+            <PageTitle>
+                {productData.product.name} - <Trans>Manage variants</Trans>
+            </PageTitle>
+            <PageLayout>
+                <PageBlock column="main" blockId="option-groups" title={<Trans>Option Groups</Trans>}>
+                    <div className="space-y-4 mb-4">
+                        {productData.product.optionGroups.length === 0 ? (
+                            <p className="text-sm text-muted-foreground">
+                                <Trans>
+                                    No option groups defined yet. Add option groups to create different
+                                    variants of your product (e.g., Size, Color, Material)
+                                </Trans>
+                            </p>
+                        ) : (
+                            productData.product.optionGroups.map(group => (
+                                <div key={group.id} className="grid grid-cols-12 gap-4 items-start">
+                                    <div className="col-span-3">
+                                        <label className="text-sm font-medium">
+                                            <Trans>Option</Trans>
+                                        </label>
+                                        <Input value={group.name} disabled />
+                                    </div>
+                                    <div className="col-span-8">
+                                        <label className="text-sm font-medium">
+                                            <Trans>Option values</Trans>
+                                        </label>
+                                        <div className="flex flex-wrap gap-2 mt-1">
+                                            {group.options.map(option => (
+                                                <Badge key={option.id} variant="secondary">
+                                                    {option.name}
+                                                </Badge>
+                                            ))}
+                                            <AddOptionValueDialog
+                                                groupId={group.id}
+                                                groupName={group.name}
+                                                onSuccess={() => refetch()}
+                                            />
+                                        </div>
+                                    </div>
+                                </div>
+                            ))
+                        )}
+                    </div>
+                    <AddOptionGroupDialog productId={id} onSuccess={() => refetch()} />
+                </PageBlock>
+
+                <PageBlock column="main" blockId="product-variants" title={<Trans>Variants</Trans>}>
+                    <div className="mb-4">
+                        <Table>
+                            <TableHeader>
+                                <TableRow>
+                                    <TableHead>
+                                        <Trans>Name</Trans>
+                                    </TableHead>
+                                    <TableHead>
+                                        <Trans>SKU</Trans>
+                                    </TableHead>
+                                    {productData.product.optionGroups.map(group => (
+                                        <TableHead key={group.id}>{group.name}</TableHead>
+                                    ))}
+                                    <TableHead>
+                                        <Trans>Delete</Trans>
+                                    </TableHead>
+                                </TableRow>
+                            </TableHeader>
+                            <TableBody>
+                                {productData.product.variants.map(variant => (
+                                    <TableRow key={variant.id}>
+                                        <TableCell>{variant.name}</TableCell>
+                                        <TableCell>{variant.sku}</TableCell>
+                                        {productData.product?.optionGroups.map(group => {
+                                            const option = getOption(variant, group.id);
+                                            return (
+                                                <TableCell key={group.id}>
+                                                    {option ? (
+                                                        <Badge variant="outline">{option.name}</Badge>
+                                                    ) : (
+                                                        <div className="flex items-center gap-2">
+                                                            <Select
+                                                                value={
+                                                                    optionsToAddToVariant[variant.id]?.[
+                                                                        group.id
+                                                                    ] || ''
+                                                                }
+                                                                onValueChange={value =>
+                                                                    setOptionToAddToVariant(
+                                                                        variant.id,
+                                                                        group.id,
+                                                                        value || undefined,
+                                                                    )
+                                                                }
+                                                            >
+                                                                <SelectTrigger className="w-32">
+                                                                    <SelectValue />
+                                                                </SelectTrigger>
+                                                                <SelectContent>
+                                                                    {group.options.map(opt => (
+                                                                        <SelectItem
+                                                                            key={opt.id}
+                                                                            value={opt.id}
+                                                                        >
+                                                                            {opt.name}
+                                                                        </SelectItem>
+                                                                    ))}
+                                                                </SelectContent>
+                                                            </Select>
+                                                            <Button
+                                                                size="sm"
+                                                                variant={
+                                                                    optionsToAddToVariant[variant.id]?.[
+                                                                        group.id
+                                                                    ]
+                                                                        ? 'default'
+                                                                        : 'outline'
+                                                                }
+                                                                disabled={
+                                                                    !optionsToAddToVariant[variant.id]?.[
+                                                                        group.id
+                                                                    ]
+                                                                }
+                                                                onClick={() => addOptionToVariant(variant)}
+                                                            >
+                                                                <Save className="h-4 w-4" />
+                                                            </Button>
+                                                        </div>
+                                                    )}
+                                                </TableCell>
+                                            );
+                                        })}
+                                        <TableCell>
+                                            <Button
+                                                size="sm"
+                                                variant="ghost"
+                                                onClick={() => deleteVariant(variant)}
+                                            >
+                                                <Trash2 className="h-4 w-4 text-destructive" />
+                                            </Button>
+                                        </TableCell>
+                                    </TableRow>
+                                ))}
+                            </TableBody>
+                        </Table>
+                    </div>
+
+                    <AddProductVariantDialog
+                        productId={id}
+                        onSuccess={() => {
+                            refetch();
+                        }}
+                    />
+                </PageBlock>
+            </PageLayout>
+        </Page>
+    );
+}