astro-tractstack 2.3.2 → 2.3.4

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 (58) hide show
  1. package/bin/create-tractstack.js +7 -4
  2. package/dist/index.js +51 -8
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +279 -118
  5. package/templates/custom/shopify/CartIcon.tsx +8 -8
  6. package/templates/custom/shopify/CheckoutModal.tsx +328 -65
  7. package/templates/custom/shopify/ShopifyCartManager.tsx +117 -60
  8. package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
  9. package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
  10. package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
  11. package/templates/custom/shopify/cart.astro +7 -1
  12. package/templates/src/components/Header.astro +4 -2
  13. package/templates/src/components/compositor/Node.tsx +39 -9
  14. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  15. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  16. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  17. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  18. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  19. package/templates/src/components/form/advanced/APIConfigSection.tsx +249 -4
  20. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  21. package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
  22. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +66 -21
  23. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +266 -18
  24. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
  25. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  26. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +240 -65
  27. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  28. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +91 -10
  29. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  30. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  31. package/templates/src/constants.ts +2 -0
  32. package/templates/src/layouts/Layout.astro +26 -0
  33. package/templates/src/pages/api/auth/logout.ts +35 -2
  34. package/templates/src/pages/api/google/oauth/callback.ts +50 -0
  35. package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
  36. package/templates/src/pages/api/google/oauth/start.ts +32 -0
  37. package/templates/src/pages/api/google/oauth/status.ts +32 -0
  38. package/templates/src/pages/api/sales/list.ts +66 -0
  39. package/templates/src/pages/api/sales/metrics.ts +60 -0
  40. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  41. package/templates/src/pages/privacy.astro +84 -0
  42. package/templates/src/pages/storykeep/advanced.astro +4 -1
  43. package/templates/src/pages/terms.astro +47 -0
  44. package/templates/src/stores/nodes.ts +8 -0
  45. package/templates/src/stores/shopify.ts +5 -0
  46. package/templates/src/types/tractstack.ts +87 -0
  47. package/templates/src/utils/api/advancedConfig.ts +2 -1
  48. package/templates/src/utils/api/advancedHelpers.ts +20 -0
  49. package/templates/src/utils/api/bookingHelpers.ts +3 -1
  50. package/templates/src/utils/api/brandConfig.ts +2 -0
  51. package/templates/src/utils/api/brandHelpers.ts +14 -1
  52. package/templates/src/utils/api/salesHelpers.ts +21 -0
  53. package/templates/src/utils/booking/appointmentMode.ts +135 -0
  54. package/templates/src/utils/customHelpers.ts +287 -2
  55. package/utils/inject-files.ts +47 -4
  56. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
  57. package/templates/src/utils/actions/actionButton.ts +0 -103
  58. package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
@@ -1,3 +1,4 @@
1
+ import { useEffect, useMemo } from 'react';
1
2
  import { useFormState } from '@/hooks/useFormState';
2
3
  import { convertToLocalState } from '@/utils/api/resourceHelpers';
3
4
  import { saveResourceWithStateUpdate } from '@/utils/api/resourceConfig';
@@ -26,8 +27,10 @@ interface ResourceFormProps {
26
27
  fullContentMap: FullContentMapItem[];
27
28
  categorySlug: string;
28
29
  categorySchema: Record<string, FieldDefinition>;
30
+ tenantRemoteOnly?: boolean;
29
31
  isCreate?: boolean;
30
32
  onClose?: (saved: boolean) => void;
33
+ onOpenLinkedProduct?: (resourceId: string) => void;
31
34
  }
32
35
 
