astro-tractstack 2.3.2 → 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 +33 -8
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +83 -14
- package/templates/custom/shopify/CheckoutModal.tsx +192 -6
- package/templates/custom/shopify/ShopifyCartManager.tsx +53 -41
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Header.astro +3 -1
- package/templates/src/components/form/advanced/APIConfigSection.tsx +219 -1
- 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 +9 -0
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +86 -8
- package/templates/src/constants.ts +2 -0
- 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 +5 -0
- package/templates/src/types/tractstack.ts +30 -0
- package/templates/src/utils/api/advancedHelpers.ts +16 -0
- package/templates/src/utils/api/bookingHelpers.ts +3 -1
- package/templates/src/utils/api/brandHelpers.ts +8 -1
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +2 -0
- package/utils/inject-files.ts +29 -4
- 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,6 +266,7 @@ 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
|
}
|
|
267
272
|
|
|
@@ -162,6 +162,8 @@ export interface SchedulingConfig {
|
|
|
162
162
|
timezone: string;
|
|
163
163
|
bufferGapsMinutes: number;
|
|
164
164
|
maxLengthMinutes: number;
|
|
165
|
+
allowRemote: boolean;
|
|
166
|
+
remoteOnly: boolean;
|
|
165
167
|
businessHours: Record<string, TimeBlock>;
|
|
166
168
|
unavailableHours: TimeBlock[];
|
|
167
169
|
}
|
|
@@ -285,6 +287,13 @@ export interface AdvancedConfigStatus {
|
|
|
285
287
|
resendApiKeySet: boolean;
|
|
286
288
|
shopifyAdminSlugSet: boolean;
|
|
287
289
|
userSetupWebhooks: boolean;
|
|
290
|
+
googleOauthClientIdSet: boolean;
|
|
291
|
+
googleOauthClientSecretSet: boolean;
|
|
292
|
+
googleCalendarIdSet: boolean;
|
|
293
|
+
googleAccessTokenSet: boolean;
|
|
294
|
+
googleRefreshTokenSet: boolean;
|
|
295
|
+
googleTokenExpirySet: boolean;
|
|
296
|
+
hasGoogleSync: boolean;
|
|
288
297
|
}
|
|
289
298
|
|
|
290
299
|
export interface AdvancedConfigState {
|
|
@@ -301,6 +310,9 @@ export interface AdvancedConfigState {
|
|
|
301
310
|
shopifyAdminSlug: string;
|
|
302
311
|
userSetupWebhooks: boolean;
|
|
303
312
|
resendApiKey: string;
|
|
313
|
+
googleOauthClientId: string;
|
|
314
|
+
googleOauthClientSecret: string;
|
|
315
|
+
googleCalendarId: string;
|
|
304
316
|
}
|
|
305
317
|
|
|
306
318
|
export interface AdvancedConfigUpdateRequest {
|
|
@@ -318,6 +330,9 @@ export interface AdvancedConfigUpdateRequest {
|
|
|
318
330
|
SHOPIFY_ADMIN_SLUG?: string;
|
|
319
331
|
USER_SETUP_WEBHOOKS?: boolean;
|
|
320
332
|
RESEND_API_KEY?: string;
|
|
333
|
+
GOOGLE_OAUTH_CLIENT_ID?: string;
|
|
334
|
+
GOOGLE_OAUTH_CLIENT_SECRET?: string;
|
|
335
|
+
GOOGLE_CALENDAR_ID?: string;
|
|
321
336
|
}
|
|
322
337
|
|
|
323
338
|
export interface MenuNodeState {
|
|
@@ -520,6 +535,14 @@ export interface CategorizedResults {
|
|
|
520
535
|
}
|
|
521
536
|
|
|
522
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';
|
|
523
546
|
|
|
524
547
|
export interface BookingEntity {
|
|
525
548
|
id: string; // traceId
|
|
@@ -528,7 +551,14 @@ export interface BookingEntity {
|
|
|
528
551
|
startTime: string; // ISO-8601 UTC string
|
|
529
552
|
endTime: string; // ISO-8601 UTC string
|
|
530
553
|
status: BookingStatus;
|
|
554
|
+
appointmentMode: AppointmentMode;
|
|
531
555
|
shopifyOrderId?: string;
|
|
556
|
+
googleEventId?: string;
|
|
557
|
+
googleMeetURL?: string;
|
|
558
|
+
googleSyncStatus: GoogleSyncStatus;
|
|
559
|
+
googleLastError?: string;
|
|
560
|
+
confirmationEmailSent: boolean;
|
|
561
|
+
linkAddedEmailSent: boolean;
|
|
532
562
|
createdAt: string; // ISO-8601 UTC string
|
|
533
563
|
leadEmail?: string;
|
|
534
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();
|
|
@@ -49,6 +49,8 @@ export function convertToLocalState(
|
|
|
49
49
|
timezone: 'UTC',
|
|
50
50
|
bufferGapsMinutes: 15,
|
|
51
51
|
maxLengthMinutes: 0,
|
|
52
|
+
allowRemote: false,
|
|
53
|
+
remoteOnly: false,
|
|
52
54
|
businessHours: {},
|
|
53
55
|
unavailableHours: [],
|
|
54
56
|
},
|
|
@@ -62,6 +64,11 @@ export function convertToLocalState(
|
|
|
62
64
|
export function convertToBackendFormat(
|
|
63
65
|
localState: BrandConfigState
|
|
64
66
|
): BrandConfig {
|
|
67
|
+
const scheduling = { ...localState.scheduling };
|
|
68
|
+
if (scheduling.remoteOnly) {
|
|
69
|
+
scheduling.allowRemote = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
return {
|
|
66
73
|
TENANT_ID: localState.tenantId,
|
|
67
74
|
SITE_INIT: localState.siteInit,
|
|
@@ -86,7 +93,7 @@ export function convertToBackendFormat(
|
|
|
86
93
|
HAS_SHOPIFY: localState.hasShopify,
|
|
87
94
|
SHOW_SHOPIFY_HELPER: localState.showShopifyHelper,
|
|
88
95
|
HAS_RESEND: localState.hasResend,
|
|
89
|
-
SCHEDULING:
|
|
96
|
+
SCHEDULING: scheduling,
|
|
90
97
|
ADMIN_EMAIL: localState.adminEmail,
|
|
91
98
|
|
|
92
99
|
// ALWAYS send asset paths (current state)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
2
|
+
import type { CartItemState } from '@/stores/shopify';
|
|
3
|
+
|
|
4
|
+
export type AppointmentMode = 'IN_PERSON' | 'REMOTE';
|
|
5
|
+
|
|
6
|
+
export type AppointmentSchedulingInput = {
|
|
7
|
+
allowRemote?: boolean;
|
|
8
|
+
remoteOnly?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AppointmentModeConstraints = {
|
|
12
|
+
serviceResources: ResourceNode[];
|
|
13
|
+
anyServiceRemoteOnly: boolean;
|
|
14
|
+
someServiceForcesInPerson: boolean;
|
|
15
|
+
allServicesAllowRemote: boolean;
|
|
16
|
+
effectiveRemoteOnly: boolean;
|
|
17
|
+
effectiveAllowRemote: boolean;
|
|
18
|
+
remoteAvailable: boolean;
|
|
19
|
+
inPersonAvailable: boolean;
|
|
20
|
+
hasImpossibleRemoteMix: boolean;
|
|
21
|
+
/** Same as `remoteAvailable`; kept for cart naming parity */
|
|
22
|
+
canRemote: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collects service resources involved in booking (same rules as Cart / CheckoutModal).
|
|
27
|
+
*/
|
|
28
|
+
export function collectBookingServiceResources(
|
|
29
|
+
cart: Record<string, CartItemState>,
|
|
30
|
+
resources: ResourceNode[]
|
|
31
|
+
): ResourceNode[] {
|
|
32
|
+
const dedupe = new Map<string, ResourceNode>();
|
|
33
|
+
for (const item of Object.values(cart)) {
|
|
34
|
+
const resource = resources.find((r) => r.id === item.resourceId);
|
|
35
|
+
if (
|
|
36
|
+
resource &&
|
|
37
|
+
(resource.categorySlug === 'service' ||
|
|
38
|
+
resource.optionsPayload?.bookingLengthMinutes)
|
|
39
|
+
) {
|
|
40
|
+
dedupe.set(resource.id, resource);
|
|
41
|
+
}
|
|
42
|
+
if (item.boundResourceId) {
|
|
43
|
+
const bound = resources.find((r) => r.id === item.boundResourceId);
|
|
44
|
+
if (bound) {
|
|
45
|
+
dedupe.set(bound.id, bound);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Array.from(dedupe.values());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns true if the cart would mix remote-only services with in-person-only services.
|
|
54
|
+
*/
|
|
55
|
+
export function wouldCartHaveImpossibleRemoteMix(
|
|
56
|
+
nextCart: Record<string, CartItemState>,
|
|
57
|
+
resources: ResourceNode[]
|
|
58
|
+
): boolean {
|
|
59
|
+
const svc = collectBookingServiceResources(nextCart, resources);
|
|
60
|
+
const anyRemoteOnly = svc.some((r) => Boolean(r.optionsPayload?.remoteOnly));
|
|
61
|
+
const someInPersonOnly = svc.some(
|
|
62
|
+
(r) =>
|
|
63
|
+
!Boolean(r.optionsPayload?.remoteOnly) &&
|
|
64
|
+
!Boolean(r.optionsPayload?.allowRemote)
|
|
65
|
+
);
|
|
66
|
+
return anyRemoteOnly && someInPersonOnly;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Single source of truth for tenant + per-service remote eligibility.
|
|
71
|
+
*/
|
|
72
|
+
export function deriveAppointmentConstraints(
|
|
73
|
+
cart: Record<string, CartItemState>,
|
|
74
|
+
resources: ResourceNode[],
|
|
75
|
+
tenantScheduling: AppointmentSchedulingInput
|
|
76
|
+
): AppointmentModeConstraints {
|
|
77
|
+
const serviceResources = collectBookingServiceResources(cart, resources);
|
|
78
|
+
const allowRemote = Boolean(tenantScheduling.allowRemote);
|
|
79
|
+
const tenantRemoteOnly = Boolean(tenantScheduling.remoteOnly);
|
|
80
|
+
|
|
81
|
+
const anyServiceRemoteOnly = serviceResources.some((r) =>
|
|
82
|
+
Boolean(r.optionsPayload?.remoteOnly)
|
|
83
|
+
);
|
|
84
|
+
const someServiceForcesInPerson = serviceResources.some(
|
|
85
|
+
(r) =>
|
|
86
|
+
!Boolean(r.optionsPayload?.remoteOnly) &&
|
|
87
|
+
!Boolean(r.optionsPayload?.allowRemote)
|
|
88
|
+
);
|
|
89
|
+
const allServicesAllowRemote =
|
|
90
|
+
serviceResources.length === 0 ||
|
|
91
|
+
serviceResources.every(
|
|
92
|
+
(r) =>
|
|
93
|
+
Boolean(r.optionsPayload?.remoteOnly) ||
|
|
94
|
+
Boolean(r.optionsPayload?.allowRemote)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const effectiveRemoteOnly = tenantRemoteOnly || anyServiceRemoteOnly;
|
|
98
|
+
const effectiveAllowRemote = allowRemote || tenantRemoteOnly;
|
|
99
|
+
|
|
100
|
+
const remoteAvailable =
|
|
101
|
+
effectiveRemoteOnly ||
|
|
102
|
+
(effectiveAllowRemote &&
|
|
103
|
+
serviceResources.length > 0 &&
|
|
104
|
+
allServicesAllowRemote);
|
|
105
|
+
|
|
106
|
+
const inPersonAvailable = !effectiveRemoteOnly;
|
|
107
|
+
const hasImpossibleRemoteMix =
|
|
108
|
+
anyServiceRemoteOnly && someServiceForcesInPerson;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
serviceResources,
|
|
112
|
+
anyServiceRemoteOnly,
|
|
113
|
+
someServiceForcesInPerson,
|
|
114
|
+
allServicesAllowRemote,
|
|
115
|
+
effectiveRemoteOnly,
|
|
116
|
+
effectiveAllowRemote,
|
|
117
|
+
remoteAvailable,
|
|
118
|
+
inPersonAvailable,
|
|
119
|
+
hasImpossibleRemoteMix,
|
|
120
|
+
canRemote: remoteAvailable,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function pickInitialAppointmentMode(
|
|
125
|
+
c: AppointmentModeConstraints,
|
|
126
|
+
currentPreferred: AppointmentMode
|
|
127
|
+
): AppointmentMode {
|
|
128
|
+
if (c.effectiveRemoteOnly) {
|
|
129
|
+
return 'REMOTE';
|
|
130
|
+
}
|
|
131
|
+
if (currentPreferred === 'REMOTE' && c.remoteAvailable) {
|
|
132
|
+
return 'REMOTE';
|
|
133
|
+
}
|
|
134
|
+
return 'IN_PERSON';
|
|
135
|
+
}
|
|
@@ -53,6 +53,8 @@ export const RESTRICTION_MESSAGES = {
|
|
|
53
53
|
TERMS: 'Please review the terms for this item before adding it to your cart.',
|
|
54
54
|
MAX_DURATION: (max: number) =>
|
|
55
55
|
`You cannot book more than ${max} minutes of services in one session.`,
|
|
56
|
+
INCOMPATIBLE_REMOTE:
|
|
57
|
+
'This service cannot be combined with the services already in your cart. Some require remote-only delivery while others can only be delivered in person.',
|
|
56
58
|
DEFAULT_ADD: (title: string) => `${title} has been added to your cart.`,
|
|
57
59
|
};
|
|
58
60
|
|
package/utils/inject-files.ts
CHANGED
|
@@ -753,10 +753,6 @@ export async function injectTemplateFiles(
|
|
|
753
753
|
src: resolve('../templates/src/utils/actions/preParse_Action.ts'),
|
|
754
754
|
dest: 'src/utils/actions/preParse_Action.ts',
|
|
755
755
|
},
|
|
756
|
-
{
|
|
757
|
-
src: resolve('../templates/src/utils/actions/preParse_Clicked.ts'),
|
|
758
|
-
dest: 'src/utils/actions/preParse_Clicked.ts',
|
|
759
|
-
},
|
|
760
756
|
{
|
|
761
757
|
src: resolve('../templates/src/utils/actions/preParse_Impression.ts'),
|
|
762
758
|
dest: 'src/utils/actions/preParse_Impression.ts',
|
|
@@ -900,6 +896,14 @@ export async function injectTemplateFiles(
|
|
|
900
896
|
src: resolve('../templates/custom/shopify/cart.astro'),
|
|
901
897
|
dest: 'src/pages/cart.astro',
|
|
902
898
|
},
|
|
899
|
+
{
|
|
900
|
+
src: resolve('../templates/src/pages/privacy.astro'),
|
|
901
|
+
dest: 'src/pages/privacy.astro',
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
src: resolve('../templates/src/pages/terms.astro'),
|
|
905
|
+
dest: 'src/pages/terms.astro',
|
|
906
|
+
},
|
|
903
907
|
{
|
|
904
908
|
src: resolve('../templates/src/pages/404.astro'),
|
|
905
909
|
dest: 'src/pages/404.astro',
|
|
@@ -1000,6 +1004,22 @@ export async function injectTemplateFiles(
|
|
|
1000
1004
|
src: resolve('../templates/src/pages/api/auth/logout.ts'),
|
|
1001
1005
|
dest: 'src/pages/api/auth/logout.ts',
|
|
1002
1006
|
},
|
|
1007
|
+
{
|
|
1008
|
+
src: resolve('../templates/src/pages/api/google/oauth/start.ts'),
|
|
1009
|
+
dest: 'src/pages/api/google/oauth/start.ts',
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
src: resolve('../templates/src/pages/api/google/oauth/status.ts'),
|
|
1013
|
+
dest: 'src/pages/api/google/oauth/status.ts',
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
src: resolve('../templates/src/pages/api/google/oauth/disconnect.ts'),
|
|
1017
|
+
dest: 'src/pages/api/google/oauth/disconnect.ts',
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
src: resolve('../templates/src/pages/api/google/oauth/callback.ts'),
|
|
1021
|
+
dest: 'src/pages/api/google/oauth/callback.ts',
|
|
1022
|
+
},
|
|
1003
1023
|
{
|
|
1004
1024
|
src: resolve('../templates/src/pages/api/orphan-analysis.ts'),
|
|
1005
1025
|
dest: 'src/pages/api/orphan-analysis.ts',
|
|
@@ -2351,6 +2371,11 @@ export async function injectTemplateFiles(
|
|
|
2351
2371
|
dest: 'src/utils/customHelpers.ts',
|
|
2352
2372
|
protected: true,
|
|
2353
2373
|
},
|
|
2374
|
+
{
|
|
2375
|
+
src: resolve('../templates/src/utils/booking/appointmentMode.ts'),
|
|
2376
|
+
dest: 'src/utils/booking/appointmentMode.ts',
|
|
2377
|
+
protected: true,
|
|
2378
|
+
},
|
|
2354
2379
|
{
|
|
2355
2380
|
src: resolve('../templates/custom/shopify/ShopifyProductGrid.tsx'),
|
|
2356
2381
|
dest: 'src/custom/shopify/ShopifyProductGrid.tsx',
|