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
@@ -1,187 +1,576 @@
1
- import { useState, useEffect, type FormEvent } from 'react';
2
- import { useStore } from '@nanostores/react';
1
+ import { useState, useEffect, useMemo, type FormEvent } from 'react';
3
2
  import { ulid } from 'ulid';
3
+ import { useStore } from '@nanostores/react';
4
4
  import { Dialog } from '@ark-ui/react/dialog';
5
5
  import { Portal } from '@ark-ui/react/portal';
6
6
  import {
7
7
  cartState,
8
8
  cartStore,
9
9
  customerDetails,
10
+ setCustomerDetails,
10
11
  CART_STATES,
12
+ transactionTraceId,
13
+ isShopifyHandoff,
11
14
  } from '@/stores/shopify';
12
- import CalDotComBooking from './CalDotComBooking';
13
- import ShopifyCheckout from './ShopifyCheckout';
14
- import { calculateCartDuration, getBookingBucket } from '@/utils/customHelpers';
15
+ import { bookingHelpers } from '@/utils/api/bookingHelpers';
16
+ import { NativeBookingCalendar } from './NativeBookingCalendar';
17
+ import { ProfileStorage } from '@/utils/profileStorage';
15
18
  import type { ResourceNode } from '@/types/compositorTypes';
16
19
 
20
+ type CheckoutState =
21
+ | 'IDENTITY_EMAIL'
22
+ | 'IDENTITY_NEW_USER'
23
+ | 'BOOKING'
24
+ | 'SUMMARY'
25
+ | 'PROCESSING'
26
+ | 'SUCCESS';
27
+
17
28
  interface CheckoutModalProps {
18
- resources: ResourceNode[];
29
+ maxLength: number;
30
+ resources?: ResourceNode[];
19
31
  }
20
32
 
21
- export default function CheckoutModal({ resources = [] }: CheckoutModalProps) {
22
- const state = useStore(cartState);
23
- const cart = useStore(cartStore);
24
-
25
- const [name, setName] = useState('');
26
- const [email, setEmail] = useState('');
27
- const [traceId, setTraceId] = useState('');
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);
28
40
 
29
- const duration = calculateCartDuration(cart, resources);
30
- const bookingSlug = getBookingBucket(duration);
31
- const needsBooking = !!bookingSlug;
41
+ const [internalState, setInternalState] =
42
+ useState<CheckoutState>('IDENTITY_EMAIL');
32
43
 
33
- const needsPayment = Object.values(cart).some((item) => {
34
- if (item.gid) return true;
35
- const resource = resources.find((r) => r.id === item.resourceId);
36
- return !!resource?.optionsPayload?.gid;
44
+ const [email, setEmail] = useState(() => {
45
+ const profile = ProfileStorage.getProfileData();
46
+ if (ProfileStorage.isProfileUnlocked() && profile.email)
47
+ return profile.email;
48
+ return $customer.email || '';
37
49
  });
38
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
+
39
62
  const isOpen =
40
- state === CART_STATES.CHECKOUT ||
41
- state === CART_STATES.BOOKING ||
42
- state === CART_STATES.SHOPIFY_HANDOFF;
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]);
43
113
 
44
114
  useEffect(() => {
45
- if (state === CART_STATES.CHECKOUT && !traceId) {
46
- setTraceId(ulid());
47
- const saved = customerDetails.get();
48
- if (saved.name) setName(saved.name);
49
- if (saved.email) setEmail(saved.email);
115
+ const profile = ProfileStorage.getProfileData();
116
+ if (ProfileStorage.isProfileUnlocked() && profile.email) {
117
+ setEmail(profile.email);
118
+ } else if ($customer.email) {
119
+ setEmail($customer.email);
50
120
  }
51
- if (state === CART_STATES.READY) {
52
- setTraceId('');
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
+ }
53
146
  }
54
- }, [state, traceId]);
147
+ }, [$globalCartState, needsBooking, $customer.leadId, internalState]);
55
148
 
56
- const handleClose = () => {
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('');
57
164
  cartState.set(CART_STATES.READY);
165
+ setInternalState('IDENTITY_EMAIL');
166
+ setError(null);
167
+ if (redirect) window.location.href = `/`;
58
168
  };
59
169
 
60
- const handleIdentitySubmit = (e: FormEvent) => {
170
+ const handleEmailLookup = async (e: FormEvent) => {
61
171
  e.preventDefault();
62
- if (!name || !email) return;
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
+ };
63
188
 
64
- customerDetails.set({ name, email });
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');
65
218
 
66
- if (needsBooking) {
67
- cartState.set(CART_STATES.BOOKING);
68
- } else if (needsPayment) {
69
- cartState.set(CART_STATES.SHOPIFY_HANDOFF);
70
- } else {
71
- cartState.set(CART_STATES.READY);
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');
72
234
  }
73
235
  };
