astro-tractstack 2.3.1 → 2.3.3
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 +3 -3
- package/dist/index.js +69 -11
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +99 -19
- package/templates/custom/shopify/CheckoutModal.tsx +196 -10
- package/templates/custom/shopify/ShopifyCartManager.tsx +79 -76
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +42 -14
- package/templates/custom/shopify/ShopifyServiceList.tsx +94 -50
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Footer.astro +2 -2
- package/templates/src/components/Header.astro +17 -9
- package/templates/src/components/Menu.tsx +157 -135
- package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
- package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
- package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
- package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
- package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
- package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
- package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
- package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
- package/templates/src/components/edit/ToolBar.tsx +2 -1
- package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/edit/state/SaveModal.tsx +1 -1
- package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
- package/templates/src/components/form/DateTimeInput.tsx +10 -3
- package/templates/src/components/form/FileUpload.tsx +11 -5
- package/templates/src/components/form/NumberInput.tsx +2 -2
- package/templates/src/components/form/advanced/APIConfigSection.tsx +221 -39
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +16 -8
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +79 -51
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -0
- package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
- package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
- package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
- package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +1 -8
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +118 -14
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/constants.ts +2 -0
- package/templates/src/layouts/Layout.astro +8 -5
- 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/privacy.astro +84 -0
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/shopify.ts +21 -0
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +35 -2
- package/templates/src/utils/api/advancedHelpers.ts +16 -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 +24 -1
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +2 -0
- package/templates/src/utils/tenantResolver.ts +1 -1
- package/utils/inject-files.ts +63 -5
- 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,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useCallback,
|
|
7
|
+
type FormEvent,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import { ulid } from 'ulid';
|
|
3
10
|
import { useStore } from '@nanostores/react';
|
|
4
11
|
import { Dialog } from '@ark-ui/react/dialog';
|
|
@@ -9,10 +16,15 @@ import {
|
|
|
9
16
|
customerDetails,
|
|
10
17
|
setCustomerDetails,
|
|
11
18
|
CART_STATES,
|
|
19
|
+
preferredAppointmentMode,
|
|
12
20
|
transactionTraceId,
|
|
13
21
|
isShopifyHandoff,
|
|
14
22
|
} from '@/stores/shopify';
|
|
15
23
|
import { bookingHelpers } from '@/utils/api/bookingHelpers';
|
|
24
|
+
import {
|
|
25
|
+
deriveAppointmentConstraints,
|
|
26
|
+
pickInitialAppointmentMode,
|
|
27
|
+
} from '@/utils/booking/appointmentMode';
|
|
16
28
|
import { NativeBookingCalendar } from './NativeBookingCalendar';
|
|
17
29
|
import { ProfileStorage } from '@/utils/profileStorage';
|
|
18
30
|
import type { ResourceNode } from '@/types/compositorTypes';
|
|
@@ -24,14 +36,19 @@ type CheckoutState =
|
|
|
24
36
|
| 'SUMMARY'
|
|
25
37
|
| 'PROCESSING'
|
|
26
38
|
| 'SUCCESS';
|
|
39
|
+
type AppointmentMode = 'IN_PERSON' | 'REMOTE';
|
|
27
40
|
|
|
28
41
|
interface CheckoutModalProps {
|
|
29
42
|
maxLength: number;
|
|
43
|
+
allowRemote?: boolean;
|
|
44
|
+
remoteOnly?: boolean;
|
|
30
45
|
resources?: ResourceNode[];
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
export default function CheckoutModal({
|
|
34
49
|
maxLength,
|
|
50
|
+
allowRemote = false,
|
|
51
|
+
remoteOnly = false,
|
|
35
52
|
resources = [],
|
|
36
53
|
}: CheckoutModalProps) {
|
|
37
54
|
const $globalCartState = useStore(cartState);
|
|
@@ -57,6 +74,24 @@ export default function CheckoutModal({
|
|
|
57
74
|
start: Date;
|
|
58
75
|
end: Date;
|
|
59
76
|
} | null>(null);
|
|
77
|
+
const [appointmentMode, setAppointmentMode] = useState<AppointmentMode>(() =>
|
|
78
|
+
pickInitialAppointmentMode(
|
|
79
|
+
deriveAppointmentConstraints(cartStore.get(), resources, {
|
|
80
|
+
allowRemote,
|
|
81
|
+
remoteOnly,
|
|
82
|
+
}),
|
|
83
|
+
preferredAppointmentMode.get()
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
const appointmentModeRef = useRef<AppointmentMode>(
|
|
87
|
+
pickInitialAppointmentMode(
|
|
88
|
+
deriveAppointmentConstraints(cartStore.get(), resources, {
|
|
89
|
+
allowRemote,
|
|
90
|
+
remoteOnly,
|
|
91
|
+
}),
|
|
92
|
+
preferredAppointmentMode.get()
|
|
93
|
+
)
|
|
94
|
+
);
|
|
60
95
|
const [error, setError] = useState<string | null>(null);
|
|
61
96
|
|
|
62
97
|
const isOpen =
|
|
@@ -76,12 +111,13 @@ export default function CheckoutModal({
|
|
|
76
111
|
}
|
|
77
112
|
}
|
|
78
113
|
const variant = (productData?.variants || []).find(
|
|
79
|
-
(v: any) => v.id ===
|
|
114
|
+
(v: any) => v.id === item.variantId
|
|
80
115
|
);
|
|
81
116
|
return {
|
|
82
117
|
...item,
|
|
83
118
|
title: productData?.title || resource?.title || 'Loading...',
|
|
84
119
|
price: variant?.price?.amount || '0.00',
|
|
120
|
+
resourceNode: resource,
|
|
85
121
|
resource: {
|
|
86
122
|
id: item.resourceId,
|
|
87
123
|
needsBooking:
|
|
@@ -99,7 +135,7 @@ export default function CheckoutModal({
|
|
|
99
135
|
[enrichedCart]
|
|
100
136
|
);
|
|
101
137
|
const needsPayment = useMemo(
|
|
102
|
-
() => enrichedCart.some((item) => !!
|
|
138
|
+
() => enrichedCart.some((item) => !!item.variantId),
|
|
103
139
|
[enrichedCart]
|
|
104
140
|
);
|
|
105
141
|
const totalDuration = useMemo(() => {
|
|
@@ -111,6 +147,51 @@ export default function CheckoutModal({
|
|
|
111
147
|
return Math.min(Math.ceil(rawMinutes / 15) * 15, maxLength);
|
|
112
148
|
}, [enrichedCart]);
|
|
113
149
|
|
|
150
|
+
const appointmentConstraints = useMemo(
|
|
151
|
+
() =>
|
|
152
|
+
deriveAppointmentConstraints($cartItems, resources, {
|
|
153
|
+
allowRemote,
|
|
154
|
+
remoteOnly,
|
|
155
|
+
}),
|
|
156
|
+
[$cartItems, resources, allowRemote, remoteOnly]
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const { effectiveRemoteOnly, inPersonAvailable } = appointmentConstraints;
|
|
160
|
+
|
|
161
|
+
const remoteAvailable =
|
|
162
|
+
appointmentConstraints.effectiveRemoteOnly ||
|
|
163
|
+
(needsBooking &&
|
|
164
|
+
appointmentConstraints.effectiveAllowRemote &&
|
|
165
|
+
appointmentConstraints.serviceResources.length > 0 &&
|
|
166
|
+
appointmentConstraints.allServicesAllowRemote);
|
|
167
|
+
|
|
168
|
+
const applyAppointmentMode = useCallback((nextMode: AppointmentMode) => {
|
|
169
|
+
appointmentModeRef.current = nextMode;
|
|
170
|
+
setAppointmentMode((currentMode) =>
|
|
171
|
+
currentMode === nextMode ? currentMode : nextMode
|
|
172
|
+
);
|
|
173
|
+
preferredAppointmentMode.set(nextMode);
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (selectedSlot) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (effectiveRemoteOnly) {
|
|
181
|
+
applyAppointmentMode('REMOTE');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!remoteAvailable && appointmentMode === 'REMOTE') {
|
|
185
|
+
applyAppointmentMode('IN_PERSON');
|
|
186
|
+
}
|
|
187
|
+
}, [
|
|
188
|
+
effectiveRemoteOnly,
|
|
189
|
+
remoteAvailable,
|
|
190
|
+
appointmentMode,
|
|
191
|
+
selectedSlot,
|
|
192
|
+
applyAppointmentMode,
|
|
193
|
+
]);
|
|
194
|
+
|
|
114
195
|
useEffect(() => {
|
|
115
196
|
const profile = ProfileStorage.getProfileData();
|
|
116
197
|
if (ProfileStorage.isProfileUnlocked() && profile.email) {
|
|
@@ -146,6 +227,36 @@ export default function CheckoutModal({
|
|
|
146
227
|
}
|
|
147
228
|
}, [$globalCartState, needsBooking, $customer.leadId, internalState]);
|
|
148
229
|
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!isOpen || selectedSlot) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
internalState !== 'IDENTITY_EMAIL' &&
|
|
237
|
+
internalState !== 'IDENTITY_NEW_USER'
|
|
238
|
+
) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (effectiveRemoteOnly) {
|
|
243
|
+
applyAppointmentMode('REMOTE');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const next = pickInitialAppointmentMode(
|
|
248
|
+
appointmentConstraints,
|
|
249
|
+
preferredAppointmentMode.get()
|
|
250
|
+
);
|
|
251
|
+
applyAppointmentMode(next);
|
|
252
|
+
}, [
|
|
253
|
+
isOpen,
|
|
254
|
+
selectedSlot,
|
|
255
|
+
internalState,
|
|
256
|
+
appointmentConstraints,
|
|
257
|
+
applyAppointmentMode,
|
|
258
|
+
]);
|
|
259
|
+
|
|
149
260
|
const handleClose = async () => {
|
|
150
261
|
const redirect = internalState === 'SUCCESS';
|
|
151
262
|
const currentTraceId = transactionTraceId.get();
|
|
@@ -249,7 +360,8 @@ export default function CheckoutModal({
|
|
|
249
360
|
transactionTraceId.get(),
|
|
250
361
|
start.toISOString(),
|
|
251
362
|
end.toISOString(),
|
|
252
|
-
cartResourceIds
|
|
363
|
+
cartResourceIds,
|
|
364
|
+
appointmentModeRef.current
|
|
253
365
|
);
|
|
254
366
|
if (response && (response.success || response.status === 'PENDING')) {
|
|
255
367
|
setInternalState('SUMMARY');
|
|
@@ -313,9 +425,9 @@ export default function CheckoutModal({
|
|
|
313
425
|
headers: { 'Content-Type': 'application/json' },
|
|
314
426
|
body: JSON.stringify({
|
|
315
427
|
lines: enrichedCart
|
|
316
|
-
.filter((i) => i.
|
|
428
|
+
.filter((i) => i.variantId)
|
|
317
429
|
.map((i) => ({
|
|
318
|
-
merchandiseId: i.variantId
|
|
430
|
+
merchandiseId: i.variantId,
|
|
319
431
|
quantity: i.quantity || 1,
|
|
320
432
|
})),
|
|
321
433
|
email: $customer.email,
|
|
@@ -343,6 +455,13 @@ export default function CheckoutModal({
|
|
|
343
455
|
}
|
|
344
456
|
),
|
|
345
457
|
},
|
|
458
|
+
{
|
|
459
|
+
key: 'Appointment Mode',
|
|
460
|
+
value:
|
|
461
|
+
appointmentMode === 'REMOTE'
|
|
462
|
+
? 'Remote'
|
|
463
|
+
: 'In Person',
|
|
464
|
+
},
|
|
346
465
|
]
|
|
347
466
|
: []),
|
|
348
467
|
],
|
|
@@ -462,13 +581,75 @@ export default function CheckoutModal({
|
|
|
462
581
|
</form>
|
|
463
582
|
)}
|
|
464
583
|
{internalState === 'BOOKING' && (
|
|
465
|
-
<
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
584
|
+
<div className="space-y-6">
|
|
585
|
+
{needsBooking && (inPersonAvailable || remoteAvailable) && (
|
|
586
|
+
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
|
587
|
+
<p className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
588
|
+
Appointment Mode
|
|
589
|
+
</p>
|
|
590
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
591
|
+
{inPersonAvailable && (
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
onClick={() => {
|
|
595
|
+
applyAppointmentMode('IN_PERSON');
|
|
596
|
+
}}
|
|
597
|
+
className={`rounded-md px-3 py-2 text-sm font-bold ${
|
|
598
|
+
appointmentMode === 'IN_PERSON'
|
|
599
|
+
? 'bg-black text-white'
|
|
600
|
+
: 'border border-gray-300 bg-white text-gray-700'
|
|
601
|
+
}`}
|
|
602
|
+
>
|
|
603
|
+
In Person
|
|
604
|
+
</button>
|
|
605
|
+
)}
|
|
606
|
+
{remoteAvailable && (
|
|
607
|
+
<button
|
|
608
|
+
type="button"
|
|
609
|
+
onClick={() => {
|
|
610
|
+
applyAppointmentMode('REMOTE');
|
|
611
|
+
}}
|
|
612
|
+
className={`rounded-md px-3 py-2 text-sm font-bold ${
|
|
613
|
+
appointmentMode === 'REMOTE'
|
|
614
|
+
? 'bg-black text-white'
|
|
615
|
+
: 'border border-gray-300 bg-white text-gray-700'
|
|
616
|
+
}`}
|
|
617
|
+
>
|
|
618
|
+
Remote
|
|
619
|
+
</button>
|
|
620
|
+
)}
|
|
621
|
+
</div>
|
|
622
|
+
{effectiveRemoteOnly && (
|
|
623
|
+
<p className="mt-2 text-xs font-bold text-gray-500">
|
|
624
|
+
Remote mode is required by shop or service settings.
|
|
625
|
+
</p>
|
|
626
|
+
)}
|
|
627
|
+
</div>
|
|
628
|
+
)}
|
|
629
|
+
<NativeBookingCalendar
|
|
630
|
+
totalDurationMinutes={totalDuration}
|
|
631
|
+
onSlotSelected={handleSlotSelection}
|
|
632
|
+
/>
|
|
633
|
+
</div>
|
|
469
634
|
)}
|
|
470
635
|
{internalState === 'SUMMARY' && (
|
|
471
636
|
<div className="space-y-6">
|
|
637
|
+
{needsBooking && (
|
|
638
|
+
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
|
639
|
+
<p className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
640
|
+
Appointment Mode
|
|
641
|
+
</p>
|
|
642
|
+
<p className="mt-3 text-sm font-bold text-gray-900">
|
|
643
|
+
{appointmentMode === 'REMOTE' ? 'Remote' : 'In Person'}
|
|
644
|
+
</p>
|
|
645
|
+
{effectiveRemoteOnly && (
|
|
646
|
+
<p className="mt-2 text-xs font-bold text-gray-500">
|
|
647
|
+
Remote mode is required by shop or service settings.
|
|
648
|
+
</p>
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
)}
|
|
652
|
+
|
|
472
653
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-6">
|
|
473
654
|
<h3 className="mb-4 font-bold text-gray-900">
|
|
474
655
|
Order Summary
|
|
@@ -492,6 +673,11 @@ export default function CheckoutModal({
|
|
|
492
673
|
<p className="text-xs font-bold uppercase text-gray-500">
|
|
493
674
|
Appointment
|
|
494
675
|
</p>
|
|
676
|
+
<p className="mt-1 text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
677
|
+
{appointmentMode === 'REMOTE'
|
|
678
|
+
? 'Remote'
|
|
679
|
+
: 'In Person'}
|
|
680
|
+
</p>
|
|
495
681
|
<p className="mt-1 text-sm font-bold text-gray-900">
|
|
496
682
|
{selectedSlot.start.toLocaleDateString('en-US', {
|
|
497
683
|
timeZone: shopTimeZone,
|
|
@@ -5,9 +5,14 @@ import {
|
|
|
5
5
|
cartStore,
|
|
6
6
|
modalState,
|
|
7
7
|
transactionTraceId,
|
|
8
|
+
getCartItemKey,
|
|
8
9
|
} from '@/stores/shopify';
|
|
9
10
|
import { bookingHelpers } from '@/utils/api/bookingHelpers';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
RESTRICTION_MESSAGES,
|
|
13
|
+
calculateCartDuration,
|
|
14
|
+
} from '@/utils/customHelpers';
|
|
15
|
+
import { wouldCartHaveImpossibleRemoteMix } from '@/utils/booking/appointmentMode';
|
|
11
16
|
import type { ResourceNode } from '@/types/compositorTypes';
|
|
12
17
|
import type { CartItemState } from '@/stores/shopify';
|
|
13
18
|
import type { BrandConfigState } from '@/types/tractstack';
|
|
@@ -34,12 +39,7 @@ export default function ShopifyCartManager({
|
|
|
34
39
|
return;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
const key =
|
|
38
|
-
actionItem.variantId ||
|
|
39
|
-
`${actionItem.resourceId}_${actionItem.variantIdShipped || 'null'}_${
|
|
40
|
-
actionItem.variantIdPickup || 'null'
|
|
41
|
-
}`;
|
|
42
|
-
|
|
42
|
+
const key = getCartItemKey(actionItem);
|
|
43
43
|
const currentCart = cartStore.get();
|
|
44
44
|
const currentItem = currentCart[key];
|
|
45
45
|
const currentQty = currentItem?.quantity || 0;
|
|
@@ -74,19 +74,19 @@ export default function ShopifyCartManager({
|
|
|
74
74
|
if (currentItem?.boundResourceId || actionItem.boundResourceId) {
|
|
75
75
|
const boundId =
|
|
76
76
|
currentItem?.boundResourceId || actionItem.boundResourceId;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
77
|
+
if (boundId) {
|
|
78
|
+
const serviceKey = getCartItemKey({ resourceId: boundId });
|
|
79
|
+
const serviceItem = nextCart[serviceKey];
|
|
80
|
+
if (serviceItem) {
|
|
81
|
+
const newServiceQty = Math.max(0, serviceItem.quantity - 1);
|
|
82
|
+
if (newServiceQty === 0) {
|
|
83
|
+
delete nextCart[serviceKey];
|
|
84
|
+
} else {
|
|
85
|
+
nextCart[serviceKey] = {
|
|
86
|
+
...serviceItem,
|
|
87
|
+
quantity: newServiceQty,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
}
|
|
@@ -113,80 +113,83 @@ export default function ShopifyCartManager({
|
|
|
113
113
|
nextCart[key] = newItem;
|
|
114
114
|
|
|
115
115
|
if (newItem.boundResourceId) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
);
|
|
116
|
+
const serviceKey = getCartItemKey({
|
|
117
|
+
resourceId: newItem.boundResourceId,
|
|
118
|
+
});
|
|
119
|
+
const serviceItem = nextCart[serviceKey];
|
|
119
120
|
|
|
120
|
-
if (
|
|
121
|
-
const [serviceKey, serviceItem] = serviceEntry;
|
|
121
|
+
if (serviceItem) {
|
|
122
122
|
nextCart[serviceKey] = {
|
|
123
123
|
...serviceItem,
|
|
124
124
|
quantity: serviceItem.quantity + 1,
|
|
125
125
|
};
|
|
126
126
|
} else {
|
|
127
|
-
nextCart[
|
|
127
|
+
nextCart[serviceKey] = {
|
|
128
128
|
resourceId: newItem.boundResourceId,
|
|
129
129
|
quantity: 1,
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
Object.values(nextCart).forEach((item) => {
|
|
136
|
-
const res = resources.find((r) => r.id === item.resourceId);
|
|
137
|
-
if (res?.optionsPayload?.needsBooking || item.boundResourceId) {
|
|
138
|
-
rawDuration +=
|
|
139
|
-
(res?.optionsPayload?.bookingLengthMinutes || 0) *
|
|
140
|
-
(item.quantity || 1);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
const interval = 15;
|
|
145
|
-
const snappedDuration = Math.ceil(rawDuration / interval) * interval;
|
|
146
|
-
|
|
147
|
-
const dynamicMax = brandConfig?.scheduling?.maxLengthMinutes || 180;
|
|
148
|
-
if (snappedDuration > dynamicMax) {
|
|
134
|
+
if (wouldCartHaveImpossibleRemoteMix(nextCart, resources)) {
|
|
149
135
|
modalState.set({
|
|
150
136
|
isOpen: true,
|
|
151
137
|
type: 'restriction',
|
|
152
|
-
title: '
|
|
153
|
-
message: RESTRICTION_MESSAGES.
|
|
138
|
+
title: 'Incompatible Booking Modes',
|
|
139
|
+
message: RESTRICTION_MESSAGES.INCOMPATIBLE_REMOTE,
|
|
154
140
|
});
|
|
155
141
|
} else {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
142
|
+
const rawDuration = calculateCartDuration(nextCart, resources);
|
|
143
|
+
|
|
144
|
+
const interval = 15;
|
|
145
|
+
const snappedDuration = Math.ceil(rawDuration / interval) * interval;
|
|
146
|
+
|
|
147
|
+
const dynamicMax = brandConfig?.scheduling?.maxLengthMinutes || 180;
|
|
148
|
+
if (snappedDuration > dynamicMax) {
|
|
149
|
+
modalState.set({
|
|
150
|
+
isOpen: true,
|
|
151
|
+
type: 'restriction',
|
|
152
|
+
title: 'Appointment Length Limit Reached',
|
|
153
|
+
message: RESTRICTION_MESSAGES.MAX_DURATION(dynamicMax),
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
cartStore.set(nextCart);
|
|
157
|
+
|
|
158
|
+
if (!actionItem.suppressModal) {
|
|
159
|
+
let targetResource = resource;
|
|
160
|
+
if (newItem.boundResourceId) {
|
|
161
|
+
const bound = resources.find(
|
|
162
|
+
(r) => r.id === newItem.boundResourceId
|
|
163
|
+
);
|
|
164
|
+
if (bound) {
|
|
165
|
+
targetResource = bound;
|
|
166
|
+
}
|
|
166
167
|
}
|
|
167
|
-
}
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
169
|
+
if (
|
|
170
|
+
targetResource.categorySlug === 'service' ||
|
|
171
|
+
targetResource.optionsPayload?.needsBooking
|
|
172
|
+
) {
|
|
173
|
+
modalState.set({
|
|
174
|
+
isOpen: true,
|
|
175
|
+
type: 'success',
|
|
176
|
+
title: 'Booking Required',
|
|
177
|
+
message: RESTRICTION_MESSAGES.BOOKING(
|
|
178
|
+
(
|
|
179
|
+
targetResource.optionsPayload?.bookingLengthMinutes || 0
|
|
180
|
+
).toString()
|
|
181
|
+
),
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
modalState.set({
|
|
185
|
+
isOpen: true,
|
|
186
|
+
type: 'success',
|
|
187
|
+
title: 'Added to Cart',
|
|
188
|
+
message: RESTRICTION_MESSAGES.DEFAULT_ADD(
|
|
189
|
+
targetResource.title
|
|
190
|
+
),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
195
|
}
|
|
@@ -194,7 +197,7 @@ export default function ShopifyCartManager({
|
|
|
194
197
|
addQueue.set(remaining);
|
|
195
198
|
}
|
|
196
199
|
}
|
|
197
|
-
}, [queue, resources]);
|
|
200
|
+
}, [queue, resources, brandConfig]);
|
|
198
201
|
|
|
199
202
|
return null;
|
|
200
203
|
}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
CART_STATES,
|
|
7
7
|
isShopifyHandoff,
|
|
8
8
|
} from '@/stores/shopify';
|
|
9
|
-
import { calculateCartDuration } from '@/utils/customHelpers';
|
|
10
9
|
import type { ResourceNode } from '@/types/compositorTypes';
|
|
11
10
|
|
|
12
11
|
interface ShopifyCheckoutProps {
|
|
@@ -36,47 +35,19 @@ export default function ShopifyCheckout({
|
|
|
36
35
|
try {
|
|
37
36
|
const cartItems = Object.values(cart);
|
|
38
37
|
|
|
39
|
-
// Determine if we are in "Pickup Mode" (Service exists in cart)
|
|
40
|
-
const duration = calculateCartDuration(cart, resources);
|
|
41
|
-
const isPickupMode = duration > 0;
|
|
42
|
-
|
|
43
38
|
const lines = cartItems
|
|
44
39
|
.map((item) => {
|
|
45
|
-
//
|
|
40
|
+
// Resolve the ResourceNode for this item
|
|
46
41
|
const resource = resources.find((r) => r.id === item.resourceId);
|
|
47
42
|
|
|
48
43
|
if (!resource) {
|
|
49
44
|
return null;
|
|
50
45
|
}
|
|
51
46
|
|
|
52
|
-
//
|
|
53
|
-
const
|
|
54
|
-
? item.variantIdPickup
|
|
55
|
-
: item.variantIdShipped;
|
|
56
|
-
|
|
57
|
-
// 3. Establish the specific ID to use from the cart state
|
|
58
|
-
let merchandiseId =
|
|
59
|
-
activeVariantId || item.variantIdPickup || item.variantId;
|
|
60
|
-
|
|
61
|
-
// 4. FALLBACK LOGIC (Mirrors Cart.tsx)
|
|
62
|
-
// If no specific variant ID is saved on the cart item,
|
|
63
|
-
// look up the Default Variant from the Resource data.
|
|
64
|
-
if (!merchandiseId && resource?.optionsPayload?.shopifyData) {
|
|
65
|
-
try {
|
|
66
|
-
const product = JSON.parse(resource.optionsPayload.shopifyData);
|
|
67
|
-
// If the product has variants, default to the first one
|
|
68
|
-
if (product.variants && product.variants.length > 0) {
|
|
69
|
-
merchandiseId = product.variants[0].id;
|
|
70
|
-
}
|
|
71
|
-
} catch (e) {
|
|
72
|
-
console.warn(
|
|
73
|
-
'ShopifyCheckout: Failed to parse shopifyData for fallback',
|
|
74
|
-
item.resourceId
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
47
|
+
// Use the explicitly sanitized variant ID from the cart state
|
|
48
|
+
const merchandiseId = item.variantId;
|
|
78
49
|
|
|
79
|
-
// If we
|
|
50
|
+
// If we have no ID, we cannot add this item to the Shopify cart.
|
|
80
51
|
if (!merchandiseId) return null;
|
|
81
52
|
|
|
82
53
|
return {
|
|
@@ -115,8 +115,9 @@ function ProductCard({ resource, allServices }: ProductCardProps) {
|
|
|
115
115
|
{
|
|
116
116
|
resourceId: resource.id,
|
|
117
117
|
gid: product?.id,
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
variantId: !hasModeOption ? variantShipped?.id : undefined,
|
|
119
|
+
variantIdShipped: hasModeOption ? variantShipped?.id : undefined,
|
|
120
|
+
variantIdPickup: hasModeOption ? variantPickup?.id : undefined,
|
|
120
121
|
action: 'add',
|
|
121
122
|
boundResourceId: boundServiceResource?.id,
|
|
122
123
|
},
|
|
@@ -214,14 +215,25 @@ function ProductCard({ resource, allServices }: ProductCardProps) {
|
|
|
214
215
|
);
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
const HEX_BG_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
219
|
+
|
|
217
220
|
export default function ShopifyProductGrid({ resources = {}, options }: Props) {
|
|
218
221
|
let products = resources['product'] || [];
|
|
219
222
|
const services = resources['service'] || [];
|
|
220
223
|
|
|
221
224
|
let group = '';
|
|
225
|
+
let title = '';
|
|
226
|
+
let bgColor = '#f9f9f9';
|
|
222
227
|
try {
|
|
223
228
|
const parsedOptions = JSON.parse(options?.params?.options || '{}');
|
|
224
|
-
group = parsedOptions.group
|
|
229
|
+
group = typeof parsedOptions.group === 'string' ? parsedOptions.group : '';
|
|
230
|
+
if (typeof parsedOptions.title === 'string') {
|
|
231
|
+
title = parsedOptions.title.trim();
|
|
232
|
+
}
|
|
233
|
+
const rawBg = parsedOptions.bgColor;
|
|
234
|
+
if (typeof rawBg === 'string' && HEX_BG_RE.test(rawBg)) {
|
|
235
|
+
bgColor = rawBg;
|
|
236
|
+
}
|
|
225
237
|
} catch (e) {}
|
|
226
238
|
|
|
227
239
|
if (group) {
|
|
@@ -230,18 +242,34 @@ export default function ShopifyProductGrid({ resources = {}, options }: Props) {
|
|
|
230
242
|
|
|
231
243
|
if (products.length === 0) return null;
|
|
232
244
|
|
|
233
|
-
// Grid with increased gaps matching the design
|
|
234
245
|
return (
|
|
235
|
-
<
|
|
236
|
-
<div
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
246
|
+
<section className="w-full">
|
|
247
|
+
<div
|
|
248
|
+
className="flex w-full flex-col gap-12 p-12 md:gap-14 md:p-12 xl:gap-16 xl:p-16"
|
|
249
|
+
style={{ backgroundColor: bgColor }}
|
|
250
|
+
>
|
|
251
|
+
{title ? (
|
|
252
|
+
<header className="max-w-4xl">
|
|
253
|
+
<h3
|
|
254
|
+
className="mb-6 text-balance font-action text-2xl font-bold md:text-3xl xl:text-4xl"
|
|
255
|
+
style={{ color: '#2d2923' }}
|
|
256
|
+
>
|
|
257
|
+
{title}
|
|
258
|
+
</h3>
|
|
259
|
+
</header>
|
|
260
|
+
) : null}
|
|
261
|
+
<section className="w-full">
|
|
262
|
+
<div className="grid grid-cols-2 gap-x-8 gap-y-16 md:gap-x-12 xl:grid-cols-3">
|
|
263
|
+
{products.map((resource) => (
|
|
264
|
+
<ProductCard
|
|
265
|
+
key={resource.id}
|
|
266
|
+
resource={resource}
|
|
267
|
+
allServices={services}
|
|
268
|
+
/>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
</section>
|
|
244
272
|
</div>
|
|
245
|
-
</
|
|
273
|
+
</section>
|
|
246
274
|
);
|
|
247
275
|
}
|