astro-tractstack 2.2.9 → 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 (50) 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/edit/pane/AddPanePanel_new.tsx +13 -2
  20. package/templates/src/components/form/advanced/APIConfigSection.tsx +165 -38
  21. package/templates/src/components/storykeep/Dashboard.tsx +17 -3
  22. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
  23. package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
  24. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +525 -0
  25. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
  26. package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
  27. package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
  28. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
  29. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +254 -0
  30. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
  31. package/templates/src/lib/resources.ts +11 -21
  32. package/templates/src/pages/api/shopify/createCart.ts +73 -0
  33. package/templates/src/pages/api/shopify/getProducts.ts +64 -0
  34. package/templates/src/pages/storykeep/login.astro +5 -10
  35. package/templates/src/pages/storykeep/logout.astro +1 -10
  36. package/templates/src/pages/storykeep/manage.astro +69 -0
  37. package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
  38. package/templates/src/pages/storykeep/shopify.astro +101 -0
  39. package/templates/src/stores/navigation.ts +3 -42
  40. package/templates/src/stores/nodes.ts +3 -1
  41. package/templates/src/stores/resources.ts +7 -10
  42. package/templates/src/stores/shopify.ts +210 -0
  43. package/templates/src/types/tractstack.ts +21 -0
  44. package/templates/src/utils/api/advancedConfig.ts +5 -1
  45. package/templates/src/utils/api/advancedHelpers.ts +48 -5
  46. package/templates/src/utils/api/brandHelpers.ts +4 -0
  47. package/templates/src/utils/api/resourceConfig.ts +13 -5
  48. package/templates/src/utils/customHelpers.ts +70 -0
  49. package/templates/src/utils/helpers.ts +59 -0
  50. package/utils/inject-files.ts +83 -2