74
236
 
75
- const handleBookingSuccess = () => {
76
- if (needsPayment) {
77
- cartState.set(CART_STATES.SHOPIFY_HANDOFF);
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
+ }
78
280
  } else {
79
- alert('Booking Confirmed!');
80
- cartStore.set({});
81
- cartState.set(CART_STATES.READY);
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');
82
369
  }
83
370
  };
84
371
 
85
372
  if (!isOpen) return null;
86
373
 
87
374
  return (
88
- <Dialog.Root open={isOpen} onOpenChange={(e) => !e.open && handleClose()}>
89
- <Portal>
375
+ <Portal>
376
+ <Dialog.Root open={isOpen} onOpenChange={(e) => !e.open && handleClose()}>
90
377
  <Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-75 backdrop-blur-sm" />
91
378
  <Dialog.Positioner className="fixed inset-0 z-50 flex items-center justify-center p-4">
92
379
  <Dialog.Content className="w-full max-w-lg overflow-hidden rounded-lg bg-white shadow-xl">
93
- {state === CART_STATES.CHECKOUT && (
94
- <div className="p-6">
95
- <Dialog.Title className="text-xl font-bold text-gray-900">
96
- Checkout Details
97
- </Dialog.Title>
98
- <div className="mt-2 text-sm text-gray-500">
99
- Please provide your details to continue.
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}
100
395
  </div>
101
-
102
- <form
103
- onSubmit={handleIdentitySubmit}
104
- className="mt-6 space-y-4"
105
- >
396
+ )}
397
+ {internalState === 'IDENTITY_EMAIL' && (
398
+ <form onSubmit={handleEmailLookup} className="space-y-6">
106
399
  <div>
107
- <label
108
- htmlFor="name"
109
- className="block text-sm font-medium text-gray-700"
110
- >
111
- Full Name
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:
112
435
  </label>
113
436
  <input
114
437
  type="text"
115
- id="name"
116
438
  required
117
439
  value={name}
118
440
  onChange={(e) => setName(e.target.value)}
119
- className="mt-1 block 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"
441
+ className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:ring-1 focus:ring-black"
120
442
  />
121
443
  </div>
122
- <div>
123
- <label
124
- htmlFor="email"
125
- className="block text-sm font-medium text-gray-700"
126
- >
127
- Email Address
444
+ <div className="space-y-1">
445
+ <label className="block text-sm font-bold text-gray-700">
446
+ Codeword to protect your account:
128
447
  </label>
129
448
  <input
130
- type="email"
131
- id="email"
449
+ type="password"
132
450
  required
133
- value={email}
134
- onChange={(e) => setEmail(e.target.value)}
135
- className="mt-1 block 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"
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"
136
454
  />
137
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>
138
522
 
139
- <div className="mt-6 flex justify-end gap-3">
140
- <button
141
- type="button"
142
- onClick={handleClose}
143
- className="rounded-md bg-gray-200 px-4 py-2 text-sm font-bold text-gray-800 hover:bg-gray-300"
144
- >
145
- Cancel
146
- </button>
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
+ )}
147
537
  <button
148
- type="submit"
149
- className="rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
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"
150
540
  >
151
- Continue
541
+ {needsPayment ? 'Complete Payment' : 'Complete Booking'}
152
542
  </button>
153
543
  </div>
154
- </form>
155
- </div>
156
- )}
157
-
158
- {state === CART_STATES.BOOKING && bookingSlug && (
159
- <div className="w-full overflow-hidden" style={{ height: 600 }}>
160
- <CalDotComBooking
161
- calSlug={bookingSlug}
162
- traceId={traceId}
163
- name={name}
164
- email={email}
165
- onSuccess={handleBookingSuccess}
166
- />
167
- </div>
168
- )}
169
-
170
- {state === CART_STATES.SHOPIFY_HANDOFF && (
171
- <div className="p-6">
172
- <ShopifyCheckout
173
- resources={resources}
174
- traceId={traceId}
175
- email={email}
176
- onError={() => {
177
- console.error('Hand-off failed');
178
- }}
179
- />
180
- </div>
181
- )}
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>
182
571
  </Dialog.Content>
183
572
  </Dialog.Positioner>
184
- </Portal>
185
- </Dialog.Root>
573
+ </Dialog.Root>
574
+ </Portal>
186
575
  );
187
576
  }