@vendure/dashboard 3.4.3-master-202509240228 → 3.4.3-master-202509250229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/index.html +11 -12
  2. package/package.json +4 -4
  3. package/src/app/common/use-page-title.test.ts +263 -0
  4. package/src/app/common/use-page-title.ts +86 -0
  5. package/src/app/routes/__root.tsx +4 -4
  6. package/src/app/routes/_authenticated/_orders/utils/order-detail-loaders.tsx +9 -4
  7. package/src/app/routes/_authenticated/_shipping-methods/components/metadata-badges.tsx +15 -0
  8. package/src/app/routes/_authenticated/_shipping-methods/components/price-display.tsx +21 -0
  9. package/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-test-result-wrapper.tsx +87 -0
  10. package/src/app/routes/_authenticated/_shipping-methods/components/test-address-form.tsx +255 -0
  11. package/src/app/routes/_authenticated/_shipping-methods/components/test-order-builder.tsx +243 -0
  12. package/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-result.tsx +97 -0
  13. package/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods-sheet.tsx +41 -0
  14. package/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-methods.tsx +74 -0
  15. package/src/app/routes/_authenticated/_shipping-methods/components/test-single-method-result.tsx +90 -0
  16. package/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method-sheet.tsx +56 -0
  17. package/src/app/routes/_authenticated/_shipping-methods/components/test-single-shipping-method.tsx +82 -0
  18. package/src/app/routes/_authenticated/_shipping-methods/components/use-shipping-method-test-state.ts +67 -0
  19. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts +27 -0
  20. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx +2 -2
  21. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx +24 -4
  22. package/src/app/routes/_authenticated/_shipping-methods/components/test-shipping-method-dialog.tsx +0 -32
package/index.html CHANGED
@@ -1,15 +1,14 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/png" href="/favicon.png" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <meta name="description" content="Vendure Admin Dashboard" />
8
- <meta name="author" content="Vendure" />
9
- <title>Vendure Admin Dashboard</title>
10
- </head>
11
- <body>
12
- <div id="app"></div>
13
- <script type="module" src="/src/app/main.jsx"></script>
14
- </body>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/png" href="/favicon.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="description" content="Vendure Admin Dashboard" />
8
+ <meta name="author" content="Vendure" />
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ <script type="module" src="/src/app/main.jsx"></script>
13
+ </body>
15
14
  </html>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.4.3-master-202509240228",
4
+ "version": "3.4.3-master-202509250229",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -104,8 +104,8 @@
104
104
  "@types/react": "^19.0.10",
105
105
  "@types/react-dom": "^19.0.4",
106
106
  "@uidotdev/usehooks": "^2.4.1",
107
- "@vendure/common": "^3.4.3-master-202509240228",
108
- "@vendure/core": "^3.4.3-master-202509240228",
107
+ "@vendure/common": "^3.4.3-master-202509250229",
108
+ "@vendure/core": "^3.4.3-master-202509250229",
109
109
  "@vitejs/plugin-react": "^4.3.4",
110
110
  "acorn": "^8.11.3",
111
111
  "acorn-walk": "^8.3.2",
@@ -156,5 +156,5 @@
156
156
  "lightningcss-linux-arm64-musl": "^1.29.3",
157
157
  "lightningcss-linux-x64-musl": "^1.29.1"
158
158
  },
159
- "gitHead": "3cf135a11d028df5b1fbbb5ab6a1b117e91e13f7"
159
+ "gitHead": "7555f68cec1c4a90ca20bbc2b79798b027dc7c4f"
160
160
  }