@@ -0,0 +1,210 @@
1
+ import { atom, map, onMount } from 'nanostores';
2
+ import { persistentAtom } from '@nanostores/persistent';
3
+
4
+ export interface ShopifyVariant {
5
+ id: string;
6
+ title: string;
7
+ price: {
8
+ amount: string;
9
+ currencyCode: string;
10
+ };
11
+ compareAtPrice?: {
12
+ amount: string;
13
+ currencyCode: string;
14
+ };
15
+ sku?: string;
16
+ availableForSale: boolean;
17
+ requiresShipping: boolean;
18
+ selectedOptions: {
19
+ name: string;
20
+ value: string;
21
+ }[];
22
+ }
23
+
24
+ export const modalState = atom<{
25
+ isOpen: boolean;
26
+ type: 'success' | 'restriction';
27
+ title: string;
28
+ message: string;
29
+ }>({
30
+ isOpen: false,
31
+ type: 'success',
32
+ title: '',
33
+ message: '',
34
+ });
35
+
36
+ export interface ShopifyOption {
37
+ name: string;
38
+ values: string[];
39
+ }
40
+
41
+ export interface ShopifyImage {
42
+ url: string;
43
+ altText?: string;
44
+ }
45
+
46
+ export interface ShopifyProduct {
47
+ id: string;
48
+ title: string;
49
+ handle: string;
50
+ description: string;
51
+ options: ShopifyOption[];
52
+ images: ShopifyImage[];
53
+ variants: ShopifyVariant[];
54
+ }
55
+
56
+ export type CartActionType = 'add' | 'remove';
57
+
58
+ export interface CartAction {
59
+ resourceId: string;
60
+ gid?: string;
61
+ variantId?: string;
62
+ variantIdShipped?: string;
63
+ variantIdPickup?: string;
64
+ boundResourceId?: string;
65
+ action: CartActionType;
66
+ suppressModal?: boolean;
67
+ }
68
+
69
+ export interface CartItemState {
70
+ resourceId: string;
71
+ quantity: number;
72
+ variantId?: string;
73
+ gid?: string;
74
+ variantIdShipped?: string;
75
+ variantIdPickup?: string;
76
+ boundResourceId?: string;
77
+ }
78
+
79
+ export const QUEUE_STATES = {
80
+ READY: 'READY',
81
+ ADDING: 'ADDING',
82
+ HAS_REQUIREMENTS: 'HAS_REQUIREMENTS',
83
+ } as const;
84
+
85
+ export type QueueState = (typeof QUEUE_STATES)[keyof typeof QUEUE_STATES];
86
+
87
+ export const CART_STATES = {
88
+ INIT: 'INIT',
89
+ READY: 'READY',
90
+ LOADED: 'LOADED',
91
+ CHECKOUT: 'CHECKOUT',
92
+ BOOKING: 'BOOKING',
93
+ BOOKED: 'BOOKED',
94
+ SHOPIFY_HANDOFF: 'SHOPIFY_HANDOFF',
95
+ } as const;
96
+
97
+ export type CartState = (typeof CART_STATES)[keyof typeof CART_STATES];
98
+
99
+ export const isShopifyHandoff = atom<boolean>(false);
100
+
101
+ export const shopifyData = persistentAtom<{
102
+ products: ShopifyProduct[];
103
+ lastFetched: number;
104
+ }>(
105
+ 'tractstack_shopify_data',
106
+ {
107
+ products: [],
108
+ lastFetched: 0,
109
+ },
110
+ {
111
+ encode: JSON.stringify,
112
+ decode: JSON.parse,
113
+ }
114
+ );
115
+
116
+ // Non-persistent Status (Load states should reset on refresh)
117
+ export const shopifyStatus = map<{
118
+ isLoading: boolean;
119
+ error: string | null;
120
+ }>({
121
+ isLoading: false,
122
+ error: null,
123
+ });
124
+
125
+ export const addQueue = persistentAtom<CartAction[]>(
126
+ 'tractstack_shopify_queue',
127
+ [],
128
+ {
129
+ encode: JSON.stringify,
130
+ decode: JSON.parse,
131
+ }
132
+ );
133
+
134
+ // We use a backing persistentAtom for the cart to ensure data survives reloads,
135
+ // but expose it as a 'map' to preserve the .setKey() API used by consumers.
136
+ const cartPersistence = persistentAtom<Record<string, CartItemState>>(
137
+ 'tractstack_shopify_cart',
138
+ {},
139
+ {
140
+ encode: JSON.stringify,
141
+ decode: JSON.parse,
142
+ }
143
+ );
144
+
145
+ export const cartStore = map<Record<string, CartItemState>>(
146
+ cartPersistence.get()
147
+ );
148
+
149
+ onMount(cartStore, () => {
150
+ // Sync initial state from persistence (in case of race conditions or hydration delay)
151
+ cartStore.set(cartPersistence.get());
152
+
153
+ // Persist any changes made to the map
154
+ const unbind = cartStore.listen((value) => {
155
+ cartPersistence.set(value);
156
+ });
157
+
158
+ return () => unbind();
159
+ });
160
+
161
+ export const queueState = persistentAtom<QueueState>(
162
+ 'tractstack_shopify_queue_state',
163
+ QUEUE_STATES.READY
164
+ );
165
+ export const cartState = persistentAtom<CartState>(
166
+ 'tractstack_shopify_cart_state',
167
+ CART_STATES.INIT
168
+ );
169
+
170
+ export async function fetchShopifyProducts() {
171
+ shopifyStatus.set({ isLoading: true, error: null });
172
+
173
+ try {
174
+ const response = await fetch('/api/shopify/getProducts');
175
+ const result = await response.json();
176
+
177
+ if (!response.ok) {
178
+ throw new Error(result.error || 'Failed to fetch products');
179
+ }
180
+
181
+ shopifyData.set({
182
+ products: result.products,
183
+ lastFetched: Date.now(),
184
+ });
185
+
186
+ shopifyStatus.set({ isLoading: false, error: null });
187
+ } catch (error) {
188
+ console.error('Shopify fetch failed:', error);
189
+ shopifyStatus.set({
190
+ isLoading: false,
191
+ error:
192
+ error instanceof Error ? error.message : 'Failed to fetch products',
193
+ });
194
+ }
195
+ }
196
+
197
+ export const customerDetails = persistentAtom<{
198
+ name: string;
199
+ email: string;
200
+ }>(
201
+ 'tractstack_shopify_customer',
202
+ {
203
+ name: '',
204
+ email: '',
205
+ },
206
+ {
207
+ encode: JSON.stringify,
208
+ decode: JSON.parse,
209
+ }
210
+ );
@@ -183,6 +183,8 @@ export interface BrandConfig {
183
183
  KNOWN_RESOURCES?: KnownResourcesConfig;
184
184
  DESIGN_LIBRARY?: DesignLibraryConfig;
185
185
  HAS_AAI?: boolean;
186
+ HAS_SHOPIFY?: boolean;
187
+ HAS_RESEND?: boolean;
186
188
  HAS_HYDRATION_TOKEN?: boolean;
187
189
  }
