astro-tractstack 2.3.0 → 2.3.2
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/README.md +1 -1
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +130 -19
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +115 -77
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
- package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +6 -6
- package/templates/src/components/Header.astro +23 -11
- 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_new.tsx +3 -3
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
- package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
- 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 +208 -2
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
- package/templates/src/components/storykeep/Dashboard.tsx +1 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
- package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- 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 +104 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
- package/templates/src/layouts/Layout.astro +8 -5
- package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
- package/templates/src/pages/api/booking/availability.ts +72 -0
- package/templates/src/pages/api/booking/cancel.ts +73 -0
- package/templates/src/pages/api/booking/confirm.ts +82 -0
- package/templates/src/pages/api/booking/hold.ts +75 -0
- package/templates/src/pages/api/booking/list.ts +66 -0
- package/templates/src/pages/api/booking/metrics.ts +60 -0
- package/templates/src/pages/api/booking/release.ts +76 -0
- package/templates/src/pages/api/sandbox.ts +2 -2
- package/templates/src/pages/api/shopify/createCart.ts +4 -8
- package/templates/src/pages/api/shopify/getProducts.ts +15 -15
- package/templates/src/pages/storykeep/login.astro +21 -14
- package/templates/src/stores/shopify.ts +97 -25
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +59 -2
- package/templates/src/utils/api/advancedConfig.ts +2 -0
- package/templates/src/utils/api/advancedHelpers.ts +40 -3
- package/templates/src/utils/api/bookingHelpers.ts +125 -0
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +26 -0
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/auth.ts +29 -9
- package/templates/src/utils/compositor/aiGeneration.ts +3 -3
- package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
- package/templates/src/utils/customHelpers.ts +0 -21
- package/templates/src/utils/profileStorage.ts +5 -0
- package/templates/src/utils/tenantResolver.ts +3 -2
- package/utils/inject-files.ts +116 -5
- 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,29 @@
|
|
|
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
|
-
|
|
6
|
-
|
|
4
|
+
addQueue,
|
|
5
|
+
cartStore,
|
|
6
|
+
modalState,
|
|
7
|
+
transactionTraceId,
|
|
8
|
+
getCartItemKey,
|
|
9
|
+
} from '@/stores/shopify';
|
|
10
|
+
import { bookingHelpers } from '@/utils/api/bookingHelpers';
|
|
11
|
+
import {
|
|
7
12
|
RESTRICTION_MESSAGES,
|
|
13
|
+
calculateCartDuration,
|
|
8
14
|
} from '@/utils/customHelpers';
|
|
9
15
|
import type { ResourceNode } from '@/types/compositorTypes';
|
|
10
16
|
import type { CartItemState } from '@/stores/shopify';
|
|
17
|
+
import type { BrandConfigState } from '@/types/tractstack';
|
|
11
18
|
|
|
12
19
|
interface ShopifyCartManagerProps {
|
|
13
|
-
resources
|
|
20
|
+
resources?: ResourceNode[];
|
|
21
|
+
brandConfig?: BrandConfigState;
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export default function ShopifyCartManager({
|
|
17
25
|
resources = [],
|
|
26
|
+
brandConfig,
|
|
18
27
|
}: ShopifyCartManagerProps) {
|
|
19
28
|
const queue = useStore(addQueue);
|
|
20
29
|
|
|
@@ -29,43 +38,65 @@ export default function ShopifyCartManager({
|
|
|
29
38
|
return;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
const key =
|
|
33
|
-
actionItem.variantId ||
|
|
34
|
-
`${actionItem.resourceId}_${actionItem.variantIdShipped || 'null'}_${
|
|
35
|
-
actionItem.variantIdPickup || 'null'
|
|
36
|
-
}`;
|
|
37
|
-
|
|
41
|
+
const key = getCartItemKey(actionItem);
|
|
38
42
|
const currentCart = cartStore.get();
|
|
39
43
|
const currentItem = currentCart[key];
|
|
40
44
|
const currentQty = currentItem?.quantity || 0;
|
|
45
|
+
const nextCart = { ...currentCart };
|
|
41
46
|
|
|
42
47
|
if (actionItem.action === 'remove') {
|
|
43
48
|
const newQty = Math.max(0, currentQty - 1);
|
|
44
49
|
|
|
45
50
|
if (newQty === 0) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
if (
|
|
52
|
+
resource?.optionsPayload?.needsBooking ||
|
|
53
|
+
currentItem?.boundResourceId
|
|
54
|
+
) {
|
|
55
|
+
const traceId = transactionTraceId.get();
|
|
56
|
+
if (traceId) {
|
|
57
|
+
bookingHelpers
|
|
58
|
+
.releaseHold(traceId)
|
|
59
|
+
.catch((err) =>
|
|
60
|
+
console.error('Failed to release hold on cart removal:', err)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
delete nextCart[key];
|
|
49
65
|
} else {
|
|
50
|
-
|
|
66
|
+
nextCart[key] = {
|
|
67
|
+
...currentItem,
|
|
51
68
|
resourceId: actionItem.resourceId,
|
|
52
69
|
quantity: newQty,
|
|
53
|
-
|
|
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
|
-
});
|
|
70
|
+
};
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
if (currentItem?.boundResourceId || actionItem.boundResourceId) {
|
|
74
|
+
const boundId =
|
|
75
|
+
currentItem?.boundResourceId || actionItem.boundResourceId;
|
|
76
|
+
if (boundId) {
|
|
77
|
+
const serviceKey = getCartItemKey({ resourceId: boundId });
|
|
78
|
+
const serviceItem = nextCart[serviceKey];
|
|
79
|
+
if (serviceItem) {
|
|
80
|
+
const newServiceQty = Math.max(0, serviceItem.quantity - 1);
|
|
81
|
+
if (newServiceQty === 0) {
|
|
82
|
+
delete nextCart[serviceKey];
|
|
83
|
+
} else {
|
|
84
|
+
nextCart[serviceKey] = {
|
|
85
|
+
...serviceItem,
|
|
86
|
+
quantity: newServiceQty,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cartStore.set(nextCart);
|
|
64
94
|
addQueue.set(remaining);
|
|
65
95
|
} else if (actionItem.action === 'add') {
|
|
96
|
+
transactionTraceId.set('');
|
|
66
97
|
const newQty = currentQty + 1;
|
|
67
98
|
|
|
68
|
-
const newItem = {
|
|
99
|
+
const newItem: CartItemState = {
|
|
69
100
|
resourceId: actionItem.resourceId,
|
|
70
101
|
quantity: newQty,
|
|
71
102
|
gid: actionItem.gid || currentItem?.gid,
|
|
@@ -78,51 +109,66 @@ export default function ShopifyCartManager({
|
|
|
78
109
|
actionItem.boundResourceId || currentItem?.boundResourceId,
|
|
79
110
|
};
|
|
80
111
|
|
|
81
|
-
|
|
82
|
-
...currentCart,
|
|
83
|
-
[key]: newItem,
|
|
84
|
-
};
|
|
112
|
+
nextCart[key] = newItem;
|
|
85
113
|
|
|
86
|
-
if (
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
);
|
|
114
|
+
if (newItem.boundResourceId) {
|
|
115
|
+
const serviceKey = getCartItemKey({
|
|
116
|
+
resourceId: newItem.boundResourceId,
|
|
117
|
+
});
|
|
118
|
+
const serviceItem = nextCart[serviceKey];
|
|
90
119
|
|
|
91
|
-
if (
|
|
92
|
-
const [serviceKey, serviceItem] = serviceEntry;
|
|
120
|
+
if (serviceItem) {
|
|
93
121
|
nextCart[serviceKey] = {
|
|
94
122
|
...serviceItem,
|
|
95
123
|
quantity: serviceItem.quantity + 1,
|
|
96
124
|
};
|
|
97
125
|
} else {
|
|
98
|
-
nextCart[
|
|
99
|
-
resourceId:
|
|
126
|
+
nextCart[serviceKey] = {
|
|
127
|
+
resourceId: newItem.boundResourceId,
|
|
100
128
|
quantity: 1,
|
|
101
129
|
};
|
|
102
130
|
}
|
|
103
131
|
}
|
|
104
132
|
|
|
105
|
-
const
|
|
106
|
-
|
|
133
|
+
const rawDuration = calculateCartDuration(nextCart, resources);
|
|
134
|
+
|
|
135
|
+
const interval = 15;
|
|
136
|
+
const snappedDuration = Math.ceil(rawDuration / interval) * interval;
|
|
107
137
|
|
|
108
|
-
|
|
138
|
+
const dynamicMax = brandConfig?.scheduling?.maxLengthMinutes || 180;
|
|
139
|
+
if (snappedDuration > dynamicMax) {
|
|
109
140
|
modalState.set({
|
|
110
141
|
isOpen: true,
|
|
111
142
|
type: 'restriction',
|
|
112
143
|
title: 'Appointment Length Limit Reached',
|
|
113
|
-
message: RESTRICTION_MESSAGES.MAX_DURATION(
|
|
144
|
+
message: RESTRICTION_MESSAGES.MAX_DURATION(dynamicMax),
|
|
114
145
|
});
|
|
115
146
|
} else {
|
|
116
|
-
cartStore.
|
|
147
|
+
cartStore.set(nextCart);
|
|
117
148
|
|
|
118
149
|
if (!actionItem.suppressModal) {
|
|
119
|
-
|
|
150
|
+
let targetResource = resource;
|
|
151
|
+
if (newItem.boundResourceId) {
|
|
152
|
+
const bound = resources.find(
|
|
153
|
+
(r) => r.id === newItem.boundResourceId
|
|
154
|
+
);
|
|
155
|
+
if (bound) {
|
|
156
|
+
targetResource = bound;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
targetResource.categorySlug === 'service' ||
|
|
162
|
+
targetResource.optionsPayload?.needsBooking
|
|
163
|
+
) {
|
|
120
164
|
modalState.set({
|
|
121
165
|
isOpen: true,
|
|
122
166
|
type: 'success',
|
|
123
167
|
title: 'Booking Required',
|
|
124
168
|
message: RESTRICTION_MESSAGES.BOOKING(
|
|
125
|
-
(
|
|
169
|
+
(
|
|
170
|
+
targetResource.optionsPayload?.bookingLengthMinutes || 0
|
|
171
|
+
).toString()
|
|
126
172
|
),
|
|
127
173
|
});
|
|
128
174
|
} else {
|
|
@@ -130,7 +176,7 @@ export default function ShopifyCartManager({
|
|
|
130
176
|
isOpen: true,
|
|
131
177
|
type: 'success',
|
|
132
178
|
title: 'Added to Cart',
|
|
133
|
-
message: RESTRICTION_MESSAGES.DEFAULT_ADD(
|
|
179
|
+
message: RESTRICTION_MESSAGES.DEFAULT_ADD(targetResource.title),
|
|
134
180
|
});
|
|
135
181
|
}
|
|
136
182
|
}
|
|
@@ -139,7 +185,7 @@ export default function ShopifyCartManager({
|
|
|
139
185
|
addQueue.set(remaining);
|
|
140
186
|
}
|
|
141
187
|
}
|
|
142
|
-
}, [queue, resources]);
|
|
188
|
+
}, [queue, resources, brandConfig]);
|
|
143
189
|
|
|
144
190
|
return null;
|
|
145
191
|
}
|
|
@@ -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 {
|