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.
- package/bin/create-tractstack.js +7 -4
- package/dist/index.js +51 -8
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +279 -118
- package/templates/custom/shopify/CartIcon.tsx +8 -8
- package/templates/custom/shopify/CheckoutModal.tsx +328 -65
- package/templates/custom/shopify/ShopifyCartManager.tsx +117 -60
- package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
- package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Header.astro +4 -2
- package/templates/src/components/compositor/Node.tsx +39 -9
- package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
- package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
- package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/form/advanced/APIConfigSection.tsx +249 -4
- package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +66 -21
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +266 -18
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +240 -65
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +91 -10
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
- package/templates/src/constants.ts +2 -0
- package/templates/src/layouts/Layout.astro +26 -0
- package/templates/src/pages/api/auth/logout.ts +35 -2
- package/templates/src/pages/api/google/oauth/callback.ts +50 -0
- package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
- package/templates/src/pages/api/google/oauth/start.ts +32 -0
- package/templates/src/pages/api/google/oauth/status.ts +32 -0
- package/templates/src/pages/api/sales/list.ts +66 -0
- package/templates/src/pages/api/sales/metrics.ts +60 -0
- package/templates/src/pages/context/[...contextSlug].astro +50 -31
- package/templates/src/pages/privacy.astro +84 -0
- package/templates/src/pages/storykeep/advanced.astro +4 -1
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/nodes.ts +8 -0
- package/templates/src/stores/shopify.ts +5 -0
- package/templates/src/types/tractstack.ts +87 -0
- package/templates/src/utils/api/advancedConfig.ts +2 -1
- package/templates/src/utils/api/advancedHelpers.ts +20 -0
- package/templates/src/utils/api/bookingHelpers.ts +3 -1
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +14 -1
- package/templates/src/utils/api/salesHelpers.ts +21 -0
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +287 -2
- package/utils/inject-files.ts +47 -4
- package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
- package/templates/src/utils/actions/actionButton.ts +0 -103
- 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 =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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 [
|
|
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
|
|
15
|
-
|
|
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
|
-
(
|
|
56
|
+
(bookingMetrics?.confirmedLast24h || 0) +
|
|
57
|
+
(bookingMetrics?.pendingLast24h || 0);
|
|
45
58
|
const intentRatio =
|
|
46
59
|
totalLast24h > 0
|
|
47
|
-
? Math.round(
|
|
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-
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
</
|
|
228
|
+
</section>
|
|
102
229
|
</div>
|
|
103
230
|
);
|
|
104
231
|
}
|