@@ -0,0 +1,263 @@
1
+ import React from 'react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { normalizeBreadcrumb } from './use-page-title.js';
5
+
6
+ describe('normalizeBreadcrumb', () => {
7
+ describe('null and undefined handling', () => {
8
+ it('should return empty string for null', () => {
9
+ expect(normalizeBreadcrumb(null)).toBe('');
10
+ });
11
+
12
+ it('should return empty string for undefined', () => {
13
+ expect(normalizeBreadcrumb(undefined)).toBe('');
14
+ });
15
+
16
+ it('should return empty string for empty array', () => {
17
+ expect(normalizeBreadcrumb([])).toBe('');
18
+ });
19
+ });
20
+
21
+ describe('string handling', () => {
22
+ it('should return string as-is', () => {
23
+ expect(normalizeBreadcrumb('Home')).toBe('Home');
24
+ });
25
+
26
+ it('should return empty string as-is', () => {
27
+ expect(normalizeBreadcrumb('')).toBe('');
28
+ });
29
+
30
+ it('should handle strings with special characters', () => {
31
+ expect(normalizeBreadcrumb('Settings & Config')).toBe('Settings & Config');
32
+ });
33
+ });
34
+
35
+ describe('number handling', () => {
36
+ it('should convert number to string', () => {
37
+ expect(normalizeBreadcrumb(42)).toBe('42');
38
+ });
39
+
40
+ it('should handle zero', () => {
41
+ expect(normalizeBreadcrumb(0)).toBe('0');
42
+ });
43
+
44
+ it('should handle negative numbers', () => {
45
+ expect(normalizeBreadcrumb(-123)).toBe('-123');
46
+ });
47
+
48
+ it('should handle decimal numbers', () => {
49
+ expect(normalizeBreadcrumb(3.14)).toBe('3.14');
50
+ });
51
+ });
52
+
53
+ describe('function handling', () => {
54
+ it('should call function and normalize the result', () => {
55
+ const fn = () => 'Dashboard';
56
+ expect(normalizeBreadcrumb(fn)).toBe('Dashboard');
57
+ });
58
+
59
+ it('should handle nested function returns', () => {
60
+ const fn = () => () => 'Nested Function';
61
+ expect(normalizeBreadcrumb(fn)).toBe('Nested Function');
62
+ });
63
+
64
+ it('should handle function returning React element', () => {
65
+ const mockReactElement = React.createElement('div', {}, 'React Content');
66
+ const fn = () => mockReactElement;
67
+ expect(normalizeBreadcrumb(fn)).toBe('React Content');
68
+ });
69
+
70
+ it('should handle function returning array', () => {
71
+ const fn = () => ['First', 'Second', 'Third'];
72
+ expect(normalizeBreadcrumb(fn)).toBe('Third');
73
+ });
74
+
75
+ it('should handle function returning object with label', () => {
76
+ const fn = () => ({ label: 'Settings', path: '/settings' });
77
+ expect(normalizeBreadcrumb(fn)).toBe('Settings');
78
+ });
79
+ });
80
+
81
+ describe('array handling', () => {
82
+ it('should return last element of string array', () => {
83
+ expect(normalizeBreadcrumb(['Home', 'Products', 'Details'])).toBe('Details');
84
+ });
85
+
86
+ it('should handle single element array', () => {
87
+ expect(normalizeBreadcrumb(['Single'])).toBe('Single');
88
+ });
89
+
90
+ it('should handle array with React element at the end', () => {
91
+ const mockReactElement = React.createElement('span', {}, 'Last Item');
92
+ expect(normalizeBreadcrumb(['First', mockReactElement])).toBe('Last Item');
93
+ });
94
+
95
+ it('should handle array with object containing label at the end', () => {
96
+ const breadcrumbs = [
97
+ 'Home',
98
+ { label: 'Products', path: '/products' },
99
+ { label: 'Details', path: '/products/123' },
100
+ ];
101
+ expect(normalizeBreadcrumb(breadcrumbs)).toBe('Details');
102
+ });
103
+
104
+ it('should handle array with function at the end', () => {
105
+ const breadcrumbs = ['Home', () => 'Dynamic Content'];
106
+ expect(normalizeBreadcrumb(breadcrumbs)).toBe('Dynamic Content');
107
+ });
108
+
109
+ it('should handle nested arrays', () => {
110
+ const breadcrumbs = ['First', ['Nested1', 'Nested2']];
111
+ expect(normalizeBreadcrumb(breadcrumbs)).toBe('Nested2');
112
+ });
113
+ });
114
+
115
+ describe('object with label property handling', () => {
116
+ it('should extract string label from object', () => {
117
+ const breadcrumb = { label: 'Settings', path: '/settings' };
118
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Settings');
119
+ });
120
+
121
+ it('should handle React element as label', () => {
122
+ const mockReactElement = React.createElement('span', {}, 'Global Settings');
123
+ const breadcrumb = { label: mockReactElement, path: '/global-settings' };
124
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Global Settings');
125
+ });
126
+
127
+ it('should handle function as label', () => {
128
+ const breadcrumb = {
129
+ label: () => 'Dynamic Label',
130
+ path: '/dynamic',
131
+ };
132
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Dynamic Label');
133
+ });
134
+
135
+ it('should handle nested object with label', () => {
136
+ const breadcrumb = {
137
+ label: {
138
+ label: 'Nested Label',
139
+ },
140
+ path: '/nested',
141
+ };
142
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Nested Label');
143
+ });
144
+
145
+ it('should handle object with label containing array', () => {
146
+ const breadcrumb = {
147
+ label: ['First', 'Second', 'Third'],
148
+ path: '/array-label',
149
+ };
150
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Third');
151
+ });
152
+ });
153
+
154
+ describe('React element handling', () => {
155
+ it('should extract text from simple React element', () => {
156
+ const element = React.createElement('div', {}, 'Simple Text');
157
+ expect(normalizeBreadcrumb(element)).toBe('Simple Text');
158
+ });
159
+
160
+ it('should handle nested React elements', () => {
161
+ const innerElement = React.createElement('span', {}, 'Nested Text');
162
+ const element = React.createElement('div', {}, innerElement);
163
+ expect(normalizeBreadcrumb(element)).toBe('Nested Text');
164
+ });
165
+
166
+ it('should handle React element with array of children', () => {
167
+ const element = React.createElement('div', {}, ['Part 1', ' ', 'Part 2']);
168
+ expect(normalizeBreadcrumb(element)).toBe('Part 1 Part 2');
169
+ });
170
+
171
+ it('should handle React element with mixed children types', () => {
172
+ const nestedElement = React.createElement('span', {}, ' nested');
173
+ const element = React.createElement('div', {}, ['Text', 42, nestedElement]);
174
+ expect(normalizeBreadcrumb(element)).toBe('Text42 nested');
175
+ });
176
+ });
177
+
178
+ describe('complex nested scenarios', () => {
179
+ it('should handle array with object containing function returning React element', () => {
180
+ const mockReactElement = React.createElement('div', {}, 'Complex Content');
181
+ const breadcrumb = [
182
+ 'Home',
183
+ {
184
+ label: () => mockReactElement,
185
+ path: '/complex',
186
+ },
187
+ ];
188
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Complex Content');
189
+ });
190
+
191
+ it('should handle function returning array with object containing React element', () => {
192
+ const mockReactElement = React.createElement('div', {}, 'Deep Nested');
193
+ const fn = () => ['Start', { label: mockReactElement, path: '/deep' }];
194
+ expect(normalizeBreadcrumb(fn)).toBe('Deep Nested');
195
+ });
196
+
197
+ it('should handle array with multiple levels of nesting', () => {
198
+ const breadcrumb = [
199
+ 'Level1',
200
+ [
201
+ 'Level2-1',
202
+ {
203
+ label: () => ['Level3-1', 'Level3-2'],
204
+ path: '/multi-level',
205
+ },
206
+ ],
207
+ ];
208
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Level3-2');
209
+ });
210
+
211
+ it('should handle Trans-like component structure', () => {
212
+ // Simulating a <Trans>Global Settings</Trans> component
213
+ const transElement = React.createElement(
214
+ 'Trans',
215
+ {
216
+ i18nKey: 'global.settings',
217
+ },
218
+ 'Global Settings',
219
+ );
220
+ const breadcrumb = [
221
+ {
222
+ path: '/global-settings',
223
+ label: transElement,
224
+ },
225
+ ];
226
+ expect(normalizeBreadcrumb(breadcrumb)).toBe('Global Settings');
227
+ });
228
+ });
229
+
230
+ describe('edge cases', () => {
231
+ it('should handle boolean values', () => {
232
+ expect(normalizeBreadcrumb(true)).toBe('');
233
+ expect(normalizeBreadcrumb(false)).toBe('');
234
+ });
235
+
236
+ it('should handle objects without label property', () => {
237
+ const obj = { path: '/test', name: 'Test' };
238
+ expect(normalizeBreadcrumb(obj)).toBe('');
239
+ });
240
+
241
+ it('should handle symbols', () => {
242
+ const sym = Symbol('test');
243
+ expect(normalizeBreadcrumb(sym)).toBe('');
244
+ });
245
+
246
+ it('should handle circular references gracefully', () => {
247
+ const circular: any = { label: null };
248
+ circular.label = circular;
249
+ // This should not cause infinite recursion
250
+ // The function should detect the circular reference and return empty string
251
+ expect(() => normalizeBreadcrumb(circular)).not.toThrow();
252
+ expect(normalizeBreadcrumb(circular)).toBe('');
253
+ });
254
+
255
+ it('should handle very deeply nested structures', () => {
256
+ let deep: any = 'Final Value';
257
+ for (let i = 0; i < 100; i++) {
258
+ deep = { label: deep };
259
+ }
260
+ expect(normalizeBreadcrumb(deep)).toBe('Final Value');
261
+ });
262
+ });
263
+ });
@@ -0,0 +1,86 @@
1
+ import { useMatches } from '@tanstack/react-router';
2
+ import React, { isValidElement, ReactElement, useEffect, useState } from 'react';
3
+
4
+ const DEFAULT_TITLE = 'Vendure';
5
+
6
+ /**
7
+ * @description
8
+ * Derives the meta title of the page based on the current route's breadcrumb
9
+ * data from the route loader.
10
+ */
11
+ export function usePageTitle() {
12
+ const matches = useMatches();
13
+ const [pageTitle, setPageTitle] = useState<string>(DEFAULT_TITLE);
14
+
15
+ useEffect(() => {
16
+ const lastMatch = matches.at(-1);
17
+ const breadcrumb = (lastMatch?.loaderData as any)?.breadcrumb;
18
+
19
+ const breadcrumbTitle = normalizeBreadcrumb(breadcrumb);
20
+ setPageTitle([breadcrumbTitle, DEFAULT_TITLE].filter(x => !!x).join(' • '));
21
+ }, [matches]);
22
+
23
+ return pageTitle;
24
+ }
25
+
26
+ const renderNodeAsString = function (reactNode: React.ReactNode): string {
27
+ let string = '';
28
+ if (typeof reactNode === 'string') {
29
+ string = reactNode;
30
+ } else if (typeof reactNode === 'number') {
31
+ string = reactNode.toString();
32
+ } else if (Array.isArray(reactNode)) {
33
+ reactNode.forEach(function (child) {
34
+ string += renderNodeAsString(child);
35
+ });
36
+ } else if (isValidElement(reactNode)) {
37
+ string += renderNodeAsString((reactNode as ReactElement<any>).props.children);
38
+ }
39
+ return string;
40
+ };
41
+
42
+ /**
43
+ * Recursively normalizes a breadcrumb value to a string.
44
+ * Handles functions, arrays, objects with labels, and React nodes.
45
+ */
46
+ export const normalizeBreadcrumb = (value: any, visited = new WeakSet()): string => {
47
+ // Handle null/undefined
48
+ if (value == null) {
49
+ return '';
50
+ }
51
+
52
+ // If it's a function, call it and normalize the result
53
+ if (typeof value === 'function') {
54
+ return normalizeBreadcrumb(value(), visited);
55
+ }
56
+
57
+ // If it's already a string, return it
58
+ if (typeof value === 'string') {
59
+ return value;
60
+ }
61
+
62
+ // If it's an array, normalize the last element
63
+ if (Array.isArray(value)) {
64
+ if (value.length === 0) {
65
+ return '';
66
+ }
67
+ return normalizeBreadcrumb(value.at(-1), visited);
68
+ }
69
+
70
+ // For objects, check for circular references
71
+ if (typeof value === 'object') {
72
+ // Prevent circular reference infinite loops
73
+ if (visited.has(value)) {
74
+ return '';
75
+ }
76
+ visited.add(value);
77
+
78
+ // If it's an object with a label property, normalize the label
79
+ if ('label' in value) {
80
+ return normalizeBreadcrumb(value.label, visited);
81
+ }
82
+ }
83
+
84
+ // For everything else (React nodes, numbers, etc.), use renderNodeAsString
85
+ return renderNodeAsString(value);
86
+ };
@@ -1,6 +1,7 @@
1
1
  import { AuthContext } from '@/vdb/providers/auth.js';
