astro-tractstack 2.3.3 → 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 (42) hide show
  1. package/bin/create-tractstack.js +5 -2
  2. package/dist/index.js +18 -0
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +196 -104
  5. package/templates/custom/shopify/CartIcon.tsx +8 -8
  6. package/templates/custom/shopify/CheckoutModal.tsx +143 -66
  7. package/templates/custom/shopify/ShopifyCartManager.tsx +64 -19
  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/src/components/Header.astro +1 -1
  12. package/templates/src/components/compositor/Node.tsx +39 -9
  13. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  14. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  15. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  16. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  17. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  18. package/templates/src/components/form/advanced/APIConfigSection.tsx +35 -8
  19. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  20. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
  21. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
  22. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  23. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +161 -66
  24. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  25. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
  26. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  27. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  28. package/templates/src/layouts/Layout.astro +26 -0
  29. package/templates/src/pages/api/auth/logout.ts +35 -2
  30. package/templates/src/pages/api/sales/list.ts +66 -0
  31. package/templates/src/pages/api/sales/metrics.ts +60 -0
  32. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  33. package/templates/src/pages/storykeep/advanced.astro +4 -1
  34. package/templates/src/stores/nodes.ts +8 -0
  35. package/templates/src/types/tractstack.ts +57 -0
  36. package/templates/src/utils/api/advancedConfig.ts +2 -1
  37. package/templates/src/utils/api/advancedHelpers.ts +4 -0
  38. package/templates/src/utils/api/brandConfig.ts +2 -0
  39. package/templates/src/utils/api/brandHelpers.ts +6 -0
  40. package/templates/src/utils/api/salesHelpers.ts +21 -0
  41. package/templates/src/utils/customHelpers.ts +285 -2
  42. package/utils/inject-files.ts +18 -0
@@ -1,4 +1,4 @@
1
- import { useEffect } from 'react';
1
+ import { useEffect, useMemo } from 'react';
2
2
  import { useFormState } from '@/hooks/useFormState';
3
3
  import { convertToLocalState } from '@/utils/api/resourceHelpers';
4
4
  import { saveResourceWithStateUpdate } from '@/utils/api/resourceConfig';
@@ -30,6 +30,7 @@ interface ResourceFormProps {
30
30
  tenantRemoteOnly?: boolean;
31
31
  isCreate?: boolean;
32
32
  onClose?: (saved: boolean) => void;
33
+ onOpenLinkedProduct?: (resourceId: string) => void;
33
34
  }
34
35
 
35
36
  export default function ResourceForm({
@@ -40,73 +41,78 @@ export default function ResourceForm({
40
41
  tenantRemoteOnly = false,
41
42
  isCreate = false,
42
43
  onClose,
44
+ onOpenLinkedProduct,
43
45
  }: ResourceFormProps) {
44
- const initialData = resourceData
45
- ? convertToLocalState(resourceData)
46
- : {
47
- id: '',
48
- title: '',
49
- slug: '',
50
- categorySlug,
51
- oneliner: '',
52
- optionsPayload: {},
53
- actionLisp: '',
54
- };
55
-
56
- // 1. Initialize optionsPayload with default values for all schema fields
57
- // (Only runs if NO existing data is provided)
58
- if (!resourceData) {
59
- const defaultOptionsPayload: Record<string, any> = {};
60
-
61
- Object.entries(categorySchema).forEach(([fieldName, fieldDef]) => {
62
- switch (fieldDef.type) {
63
- case 'number':
64
- defaultOptionsPayload[fieldName] =
65
- fieldDef.defaultValue ?? fieldDef.minNumber ?? 0;
66
- break;
67
- case 'boolean':
68
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? false;
69
- break;
70
- case 'string':
71
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
72
- break;
73
- case 'multi':
74
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? [];
75
- break;
76
- case 'date':
77
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? 0;
78
- break;
79
- case 'image':
80
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
81
- break;
82
- default:
83
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? '';
84
- }
85
- });
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
+ });
86
89
 
87
- initialData.optionsPayload = defaultOptionsPayload;
88
- }
89
-
90
- // 2. Pre-process JSON fields for display (Pretty Print)
91
- // This runs for both new and existing records to ensure readability
92
- if (initialData.optionsPayload) {
93
- resourceJsonifyFields.forEach((field) => {
94
- const val = initialData.optionsPayload[field];
95
- if (val && typeof val === 'string') {
96
- try {
97
- // Parse and re-stringify with indentation
98
- initialData.optionsPayload[field] = JSON.stringify(
99
- JSON.parse(val),
100
- null,
101
- 2
102
- );
103
- } catch (e) {
104
- // If it's not valid JSON, leave it as is
105
- 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
+ }
106
110
  }
107
- }
108
- });
109
- }
111
+ });
112
+ }
113
+
114
+ return nextInitialData;
115
+ }, [resourceData, categorySlug, categorySchema]);
110
116
 
111
117
  const validator = (state: ResourceState): FieldErrors => {
112
118
  const errors: FieldErrors = {};
@@ -186,10 +192,43 @@ export default function ResourceForm({
186
192
  },
187
193
  });
188
194
 
189
- const { state, updateField, errors } = formState;
195
+ const { state, updateField, errors, resetToState } = formState;
190
196
  const isServiceCategory = categorySlug === 'service';
197
+ const isProductCategory = categorySlug === 'product';
191
198
  const serviceRemoteOnly = Boolean(state.optionsPayload?.remoteOnly);
192
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]);
193
232
 
194
233
  useEffect(() => {
195
234
  if (!isServiceCategory) return;
@@ -241,6 +280,39 @@ export default function ResourceForm({
241
280
  };
242
281
 
243
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
+
244
316
  if (
245
317
  !isServiceCategory &&
246
318
  (fieldName === 'allowRemote' || fieldName === 'remoteOnly')
@@ -402,6 +474,29 @@ export default function ResourceForm({
402
474
  );
403
475
  }
404
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
+
405
500
  return (
406
501
  <BooleanToggle
407
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
  }
@@ -52,9 +52,12 @@ export default function ShopifyDashboard_Bookings({
52
52
  setBookings(response.data || []);
53
53
  setTotalCount(response.totalCount || 0);
54
54
 
55
+ const availabilityStart = new Date();
56
+ const availabilityEnd = new Date(availabilityStart);
57
+ availabilityEnd.setDate(availabilityEnd.getDate() + 30);
55
58
  const availability = await bookingHelpers.getAvailability(
56
- new Date().toISOString(),
57
- new Date().toISOString()
59
+ availabilityStart.toISOString(),
60
+ availabilityEnd.toISOString()
58
61
  );
59
62
  if (availability?.scheduling?.timezone) {
60
63
  setShopTimezone(availability.scheduling.timezone);