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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +130 -19
  4. package/package.json +2 -2
  5. package/templates/custom/minimal/CodeHook.astro +10 -2
  6. package/templates/custom/shopify/Cart.tsx +115 -77
  7. package/templates/custom/shopify/CheckoutModal.tsx +509 -120
  8. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  9. package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
  10. package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
  11. package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
  12. package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
  13. package/templates/custom/with-examples/CodeHook.astro +10 -2
  14. package/templates/src/components/Footer.astro +6 -6
  15. package/templates/src/components/Header.astro +23 -11
  16. package/templates/src/components/Menu.tsx +157 -135
  17. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
  19. package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
  20. package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
  21. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
  22. package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
  23. package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
  24. package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
  25. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
  26. package/templates/src/components/edit/ToolBar.tsx +2 -1
  27. package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
  28. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
  29. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  30. package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
  31. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  32. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  33. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  34. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  35. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  36. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  37. package/templates/src/components/edit/state/SaveModal.tsx +1 -1
  38. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
  39. package/templates/src/components/form/DateTimeInput.tsx +10 -3
  40. package/templates/src/components/form/FileUpload.tsx +11 -5
  41. package/templates/src/components/form/NumberInput.tsx +2 -2
  42. package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
  43. package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
  44. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  45. package/templates/src/components/storykeep/Dashboard.tsx +1 -1
  46. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
  47. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
  48. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  49. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  50. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  51. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
  52. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
  53. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
  54. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  55. package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
  56. package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
  57. package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
  58. package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
  59. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
  60. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
  61. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
  62. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  63. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  64. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  65. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  66. package/templates/src/layouts/Layout.astro +8 -5
  67. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  68. package/templates/src/pages/api/booking/availability.ts +72 -0
  69. package/templates/src/pages/api/booking/cancel.ts +73 -0
  70. package/templates/src/pages/api/booking/confirm.ts +82 -0
  71. package/templates/src/pages/api/booking/hold.ts +75 -0
  72. package/templates/src/pages/api/booking/list.ts +66 -0
  73. package/templates/src/pages/api/booking/metrics.ts +60 -0
  74. package/templates/src/pages/api/booking/release.ts +76 -0
  75. package/templates/src/pages/api/sandbox.ts +2 -2
  76. package/templates/src/pages/api/shopify/createCart.ts +4 -8
  77. package/templates/src/pages/api/shopify/getProducts.ts +15 -15
  78. package/templates/src/pages/storykeep/login.astro +21 -14
  79. package/templates/src/stores/shopify.ts +97 -25
  80. package/templates/src/types/formTypes.ts +4 -2
  81. package/templates/src/types/tractstack.ts +59 -2
  82. package/templates/src/utils/api/advancedConfig.ts +2 -0
  83. package/templates/src/utils/api/advancedHelpers.ts +40 -3
  84. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  85. package/templates/src/utils/api/brandConfig.ts +2 -0
  86. package/templates/src/utils/api/brandHelpers.ts +26 -0
  87. package/templates/src/utils/api/emailHelpers.ts +105 -0
  88. package/templates/src/utils/auth.ts +29 -9
  89. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  90. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  91. package/templates/src/utils/customHelpers.ts +0 -21
  92. package/templates/src/utils/profileStorage.ts +5 -0
  93. package/templates/src/utils/tenantResolver.ts +3 -2
  94. package/utils/inject-files.ts +116 -5
  95. 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((o) => o.name !== 'Mode');
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 = getVariant(hasModeOption ? 'Shipped' : null);
89
- const variantPickup = getVariant(hasModeOption ? 'Pickup' : null);
90
- const cartKey = `${resource.id}_${variantShipped?.id || 'null'}_${
91
- variantPickup?.id || 'null'
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 = (action: 'add' | 'remove') => {
107
- if (action === 'remove') {
108
- const queueUpdates: CartAction[] = [];
109
-
110
- queueUpdates.push({
113
+ const handleAction = () => {
114
+ const queueUpdates: CartAction[] = [
115
+ {
111
116
  resourceId: resource.id,
112
- variantIdShipped: variantShipped?.id,
113
- variantIdPickup: variantPickup?.id,
114
- action: 'remove',
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
- } else if (serviceBoundSlug) {
161
- console.warn(
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 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
171
- <div className="aspect-square w-full overflow-hidden bg-gray-100">
172
- <img
173
- src={src}
174
- srcSet={srcSet}
175
- alt={resource.title}
176
- className="h-full w-full object-cover object-center"
177
- loading="lazy"
178
- />
179
- </div>
180
-
181
- <div className="flex flex-1 flex-col p-6">
182
- <h3 className="text-lg font-bold text-gray-900">{resource.title}</h3>
183
- <p className="mt-2 flex-grow text-sm text-gray-500">
184
- {resource.oneliner}
185
- </p>
186
-
187
- {boundServiceResource && (
188
- <div className="mt-2 w-fit rounded bg-blue-50 p-3 text-xs font-bold text-blue-700">
189
- Includes {boundServiceResource.title}
190
- </div>
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
- {visibleOptions.length > 0 && (
194
- <div className="mt-4 w-fit space-y-3 p-1">
195
- {visibleOptions.map((opt) => (
196
- <div key={opt.name}>
197
- <label className="mb-1 block text-xs font-bold text-gray-700">
198
- {opt.name}
199
- </label>
200
- <select
201
- value={selections[opt.name]}
202
- onChange={(e) => {
203
- const newVal = e.target.value;
204
- setSelections((prev) => ({
205
- ...prev,
206
- [opt.name]: newVal,
207
- }));
208
- }}
209
- className="block w-full rounded-md border-gray-300 p-2 text-sm shadow-sm focus:border-black focus:ring-black"
210
- >
211
- {opt.values.map((val) => (
212
- <option key={val} value={val}>
213
- {val}
214
- </option>
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
- <div className="mt-4 flex items-center justify-between">
223
- <span className="text-base font-bold text-gray-900">
224
- {price ? `${price} ${currency}` : ''}
225
- </span>
226
- <div className="flex items-center space-x-3">
227
- {quantity > 0 ? (
228
- <>
229
- <button
230
- onClick={() => handleAction('remove')}
231
- className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:bg-gray-100"
232
- aria-label="Remove one"
233
- >
234
- -
235
- </button>
236
- <span className="text-sm font-bold text-gray-900">
237
- {quantity}
238
- </span>
239
- <button
240
- onClick={() => handleAction('add')}
241
- className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:bg-gray-100"
242
- aria-label="Add one"
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
- Add to Cart
253
- </button>
254
- )}
255
- </div>
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
- </div>
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
- export default function ShopifyProductGrid({ resources = {} }: Props) {
263
- const products = resources['product'] || [];
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
- if (products.length === 0) {
267
- return null;
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
- <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
272
- {products.map((resource) => (
273
- <ProductCard
274
- key={resource.id}
275
- resource={resource}
276
- allServices={services}
277
- />
278
- ))}
279
- </div>
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 { cartStore, addQueue, type CartAction } from '@/stores/shopify';
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
- export default function ShopifyServiceList({ resources = {} }: Props) {
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
- const services = resources['service'] || [];
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
- <div className="space-y-4">
68
- {displayServices.map((resource) => {
69
- const variantId = getServiceVariantId(resource);
70
- const key = variantId || `${resource.id}_null_null`;
71
-
72
- const cartItem = cart[key];
73
- const isSelected = (cartItem?.quantity || 0) > 0;
74
- const duration = resource.optionsPayload?.bookingLengthMinutes;
75
-
76
- return (
77
- <div
78
- key={resource.id}
79
- className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
80
- isSelected
81
- ? 'border-black bg-gray-50'
82
- : 'border-gray-200 bg-white hover:border-gray-300'
83
- }`}
84
- >
85
- <div className="flex-grow">
86
- <div className="flex items-center gap-2">
87
- <h3 className="font-bold text-gray-900">{resource.title}</h3>
88
- {duration && (
89
- <span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-700">
90
- {duration} mins
91
- </span>
92
- )}
93
- </div>
94
- <p className="mt-1 text-sm text-gray-500">{resource.oneliner}</p>
95
- </div>
96
-
97
- <div className="ml-4 flex-shrink-0">
98
- <button
99
- onClick={() => handleToggle(resource, cartItem?.quantity || 0)}
100
- 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 ${
101
- isSelected ? 'bg-black' : 'bg-gray-200'
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
- </button>
112
- </div>
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
- </div>
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 resources={resourcesPayload} client:only="react" />
65
+ <ShopifyProductGrid
66
+ options={options}
67
+ resources={resourcesPayload}
68
+ client:only="react"
69
+ />
66
70
  ) : target === 'shopify-service-list' ? (
67
- <ShopifyServiceList resources={resourcesPayload} client:only="react" />
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>