Browse Source

feat(dashboard): Implement variant creation

Michael Bromley 10 months ago
parent
commit
ff60f27769

+ 12 - 1
packages/dashboard/src/framework/page/use-detail-page.ts

@@ -5,6 +5,7 @@ import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@ta
 import { ResultOf, VariablesOf } from 'gql.tada';
 import { DocumentNode } from 'graphql';
 import { Variables } from 'graphql-request';
+import { useCallback } from 'react';
 
 import { getMutationName, getQueryName } from '../document-introspection/get-document-structure.js';
 import { useGeneratedForm } from '../form-engine/use-generated-form.js';
@@ -131,5 +132,15 @@ export function useDetailPage<
         },
     });
 
-    return { form, submitHandler, entity, isPending: updateMutation.isPending || detailQuery?.isPending };
+    const refreshEntity = useCallback(() => {
+        void queryClient.invalidateQueries({ queryKey: detailQueryOptions.queryKey });
+    }, [queryClient, detailQueryOptions.queryKey]);
+
+    return {
+        form,
+        submitHandler,
+        entity,
+        isPending: updateMutation.isPending || detailQuery?.isPending,
+        refreshEntity,
+    };
 }

+ 20 - 0
packages/dashboard/src/lib/utils.ts

@@ -38,3 +38,23 @@ export function formatFileSize(bytes: number): string {
 
     return parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toString() + ' ' + sizes[i];
 }
