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.
Files changed (73) hide show
  1. package/bin/create-tractstack.js +3 -3
  2. package/dist/index.js +69 -11
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +99 -19
  5. package/templates/custom/shopify/CheckoutModal.tsx +196 -10
  6. package/templates/custom/shopify/ShopifyCartManager.tsx +79 -76
  7. package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
  8. package/templates/custom/shopify/ShopifyProductGrid.tsx +42 -14
  9. package/templates/custom/shopify/ShopifyServiceList.tsx +94 -50
  10. package/templates/custom/shopify/cart.astro +7 -1
  11. package/templates/src/components/Footer.astro +2 -2
  12. package/templates/src/components/Header.astro +17 -9
  13. package/templates/src/components/Menu.tsx +157 -135
  14. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  15. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
  16. package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
  17. package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
  18. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
  19. package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
  20. package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
  21. package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
  22. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
  23. package/templates/src/components/edit/ToolBar.tsx +2 -1
  24. package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
  25. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
  26. package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
  27. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  28. package/templates/src/components/edit/state/SaveModal.tsx +1 -1
  29. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
  30. package/templates/src/components/form/DateTimeInput.tsx +10 -3
  31. package/templates/src/components/form/FileUpload.tsx +11 -5
  32. package/templates/src/components/form/NumberInput.tsx +2 -2
  33. package/templates/src/components/form/advanced/APIConfigSection.tsx +221 -39
  34. package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
  35. package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
  36. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
  37. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +16 -8
  38. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
  39. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
  40. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +2 -2
  41. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +79 -51
  42. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
  43. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -0
  44. package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
  45. package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
  46. package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
  47. package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
  48. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +1 -8
  49. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +118 -14
  50. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
  51. package/templates/src/constants.ts +2 -0
  52. package/templates/src/layouts/Layout.astro +8 -5
  53. package/templates/src/pages/api/google/oauth/callback.ts +50 -0
  54. package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
  55. package/templates/src/pages/api/google/oauth/start.ts +32 -0
  56. package/templates/src/pages/api/google/oauth/status.ts +32 -0
  57. package/templates/src/pages/privacy.astro +84 -0
  58. package/templates/src/pages/terms.astro +47 -0
  59. package/templates/src/stores/shopify.ts +21 -0
  60. package/templates/src/types/formTypes.ts +4 -2
  61. package/templates/src/types/tractstack.ts +35 -2
  62. package/templates/src/utils/api/advancedHelpers.ts +16 -0
  63. package/templates/src/utils/api/bookingHelpers.ts +3 -1
  64. package/templates/src/utils/api/brandConfig.ts +2 -0
  65. package/templates/src/utils/api/brandHelpers.ts +24 -1
  66. package/templates/src/utils/api/emailHelpers.ts +105 -0
  67. package/templates/src/utils/booking/appointmentMode.ts +135 -0
  68. package/templates/src/utils/customHelpers.ts +2 -0
  69. package/templates/src/utils/tenantResolver.ts +1 -1
  70. package/utils/inject-files.ts +63 -5
  71. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
  72. package/templates/src/utils/actions/actionButton.ts +0 -103
  73. package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
@@ -1,4 +1,11 @@
1
- import { useState, useEffect, useMemo, type FormEvent } from 'react';
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 === (item.variantId || item.gid)
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) => !!(item.gid || item.variantId)),
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.gid || i.variantId)
428
+ .filter((i) => i.variantId)
317
429
  .map((i) => ({
318
- merchandiseId: i.variantId || i.gid,
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
- <NativeBookingCalendar
466
- totalDurationMinutes={totalDuration}
467
- onSlotSelected={handleSlotSelection}
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 { RESTRICTION_MESSAGES } from '@/utils/customHelpers';
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
- const serviceEntry = Object.entries(nextCart).find(
78
- ([_, item]) => item.resourceId === boundId
79
- );
80
- if (serviceEntry) {
81
- const [serviceKey, serviceItem] = serviceEntry;
82
- const newServiceQty = Math.max(0, serviceItem.quantity - 1);
83
- if (newServiceQty === 0) {
84
- delete nextCart[serviceKey];
85
- } else {
86
- nextCart[serviceKey] = {
87
- ...serviceItem,
88
- quantity: newServiceQty,
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 serviceEntry = Object.entries(nextCart).find(
117
- ([_, item]) => item.resourceId === newItem.boundResourceId
118
- );
116
+ const serviceKey = getCartItemKey({
117
+ resourceId: newItem.boundResourceId,
118
+ });
119
+ const serviceItem = nextCart[serviceKey];
119
120
 
120
- if (serviceEntry) {
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[`temp_service_${newItem.boundResourceId}`] = {
127
+ nextCart[serviceKey] = {
128
128
  resourceId: newItem.boundResourceId,
129
129
  quantity: 1,
130
130
  };
131
131
  }
132
132
  }
133
133
 
134
- let rawDuration = 0;
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: 'Appointment Length Limit Reached',
153
- message: RESTRICTION_MESSAGES.MAX_DURATION(dynamicMax),
138
+ title: 'Incompatible Booking Modes',
139
+ message: RESTRICTION_MESSAGES.INCOMPATIBLE_REMOTE,
154
140
  });
155
141
  } 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;
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
- 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(targetResource.title),
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
- // 1. Resolve the ResourceNode for this item
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
- // 2. Determine the preferred Variant ID based on mode
53
- const activeVariantId = isPickupMode
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 still have no ID, we cannot add this item to the Shopify cart.
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
- variantIdShipped: variantShipped?.id,
119
- variantIdPickup: variantPickup?.id,
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
- <div className="mx-auto max-w-7xl px-4 md:px-8">
236
- <div className="grid grid-cols-2 gap-x-8 gap-y-16 md:gap-x-12 xl:grid-cols-3">
237
- {products.map((resource) => (
238
- <ProductCard
239
- key={resource.id}
240
- resource={resource}
241
- allServices={services}
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
- </div>
273
+ </section>
246
274
  );
247
275
  }