2
2
  import { QueryClient } from '@tanstack/react-query';
3
- import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
3
+ import { createRootRouteWithContext, HeadContent, Outlet } from '@tanstack/react-router';
4
+ import { usePageTitle } from '../common/use-page-title.js';
4
5
 
5
6
  export interface MyRouterContext {
6
7
  auth: AuthContext;
@@ -9,14 +10,13 @@ export interface MyRouterContext {
9
10
 
10
11
  export const Route = createRootRouteWithContext<MyRouterContext>()({
11
12
  component: RootComponent,
12
- search: {
13
- // middlewares: [retainSearchParams(['page', 'perPage', 'sort'] as any)],
14
- },
15
13
  });
16
14
 
17
15
  function RootComponent() {
16
+ document.title = usePageTitle();
18
17
  return (
19
18
  <>
19
+ <HeadContent />
20
20
  <Outlet />
21
21
  </>
22
22
  );
@@ -6,7 +6,7 @@ import { redirect } from '@tanstack/react-router';
6
6
  import { OrderDetail } from '../components/order-detail-shared.js';
7
7
  import { orderDetailDocument } from '../orders.graphql.js';
8
8
 
9
- export async function commonRegularOrderLoader(context: any, params: { id: string }): Promise<OrderDetail> {
9
+ async function ensureOrderWithIdExists(context: any, params: { id: string }): Promise<OrderDetail> {
10
10
  if (!params.id) {
11
11
  throw new Error('ID param is required');
12
12
  }
@@ -18,13 +18,18 @@ export async function commonRegularOrderLoader(context: any, params: { id: strin
18
18
  if (!result.order) {
19
19
  throw new Error(`Order with the ID ${params.id} was not found`);
20
20
  }
21
+ return result.order;
22
+ }
21
23
 
22
- if (result.order.state === 'Draft') {
24
+ export async function commonRegularOrderLoader(context: any, params: { id: string }): Promise<OrderDetail> {
25
+ const order = await ensureOrderWithIdExists(context, params);
26
+
27
+ if (order.state === 'Draft') {
23
28
  throw redirect({
24
29
  to: `/orders/draft/${params.id}`,
25
30
  });
26
31
  }
27
- return result.order;
32
+ return order;
28
33
  }
29
34
 
30
35
  export async function loadRegularOrder(context: any, params: { id: string }) {
@@ -42,7 +47,7 @@ export async function loadRegularOrder(context: any, params: { id: string }) {
42
47
  }
43
48
 
44
49
  export async function loadDraftOrder(context: any, params: { id: string }) {
45
- const order = await commonRegularOrderLoader(context, params);
50
+ const order = await ensureOrderWithIdExists(context, params);
46
51
 
47
52
  if (order.state !== 'Draft') {
48
53
  throw redirect({
@@ -0,0 +1,15 @@
1
+ import { Badge } from '@/vdb/components/ui/badge.js';
2
+ import React from 'react';
3
+
4
+ export function MetadataBadges({ metadata }: Readonly<{ metadata?: Record<string, any> }>) {
5
+ if (!metadata || Object.keys(metadata).length === 0) return null;
6
+ return (
7
+ <div className="mt-2 flex flex-wrap gap-1">
8
+ {Object.entries(metadata).map(([key, value]) => (
9
+ <Badge key={key} variant="outline" className="text-xs">
10
+ {key}: {String(value)}
11
+ </Badge>
12
+ ))}
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,21 @@
1
+ import { Money } from '@/vdb/components/data-display/money.js';
2
+ import { Trans } from '@/vdb/lib/trans.js';
3
+
4
+ export function PriceDisplay({
5
+ price,
6
+ priceWithTax,
7
+ currencyCode,
8
+ }: Readonly<{
9
+ price: number;
10
+ priceWithTax: number;
11
+ currencyCode: string;
12
+ }>) {
13
+ return (
14
+ <div className="text-right">
15
+ <Money value={priceWithTax} currency={currencyCode} />
16
+ <div className="text-xs text-muted-foreground">
17
+ <Trans>ex. tax:</Trans> <Money value={price} currency={currencyCode} />
18
+ </div>
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,87 @@
1
+ import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
2
+ import { Button } from '@/vdb/components/ui/button.js';
3
+ import { Card, CardContent, CardHeader, CardTitle } from '@/vdb/components/ui/card.js';
4
+ import { Trans } from '@/vdb/lib/trans.js';
5
+ import { PlayIcon } from 'lucide-react';
6
+ import React from 'react';
7
+
8
+ interface ShippingMethodTestResultWrapperProps {
9
+ okToRun: boolean;
10
+ testDataUpdated: boolean;
11
+ hasTestedOnce: boolean;
12
+ onRunTest: () => void;
13
+ loading?: boolean;
14
+ children: React.ReactNode;
15
+ emptyState?: React.ReactNode;
16
+ showEmptyState?: boolean;
17
+ runTestLabel?: React.ReactNode;
18
+ loadingLabel?: React.ReactNode;
19
+ }
20
+
21
+ export function ShippingMethodTestResultWrapper({
22
+ okToRun,
23
+ testDataUpdated,
24
+ hasTestedOnce,
25
+ onRunTest,
26
+ loading = false,
27
+ children,
28
+ emptyState,
29
+ showEmptyState = false,
30
+ runTestLabel = <Trans>Run Test</Trans>,
31
+ loadingLabel = <Trans>Testing shipping method...</Trans>,
32
+ }: Readonly<ShippingMethodTestResultWrapperProps>) {
33
+ const canRunTest = okToRun && testDataUpdated;
34
+ return (
35
+ <Card>
36
+ <CardHeader>
37
+ <CardTitle className="flex items-center justify-between">
38
+ <span>
39
+ <Trans>Test Results</Trans>
40
+ </span>
41
+ {okToRun && (
42
+ <Button
43
+ onClick={onRunTest}
44
+ disabled={!canRunTest || loading}
45
+ size="sm"
46
+ className="ml-auto"
47
+ >
48
+ <PlayIcon className="mr-1 h-4 w-4" />
49
+ {runTestLabel}
50
+ </Button>
51
+ )}
52
+ </CardTitle>
53
+ </CardHeader>
54
+ <CardContent>
55
+ {!okToRun && (
56
+ <Alert>
57
+ <AlertDescription>
58
+ <Trans>
59
+ Please add products and complete the shipping address to run the test.
60
+ </Trans>
61
+ </AlertDescription>
62
+ </Alert>
63
+ )}
64
+
65
+ {okToRun && testDataUpdated && hasTestedOnce && (
66
+ <Alert variant="destructive">
67
+ <AlertDescription>
68
+ <Trans>
69
+ Test data has been updated. Click "Run Test" to see updated results.
70
+ </Trans>
71
+ </AlertDescription>
72
+ </Alert>
73
+ )}
74
+
75
+ {loading && (
76
+ <div className="text-center py-8">
77
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
78
+ <p className="mt-2 text-sm text-muted-foreground">{loadingLabel}</p>
79
+ </div>
80
+ )}
81
+
82
+ {!loading && showEmptyState && emptyState}
83
+ {!loading && !showEmptyState && children}
84
+ </CardContent>
85
+ </Card>
86
+ );
87
+ }