astro-tractstack 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +130 -19
  4. package/package.json +2 -2
  5. package/templates/custom/minimal/CodeHook.astro +10 -2
  6. package/templates/custom/shopify/Cart.tsx +115 -77
  7. package/templates/custom/shopify/CheckoutModal.tsx +509 -120
  8. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  9. package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
  10. package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
  11. package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
  12. package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
  13. package/templates/custom/with-examples/CodeHook.astro +10 -2
  14. package/templates/src/components/Footer.astro +6 -6
  15. package/templates/src/components/Header.astro +23 -11
  16. package/templates/src/components/Menu.tsx +157 -135
  17. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
  19. package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
  20. package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
  21. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
  22. package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
  23. package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
  24. package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
  25. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
  26. package/templates/src/components/edit/ToolBar.tsx +2 -1
  27. package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
  28. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
  29. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  30. package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
  31. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  32. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  33. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  34. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  35. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  36. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  37. package/templates/src/components/edit/state/SaveModal.tsx +1 -1
  38. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
  39. package/templates/src/components/form/DateTimeInput.tsx +10 -3
  40. package/templates/src/components/form/FileUpload.tsx +11 -5
  41. package/templates/src/components/form/NumberInput.tsx +2 -2
  42. package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
  43. package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
  44. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  45. package/templates/src/components/storykeep/Dashboard.tsx +1 -1
  46. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
  47. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
  48. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  49. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  50. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  51. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
  52. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
  53. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
  54. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  55. package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
  56. package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
  57. package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
  58. package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
  59. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
  60. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
  61. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
  62. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  63. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  64. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  65. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  66. package/templates/src/layouts/Layout.astro +8 -5
  67. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  68. package/templates/src/pages/api/booking/availability.ts +72 -0
  69. package/templates/src/pages/api/booking/cancel.ts +73 -0
  70. package/templates/src/pages/api/booking/confirm.ts +82 -0
  71. package/templates/src/pages/api/booking/hold.ts +75 -0
  72. package/templates/src/pages/api/booking/list.ts +66 -0
  73. package/templates/src/pages/api/booking/metrics.ts +60 -0
  74. package/templates/src/pages/api/booking/release.ts +76 -0
  75. package/templates/src/pages/api/sandbox.ts +2 -2
  76. package/templates/src/pages/api/shopify/createCart.ts +4 -8
  77. package/templates/src/pages/api/shopify/getProducts.ts +15 -15
  78. package/templates/src/pages/storykeep/login.astro +21 -14
  79. package/templates/src/stores/shopify.ts +97 -25
  80. package/templates/src/types/formTypes.ts +4 -2
  81. package/templates/src/types/tractstack.ts +59 -2
  82. package/templates/src/utils/api/advancedConfig.ts +2 -0
  83. package/templates/src/utils/api/advancedHelpers.ts +40 -3
  84. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  85. package/templates/src/utils/api/brandConfig.ts +2 -0
  86. package/templates/src/utils/api/brandHelpers.ts +26 -0
  87. package/templates/src/utils/api/emailHelpers.ts +105 -0
  88. package/templates/src/utils/auth.ts +29 -9
  89. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  90. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  91. package/templates/src/utils/customHelpers.ts +0 -21
  92. package/templates/src/utils/profileStorage.ts +5 -0
  93. package/templates/src/utils/tenantResolver.ts +3 -2
  94. package/utils/inject-files.ts +116 -5
  95. package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
@@ -1,3 +1,4 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
1
2
  import type { StoragePane } from './compositorTypes';
2
3
 
3
4
  export type DesignLibraryEntry = {
@@ -13,7 +14,7 @@ export type DesignLibraryConfig = DesignLibraryEntry[];
13
14
 
14
15
  export interface BaseComponentProps {
15
16
  class?: string;
16
- style?: React.CSSProperties | string;
17
+ style?: CSSProperties | string;
17
18
  id?: string;
18
19
  }
19
20
 
@@ -74,7 +75,7 @@ export interface FragmentProps
74
75
  eager?: boolean;
75
76
 
76
77
  /** Fallback content while loading */
77
- fallback?: React.ReactNode;
78
+ fallback?: ReactNode;
78
79
  }
79
80
 
80
81
  // Utility type for extracting props from Astro components
@@ -152,6 +153,19 @@ export interface FullContentMapItem {
152
153
  scale?: string;
153
154
  }
154
155
 
