astro-tractstack 2.2.10 → 2.3.0

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 (49) hide show
  1. package/bin/create-tractstack.js +2 -2
  2. package/dist/index.js +89 -8
  3. package/package.json +3 -1
  4. package/templates/custom/minimal/CodeHook.astro +14 -5
  5. package/templates/custom/shopify/CalDotComBooking.tsx +44 -0
  6. package/templates/custom/shopify/Cart.tsx +345 -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 +187 -0
  10. package/templates/custom/shopify/ShopifyCartManager.tsx +145 -0
  11. package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
  12. package/templates/custom/shopify/ShopifyProductGrid.tsx +281 -0
  13. package/templates/custom/shopify/ShopifyServiceList.tsx +118 -0
  14. package/templates/custom/shopify/cart.astro +23 -0
  15. package/templates/custom/with-examples/CodeHook.astro +9 -1
  16. package/templates/custom/with-examples/ProductGrid.astro +1 -1
  17. package/templates/src/client/app.js +4 -2
  18. package/templates/src/components/Header.astro +37 -11
  19. package/templates/src/components/form/advanced/APIConfigSection.tsx +165 -38
  20. package/templates/src/components/storykeep/Dashboard.tsx +17 -3
  21. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
  22. package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
  23. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +525 -0
  24. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
  25. package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
  26. package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
  27. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
  28. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +254 -0
  29. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
  30. package/templates/src/lib/resources.ts +11 -21
  31. package/templates/src/pages/api/shopify/createCart.ts +73 -0
  32. package/templates/src/pages/api/shopify/getProducts.ts +64 -0
  33. package/templates/src/pages/storykeep/login.astro +5 -10
  34. package/templates/src/pages/storykeep/logout.astro +1 -10
  35. package/templates/src/pages/storykeep/manage.astro +69 -0
  36. package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
  37. package/templates/src/pages/storykeep/shopify.astro +101 -0
  38. package/templates/src/stores/navigation.ts +3 -42
  39. package/templates/src/stores/nodes.ts +3 -1
  40. package/templates/src/stores/resources.ts +7 -10
  41. package/templates/src/stores/shopify.ts +210 -0
  42. package/templates/src/types/tractstack.ts +21 -0
  43. package/templates/src/utils/api/advancedConfig.ts +5 -1
  44. package/templates/src/utils/api/advancedHelpers.ts +48 -5
  45. package/templates/src/utils/api/brandHelpers.ts +4 -0
  46. package/templates/src/utils/api/resourceConfig.ts +13 -5
  47. package/templates/src/utils/customHelpers.ts +70 -0
  48. package/templates/src/utils/helpers.ts +59 -0
  49. package/utils/inject-files.ts +83 -2
