astro-tractstack 2.2.10 → 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 (85) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +177 -18
  4. package/package.json +4 -2
  5. package/templates/custom/minimal/CodeHook.astro +22 -5
  6. package/templates/custom/shopify/Cart.tsx +372 -0
  7. package/templates/custom/shopify/CartIcon.tsx +47 -0
  8. package/templates/custom/shopify/CartModal.tsx +63 -0
  9. package/templates/custom/shopify/CheckoutModal.tsx +576 -0
  10. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  11. package/templates/custom/shopify/ShopifyCartManager.tsx +200 -0
  12. package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
  13. package/templates/custom/shopify/ShopifyProductGrid.tsx +247 -0
  14. package/templates/custom/shopify/ShopifyServiceList.tsx +135 -0
  15. package/templates/custom/shopify/cart.astro +23 -0
  16. package/templates/custom/with-examples/CodeHook.astro +17 -1
  17. package/templates/custom/with-examples/ProductGrid.astro +1 -1
  18. package/templates/src/client/app.js +4 -2
  19. package/templates/src/components/Footer.astro +4 -4
  20. package/templates/src/components/Header.astro +44 -12
  21. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  22. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  23. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  24. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  25. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  26. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  27. package/templates/src/components/form/advanced/APIConfigSection.tsx +407 -38
  28. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  29. package/templates/src/components/storykeep/Dashboard.tsx +18 -4
  30. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
  31. package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
  32. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +668 -0
  33. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
  34. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  35. package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
  36. package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
  37. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  38. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
  39. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  40. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +333 -0
  41. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +9 -5
  42. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
  43. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
  44. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  45. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
  46. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -0
  47. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  48. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  49. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  50. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  51. package/templates/src/lib/resources.ts +11 -21
  52. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  53. package/templates/src/pages/api/booking/availability.ts +72 -0
  54. package/templates/src/pages/api/booking/cancel.ts +73 -0
  55. package/templates/src/pages/api/booking/confirm.ts +82 -0
  56. package/templates/src/pages/api/booking/hold.ts +75 -0
  57. package/templates/src/pages/api/booking/list.ts +66 -0
  58. package/templates/src/pages/api/booking/metrics.ts +60 -0
  59. package/templates/src/pages/api/booking/release.ts +76 -0
  60. package/templates/src/pages/api/sandbox.ts +2 -2
  61. package/templates/src/pages/api/shopify/createCart.ts +69 -0
  62. package/templates/src/pages/api/shopify/getProducts.ts +64 -0
  63. package/templates/src/pages/storykeep/login.astro +26 -24
  64. package/templates/src/pages/storykeep/logout.astro +1 -10
  65. package/templates/src/pages/storykeep/manage.astro +69 -0
  66. package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
  67. package/templates/src/pages/storykeep/shopify.astro +101 -0
  68. package/templates/src/stores/navigation.ts +3 -42
  69. package/templates/src/stores/nodes.ts +3 -1
  70. package/templates/src/stores/resources.ts +7 -10
  71. package/templates/src/stores/shopify.ts +266 -0
  72. package/templates/src/types/tractstack.ts +75 -0
  73. package/templates/src/utils/api/advancedConfig.ts +7 -1
  74. package/templates/src/utils/api/advancedHelpers.ts +87 -7
  75. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  76. package/templates/src/utils/api/brandHelpers.ts +14 -0
  77. package/templates/src/utils/api/resourceConfig.ts +13 -5
  78. package/templates/src/utils/auth.ts +29 -9
  79. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  80. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  81. package/templates/src/utils/customHelpers.ts +49 -0
  82. package/templates/src/utils/helpers.ts +59 -0
  83. package/templates/src/utils/profileStorage.ts +5 -0
  84. package/templates/src/utils/tenantResolver.ts +2 -1
  85. package/utils/inject-files.ts +161 -2