188
190
 
@@ -217,6 +219,8 @@ export interface BrandConfigState {
217
219
  knownResources: KnownResourcesConfig;
218
220
  designLibrary?: DesignLibraryConfig;
219
221
  hasAAI: boolean;
222
+ hasShopify: boolean;
223
+ hasResend: boolean;
220
224
  hasHydrationToken: boolean;
221
225
  }
222
226
 
@@ -251,6 +255,12 @@ export interface AdvancedConfigStatus {
251
255
  editorPasswordSet: boolean;
252
256
  aaiAPIKeySet: boolean;
253
257
  tursoEnabled: boolean;
258
+ shopifyStorefrontTokenSet: boolean;
259
+ shopifyAdminApiKeySet: boolean;
260
+ shopifyApiSecretSet: boolean;
261
+ shopifyApiVersion: string;
262
+ shopifyStoreDomainSet: boolean;
263
+ resendApiKeySet: boolean;
254
264
  }
255
265
 
256
266
  export interface AdvancedConfigState {
@@ -259,6 +269,12 @@ export interface AdvancedConfigState {
259
269
  adminPassword: string;
260
270
  editorPassword: string;
261
271
  aaiApiKey: string;
272
+ shopifyStorefrontToken: string;
273
+ shopifyAdminApiKey: string;
274
+ shopifyApiSecret: string;
275
+ shopifyApiVersion: string;
276
+ shopifyStoreDomain: string;
277
+ resendApiKey: string;
262
278
  }
263
279
 
264
280
  export interface AdvancedConfigUpdateRequest {
@@ -269,6 +285,11 @@ export interface AdvancedConfigUpdateRequest {
269
285
  AAI_API_KEY?: string;
270
286
  HOME_SLUG?: string;
271
287
  TRACTSTACK_HOME_SLUG?: string;
288
+ SHOPIFY_STOREFRONT_TOKEN?: string;
289
+ SHOPIFY_API_SECRET?: string;
290
+ SHOPIFY_API_VERSION?: string;
291
+ SHOPIFY_STORE_DOMAIN?: string;
292
+ RESEND_API_KEY?: string;
272
293
  }
273
294
 
274
295
  export interface MenuNodeState {
@@ -28,7 +28,11 @@ export async function getAdvancedConfigStatus(
28
28
  typeof data.tursoTokenSet !== 'boolean' ||
29
29
  typeof data.adminPasswordSet !== 'boolean' ||
30
30
  typeof data.editorPasswordSet !== 'boolean' ||
31
- typeof data.aaiAPIKeySet !== 'boolean'
31
+ typeof data.aaiAPIKeySet !== 'boolean' ||
32
+ typeof data.shopifyStorefrontTokenSet !== 'boolean' ||
33
+ typeof data.shopifyApiSecretSet !== 'boolean' ||
34
+ typeof data.shopifyStoreDomainSet !== 'boolean' ||
35
+ typeof data.resendApiKeySet !== 'boolean'
32
36
  ) {
33
37
  throw new Error('Invalid response format from server');
34
38
  }
@@ -19,6 +19,12 @@ export function convertToLocalState(
19
19
  adminPassword: '',
20
20
  editorPassword: '',
21
21
  aaiApiKey: '',
22
+ shopifyStorefrontToken: '',
23
+ shopifyAdminApiKey: '',
24
+ shopifyApiSecret: '',
25
+ shopifyApiVersion: '',
26
+ shopifyStoreDomain: '',
27
+ resendApiKey: '',
22
28
  };
23
29
  }
24
30
 
@@ -31,6 +37,12 @@ export function convertToLocalState(
31
37
  adminPassword: '',
32
38
  editorPassword: '',
33
39
  aaiApiKey: '',
40
+ shopifyStorefrontToken: '',
41
+ shopifyAdminApiKey: '',
42
+ shopifyApiSecret: '',
43
+ shopifyApiVersion: status.shopifyApiVersion || '',
44
+ shopifyStoreDomain: '',
45
+ resendApiKey: '',
34
46
  };
35
47
  }
36
48
 
@@ -64,6 +76,26 @@ export function convertToBackendFormat(
64
76
  request.AAI_API_KEY = state.aaiApiKey.trim();
65
77
  }
66
78
 
79
+ if (state.shopifyStorefrontToken?.trim()) {
80
+ request.SHOPIFY_STOREFRONT_TOKEN = state.shopifyStorefrontToken.trim();
81
+ }
82
+
83
+ if (state.shopifyApiSecret?.trim()) {
84
+ request.SHOPIFY_API_SECRET = state.shopifyApiSecret.trim();
85
+ }
86
+
87
+ if (state.shopifyApiVersion?.trim()) {
88
+ request.SHOPIFY_API_VERSION = state.shopifyApiVersion.trim();
89
+ }
90
+
91
+ if (state.shopifyStoreDomain?.trim()) {
92
+ request.SHOPIFY_STORE_DOMAIN = state.shopifyStoreDomain.trim();
93
+ }
94
+
95
+ if (state.resendApiKey?.trim()) {
96
+ request.RESEND_API_KEY = state.resendApiKey.trim();
97
+ }
98
+
67
99
  return request;
68
100
  }
