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.
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +89 -8
- package/package.json +3 -1
- package/templates/custom/minimal/CodeHook.astro +14 -5
- package/templates/custom/shopify/CalDotComBooking.tsx +44 -0
- package/templates/custom/shopify/Cart.tsx +345 -0
- package/templates/custom/shopify/CartIcon.tsx +47 -0
- package/templates/custom/shopify/CartModal.tsx +63 -0
- package/templates/custom/shopify/CheckoutModal.tsx +187 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +145 -0
- package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
- package/templates/custom/shopify/ShopifyProductGrid.tsx +281 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +118 -0
- package/templates/custom/shopify/cart.astro +23 -0
- package/templates/custom/with-examples/CodeHook.astro +9 -1
- package/templates/custom/with-examples/ProductGrid.astro +1 -1
- package/templates/src/client/app.js +4 -2
- package/templates/src/components/Header.astro +37 -11
- package/templates/src/components/form/advanced/APIConfigSection.tsx +165 -38
- package/templates/src/components/storykeep/Dashboard.tsx +17 -3
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
- package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +525 -0
- package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
- package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
- package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +254 -0
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
- package/templates/src/lib/resources.ts +11 -21
- package/templates/src/pages/api/shopify/createCart.ts +73 -0
- package/templates/src/pages/api/shopify/getProducts.ts +64 -0
- package/templates/src/pages/storykeep/login.astro +5 -10
- package/templates/src/pages/storykeep/logout.astro +1 -10
- package/templates/src/pages/storykeep/manage.astro +69 -0
- package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
- package/templates/src/pages/storykeep/shopify.astro +101 -0
- package/templates/src/stores/navigation.ts +3 -42
- package/templates/src/stores/nodes.ts +3 -1
- package/templates/src/stores/resources.ts +7 -10
- package/templates/src/stores/shopify.ts +210 -0
- package/templates/src/types/tractstack.ts +21 -0
- package/templates/src/utils/api/advancedConfig.ts +5 -1
- package/templates/src/utils/api/advancedHelpers.ts +48 -5
- package/templates/src/utils/api/brandHelpers.ts +4 -0
- package/templates/src/utils/api/resourceConfig.ts +13 -5
- package/templates/src/utils/customHelpers.ts +70 -0
- package/templates/src/utils/helpers.ts +59 -0
- 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
|
+
}
|