156
+ export interface TimeBlock {
157
+ start: string; // "09:00" for business hours or ISO-8601 for unavailable blocks
158
+ end: string;
159
+ }
160
+
161
+ export interface SchedulingConfig {
162
+ timezone: string;
163
+ bufferGapsMinutes: number;
164
+ maxLengthMinutes: number;
165
+ businessHours: Record<string, TimeBlock>;
166
+ unavailableHours: TimeBlock[];
167
+ }
168
+
155
169
  export interface BrandConfig {
156
170
  TENANT_ID: string;
157
171
  SITE_INIT?: boolean;
@@ -184,8 +198,13 @@ export interface BrandConfig {
184
198
  DESIGN_LIBRARY?: DesignLibraryConfig;
185
199
  HAS_AAI?: boolean;
186
200
  HAS_SHOPIFY?: boolean;
201
+ SHOW_SHOPIFY_HELPER?: boolean;
202
+ SHOPIFY_ADMIN_SLUG?: string;
203
+ USER_SETUP_WEBHOOKS?: boolean;
187
204
  HAS_RESEND?: boolean;
188
205
  HAS_HYDRATION_TOKEN?: boolean;
206
+ SCHEDULING?: SchedulingConfig;
207
+ ADMIN_EMAIL?: string;
189
208
  }
190
209
 
191
210
  export interface BrandConfigState {
@@ -220,8 +239,11 @@ export interface BrandConfigState {
220
239
  designLibrary?: DesignLibraryConfig;
221
240
  hasAAI: boolean;
222
241
  hasShopify: boolean;
242
+ showShopifyHelper: boolean;
223
243
  hasResend: boolean;
224
244
  hasHydrationToken: boolean;
245
+ scheduling: SchedulingConfig;
246
+ adminEmail: string;
225
247
  }
226
248
 
227
249
  // Form validation types
@@ -261,6 +283,8 @@ export interface AdvancedConfigStatus {
261
283
  shopifyApiVersion: string;
262
284
  shopifyStoreDomainSet: boolean;
263
285
  resendApiKeySet: boolean;
286
+ shopifyAdminSlugSet: boolean;
287
+ userSetupWebhooks: boolean;
264
288
  }
265
289
 
266
290
  export interface AdvancedConfigState {
@@ -274,6 +298,8 @@ export interface AdvancedConfigState {
274
298
  shopifyApiSecret: string;
275
299
  shopifyApiVersion: string;
276
300
  shopifyStoreDomain: string;
301
+ shopifyAdminSlug: string;
302
+ userSetupWebhooks: boolean;
277
303
  resendApiKey: string;
278
304
  }
279
305
 
@@ -289,6 +315,8 @@ export interface AdvancedConfigUpdateRequest {
289
315
  SHOPIFY_API_SECRET?: string;
290
316
  SHOPIFY_API_VERSION?: string;
291
317
  SHOPIFY_STORE_DOMAIN?: string;
318
+ SHOPIFY_ADMIN_SLUG?: string;
319
+ USER_SETUP_WEBHOOKS?: boolean;
292
320
  RESEND_API_KEY?: string;
293
321
  }
294
322
 
@@ -490,3 +518,32 @@ export interface CategorizedResults {
490
518
  contextPaneResults: FTSResult[];
491
519
  resourceResults: FTSResult[];
492
520
  }
521
+
522
+ export type BookingStatus = 'PENDING' | 'CONFIRMED' | 'CANCELLED';
523
+
524
+ export interface BookingEntity {
525
+ id: string; // traceId
526
+ resourceIds: string[];
527
+ leadId: string;
528
+ startTime: string; // ISO-8601 UTC string
529
+ endTime: string; // ISO-8601 UTC string
530
+ status: BookingStatus;
531
+ shopifyOrderId?: string;
532
+ createdAt: string; // ISO-8601 UTC string
533
+ leadEmail?: string;
534
+ leadName?: string;
535
+ }
536
+
537
+ export interface BookingListResponse {
538
+ data: BookingEntity[];
539
+ totalCount: number;
540
+ }
541
+
542
+ export interface BookingMetricsResponse {
543
+ totalMonthlyConfirmed: number;
544
+ totalAnnualConfirmed: number;
545
+ totalWeeklyConfirmed: number;
546
+ leadConversionAnchor: number;
547
+ pendingLast24h: number;
548
+ confirmedLast24h: number;
549
+ }
@@ -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
+ };
@@ -66,6 +66,7 @@ export async function getBrandConfig(tenantId: string): Promise<BrandConfig> {
66
66
  KNOWN_RESOURCES: {},
67
67
  DESIGN_LIBRARY: [],
68
68
  HAS_AAI: false,
69
+ ADMIN_EMAIL: '',
69
70
  } as BrandConfig;
70
71
  }
71
72
  throw new Error(response.error || 'Failed to get brand configuration');
@@ -100,6 +101,7 @@ export async function getBrandConfig(tenantId: string): Promise<BrandConfig> {
100
101
  KNOWN_RESOURCES: {},
101
102
  DESIGN_LIBRARY: [],
102
103
  HAS_AAI: false,
104
+ ADMIN_EMAIL: '',
103
105
  } as BrandConfig;
104
106
  }
105
107
  throw error;
@@ -41,8 +41,17 @@ 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
+ adminEmail: brandConfig.ADMIN_EMAIL ?? '',
48
+ scheduling: brandConfig.SCHEDULING ?? {
49
+ timezone: 'UTC',
50
+ bufferGapsMinutes: 15,
51
+ maxLengthMinutes: 0,
52
+ businessHours: {},
53
+ unavailableHours: [],
54
+ },
46
55
  };
47
56
  }
48
57
 
