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.
- package/README.md +1 -1
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +130 -19
- package/package.json +2 -2
- package/templates/custom/minimal/CodeHook.astro +10 -2
- package/templates/custom/shopify/Cart.tsx +115 -77
- package/templates/custom/shopify/CheckoutModal.tsx +509 -120
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
- package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
- package/templates/custom/with-examples/CodeHook.astro +10 -2
- package/templates/src/components/Footer.astro +6 -6
- package/templates/src/components/Header.astro +23 -11
- package/templates/src/components/Menu.tsx +157 -135
- package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
- package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
- package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
- package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
- package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
- package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
- package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
- package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
- package/templates/src/components/edit/ToolBar.tsx +2 -1
- package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- 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/edit/state/SaveModal.tsx +1 -1
- package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
- package/templates/src/components/form/DateTimeInput.tsx +10 -3
- package/templates/src/components/form/FileUpload.tsx +11 -5
- package/templates/src/components/form/NumberInput.tsx +2 -2
- package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- 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 +252 -110
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- 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 +88 -56
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
- package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
- package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
- package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -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/layouts/Layout.astro +8 -5
- 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 +97 -25
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +59 -2
- 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/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +26 -0
- package/templates/src/utils/api/emailHelpers.ts +105 -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 +3 -2
- package/utils/inject-files.ts +116 -5
- 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?:
|
|
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?:
|
|
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
|
-
|
|
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(
|
|
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
|
|