astro-tractstack 2.3.0 → 2.3.1

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 (63) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +94 -16
  4. package/package.json +2 -2
  5. package/templates/custom/minimal/CodeHook.astro +10 -2
  6. package/templates/custom/shopify/Cart.tsx +100 -73
  7. package/templates/custom/shopify/CheckoutModal.tsx +509 -120
  8. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  9. package/templates/custom/shopify/ShopifyCartManager.tsx +92 -37
  10. package/templates/custom/shopify/ShopifyProductGrid.tsx +139 -173
  11. package/templates/custom/shopify/ShopifyServiceList.tsx +20 -3
  12. package/templates/custom/with-examples/CodeHook.astro +10 -2
  13. package/templates/src/components/Footer.astro +4 -4
  14. package/templates/src/components/Header.astro +9 -3
  15. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  16. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  17. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  18. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  19. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  20. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  21. package/templates/src/components/form/advanced/APIConfigSection.tsx +244 -2
  22. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  23. package/templates/src/components/storykeep/Dashboard.tsx +1 -1
  24. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +253 -110
  25. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  26. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  27. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  28. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
  29. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +9 -5
  30. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
  31. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  32. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
  33. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -0
  34. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  35. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  36. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  37. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  38. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  39. package/templates/src/pages/api/booking/availability.ts +72 -0
  40. package/templates/src/pages/api/booking/cancel.ts +73 -0
  41. package/templates/src/pages/api/booking/confirm.ts +82 -0
  42. package/templates/src/pages/api/booking/hold.ts +75 -0
  43. package/templates/src/pages/api/booking/list.ts +66 -0
  44. package/templates/src/pages/api/booking/metrics.ts +60 -0
  45. package/templates/src/pages/api/booking/release.ts +76 -0
  46. package/templates/src/pages/api/sandbox.ts +2 -2
  47. package/templates/src/pages/api/shopify/createCart.ts +4 -8
  48. package/templates/src/pages/api/shopify/getProducts.ts +15 -15
  49. package/templates/src/pages/storykeep/login.astro +21 -14
  50. package/templates/src/stores/shopify.ts +81 -25
  51. package/templates/src/types/tractstack.ts +54 -0
  52. package/templates/src/utils/api/advancedConfig.ts +2 -0
  53. package/templates/src/utils/api/advancedHelpers.ts +40 -3
  54. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  55. package/templates/src/utils/api/brandHelpers.ts +10 -0
  56. package/templates/src/utils/auth.ts +29 -9
  57. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  58. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  59. package/templates/src/utils/customHelpers.ts +0 -21
  60. package/templates/src/utils/profileStorage.ts +5 -0
  61. package/templates/src/utils/tenantResolver.ts +2 -1
  62. package/utils/inject-files.ts +82 -4
  63. package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