@@ -75,7 +84,10 @@ export function convertToBackendFormat(
75
84
  DESIGN_LIBRARY: localState.designLibrary,
76
85
  HAS_AAI: localState.hasAAI,
77
86
  HAS_SHOPIFY: localState.hasShopify,
87
+ SHOW_SHOPIFY_HELPER: localState.showShopifyHelper,
78
88
  HAS_RESEND: localState.hasResend,
89
+ SCHEDULING: localState.scheduling,
90
+ ADMIN_EMAIL: localState.adminEmail,
79
91
 
80
92
  // ALWAYS send asset paths (current state)
81
93
  LOGO: localState.logo,
@@ -114,6 +126,12 @@ export function validateBrandConfig(state: BrandConfigState): FieldErrors {
114
126
  errors.footer = 'Site footer is required';
115
127
  }
116
128
 
129
+ if (!state.adminEmail?.trim()) {
130
+ errors.adminEmail = 'Admin Email is required';
131
+ } else if (!isValidEmail(state.adminEmail)) {
132
+ errors.adminEmail = 'Please enter a valid email address';
133
+ }
134
+
117
135
  // Validate brand colors (must have exactly 8)
118
136
  if (!state.brandColours || state.brandColours.length !== 8) {
119
137
  errors.brandColours = 'Must have exactly 8 brand colors';
@@ -162,3 +180,11 @@ function isValidHexColor(color: string): boolean {
162
180
  const hex = color.startsWith('#') ? color.slice(1) : color;
163
181
  return /^([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex);
164
182
  }
183
+
184
+ /**
185
+ * Helper function to validate email addresses
186
+ */
187
+ function isValidEmail(email: string): boolean {
188
+ const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
189
+ return re.test(email);
190
+ }
@@ -0,0 +1,105 @@
1
+ import { TractStackAPI } from '../api';
2
+
3
+ export interface TextBlock {
4
+ type: 'text';
5
+ content: string;
6
+ align: 'left' | 'center' | 'right';
7
+ color: string;
8
+ isBold: boolean;
9
+ }
10
+
11
+ export interface ButtonBlock {
12
+ type: 'button';
13
+ label: string;
14
+ url: string;
15
+ bgColor: string;
16
+ textColor: string;
17
+ }
18
+
19
+ export interface DividerBlock {
20
+ type: 'divider';
21
+ color: string;
22
+ }
23
+
24
+ export type EmailBlock = TextBlock | ButtonBlock | DividerBlock;
25
+
26
+ export interface EmailTemplate {
27
+ subject: string;
28
+ blocks: EmailBlock[];
29
+ }
30
+
31
+ export interface PreviewResponse {
32
+ subject: string;
33
+ html: string;
34
+ }
35
+
36
+ /** One row from GET /api/v1/emails/templates (merged manifests). */
37
+ export interface EmailTemplateListEntry {
38
+ name: string;
39
+ adminTitle: string;
40
+ }
41
+
42
+ const getApi = () => {
43
+ return new TractStackAPI(
44
+ typeof window !== 'undefined'
45
+ ? (window as any).TRACTSTACK_CONFIG?.tenantId || 'default'
46
+ : 'default'
47
+ );
48
+ };
49
+
50
+ export const emailHelpers = {
51
+ getTemplates: async (): Promise<Record<string, EmailTemplateListEntry[]>> => {
52
+ const api = getApi();
53
+ const response = await api.get<Record<string, EmailTemplateListEntry[]>>(
54
+ '/api/v1/emails/templates'
55
+ );
56
+ if (!response.success || !response.data) {
57
+ throw new Error(response.error || 'Failed to fetch templates');
58
+ }
59
+ return response.data;
60
+ },
61
+
62
+ getTemplate: async (
63
+ category: string,
64
+ template: string
65
+ ): Promise<EmailTemplate> => {
66
+ const api = getApi();
67
+ const response = await api.get<EmailTemplate>(
68
+ `/api/v1/emails/templates/${category}/${template}`
69
+ );
70
+ if (!response.success || !response.data) {
71
+ throw new Error(response.error || 'Failed to fetch template');
72
+ }
73
+ return response.data;
74
+ },
75
+
76
+ saveTemplate: async (
77
+ category: string,
78
+ template: string,
79
+ data: EmailTemplate
80
+ ): Promise<void> => {
81
+ const api = getApi();
82
+ const response = await api.post(
83
+ `/api/v1/emails/templates/${category}/${template}`,
84
+ data
85
+ );
86
+ if (!response.success) {
87
+ throw new Error(response.error || 'Failed to save template');
88
+ }
89
+ },
90
+
91
+ previewTemplate: async (
92
+ template: EmailTemplate,
93
+ mockData: Record<string, any>
94
+ ): Promise<PreviewResponse> => {
95
+ const api = getApi();
96
+ const response = await api.post<PreviewResponse>('/api/v1/emails/preview', {
97
+ template,
98
+ data: mockData,
99
+ });
100
+ if (!response.success || !response.data) {
101
+ throw new Error(response.error || 'Failed to generate preview');
102
+ }
103
+ return response.data;
104
+ },
105
+ };
@@ -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