@@ -0,0 +1,576 @@
1
+ import { useState, useEffect, useMemo, type FormEvent } from 'react';
2
+ import { ulid } from 'ulid';
3
+ import { useStore } from '@nanostores/react';
4
+ import { Dialog } from '@ark-ui/react/dialog';
5
+ import { Portal } from '@ark-ui/react/portal';
6
+ import {
7
+ cartState,
8
+ cartStore,
9
+ customerDetails,
10
+ setCustomerDetails,
11
+ CART_STATES,
12
+ transactionTraceId,
13
+ isShopifyHandoff,
14
+ } from '@/stores/shopify';
15
+ import { bookingHelpers } from '@/utils/api/bookingHelpers';
16
+ import { NativeBookingCalendar } from './NativeBookingCalendar';
17
+ import { ProfileStorage } from '@/utils/profileStorage';
18
+ import type { ResourceNode } from '@/types/compositorTypes';
19
+
20
+ type CheckoutState =
21
+ | 'IDENTITY_EMAIL'
22
+ | 'IDENTITY_NEW_USER'
23
+ | 'BOOKING'
24
+ | 'SUMMARY'
25
+ | 'PROCESSING'
26
+ | 'SUCCESS';
27
+
28
+ interface CheckoutModalProps {
29
+ maxLength: number;
30
+ resources?: ResourceNode[];
31
+ }
32
+
33
+ export default function CheckoutModal({
34
+ maxLength,
35
+ resources = [],
36
+ }: CheckoutModalProps) {
37
+ const $globalCartState = useStore(cartState);
38
+ const $cartItems = useStore(cartStore);
39
+ const $customer = useStore(customerDetails);
40
+
41
+ const [internalState, setInternalState] =
42
+ useState<CheckoutState>('IDENTITY_EMAIL');
43
+
44
+ const [email, setEmail] = useState(() => {
45
+ const profile = ProfileStorage.getProfileData();
46
+ if (ProfileStorage.isProfileUnlocked() && profile.email)
47
+ return profile.email;
48
+ return $customer.email || '';
49
+ });
50
+
51
+ const [name, setName] = useState('');
52
+ const [codeword, setCodeword] = useState('');
53
+ const [shopTimeZone, setShopTimeZone] = useState<string | undefined>(
54
+ undefined
55
+ );
56
+ const [selectedSlot, setSelectedSlot] = useState<{
57
+ start: Date;
58
+ end: Date;
59
+ } | null>(null);
60
+ const [error, setError] = useState<string | null>(null);
61
+
62
+ const isOpen =
63
+ $globalCartState === CART_STATES.CHECKOUT ||
64
+ $globalCartState === CART_STATES.BOOKING ||
65
+ $globalCartState === CART_STATES.SHOPIFY_HANDOFF;
66
+
67
+ const enrichedCart = useMemo(() => {
68
+ return Object.values($cartItems).map((item: any) => {
69
+ const resource = resources.find((r) => r.id === item.resourceId);
70
+ let productData: any = {};
71
+ if (resource?.optionsPayload?.shopifyData) {
72
+ try {
73
+ productData = JSON.parse(resource.optionsPayload.shopifyData);
74
+ } catch (e) {
75
+ console.error('Failed to parse Shopify data', item.resourceId);
76
+ }
77
+ }
78
+ const variant = (productData?.variants || []).find(
79
+ (v: any) => v.id === (item.variantId || item.gid)
80
+ );
81
+ return {
82
+ ...item,
83
+ title: productData?.title || resource?.title || 'Loading...',
84
+ price: variant?.price?.amount || '0.00',
85
+ resource: {
86
+ id: item.resourceId,
87
+ needsBooking:
88
+ resource?.categorySlug === 'service' ||
89
+ resource?.optionsPayload?.needsBooking ||
90
+ !!item.boundResourceId,
91
+ duration: resource?.optionsPayload?.bookingLengthMinutes || 0,
92
+ },
93
+ };
94
+ });
95
+ }, [$cartItems, resources]);
96
+
97
+ const needsBooking = useMemo(
98
+ () => enrichedCart.some((item) => item.resource?.needsBooking),
99
+ [enrichedCart]
100
+ );
101
+ const needsPayment = useMemo(
102
+ () => enrichedCart.some((item) => !!(item.gid || item.variantId)),
103
+ [enrichedCart]
104
+ );
105
+ const totalDuration = useMemo(() => {
106
+ const rawMinutes = enrichedCart.reduce(
107
+ (acc, item) =>
108
+ acc + (item.resource?.duration || 0) * (item.quantity || 1),
109
+ 0
110
+ );
111
+ return Math.min(Math.ceil(rawMinutes / 15) * 15, maxLength);
112
+ }, [enrichedCart]);
113
+
114
+ useEffect(() => {
115
+ const profile = ProfileStorage.getProfileData();
116
+ if (ProfileStorage.isProfileUnlocked() && profile.email) {
117
+ setEmail(profile.email);
118
+ } else if ($customer.email) {
119
+ setEmail($customer.email);
120
+ }
121
+ }, [ProfileStorage.isProfileUnlocked(), $customer.email]);
122
+
123
+ useEffect(() => {
124
+ if (
125
+ $globalCartState !== CART_STATES.CHECKOUT ||
126
+ internalState === 'SUCCESS' ||
127
+ internalState === 'PROCESSING'
128
+ )
129
+ return;
130
+
131
+ if ($customer.leadId) {
132
+ if (
133
+ internalState === 'IDENTITY_EMAIL' ||
134
+ internalState === 'IDENTITY_NEW_USER'
135
+ ) {
136
+ if (!transactionTraceId.get()) transactionTraceId.set(ulid());
137
+ setInternalState(needsBooking ? 'BOOKING' : 'SUMMARY');
138
+ }
139
+ } else {
140
+ if (
141
+ internalState !== 'IDENTITY_EMAIL' &&
142
+ internalState !== 'IDENTITY_NEW_USER'
143
+ ) {
144
+ setInternalState('IDENTITY_EMAIL');
145
+ }
146
+ }
147
+ }, [$globalCartState, needsBooking, $customer.leadId, internalState]);
148
+
149
+ const handleClose = async () => {
150
+ const redirect = internalState === 'SUCCESS';
151
+ const currentTraceId = transactionTraceId.get();
152
+ if (
153
+ currentTraceId &&
154
+ internalState !== 'SUCCESS' &&
155
+ !isShopifyHandoff.get()
156
+ ) {
157
+ try {
158
+ await bookingHelpers.releaseHold(currentTraceId);
159
+ } catch (err) {
160
+ console.error('Failed to release hold on close', err);
161
+ }
162
+ }
163
+ transactionTraceId.set('');
164
+ cartState.set(CART_STATES.READY);
165
+ setInternalState('IDENTITY_EMAIL');
166
+ setError(null);
167
+ if (redirect) window.location.href = `/`;
168
+ };
169
+
170
+ const handleEmailLookup = async (e: FormEvent) => {
171
+ e.preventDefault();
172
+ setError(null);
173
+ setInternalState('PROCESSING');
174
+ try {
175
+ const response: any = await bookingHelpers.lookupLead(email);
176
+ if (response && response.exists && response.leadId) {
177
+ if (!transactionTraceId.get()) transactionTraceId.set(ulid());
178
+ setCustomerDetails({ ...$customer, email, leadId: response.leadId });
179
+ setInternalState(needsBooking ? 'BOOKING' : 'SUMMARY');
180
+ } else {
181
+ setInternalState('IDENTITY_NEW_USER');
182
+ }
183
+ } catch (err) {
184
+ setError('Failed to verify email.');
185
+ setInternalState('IDENTITY_EMAIL');
186
+ }
187
+ };
188
+
189
+ const handleCreateLead = async (e: FormEvent) => {
190
+ e.preventDefault();
191
+ setError(null);
192
+ setInternalState('PROCESSING');
193
+ try {
194
+ const handshake = ProfileStorage.prepareHandshakeData();
195
+ const response = await fetch('/api/auth/profile', {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({
199
+ email,
200
+ firstName: name,
201
+ codeword,
202
+ contactPersona: 'major',
203
+ shortBio: '',
204
+ sessionId: handshake.sessionId,
205
+ consent: '1',
206
+ isUpdate: false,
207
+ }),
208
+ });
209
+ const data = await response.json();
210
+ if (response.ok && data.profile?.LeadID) {
211
+ ProfileStorage.storeProfileToken(data.token);
212
+ ProfileStorage.setProfileData(data.profile);
213
+ ProfileStorage.storeEncryptedCredentials(
214
+ data.encryptedEmail,
215
+ data.encryptedCode
216
+ );
217
+ ProfileStorage.storeConsent('1');
218
+
219
+ if (!transactionTraceId.get()) transactionTraceId.set(ulid());
220
+ setCustomerDetails({
221
+ ...$customer,
222
+ email: data.profile.Email,
223
+ name: data.profile.Firstname,
224
+ leadId: data.profile.LeadID,
225
+ });
226
+ setInternalState(needsBooking ? 'BOOKING' : 'SUMMARY');
227
+ } else {
228
+ setError(data.error || 'Registration failed.');
229
+ setInternalState('IDENTITY_NEW_USER');
230
+ }
231
+ } catch (err) {
232
+ setError('Error during registration.');
233
+ setInternalState('IDENTITY_NEW_USER');
234
+ }
235
+ };
236
+
237
+ const handleSlotSelection = async (
238
+ start: Date,
239
+ end: Date,
240
+ timeZone: string
241
+ ) => {
242
+ setSelectedSlot({ start, end });
243
+ setShopTimeZone(timeZone);
244
+ setError(null);
245
+ setInternalState('PROCESSING');
246
+ const cartResourceIds = enrichedCart.map((item) => item.resourceId);
247
+ try {
248
+ const response: any = await bookingHelpers.holdSlot(
249
+ transactionTraceId.get(),
250
+ start.toISOString(),
251
+ end.toISOString(),
252
+ cartResourceIds
253
+ );
254
+ if (response && (response.success || response.status === 'PENDING')) {
255
+ setInternalState('SUMMARY');
256
+ } else {
257
+ setError(response?.message || 'Slot no longer available.');
258
+ setInternalState('BOOKING');
259
+ }
260
+ } catch (err) {
261
+ setError('Error securing appointment.');
262
+ setInternalState('BOOKING');
263
+ }
264
+ };
265
+
266
+ const handleCancelBooking = async () => {
267
+ const currentTraceId = transactionTraceId.get();
268
+ if (currentTraceId) {
269
+ setError(null);
270
+ setInternalState('PROCESSING');
271
+ try {
272
+ await bookingHelpers.releaseHold(currentTraceId);
273
+ setSelectedSlot(null);
274
+ setInternalState('BOOKING');
275
+ } catch (err) {
276
+ console.error('Failed to release hold', err);
277
+ setError('Failed to cancel the hold. Please try again.');
278
+ setInternalState('SUMMARY');
279
+ }
280
+ } else {
281
+ setSelectedSlot(null);
282
+ setInternalState('BOOKING');
283
+ }
284
+ };
285
+
286
+ const handleFinalCheckout = async () => {
287
+ if (!needsPayment) {
288
+ setError(null);
289
+ setInternalState('PROCESSING');
290
+ try {
291
+ const response: any = await bookingHelpers.confirmBooking(
292
+ transactionTraceId.get()
293
+ );
294
+ if (response && response.success) {
295
+ cartStore.set({});
296
+ setInternalState('SUCCESS');
297
+ } else {
298
+ setError(response?.error || 'Failed to confirm booking.');
299
+ setInternalState('SUMMARY');
300
+ }
301
+ } catch (err) {
302
+ setError('Error confirming booking.');
303
+ setInternalState('SUMMARY');
304
+ }
305
+ return;
306
+ }
307
+
308
+ setError(null);
309
+ setInternalState('PROCESSING');
310
+ try {
311
+ const response = await fetch('/api/shopify/createCart', {
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({
315
+ lines: enrichedCart
316
+ .filter((i) => i.gid || i.variantId)
317
+ .map((i) => ({
318
+ merchandiseId: i.variantId || i.gid,
319
+ quantity: i.quantity || 1,
320
+ })),
321
+ email: $customer.email,
322
+ ...(needsBooking
323
+ ? {
324
+ attributes: [
325
+ { key: 'bookingId', value: transactionTraceId.get() },
326
+ ...(selectedSlot
327
+ ? [
328
+ {
329
+ key: 'Appointment Date',
330
+ value: selectedSlot.start.toLocaleDateString(
331
+ 'en-US',
332
+ { timeZone: shopTimeZone }
333
+ ),
334
+ },
335
+ {
336
+ key: 'Appointment Time',
337
+ value: selectedSlot.start.toLocaleTimeString(
338
+ 'en-US',
339
+ {
340
+ hour: '2-digit',
341
+ minute: '2-digit',
342
+ timeZone: shopTimeZone,
343
+ }
344
+ ),
345
+ },
346
+ ]
347
+ : []),
348
+ ],
349
+ }
350
+ : {}),
351
+ }),
352
+ });
353
+ const result = await response.json();
354
+ if (!response.ok) throw new Error(result.error || 'Checkout failed');
355
+
356
+ if (result.checkoutUrl) {
357
+ isShopifyHandoff.set(true);
358
+ cartStore.set({});
359
+ cartState.set(CART_STATES.READY);
360
+ window.location.href = result.checkoutUrl;
361
+ } else {
362
+ throw new Error('No checkout URL');
363
+ }
364
+ } catch (err) {
365
+ setError(
366
+ err instanceof Error ? err.message : 'Failed to reach checkout.'
367
+ );
368
+ setInternalState('SUMMARY');
369
+ }
370
+ };
371
+
372
+ if (!isOpen) return null;
373
+
374
+ return (
375
+ <Portal>
376
+ <Dialog.Root open={isOpen} onOpenChange={(e) => !e.open && handleClose()}>
377
+ <Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-75 backdrop-blur-sm" />
378
+ <Dialog.Positioner className="fixed inset-0 z-50 flex items-center justify-center p-4">
379
+ <Dialog.Content className="w-full max-w-lg overflow-hidden rounded-lg bg-white shadow-xl">
380
+ <div className="flex items-center justify-between border-b p-6">
381
+ <Dialog.Title className="text-xl font-bold text-gray-900">
382
+ Checkout
383
+ </Dialog.Title>
384
+ <button
385
+ onClick={handleClose}
386
+ className="text-gray-400 hover:text-gray-600 focus:outline-none"
387
+ >
388
+
389
+ </button>
390
+ </div>
391
+ <div className="p-8">
392
+ {error && (
393
+ <div className="mb-6 rounded-md border border-red-100 bg-red-50 p-4 text-sm text-red-700">
394
+ {error}
395
+ </div>
396
+ )}
397
+ {internalState === 'IDENTITY_EMAIL' && (
398
+ <form onSubmit={handleEmailLookup} className="space-y-6">
399
+ <div>
400
+ <label className="block text-sm font-bold text-gray-700">
401
+ Email Address
402
+ </label>
403
+ <input
404
+ type="email"
405
+ required
406
+ value={email}
407
+ onChange={(e) => setEmail(e.target.value)}
408
+ className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:ring-1 focus:ring-black"
409
+ />
410
+ </div>
411
+ <button
412
+ type="submit"
413
+ className="w-full rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
414
+ >
415
+ Continue
416
+ </button>
417
+ </form>
418
+ )}
419
+ {internalState === 'IDENTITY_NEW_USER' && (
420
+ <form onSubmit={handleCreateLead} className="space-y-6">
421
+ <div className="space-y-1">
422
+ <label className="block text-sm font-bold text-gray-700">
423
+ Email:
424
+ </label>
425
+ <input
426
+ type="email"
427
+ disabled
428
+ value={email}
429
+ className="block w-full rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-gray-500 shadow-sm"
430
+ />
431
+ </div>
432
+ <div className="space-y-1">
433
+ <label className="block text-sm font-bold text-gray-700">
434
+ Your name:
435
+ </label>
436
+ <input
437
+ type="text"
438
+ required
439
+ value={name}
440
+ onChange={(e) => setName(e.target.value)}
441
+ className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:ring-1 focus:ring-black"
442
+ />
443
+ </div>
444
+ <div className="space-y-1">
445
+ <label className="block text-sm font-bold text-gray-700">
446
+ Codeword to protect your account:
447
+ </label>
448
+ <input
449
+ type="password"
450
+ required
451
+ value={codeword}
452
+ onChange={(e) => setCodeword(e.target.value)}
453
+ className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:ring-1 focus:ring-black"
454
+ />
455
+ </div>
456
+ <button
457
+ type="submit"
458
+ className="w-full rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
459
+ >
460
+ Create Profile & Continue
461
+ </button>
462
+ </form>
463
+ )}
464
+ {internalState === 'BOOKING' && (
465
+ <NativeBookingCalendar
466
+ totalDurationMinutes={totalDuration}
467
+ onSlotSelected={handleSlotSelection}
468
+ />
469
+ )}
470
+ {internalState === 'SUMMARY' && (
471
+ <div className="space-y-6">
472
+ <div className="rounded-xl border border-gray-200 bg-gray-50 p-6">
473
+ <h3 className="mb-4 font-bold text-gray-900">
474
+ Order Summary
475
+ </h3>
476
+ {enrichedCart.map((item, idx) => (
477
+ <div
478
+ key={idx}
479
+ className="flex justify-between text-sm text-gray-700"
480
+ >
481
+ <span>{item.title}</span>
482
+ <span className="font-bold">
483
+ $
484
+ {(
485
+ parseFloat(item.price) * (item.quantity || 1)
486
+ ).toFixed(2)}
487
+ </span>
488
+ </div>
489
+ ))}
490
+ {selectedSlot && (
491
+ <div className="mt-6 border-t border-gray-200 pt-6">
492
+ <p className="text-xs font-bold uppercase text-gray-500">
493
+ Appointment
494
+ </p>
495
+ <p className="mt-1 text-sm font-bold text-gray-900">
496
+ {selectedSlot.start.toLocaleDateString('en-US', {
497
+ timeZone: shopTimeZone,
498
+ })}{' '}
499
+ at{' '}
500
+ {selectedSlot.start.toLocaleTimeString('en-US', {
501
+ hour: '2-digit',
502
+ minute: '2-digit',
503
+ timeZone: shopTimeZone,
504
+ })}
505
+ </p>
506
+ {shopTimeZone &&
507
+ shopTimeZone !==
508
+ Intl.DateTimeFormat().resolvedOptions()
509
+ .timeZone && (
510
+ <p className="mt-1 text-xs font-bold text-gray-500">
511
+ (
512
+ {selectedSlot.start.toLocaleTimeString('en-US', {
513
+ hour: '2-digit',
514
+ minute: '2-digit',
515
+ })}{' '}
516
+ local)
517
+ </p>
518
+ )}
519
+ </div>
520
+ )}
521
+ </div>
522
+
523
+ {needsBooking && (
524
+ <div className="mb-4 rounded-md bg-amber-50 p-3 text-center text-xs font-bold text-amber-800">
525
+ Your appointment time is held for the next 30 minutes.
526
+ </div>
527
+ )}
528
+ <div className="flex gap-3">
529
+ {needsBooking && (
530
+ <button
531
+ onClick={handleCancelBooking}
532
+ className="w-1/3 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50"
533
+ >
534
+ Go Back
535
+ </button>
536
+ )}
537
+ <button
538
+ onClick={handleFinalCheckout}
539
+ className="flex-1 rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
540
+ >
541
+ {needsPayment ? 'Complete Payment' : 'Complete Booking'}
542
+ </button>
543
+ </div>
544
+ </div>
545
+ )}
546
+ {internalState === 'SUCCESS' && (
547
+ <div className="py-10 text-center">
548
+ <h3 className="text-2xl font-bold text-gray-900">
549
+ Confirmed!
550
+ </h3>
551
+ <p className="mt-3 text-sm text-gray-600">
552
+ Confirmation sent to {email}.
553
+ </p>
554
+ <button
555
+ onClick={handleClose}
556
+ className="mt-10 w-full rounded-md bg-gray-200 px-4 py-2 text-sm font-bold text-gray-800 hover:bg-gray-300"
557
+ >
558
+ Return to Site
559
+ </button>
560
+ </div>
561
+ )}
562
+ {internalState === 'PROCESSING' && (
563
+ <div className="flex flex-col items-center py-20">
564
+ <div className="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-black"></div>
565
+ <p className="mt-6 animate-pulse text-sm font-bold text-gray-500">
566
+ Syncing...
567
+ </p>
568
+ </div>
569
+ )}
570
+ </div>
571
+ </Dialog.Content>
572
+ </Dialog.Positioner>
573
+ </Dialog.Root>
574
+ </Portal>
575
+ );
576
+ }