33
36
  export default function ResourceForm({
@@ -35,75 +38,81 @@ export default function ResourceForm({
35
38
  fullContentMap,
36
39
  categorySlug,
37
40
  categorySchema,
41
+ tenantRemoteOnly = false,
38
42
  isCreate = false,
39
43
  onClose,
44
+ onOpenLinkedProduct,
40
45
  }: ResourceFormProps) {
41
- const initialData = resourceData
42
- ? convertToLocalState(resourceData)
43
- : {
44
- id: '',
45
- title: '',
46
- slug: '',
47
- categorySlug,
48
- oneliner: '',
49
- optionsPayload: {},
50
- actionLisp: '',
51
- };
52
-
53
- // 1. Initialize optionsPayload with default values for all schema fields
54
- // (Only runs if NO existing data is provided)
55
- if (!resourceData) {
56
- const defaultOptionsPayload: Record<string, any> = {};
57
-
58
- Object.entries(categorySchema).forEach(([fieldName, fieldDef]) => {
59
- switch (fieldDef.type) {
60
- case 'number':
61
- defaultOptionsPayload[fieldName] =
62
- fieldDef.defaultValue ?? fieldDef.minNumber ?? 0;
63
- break;
64
- case 'boolean':
65
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? false;
66
- break;
67
- case 'string':
68
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
69
- break;
70
- case 'multi':
71
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? [];
72
- break;
73
- case 'date':
74
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? 0;
75
- break;
76
- case 'image':
77
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
78
- break;
79
- default:
80
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
81
- }
82
- });
46
+ const initialData = useMemo(() => {
47
+ const nextInitialData = resourceData
48
+ ? convertToLocalState(resourceData)
49
+ : {
50
+ id: '',
51
+ title: '',
52
+ slug: '',
53
+ categorySlug,
54
+ oneliner: '',
55
+ optionsPayload: {},
56
+ actionLisp: '',
57
+ };
58
+
59
+ // 1. Initialize optionsPayload with default values for all schema fields
60
+ // (Only runs if NO existing data is provided)
61
+ if (!resourceData) {
62
+ const defaultOptionsPayload: Record<string, any> = {};
63
+
64
+ Object.entries(categorySchema).forEach(([fieldName, fieldDef]) => {
65
+ switch (fieldDef.type) {
66
+ case 'number':
67
+ defaultOptionsPayload[fieldName] =
68
+ fieldDef.defaultValue ?? fieldDef.minNumber ?? 0;
69
+ break;
70
+ case 'boolean':
71
+ defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? false;
72
+ break;
73
+ case 'string':
74
+ defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
75
+ break;
76
+ case 'multi':
77
+ defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? [];
78
+ break;
79
+ case 'date':
80
+ defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? 0;
81
+ break;
82
+ case 'image':
83
+ defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
84
+ break;
85
+ default:
86
+ defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
87
+ }
88
+ });
83
89
 
84
- initialData.optionsPayload = defaultOptionsPayload;
85
- }
86
-
87
- // 2. Pre-process JSON fields for display (Pretty Print)
88
- // This runs for both new and existing records to ensure readability
89
- if (initialData.optionsPayload) {
90
- resourceJsonifyFields.forEach((field) => {
91
- const val = initialData.optionsPayload[field];
92
- if (val && typeof val === 'string') {
93
- try {
94
- // Parse and re-stringify with indentation
95
- initialData.optionsPayload[field] = JSON.stringify(
96
- JSON.parse(val),
97
- null,
98
- 2
99
- );
100
- } catch (e) {
101
- // If it's not valid JSON, leave it as is
102
- console.warn(`Failed to pretty-print field ${field}`, e);
90
+ nextInitialData.optionsPayload = defaultOptionsPayload;
91
+ }
92
+
93
+ // 2. Pre-process JSON fields for display (Pretty Print)
94
+ // This runs for both new and existing records to ensure readability
95
+ if (nextInitialData.optionsPayload) {
96
+ resourceJsonifyFields.forEach((field) => {
97
+ const val = nextInitialData.optionsPayload[field];
98
+ if (val && typeof val === 'string') {
99
+ try {
100
+ // Parse and re-stringify with indentation
101
+ nextInitialData.optionsPayload[field] = JSON.stringify(
102
+ JSON.parse(val),
103
+ null,
104
+ 2
105
+ );
106
+ } catch (e) {
107
+ // If it's not valid JSON, leave it as is
108
+ console.warn(`Failed to pretty-print field ${field}`, e);
109
+ }
103
110
  }
104
- }
105
- });
106
- }
111
+ });
112
+ }
113
+
114
+ return nextInitialData;
115
+ }, [resourceData, categorySlug, categorySchema]);
107
116
 
108
117
  const validator = (state: ResourceState): FieldErrors => {
109
118
  const errors: FieldErrors = {};
@@ -183,7 +192,65 @@ export default function ResourceForm({
183
192
  },
184
193
  });
185
194
 
186
- const { state, updateField, errors } = formState;
195
+ const { state, updateField, errors, resetToState } = formState;
196
+ const isServiceCategory = categorySlug === 'service';
197
+ const isProductCategory = categorySlug === 'product';
198
+ const serviceRemoteOnly = Boolean(state.optionsPayload?.remoteOnly);
199
+ const effectiveServiceRemoteOnly = tenantRemoteOnly || serviceRemoteOnly;
200
+ const serviceGid =
201
+ isServiceCategory && typeof state.optionsPayload?.gid === 'string'
202
+ ? state.optionsPayload.gid
203
+ : '';
204
+ const linkedCanonicalProduct =
205
+ isServiceCategory && serviceGid
206
+ ? fullContentMap.find(
207
+ (item: any) =>
208
+ item.categorySlug === 'product' &&
209
+ typeof item.optionsPayload?.gid === 'string' &&
210
+ item.optionsPayload.gid === serviceGid
211
+ )
212
+ : undefined;
213
+ const productGid =
214
+ isProductCategory && typeof state.optionsPayload?.gid === 'string'
215
+ ? state.optionsPayload.gid
216
+ : '';
217
+ const linkedServiceCount =
218
+ isProductCategory && productGid
219
+ ? fullContentMap.filter(
220
+ (item: any) =>
221
+ item.categorySlug === 'service' &&
222
+ typeof item.optionsPayload?.gid === 'string' &&
223
+ item.optionsPayload.gid === productGid
224
+ ).length
225
+ : 0;
226
+ const formIdentity = `${categorySlug}:${resourceData?.id || 'create'}`;
227
+
228
+ useEffect(() => {
229
+ // Keep the mounted form in sync when switching edit targets inside the same modal.
230
+ resetToState(initialData as ResourceState);
231
+ }, [formIdentity, initialData, resetToState]);
232
+
233
+ useEffect(() => {
234
+ if (!isServiceCategory) return;
235
+ if (!effectiveServiceRemoteOnly) return;
236
+
237
+ const nextPayload = {
238
+ ...state.optionsPayload,
239
+ allowRemote: true,
240
+ remoteOnly: true,
241
+ };
242
+ const needsUpdate =
243
+ state.optionsPayload?.allowRemote !== true ||
244
+ state.optionsPayload?.remoteOnly !== true;
245
+ if (needsUpdate) {
246
+ updateField('optionsPayload', nextPayload);
247
+ }
248
+ }, [
249
+ effectiveServiceRemoteOnly,
250
+ isServiceCategory,
251
+ state.optionsPayload,
252
+ updateField,
253
+ ]);
187
254
 
188
255
  // Helper to get category reference options for a field
189
256
  const getCategoryReferenceOptions = (belongsToCategory: string) => {
@@ -213,6 +280,46 @@ export default function ResourceForm({
213
280
  };
214
281
 
215
282
  const renderDynamicField = (fieldName: string, fieldDef: FieldDefinition) => {
283
+ if (fieldName === 'gid' && !isServiceCategory) {
284
+ return null;
285
+ }
286
+
287
+ if (isServiceCategory && fieldName === 'gid') {
288
+ return (
289
+ <div key={fieldName} className="space-y-2">
290
+ <label
291
+ htmlFor="field-gid-readonly"
292
+ className="block text-sm font-bold text-gray-700"
293
+ >
294
+ Gid
295
+ </label>
296
+ <input
297
+ id="field-gid-readonly"
298
+ type="text"
299
+ value={serviceGid}
300
+ readOnly
301
+ className="block w-full cursor-default rounded-md border-0 bg-gray-50 px-3 py-1.5 text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 md:text-sm md:leading-6"
302
+ />
303
+ {linkedCanonicalProduct && onOpenLinkedProduct && (
304
+ <button
305
+ type="button"
306
+ onClick={() => onOpenLinkedProduct(linkedCanonicalProduct.id)}
307
+ className="text-xs font-bold text-cyan-700 underline hover:text-cyan-900"
308
+ >
309
+ Open linked product
310
+ </button>
311
+ )}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ if (
317
+ !isServiceCategory &&
318
+ (fieldName === 'allowRemote' || fieldName === 'remoteOnly')
319
+ ) {
320
+ return null;
321
+ }
322
+
216
323
  if (
217
324
  resourceFormHideFields.includes(fieldName)
218
325
  // && initialData.optionsPayload?.[fieldName]
@@ -322,6 +429,74 @@ export default function ResourceForm({
322
429
  );
323
430
 
324
431
  case 'boolean':
432
+ if (isServiceCategory && fieldName === 'allowRemote') {
433
+ const locked = effectiveServiceRemoteOnly;
434
+ return (
435
+ <BooleanToggle
436
+ key={fieldName}
437
+ label="Allow Remote"
438
+ value={locked ? true : Boolean(fieldValue)}
439
+ onChange={(value) =>
440
+ updateOptionsField(fieldName, locked ? true : Boolean(value))
441
+ }
442
+ error={fieldError}
443
+ disabled={locked}
444
+ description={
445
+ locked
446
+ ? 'Locked to true because remoteOnly is enabled at tenant or service scope.'
447
+ : undefined
448
+ }
449
+ />
450
+ );
451
+ }
452
+ if (isServiceCategory && fieldName === 'remoteOnly') {
453
+ const locked = tenantRemoteOnly;
454
+ return (
455
+ <BooleanToggle
456
+ key={fieldName}
457
+ label="Remote Only"
458
+ value={locked ? true : Boolean(fieldValue)}
459
+ onChange={(value) => {
460
+ const next = Boolean(value);
461
+ updateOptionsField(fieldName, locked ? true : next);
462
+ if (next || locked) {
463
+ updateOptionsField('allowRemote', true);
464
+ }
465
+ }}
466
+ error={fieldError}
467
+ disabled={locked}
468
+ description={
469
+ locked
470
+ ? 'Locked to true because tenant scheduling is set to remoteOnly.'
471
+ : 'When enabled, this service can only be booked remotely.'
472
+ }
473
+ />
474
+ );
475
+ }
476
+
477
+ if (isProductCategory && fieldName === 'sharedServiceFee') {
478
+ const isLocked = Boolean(fieldValue) && linkedServiceCount > 0;
479
+ return (
480
+ <BooleanToggle
481
+ key={fieldName}
482
+ label="SharedServiceFee"
483
+ value={
484
+ fieldValue !== undefined && fieldValue !== null
485
+ ? fieldValue
486
+ : (fieldDef.defaultValue ?? false)
487
+ }
488
+ onChange={(value) => updateOptionsField(fieldName, value)}
489
+ error={fieldError}
490
+ disabled={isLocked}
491
+ description={
492
+ isLocked
493
+ ? 'Locked because this product gid is linked by one or more services.'
494
+ : undefined
495
+ }
496
+ />
497
+ );
498
+ }
499
+
325
500
  return (
326
501
  <BooleanToggle
327
502
  key={fieldName}
@@ -1,9 +1,17 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { bookingHelpers } from '@/utils/api/bookingHelpers';
3
- import type { BookingMetricsResponse } from '@/types/tractstack';
3
+ import { salesHelpers } from '@/utils/api/salesHelpers';
4
+ import type {
5
+ BookingMetricsResponse,
6
+ SaleMetricsResponse,
7
+ } from '@/types/tractstack';
4
8
 
5
9
  export default function ShopifyDashboard({}) {
6
- const [metrics, setMetrics] = useState<BookingMetricsResponse | null>(null);
10
+ const [bookingMetrics, setBookingMetrics] =
11
+ useState<BookingMetricsResponse | null>(null);
12
+ const [salesMetrics, setSalesMetrics] = useState<SaleMetricsResponse | null>(
13
+ null
14
+ );
7
15
  const [isLoading, setIsLoading] = useState(true);
8
16
  const [error, setError] = useState<string | null>(null);
9
17
 
@@ -11,8 +19,12 @@ export default function ShopifyDashboard({}) {
11
19
  const loadMetrics = async () => {
12
20
  try {
13
21
  setIsLoading(true);
14
- const data = await bookingHelpers.getMetrics();
15
- setMetrics(data);
22
+ const [bookingData, salesData] = await Promise.all([
23
+ bookingHelpers.getMetrics(),
24
+ salesHelpers.getMetrics(),
25
+ ]);
26
+ setBookingMetrics(bookingData);
27
+ setSalesMetrics(salesData);
16
28
  } catch (err) {
17
29
  console.error('Failed to fetch metrics:', err);
18
30
  setError('Failed to load dashboard metrics.');
@@ -41,64 +53,179 @@ export default function ShopifyDashboard({}) {
41
53
  }
42
54
 
43
55
  const totalLast24h =
44
- (metrics?.confirmedLast24h || 0) + (metrics?.pendingLast24h || 0);
56
+ (bookingMetrics?.confirmedLast24h || 0) +
57
+ (bookingMetrics?.pendingLast24h || 0);
45
58
  const intentRatio =
46
59
  totalLast24h > 0
47
- ? Math.round(((metrics?.confirmedLast24h || 0) / totalLast24h) * 100)
60
+ ? Math.round(
61
+ ((bookingMetrics?.confirmedLast24h || 0) / totalLast24h) * 100
62
+ )
48
63
  : 0;
64
+ const currencyCode = salesMetrics?.currencyCode || 'USD';
65
+ const formatMoney = (amount: string | undefined) => {
66
+ const parsed = parseFloat(amount || '0');
67
+ const safeAmount = Number.isFinite(parsed) ? parsed : 0;
68
+ return `${safeAmount.toFixed(2)} ${currencyCode}`;
69
+ };
70
+
71
+ const MetricCard = ({
72
+ title,
73
+ value,
74
+ subtext,
75
+ alert = false,
76
+ }: {
77
+ title: string;
78
+ value: string | number;
79
+ subtext?: string;
80
+ alert?: boolean;
81
+ }) => (
82
+ <div
83
+ className={`rounded-lg border p-6 shadow-sm ${
84
+ alert ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'
85
+ }`}
86
+ >
87
+ <h3
88
+ className={`text-sm font-bold ${
89
+ alert ? 'text-red-700' : 'text-gray-500'
90
+ }`}
91
+ >
92
+ {title}
93
+ </h3>
94
+ <p
95
+ className={`mt-2 text-3xl font-bold ${
96
+ alert ? 'text-red-900' : 'text-gray-900'
97
+ }`}
98
+ >
99
+ {value}
100
+ </p>
101
+ {subtext && (
102
+ <p
103
+ className={`mt-1 text-sm font-bold ${
104
+ alert ? 'text-red-600' : 'text-gray-500'
105
+ }`}
106
+ >
107
+ {subtext}
108
+ </p>
109
+ )}
110
+ </div>
111
+ );
49
112
 
50
113
  return (
51
- <div className="space-y-6">
52
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
53
- <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
54
- <h3 className="text-sm font-bold text-gray-500">Monthly Confirmed</h3>
55
- <p className="mt-2 text-3xl font-bold text-gray-900">
56
- {metrics?.totalMonthlyConfirmed || 0}
57
- </p>
114
+ <div className="space-y-8">
115
+ <section className="space-y-4">
116
+ <h2 className="text-lg font-bold text-gray-900">Sales Performance</h2>
117
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
118
+ <MetricCard
119
+ title="Paid Order Total (Month)"
120
+ value={formatMoney(salesMetrics?.paidOrderTotalMonth)}
121
+ subtext={`Year: ${formatMoney(
122
+ salesMetrics?.paidOrderTotalYear
123
+ )} · All time: ${formatMoney(salesMetrics?.paidOrderTotalAllTime)}`}
124
+ />
125
+ <MetricCard
126
+ title="Paid Orders (Month)"
127
+ value={salesMetrics?.paidOrdersMonth || 0}
128
+ subtext={`Year: ${salesMetrics?.paidOrdersYear || 0} · All time: ${
129
+ salesMetrics?.paidOrdersAllTime || 0
130
+ }`}
131
+ />
132
+ <MetricCard
133
+ title="Average Paid Order"
134
+ value={formatMoney(salesMetrics?.averagePaidOrderMonth)}
135
+ />
136
+ <MetricCard
137
+ title="Local Pickup Line Total"
138
+ value={formatMoney(salesMetrics?.localPickupLineTotalMonth)}
139
+ />
140
+ <MetricCard
141
+ title="Unique Paying Customers"
142
+ value={salesMetrics?.uniquePayingCustomers || 0}
143
+ />
144
+ <MetricCard
145
+ title="Orphan Payments"
146
+ value={salesMetrics?.orphanOrdersMonth || 0}
147
+ alert={(salesMetrics?.orphanOrdersMonth || 0) > 0}
148
+ />
58
149
  </div>
150
+ </section>
59
151
 
60
- <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
61
- <h3 className="text-sm font-bold text-gray-500">Weekly Confirmed</h3>
62
- <p className="mt-2 text-3xl font-bold text-gray-900">
63
- {metrics?.totalWeeklyConfirmed || 0}
64
- </p>
152
+ <section className="space-y-4">
153
+ <h2 className="text-lg font-bold text-gray-900">Sales Mix</h2>
154
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
155
+ <MetricCard
156
+ title="Appointment Sales"
157
+ value={salesMetrics?.appointmentOrdersMonth || 0}
158
+ />
159
+ <MetricCard
160
+ title="Product-only Sales"
161
+ value={salesMetrics?.productOnlyOrdersMonth || 0}
162
+ />
163
+ <MetricCard
164
+ title="Local Pickup Orders"
165
+ value={salesMetrics?.localPickupOrdersMonth || 0}
166
+ />
65
167
  </div>
168
+ </section>
66
169
 
67
- <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
68
- <h3 className="text-sm font-bold text-gray-500">Annual Confirmed</h3>
69
- <p className="mt-2 text-3xl font-bold text-gray-900">
70
- {metrics?.totalAnnualConfirmed || 0}
71
- </p>
72
- </div>
170
+ <section className="space-y-4">
171
+ <h2 className="text-lg font-bold text-gray-900">Booking Funnel</h2>
172
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
173
+ <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
174
+ <h3 className="text-sm font-bold text-gray-500">
175
+ Monthly Confirmed
176
+ </h3>
177
+ <p className="mt-2 text-3xl font-bold text-gray-900">
178
+ {bookingMetrics?.totalMonthlyConfirmed || 0}
179
+ </p>
180
+ </div>
73
181
 
74
- <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
75
- <h3 className="text-sm font-bold text-gray-500">
76
- Total Leads Converted
77
- </h3>
78
- <p className="mt-2 text-3xl font-bold text-gray-900">
79
- {metrics?.leadConversionAnchor || 0}
80
- </p>
81
- </div>
182
+ <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
183
+ <h3 className="text-sm font-bold text-gray-500">
184
+ Weekly Confirmed
185
+ </h3>
186
+ <p className="mt-2 text-3xl font-bold text-gray-900">
187
+ {bookingMetrics?.totalWeeklyConfirmed || 0}
188
+ </p>
189
+ </div>
82
190
 
83
- <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
84
- <h3 className="text-sm font-bold text-gray-500">
85
- Pending (Last 24h)
86
- </h3>
87
- <p className="mt-2 text-3xl font-bold text-gray-900">
88
- {metrics?.pendingLast24h || 0}
89
- </p>
90
- </div>
191
+ <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
192
+ <h3 className="text-sm font-bold text-gray-500">
193
+ Annual Confirmed
194
+ </h3>
195
+ <p className="mt-2 text-3xl font-bold text-gray-900">
196
+ {bookingMetrics?.totalAnnualConfirmed || 0}
197
+ </p>
198
+ </div>
199
+
200
+ <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
201
+ <h3 className="text-sm font-bold text-gray-500">
202
+ Total Leads Converted
203
+ </h3>
204
+ <p className="mt-2 text-3xl font-bold text-gray-900">
205
+ {bookingMetrics?.leadConversionAnchor || 0}
206
+ </p>
207
+ </div>
91
208
 
92
- <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
93
- <h3 className="text-sm font-bold text-gray-500">
94
- Checkout Intent Ratio
95
- </h3>
96
- <div className="mt-2 flex items-baseline gap-2">
97
- <p className="text-3xl font-bold text-gray-900">{intentRatio}%</p>
98
- <p className="text-sm font-bold text-gray-500">conversion</p>
209
+ <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
210
+ <h3 className="text-sm font-bold text-gray-500">
211
+ Pending (Last 24h)
212
+ </h3>
213
+ <p className="mt-2 text-3xl font-bold text-gray-900">
214
+ {bookingMetrics?.pendingLast24h || 0}
215
+ </p>
216
+ </div>
217
+
218
+ <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
219
+ <h3 className="text-sm font-bold text-gray-500">
220
+ Checkout Intent Ratio
221
+ </h3>
222
+ <div className="mt-2 flex items-baseline gap-2">
223
+ <p className="text-3xl font-bold text-gray-900">{intentRatio}%</p>
224
+ <p className="text-sm font-bold text-gray-500">conversion</p>
225
+ </div>
99
226
  </div>
100
227
  </div>
101
- </div>
228
+ </section>
102
229
  </div>
103
230
  );
104
231
  }