+
+/**
+ * This is a copy of the normalizeString function from @vendure/common/lib/normalize-string.js
+ * It is duplicated here due to issues importing from that package
+ * inside the monorepo.
+ */
+export function normalizeString(input: string, spaceReplacer = ' '): string {
+    const multipleSequentialReplacerRegex = new RegExp(`([${spaceReplacer}]){2,}`, 'g');
+
+    return (input || '')
+        .normalize('NFD')
+        .replace(/[\u00df]/g, 'ss')
+        .replace(/[\u1e9e]/g, 'SS')
+        .replace(/[\u0308]/g, 'e')
+        .replace(/[\u0300-\u036f]/g, '')
+        .toLowerCase()
+        .replace(/[!"£$%^&*()+[\]{};:@#~?\\/,|><`¬'=‘’©®™]/g, '')
+        .replace(/\s+/g, spaceReplacer)
+        .replace(multipleSequentialReplacerRegex, spaceReplacer);
+}

+ 11 - 25
packages/dashboard/src/providers/channel-provider.tsx

@@ -15,10 +15,13 @@ const channelFragment = graphql(`
     }
 `);
 
-// Query to get all available channels
+// Query to get all available channels and the active channel
 const ChannelsQuery = graphql(
     `
-        query Channels {
+        query ChannelInformation {
+            activeChannel {
+                ...ChannelInfo
+            }
             channels {
                 items {
                     ...ChannelInfo
@@ -30,17 +33,6 @@ const ChannelsQuery = graphql(
     [channelFragment],
 );
 
-// Query to get the active channel
-const ActiveChannelQuery = graphql(
-    `
-        query ActiveChannel {
-            activeChannel {
-                ...ChannelInfo
-            }
-        }
-    `,
-    [channelFragment],
-);
 
 // Define the type for a channel
 type Channel = ResultOf<typeof channelFragment>;
@@ -79,12 +71,6 @@ export function ChannelProvider({ children }: { children: React.ReactNode }) {
         queryFn: () => api.query(ChannelsQuery),
     });
 
-    // Fetch the active channel
-    const { data: activeChannelData, isLoading: isActiveChannelLoading } = useQuery({
-        queryKey: ['activeChannel'],
-        queryFn: () => api.query(ActiveChannelQuery),
-    });
-
     // Set the selected channel and update localStorage
     const setSelectedChannel = React.useCallback((channelId: string) => {
         try {
@@ -98,19 +84,19 @@ export function ChannelProvider({ children }: { children: React.ReactNode }) {
 
     // If no selected channel is set but we have an active channel, use that
     React.useEffect(() => {
-        if (!selectedChannelId && activeChannelData?.activeChannel?.id) {
-            setSelectedChannelId(activeChannelData.activeChannel.id);
+        if (!selectedChannelId && channelsData?.activeChannel?.id) {
+            setSelectedChannelId(channelsData.activeChannel.id);
             try {
-                localStorage.setItem(SELECTED_CHANNEL_KEY, activeChannelData.activeChannel.id);
+                localStorage.setItem(SELECTED_CHANNEL_KEY, channelsData.activeChannel.id);
             } catch (e) {
                 console.error('Failed to store selected channel in localStorage', e);
             }
         }
-    }, [selectedChannelId, activeChannelData]);
+    }, [selectedChannelId, channelsData]);
 
     const channels = channelsData?.channels.items || [];
-    const activeChannel = activeChannelData?.activeChannel;
-    const isLoading = isChannelsLoading || isActiveChannelLoading;
+    const activeChannel = channelsData?.activeChannel;
+    const isLoading = isChannelsLoading;
 
     // Find the selected channel from the list of channels
     const selectedChannel = React.useMemo(() => {

+ 228 - 0
packages/dashboard/src/routes/_authenticated/_products/components/create-product-variants-dialog.tsx

@@ -0,0 +1,228 @@
+import { useCallback, useMemo, useState } from 'react';
+import { CreateProductVariants, VariantConfiguration } from './create-product-variants.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+    DialogDescription,
+    DialogTrigger,
+} from '@/components/ui/dialog.js';
+import { Button } from '@/components/ui/button.js';
+import { Plus } from 'lucide-react';
+import { useChannel } from '@/hooks/use-channel.js';
+import { Trans } from '@lingui/react/macro';
+import { graphql } from '@/graphql/graphql.js';
+import { api } from '@/graphql/api.js';
+import { useMutation } from '@tanstack/react-query';
+import { normalizeString } from '@/lib/utils.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,
+    onSuccess,
+}: {
+    productId: string;
+    productName: string;
+    onSuccess?: () => void;
+}) {
+    const { activeChannel } = useChannel();
+    const [variantData, setVariantData] = useState<VariantConfiguration | null>(null);
+    const [open, setOpen] = useState(false);
+
+    const createOptionGroupMutation = useMutation({
+        mutationFn: api.mutate(createProductOptionsMutation),
+    });
+
+    const addOptionGroupToProductMutation = useMutation({
+        mutationFn: api.mutate(addOptionGroupToProductDocument),
+    });
+
+    const createProductVariantsMutation = useMutation({
+        mutationFn: api.mutate(createProductVariantsDocument),
+    });
+
+    async function handleCreateVariants() {
+        if (!variantData || !activeChannel?.defaultLanguageCode) return;
+
+        try {
+            // 1. Create option groups and their options
+            const createdOptionGroups = await Promise.all(
+                variantData.optionGroups.map(async optionGroup => {
+                    const result = await createOptionGroupMutation.mutateAsync({
+                        input: {
+                            code: normalizeString(optionGroup.name, '-'),
+                            translations: [
+                                {
+                                    languageCode: activeChannel.defaultLanguageCode,
+                                    name: optionGroup.name,
+                                },
+                            ],
+                            options: optionGroup.values.map(value => ({
+                                code: normalizeString(value.value, '-'),
+                                translations: [
+                                    {
+                                        languageCode: activeChannel.defaultLanguageCode,
+                                        name: value.value,
+                                    },
+                                ],
+                            })),
+                        },
+                    });
+                    return result.createProductOptionGroup;
+                }),
+            );
+
+            // 2. Add option groups to product
+            await Promise.all(
+                createdOptionGroups.map(group =>
+                    addOptionGroupToProductMutation.mutateAsync({
+                        productId,
+                        optionGroupId: group.id,
+                    }),
+                ),
+            );
+
+            // 3. Create variants
+            const variantsToCreate = variantData.variants
+                .filter(variant => variant.enabled)
+                .map(variant => {
+                    const name = variant.options.length
+                        ? `${productName} ${variant.options.map(option => option.value).join(' ')}`
+                        : productName;
+                    return {
+                        productId,
+                        sku: variant.sku,
+                        price: Number(variant.price),
+                        stockOnHand: Number(variant.stock),
+                        optionIds: variant.options.map(option => {
+                            const optionGroup = createdOptionGroups.find(g => g.name === option.name);
+                            if (!optionGroup) {
+                                throw new Error(`Could not find option group ${option.name}`);
+                            }
+                            const createdOption = optionGroup.options.find(o => o.name === option.value);
+                            if (!createdOption) {
+                                throw new Error(
+                                    `Could not find option ${option.value} in group ${option.name}`,
+                                );
+                            }
+                            return createdOption.id;
+                        }),
+                        translations: [
+                            {
+                                languageCode: activeChannel.defaultLanguageCode,
+                                name: name,
+                            },
+                        ],
+                    };
+                });
+
+            await createProductVariantsMutation.mutateAsync({ input: variantsToCreate });
+            setOpen(false);
+            onSuccess?.();
+        } catch (error) {
+            console.error('Error creating variants:', error);
+            // Handle error (show toast notification, etc.)
+        }
+    }
+
+    const handleOnChange = useCallback(
+        ({ data }: { data: VariantConfiguration }) => setVariantData(data),
+        [],
+    );
+
+    return (
+        <>
+            <Dialog open={open} onOpenChange={setOpen}>
+                <DialogTrigger asChild>
+                    <Button type="button">
+                        <Plus className="mr-2 h-4 w-4" /> Create Variants
+                    </Button>
+                </DialogTrigger>
+
+                <DialogContent>
+                    <DialogHeader>
+                        <DialogTitle>
+                            <Trans>Create Variants</Trans>
+                        </DialogTitle>
+                        <DialogDescription>
+                            <Trans>Create variants for your product</Trans>
+                        </DialogDescription>
+                    </DialogHeader>
+                    <div className="mt-4">
+                        <CreateProductVariants
+                            onChange={handleOnChange}
+                            currencyCode={activeChannel?.defaultCurrencyCode}
+                        />
+                    </div>
+                    <DialogFooter>
+                        <Button
+                            type="button"
+                            onClick={handleCreateVariants}
+                            disabled={
+                                !variantData ||
+                                createOptionGroupMutation.isPending ||
+                                addOptionGroupToProductMutation.isPending ||
+                                createProductVariantsMutation.isPending
+                            }
+                        >
+                            {createOptionGroupMutation.isPending ||
+                            addOptionGroupToProductMutation.isPending ||
+                            createProductVariantsMutation.isPending ? (
+                                <Trans>Creating...</Trans>
+                            ) : (
+                                <Trans>
+                                    Create{' '}
+                                    {variantData
+                                        ? Object.values(variantData.variants).filter(v => v.enabled).length
+                                        : 0}{' '}
+                                    variants
+                                </Trans>
+                            )}
+                        </Button>
+                    </DialogFooter>
+                </DialogContent>
+            </Dialog>
+        </>
+    );
+}

+ 462 - 0
packages/dashboard/src/routes/_authenticated/_products/components/create-product-variants.tsx

@@ -0,0 +1,462 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Trans } from '@lingui/react/macro';
+import { Plus, Trash2 } from 'lucide-react';
+import { useEffect, useMemo } from 'react';
+import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { useQuery } from '@tanstack/react-query';
+import { Alert, AlertDescription } from '@/components/ui/alert.js';
+import { Button } from '@/components/ui/button.js';
+import { Card } from '@/components/ui/card.js';
+import { Checkbox } from '@/components/ui/checkbox.js';
+import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
+import { graphql } from '@/graphql/graphql.js';
+import { api } from '@/graphql/api.js';
+import { OptionValueInput } from './option-value-input.js';
+
+const getStockLocationsDocument = graphql(`
+    query GetStockLocations($options: StockLocationListOptions) {
+        stockLocations(options: $options) {
+            items {
+                id
+                name
+            }
+            totalItems
+        }
+    }
+`);
+
+// 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;
+    id: string;
+};
+
+type GeneratedVariant = {
+    id: string;
+    name: string;
+    values: string[];
+    options: VariantOption[];
+    enabled: boolean;
+    sku: string;
+    price: string;
+    stock: string;
+};
+
+export interface VariantConfiguration {
+    optionGroups: Array<{
+        name: string;
+        values: Array<{
+            value: string;
+            id: string;
+        }>;
+    }>;
+    variants: Array<{
+        enabled: boolean;
+        sku: string;
+        price: string;
+        stock: string;
+        options: VariantOption[];
+    }>;
+}
+
+const variantSchema = z.object({
+    enabled: z.boolean().default(true),
+    sku: z.string().min(1, { message: 'SKU is required' }),
+    price: z.string().refine(val => !isNaN(Number(val)) && Number(val) >= 0, {
+        message: 'Price must be a positive number',
+    }),
+    stock: z.string().refine(val => !isNaN(Number(val)) && parseInt(val, 10) >= 0, {
+        message: 'Stock must be a non-negative integer',
+    }),
+});
+
+const formSchema = z.object({
+    optionGroups: z.array(optionGroupSchema),
+    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;
+    onChange?: ({ data }: { data: VariantConfiguration }) => void;
+}
+
+export function CreateProductVariants({ currencyCode = 'USD', onChange }: CreateProductVariantsProps) {
+    const { data: stockLocationsResult } = useQuery({
+        queryKey: ['stockLocations'],
+        queryFn: () => api.query(getStockLocationsDocument, { options: { take: 100 } }),
+    });
+    const stockLocations = stockLocationsResult?.stockLocations.items ?? [];
+
+    const form = useForm<FormValues>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            optionGroups: [],
+            variants: {},
+        },
+        mode: 'onChange',
+    });
+
+    const { control, watch, setValue } = form;
+    const {
+        fields: optionGroups,
+        append: appendOptionGroup,
+        remove: removeOptionGroup,
+    } = useFieldArray({
+        control,
+        name: 'optionGroups',
+    });
+
+    const watchedOptionGroups = watch('optionGroups');
+    // memoize the variants
+    const variants = useMemo(() => generateVariants(watchedOptionGroups), [JSON.stringify(watchedOptionGroups)]);
+
+    // 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 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 });
+            }
+        });
+
+        return () => subscription.unsubscribe();
+    }, [form, onChange, variants]);
+
+    // Initialize variant form values when variants change
+    useEffect(() => {
+        // Initialize any new variants with default values
+        const currentVariants = form.getValues().variants || {};
+        const updatedVariants = { ...currentVariants };
+
+        variants.forEach(variant => {
+            if (!updatedVariants[variant.id]) {
+                updatedVariants[variant.id] = {
+                    enabled: true,
+                    sku: '',
+                    price: '',
+                    stock: '',
+                };
+            }
+        });
+
+        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>
+
+            {stockLocations.length === 0 ? (
+                <Alert variant="destructive">
+                    <AlertDescription>
+                        <Trans>No stock locations available on current channel</Trans>
+                    </AlertDescription>
+                </Alert>
+            ) : (
+                <>
+                    {stockLocations.length > 1 && (
+                        <div className="mb-4">
+                            <FormLabel>
+                                <Trans>Add Stock to Location</Trans>
+                            </FormLabel>
+                            <select className="w-full rounded-md border border-input bg-background px-3 py-2">
+                                {stockLocations.map(location => (
+                                    <option key={location.id} value={location.id}>
+                                        {location.name}
+                                    </option>
+                                ))}
+                            </select>
+                        </div>
+                    )}
+
+                    {variants.length > 0 && (
+                        <Table>
+                            <TableHeader>
+                                <TableRow>
+                                    {variants.length > 1 && (
+                                        <TableHead>
+                                            <Trans>Create</Trans>
+                                        </TableHead>
+                                    )}
+                                    {variants.length > 1 && (
+                                        <TableHead>
+                                            <Trans>Variant</Trans>
+                                        </TableHead>
+                                    )}
+                                    <TableHead>
+                                        <Trans>SKU</Trans>
+                                    </TableHead>
+                                    <TableHead>
+                                        <Trans>Price</Trans>
+                                    </TableHead>
+                                    <TableHead>
+                                        <Trans>Stock on Hand</Trans>
+                                    </TableHead>
+                                </TableRow>
+                            </TableHeader>
+                            <TableBody>
+                                {variants.map(variant => (
+                                    <TableRow key={variant.id}>
+                                        {variants.length > 1 && (
+                                            <TableCell>
+                                                <FormField
+                                                    control={form.control}
+                                                    name={`variants.${variant.id}.enabled`}
+                                                    render={({ field }) => (
+                                                        <FormItem className="flex items-center space-x-2">
+                                                            <FormControl>
+                                                                <Checkbox
+                                                                    checked={field.value}
+                                                                    onCheckedChange={field.onChange}
+                                                                />
+                                                            </FormControl>
+                                                        </FormItem>
+                                                    )}
+                                                />
+                                            </TableCell>
+                                        )}
+
+                                        {variants.length > 1 && (
+                                            <TableCell>{variant.values.join(' ')}</TableCell>
+                                        )}
+
+                                        <TableCell>
+                                            <FormField
+                                                control={form.control}
+                                                name={`variants.${variant.id}.sku`}
+                                                render={({ field }) => (
+                                                    <FormItem>
+                                                        <FormControl>
+                                                            <Input {...field} placeholder="SKU" />
+                                                        </FormControl>
+                                                        <FormMessage />
+                                                    </FormItem>
+                                                )}
+                                            />
+                                        </TableCell>
+
+                                        <TableCell>
+                                            <FormField
+                                                control={form.control}
+                                                name={`variants.${variant.id}.price`}
+                                                render={({ field }) => (
+                                                    <FormItem>
+                                                        <FormControl>
+                                                            <div className="relative">
+                                                                <span className="absolute left-3 top-2.5">
+                                                                    {currencyCode}
+                                                                </span>
+                                                                <Input
+                                                                    {...field}
+                                                                    className="pl-12"
+                                                                    placeholder="0.00"
+                                                                />
+                                                            </div>
+                                                        </FormControl>
+                                                        <FormMessage />
+                                                    </FormItem>
+                                                )}
+                                            />
+                                        </TableCell>
+
+                                        <TableCell>
+                                            <FormField
+                                                control={form.control}
+                                                name={`variants.${variant.id}.stock`}
+                                                render={({ field }) => (
+                                                    <FormItem>
+                                                        <FormControl>
+                                                            <Input
+                                                                {...field}
+                                                                type="number"
+                                                                min="0"
+                                                                step="1"
+                                                            />
+                                                        </FormControl>
+                                                        <FormMessage />
+                                                    </FormItem>
+                                                )}
+                                            />
+                                        </TableCell>
+                                    </TableRow>
+                                ))}
+                            </TableBody>
+                        </Table>
+                    )}
+                </>
+            )}
+        </FormProvider>
+    );
+}
+
+// Generate all possible combinations of option values
+function generateVariants(groups: OptionGroupForm[]): GeneratedVariant[] {
+    // If there are no groups, return a single variant with no options
+    if (!groups.length)
+        return [
+            {
+                id: 'default',
+                name: '',
+                values: [],
+                options: [],
+                enabled: true,
+                sku: '',
+                price: '',
+                stock: '',
+            },
+        ];
+
+    // Make sure all groups have at least one value
+    const validGroups = groups.filter(group => group.name && group.values && group.values.length > 0);
+    if (!validGroups.length) return [];
+
+    // Generate combinations
+    const generateCombinations = (
+        optionGroups: OptionGroupForm[],
+        currentIndex: number,
+        currentCombination: VariantOption[],
+    ): GeneratedVariant[] => {
+        if (currentIndex === optionGroups.length) {
+            return [
+                {
+                    id: currentCombination.map(c => c.id).join('-'),
+                    name: currentCombination.map(c => c.value).join(' '),
+                    values: currentCombination.map(c => c.value),
+                    options: currentCombination,
+                    enabled: true,
+                    sku: '',
+                    price: '',
+                    stock: '',
+                },
+            ];
+        }
+
+        const currentGroup = optionGroups[currentIndex];
+        const results: GeneratedVariant[] = [];
+
+        currentGroup.values.forEach(optionValue => {
+            const newCombination = [
+                ...currentCombination,
+                { name: currentGroup.name, value: optionValue.value, id: optionValue.id },
+            ];
+
+            const subResults = generateCombinations(optionGroups, currentIndex + 1, newCombination);
+            results.push(...subResults);
+        });
+
+        return results;
+    };
+
+    return generateCombinations(validGroups, 0, []);
+}

+ 79 - 0
packages/dashboard/src/routes/_authenticated/_products/components/option-value-input.tsx

@@ -0,0 +1,79 @@
+import { useFieldArray } from "react-hook-form";
+import { useFormContext } from "react-hook-form";
+import { useState } from "react";
+import { Input } from "@/components/ui/input.js";
+import { Button } from "@/components/ui/button.js";
+import { Badge } from "@/components/ui/badge.js";
+import { Plus, X } from "lucide-react";
+
+
+interface OptionValueInputProps {
+    groupName: string;
+    groupIndex: number;
+    disabled?: boolean;
+}
+
+export function OptionValueInput({ groupName, groupIndex, disabled = false }: OptionValueInputProps) {
+    const { control, watch } = useFormContext();
+    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: crypto.randomUUID() });
+            setNewValue('');
+        }
+    };
+
+    const handleKeyPress = (e: React.KeyboardEvent) => {
+        if (e.key === 'Enter') {
+            e.preventDefault();
+            handleAddValue();
+        }
+    };
+
+    return (
+        <div className="space-y-2">
+            <div className="flex items-center gap-2">
+                <Input
+                    value={newValue}
+                    onChange={e => setNewValue(e.target.value)}
+                    onKeyDown={handleKeyPress}
+                    placeholder="Enter value and press Enter"
+                    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">
+                {fields.map((field, index) => (
+                    <Badge key={field.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
+                        {field.value}
+                        <Button
+                            type="button"
+                            variant="ghost"
+                            size="sm"
+                            className="h-4 w-4 p-0 ml-1"
+                            onClick={() => remove(index)}
+                        >
+                            <X className="h-3 w-3" />
+                        </Button>
+                    </Badge>
+                ))}
+            </div>
+        </div>
+    );
+}

+ 4 - 0
packages/dashboard/src/routes/_authenticated/_products/components/product-variants-table.tsx

@@ -22,6 +22,10 @@ export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
             ...variables,
             productId,
         })}
+        defaultVisibility={{
+            id: false,
+            currencyCode: false,
+        }}
         customizeColumns={{
             currencyCode: {
                 cell: ({ cell, row }) => {

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

@@ -80,6 +80,9 @@ export const productDetailDocument = graphql(
         query ProductDetail($id: ID!) {
             product(id: $id) {
                 ...ProductDetail
+                variantList {
+                    totalItems
+                }
             }
         }
     `,

+ 21 - 6
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -31,13 +31,17 @@ import { createProductDocument, productDetailDocument, updateProductDocument } f
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import { notFound } from '@tanstack/react-router';
 import { ErrorPage } from '@/components/shared/error-page.js';
+import { CreateProductVariants } from './components/create-product-variants.js';
+import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
     loader: async ({ context, params }) => {
         const isNew = params.id === NEW_ENTITY_PATH;
-        const result = isNew ? null : await context.queryClient.ensureQueryData(
-            getDetailQueryOptions(productDetailDocument, { id: params.id }),
-        );
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(productDetailDocument, { id: params.id }),
+              );
         if (!isNew && !result.product) {
             throw new Error(`Product with the ID ${params.id} was not found`);
         }
@@ -57,7 +61,7 @@ export function ProductDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
 
-    const { form, submitHandler, entity, isPending } = useDetailPage({
+    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
         queryDocument: productDetailDocument,
         entityField: 'product',
         createDocument: createProductDocument,
@@ -79,7 +83,7 @@ export function ProductDetailPage() {
             };
         },
         params: { id: params.id },
-        onSuccess: (data) => {
+        onSuccess: data => {
             toast(i18n.t('Successfully updated product'), {
                 position: 'top-right',
             });
@@ -188,11 +192,22 @@ export function ProductDetailPage() {
                                 )}
                             />
                         </PageBlock>
-                        {!creatingNewEntity && (
+                        {entity && entity.variantList.totalItems > 0 && (
                             <PageBlock column="main">
                                 <ProductVariantsTable productId={params.id} />
                             </PageBlock>
                         )}
+                        {entity && entity.variantList.totalItems === 0 && (
+                            <PageBlock column="main">
+                                <CreateProductVariantsDialog
+                                    productId={entity.id}
+                                    productName={entity.name}
+                                    onSuccess={() => {
+                                        refreshEntity();
+                                    }}
+                                />
+                            </PageBlock>
+                        )}
                         <PageBlock column="side">
                             <FormField
                                 control={form.control}

+ 2 - 2
packages/dashboard/vite/ui-config.ts

@@ -2,8 +2,8 @@ import {
     DEFAULT_AUTH_TOKEN_HEADER_KEY,
     DEFAULT_CHANNEL_TOKEN_KEY,
     ADMIN_API_PATH,
-} from '@vendure/common/lib/shared-constants.js';
-import { AdminUiConfig } from '@vendure/common/lib/shared-types.js';
+} from '@vendure/common/lib/shared-constants';
+import { AdminUiConfig } from '@vendure/common/lib/shared-types';
 import { VendureConfig } from '@vendure/core';
 
 import { defaultAvailableLocales } from './constants.js';