69
101
 
@@ -78,18 +110,14 @@ export function validateAdvancedConfig(
78
110
  // Turso validation: both URL and token must be provided together
79
111
  const hasTursoUrl = Boolean(state.tursoUrl?.trim());
80
112
  const hasTursoToken = Boolean(state.tursoToken?.trim());
81
-
82
113
  if (hasTursoUrl && !hasTursoToken) {
83
114
  errors.tursoToken =
84
115
  'Turso Auth Token is required when Turso URL is provided';
85
116
  }
86
-
87
117
  if (hasTursoToken && !hasTursoUrl) {
88
118
  errors.tursoUrl =
89
119
  'Turso Database URL is required when Turso Token is provided';
90
120
  }
91
-
92
- // Basic URL validation for Turso
93
121
  if (hasTursoUrl && state.tursoUrl) {
94
122
  const urlPattern = /^libsql:\/\/.+/;
95
123
  if (!urlPattern.test(state.tursoUrl.trim())) {
@@ -97,12 +125,27 @@ export function validateAdvancedConfig(
97
125
  }
98
126
  }
99
127
 
128
+ // Shopify store domain validation
129
+ if (state.shopifyStoreDomain?.trim()) {
130
+ const domainPattern = /^[a-zA-Z0-9-]+\.myshopify\.com$/;
131
+ if (!domainPattern.test(state.shopifyStoreDomain.trim())) {
132
+ errors.shopifyStoreDomain =
133
+ 'Store domain must be in the format "your-shop.myshopify.com"';
134
+ }
135
+ }
136
+
137
+ if (state.shopifyApiVersion?.trim()) {
138
+ if (!/^\d{4}-\d{2}$/.test(state.shopifyApiVersion.trim())) {
139
+ errors.shopifyApiVersion =
140
+ 'Version must match YYYY-MM format (e.g. 2026-01)';
141
+ }
142
+ }
143
+
100
144
  // Password strength validation (optional but recommended)
101
145
  if (state.adminPassword && state.adminPassword.length < 8) {
102
146
  errors.adminPassword =
103
147
  'Admin password should be at least 8 characters long';
104
148
  }
105
-
106
149
  if (state.editorPassword && state.editorPassword.length < 8) {
107
150
  errors.editorPassword =
108
151
  'Editor password should be at least 8 characters long';
@@ -40,6 +40,8 @@ export function convertToLocalState(
40
40
  knownResources: brandConfig.KNOWN_RESOURCES ?? {},
41
41
  designLibrary: brandConfig.DESIGN_LIBRARY ?? undefined,
42
42
  hasAAI: brandConfig.HAS_AAI ?? false,
43
+ hasShopify: brandConfig.HAS_SHOPIFY ?? false,
44
+ hasResend: brandConfig.HAS_RESEND ?? false,
43
45
  hasHydrationToken: brandConfig.HAS_HYDRATION_TOKEN ?? false,
44
46
  };
45
47
  }
@@ -72,6 +74,8 @@ export function convertToBackendFormat(
72
74
  KNOWN_RESOURCES: localState.knownResources,
73
75
  DESIGN_LIBRARY: localState.designLibrary,
74
76
  HAS_AAI: localState.hasAAI,
77
+ HAS_SHOPIFY: localState.hasShopify,
78
+ HAS_RESEND: localState.hasResend,
75
79
 
76
80
  // ALWAYS send asset paths (current state)
77
81
  LOGO: localState.logo,
@@ -104,11 +104,13 @@ export async function deleteResource(
104
104
 
105
105
  export async function getAllResourceIds(tenantId: string): Promise<string[]> {
106
106
  const api = new TractStackAPI(tenantId);
107
- const response = await api.get('/api/v1/nodes/resources');
107
+ const response = await api.get<{ count: number; resourceIds: string[] }>(
108
+ '/api/v1/nodes/resources'
109
+ );
108
110
  if (!response.success) {
109
111
  throw new Error(response.error || 'Failed to get resource IDs');
110
112
  }
111
- return response.data;
113
+ return response.data?.resourceIds || [];
112
114
  }
113
115
 
114
116
  export async function getResourcesByIds(
@@ -116,11 +118,14 @@ export async function getResourcesByIds(
116
118
  ids: string[]
117
119
  ): Promise<ResourceConfig[]> {
118
120
  const api = new TractStackAPI(tenantId);
119
- const response = await api.post('/api/v1/nodes/resources', { ids });
120
- if (!response.success) {
121
+ const response = await api.post<{ resources: ResourceConfig[] }>(
122
+ '/api/v1/nodes/resources',
123
+ { resourceIds: ids }
124
+ );
125
+ if (!response.success || !response.data) {
121
126
  throw new Error(response.error || 'Failed to get resources by IDs');
122
127
  }
123
- return response.data;
128
+ return response.data.resources;
124
129
  }
125
130
 
126
131
  export async function getResourcesByCategory(
@@ -128,6 +133,9 @@ export async function getResourcesByCategory(
128
133
  categorySlug: string
129
134
  ): Promise<ResourceConfig[]> {
130
135
  const allIds = await getAllResourceIds(tenantId);
136
+ if (!allIds || allIds.length === 0) {
137
+ return [];
138
+ }
131
139
  const allResources = await getResourcesByIds(tenantId, allIds);
132
140
  return allResources.filter(
133
141
  (resource) => resource.categorySlug === categorySlug
@@ -1,3 +1,6 @@
1
+ import type { ResourceNode } from '@/types/compositorTypes';
2
+ import type { CartItemState } from '@/stores/shopify';
3
+
1
4
  // URL Helper: Strip category prefix from slug
2
5
  // e.g., "people-bleako" -> "bleako"
3
6
  export function getCleanSlug(categorySlug: string, fullSlug: string): string {
@@ -36,3 +39,70 @@ export function initSearch(): void {
36
39
  // Default implementation does nothing
37
40
  // Override this function in your custom implementation to load search data
38
41
  }
42
+
43
+ // Field Visibility Controls for ResourceForm
44
+ export const resourceFormHideFields = ['gid', 'shopifyImage'];
45
+
46
+ // Field Formatting Controls for ResourceForm
47
+ // Fields listed here will be treated as JSON objects but rendered as stringified text areas
48
+ export const resourceJsonifyFields = ['shopifyData', 'shopifyImage'];
49
+
50
+ export const RESTRICTION_MESSAGES = {
51
+ BOOKING: (duration: number) =>
52
+ `This is a ${duration} minute service. On checkout we'll help you book at your convenience.`,
53
+ TERMS: 'Please review the terms for this item before adding it to your cart.',
54
+ MAX_DURATION: (max: number) =>
55
+ `You cannot book more than ${max} minutes of services in one session.`,
56
+ DEFAULT_ADD: (title: string) => `${title} has been added to your cart.`,
57
+ };
58
+
59
+ // For CartModal.tsx
60
+ export function checkRestrictions(resource: ResourceNode): boolean {
61
+ // 1. Service / Booking Requirement
62
+ // We check for the explicit option payload value used by services
63
+ if (resource.optionsPayload?.bookingLengthMinutes) {
64
+ return true;
65
+ }
66
+
67
+ // 2. Final Sale / Terms Check
68
+ // Placeholder: In the future, check for flags like resource.optionsPayload?.finalSale
69
+ // if (resource.optionsPayload?.finalSale) {
70
+ // return true;
71
+ // }
72
+
73
+ return false;
74
+ }
75
+
76
+ export const MAX_LENGTH_MINUTES = 180;
77
+
78
+ export const BOOKING_LINK_INCREMENTS = [30, 60, 90, 120, 150, 180];
79
+
80
+ export const BOOKING_LINKS: Record<number, string> = {
81
+ 30: '30min',
82
+ 60: '60min',
83
+ 90: '90min',
84
+ 120: '120min',
85
+ 150: '150min',
86
+ 180: '180min',
87
+ };
88
+
89
+ export function getBookingBucket(minutes: number): string | null {
90
+ if (minutes <= 0) return null;
91
+ if (minutes > MAX_LENGTH_MINUTES) return null;
92
+
93
+ const bucket = BOOKING_LINK_INCREMENTS.find((inc) => minutes <= inc);
94
+ return bucket ? BOOKING_LINKS[bucket] : null;
95
+ }
96
+
97
+ export function calculateCartDuration(
98
+ cart: Record<string, CartItemState>,
99
+ resources: ResourceNode[]
100
+ ): number {
101
+ return Object.values(cart).reduce((total, item) => {
102
+ const resource = resources.find((r) => r.id === item.resourceId);
103
+ const duration = Number(
104
+ resource?.optionsPayload?.bookingLengthMinutes || 0
105
+ );
106
+ return total + (isNaN(duration) ? 0 : duration * item.quantity);
107
+ }, 0);
108
+ }
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
2
2
  import { stopWords } from '@/constants/stopWords';
3
3
  import type { RefObject } from 'react';
4
4
  import type { MenuNode } from '@/types/tractstack';
5
+ import type { ResourceNode } from '@/types/compositorTypes';
5
6
 
6
7
  let progressInterval: NodeJS.Timeout | null = null;
7
8
  let safetyTimeout: NodeJS.Timeout | null = null;
@@ -536,3 +537,61 @@ export const resolveCollisions = () => {
536
537
  }
537
538
  });
538
539
  };
540
+
541
+ // Shopify Image Helper: Returns responsive WebP paths for the resource image
542
+ export function getShopifyImage(
543
+ resource: ResourceNode,
544
+ size: '600' | '1080' | '1920' = '600',
545
+ variantId?: string
546
+ ): { src: string; srcSet: string } {
547
+ let imageId = resource.optionsPayload?.image;
548
+
549
+ if (variantId && typeof resource.optionsPayload?.shopifyImage === 'string') {
550
+ try {
551
+ const variantMap = JSON.parse(resource.optionsPayload.shopifyImage);
552
+ if (variantMap[variantId]?.fileId) {
553
+ imageId = variantMap[variantId].fileId;
554
+ }
555
+ } catch (e) {
556
+ console.warn(
557
+ `[Shopify] Failed to parse shopifyImage map for resource ${resource.id}`,
558
+ e
559
+ );
560
+ }
561
+ }
562
+
563
+ if (imageId && typeof imageId === 'string') {
564
+ const baseUrl = `/media/images/resources/${imageId}`;
565
+ return {
566
+ src: `${baseUrl}_${size}px.webp`,
567
+ srcSet: `${baseUrl}_1920px.webp 1920w, ${baseUrl}_1080px.webp 1080w, ${baseUrl}_600px.webp 600w`,
568
+ };
569
+ }
570
+
571
+ return {
572
+ src: '/static.jpg',
573
+ srcSet: '',
574
+ };
575
+ }
576
+
577
+ // Image Helper: Returns responsive WebP paths for the resource image
578
+ export function getResourceImage(
579
+ resource: ResourceNode,
580
+ size: '600' | '1080' | '1920' = '600'
581
+ ): { src: string; srcSet: string } {
582
+ const imageId = resource.optionsPayload?.image;
583
+
584
+ if (imageId && typeof imageId === 'string') {
585
+ const baseUrl = `/media/images/resources/${imageId}`;
586
+ return {
587
+ src: `${baseUrl}_${size}px.webp`,
588
+ srcSet: `${baseUrl}_1920px.webp 1920w, ${baseUrl}_1080px.webp 1080w, ${baseUrl}_600px.webp 600w`,
589
+ };
590
+ }
591
+
592
+ // Fallback for resources with no synced image
593
+ return {
594
+ src: '/static.jpg',
595
+ srcSet: '',
596
+ };
597
+ }