Browse Source

feat(dashboard): Implement testing of shipping methods (#3833)

Michael Bromley 3 months ago
parent
commit
a0df5bed09
17 changed files with 1190 additions and 42 deletions
  1. 9 4
      packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-detail-loaders.tsx
  2. 15 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/metadata-badges.tsx
  3. 21 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/price-display.tsx
  4. 87 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-test-result-wrapper.tsx
  5. 255 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-address-form.tsx
  6. 243 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-order-builder.tsx
  7. 0 32
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-method-dialog.tsx
  8. 97 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-result.tsx
  9. 41 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-sheet.tsx
  10. 74 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods.tsx
  11. 90 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-method-result.tsx
  12. 56 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method-sheet.tsx
  13. 82 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method.tsx
  14. 67 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/use-shipping-method-test-state.ts
  15. 27 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts
  16. 2 2
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx
  17. 24 4
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx

+ 9 - 4
packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-detail-loaders.tsx

@@ -6,7 +6,7 @@ import { redirect } from '@tanstack/react-router';
 import { OrderDetail } from '../components/order-detail-shared.js';
 import { orderDetailDocument } from '../orders.graphql.js';
 
-export async function commonRegularOrderLoader(context: any, params: { id: string }): Promise<OrderDetail> {
+async function ensureOrderWithIdExists(context: any, params: { id: string }): Promise<OrderDetail> {
     if (!params.id) {
         throw new Error('ID param is required');
     }
@@ -18,13 +18,18 @@ export async function commonRegularOrderLoader(context: any, params: { id: strin
     if (!result.order) {
         throw new Error(`Order with the ID ${params.id} was not found`);
     }
+    return result.order;
+}
 
-    if (result.order.state === 'Draft') {
+export async function commonRegularOrderLoader(context: any, params: { id: string }): Promise<OrderDetail> {
+    const order = await ensureOrderWithIdExists(context, params);
+
+    if (order.state === 'Draft') {
         throw redirect({
             to: `/orders/draft/${params.id}`,
         });
     }
-    return result.order;
+    return order;
 }
 
 export async function loadRegularOrder(context: any, params: { id: string }) {
@@ -42,7 +47,7 @@ export async function loadRegularOrder(context: any, params: { id: string }) {
 }
 
 export async function loadDraftOrder(context: any, params: { id: string }) {
-    const order = await commonRegularOrderLoader(context, params);
+    const order = await ensureOrderWithIdExists(context, params);
 
     if (order.state !== 'Draft') {
         throw redirect({

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/metadata-badges.tsx

@@ -0,0 +1,15 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import React from 'react';
+
+export function MetadataBadges({ metadata }: Readonly<{ metadata?: Record<string, any> }>) {
+    if (!metadata || Object.keys(metadata).length === 0) return null;
+    return (
+        <div className="mt-2 flex flex-wrap gap-1">
+            {Object.entries(metadata).map(([key, value]) => (
+                <Badge key={key} variant="outline" className="text-xs">
+                    {key}: {String(value)}
+                </Badge>
+            ))}
+        </div>
+    );
+}

+ 21 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/price-display.tsx

@@ -0,0 +1,21 @@
+import { Money } from '@/vdb/components/data-display/money.js';
+import { Trans } from '@/vdb/lib/trans.js';
+
+export function PriceDisplay({
+    price,
+    priceWithTax,
+    currencyCode,
+}: Readonly<{
+    price: number;
+    priceWithTax: number;
+    currencyCode: string;
+}>) {
+    return (
+        <div className="text-right">
+            <Money value={priceWithTax} currency={currencyCode} />
+            <div className="text-xs text-muted-foreground">
+                <Trans>ex. tax:</Trans> <Money value={price} currency={currencyCode} />
+            </div>
+        </div>
+    );
+}

+ 87 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-test-result-wrapper.tsx

@@ -0,0 +1,87 @@
+import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Card, CardContent, CardHeader, CardTitle } from '@/vdb/components/ui/card.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { PlayIcon } from 'lucide-react';
+import React from 'react';
+
+interface ShippingMethodTestResultWrapperProps {
+    okToRun: boolean;
+    testDataUpdated: boolean;
+    hasTestedOnce: boolean;
+    onRunTest: () => void;
+    loading?: boolean;
+    children: React.ReactNode;
+    emptyState?: React.ReactNode;
+    showEmptyState?: boolean;
+    runTestLabel?: React.ReactNode;
+    loadingLabel?: React.ReactNode;
+}
+
+export function ShippingMethodTestResultWrapper({
+    okToRun,
+    testDataUpdated,
+    hasTestedOnce,
+    onRunTest,
+    loading = false,
+    children,
+    emptyState,
+    showEmptyState = false,
+    runTestLabel = <Trans>Run Test</Trans>,
+    loadingLabel = <Trans>Testing shipping method...</Trans>,
+}: Readonly<ShippingMethodTestResultWrapperProps>) {
+    const canRunTest = okToRun && testDataUpdated;
+    return (
+        <Card>
+            <CardHeader>
+                <CardTitle className="flex items-center justify-between">
+                    <span>
+                        <Trans>Test Results</Trans>
+                    </span>
+                    {okToRun && (
+                        <Button
+                            onClick={onRunTest}
+                            disabled={!canRunTest || loading}
+                            size="sm"
+                            className="ml-auto"
+                        >
+                            <PlayIcon className="mr-1 h-4 w-4" />
+                            {runTestLabel}
+                        </Button>
+                    )}
+                </CardTitle>
+            </CardHeader>
+            <CardContent>
+                {!okToRun && (
+                    <Alert>
+                        <AlertDescription>
+                            <Trans>
+                                Please add products and complete the shipping address to run the test.
+                            </Trans>
+                        </AlertDescription>
+                    </Alert>
+                )}
+
+                {okToRun && testDataUpdated && hasTestedOnce && (
+                    <Alert variant="destructive">
+                        <AlertDescription>
+                            <Trans>
+                                Test data has been updated. Click "Run Test" to see updated results.
+                            </Trans>
+                        </AlertDescription>
+                    </Alert>
+                )}
+
+                {loading && (
+                    <div className="text-center py-8">
+                        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+                        <p className="mt-2 text-sm text-muted-foreground">{loadingLabel}</p>
+                    </div>
+                )}
+
+                {!loading && showEmptyState && emptyState}
+                {!loading && !showEmptyState && children}
+            </CardContent>
+        </Card>
+    );
+}

+ 255 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-address-form.tsx

@@ -0,0 +1,255 @@
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { AccordionContent, AccordionItem, AccordionTrigger } from '@/vdb/components/ui/accordion.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 { api } from '@/vdb/graphql/api.js';
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { useQuery } from '@tanstack/react-query';
+import { useEffect, useRef } from 'react';
+import { useForm } from 'react-hook-form';
+
+// Query document to fetch available countries
+const getAvailableCountriesDocument = graphql(`
+    query GetAvailableCountries {
+        countries(options: { filter: { enabled: { eq: true } } }) {
+            items {
+                id
+                code
+                name
+            }
+        }
+    }
+`);
+
+export interface TestAddress {
+    fullName: string;
+    company?: string;
+    streetLine1: string;
+    streetLine2?: string;
+    city: string;
+    province: string;
+    postalCode: string;
+    countryCode: string;
+    phoneNumber?: string;
+}
+
+interface TestAddressFormProps {
+    onAddressChange: (address: TestAddress) => void;
+}
+
+export function TestAddressForm({ onAddressChange }: Readonly<TestAddressFormProps>) {
+    const form = useForm<TestAddress>({
+        defaultValues: (() => {
+            try {
+                const stored = localStorage.getItem('shippingTestAddress');
+                return stored
+                    ? JSON.parse(stored)
+                    : {
+                          fullName: '',
+                          company: '',
+                          streetLine1: '',
+                          streetLine2: '',
+                          city: '',
+                          province: '',
+                          postalCode: '',
+                          countryCode: '',
+                          phoneNumber: '',
+                      };
+            } catch {
+                return {
+                    fullName: '',
+                    company: '',
+                    streetLine1: '',
+                    streetLine2: '',
+                    city: '',
+                    province: '',
+                    postalCode: '',
+                    countryCode: '',
+                    phoneNumber: '',
+                };
+            }
+        })(),
+    });
+
+    // Fetch available countries
+    const { data: countriesData, isLoading: isLoadingCountries } = useQuery({
+        queryKey: ['availableCountries'],
+        queryFn: () => api.query(getAvailableCountriesDocument),
+        staleTime: 1000 * 60 * 60 * 24, // 24 hours
+    });
+
+    const previousValuesRef = useRef<string>('');
+
+    // Use form subscription instead of watch() to avoid infinite loops
+    useEffect(() => {
+        const subscription = form.watch(value => {
+            const currentValueString = JSON.stringify(value);
+
+            // Only update if values actually changed
+            if (currentValueString !== previousValuesRef.current) {
+                previousValuesRef.current = currentValueString;
+
+                try {
+                    localStorage.setItem('shippingTestAddress', currentValueString);
+                } catch {
+                    // Ignore localStorage errors
+                }
+
+                if (value) {
+                    onAddressChange(value as TestAddress);
+                }
+            }
+        });
+
+        return () => subscription.unsubscribe();
+    }, [form, onAddressChange]);
+
+    useEffect(() => {
+        const initialAddress = form.getValues();
+        onAddressChange(initialAddress);
+    }, []);
+
+    const currentValues = form.getValues();
+
+    const getAddressSummary = () => {
+        const parts = [
+            currentValues.fullName,
+            currentValues.streetLine1,
+            currentValues.city,
+            currentValues.province,
+            currentValues.postalCode,
+            currentValues.countryCode,
+        ].filter(Boolean);
+        return parts.length > 0 ? parts.join(', ') : '';
+    };
+
+    const isComplete = !!(
+        currentValues.fullName &&
+        currentValues.streetLine1 &&
+        currentValues.city &&
+        currentValues.province &&
+        currentValues.postalCode &&
+        currentValues.countryCode
+    );
+
+    return (
+        <AccordionItem value="shipping-address">
+            <AccordionTrigger>
+                <div className="flex items-center justify-between w-full pr-2">
+                    <span>
+                        <Trans>Shipping Address</Trans>
+                    </span>
+                    {isComplete && (
+                        <span className="text-sm text-muted-foreground truncate max-w-md">
+                            {getAddressSummary()}
+                        </span>
+                    )}
+                </div>
+            </AccordionTrigger>
+            <AccordionContent className="px-2">
+                <Form {...form}>
+                    <div className="space-y-4">
+                        <div className="grid grid-cols-2 gap-4">
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="fullName"
+                                label={<Trans>Full Name</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="John Smith" />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="company"
+                                label={<Trans>Company</Trans>}
+                                render={({ field }) => (
+                                    <Input {...field} value={field.value || ''} placeholder="Company name" />
+                                )}
+                            />
+                        </div>
+
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="streetLine1"
+                            label={<Trans>Street Address</Trans>}
+                            render={({ field }) => <Input {...field} placeholder="123 Main Street" />}
+                        />
+
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="streetLine2"
+                            label={<Trans>Street Address 2</Trans>}
+                            render={({ field }) => (
+                                <Input
+                                    {...field}
+                                    value={field.value || ''}
+                                    placeholder="Apartment, suite, etc."
+                                />
+                            )}
+                        />
+
+                        <div className="grid grid-cols-3 gap-4">
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="city"
+                                label={<Trans>City</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="New York" />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="province"
+                                label={<Trans>State / Province</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="NY" />}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="postalCode"
+                                label={<Trans>Postal Code</Trans>}
+                                render={({ field }) => <Input {...field} placeholder="10001" />}
+                            />
+                        </div>
+
+                        <div className="grid grid-cols-2 gap-4">
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="countryCode"
+                                label={<Trans>Country</Trans>}
+                                renderFormControl={false}
+                                render={({ field }) => (
+                                    <Select
+                                        onValueChange={field.onChange}
+                                        value={field.value}
+                                        disabled={isLoadingCountries}
+                                    >
+                                        <SelectTrigger>
+                                            <SelectValue placeholder="Select a country" />
+                                        </SelectTrigger>
+                                        <SelectContent>
+                                            {countriesData?.countries.items.map(country => (
+                                                <SelectItem key={country.code} value={country.code}>
+                                                    {country.name}
+                                                </SelectItem>
+                                            ))}
+                                        </SelectContent>
+                                    </Select>
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="phoneNumber"
+                                label={<Trans>Phone Number</Trans>}
+                                render={({ field }) => (
+                                    <Input
+                                        {...field}
+                                        value={field.value || ''}
+                                        placeholder="+1 (555) 123-4567"
+                                    />
+                                )}
+                            />
+                        </div>
+                    </div>
+                </Form>
+            </AccordionContent>
+        </AccordionItem>
+    );
+}

+ 243 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-order-builder.tsx

@@ -0,0 +1,243 @@
+import {
+    ProductVariantSelector,
+    ProductVariantSelectorProps,
+} from '@/vdb/components/shared/product-variant-selector.js';
+import { AssetLike, VendureImage } from '@/vdb/components/shared/vendure-image.js';
+import { AccordionContent, AccordionItem, AccordionTrigger } from '@/vdb/components/ui/accordion.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import {
+    ColumnDef,
+    flexRender,
+    getCoreRowModel,
+    useReactTable,
+    VisibilityState,
+} from '@tanstack/react-table';
+import { Trash2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+export interface TestOrderLine {
+    id: string;
+    name: string;
+    featuredAsset?: AssetLike;
+    sku: string;
+    unitPriceWithTax: number;
+    quantity: number;
+}
+
+interface TestOrderBuilderProps {
+    onOrderLinesChange: (lines: TestOrderLine[]) => void;
+}
+
+export function TestOrderBuilder({ onOrderLinesChange }: Readonly<TestOrderBuilderProps>) {
+    const { formatCurrency } = useLocalFormat();
+    const { activeChannel } = useChannel();
+    const [lines, setLines] = useState<TestOrderLine[]>(() => {
+        try {
+            const stored = localStorage.getItem('shippingTestOrder');
+            return stored ? JSON.parse(stored) : [];
+        } catch {
+            return [];
+        }
+    });
+    const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
+
+    const currencyCode = activeChannel?.defaultCurrencyCode ?? 'USD';
+    const subTotal = lines.reduce((sum, l) => sum + l.unitPriceWithTax * l.quantity, 0);
+
+    useEffect(() => {
+        try {
+            localStorage.setItem('shippingTestOrder', JSON.stringify(lines));
+        } catch {
+            // Ignore localStorage errors
+        }
+        onOrderLinesChange(lines);
+    }, [lines, onOrderLinesChange]);
+
+    const addProduct = (product: Parameters<ProductVariantSelectorProps['onProductVariantSelect']>[0]) => {
+        if (!lines.find(l => l.id === product.productVariantId)) {
+            const newLine: TestOrderLine = {
+                id: product.productVariantId,
+                name: product.productVariantName,
+                featuredAsset: product.productAsset ?? undefined,
+                quantity: 1,
+                sku: product.sku,
+                unitPriceWithTax: product.priceWithTax || 0,
+            };
+            setLines(prev => [...prev, newLine]);
+        }
+    };
+
+    const updateQuantity = (lineId: string, newQuantity: number) => {
+        if (newQuantity <= 0) {
+            removeLine(lineId);
+            return;
+        }
+        setLines(prev => prev.map(line => (line.id === lineId ? { ...line, quantity: newQuantity } : line)));
+    };
+
+    const removeLine = (lineId: string) => {
+        setLines(prev => prev.filter(line => line.id !== lineId));
+    };
+
+    const columns: ColumnDef<TestOrderLine>[] = [
+        {
+            header: 'Image',
+            accessorKey: 'preview',
+            cell: ({ row }) => {
+                const asset = row.original.featuredAsset ?? null;
+                return <VendureImage asset={asset} preset="tiny" />;
+            },
+        },
+        {
+            header: 'Product',
+            accessorKey: 'name',
+        },
+        {
+            header: 'SKU',
+            accessorKey: 'sku',
+        },
+        {
+            header: 'Unit price',
+            accessorKey: 'unitPriceWithTax',
+            cell: ({ row }) => {
+                return formatCurrency(row.original.unitPriceWithTax, currencyCode);
+            },
+        },
+        {
+            header: 'Quantity',
+            accessorKey: 'quantity',
+            cell: ({ row }) => {
+                return (
+                    <div className="flex gap-2 items-center">
+                        <Input
+                            type="number"
+                            min="1"
+                            value={row.original.quantity}
+                            onChange={e => updateQuantity(row.original.id, parseInt(e.target.value) || 1)}
+                            className="w-16"
+                        />
+                        <Button
+                            variant="outline"
+                            type="button"
+                            size="icon"
+                            onClick={() => removeLine(row.original.id)}
+                            className="h-8 w-8"
+                        >
+                            <Trash2 className="h-4 w-4" />
+                        </Button>
+                    </div>
+                );
+            },
+        },
+        {
+            header: 'Total',
+            accessorKey: 'total',
+            cell: ({ row }) => {
+                const total = row.original.unitPriceWithTax * row.original.quantity;
+                return formatCurrency(total, currencyCode);
+            },
+        },
+    ];
+
+    const table = useReactTable({
+        data: lines,
+        columns,
+        getCoreRowModel: getCoreRowModel(),
+        rowCount: lines.length,
+        onColumnVisibilityChange: setColumnVisibility,
+        state: {
+            columnVisibility,
+        },
+    });
+
+    return (
+        <AccordionItem value="test-order">
+            <AccordionTrigger>
+                <div className="flex items-center justify-between w-full pr-2">
+                    <span>
+                        <Trans>Test Order</Trans>
+                    </span>
+                    {lines.length > 0 && (
+                        <span className="text-sm text-muted-foreground">
+                            {lines.length} item{lines.length !== 1 ? 's' : ''} •{' '}
+                            {formatCurrency(subTotal, currencyCode)}
+                        </span>
+                    )}
+                </div>
+            </AccordionTrigger>
+            <AccordionContent className="space-y-4 px-2">
+                {lines.length > 0 ? (
+                    <div className="w-full">
+                        <Table>
+                            <TableHeader>
+                                {table.getHeaderGroups().map(headerGroup => (
+                                    <TableRow key={headerGroup.id}>
+                                        {headerGroup.headers.map(header => {
+                                            return (
+                                                <TableHead key={header.id}>
+                                                    {header.isPlaceholder
+                                                        ? null
+                                                        : flexRender(
+                                                              header.column.columnDef.header,
+                                                              header.getContext(),
+                                                          )}
+                                                </TableHead>
+                                            );
+                                        })}
+                                    </TableRow>
+                                ))}
+                            </TableHeader>
+                            <TableBody>
+                                {table.getRowModel().rows.map(row => (
+                                    <TableRow key={row.id}>
+                                        {row.getVisibleCells().map(cell => (
+                                            <TableCell key={cell.id}>
+                                                {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                                            </TableCell>
+                                        ))}
+                                    </TableRow>
+                                ))}
+                                <TableRow>
+                                    <TableCell colSpan={columns.length} className="h-12">
+                                        <div className="my-4 flex justify-center">
+                                            <div className="max-w-lg">
+                                                <ProductVariantSelector onProductVariantSelect={addProduct} />
+                                            </div>
+                                        </div>
+                                    </TableCell>
+                                </TableRow>
+                                <TableRow>
+                                    <TableCell
+                                        colSpan={columns.length - 1}
+                                        className="text-right font-medium"
+                                    >
+                                        <Trans>Subtotal</Trans>
+                                    </TableCell>
+                                    <TableCell className="font-medium">
+                                        {formatCurrency(subTotal, currencyCode)}
+                                    </TableCell>
+                                </TableRow>
+                            </TableBody>
+                        </Table>
+                    </div>
+                ) : (
+                    <div className="space-y-4">
+                        <div className="text-center py-8 text-muted-foreground">
+                            <Trans>Add products to create a test order</Trans>
+                        </div>
+                        <div className="flex justify-center">
+                            <div className="max-w-lg">
+                                <ProductVariantSelector onProductVariantSelect={addProduct} />
+                            </div>
+                        </div>
+                    </div>
+                )}
+            </AccordionContent>
+        </AccordionItem>
+    );
+}

+ 0 - 32
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-method-dialog.tsx

@@ -1,32 +0,0 @@
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    Dialog,
-    DialogContent,
-    DialogDescription,
-    DialogHeader,
-    DialogTitle,
-    DialogTrigger,
-} from '@/vdb/components/ui/dialog.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { TestTube } from 'lucide-react';
-
-export function TestShippingMethodDialog() {
-    return (
-        <Dialog>
-            <DialogTrigger asChild>
-                <Button variant="secondary">
-                    <TestTube />
-                    <Trans>Test shipping method</Trans>
-                </Button>
-            </DialogTrigger>
-            <DialogContent className="min-w-[800px]">
-                <DialogHeader>
-                    <DialogTitle>Test shipping method</DialogTitle>
-                    <DialogDescription>
-                        Test your shipping method by simulating a new order.
-                    </DialogDescription>
-                </DialogHeader>
-            </DialogContent>
-        </Dialog>
-    );
-}

+ 97 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-result.tsx

@@ -0,0 +1,97 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { Check } from 'lucide-react';
+import { MetadataBadges } from './metadata-badges.js';
+import { PriceDisplay } from './price-display.js';
+import { ShippingMethodTestResultWrapper } from './shipping-method-test-result-wrapper.js';
+
+export interface ShippingMethodQuote {
+    id: string;
+    name: string;
+    code: string;
+    description: string;
+    price: number;
+    priceWithTax: number;
+    metadata?: Record<string, any>;
+}
+
+interface ShippingEligibilityTestResultProps {
+    testResult?: ShippingMethodQuote[];
+    okToRun: boolean;
+    testDataUpdated: boolean;
+    hasTestedOnce: boolean;
+    onRunTest: () => void;
+    loading?: boolean;
+}
+
+export function TestShippingMethodsResult({
+    testResult,
+    okToRun,
+    testDataUpdated,
+    hasTestedOnce,
+    onRunTest,
+    loading = false,
+}: Readonly<ShippingEligibilityTestResultProps>) {
+    const { activeChannel } = useChannel();
+    const currencyCode = activeChannel?.defaultCurrencyCode ?? 'USD';
+    const hasResults = testResult && testResult.length > 0;
+    const showEmptyState = testResult && testResult.length === 0 && !loading && !testDataUpdated;
+
+    return (
+        <ShippingMethodTestResultWrapper
+            okToRun={okToRun}
+            testDataUpdated={testDataUpdated}
+            hasTestedOnce={hasTestedOnce}
+            onRunTest={onRunTest}
+            loading={loading}
+            showEmptyState={showEmptyState}
+            emptyState={
+                <div className="text-center py-8">
+                    <p className="text-muted-foreground">
+                        <Trans>No eligible shipping methods found for this order.</Trans>
+                    </p>
+                </div>
+            }
+            loadingLabel={<Trans>Testing shipping methods...</Trans>}
+        >
+            {hasResults && (
+                <div className="space-y-3">
+                    <div className="flex items-center gap-2 mb-4">
+                        <span className="text-sm font-medium">
+                            <Trans>
+                                Found {testResult.length} eligible shipping method
+                                {testResult.length !== 1 ? 's' : ''}
+                            </Trans>
+                        </span>
+                    </div>
+                    {testResult.map(method => (
+                        <div
+                            key={method.id}
+                            className="flex items-center text-sm justify-between p-3 rounded-lg bg-muted/50"
+                        >
+                            <div className="flex-1">
+                                <div className="flex gap-1">
+                                    <Check className="h-5 w-5 text-success" />
+                                    <div className="">{method.name}</div>
+                                </div>
+                                <Badge variant="secondary">{method.code}</Badge>
+                                {method.description && (
+                                    <div className="text-sm text-muted-foreground mt-1">
+                                        {method.description}
+                                    </div>
+                                )}
+                                <MetadataBadges metadata={method.metadata} />
+                            </div>
+                            <PriceDisplay
+                                price={method.price}
+                                priceWithTax={method.priceWithTax}
+                                currencyCode={currencyCode}
+                            />
+                        </div>
+                    ))}
+                </div>
+            )}
+        </ShippingMethodTestResultWrapper>
+    );
+}

+ 41 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-sheet.tsx

@@ -0,0 +1,41 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Sheet,
+    SheetContent,
+    SheetDescription,
+    SheetHeader,
+    SheetTitle,
+    SheetTrigger,
+} from '@/vdb/components/ui/sheet.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { FlaskConical } from 'lucide-react';
+import { useState } from 'react';
+import { TestShippingMethods } from './test-shipping-methods.js';
+
+export function TestShippingMethodsSheet() {
+    const [open, setOpen] = useState(false);
+
+    return (
+        <Sheet open={open} onOpenChange={setOpen}>
+            <SheetTrigger asChild>
+                <Button variant="secondary">
+                    <FlaskConical />
+                    <Trans>Test</Trans>
+                </Button>
+            </SheetTrigger>
+            <SheetContent className="w-[800px] sm:max-w-[800px]">
+                <SheetHeader>
+                    <SheetTitle>
+                        <Trans>Test shipping methods</Trans>
+                    </SheetTitle>
+                    <SheetDescription>
+                        <Trans>Test your shipping methods by simulating a new order.</Trans>
+                    </SheetDescription>
+                </SheetHeader>
+                <div className="mt-6">
+                    <TestShippingMethods />
+                </div>
+            </SheetContent>
+        </Sheet>
+    );
+}

+ 74 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods.tsx

@@ -0,0 +1,74 @@
+import { Accordion } from '@/vdb/components/ui/accordion.js';
+import { api } from '@/vdb/graphql/api.js';
+import { useQuery } from '@tanstack/react-query';
+import { testEligibleShippingMethodsDocument } from '../shipping-methods.graphql.js';
+import { TestAddressForm } from './test-address-form.js';
+import { TestOrderBuilder } from './test-order-builder.js';
+import { TestShippingMethodsResult } from './test-shipping-methods-result.js';
+import { useShippingMethodTestState } from './use-shipping-method-test-state.js';
+
+export function TestShippingMethods() {
+    const {
+        testAddress,
+        testOrderLines,
+        testDataUpdated,
+        hasTestedOnce,
+        expandedAccordions,
+        setExpandedAccordions,
+        allTestDataPresent,
+        handleAddressChange,
+        handleOrderLinesChange,
+        markTestRun,
+    } = useShippingMethodTestState();
+
+    const { data, isLoading, refetch } = useQuery({
+        queryKey: ['testEligibleShippingMethods', testAddress, testOrderLines],
+        queryFn: async () => {
+            if (!testAddress || !testOrderLines.length) {
+                return { testEligibleShippingMethods: [] };
+            }
+            return api.query(testEligibleShippingMethodsDocument, {
+                input: {
+                    shippingAddress: testAddress,
+                    lines: testOrderLines.map(l => ({
+                        productVariantId: l.id,
+                        quantity: l.quantity,
+                    })),
+                },
+            });
+        },
+        enabled: false,
+    });
+
+    const testResult = data?.testEligibleShippingMethods || [];
+
+    const runTest = () => {
+        if (allTestDataPresent) {
+            markTestRun();
+            refetch();
+        }
+    };
+
+    return (
+        <div className="space-y-6 overflow-y-auto max-h-[calc(100vh-200px)] px-4">
+            <Accordion
+                type="multiple"
+                value={expandedAccordions}
+                onValueChange={setExpandedAccordions}
+                className="w-full"
+            >
+                <TestOrderBuilder onOrderLinesChange={handleOrderLinesChange} />
+                <TestAddressForm onAddressChange={handleAddressChange} />
+            </Accordion>
+
+            <TestShippingMethodsResult
+                testResult={testResult}
+                okToRun={allTestDataPresent}
+                testDataUpdated={testDataUpdated}
+                hasTestedOnce={hasTestedOnce}
+                onRunTest={runTest}
+                loading={isLoading}
+            />
+        </div>
+    );
+}

+ 90 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-method-result.tsx

@@ -0,0 +1,90 @@
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { ResultOf } from 'gql.tada';
+import { Check } from 'lucide-react';
+import { testShippingMethodDocument } from '../shipping-methods.graphql.js';
+import { MetadataBadges } from './metadata-badges.js';
+import { PriceDisplay } from './price-display.js';
+import { ShippingMethodTestResultWrapper } from './shipping-method-test-result-wrapper.js';
+
+export type TestShippingMethodResult = ResultOf<typeof testShippingMethodDocument>['testShippingMethod'];
+
+interface TestSingleMethodResultProps {
+    testResult?: TestShippingMethodResult;
+    okToRun: boolean;
+    testDataUpdated: boolean;
+    hasTestedOnce: boolean;
+    onRunTest: () => void;
+    loading?: boolean;
+}
+
+export function TestSingleMethodResult({
+    testResult,
+    okToRun,
+    testDataUpdated,
+    hasTestedOnce,
+    onRunTest,
+    loading = false,
+}: Readonly<TestSingleMethodResultProps>) {
+    const { activeChannel } = useChannel();
+    const currencyCode = activeChannel?.defaultCurrencyCode ?? 'USD';
+    const showEmptyState = testResult === undefined && hasTestedOnce && !testDataUpdated && !loading;
+
+    return (
+        <ShippingMethodTestResultWrapper
+            okToRun={okToRun}
+            testDataUpdated={testDataUpdated}
+            hasTestedOnce={hasTestedOnce}
+            onRunTest={onRunTest}
+            loading={loading}
+            showEmptyState={showEmptyState}
+            emptyState={
+                <div className="text-center py-8 text-muted-foreground">
+                    <Trans>Click "Run Test" to test this shipping method.</Trans>
+                </div>
+            }
+            loadingLabel={<Trans>Testing shipping method...</Trans>}
+        >
+            {testResult && (
+                <div className="space-y-4">
+                    {testResult.eligible ? (
+                        <div className="space-y-3">
+                            {testResult.quote && (
+                                <div className="p-3 border rounded-lg bg-muted/50">
+                                    <div className="flex justify-between items-center">
+                                        <div className="flex items-center gap-2">
+                                            <Check className="h-5 w-5 text-success" />
+                                            <span className="text-sm">
+                                                <Trans>Shipping method is eligible for this order</Trans>
+                                            </span>
+                                        </div>
+                                        <PriceDisplay
+                                            price={testResult.quote.price}
+                                            priceWithTax={testResult.quote.priceWithTax}
+                                            currencyCode={currencyCode}
+                                        />
+                                    </div>
+                                    <div className="flex-1">
+                                        <MetadataBadges metadata={testResult.quote.metadata} />
+                                    </div>
+                                </div>
+                            )}
+                        </div>
+                    ) : (
+                        <div className="text-center py-8">
+                            <p className="text-destructive">
+                                <Trans>Shipping method is not eligible for this order</Trans>
+                            </p>
+                            <p className="text-sm text-muted-foreground mt-2">
+                                <Trans>
+                                    This shipping method's eligibility checker conditions are not met for the
+                                    current order and shipping address.
+                                </Trans>
+                            </p>
+                        </div>
+                    )}
+                </div>
+            )}
+        </ShippingMethodTestResultWrapper>
+    );
+}

+ 56 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method-sheet.tsx

@@ -0,0 +1,56 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Sheet,
+    SheetContent,
+    SheetDescription,
+    SheetHeader,
+    SheetTitle,
+    SheetTrigger,
+} from '@/vdb/components/ui/sheet.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { VariablesOf } from 'gql.tada';
+import { FlaskConical } from 'lucide-react';
+import { useState } from 'react';
+import { testShippingMethodDocument } from '../shipping-methods.graphql.js';
+import { TestSingleShippingMethod } from './test-single-shipping-method.js';
+
+interface TestSingleShippingMethodDialogProps {
+    checker?: VariablesOf<typeof testShippingMethodDocument>['input']['checker'];
+    calculator?: VariablesOf<typeof testShippingMethodDocument>['input']['calculator'];
+}
+
+export function TestSingleShippingMethodSheet({
+    checker,
+    calculator,
+}: Readonly<TestSingleShippingMethodDialogProps>) {
+    const [open, setOpen] = useState(false);
+
+    return (
+        <Sheet open={open} onOpenChange={setOpen}>
+            <SheetTrigger asChild>
+                <Button variant="secondary">
+                    <FlaskConical />
+                    <Trans>Test</Trans>
+                </Button>
+            </SheetTrigger>
+            <SheetContent className="w-[800px] sm:max-w-[800px]">
+                <SheetHeader>
+                    <SheetTitle>
+                        <Trans>Test Shipping Method</Trans>
+                    </SheetTitle>
+                    <SheetDescription>
+                        <Trans>
+                            Test this shipping method by simulating an order to see if it's eligible and what
+                            the shipping cost would be.
+                        </Trans>
+                    </SheetDescription>
+                </SheetHeader>
+                <div className="mt-6">
+                    {checker && calculator ? (
+                        <TestSingleShippingMethod checker={checker} calculator={calculator} />
+                    ) : null}
+                </div>
+            </SheetContent>
+        </Sheet>
+    );
+}

+ 82 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method.tsx

@@ -0,0 +1,82 @@
+import { Accordion } from '@/vdb/components/ui/accordion.js';
+import { api } from '@/vdb/graphql/api.js';
+import { useQuery } from '@tanstack/react-query';
+import { VariablesOf } from 'gql.tada';
+import { testShippingMethodDocument } from '../shipping-methods.graphql.js';
+import { TestAddressForm } from './test-address-form.js';
+import { TestOrderBuilder } from './test-order-builder.js';
+import { TestSingleMethodResult } from './test-single-method-result.js';
+import { useShippingMethodTestState } from './use-shipping-method-test-state.js';
+
+interface TestSingleShippingMethodProps {
+    checker: VariablesOf<typeof testShippingMethodDocument>['input']['checker'];
+    calculator: VariablesOf<typeof testShippingMethodDocument>['input']['calculator'];
+}
+
+export function TestSingleShippingMethod({ checker, calculator }: Readonly<TestSingleShippingMethodProps>) {
+    const {
+        testAddress,
+        testOrderLines,
+        testDataUpdated,
+        hasTestedOnce,
+        expandedAccordions,
+        setExpandedAccordions,
+        allTestDataPresent,
+        handleAddressChange,
+        handleOrderLinesChange,
+        markTestRun,
+    } = useShippingMethodTestState();
+
+    const { data, isLoading, refetch } = useQuery({
+        queryKey: ['testShippingMethod', testAddress, testOrderLines, checker, calculator],
+        queryFn: async () => {
+            if (!testAddress || !testOrderLines.length) {
+                return { testShippingMethod: undefined };
+            }
+            return api.query(testShippingMethodDocument, {
+                input: {
+                    shippingAddress: testAddress,
+                    lines: testOrderLines.map(l => ({
+                        productVariantId: l.id,
+                        quantity: l.quantity,
+                    })),
+                    checker,
+                    calculator,
+                },
+            });
+        },
+        enabled: false,
+    });
+
+    const testResult = data?.testShippingMethod;
+
+    const runTest = () => {
+        if (allTestDataPresent) {
+            markTestRun();
+            refetch();
+        }
+    };
+
+    return (
+        <div className="space-y-6 overflow-y-auto max-h-[calc(100vh-200px)] px-4">
+            <Accordion
+                type="multiple"
+                value={expandedAccordions}
+                onValueChange={setExpandedAccordions}
+                className="w-full"
+            >
+                <TestOrderBuilder onOrderLinesChange={handleOrderLinesChange} />
+                <TestAddressForm onAddressChange={handleAddressChange} />
+            </Accordion>
+
+            <TestSingleMethodResult
+                testResult={testResult}
+                okToRun={allTestDataPresent}
+                testDataUpdated={testDataUpdated}
+                hasTestedOnce={hasTestedOnce}
+                onRunTest={runTest}
+                loading={isLoading}
+            />
+        </div>
+    );
+}

+ 67 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/use-shipping-method-test-state.ts

@@ -0,0 +1,67 @@
+import { useCallback, useState } from 'react';
+
+import { TestAddress } from './test-address-form.js';
+import { TestOrderLine } from './test-order-builder.js';
+
+export function useShippingMethodTestState() {
+    const [testAddress, setTestAddress] = useState<TestAddress | null>(null);
+    const [testOrderLines, setTestOrderLines] = useState<TestOrderLine[]>([]);
+    const [testDataUpdated, setTestDataUpdated] = useState(true);
+    const [hasTestedOnce, setHasTestedOnce] = useState(false);
+    const [expandedAccordions, setExpandedAccordions] = useState<string[]>([
+        'test-order',
+        'shipping-address',
+    ]);
+    const [lastTestedAddress, setLastTestedAddress] = useState<TestAddress | null>(null);
+    const [lastTestedOrderLines, setLastTestedOrderLines] = useState<TestOrderLine[]>([]);
+
+    const allTestDataPresent = !!(testAddress && testOrderLines && testOrderLines.length > 0);
+
+    const handleAddressChange = useCallback(
+        (address: TestAddress) => {
+            setTestAddress(address);
+            if (hasTestedOnce && JSON.stringify(address) !== JSON.stringify(lastTestedAddress)) {
+                setTestDataUpdated(true);
+            }
+        },
+        [hasTestedOnce, lastTestedAddress],
+    );
+
+    const handleOrderLinesChange = useCallback(
+        (lines: TestOrderLine[]) => {
+            setTestOrderLines(lines);
+            if (hasTestedOnce && JSON.stringify(lines) !== JSON.stringify(lastTestedOrderLines)) {
+                setTestDataUpdated(true);
+            }
+        },
+        [hasTestedOnce, lastTestedOrderLines],
+    );
+
+    // runTest now only updates state; actual query logic is handled in the component
+    const markTestRun = () => {
+        setTestDataUpdated(false);
+        setHasTestedOnce(true);
+        setLastTestedAddress(testAddress);
+        setLastTestedOrderLines(testOrderLines);
+        setExpandedAccordions([]); // Collapse all accordions
+    };
+
+    return {
+        testAddress,
+        setTestAddress,
+        testOrderLines,
+        setTestOrderLines,
+        testDataUpdated,
+        setTestDataUpdated,
+        hasTestedOnce,
+        setHasTestedOnce,
+        expandedAccordions,
+        setExpandedAccordions,
+        lastTestedAddress,
+        lastTestedOrderLines,
+        allTestDataPresent,
+        handleAddressChange,
+        handleOrderLinesChange,
+        markTestRun,
+    };
+}

+ 27 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts

@@ -108,3 +108,30 @@ export const removeShippingMethodsFromChannelDocument = graphql(`
         }
     }
 `);
+
+export const testEligibleShippingMethodsDocument = graphql(`
+    query TestEligibleShippingMethods($input: TestEligibleShippingMethodsInput!) {
+        testEligibleShippingMethods(input: $input) {
+            id
+            name
+            code
+            description
+            price
+            priceWithTax
+            metadata
+        }
+    }
+`);
+
+export const testShippingMethodDocument = graphql(`
+    query TestShippingMethod($input: TestShippingMethodInput!) {
+        testShippingMethod(input: $input) {
+            eligible
+            quote {
+                price
+                priceWithTax
+                metadata
+            }
+        }
+    }
+`);

+ 2 - 2
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx

@@ -11,7 +11,7 @@ import {
     DeleteShippingMethodsBulkAction,
     RemoveShippingMethodsFromChannelBulkAction,
 } from './components/shipping-method-bulk-actions.js';
-import { TestShippingMethodDialog } from './components/test-shipping-method-dialog.js';
+import { TestShippingMethodsSheet } from './components/test-shipping-methods-sheet.js';
 import { shippingMethodListQuery } from './shipping-methods.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_shipping-methods/shipping-methods')({
@@ -58,6 +58,7 @@ function ShippingMethodListPage() {
             ]}
         >
             <PageActionBarRight>
+                <TestShippingMethodsSheet />
                 <PermissionGuard requires={['CreateShippingMethod']}>
                     <Button asChild>
                         <Link to="./new">
@@ -66,7 +67,6 @@ function ShippingMethodListPage() {
                         </Link>
                     </Button>
                 </PermissionGuard>
-                <TestShippingMethodDialog />
             </PageActionBarRight>
         </ListPage>
     );

+ 24 - 4
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx

@@ -24,6 +24,7 @@ import { toast } from 'sonner';
 import { FulfillmentHandlerSelector } from './components/fulfillment-handler-selector.js';
 import { ShippingCalculatorSelector } from './components/shipping-calculator-selector.js';
 import { ShippingEligibilityCheckerSelector } from './components/shipping-eligibility-checker-selector.js';
+import { TestSingleShippingMethodSheet } from './components/test-single-shipping-method-sheet.js';
 import {
     createShippingMethodDocument,
     shippingMethodDetailDocument,
@@ -84,19 +85,35 @@ function ShippingMethodDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast.success(i18n.t(creatingNewEntity ? 'Successfully created shipping method' : 'Successfully updated shipping method'));
+            toast.success(
+                i18n.t(
+                    creatingNewEntity
+                        ? 'Successfully created shipping method'
+                        : 'Successfully updated shipping method',
+                ),
+            );
             resetForm();
             if (creatingNewEntity) {
                 await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {
-            toast.error(i18n.t(creatingNewEntity ? 'Failed to create shipping method' : 'Failed to update shipping method'), {
-                description: err instanceof Error ? err.message : 'Unknown error',
-            });
+            toast.error(
+                i18n.t(
+                    creatingNewEntity
+                        ? 'Failed to create shipping method'
+                        : 'Failed to update shipping method',
+                ),
+                {
+                    description: err instanceof Error ? err.message : 'Unknown error',
+                },
+            );
         },
     });
 
+    const checker = form.watch('checker');
+    const calculator = form.watch('calculator');
+
     return (
         <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
             <PageTitle>
@@ -104,6 +121,9 @@ function ShippingMethodDetailPage() {
             </PageTitle>
             <PageActionBar>
                 <PageActionBarRight>
+                    {!creatingNewEntity && entity && (
+                        <TestSingleShippingMethodSheet checker={checker} calculator={calculator} />
+                    )}
                     <PermissionGuard requires={['UpdateShippingMethod']}>
                         <Button
                             type="submit"