astro-tractstack 2.3.0 → 2.3.2
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 +130 -19
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +115 -77
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
- package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +6 -6
- package/templates/src/components/Header.astro +23 -11
- package/templates/src/components/Menu.tsx +157 -135
- package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
- package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
- package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
- package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
- package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
- package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
- package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
- package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
- package/templates/src/components/edit/ToolBar.tsx +2 -1
- package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- 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/edit/state/SaveModal.tsx +1 -1
- package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
- package/templates/src/components/form/DateTimeInput.tsx +10 -3
- package/templates/src/components/form/FileUpload.tsx +11 -5
- package/templates/src/components/form/NumberInput.tsx +2 -2
- package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- 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 +252 -110
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- 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 +88 -56
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
- package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
- package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
- package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -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/layouts/Layout.astro +8 -5
- 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 +97 -25
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +59 -2
- 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/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +26 -0
- package/templates/src/utils/api/emailHelpers.ts +105 -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 +3 -2
- package/utils/inject-files.ts +116 -5
- package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
|
@@ -6,6 +6,11 @@ import type { ResourceNode } from '@/types/compositorTypes';
|
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
8
8
|
resources: Record<string, ResourceNode[]>;
|
|
9
|
+
options?: {
|
|
10
|
+
params?: {
|
|
11
|
+
options?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
interface ShopifyOption {
|
|
@@ -17,6 +22,7 @@ interface ShopifyVariant {
|
|
|
17
22
|
id: string;
|
|
18
23
|
title: string;
|
|
19
24
|
price: { amount: string; currencyCode: string };
|
|
25
|
+
compareAtPrice?: { amount: string; currencyCode: string };
|
|
20
26
|
selectedOptions: { name: string; value: string }[];
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -31,7 +37,6 @@ function ProductCard({ resource, allServices }: ProductCardProps) {
|
|
|
31
37
|
const serviceBoundSlug = resource.optionsPayload?.serviceBound as
|
|
32
38
|
| string
|
|
33
39
|
| undefined;
|
|
34
|
-
|
|
35
40
|
const boundServiceResource = serviceBoundSlug
|
|
36
41
|
? allServices.find((r) => r.slug === serviceBoundSlug)
|
|
37
42
|
: undefined;
|
|
@@ -47,15 +52,12 @@ function ProductCard({ resource, allServices }: ProductCardProps) {
|
|
|
47
52
|
|
|
48
53
|
const options: ShopifyOption[] = product?.options || [];
|
|
49
54
|
const variants: ShopifyVariant[] = product?.variants || [];
|
|
50
|
-
|
|
51
|
-
const isUnconfigured = options.some((o) => o.name === 'Title');
|
|
52
|
-
|
|
53
|
-
if (isUnconfigured) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
55
|
+
const vendor: string = product?.vendor || '';
|
|
56
56
|
|
|
57
57
|
const hasModeOption = options.some((o) => o.name === 'Mode');
|
|
58
|
-
const visibleOptions = options.filter(
|
|
58
|
+
const visibleOptions = options.filter(
|
|
59
|
+
(o) => o.name !== 'Mode' && o.name !== 'Title'
|
|
60
|
+
);
|
|
59
61
|
|
|
60
62
|
const [selections, setSelections] = useState<Record<string, string>>(() => {
|
|
61
63
|
const initial: Record<string, string> = {};
|
|
@@ -66,216 +68,208 @@ function ProductCard({ resource, allServices }: ProductCardProps) {
|
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
const getVariant = (targetMode: 'Shipped' | 'Pickup' | null) => {
|
|
71
|
+
if (targetMode && !hasModeOption) return undefined;
|
|
69
72
|
const found = variants.find((v) => {
|
|
70
73
|
const optionsMatch = visibleOptions.every((opt) => {
|
|
71
74
|
const variantOpt = v.selectedOptions.find((o) => o.name === opt.name);
|
|
72
75
|
return variantOpt?.value === selections[opt.name];
|
|
73
76
|
});
|
|
74
|
-
|
|
75
77
|
if (!optionsMatch) return false;
|
|
76
|
-
|
|
77
78
|
if (hasModeOption && targetMode) {
|
|
78
79
|
const modeOpt = v.selectedOptions.find((o) => o.name === 'Mode');
|
|
79
80
|
return modeOpt?.value === targetMode;
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
-
return true;
|
|
82
|
+
return !(hasModeOption && !targetMode);
|
|
83
83
|
});
|
|
84
|
-
|
|
85
84
|
return found;
|
|
86
85
|
};
|
|
87
86
|
|
|
88
|
-
const variantShipped =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}`;
|
|
93
|
-
const cartItem = cart[cartKey];
|
|
94
|
-
const quantity = cartItem?.quantity || 0;
|
|
87
|
+
const variantShipped = hasModeOption
|
|
88
|
+
? getVariant('Shipped')
|
|
89
|
+
: getVariant(null);
|
|
90
|
+
const variantPickup = hasModeOption ? getVariant('Pickup') : undefined;
|
|
95
91
|
|
|
96
|
-
const currentDisplayVariant =
|
|
97
|
-
getVariant('Shipped') || getVariant('Pickup') || variants[0];
|
|
92
|
+
const currentDisplayVariant = variantShipped || variantPickup || variants[0];
|
|
98
93
|
const price = currentDisplayVariant?.price?.amount;
|
|
94
|
+
const compareAtPrice = currentDisplayVariant?.compareAtPrice?.amount;
|
|
99
95
|
const currency = currentDisplayVariant?.price?.currencyCode || 'USD';
|
|
96
|
+
|
|
97
|
+
// High contrast rose badge calculation
|
|
98
|
+
let discountPercent = 0;
|
|
99
|
+
if (price && compareAtPrice) {
|
|
100
|
+
const p = parseFloat(price);
|
|
101
|
+
const cap = parseFloat(compareAtPrice);
|
|
102
|
+
if (cap > p) {
|
|
103
|
+
discountPercent = Math.round(((cap - p) / cap) * 100);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
const { src, srcSet } = getShopifyImage(
|
|
101
108
|
resource,
|
|
102
109
|
'600',
|
|
103
110
|
currentDisplayVariant?.id
|
|
104
111
|
);
|
|
105
112
|
|
|
106
|
-
const handleAction = (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
queueUpdates.push({
|
|
113
|
+
const handleAction = () => {
|
|
114
|
+
const queueUpdates: CartAction[] = [
|
|
115
|
+
{
|
|
111
116
|
resourceId: resource.id,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (boundServiceResource) {
|
|
118
|
-
queueUpdates.push({
|
|
119
|
-
resourceId: boundServiceResource.id,
|
|
120
|
-
variantId: boundServiceResource.optionsPayload?.shopifyData
|
|
121
|
-
? JSON.parse(boundServiceResource.optionsPayload.shopifyData)
|
|
122
|
-
.variants?.[0]?.id
|
|
123
|
-
: undefined,
|
|
124
|
-
action: 'remove',
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
addQueue.set([...addQueue.get(), ...queueUpdates]);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const queueUpdates: CartAction[] = [];
|
|
133
|
-
|
|
134
|
-
const productAction: CartAction & { boundResourceId?: string } = {
|
|
135
|
-
resourceId: resource.id,
|
|
136
|
-
gid: product?.id,
|
|
137
|
-
variantIdShipped: variantShipped?.id,
|
|
138
|
-
variantIdPickup: variantPickup?.id,
|
|
139
|
-
action: 'add',
|
|
140
|
-
boundResourceId: boundServiceResource?.id,
|
|
141
|
-
};
|
|
142
|
-
queueUpdates.push(productAction);
|
|
143
|
-
|
|
144
|
-
if (boundServiceResource) {
|
|
145
|
-
let serviceVariantId = undefined;
|
|
146
|
-
try {
|
|
147
|
-
if (boundServiceResource.optionsPayload?.shopifyData) {
|
|
148
|
-
const serviceData = JSON.parse(
|
|
149
|
-
boundServiceResource.optionsPayload.shopifyData
|
|
150
|
-
);
|
|
151
|
-
serviceVariantId = serviceData.variants?.[0]?.id;
|
|
152
|
-
}
|
|
153
|
-
} catch (e) {}
|
|
154
|
-
|
|
155
|
-
queueUpdates.push({
|
|
156
|
-
resourceId: boundServiceResource.id,
|
|
157
|
-
variantId: serviceVariantId,
|
|
117
|
+
gid: product?.id,
|
|
118
|
+
variantId: !hasModeOption ? variantShipped?.id : undefined,
|
|
119
|
+
variantIdShipped: hasModeOption ? variantShipped?.id : undefined,
|
|
120
|
+
variantIdPickup: hasModeOption ? variantPickup?.id : undefined,
|
|
158
121
|
action: 'add',
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
`[Shopify] Service bound to slug '${serviceBoundSlug}' was not found in provided resources.`
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
122
|
+
boundResourceId: boundServiceResource?.id,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
166
125
|
addQueue.set([...addQueue.get(), ...queueUpdates]);
|
|
167
126
|
};
|
|
168
127
|
|
|
169
128
|
return (
|
|
170
|
-
<div className="flex flex-col
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
129
|
+
<div className="group flex flex-col text-left font-main">
|
|
130
|
+
{/* Clickable Area: Image and Title */}
|
|
131
|
+
<button
|
|
132
|
+
onClick={handleAction}
|
|
133
|
+
className="text-left focus:outline-none"
|
|
134
|
+
aria-label={`Add ${resource.title} to cart`}
|
|
135
|
+
>
|
|
136
|
+
{/* Rounded-2xl Frame with Top-Left Badge */}
|
|
137
|
+
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-brand-8 transition-opacity group-hover:opacity-90">
|
|
138
|
+
<img
|
|
139
|
+
src={src}
|
|
140
|
+
srcSet={srcSet}
|
|
141
|
+
alt={resource.title}
|
|
142
|
+
className="h-full w-full object-cover object-center"
|
|
143
|
+
loading="lazy"
|
|
144
|
+
/>
|
|
145
|
+
{discountPercent > 0 && (
|
|
146
|
+
<div className="absolute left-4 top-4 flex h-12 w-12 items-center justify-center rounded-md bg-rose-600 text-xs font-bold text-white shadow-sm">
|
|
147
|
+
-{discountPercent}%
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
192
151
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
))}
|
|
216
|
-
</select>
|
|
217
|
-
</div>
|
|
218
|
-
))}
|
|
152
|
+
<div className="mt-6 flex flex-col">
|
|
153
|
+
{/* Vendor Label */}
|
|
154
|
+
{vendor && (
|
|
155
|
+
<span className="text-xs font-bold uppercase tracking-widest text-brand-6">
|
|
156
|
+
{vendor}
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
<h3 className="mt-1 text-2xl font-bold text-brand-1">
|
|
161
|
+
{resource.title}
|
|
162
|
+
</h3>
|
|
163
|
+
|
|
164
|
+
{/* Combined Price Baseline */}
|
|
165
|
+
<div className="mt-1 flex items-baseline space-x-2">
|
|
166
|
+
<span className="text-lg font-bold text-brand-1">
|
|
167
|
+
{price} {currency}
|
|
168
|
+
</span>
|
|
169
|
+
{discountPercent > 0 && (
|
|
170
|
+
<span className="text-sm text-brand-6 line-through">
|
|
171
|
+
{compareAtPrice} {currency}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
219
174
|
</div>
|
|
220
|
-
)}
|
|
221
175
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
{
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
>
|
|
244
|
-
+
|
|
245
|
-
</button>
|
|
246
|
-
</>
|
|
247
|
-
) : (
|
|
248
|
-
<button
|
|
249
|
-
onClick={() => handleAction('add')}
|
|
250
|
-
className="rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
|
|
176
|
+
<p className="mt-3 text-sm text-brand-7">{resource.oneliner}</p>
|
|
177
|
+
</div>
|
|
178
|
+
</button>
|
|
179
|
+
|
|
180
|
+
{/* Interactive Variant Selectors */}
|
|
181
|
+
{visibleOptions.length > 0 && (
|
|
182
|
+
<div className="mt-4 space-y-4">
|
|
183
|
+
{visibleOptions.map((opt) => (
|
|
184
|
+
<div key={opt.name} onClick={(e) => e.stopPropagation()}>
|
|
185
|
+
<label className="mb-1 block text-xs font-bold uppercase text-brand-7">
|
|
186
|
+
{opt.name}
|
|
187
|
+
</label>
|
|
188
|
+
<select
|
|
189
|
+
value={selections[opt.name]}
|
|
190
|
+
onChange={(e) =>
|
|
191
|
+
setSelections((prev) => ({
|
|
192
|
+
...prev,
|
|
193
|
+
[opt.name]: e.target.value,
|
|
194
|
+
}))
|
|
195
|
+
}
|
|
196
|
+
className="block w-full border-b border-brand-8 bg-transparent py-2 text-sm text-brand-1 focus:border-brand-1 focus:outline-none"
|
|
251
197
|
>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
198
|
+
{opt.values.map((val) => (
|
|
199
|
+
<option key={val} value={val}>
|
|
200
|
+
{val}
|
|
201
|
+
</option>
|
|
202
|
+
))}
|
|
203
|
+
</select>
|
|
204
|
+
</div>
|
|
205
|
+
))}
|
|
256
206
|
</div>
|
|
257
|
-
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{boundServiceResource && (
|
|
210
|
+
<div className="bg-brand-4/10 mt-4 w-fit rounded px-2 py-1 text-xs font-bold text-brand-4">
|
|
211
|
+
INCLUDES {boundServiceResource.title.toUpperCase()}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
258
214
|
</div>
|
|
259
215
|
);
|
|
260
216
|
}
|
|
261
217
|
|
|
262
|
-
|
|
263
|
-
|
|
218
|
+
const HEX_BG_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
219
|
+
|
|
220
|
+
export default function ShopifyProductGrid({ resources = {}, options }: Props) {
|
|
221
|
+
let products = resources['product'] || [];
|
|
264
222
|
const services = resources['service'] || [];
|
|
265
223
|
|
|
266
|
-
|
|
267
|
-
|
|
224
|
+
let group = '';
|
|
225
|
+
let title = '';
|
|
226
|
+
let bgColor = '#f9f9f9';
|
|
227
|
+
try {
|
|
228
|
+
const parsedOptions = JSON.parse(options?.params?.options || '{}');
|
|
229
|
+
group = typeof parsedOptions.group === 'string' ? parsedOptions.group : '';
|
|
230
|
+
if (typeof parsedOptions.title === 'string') {
|
|
231
|
+
title = parsedOptions.title.trim();
|
|
232
|
+
}
|
|
233
|
+
const rawBg = parsedOptions.bgColor;
|
|
234
|
+
if (typeof rawBg === 'string' && HEX_BG_RE.test(rawBg)) {
|
|
235
|
+
bgColor = rawBg;
|
|
236
|
+
}
|
|
237
|
+
} catch (e) {}
|
|
238
|
+
|
|
239
|
+
if (group) {
|
|
240
|
+
products = products.filter((p) => p.optionsPayload?.group === group);
|
|
268
241
|
}
|
|
269
242
|
|
|
243
|
+
if (products.length === 0) return null;
|
|
244
|
+
|
|
270
245
|
return (
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
246
|
+
<section className="w-full">
|
|
247
|
+
<div
|
|
248
|
+
className="flex w-full flex-col gap-12 p-12 md:gap-14 md:p-12 xl:gap-16 xl:p-16"
|
|
249
|
+
style={{ backgroundColor: bgColor }}
|
|
250
|
+
>
|
|
251
|
+
{title ? (
|
|
252
|
+
<header className="max-w-4xl">
|
|
253
|
+
<h3
|
|
254
|
+
className="mb-6 text-balance font-action text-2xl font-bold md:text-3xl xl:text-4xl"
|
|
255
|
+
style={{ color: '#2d2923' }}
|
|
256
|
+
>
|
|
257
|
+
{title}
|
|
258
|
+
</h3>
|
|
259
|
+
</header>
|
|
260
|
+
) : null}
|
|
261
|
+
<section className="w-full">
|
|
262
|
+
<div className="grid grid-cols-2 gap-x-8 gap-y-16 md:gap-x-12 xl:grid-cols-3">
|
|
263
|
+
{products.map((resource) => (
|
|
264
|
+
<ProductCard
|
|
265
|
+
key={resource.id}
|
|
266
|
+
resource={resource}
|
|
267
|
+
allServices={services}
|
|
268
|
+
/>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
</section>
|
|
272
|
+
</div>
|
|
273
|
+
</section>
|
|
280
274
|
);
|
|
281
275
|
}
|
|
@@ -1,16 +1,49 @@
|
|
|
1
1
|
import { useStore } from '@nanostores/react';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
cartStore,
|
|
4
|
+
addQueue,
|
|
5
|
+
getCartItemKey,
|
|
6
|
+
type CartAction,
|
|
7
|
+
} from '@/stores/shopify';
|
|
3
8
|
import type { ResourceNode } from '@/types/compositorTypes';
|
|
4
9
|
|
|
5
10
|
interface Props {
|
|
6
11
|
resources: Record<string, ResourceNode[]>;
|
|
12
|
+
options?: {
|
|
13
|
+
params?: {
|
|
14
|
+
options?: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
7
17
|
}
|
|
8
18
|
|
|
9
|
-
|
|
19
|
+
const HEX_BG_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
20
|
+
|
|
21
|
+
export default function ShopifyServiceList({ resources = {}, options }: Props) {
|
|
10
22
|
const cart = useStore(cartStore);
|
|
11
23
|
|
|
12
24
|
const products = resources['product'] || [];
|
|
13
|
-
|
|
25
|
+
let services = resources['service'] || [];
|
|
26
|
+
|
|
27
|
+
let group = '';
|
|
28
|
+
let title = '';
|
|
29
|
+
let bgColor = '#f9f9f9';
|
|
30
|
+
try {
|
|
31
|
+
const parsedOptions = JSON.parse(options?.params?.options || '{}');
|
|
32
|
+
group = typeof parsedOptions.group === 'string' ? parsedOptions.group : '';
|
|
33
|
+
if (typeof parsedOptions.title === 'string') {
|
|
34
|
+
title = parsedOptions.title.trim();
|
|
35
|
+
}
|
|
36
|
+
const rawBg = parsedOptions.bgColor;
|
|
37
|
+
if (typeof rawBg === 'string' && HEX_BG_RE.test(rawBg)) {
|
|
38
|
+
bgColor = rawBg;
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// Ignore JSON parse errors
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (group) {
|
|
45
|
+
services = services.filter((s) => s.optionsPayload?.group === group);
|
|
46
|
+
}
|
|
14
47
|
|
|
15
48
|
const boundServiceSlugs = new Set(
|
|
16
49
|
products
|
|
@@ -64,55 +97,83 @@ export default function ShopifyServiceList({ resources = {} }: Props) {
|
|
|
64
97
|
}
|
|
65
98
|
|
|
66
99
|
return (
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}`}
|
|
103
|
-
role="switch"
|
|
104
|
-
aria-checked={isSelected}
|
|
105
|
-
>
|
|
106
|
-
<span
|
|
107
|
-
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
108
|
-
isSelected ? 'translate-x-5' : 'translate-x-0'
|
|
100
|
+
<section className="w-full">
|
|
101
|
+
<div
|
|
102
|
+
className="flex w-full flex-col p-12 md:p-12 xl:p-16"
|
|
103
|
+
style={{ backgroundColor: bgColor }}
|
|
104
|
+
>
|
|
105
|
+
{title ? (
|
|
106
|
+
<header className="max-w-4xl">
|
|
107
|
+
<h3
|
|
108
|
+
className="mb-6 text-balance font-action text-2xl font-bold md:text-3xl xl:text-4xl"
|
|
109
|
+
style={{ color: '#2d2923' }}
|
|
110
|
+
>
|
|
111
|
+
{title}
|
|
112
|
+
</h3>
|
|
113
|
+
</header>
|
|
114
|
+
) : null}
|
|
115
|
+
<section className="w-full">
|
|
116
|
+
<div className="space-y-4">
|
|
117
|
+
{displayServices.map((resource) => {
|
|
118
|
+
const variantId = getServiceVariantId(resource);
|
|
119
|
+
const key = getCartItemKey({
|
|
120
|
+
resourceId: resource.id,
|
|
121
|
+
variantId,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const cartItem = cart[key];
|
|
125
|
+
const isSelected = (cartItem?.quantity || 0) > 0;
|
|
126
|
+
const duration = resource.optionsPayload?.bookingLengthMinutes;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
key={resource.id}
|
|
131
|
+
className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
|
|
132
|
+
isSelected
|
|
133
|
+
? 'border-black bg-gray-50'
|
|
134
|
+
: 'border-gray-200 bg-white hover:border-gray-300'
|
|
109
135
|
}`}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
>
|
|
137
|
+
<div className="flex-grow">
|
|
138
|
+
<div className="flex items-center gap-2">
|
|
139
|
+
<h3 className="font-bold text-gray-900">
|
|
140
|
+
{resource.title}
|
|
141
|
+
</h3>
|
|
142
|
+
{duration && (
|
|
143
|
+
<span className="inline-flex items-center rounded-sm bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-700">
|
|
144
|
+
{duration} mins
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
149
|
+
{resource.oneliner}
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="ml-4 flex-shrink-0">
|
|
154
|
+
<button
|
|
155
|
+
onClick={() =>
|
|
156
|
+
handleToggle(resource, cartItem?.quantity || 0)
|
|
157
|
+
}
|
|
158
|
+
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
|
159
|
+
isSelected ? 'bg-black' : 'bg-gray-200'
|
|
160
|
+
}`}
|
|
161
|
+
role="switch"
|
|
162
|
+
aria-checked={isSelected}
|
|
163
|
+
>
|
|
164
|
+
<span
|
|
165
|
+
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
166
|
+
isSelected ? 'translate-x-5' : 'translate-x-0'
|
|
167
|
+
}`}
|
|
168
|
+
/>
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
113
174
|
</div>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
</
|
|
175
|
+
</section>
|
|
176
|
+
</div>
|
|
177
|
+
</section>
|
|
117
178
|
);
|
|
118
179
|
}
|
|
@@ -62,9 +62,17 @@ export const components = {
|
|
|
62
62
|
) : target === 'epinet' ? (
|
|
63
63
|
<EpinetWrapper fullContentMap={fullContentMap} client:only="react" />
|
|
64
64
|
) : target === 'shopify-product-grid' ? (
|
|
65
|
-
<ShopifyProductGrid
|
|
65
|
+
<ShopifyProductGrid
|
|
66
|
+
options={options}
|
|
67
|
+
resources={resourcesPayload}
|
|
68
|
+
client:only="react"
|
|
69
|
+
/>
|
|
66
70
|
) : target === 'shopify-service-list' ? (
|
|
67
|
-
<ShopifyServiceList
|
|
71
|
+
<ShopifyServiceList
|
|
72
|
+
options={options}
|
|
73
|
+
resources={resourcesPayload}
|
|
74
|
+
client:only="react"
|
|
75
|
+
/>
|
|
68
76
|
) : (
|
|
69
77
|
<div class="rounded-lg bg-gray-50 p-8 text-center">
|
|
70
78
|
<p class="text-gray-600">CodeHook target "{target}" not found</p>
|