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.
- 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/edit/pane/AddPanePanel_new.tsx +13 -2
- 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,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(
|
|
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
|
|
120
|
-
|
|
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
|
+
}
|