@@ -0,0 +1,47 @@
1
+ import { useStore } from '@nanostores/react';
2
+ import { cartStore } from '@/stores/shopify';
3
+
4
+ export default function CartIcon() {
5
+ const cart = useStore(cartStore);
6
+ const cartValues = Object.values(cart);
7
+ const boundServiceIds = new Set(
8
+ cartValues.map((item) => item.boundResourceId).filter(Boolean)
9
+ );
10
+ const totalQuantity = cartValues
11
+ .filter((item) => !boundServiceIds.has(item.resourceId))
12
+ .reduce((total, item) => total + item.quantity, 0);
13
+
14
+ const handleOpenCart = () => {
15
+ window.location.href = '/cart';
16
+ };
17
+
18
+ if (totalQuantity === 0) {
19
+ return null;
20
+ }
21
+
22
+ return (
23
+ <button
24
+ onClick={handleOpenCart}
25
+ className="relative flex items-center justify-center rounded-full p-2 text-gray-700 transition-colors hover:bg-gray-100"
26
+ aria-label="Open Cart"
27
+ >
28
+ <svg
29
+ xmlns="http://www.w3.org/2000/svg"
30
+ fill="none"
31
+ viewBox="0 0 24 24"
32
+ strokeWidth={1.5}
33
+ stroke="currentColor"
34
+ className="h-6 w-6"
35
+ >
36
+ <path
37
+ strokeLinecap="round"
38
+ strokeLinejoin="round"
39
+ d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
40
+ />
41
+ </svg>
42
+ <span className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-xs font-bold text-white ring-2 ring-white">
43
+ {totalQuantity}
44
+ </span>
45
+ </button>
46
+ );
47
+ }
@@ -0,0 +1,63 @@
1
+ import { useStore } from '@nanostores/react';
2
+ import { Dialog } from '@ark-ui/react/dialog';
3
+ import { Portal } from '@ark-ui/react/portal';
4
+ import { modalState } from '@/stores/shopify';
5
+
6
+ export default function CartModal() {
7
+ const state = useStore(modalState);
8
+
9
+ const handleClose = () => {
10
+ modalState.set({ ...state, isOpen: false });
11
+ };
12
+
13
+ const handleAccept = () => {
14
+ modalState.set({ ...state, isOpen: false });
15
+ window.location.href = '/cart';
16
+ };
17
+
18
+ if (!state.isOpen) return null;
19
+
20
+ const isCartPage =
21
+ typeof window !== 'undefined' && window.location.pathname === '/cart';
22
+
23
+ return (
24
+ <Dialog.Root
25
+ open={state.isOpen}
26
+ onOpenChange={(e) => !e.open && handleClose()}
27
+ >
28
+ <Portal>
29
+ <Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-75 backdrop-blur-sm" />
30
+ <Dialog.Positioner className="fixed inset-0 z-50 flex items-center justify-center p-4">
31
+ <Dialog.Content className="w-full max-w-md overflow-hidden rounded-lg bg-white shadow-xl">
32
+ <div className="p-6">
33
+ <Dialog.Title className="text-xl font-bold text-gray-900">
34
+ {state.title}
35
+ </Dialog.Title>
36
+
37
+ <div className="mt-4 text-gray-600">
38
+ <p>{state.message}</p>
39
+ </div>
40
+
41
+ <div className="mt-6 flex justify-end gap-3">
42
+ <button
43
+ onClick={handleClose}
44
+ className="rounded-md bg-gray-200 px-4 py-2 text-sm font-bold text-gray-800 hover:bg-gray-300"
45
+ >
46
+ {isCartPage ? 'Close' : 'Continue Shopping'}
47
+ </button>
48
+ {!isCartPage && (
49
+ <button
50
+ onClick={handleAccept}
51
+ className="rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
52
+ >
53
+ View Cart
54
+ </button>
55
+ )}
56
+ </div>
57
+ </div>
58
+ </Dialog.Content>
59
+ </Dialog.Positioner>
60
+ </Portal>
61
+ </Dialog.Root>
62
+ );
63
+ }
@@ -0,0 +1,187 @@
1
+ import { useState, useEffect, type FormEvent } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import { ulid } from 'ulid';
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
+ CART_STATES,
11
+ } from '@/stores/shopify';
12
+ import CalDotComBooking from './CalDotComBooking';
13
+ import ShopifyCheckout from './ShopifyCheckout';
14
+ import { calculateCartDuration, getBookingBucket } from '@/utils/customHelpers';
15
+ import type { ResourceNode } from '@/types/compositorTypes';
16
+
17
+ interface CheckoutModalProps {
18
+ resources: ResourceNode[];
19
+ }
20
+
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('');
28
+
29
+ const duration = calculateCartDuration(cart, resources);
30
+ const bookingSlug = getBookingBucket(duration);
31
+ const needsBooking = !!bookingSlug;
32
+
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;
37
+ });
38
+
39
+ const isOpen =
40
+ state === CART_STATES.CHECKOUT ||
41
+ state === CART_STATES.BOOKING ||
42
+ state === CART_STATES.SHOPIFY_HANDOFF;
43
+
44
+ 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);
50
+ }
51
+ if (state === CART_STATES.READY) {
52
+ setTraceId('');
53
+ }
54
+ }, [state, traceId]);
55
+
56
+ const handleClose = () => {
57
+ cartState.set(CART_STATES.READY);
58
+ };
59
+
60
+ const handleIdentitySubmit = (e: FormEvent) => {
61
+ e.preventDefault();
62
+ if (!name || !email) return;
63
+
64
+ customerDetails.set({ name, email });
65
+
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);
72
+ }
73
+ };
74
+
75
+ const handleBookingSuccess = () => {
76
+ if (needsPayment) {
77
+ cartState.set(CART_STATES.SHOPIFY_HANDOFF);
78
+ } else {
79
+ alert('Booking Confirmed!');
80
+ cartStore.set({});
81
+ cartState.set(CART_STATES.READY);
82
+ }
83
+ };
84
+
85
+ if (!isOpen) return null;
86
+
87
+ return (
88
+ <Dialog.Root open={isOpen} onOpenChange={(e) => !e.open && handleClose()}>
89
+ <Portal>
90
+ <Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-75 backdrop-blur-sm" />
91
+ <Dialog.Positioner className="fixed inset-0 z-50 flex items-center justify-center p-4">
92
+ <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.
100
+ </div>
101
+
102
+ <form
103
+ onSubmit={handleIdentitySubmit}
104
+ className="mt-6 space-y-4"
105
+ >
106
+ <div>
107
+ <label
108
+ htmlFor="name"
109
+ className="block text-sm font-medium text-gray-700"
110
+ >
111
+ Full Name
112
+ </label>
113
+ <input
114
+ type="text"
115
+ id="name"
116
+ required
117
+ value={name}
118
+ 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"
120
+ />
121
+ </div>
122
+ <div>
123
+ <label
124
+ htmlFor="email"
125
+ className="block text-sm font-medium text-gray-700"
126
+ >
127
+ Email Address
128
+ </label>
129
+ <input
130
+ type="email"
131
+ id="email"
132
+ 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"
136
+ />
137
+ </div>
138
+
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>
147
+ <button
148
+ type="submit"
149
+ className="rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
150
+ >
151
+ Continue
152
+ </button>
153
+ </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
+ )}
182
+ </Dialog.Content>
183
+ </Dialog.Positioner>
184
+ </Portal>
185
+ </Dialog.Root>
186
+ );
187
+ }
@@ -0,0 +1,145 @@
1
+ import { useEffect } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import { addQueue, cartStore, modalState } from '@/stores/shopify';
4
+ import {
5
+ calculateCartDuration,
6
+ MAX_LENGTH_MINUTES,
7
+ RESTRICTION_MESSAGES,
8
+ } from '@/utils/customHelpers';
9
+ import type { ResourceNode } from '@/types/compositorTypes';
10
+ import type { CartItemState } from '@/stores/shopify';
11
+
12
+ interface ShopifyCartManagerProps {
13
+ resources: ResourceNode[];
14
+ }
15
+
16
+ export default function ShopifyCartManager({
17
+ resources = [],
18
+ }: ShopifyCartManagerProps) {
19
+ const queue = useStore(addQueue);
20
+
21
+ useEffect(() => {
22
+ if (queue.length > 0) {
23
+ const actionItem = queue[0];
24
+ const remaining = queue.slice(1);
25
+ const resource = resources.find((r) => r.id === actionItem.resourceId);
26
+
27
+ if (!resource) {
28
+ addQueue.set(remaining);
29
+ return;
30
+ }
31
+
32
+ const key =
33
+ actionItem.variantId ||
34
+ `${actionItem.resourceId}_${actionItem.variantIdShipped || 'null'}_${
35
+ actionItem.variantIdPickup || 'null'
36
+ }`;
37
+
38
+ const currentCart = cartStore.get();
39
+ const currentItem = currentCart[key];
40
+ const currentQty = currentItem?.quantity || 0;
41
+
42
+ if (actionItem.action === 'remove') {
43
+ const newQty = Math.max(0, currentQty - 1);
44
+
45
+ if (newQty === 0) {
46
+ const newCart = { ...currentCart };
47
+ delete newCart[key];
48
+ cartStore.set(newCart);
49
+ } else {
50
+ cartStore.setKey(key, {
51
+ resourceId: actionItem.resourceId,
52
+ quantity: newQty,
53
+ gid: actionItem.gid || currentItem?.gid,
54
+ variantId: actionItem.variantId || currentItem?.variantId,
55
+ variantIdShipped:
56
+ actionItem.variantIdShipped || currentItem?.variantIdShipped,
57
+ variantIdPickup:
58
+ actionItem.variantIdPickup || currentItem?.variantIdPickup,
59
+ boundResourceId:
60
+ actionItem.boundResourceId || currentItem?.boundResourceId,
61
+ });
62
+ }
63
+
64
+ addQueue.set(remaining);
65
+ } else if (actionItem.action === 'add') {
66
+ const newQty = currentQty + 1;
67
+
68
+ const newItem = {
69
+ resourceId: actionItem.resourceId,
70
+ quantity: newQty,
71
+ gid: actionItem.gid || currentItem?.gid,
72
+ variantId: actionItem.variantId || currentItem?.variantId,
73
+ variantIdShipped:
74
+ actionItem.variantIdShipped || currentItem?.variantIdShipped,
75
+ variantIdPickup:
76
+ actionItem.variantIdPickup || currentItem?.variantIdPickup,
77
+ boundResourceId:
78
+ actionItem.boundResourceId || currentItem?.boundResourceId,
79
+ };
80
+
81
+ const nextCart: Record<string, CartItemState> = {
82
+ ...currentCart,
83
+ [key]: newItem,
84
+ };
85
+
86
+ if (actionItem.boundResourceId) {
87
+ const serviceEntry = Object.entries(currentCart).find(
88
+ ([_, item]) => item.resourceId === actionItem.boundResourceId
89
+ );
90
+
91
+ if (serviceEntry) {
92
+ const [serviceKey, serviceItem] = serviceEntry;
93
+ nextCart[serviceKey] = {
94
+ ...serviceItem,
95
+ quantity: serviceItem.quantity + 1,
96
+ };
97
+ } else {
98
+ nextCart[`temp_service_${actionItem.boundResourceId}`] = {
99
+ resourceId: actionItem.boundResourceId,
100
+ quantity: 1,
101
+ };
102
+ }
103
+ }
104
+
105
+ const duration = calculateCartDuration(nextCart, resources);
106
+ const bookingDuration = resource.optionsPayload?.bookingLengthMinutes;
107
+
108
+ if (duration > MAX_LENGTH_MINUTES) {
109
+ modalState.set({
110
+ isOpen: true,
111
+ type: 'restriction',
112
+ title: 'Appointment Length Limit Reached',
113
+ message: RESTRICTION_MESSAGES.MAX_DURATION(MAX_LENGTH_MINUTES),
114
+ });
115
+ } else {
116
+ cartStore.setKey(key, newItem);
117
+
118
+ if (!actionItem.suppressModal) {
119
+ if (resource.categorySlug === 'service') {
120
+ modalState.set({
121
+ isOpen: true,
122
+ type: 'success',
123
+ title: 'Booking Required',
124
+ message: RESTRICTION_MESSAGES.BOOKING(
125
+ (bookingDuration || 0).toString()
126
+ ),
127
+ });
128
+ } else {
129
+ modalState.set({
130
+ isOpen: true,
131
+ type: 'success',
132
+ title: 'Added to Cart',
133
+ message: RESTRICTION_MESSAGES.DEFAULT_ADD(resource.title),
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ addQueue.set(remaining);
140
+ }
141
+ }
142
+ }, [queue, resources]);
143
+
144
+ return null;
145
+ }
@@ -0,0 +1,167 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import {
4
+ cartStore,
5
+ cartState,
6
+ CART_STATES,
7
+ isShopifyHandoff,
8
+ } from '@/stores/shopify';
9
+ import { calculateCartDuration } from '@/utils/customHelpers';
10
+ import type { ResourceNode } from '@/types/compositorTypes';
11
+
12
+ interface ShopifyCheckoutProps {
13
+ traceId: string;
14
+ email: string;
15
+ resources: ResourceNode[];
16
+ onError: (error: string) => void;
17
+ }
18
+
19
+ export default function ShopifyCheckout({
20
+ traceId,
21
+ email,
22
+ resources = [],
23
+ onError,
24
+ }: ShopifyCheckoutProps) {
25
+ const cart = useStore(cartStore);
26
+ const [status, setStatus] = useState<'IDLE' | 'PROCESSING' | 'REDIRECTING'>(
27
+ 'IDLE'
28
+ );
29
+
30
+ useEffect(() => {
31
+ if (status !== 'IDLE') return;
32
+
33
+ const initCheckout = async () => {
34
+ setStatus('PROCESSING');
35
+
36
+ try {
37
+ const cartItems = Object.values(cart);
38
+
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
+ const lines = cartItems
44
+ .map((item) => {
45
+ // 1. Resolve the ResourceNode for this item
46
+ const resource = resources.find((r) => r.id === item.resourceId);
47
+
48
+ if (!resource) {
49
+ return null;
50
+ }
51
+
52
+ // 2. Determine the preferred Variant ID based on mode
53
+ const activeVariantId = isPickupMode
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
+ }
78
+
79
+ // If we still have no ID, we cannot add this item to the Shopify cart.
80
+ if (!merchandiseId) return null;
81
+
82
+ return {
83
+ merchandiseId,
84
+ quantity: item.quantity,
85
+ };
86
+ })
87
+ .filter((line) => line !== null) as Array<{
88
+ merchandiseId: string;
89
+ quantity: number;
90
+ }>;
91
+
92
+ if (lines.length === 0) {
93
+ throw new Error(
94
+ 'No valid Shopify items found in cart. Please try removing and re-adding your items.'
95
+ );
96
+ }
97
+
98
+ const payload = {
99
+ lines,
100
+ email,
101
+ attributes: [
102
+ {
103
+ key: 'Trace ID',
104
+ value: traceId,
105
+ },
106
+ ],
107
+ };
108
+
109
+ const response = await fetch('/api/shopify/createCart', {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ },
114
+ body: JSON.stringify(payload),
115
+ });
116
+
117
+ const result = await response.json();
118
+
119
+ if (!response.ok || result.error) {
120
+ throw new Error(result.error || 'Failed to create checkout');
121
+ }
122
+
123
+ if (result.checkoutUrl) {
124
+ setStatus('REDIRECTING');
125
+ isShopifyHandoff.set(true);
126
+ cartStore.set({});
127
+ cartState.set(CART_STATES.READY);
128
+ window.location.href = result.checkoutUrl;
129
+ } else {
130
+ throw new Error('No checkout URL returned from Shopify');
131
+ }
132
+ } catch (err) {
133
+ console.error('Checkout Error:', err);
134
+ setStatus('IDLE');
135
+ onError(err instanceof Error ? err.message : 'Checkout failed');
136
+ }
137
+ };
138
+
139
+ initCheckout();
140
+ }, [cart, email, traceId, resources, status, onError]);
141
+
142
+ return (
143
+ <div className="flex h-64 flex-col items-center justify-center text-center">
144
+ {status === 'REDIRECTING' ? (
145
+ <>
146
+ <div className="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-500"></div>
147
+ <h3 className="mt-4 text-lg font-bold text-gray-900">
148
+ Redirecting to Payment...
149
+ </h3>
150
+ <p className="mt-2 text-sm text-gray-500">
151
+ Please wait while we transfer you to Shopify.
152
+ </p>
153
+ </>
154
+ ) : (
155
+ <>
156
+ <div className="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-black"></div>
157
+ <h3 className="mt-4 text-lg font-bold text-gray-900">
158
+ Preparing your Invoice
159
+ </h3>
160
+ <p className="mt-2 text-sm text-gray-500">
161
+ Syncing booking details and calculating totals...
162
+ </p>
163
+ </>
164
+ )}
165
+ </div>
166
+ );
167
+ }