astro-tractstack 2.3.1 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-tractstack.js +3 -3
- package/dist/index.js +69 -11
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +99 -19
- package/templates/custom/shopify/CheckoutModal.tsx +196 -10
- package/templates/custom/shopify/ShopifyCartManager.tsx +79 -76
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +42 -14
- package/templates/custom/shopify/ShopifyServiceList.tsx +94 -50
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Footer.astro +2 -2
- package/templates/src/components/Header.astro +17 -9
- 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_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- 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 +221 -39
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +16 -8
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +79 -51
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -0
- 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 +1 -8
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +118 -14
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/constants.ts +2 -0
- package/templates/src/layouts/Layout.astro +8 -5
- package/templates/src/pages/api/google/oauth/callback.ts +50 -0
- package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
- package/templates/src/pages/api/google/oauth/start.ts +32 -0
- package/templates/src/pages/api/google/oauth/status.ts +32 -0
- package/templates/src/pages/privacy.astro +84 -0
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/shopify.ts +21 -0
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +35 -2
- package/templates/src/utils/api/advancedHelpers.ts +16 -0
- package/templates/src/utils/api/bookingHelpers.ts +3 -1
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +24 -1
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +2 -0
- package/templates/src/utils/tenantResolver.ts +1 -1
- package/utils/inject-files.ts +63 -5
- package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
- package/templates/src/utils/actions/actionButton.ts +0 -103
- package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { APIRoute } from '@/types/astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
4
|
+
const GO_BACKEND =
|
|
5
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
6
|
+
const tenantId =
|
|
7
|
+
locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
8
|
+
|
|
9
|
+
const forwardedProto =
|
|
10
|
+
request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '');
|
|
11
|
+
const forwardedHost = request.headers.get('x-forwarded-host') || url.host;
|
|
12
|
+
|
|
13
|
+
const response = await fetch(
|
|
14
|
+
`${GO_BACKEND}/api/v1/google/oauth/callback${url.search}`,
|
|
15
|
+
{
|
|
16
|
+
method: 'GET',
|
|
17
|
+
redirect: 'manual',
|
|
18
|
+
headers: {
|
|
19
|
+
'X-Tenant-ID': tenantId,
|
|
20
|
+
'X-Forwarded-Proto': forwardedProto,
|
|
21
|
+
'X-Forwarded-Host': forwardedHost,
|
|
22
|
+
...(request.headers.get('Authorization') && {
|
|
23
|
+
Authorization: request.headers.get('Authorization')!,
|
|
24
|
+
}),
|
|
25
|
+
...(request.headers.get('Cookie') && {
|
|
26
|
+
Cookie: request.headers.get('Cookie')!,
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const location = response.headers.get('location');
|
|
33
|
+
if (location) {
|
|
34
|
+
const frontendOrigin = `${forwardedProto}://${forwardedHost}`;
|
|
35
|
+
const redirectUrl = new URL(location, frontendOrigin).toString();
|
|
36
|
+
return Response.redirect(
|
|
37
|
+
redirectUrl,
|
|
38
|
+
response.status >= 300 && response.status < 400 ? response.status : 302
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const body = await response.text();
|
|
43
|
+
return new Response(body, {
|
|
44
|
+
status: response.status,
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type':
|
|
47
|
+
response.headers.get('Content-Type') || 'application/json',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { APIRoute } from '@/types/astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
4
|
+
const GO_BACKEND =
|
|
5
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
6
|
+
const tenantId =
|
|
7
|
+
locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
8
|
+
const forwardedProto =
|
|
9
|
+
request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '');
|
|
10
|
+
const forwardedHost = request.headers.get('x-forwarded-host') || url.host;
|
|
11
|
+
|
|
12
|
+
const response = await fetch(`${GO_BACKEND}/api/v1/google/oauth/disconnect`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'X-Tenant-ID': tenantId,
|
|
16
|
+
'X-Forwarded-Proto': forwardedProto,
|
|
17
|
+
'X-Forwarded-Host': forwardedHost,
|
|
18
|
+
...(request.headers.get('Authorization') && {
|
|
19
|
+
Authorization: request.headers.get('Authorization')!,
|
|
20
|
+
}),
|
|
21
|
+
...(request.headers.get('Cookie') && {
|
|
22
|
+
Cookie: request.headers.get('Cookie')!,
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const data = await response.text();
|
|
28
|
+
return new Response(data, {
|
|
29
|
+
status: response.status,
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { APIRoute } from '@/types/astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
4
|
+
const GO_BACKEND =
|
|
5
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
6
|
+
const tenantId =
|
|
7
|
+
locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
8
|
+
const forwardedProto =
|
|
9
|
+
request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '');
|
|
10
|
+
const forwardedHost = request.headers.get('x-forwarded-host') || url.host;
|
|
11
|
+
|
|
12
|
+
const response = await fetch(`${GO_BACKEND}/api/v1/google/oauth/start`, {
|
|
13
|
+
method: 'GET',
|
|
14
|
+
headers: {
|
|
15
|
+
'X-Tenant-ID': tenantId,
|
|
16
|
+
'X-Forwarded-Proto': forwardedProto,
|
|
17
|
+
'X-Forwarded-Host': forwardedHost,
|
|
18
|
+
...(request.headers.get('Authorization') && {
|
|
19
|
+
Authorization: request.headers.get('Authorization')!,
|
|
20
|
+
}),
|
|
21
|
+
...(request.headers.get('Cookie') && {
|
|
22
|
+
Cookie: request.headers.get('Cookie')!,
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const data = await response.text();
|
|
28
|
+
return new Response(data, {
|
|
29
|
+
status: response.status,
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { APIRoute } from '@/types/astro';
|
|
2
|
+
|
|
3
|
+
export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
4
|
+
const GO_BACKEND =
|
|
5
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
6
|
+
const tenantId =
|
|
7
|
+
locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
8
|
+
const forwardedProto =
|
|
9
|
+
request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '');
|
|
10
|
+
const forwardedHost = request.headers.get('x-forwarded-host') || url.host;
|
|
11
|
+
|
|
12
|
+
const response = await fetch(`${GO_BACKEND}/api/v1/google/oauth/status`, {
|
|
13
|
+
method: 'GET',
|
|
14
|
+
headers: {
|
|
15
|
+
'X-Tenant-ID': tenantId,
|
|
16
|
+
'X-Forwarded-Proto': forwardedProto,
|
|
17
|
+
'X-Forwarded-Host': forwardedHost,
|
|
18
|
+
...(request.headers.get('Authorization') && {
|
|
19
|
+
Authorization: request.headers.get('Authorization')!,
|
|
20
|
+
}),
|
|
21
|
+
...(request.headers.get('Cookie') && {
|
|
22
|
+
Cookie: request.headers.get('Cookie')!,
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const data = await response.text();
|
|
28
|
+
return new Response(data, {
|
|
29
|
+
status: response.status,
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
const effectiveDate = '2026-05-03';
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<!doctype html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="UTF-8" />
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
10
|
+
<title>Privacy Policy</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<main
|
|
14
|
+
style="margin: 0 auto; max-width: 48rem; padding: 2rem 1rem; font-family: Inter, Arial, sans-serif; line-height: 1.6;"
|
|
15
|
+
>
|
|
16
|
+
<h1>Privacy Policy</h1>
|
|
17
|
+
<p><strong>Effective date:</strong> {effectiveDate}</p>
|
|
18
|
+
<p>
|
|
19
|
+
This site is built with TractStack and is designed to minimize personal
|
|
20
|
+
data collection while supporting operational analytics.
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<h2>What We Collect</h2>
|
|
24
|
+
<ul>
|
|
25
|
+
<li>
|
|
26
|
+
Pseudonymous usage and activity events for site operation and content
|
|
27
|
+
performance.
|
|
28
|
+
</li>
|
|
29
|
+
<li>
|
|
30
|
+
Session identifiers used for continuity, including session, visit, and
|
|
31
|
+
fingerprint IDs.
|
|
32
|
+
</li>
|
|
33
|
+
<li>
|
|
34
|
+
Optional profile/contact details only when you explicitly provide
|
|
35
|
+
them.
|
|
36
|
+
</li>
|
|
37
|
+
</ul>
|
|
38
|
+
|
|
39
|
+
<h2>How We Use Data</h2>
|
|
40
|
+
<ul>
|
|
41
|
+
<li>To operate site behavior, booking flows, and communications.</li>
|
|
42
|
+
<li>To measure page and interaction performance.</li>
|
|
43
|
+
<li>
|
|
44
|
+
To provide profile-based personalization when a profile is created.
|
|
45
|
+
</li>
|
|
46
|
+
</ul>
|
|
47
|
+
|
|
48
|
+
<h2>Consent and Controls</h2>
|
|
49
|
+
<ul>
|
|
50
|
+
<li>
|
|
51
|
+
Consent controls profile memory and preference retention in your
|
|
52
|
+
browser.
|
|
53
|
+
</li>
|
|
54
|
+
<li>
|
|
55
|
+
Operational session and interaction tracking may occur even when full
|
|
56
|
+
profile consent is not enabled.
|
|
57
|
+
</li>
|
|
58
|
+
<li>
|
|
59
|
+
You can revoke consent from the profile/session controls on this site;
|
|
60
|
+
this clears browser-stored session/profile data for this browser.
|
|
61
|
+
</li>
|
|
62
|
+
</ul>
|
|
63
|
+
|
|
64
|
+
<h2>Retention</h2>
|
|
65
|
+
<p>
|
|
66
|
+
Revoking consent clears local browser storage for session/profile data,
|
|
67
|
+
but previously recorded server-side operational logs and analytics
|
|
68
|
+
events may still exist according to site retention policy.
|
|
69
|
+
</p>
|
|
70
|
+
|
|
71
|
+
<h2>Consent Signal</h2>
|
|
72
|
+
<p>
|
|
73
|
+
The consent signal sent during session handshake can be present, denied,
|
|
74
|
+
or unknown depending on client state.
|
|
75
|
+
</p>
|
|
76
|
+
|
|
77
|
+
<h2>Contact</h2>
|
|
78
|
+
<p>
|
|
79
|
+
For privacy questions, contact the site owner using the contact details
|
|
80
|
+
provided on this website.
|
|
81
|
+
</p>
|
|
82
|
+
</main>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
const effectiveDate = '2026-05-03';
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<!doctype html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="UTF-8" />
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
10
|
+
<title>Terms of Service</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<main
|
|
14
|
+
style="margin: 0 auto; max-width: 48rem; padding: 2rem 1rem; font-family: Inter, Arial, sans-serif; line-height: 1.6;"
|
|
15
|
+
>
|
|
16
|
+
<h1>Terms of Service</h1>
|
|
17
|
+
<p><strong>Effective date:</strong> {effectiveDate}</p>
|
|
18
|
+
|
|
19
|
+
<h2>Acceptance of Terms</h2>
|
|
20
|
+
<p>By using this website and its services, you agree to these terms.</p>
|
|
21
|
+
|
|
22
|
+
<h2>Use of Service</h2>
|
|
23
|
+
<p>
|
|
24
|
+
You agree not to misuse the website, attempt unauthorized access, or
|
|
25
|
+
interfere with normal operation.
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<h2>Bookings and Communications</h2>
|
|
29
|
+
<p>
|
|
30
|
+
If this site offers booking services, confirmations and related
|
|
31
|
+
communications are provided as operational notifications.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<h2>Disclaimer</h2>
|
|
35
|
+
<p>
|
|
36
|
+
Services are provided on an “as is” basis, without warranties except as
|
|
37
|
+
required by law.
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<h2>Contact</h2>
|
|
41
|
+
<p>
|
|
42
|
+
For terms questions, contact the site owner using details provided on
|
|
43
|
+
this website.
|
|
44
|
+
</p>
|
|
45
|
+
</main>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
@@ -227,6 +227,10 @@ export const transactionTraceId = persistentAtom<string>(
|
|
|
227
227
|
'tractstack_shopify_trace_id',
|
|
228
228
|
''
|
|
229
229
|
);
|
|
230
|
+
export const preferredAppointmentMode = persistentAtom<'IN_PERSON' | 'REMOTE'>(
|
|
231
|
+
'tractstack_shopify_appointment_mode',
|
|
232
|
+
'IN_PERSON'
|
|
233
|
+
);
|
|
230
234
|
|
|
231
235
|
export interface CustomerDetails {
|
|
232
236
|
name: string;
|
|
@@ -262,5 +266,22 @@ export function clearCommerceState() {
|
|
|
262
266
|
leadId: '',
|
|
263
267
|
});
|
|
264
268
|
transactionTraceId.set('');
|
|
269
|
+
preferredAppointmentMode.set('IN_PERSON');
|
|
265
270
|
cartState.set(CART_STATES.READY);
|
|
266
271
|
}
|
|
272
|
+
|
|
273
|
+
export interface CartKeyParams {
|
|
274
|
+
resourceId: string;
|
|
275
|
+
variantId?: string;
|
|
276
|
+
variantIdShipped?: string;
|
|
277
|
+
variantIdPickup?: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function getCartItemKey(params: CartKeyParams): string {
|
|
281
|
+
if (params.variantId) {
|
|
282
|
+
return params.variantId;
|
|
283
|
+
}
|
|
284
|
+
return `${params.resourceId}_${params.variantIdShipped || 'null'}_${
|
|
285
|
+
params.variantIdPickup || 'null'
|
|
286
|
+
}`;
|
|
287
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
1
3
|
// Base props interface for all atomic form components
|
|
2
4
|
export interface BaseFormComponentProps<T> {
|
|
3
5
|
value: T;
|
|
@@ -71,7 +73,7 @@ export interface NumberInputProps extends BaseFormComponentProps<number> {
|
|
|
71
73
|
export interface FormSectionProps {
|
|
72
74
|
title: string;
|
|
73
75
|
description?: string;
|
|
74
|
-
children:
|
|
76
|
+
children: ReactNode;
|
|
75
77
|
collapsible?: boolean;
|
|
76
78
|
defaultExpanded?: boolean;
|
|
77
79
|
}
|
|
@@ -216,7 +218,7 @@ export interface NumberInputProps extends BaseFormComponentProps<number> {
|
|
|
216
218
|
export interface FormSectionProps {
|
|
217
219
|
title: string;
|
|
218
220
|
description?: string;
|
|
219
|
-
children:
|
|
221
|
+
children: ReactNode;
|
|
220
222
|
collapsible?: boolean;
|
|
221
223
|
defaultExpanded?: boolean;
|
|
222
224
|
}
|
|
@@ -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
|
|
@@ -161,6 +162,8 @@ export interface SchedulingConfig {
|
|
|
161
162
|
timezone: string;
|
|
162
163
|
bufferGapsMinutes: number;
|
|
163
164
|
maxLengthMinutes: number;
|
|
165
|
+
allowRemote: boolean;
|
|
166
|
+
remoteOnly: boolean;
|
|
164
167
|
businessHours: Record<string, TimeBlock>;
|
|
165
168
|
unavailableHours: TimeBlock[];
|
|
166
169
|
}
|
|
@@ -203,6 +206,7 @@ export interface BrandConfig {
|
|
|
203
206
|
HAS_RESEND?: boolean;
|
|
204
207
|
HAS_HYDRATION_TOKEN?: boolean;
|
|
205
208
|
SCHEDULING?: SchedulingConfig;
|
|
209
|
+
ADMIN_EMAIL?: string;
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
export interface BrandConfigState {
|
|
@@ -241,6 +245,7 @@ export interface BrandConfigState {
|
|
|
241
245
|
hasResend: boolean;
|
|
242
246
|
hasHydrationToken: boolean;
|
|
243
247
|
scheduling: SchedulingConfig;
|
|
248
|
+
adminEmail: string;
|
|
244
249
|
}
|
|
245
250
|
|
|
246
251
|
// Form validation types
|
|
@@ -282,6 +287,13 @@ export interface AdvancedConfigStatus {
|
|
|
282
287
|
resendApiKeySet: boolean;
|
|
283
288
|
shopifyAdminSlugSet: boolean;
|
|
284
289
|
userSetupWebhooks: boolean;
|
|
290
|
+
googleOauthClientIdSet: boolean;
|
|
291
|
+
googleOauthClientSecretSet: boolean;
|
|
292
|
+
googleCalendarIdSet: boolean;
|
|
293
|
+
googleAccessTokenSet: boolean;
|
|
294
|
+
googleRefreshTokenSet: boolean;
|
|
295
|
+
googleTokenExpirySet: boolean;
|
|
296
|
+
hasGoogleSync: boolean;
|
|
285
297
|
}
|
|
286
298
|
|
|
287
299
|
export interface AdvancedConfigState {
|
|
@@ -298,6 +310,9 @@ export interface AdvancedConfigState {
|
|
|
298
310
|
shopifyAdminSlug: string;
|
|
299
311
|
userSetupWebhooks: boolean;
|
|
300
312
|
resendApiKey: string;
|
|
313
|
+
googleOauthClientId: string;
|
|
314
|
+
googleOauthClientSecret: string;
|
|
315
|
+
googleCalendarId: string;
|
|
301
316
|
}
|
|
302
317
|
|
|
303
318
|
export interface AdvancedConfigUpdateRequest {
|
|
@@ -315,6 +330,9 @@ export interface AdvancedConfigUpdateRequest {
|
|
|
315
330
|
SHOPIFY_ADMIN_SLUG?: string;
|
|
316
331
|
USER_SETUP_WEBHOOKS?: boolean;
|
|
317
332
|
RESEND_API_KEY?: string;
|
|
333
|
+
GOOGLE_OAUTH_CLIENT_ID?: string;
|
|
334
|
+
GOOGLE_OAUTH_CLIENT_SECRET?: string;
|
|
335
|
+
GOOGLE_CALENDAR_ID?: string;
|
|
318
336
|
}
|
|
319
337
|
|
|
320
338
|
export interface MenuNodeState {
|
|
@@ -517,6 +535,14 @@ export interface CategorizedResults {
|
|
|
517
535
|
}
|
|
518
536
|
|
|
519
537
|
export type BookingStatus = 'PENDING' | 'CONFIRMED' | 'CANCELLED';
|
|
538
|
+
export type AppointmentMode = 'IN_PERSON' | 'REMOTE';
|
|
539
|
+
export type GoogleSyncStatus =
|
|
540
|
+
| 'NOT_SYNCED'
|
|
541
|
+
| 'PENDING'
|
|
542
|
+
| 'SYNCED'
|
|
543
|
+
| 'DELETE_PENDING'
|
|
544
|
+
| 'DELETE_SYNCED'
|
|
545
|
+
| 'FAILED';
|
|
520
546
|
|
|
521
547
|
export interface BookingEntity {
|
|
522
548
|
id: string; // traceId
|
|
@@ -525,7 +551,14 @@ export interface BookingEntity {
|
|
|
525
551
|
startTime: string; // ISO-8601 UTC string
|
|
526
552
|
endTime: string; // ISO-8601 UTC string
|
|
527
553
|
status: BookingStatus;
|
|
554
|
+
appointmentMode: AppointmentMode;
|
|
528
555
|
shopifyOrderId?: string;
|
|
556
|
+
googleEventId?: string;
|
|
557
|
+
googleMeetURL?: string;
|
|
558
|
+
googleSyncStatus: GoogleSyncStatus;
|
|
559
|
+
googleLastError?: string;
|
|
560
|
+
confirmationEmailSent: boolean;
|
|
561
|
+
linkAddedEmailSent: boolean;
|
|
529
562
|
createdAt: string; // ISO-8601 UTC string
|
|
530
563
|
leadEmail?: string;
|
|
531
564
|
leadName?: string;
|
|
@@ -27,6 +27,9 @@ export function convertToLocalState(
|
|
|
27
27
|
resendApiKey: '',
|
|
28
28
|
shopifyAdminSlug: '',
|
|
29
29
|
userSetupWebhooks: false,
|
|
30
|
+
googleOauthClientId: '',
|
|
31
|
+
googleOauthClientSecret: '',
|
|
32
|
+
googleCalendarId: '',
|
|
30
33
|
};
|
|
31
34
|
}
|
|
32
35
|
|
|
@@ -47,6 +50,9 @@ export function convertToLocalState(
|
|
|
47
50
|
resendApiKey: '',
|
|
48
51
|
shopifyAdminSlug: '',
|
|
49
52
|
userSetupWebhooks: status.userSetupWebhooks,
|
|
53
|
+
googleOauthClientId: '',
|
|
54
|
+
googleOauthClientSecret: '',
|
|
55
|
+
googleCalendarId: '',
|
|
50
56
|
};
|
|
51
57
|
}
|
|
52
58
|
|
|
@@ -108,6 +114,16 @@ export function convertToBackendFormat(
|
|
|
108
114
|
request.RESEND_API_KEY = state.resendApiKey.trim();
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
if (state.googleOauthClientId?.trim()) {
|
|
118
|
+
request.GOOGLE_OAUTH_CLIENT_ID = state.googleOauthClientId.trim();
|
|
119
|
+
}
|
|
120
|
+
if (state.googleOauthClientSecret?.trim()) {
|
|
121
|
+
request.GOOGLE_OAUTH_CLIENT_SECRET = state.googleOauthClientSecret.trim();
|
|
122
|
+
}
|
|
123
|
+
if (state.googleCalendarId?.trim()) {
|
|
124
|
+
request.GOOGLE_CALENDAR_ID = state.googleCalendarId.trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
return request;
|
|
112
128
|
}
|
|
113
129
|
|
|
@@ -36,7 +36,8 @@ export const bookingHelpers = {
|
|
|
36
36
|
traceId: string,
|
|
37
37
|
startTime: string,
|
|
38
38
|
endTime: string,
|
|
39
|
-
resourceIds: string[]
|
|
39
|
+
resourceIds: string[],
|
|
40
|
+
appointmentMode: 'IN_PERSON' | 'REMOTE'
|
|
40
41
|
) => {
|
|
41
42
|
const details = customerDetails.get();
|
|
42
43
|
|
|
@@ -51,6 +52,7 @@ export const bookingHelpers = {
|
|
|
51
52
|
resourceIds,
|
|
52
53
|
startTime,
|
|
53
54
|
endTime,
|
|
55
|
+
appointmentMode,
|
|
54
56
|
}),
|
|
55
57
|
});
|
|
56
58
|
return await response.json();
|
|
@@ -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;
|
|
@@ -44,10 +44,13 @@ export function convertToLocalState(
|
|
|
44
44
|
showShopifyHelper: brandConfig.SHOW_SHOPIFY_HELPER ?? false,
|
|
45
45
|
hasResend: brandConfig.HAS_RESEND ?? false,
|
|
46
46
|
hasHydrationToken: brandConfig.HAS_HYDRATION_TOKEN ?? false,
|
|
47
|
+
adminEmail: brandConfig.ADMIN_EMAIL ?? '',
|
|
47
48
|
scheduling: brandConfig.SCHEDULING ?? {
|
|
48
49
|
timezone: 'UTC',
|
|
49
50
|
bufferGapsMinutes: 15,
|
|
50
51
|
maxLengthMinutes: 0,
|
|
52
|
+
allowRemote: false,
|
|
53
|
+
remoteOnly: false,
|
|
51
54
|
businessHours: {},
|
|
52
55
|
unavailableHours: [],
|
|
53
56
|
},
|
|
@@ -61,6 +64,11 @@ export function convertToLocalState(
|
|
|
61
64
|
export function convertToBackendFormat(
|
|
62
65
|
localState: BrandConfigState
|
|
63
66
|
): BrandConfig {
|
|
67
|
+
const scheduling = { ...localState.scheduling };
|
|
68
|
+
if (scheduling.remoteOnly) {
|
|
69
|
+
scheduling.allowRemote = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
return {
|
|
65
73
|
TENANT_ID: localState.tenantId,
|
|
66
74
|
SITE_INIT: localState.siteInit,
|
|
@@ -85,7 +93,8 @@ export function convertToBackendFormat(
|
|
|
85
93
|
HAS_SHOPIFY: localState.hasShopify,
|
|
86
94
|
SHOW_SHOPIFY_HELPER: localState.showShopifyHelper,
|
|
87
95
|
HAS_RESEND: localState.hasResend,
|
|
88
|
-
SCHEDULING:
|
|
96
|
+
SCHEDULING: scheduling,
|
|
97
|
+
ADMIN_EMAIL: localState.adminEmail,
|
|
89
98
|
|
|
90
99
|
// ALWAYS send asset paths (current state)
|
|
91
100
|
LOGO: localState.logo,
|
|
@@ -124,6 +133,12 @@ export function validateBrandConfig(state: BrandConfigState): FieldErrors {
|
|
|
124
133
|
errors.footer = 'Site footer is required';
|
|
125
134
|
}
|
|
126
135
|
|
|
136
|
+
if (!state.adminEmail?.trim()) {
|
|
137
|
+
errors.adminEmail = 'Admin Email is required';
|
|
138
|
+
} else if (!isValidEmail(state.adminEmail)) {
|
|
139
|
+
errors.adminEmail = 'Please enter a valid email address';
|
|
140
|
+
}
|
|
141
|
+
|
|
127
142
|
// Validate brand colors (must have exactly 8)
|
|
128
143
|
if (!state.brandColours || state.brandColours.length !== 8) {
|
|
129
144
|
errors.brandColours = 'Must have exactly 8 brand colors';
|
|
@@ -172,3 +187,11 @@ function isValidHexColor(color: string): boolean {
|
|
|
172
187
|
const hex = color.startsWith('#') ? color.slice(1) : color;
|
|
173
188
|
return /^([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex);
|
|
174
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Helper function to validate email addresses
|
|
193
|
+
*/
|
|
194
|
+
function isValidEmail(email: string): boolean {
|
|
195
|
+
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
196
|
+
return re.test(email);
|
|
197
|
+
}
|
|
@@ -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
|
+
};
|