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.
- package/README.md +1 -1
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +94 -16
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +100 -73
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +92 -37
- package/templates/custom/shopify/ShopifyProductGrid.tsx +139 -173
- package/templates/custom/shopify/ShopifyServiceList.tsx +20 -3
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +4 -4
- package/templates/src/components/Header.astro +9 -3
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- 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/form/advanced/APIConfigSection.tsx +244 -2
- 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 +253 -110
- 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 +9 -5
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -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/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 +81 -25
- package/templates/src/types/tractstack.ts +54 -0
- 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/brandHelpers.ts +10 -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 +2 -1
- package/utils/inject-files.ts +82 -4
- 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
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
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
|
-
|
|
29
|
+
maxLength: number;
|
|
30
|
+
resources?: ResourceNode[];
|
|
19
31
|
}
|
|
20
32
|
|
|
21
|
-
export default function CheckoutModal({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
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
|
|
30
|
-
|
|
31
|
-
const needsBooking = !!bookingSlug;
|
|
41
|
+
const [internalState, setInternalState] =
|
|
42
|
+
useState<CheckoutState>('IDENTITY_EMAIL');
|
|
32
43
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
}, [
|
|
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
|
|
170
|
+
const handleEmailLookup = async (e: FormEvent) => {
|
|
61
171
|
e.preventDefault();
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
<
|
|
89
|
-
<
|
|
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
|
-
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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="
|
|
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
|
-
|
|
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="
|
|
131
|
-
id="email"
|
|
449
|
+
type="password"
|
|
132
450
|
required
|
|
133
|
-
value={
|
|
134
|
-
onChange={(e) =>
|
|
135
|
-
className="
|
|
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
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
+
{needsPayment ? 'Complete Payment' : 'Complete Booking'}
|
|
152
542
|
</button>
|
|
153
543
|
</div>
|
|
154
|
-
</
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
</
|
|
185
|
-
</
|
|
573
|
+
</Dialog.Root>
|
|
574
|
+
</Portal>
|
|
186
575
|
);
|
|
187
576
|
}
|