@@ -0,0 +1,375 @@
1
+ import { useState, useEffect, useMemo, useCallback } from 'react';
2
+ import { bookingHelpers } from '@/utils/api/bookingHelpers';
3
+
4
+ interface TimeBlock {
5
+ start: string;
6
+ end: string;
7
+ }
8
+
9
+ interface SchedulingConfig {
10
+ timezone: string;
11
+ bufferGapsMinutes: number;
12
+ businessHours: Record<string, TimeBlock>;
13
+ unavailableHours: TimeBlock[];
14
+ }
15
+
16
+ interface Booking {
17
+ id: string;
18
+ startTime: string;
19
+ endTime: string;
20
+ status: string;
21
+ }
22
+
23
+ interface NativeBookingCalendarProps {
24
+ totalDurationMinutes: number;
25
+ onSlotSelected: (start: Date, end: Date, timeZone: string) => void;
26
+ }
27
+
28
+ function getUtcFromWallTime(wallTimeIso: string, timeZone: string): Date {
29
+ const [datePart, timePart] = wallTimeIso.split('T');
30
+ const [year, month, day] = datePart.split('-').map(Number);
31
+ const [hour, minute] = timePart.split(':').map(Number);
32
+
33
+ const pad = (n: number) => n.toString().padStart(2, '0');
34
+ const pseudoUtc = new Date(
35
+ `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:00Z`
36
+ );
37
+
38
+ const formatter = new Intl.DateTimeFormat('en-US', {
39
+ timeZone,
40
+ year: 'numeric',
41
+ month: '2-digit',
42
+ day: '2-digit',
43
+ hour: '2-digit',
44
+ minute: '2-digit',
45
+ hour12: false,
46
+ });
47
+
48
+ const parts = formatter.formatToParts(pseudoUtc);
49
+ const getPart = (type: string) => parts.find((p) => p.type === type)?.value;
50
+
51
+ const tzDate = new Date(
52
+ `${getPart('year')}-${getPart('month')}-${getPart('day')}T${getPart('hour')}:${getPart('minute')}:00Z`
53
+ );
54
+ const diff = pseudoUtc.getTime() - tzDate.getTime();
55
+
56
+ return new Date(pseudoUtc.getTime() + diff);
57
+ }
58
+
59
+ export const NativeBookingCalendar = ({
60
+ totalDurationMinutes,
61
+ onSlotSelected,
62
+ }: NativeBookingCalendarProps) => {
63
+ const [selectedDate, setSelectedDate] = useState<Date>(new Date());
64
+ const [availability, setAvailability] = useState<{
65
+ bookings: Booking[];
66
+ scheduling: SchedulingConfig;
67
+ } | null>(null);
68
+ const [loading, setLoading] = useState(true);
69
+ const [error, setError] = useState<string | null>(null);
70
+ const [selectedSlot, setSelectedSlot] = useState<{
71
+ start: Date;
72
+ end: Date;
73
+ } | null>(null);
74
+ const [isAtCapacity, setIsAtCapacity] = useState(false);
75
+
76
+ const checkDayHasSlots = useCallback(
77
+ (date: Date, scheduling: SchedulingConfig, bookings: Booking[]) => {
78
+ const dayName = date
79
+ .toLocaleDateString('en-US', { weekday: 'long' })
80
+ .toLowerCase();
81
+
82
+ const businessHours = scheduling.businessHours?.[dayName];
83
+ if (!businessHours) return false;
84
+
85
+ const baseDateIso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T`;
86
+ let iterUtc = getUtcFromWallTime(
87
+ `${baseDateIso}${businessHours.start}`,
88
+ scheduling.timezone
89
+ );
90
+ const dayEndUtc = getUtcFromWallTime(
91
+ `${baseDateIso}${businessHours.end}`,
92
+ scheduling.timezone
93
+ );
94
+ const bufferGap = scheduling.bufferGapsMinutes || 0;
95
+ const now = new Date();
96
+
97
+ while (
98
+ iterUtc.getTime() + totalDurationMinutes * 60000 <=
99
+ dayEndUtc.getTime()
100
+ ) {
101
+ const slotStart = new Date(iterUtc);
102
+ const slotEnd = new Date(
103
+ iterUtc.getTime() + totalDurationMinutes * 60000
104
+ );
105
+ const slotEndWithBuffer = new Date(
106
+ slotEnd.getTime() + bufferGap * 60000
107
+ );
108
+
109
+ if (slotStart >= now) {
110
+ const isBlocked =
111
+ bookings.some((b) => {
112
+ const bStart = new Date(b.startTime);
113
+ const bEnd = new Date(b.endTime);
114
+ return slotStart < bEnd && slotEndWithBuffer > bStart;
115
+ }) ||
116
+ scheduling.unavailableHours.some((u) => {
117
+ const uStart = new Date(u.start);
118
+ const uEnd = new Date(u.end);
119
+ return slotStart < uEnd && slotEndWithBuffer > uStart;
120
+ });
121
+
122
+ if (!isBlocked) return true;
123
+ }
124
+ iterUtc = new Date(iterUtc.getTime() + 15 * 60000);
125
+ }
126
+ return false;
127
+ },
128
+ [totalDurationMinutes]
129
+ );
130
+
131
+ useEffect(() => {
132
+ const fetchAvailability = async () => {
133
+ try {
134
+ setLoading(true);
135
+ const start = new Date();
136
+ const end = new Date();
137
+ // Capped Front-Load: 30 days forward scanning limits infinite loop risk while finding first available opening
138
+ end.setDate(end.getDate() + 30);
139
+
140
+ const response = await bookingHelpers.getAvailability(
141
+ start.toISOString(),
142
+ end.toISOString()
143
+ );
144
+
145
+ if (response && response.scheduling) {
146
+ setAvailability({
147
+ bookings: response.bookings || [],
148
+ scheduling: response.scheduling,
149
+ });
150
+
151
+ let foundAvailable = false;
152
+ let currentCheck = new Date();
153
+
154
+ for (let i = 0; i <= 30; i++) {
155
+ if (
156
+ checkDayHasSlots(
157
+ currentCheck,
158
+ response.scheduling,
159
+ response.bookings || []
160
+ )
161
+ ) {
162
+ setSelectedDate(new Date(currentCheck));
163
+ foundAvailable = true;
164
+ break;
165
+ }
166
+ currentCheck.setDate(currentCheck.getDate() + 1);
167
+ }
168
+
169
+ if (!foundAvailable) {
170
+ setIsAtCapacity(true);
171
+ }
172
+ setError(null);
173
+ } else {
174
+ setError(response.message || 'Failed to load availability.');
175
+ }
176
+ } catch (err) {
177
+ setError('A network error occurred while fetching availability.');
178
+ } finally {
179
+ setLoading(false);
180
+ }
181
+ };
182
+
183
+ fetchAvailability();
184
+ }, [checkDayHasSlots]);
185
+
186
+ const slots = useMemo(() => {
187
+ if (!availability || !selectedDate) return [];
188
+ const shopTz = availability.scheduling.timezone;
189
+
190
+ const dayName = selectedDate
191
+ .toLocaleDateString('en-US', { weekday: 'long' })
192
+ .toLowerCase();
193
+
194
+ const businessHours = availability.scheduling.businessHours?.[dayName];
195
+ if (!businessHours) return [];
196
+
197
+ const baseDateIso = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}T`;
198
+ let iterUtc = getUtcFromWallTime(
199
+ `${baseDateIso}${businessHours.start}`,
200
+ shopTz
201
+ );
202
+ const dayEndUtc = getUtcFromWallTime(
203
+ `${baseDateIso}${businessHours.end}`,
204
+ shopTz
205
+ );
206
+ const bufferGap = availability.scheduling.bufferGapsMinutes || 0;
207
+ const now = new Date();
208
+ const daySlots = [];
209
+
210
+ while (
211
+ iterUtc.getTime() + totalDurationMinutes * 60000 <=
212
+ dayEndUtc.getTime()
213
+ ) {
214
+ const slotStart = new Date(iterUtc);
215
+ const slotEnd = new Date(
216
+ iterUtc.getTime() + totalDurationMinutes * 60000
217
+ );
218
+ const slotEndWithBuffer = new Date(slotEnd.getTime() + bufferGap * 60000);
219
+
220
+ const isPast = slotStart < now;
221
+ const isBlocked =
222
+ isPast ||
223
+ availability.bookings.some((b) => {
224
+ const bStart = new Date(b.startTime);
225
+ const bEnd = new Date(b.endTime);
226
+ return slotStart < bEnd && slotEndWithBuffer > bStart;
227
+ }) ||
228
+ availability.scheduling.unavailableHours.some((u) => {
229
+ const uStart = new Date(u.start);
230
+ const uEnd = new Date(u.end);
231
+ return slotStart < uEnd && slotEndWithBuffer > uStart;
232
+ });
233
+
234
+ daySlots.push({
235
+ start: slotStart,
236
+ end: slotEnd,
237
+ isAvailable: !isBlocked,
238
+ });
239
+ iterUtc = new Date(iterUtc.getTime() + 15 * 60000);
240
+ }
241
+ return daySlots;
242
+ }, [availability, selectedDate, totalDurationMinutes]);
243
+
244
+ const handleSlotClick = (start: Date, end: Date) => {
245
+ setSelectedSlot({ start, end });
246
+ if (availability?.scheduling.timezone) {
247
+ onSlotSelected(start, end, availability.scheduling.timezone);
248
+ }
249
+ };
250
+
251
+ const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
252
+ const shopTz = availability?.scheduling?.timezone;
253
+ const isDiffTz = Boolean(userTz && shopTz && userTz !== shopTz);
254
+
255
+ if (loading)
256
+ return (
257
+ <div className="p-8 text-center text-gray-500">
258
+ Loading availability...
259
+ </div>
260
+ );
261
+ if (error) return <div className="p-8 text-center text-red-500">{error}</div>;
262
+
263
+ if (isAtCapacity) {
264
+ return (
265
+ <div className="flex flex-col space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-8 text-center">
266
+ <h3 className="text-lg font-bold text-gray-900">At Capacity</h3>
267
+ <p className="text-gray-600">
268
+ We are currently fully booked for the next 30 days! Please check back
269
+ soon as cancellations do happen, or contact us directly.
270
+ </p>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ const todayStr = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`;
276
+
277
+ return (
278
+ <div className="flex flex-col space-y-6">
279
+ <div className="flex items-center justify-between border-b pb-4">
280
+ <h3 className="text-lg font-bold text-gray-900">
281
+ Select an Appointment
282
+ </h3>
283
+ <div className="text-sm text-gray-500">
284
+ Duration: {totalDurationMinutes} mins
285
+ </div>
286
+ </div>
287
+
288
+ {isDiffTz && (
289
+ <div className="rounded-md border border-yellow-200 bg-yellow-50 p-4">
290
+ <p className="text-sm text-yellow-800">
291
+ <strong>Please Note:</strong> Appointments are booked in{' '}
292
+ <strong>{shopTz}</strong> time. Your local timezone is {userTz}.
293
+ </p>
294
+ </div>
295
+ )}
296
+
297
+ <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
298
+ <div className="space-y-4">
299
+ <label className="block text-sm text-gray-700">Date</label>
300
+ <input
301
+ type="date"
302
+ min={todayStr}
303
+ value={`${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`}
304
+ onChange={(e) => {
305
+ const [y, m, d] = e.target.value.split('-');
306
+ setSelectedDate(
307
+ new Date(parseInt(y), parseInt(m) - 1, parseInt(d))
308
+ );
309
+ }}
310
+ className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
311
+ />
312
+ </div>
313
+
314
+ <div className="space-y-4">
315
+ <label className="block text-sm text-gray-700">Available Times</label>
316
+ <div className="grid max-h-64 grid-cols-2 gap-2 overflow-y-auto pr-2">
317
+ {slots.length > 0 ? (
318
+ slots.map((slot, i) => (
319
+ <button
320
+ type="button"
321
+ key={i}
322
+ disabled={!slot.isAvailable}
323
+ onClick={() => handleSlotClick(slot.start, slot.end)}
324
+ className={`rounded-md border p-2 text-sm transition-colors ${selectedSlot?.start.getTime() === slot.start.getTime() ? 'border-black bg-black text-white' : slot.isAvailable ? 'bg-white text-gray-700 hover:border-black' : 'cursor-not-allowed border-gray-200 bg-gray-50 text-gray-300'}`}
325
+ >
326
+ <span className="block font-bold">
327
+ {slot.start.toLocaleTimeString('en-US', {
328
+ hour: '2-digit',
329
+ minute: '2-digit',
330
+ timeZone: shopTz,
331
+ })}
332
+ </span>
333
+ {isDiffTz && (
334
+ <span className="mt-0.5 block text-xs opacity-75">
335
+ (
336
+ {slot.start.toLocaleTimeString('en-US', {
337
+ hour: '2-digit',
338
+ minute: '2-digit',
339
+ timeZone: userTz,
340
+ })}{' '}
341
+ local)
342
+ </span>
343
+ )}
344
+ </button>
345
+ ))
346
+ ) : (
347
+ <div className="col-span-2 py-4 text-center text-sm text-gray-400">
348
+ No slots available for this day.
349
+ </div>
350
+ )}
351
+ </div>
352
+ </div>
353
+ </div>
354
+
355
+ {selectedSlot && (
356
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
357
+ <p className="text-sm text-gray-900">
358
+ Selected:{' '}
359
+ <span className="font-bold">
360
+ {selectedSlot.start.toLocaleDateString('en-US', {
361
+ timeZone: shopTz,
362
+ })}{' '}
363
+ at{' '}
364
+ {selectedSlot.start.toLocaleTimeString('en-US', {
365
+ hour: '2-digit',
366
+ minute: '2-digit',
367
+ timeZone: shopTz,
368
+ })}
369
+ </span>
370
+ </p>
371
+ </div>
372
+ )}
373
+ </div>
374
+ );
375
+ };
@@ -1,20 +1,25 @@
1
1
  import { useEffect } from 'react';
2
2
  import { useStore } from '@nanostores/react';
3
- import { addQueue, cartStore, modalState } from '@/stores/shopify';
4
3
  import {
5
- calculateCartDuration,
6
- MAX_LENGTH_MINUTES,
7
- RESTRICTION_MESSAGES,
8
- } from '@/utils/customHelpers';
4
+ addQueue,
5
+ cartStore,
6
+ modalState,
7
+ transactionTraceId,
8
+ } from '@/stores/shopify';
9
+ import { bookingHelpers } from '@/utils/api/bookingHelpers';
10
+ import { RESTRICTION_MESSAGES } from '@/utils/customHelpers';
9
11
  import type { ResourceNode } from '@/types/compositorTypes';
10
12
  import type { CartItemState } from '@/stores/shopify';
13
+ import type { BrandConfigState } from '@/types/tractstack';
11
14
 
12
15
  interface ShopifyCartManagerProps {
13
- resources: ResourceNode[];
16
+ resources?: ResourceNode[];
17
+ brandConfig?: BrandConfigState;
14
18
  }
15
19
 
16
20
  export default function ShopifyCartManager({
17
21
  resources = [],
22
+ brandConfig,
18
23
  }: ShopifyCartManagerProps) {
19
24
  const queue = useStore(addQueue);
20
25
 
@@ -38,34 +43,61 @@ export default function ShopifyCartManager({
38
43
  const currentCart = cartStore.get();
39
44
  const currentItem = currentCart[key];
40
45
  const currentQty = currentItem?.quantity || 0;
46
+ const nextCart = { ...currentCart };
41
47
 
42
48
  if (actionItem.action === 'remove') {
43
49
  const newQty = Math.max(0, currentQty - 1);
44
50
 
45
51
  if (newQty === 0) {
46
- const newCart = { ...currentCart };
47
- delete newCart[key];
48
- cartStore.set(newCart);
52
+ if (
53
+ resource?.optionsPayload?.needsBooking ||
54
+ currentItem?.boundResourceId
55
+ ) {
56
+ const traceId = transactionTraceId.get();
57
+ if (traceId) {
58
+ bookingHelpers
59
+ .releaseHold(traceId)
60
+ .catch((err) =>
61
+ console.error('Failed to release hold on cart removal:', err)
62
+ );
63
+ }
64
+ }
65
+ delete nextCart[key];
49
66
  } else {
50
- cartStore.setKey(key, {
67
+ nextCart[key] = {
68
+ ...currentItem,
51
69
  resourceId: actionItem.resourceId,
52
70
  quantity: newQty,
53
- gid: actionItem.gid || currentItem?.gid,
54
- variantId: actionItem.variantId || currentItem?.variantId,
55
- variantIdShipped:
56
- actionItem.variantIdShipped || currentItem?.variantIdShipped,
57
- variantIdPickup:
58
- actionItem.variantIdPickup || currentItem?.variantIdPickup,
59
- boundResourceId:
60
- actionItem.boundResourceId || currentItem?.boundResourceId,
61
- });
71
+ };
72
+ }
73
+
74
+ if (currentItem?.boundResourceId || actionItem.boundResourceId) {
75
+ const boundId =
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
+ };
90
+ }
91
+ }
62
92
  }
63
93
 
94
+ cartStore.set(nextCart);
64
95
  addQueue.set(remaining);
65
96
  } else if (actionItem.action === 'add') {
97
+ transactionTraceId.set('');
66
98
  const newQty = currentQty + 1;
67
99
 
68
- const newItem = {
100
+ const newItem: CartItemState = {
69
101
  resourceId: actionItem.resourceId,
70
102
  quantity: newQty,
71
103
  gid: actionItem.gid || currentItem?.gid,
@@ -78,14 +110,11 @@ export default function ShopifyCartManager({
78
110
  actionItem.boundResourceId || currentItem?.boundResourceId,
79
111
  };
80
112
 
81
- const nextCart: Record<string, CartItemState> = {
82
- ...currentCart,
83
- [key]: newItem,
84
- };
113
+ nextCart[key] = newItem;
85
114
 
86
- if (actionItem.boundResourceId) {
87
- const serviceEntry = Object.entries(currentCart).find(
88
- ([_, item]) => item.resourceId === actionItem.boundResourceId
115
+ if (newItem.boundResourceId) {
116
+ const serviceEntry = Object.entries(nextCart).find(
117
+ ([_, item]) => item.resourceId === newItem.boundResourceId
89
118
  );
90
119
 
91
120
  if (serviceEntry) {
@@ -95,34 +124,60 @@ export default function ShopifyCartManager({
95
124
  quantity: serviceItem.quantity + 1,
96
125
  };
97
126
  } else {
98
- nextCart[`temp_service_${actionItem.boundResourceId}`] = {
99
- resourceId: actionItem.boundResourceId,
127
+ nextCart[`temp_service_${newItem.boundResourceId}`] = {
128
+ resourceId: newItem.boundResourceId,
100
129
  quantity: 1,
101
130
  };
102
131
  }
103
132
  }
104
133
 
105
- const duration = calculateCartDuration(nextCart, resources);
106
- const bookingDuration = resource.optionsPayload?.bookingLengthMinutes;
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;
107
146
 
108
- if (duration > MAX_LENGTH_MINUTES) {
147
+ const dynamicMax = brandConfig?.scheduling?.maxLengthMinutes || 180;
148
+ if (snappedDuration > dynamicMax) {
109
149
  modalState.set({
110
150
  isOpen: true,
111
151
  type: 'restriction',
112
152
  title: 'Appointment Length Limit Reached',
113
- message: RESTRICTION_MESSAGES.MAX_DURATION(MAX_LENGTH_MINUTES),
153
+ message: RESTRICTION_MESSAGES.MAX_DURATION(dynamicMax),
114
154
  });
115
155
  } else {
116
- cartStore.setKey(key, newItem);
156
+ cartStore.set(nextCart);
117
157
 
118
158
  if (!actionItem.suppressModal) {
119
- if (resource.categorySlug === 'service') {
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
+ }
167
+ }
168
+
169
+ if (
170
+ targetResource.categorySlug === 'service' ||
171
+ targetResource.optionsPayload?.needsBooking
172
+ ) {
120
173
  modalState.set({
121
174
  isOpen: true,
122
175
  type: 'success',
123
176
  title: 'Booking Required',
124
177
  message: RESTRICTION_MESSAGES.BOOKING(
125
- (bookingDuration || 0).toString()
178
+ (
179
+ targetResource.optionsPayload?.bookingLengthMinutes || 0
180
+ ).toString()
126
181
  ),
127
182
  });
128
183
  } else {
@@ -130,7 +185,7 @@ export default function ShopifyCartManager({
130
185
  isOpen: true,
131
186
  type: 'success',
132
187
  title: 'Added to Cart',
133
- message: RESTRICTION_MESSAGES.DEFAULT_ADD(resource.title),
188
+ message: RESTRICTION_MESSAGES.DEFAULT_ADD(targetResource.title),
134
189
  });
135
190
  }
136
191
  }