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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +94 -16
  4. package/package.json +2 -2
  5. package/templates/custom/minimal/CodeHook.astro +10 -2
  6. package/templates/custom/shopify/Cart.tsx +100 -73
  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 +92 -37
  10. package/templates/custom/shopify/ShopifyProductGrid.tsx +139 -173
  11. package/templates/custom/shopify/ShopifyServiceList.tsx +20 -3
  12. package/templates/custom/with-examples/CodeHook.astro +10 -2
  13. package/templates/src/components/Footer.astro +4 -4
  14. package/templates/src/components/Header.astro +9 -3
  15. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  16. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  17. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  18. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  19. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  20. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  21. package/templates/src/components/form/advanced/APIConfigSection.tsx +244 -2
  22. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  23. package/templates/src/components/storykeep/Dashboard.tsx +1 -1
  24. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +253 -110
  25. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  26. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  27. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  28. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
  29. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +9 -5
  30. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
  31. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  32. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
  33. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -0
  34. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  35. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  36. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  37. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  38. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  39. package/templates/src/pages/api/booking/availability.ts +72 -0
  40. package/templates/src/pages/api/booking/cancel.ts +73 -0
  41. package/templates/src/pages/api/booking/confirm.ts +82 -0
  42. package/templates/src/pages/api/booking/hold.ts +75 -0
  43. package/templates/src/pages/api/booking/list.ts +66 -0
  44. package/templates/src/pages/api/booking/metrics.ts +60 -0
  45. package/templates/src/pages/api/booking/release.ts +76 -0
  46. package/templates/src/pages/api/sandbox.ts +2 -2
  47. package/templates/src/pages/api/shopify/createCart.ts +4 -8
  48. package/templates/src/pages/api/shopify/getProducts.ts +15 -15
  49. package/templates/src/pages/storykeep/login.astro +21 -14
  50. package/templates/src/stores/shopify.ts +81 -25
  51. package/templates/src/types/tractstack.ts +54 -0
  52. package/templates/src/utils/api/advancedConfig.ts +2 -0
  53. package/templates/src/utils/api/advancedHelpers.ts +40 -3
  54. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  55. package/templates/src/utils/api/brandHelpers.ts +10 -0
  56. package/templates/src/utils/auth.ts +29 -9
  57. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  58. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  59. package/templates/src/utils/customHelpers.ts +0 -21
  60. package/templates/src/utils/profileStorage.ts +5 -0
  61. package/templates/src/utils/tenantResolver.ts +2 -1
  62. package/utils/inject-files.ts +82 -4
  63. 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 = persistentAtom<{
106
+ export const shopifyData = atom<{
102
107
  products: ShopifyProduct[];
108
+ pageInfo?: ShopifyPageInfo;
103
109
  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
- );
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
- // 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>>(
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
- export async function fetchShopifyProducts() {
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 response = await fetch('/api/shopify/getProducts');
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 const customerDetails = persistentAtom<{
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
- // Password strength validation (optional but recommended)
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(token: string): AdminAuthClaims | null {
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 callAskLemurAPI = async ({
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: 'askLemur', payload: requestBody }),
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/askLemur', requestBody);
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-indigo-600 hover:text-indigo-500 font-semibold',
774
+ mobile: 'text-cyan-600 hover:text-cyan-500 font-bold',
775
775
  },
776
776
  button: {
777
777
  mobile:
778
- 'rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
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