astro-tractstack 2.3.3 → 2.3.5
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 +5 -2
- package/dist/index.js +32 -4
- package/package.json +1 -1
- package/templates/custom/customHelpers.ts +45 -0
- package/templates/custom/shopify/Cart.tsx +197 -105
- package/templates/custom/shopify/CartIcon.tsx +8 -8
- package/templates/custom/shopify/CheckoutModal.tsx +145 -68
- package/templates/custom/shopify/ShopifyCartManager.tsx +67 -22
- 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/shopifyCustomHelper.ts +10 -0
- package/templates/custom/shopify/shopifyHelpers.ts +298 -0
- package/templates/src/components/Header.astro +2 -2
- package/templates/src/components/codehooks/SearchWidget.tsx +1 -1
- 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 +35 -8
- package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
- package/templates/src/components/search/SearchResults.tsx +1 -1
- package/templates/src/components/search/SearchWrapper.tsx +1 -1
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +162 -67
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
- 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/layouts/Layout.astro +26 -0
- package/templates/src/pages/api/auth/logout.ts +35 -2
- 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/storykeep/advanced.astro +4 -1
- package/templates/src/stores/nodes.ts +8 -0
- package/templates/src/types/tractstack.ts +57 -0
- package/templates/src/utils/api/advancedConfig.ts +2 -1
- package/templates/src/utils/api/advancedHelpers.ts +4 -0
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +6 -0
- package/templates/src/utils/api/salesHelpers.ts +21 -0
- package/utils/inject-files.ts +32 -4
- package/templates/src/utils/customHelpers.ts +0 -89
- /package/templates/{src/utils/booking → custom/shopify}/appointmentMode.ts +0 -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';
|
|
@@ -13,7 +13,7 @@ import EnumSelect from '@/components/form/EnumSelect';
|
|
|
13
13
|
import {
|
|
14
14
|
resourceFormHideFields,
|
|
15
15
|
resourceJsonifyFields,
|
|
16
|
-
} from '@/
|
|
16
|
+
} from '@/custom/customHelpers';
|
|
17
17
|
import type {
|
|
18
18
|
ResourceConfig,
|
|
19
19
|
ResourceState,
|
|
@@ -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 =
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
57
|
-
|
|
59
|
+
availabilityStart.toISOString(),
|
|
60
|
+
availabilityEnd.toISOString()
|
|
58
61
|
);
|
|
59
62
|
if (availability?.scheduling?.timezone) {
|
|
60
63
|
setShopTimezone(availability.scheduling.timezone);
|