astro-tractstack 2.3.0 → 2.3.1
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 +94 -16
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +100 -73
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +92 -37
- package/templates/custom/shopify/ShopifyProductGrid.tsx +139 -173
- package/templates/custom/shopify/ShopifyServiceList.tsx +20 -3
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +4 -4
- package/templates/src/components/Header.astro +9 -3
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- 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/form/advanced/APIConfigSection.tsx +244 -2
- 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 +253 -110
- 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 +9 -5
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -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/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 +81 -25
- package/templates/src/types/tractstack.ts +54 -0
- 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/brandHelpers.ts +10 -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 +2 -1
- package/utils/inject-files.ts +82 -4
- package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
|
@@ -53,6 +53,11 @@ export interface ShopifyProduct {
|
|
|
53
53
|
variants: ShopifyVariant[];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface ShopifyPageInfo {
|
|
57
|
+
hasNextPage: boolean;
|
|
58
|
+
endCursor: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
export type CartActionType = 'add' | 'remove';
|
|
57
62
|
|
|
58
63
|
export interface CartAction {
|
|
@@ -90,30 +95,23 @@ export const CART_STATES = {
|
|
|
90
95
|
LOADED: 'LOADED',
|
|
91
96
|
CHECKOUT: 'CHECKOUT',
|
|
92
97
|
BOOKING: 'BOOKING',
|
|
93
|
-
BOOKED: 'BOOKED',
|
|
94
98
|
SHOPIFY_HANDOFF: 'SHOPIFY_HANDOFF',
|
|
95
99
|
} as const;
|
|
96
100
|
|
|
97
101
|
export type CartState = (typeof CART_STATES)[keyof typeof CART_STATES];
|
|
98
102
|
|
|
99
103
|
export const isShopifyHandoff = atom<boolean>(false);
|
|
104
|
+
export const shopifyActiveTabStore = atom<string>('dashboards');
|
|
100
105
|
|
|
101
|
-
export const shopifyData =
|
|
106
|
+
export const shopifyData = atom<{
|
|
102
107
|
products: ShopifyProduct[];
|
|
108
|
+
pageInfo?: ShopifyPageInfo;
|
|
103
109
|
lastFetched: number;
|
|
104
|
-
}>(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
lastFetched: 0,
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
encode: JSON.stringify,
|
|
112
|
-
decode: JSON.parse,
|
|
113
|
-
}
|
|
114
|
-
);
|
|
110
|
+
}>({
|
|
111
|
+
products: [],
|
|
112
|
+
lastFetched: 0,
|
|
113
|
+
});
|
|
115
114
|
|
|
116
|
-
// Non-persistent Status (Load states should reset on refresh)
|
|
117
115
|
export const shopifyStatus = map<{
|
|
118
116
|
isLoading: boolean;
|
|
119
117
|
error: string | null;
|
|
@@ -131,9 +129,7 @@ export const addQueue = persistentAtom<CartAction[]>(
|
|
|
131
129
|
}
|
|
132
130
|
);
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
// but expose it as a 'map' to preserve the .setKey() API used by consumers.
|
|
136
|
-
const cartPersistence = persistentAtom<Record<string, CartItemState>>(
|
|
132
|
+
export const cartPersistence = persistentAtom<Record<string, CartItemState>>(
|
|
137
133
|
'tractstack_shopify_cart',
|
|
138
134
|
{},
|
|
139
135
|
{
|
|
@@ -147,10 +143,8 @@ export const cartStore = map<Record<string, CartItemState>>(
|
|
|
147
143
|
);
|
|
148
144
|
|
|
149
145
|
onMount(cartStore, () => {
|
|
150
|
-
// Sync initial state from persistence (in case of race conditions or hydration delay)
|
|
151
146
|
cartStore.set(cartPersistence.get());
|
|
152
147
|
|
|
153
|
-
// Persist any changes made to the map
|
|
154
148
|
const unbind = cartStore.listen((value) => {
|
|
155
149
|
cartPersistence.set(value);
|
|
156
150
|
});
|
|
@@ -167,11 +161,29 @@ export const cartState = persistentAtom<CartState>(
|
|
|
167
161
|
CART_STATES.INIT
|
|
168
162
|
);
|
|
169
163
|
|
|
170
|
-
|
|
164
|
+
let currentAbortController: AbortController | null = null;
|
|
165
|
+
|
|
166
|
+
export async function fetchShopifyProducts(
|
|
167
|
+
q: string = '',
|
|
168
|
+
cursor: string | null = null
|
|
169
|
+
) {
|
|
170
|
+
if (currentAbortController) {
|
|
171
|
+
currentAbortController.abort();
|
|
172
|
+
}
|
|
173
|
+
currentAbortController = new AbortController();
|
|
174
|
+
const signal = currentAbortController.signal;
|
|
175
|
+
|
|
171
176
|
shopifyStatus.set({ isLoading: true, error: null });
|
|
172
177
|
|
|
173
178
|
try {
|
|
174
|
-
const
|
|
179
|
+
const params = new URLSearchParams();
|
|
180
|
+
if (q) params.set('q', q);
|
|
181
|
+
if (cursor) params.set('cursor', cursor);
|
|
182
|
+
|
|
183
|
+
const queryString = params.toString();
|
|
184
|
+
const url = `/api/shopify/getProducts${queryString ? `?${queryString}` : ''}`;
|
|
185
|
+
|
|
186
|
+
const response = await fetch(url, { signal });
|
|
175
187
|
const result = await response.json();
|
|
176
188
|
|
|
177
189
|
if (!response.ok) {
|
|
@@ -179,12 +191,17 @@ export async function fetchShopifyProducts() {
|
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
shopifyData.set({
|
|
182
|
-
products: result.products,
|
|
194
|
+
products: result.products || [],
|
|
195
|
+
pageInfo: result.pageInfo,
|
|
183
196
|
lastFetched: Date.now(),
|
|
184
197
|
});
|
|
185
198
|
|
|
186
199
|
shopifyStatus.set({ isLoading: false, error: null });
|
|
187
|
-
} catch (error) {
|
|
200
|
+
} catch (error: unknown) {
|
|
201
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
188
205
|
console.error('Shopify fetch failed:', error);
|
|
189
206
|
shopifyStatus.set({
|
|
190
207
|
isLoading: false,
|
|
@@ -194,17 +211,56 @@ export async function fetchShopifyProducts() {
|
|
|
194
211
|
}
|
|
195
212
|
}
|
|
196
213
|
|
|
197
|
-
export
|
|
214
|
+
export function clearShopifySearch() {
|
|
215
|
+
if (currentAbortController) {
|
|
216
|
+
currentAbortController.abort();
|
|
217
|
+
}
|
|
218
|
+
shopifyData.set({
|
|
219
|
+
products: [],
|
|
220
|
+
pageInfo: undefined,
|
|
221
|
+
lastFetched: 0,
|
|
222
|
+
});
|
|
223
|
+
shopifyStatus.set({ isLoading: false, error: null });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const transactionTraceId = persistentAtom<string>(
|
|
227
|
+
'tractstack_shopify_trace_id',
|
|
228
|
+
''
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
export interface CustomerDetails {
|
|
198
232
|
name: string;
|
|
199
233
|
email: string;
|
|
200
|
-
|
|
234
|
+
leadId: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const customerDetails = persistentAtom<CustomerDetails>(
|
|
201
238
|
'tractstack_shopify_customer',
|
|
202
239
|
{
|
|
203
240
|
name: '',
|
|
204
241
|
email: '',
|
|
242
|
+
leadId: '',
|
|
205
243
|
},
|
|
206
244
|
{
|
|
207
245
|
encode: JSON.stringify,
|
|
208
246
|
decode: JSON.parse,
|
|
209
247
|
}
|
|
210
248
|
);
|
|
249
|
+
|
|
250
|
+
export function setCustomerDetails(details: Partial<CustomerDetails>) {
|
|
251
|
+
customerDetails.set({
|
|
252
|
+
...customerDetails.get(),
|
|
253
|
+
...details,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function clearCommerceState() {
|
|
258
|
+
cartStore.set({});
|
|
259
|
+
customerDetails.set({
|
|
260
|
+
name: '',
|
|
261
|
+
email: '',
|
|
262
|
+
leadId: '',
|
|
263
|
+
});
|
|
264
|
+
transactionTraceId.set('');
|
|
265
|
+
cartState.set(CART_STATES.READY);
|
|
266
|
+
}
|
|
@@ -152,6 +152,19 @@ export interface FullContentMapItem {
|
|
|
152
152
|
scale?: string;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
export interface TimeBlock {
|
|
156
|
+
start: string; // "09:00" for business hours or ISO-8601 for unavailable blocks
|
|
157
|
+
end: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface SchedulingConfig {
|
|
161
|
+
timezone: string;
|
|
162
|
+
bufferGapsMinutes: number;
|
|
163
|
+
maxLengthMinutes: number;
|
|
164
|
+
businessHours: Record<string, TimeBlock>;
|
|
165
|
+
unavailableHours: TimeBlock[];
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
export interface BrandConfig {
|
|
156
169
|
TENANT_ID: string;
|
|
157
170
|
SITE_INIT?: boolean;
|
|
@@ -184,8 +197,12 @@ export interface BrandConfig {
|
|
|
184
197
|
DESIGN_LIBRARY?: DesignLibraryConfig;
|
|
185
198
|
HAS_AAI?: boolean;
|
|
186
199
|
HAS_SHOPIFY?: boolean;
|
|
200
|
+
SHOW_SHOPIFY_HELPER?: boolean;
|
|
201
|
+
SHOPIFY_ADMIN_SLUG?: string;
|
|
202
|
+
USER_SETUP_WEBHOOKS?: boolean;
|
|
187
203
|
HAS_RESEND?: boolean;
|
|
188
204
|
HAS_HYDRATION_TOKEN?: boolean;
|
|
205
|
+
SCHEDULING?: SchedulingConfig;
|
|
189
206
|
}
|
|
190
207
|
|
|
191
208
|
export interface BrandConfigState {
|
|
@@ -220,8 +237,10 @@ export interface BrandConfigState {
|
|
|
220
237
|
designLibrary?: DesignLibraryConfig;
|
|
221
238
|
hasAAI: boolean;
|
|
222
239
|
hasShopify: boolean;
|
|
240
|
+
showShopifyHelper: boolean;
|
|
223
241
|
hasResend: boolean;
|
|
224
242
|
hasHydrationToken: boolean;
|
|
243
|
+
scheduling: SchedulingConfig;
|
|
225
244
|
}
|
|
226
245
|
|
|
227
246
|
// Form validation types
|
|
@@ -261,6 +280,8 @@ export interface AdvancedConfigStatus {
|
|
|
261
280
|
shopifyApiVersion: string;
|
|
262
281
|
shopifyStoreDomainSet: boolean;
|
|
263
282
|
resendApiKeySet: boolean;
|
|
283
|
+
shopifyAdminSlugSet: boolean;
|
|
284
|
+
userSetupWebhooks: boolean;
|
|
264
285
|
}
|
|
265
286
|
|
|
266
287
|
export interface AdvancedConfigState {
|
|
@@ -274,6 +295,8 @@ export interface AdvancedConfigState {
|
|
|
274
295
|
shopifyApiSecret: string;
|
|
275
296
|
shopifyApiVersion: string;
|
|
276
297
|
shopifyStoreDomain: string;
|
|
298
|
+
shopifyAdminSlug: string;
|
|
299
|
+
userSetupWebhooks: boolean;
|
|
277
300
|
resendApiKey: string;
|
|
278
301
|
}
|
|
279
302
|
|
|
@@ -289,6 +312,8 @@ export interface AdvancedConfigUpdateRequest {
|
|
|
289
312
|
SHOPIFY_API_SECRET?: string;
|
|
290
313
|
SHOPIFY_API_VERSION?: string;
|
|
291
314
|
SHOPIFY_STORE_DOMAIN?: string;
|
|
315
|
+
SHOPIFY_ADMIN_SLUG?: string;
|
|
316
|
+
USER_SETUP_WEBHOOKS?: boolean;
|
|
292
317
|
RESEND_API_KEY?: string;
|
|
293
318
|
}
|
|
294
319
|
|
|
@@ -490,3 +515,32 @@ export interface CategorizedResults {
|
|
|
490
515
|
contextPaneResults: FTSResult[];
|
|
491
516
|
resourceResults: FTSResult[];
|
|
492
517
|
}
|
|
518
|
+
|
|
519
|
+
export type BookingStatus = 'PENDING' | 'CONFIRMED' | 'CANCELLED';
|
|
520
|
+
|
|
521
|
+
export interface BookingEntity {
|
|
522
|
+
id: string; // traceId
|
|
523
|
+
resourceIds: string[];
|
|
524
|
+
leadId: string;
|
|
525
|
+
startTime: string; // ISO-8601 UTC string
|
|
526
|
+
endTime: string; // ISO-8601 UTC string
|
|
527
|
+
status: BookingStatus;
|
|
528
|
+
shopifyOrderId?: string;
|
|
529
|
+
createdAt: string; // ISO-8601 UTC string
|
|
530
|
+
leadEmail?: string;
|
|
531
|
+
leadName?: string;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export interface BookingListResponse {
|
|
535
|
+
data: BookingEntity[];
|
|
536
|
+
totalCount: number;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export interface BookingMetricsResponse {
|
|
540
|
+
totalMonthlyConfirmed: number;
|
|
541
|
+
totalAnnualConfirmed: number;
|
|
542
|
+
totalWeeklyConfirmed: number;
|
|
543
|
+
leadConversionAnchor: number;
|
|
544
|
+
pendingLast24h: number;
|
|
545
|
+
confirmedLast24h: number;
|
|
546
|
+
}
|
|
@@ -32,6 +32,8 @@ export async function getAdvancedConfigStatus(
|
|
|
32
32
|
typeof data.shopifyStorefrontTokenSet !== 'boolean' ||
|
|
33
33
|
typeof data.shopifyApiSecretSet !== 'boolean' ||
|
|
34
34
|
typeof data.shopifyStoreDomainSet !== 'boolean' ||
|
|
35
|
+
typeof data.shopifyAdminSlugSet !== 'boolean' ||
|
|
36
|
+
typeof data.userSetupWebhooks !== 'boolean' ||
|
|
35
37
|
typeof data.resendApiKeySet !== 'boolean'
|
|
36
38
|
) {
|
|
37
39
|
throw new Error('Invalid response format from server');
|
|
@@ -25,6 +25,8 @@ export function convertToLocalState(
|
|
|
25
25
|
shopifyApiVersion: '',
|
|
26
26
|
shopifyStoreDomain: '',
|
|
27
27
|
resendApiKey: '',
|
|
28
|
+
shopifyAdminSlug: '',
|
|
29
|
+
userSetupWebhooks: false,
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -43,6 +45,8 @@ export function convertToLocalState(
|
|
|
43
45
|
shopifyApiVersion: status.shopifyApiVersion || '',
|
|
44
46
|
shopifyStoreDomain: '',
|
|
45
47
|
resendApiKey: '',
|
|
48
|
+
shopifyAdminSlug: '',
|
|
49
|
+
userSetupWebhooks: status.userSetupWebhooks,
|
|
46
50
|
};
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -92,6 +96,14 @@ export function convertToBackendFormat(
|
|
|
92
96
|
request.SHOPIFY_STORE_DOMAIN = state.shopifyStoreDomain.trim();
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
if (state.shopifyAdminSlug?.trim()) {
|
|
100
|
+
request.SHOPIFY_ADMIN_SLUG = state.shopifyAdminSlug.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (state.userSetupWebhooks !== undefined) {
|
|
104
|
+
request.USER_SETUP_WEBHOOKS = state.userSetupWebhooks;
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
if (state.resendApiKey?.trim()) {
|
|
96
108
|
request.RESEND_API_KEY = state.resendApiKey.trim();
|
|
97
109
|
}
|
|
@@ -107,7 +119,6 @@ export function validateAdvancedConfig(
|
|
|
107
119
|
): FieldErrors {
|
|
108
120
|
const errors: FieldErrors = {};
|
|
109
121
|
|
|
110
|
-
// Turso validation: both URL and token must be provided together
|
|
111
122
|
const hasTursoUrl = Boolean(state.tursoUrl?.trim());
|
|
112
123
|
const hasTursoToken = Boolean(state.tursoToken?.trim());
|
|
113
124
|
if (hasTursoUrl && !hasTursoToken) {
|
|
@@ -125,7 +136,6 @@ export function validateAdvancedConfig(
|
|
|
125
136
|
}
|
|
126
137
|
}
|
|
127
138
|
|
|
128
|
-
// Shopify store domain validation
|
|
129
139
|
if (state.shopifyStoreDomain?.trim()) {
|
|
130
140
|
const domainPattern = /^[a-zA-Z0-9-]+\.myshopify\.com$/;
|
|
131
141
|
if (!domainPattern.test(state.shopifyStoreDomain.trim())) {
|
|
@@ -141,7 +151,34 @@ export function validateAdvancedConfig(
|
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
153
|
|
|
144
|
-
|
|
154
|
+
if (state.shopifyAdminSlug?.trim()) {
|
|
155
|
+
if (
|
|
156
|
+
state.shopifyAdminSlug.includes('/') ||
|
|
157
|
+
state.shopifyAdminSlug.includes('.')
|
|
158
|
+
) {
|
|
159
|
+
errors.shopifyAdminSlug =
|
|
160
|
+
'Please enter only the store slug, not the full URL';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const isConfiguringShopify = Boolean(
|
|
165
|
+
state.shopifyStoreDomain?.trim() ||
|
|
166
|
+
state.shopifyStorefrontToken?.trim() ||
|
|
167
|
+
state.shopifyApiSecret?.trim() ||
|
|
168
|
+
state.shopifyAdminSlug?.trim()
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (isConfiguringShopify) {
|
|
172
|
+
if (!state.shopifyAdminSlug?.trim()) {
|
|
173
|
+
errors.shopifyAdminSlug =
|
|
174
|
+
'Admin slug is required to generate webhook links.';
|
|
175
|
+
}
|
|
176
|
+
if (!state.userSetupWebhooks) {
|
|
177
|
+
errors.userSetupWebhooks =
|
|
178
|
+
'You must confirm webhooks are configured to ensure synchronization.';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
145
182
|
if (state.adminPassword && state.adminPassword.length < 8) {
|
|
146
183
|
errors.adminPassword =
|
|
147
184
|
'Admin password should be at least 8 characters long';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { customerDetails } from '@/stores/shopify';
|
|
2
|
+
|
|
3
|
+
export const bookingHelpers = {
|
|
4
|
+
/**
|
|
5
|
+
* Checks if a user profile already exists for a given email via the BFF.
|
|
6
|
+
*/
|
|
7
|
+
lookupLead: async (email: string) => {
|
|
8
|
+
const response = await fetch('/api/auth/lookup-lead', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: {
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify({ email }),
|
|
14
|
+
});
|
|
15
|
+
return await response.json();
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Asks backend for availability slots
|
|
20
|
+
*/
|
|
21
|
+
getAvailability: async (start: string, end: string) => {
|
|
22
|
+
const query = new URLSearchParams({
|
|
23
|
+
start,
|
|
24
|
+
end,
|
|
25
|
+
});
|
|
26
|
+
const response = await fetch(
|
|
27
|
+
`/api/booking/availability?${query.toString()}`
|
|
28
|
+
);
|
|
29
|
+
return await response.json();
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Places a temporary hold on a specific slot for the manifest of services and pickup products.
|
|
34
|
+
*/
|
|
35
|
+
holdSlot: async (
|
|
36
|
+
traceId: string,
|
|
37
|
+
startTime: string,
|
|
38
|
+
endTime: string,
|
|
39
|
+
resourceIds: string[]
|
|
40
|
+
) => {
|
|
41
|
+
const details = customerDetails.get();
|
|
42
|
+
|
|
43
|
+
const response = await fetch('/api/booking/hold', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
traceId,
|
|
50
|
+
leadId: details.leadId,
|
|
51
|
+
resourceIds,
|
|
52
|
+
startTime,
|
|
53
|
+
endTime,
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
return await response.json();
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Releases a temporary hold, typically used when a user abandons checkout.
|
|
61
|
+
*/
|
|
62
|
+
releaseHold: async (traceId: string) => {
|
|
63
|
+
const response = await fetch('/api/booking/release', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ traceId }),
|
|
69
|
+
});
|
|
70
|
+
return await response.json();
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Confirms a pending hold, finalizing the booking for free services.
|
|
75
|
+
*/
|
|
76
|
+
confirmBooking: async (traceId: string) => {
|
|
77
|
+
const response = await fetch('/api/booking/confirm', {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify({ traceId }),
|
|
83
|
+
});
|
|
84
|
+
return await response.json();
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Retrieves a paginated list of all bookings for the administrative dashboard.
|
|
89
|
+
*/
|
|
90
|
+
listBookings: async (
|
|
91
|
+
limit: number = 50,
|
|
92
|
+
offset: number = 0,
|
|
93
|
+
status: string = 'ALL'
|
|
94
|
+
) => {
|
|
95
|
+
const query = new URLSearchParams({
|
|
96
|
+
limit: limit.toString(),
|
|
97
|
+
offset: offset.toString(),
|
|
98
|
+
status,
|
|
99
|
+
});
|
|
100
|
+
const response = await fetch(`/api/booking/list?${query.toString()}`);
|
|
101
|
+
return await response.json();
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Retrieves aggregated booking volume and conversion statistics.
|
|
106
|
+
*/
|
|
107
|
+
getMetrics: async () => {
|
|
108
|
+
const response = await fetch('/api/booking/metrics');
|
|
109
|
+
return await response.json();
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Manually cancels an existing booking from the administrative dashboard.
|
|
114
|
+
*/
|
|
115
|
+
cancelBooking: async (traceId: string) => {
|
|
116
|
+
const response = await fetch('/api/booking/cancel', {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({ traceId }),
|
|
122
|
+
});
|
|
123
|
+
return await response.json();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
@@ -41,8 +41,16 @@ export function convertToLocalState(
|
|
|
41
41
|
designLibrary: brandConfig.DESIGN_LIBRARY ?? undefined,
|
|
42
42
|
hasAAI: brandConfig.HAS_AAI ?? false,
|
|
43
43
|
hasShopify: brandConfig.HAS_SHOPIFY ?? false,
|
|
44
|
+
showShopifyHelper: brandConfig.SHOW_SHOPIFY_HELPER ?? false,
|
|
44
45
|
hasResend: brandConfig.HAS_RESEND ?? false,
|
|
45
46
|
hasHydrationToken: brandConfig.HAS_HYDRATION_TOKEN ?? false,
|
|
47
|
+
scheduling: brandConfig.SCHEDULING ?? {
|
|
48
|
+
timezone: 'UTC',
|
|
49
|
+
bufferGapsMinutes: 15,
|
|
50
|
+
maxLengthMinutes: 0,
|
|
51
|
+
businessHours: {},
|
|
52
|
+
unavailableHours: [],
|
|
53
|
+
},
|
|
46
54
|
};
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -75,7 +83,9 @@ export function convertToBackendFormat(
|
|
|
75
83
|
DESIGN_LIBRARY: localState.designLibrary,
|
|
76
84
|
HAS_AAI: localState.hasAAI,
|
|
77
85
|
HAS_SHOPIFY: localState.hasShopify,
|
|
86
|
+
SHOW_SHOPIFY_HELPER: localState.showShopifyHelper,
|
|
78
87
|
HAS_RESEND: localState.hasResend,
|
|
88
|
+
SCHEDULING: localState.scheduling,
|
|
79
89
|
|
|
80
90
|
// ALWAYS send asset paths (current state)
|
|
81
91
|
LOGO: localState.logo,
|
|
@@ -11,20 +11,30 @@ export interface AdminAuthClaims {
|
|
|
11
11
|
exp: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Helper to get the current expected tenant ID from the Astro context
|
|
16
|
+
*/
|
|
17
|
+
function getExpectedTenantId(astro: any): string {
|
|
18
|
+
return (
|
|
19
|
+
astro.locals?.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* Check if user is authenticated (either admin or editor)
|
|
16
25
|
*/
|
|
17
26
|
export function isAuthenticated(astro: any): boolean {
|
|
27
|
+
const expectedTenantId = getExpectedTenantId(astro);
|
|
18
28
|
const adminCookie = astro.cookies.get('admin_auth');
|
|
19
29
|
const editorCookie = astro.cookies.get('editor_auth');
|
|
20
30
|
|
|
21
31
|
if (adminCookie?.value) {
|
|
22
|
-
const claims = validateAdminToken(adminCookie.value);
|
|
32
|
+
const claims = validateAdminToken(adminCookie.value, expectedTenantId);
|
|
23
33
|
if (claims && claims.role === 'admin') return true;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
if (editorCookie?.value) {
|
|
27
|
-
const claims = validateAdminToken(editorCookie.value);
|
|
37
|
+
const claims = validateAdminToken(editorCookie.value, expectedTenantId);
|
|
28
38
|
if (claims && claims.role === 'editor') return true;
|
|
29
39
|
}
|
|
30
40
|
|
|
@@ -35,10 +45,11 @@ export function isAuthenticated(astro: any): boolean {
|
|
|
35
45
|
* Check if user has admin role
|
|
36
46
|
*/
|
|
37
47
|
export function isAdmin(astro: any): boolean {
|
|
48
|
+
const expectedTenantId = getExpectedTenantId(astro);
|
|
38
49
|
const adminCookie = astro.cookies.get('admin_auth');
|
|
39
50
|
if (!adminCookie?.value) return false;
|
|
40
51
|
|
|
41
|
-
const claims = validateAdminToken(adminCookie.value);
|
|
52
|
+
const claims = validateAdminToken(adminCookie.value, expectedTenantId);
|
|
42
53
|
return claims?.role === 'admin';
|
|
43
54
|
}
|
|
44
55
|
|
|
@@ -46,10 +57,11 @@ export function isAdmin(astro: any): boolean {
|
|
|
46
57
|
* Check if user has editor role
|
|
47
58
|
*/
|
|
48
59
|
export function isEditor(astro: any): boolean {
|
|
60
|
+
const expectedTenantId = getExpectedTenantId(astro);
|
|
49
61
|
const editorCookie = astro.cookies.get('editor_auth');
|
|
50
62
|
if (!editorCookie?.value) return false;
|
|
51
63
|
|
|
52
|
-
const claims = validateAdminToken(editorCookie.value);
|
|
64
|
+
const claims = validateAdminToken(editorCookie.value, expectedTenantId);
|
|
53
65
|
return claims?.role === 'editor';
|
|
54
66
|
}
|
|
55
67
|
|
|
@@ -57,15 +69,16 @@ export function isEditor(astro: any): boolean {
|
|
|
57
69
|
* Get user role (admin, editor, or null)
|
|
58
70
|
*/
|
|
59
71
|
export function getUserRole(astro: any): 'admin' | 'editor' | null {
|
|
72
|
+
const expectedTenantId = getExpectedTenantId(astro);
|
|
60
73
|
const adminCookie = astro.cookies.get('admin_auth');
|
|
61
74
|
if (adminCookie?.value) {
|
|
62
|
-
const claims = validateAdminToken(adminCookie.value);
|
|
75
|
+
const claims = validateAdminToken(adminCookie.value, expectedTenantId);
|
|
63
76
|
if (claims?.role === 'admin') return 'admin';
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
const editorCookie = astro.cookies.get('editor_auth');
|
|
67
80
|
if (editorCookie?.value) {
|
|
68
|
-
const claims = validateAdminToken(editorCookie.value);
|
|
81
|
+
const claims = validateAdminToken(editorCookie.value, expectedTenantId);
|
|
69
82
|
if (claims?.role === 'editor') return 'editor';
|
|
70
83
|
}
|
|
71
84
|
|
|
@@ -110,15 +123,16 @@ export function requireAdminOrEditor(astro: any): Response | undefined {
|
|
|
110
123
|
* Returns the JWT token from the appropriate cookie
|
|
111
124
|
*/
|
|
112
125
|
export function getAdminToken(astro: any): string | null {
|
|
126
|
+
const expectedTenantId = getExpectedTenantId(astro);
|
|
113
127
|
const adminCookie = astro.cookies.get('admin_auth');
|
|
114
128
|
if (adminCookie?.value) {
|
|
115
|
-
const claims = validateAdminToken(adminCookie.value);
|
|
129
|
+
const claims = validateAdminToken(adminCookie.value, expectedTenantId);
|
|
116
130
|
if (claims?.role === 'admin') return adminCookie.value;
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
const editorCookie = astro.cookies.get('editor_auth');
|
|
120
134
|
if (editorCookie?.value) {
|
|
121
|
-
const claims = validateAdminToken(editorCookie.value);
|
|
135
|
+
const claims = validateAdminToken(editorCookie.value, expectedTenantId);
|
|
122
136
|
if (claims?.role === 'editor') return editorCookie.value;
|
|
123
137
|
}
|
|
124
138
|
|
|
@@ -130,7 +144,10 @@ export function getAdminToken(astro: any): string | null {
|
|
|
130
144
|
* Note: This is a simplified client-side validation
|
|
131
145
|
* Real validation happens on the backend
|
|
132
146
|
*/
|
|
133
|
-
function validateAdminToken(
|
|
147
|
+
function validateAdminToken(
|
|
148
|
+
token: string,
|
|
149
|
+
expectedTenantId: string
|
|
150
|
+
): AdminAuthClaims | null {
|
|
134
151
|
try {
|
|
135
152
|
// Split JWT token
|
|
136
153
|
const parts = token.split('.');
|
|
@@ -146,6 +163,9 @@ function validateAdminToken(token: string): AdminAuthClaims | null {
|
|
|
146
163
|
if (!claims.role || !['admin', 'editor'].includes(claims.role)) return null;
|
|
147
164
|
if (Date.now() / 1000 > claims.exp) return null; // Token expired
|
|
148
165
|
|
|
166
|
+
// Tenant validation
|
|
167
|
+
if (claims.tenantId !== expectedTenantId) return null; // Must match current environment
|
|
168
|
+
|
|
149
169
|
return claims;
|
|
150
170
|
} catch {
|
|
151
171
|
return null;
|
|
@@ -10,7 +10,7 @@ interface AiGenerationOptions {
|
|
|
10
10
|
temperature?: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export const
|
|
13
|
+
export const callAaiAPI = async ({
|
|
14
14
|
prompt,
|
|
15
15
|
context,
|
|
16
16
|
expectJson,
|
|
@@ -43,7 +43,7 @@ export const callAskLemurAPI = async ({
|
|
|
43
43
|
'X-Sandbox-Token': token || '',
|
|
44
44
|
},
|
|
45
45
|
credentials: 'include',
|
|
46
|
-
body: JSON.stringify({ action: '
|
|
46
|
+
body: JSON.stringify({ action: 'aai', payload: requestBody }),
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
if (!response.ok) {
|
|
@@ -58,7 +58,7 @@ export const callAskLemurAPI = async ({
|
|
|
58
58
|
resultData = json.data;
|
|
59
59
|
} else {
|
|
60
60
|
const api = new TractStackAPI(tenantId);
|
|
61
|
-
const response = await api.post('/api/v1/aai/
|
|
61
|
+
const response = await api.post('/api/v1/aai/aai', requestBody);
|
|
62
62
|
|
|
63
63
|
if (!response.success) {
|
|
64
64
|
throw new Error(
|
|
@@ -771,11 +771,11 @@ export function createDefaultShell(layout: 'standard' | 'grid'): ShellJson {
|
|
|
771
771
|
mobile: 'mb-2',
|
|
772
772
|
},
|
|
773
773
|
a: {
|
|
774
|
-
mobile: 'text-
|
|
774
|
+
mobile: 'text-cyan-600 hover:text-cyan-500 font-bold',
|
|
775
775
|
},
|
|
776
776
|
button: {
|
|
777
777
|
mobile:
|
|
778
|
-
'rounded-md bg-
|
|
778
|
+
'rounded-md bg-cyan-600 px-3.5 py-2.5 text-sm font-bold text-white shadow-sm hover:bg-cyan-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-600',
|
|
779
779
|
},
|
|
780
780
|
};
|
|
781
781
|
|