astro-tractstack 2.3.2 → 2.3.4
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 +7 -4
- package/dist/index.js +51 -8
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +279 -118
- package/templates/custom/shopify/CartIcon.tsx +8 -8
- package/templates/custom/shopify/CheckoutModal.tsx +328 -65
- package/templates/custom/shopify/ShopifyCartManager.tsx +117 -60
- package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
- package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Header.astro +4 -2
- package/templates/src/components/compositor/Node.tsx +39 -9
- package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
- package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
- package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/form/advanced/APIConfigSection.tsx +249 -4
- package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +66 -21
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +266 -18
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +240 -65
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +91 -10
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
- package/templates/src/constants.ts +2 -0
- package/templates/src/layouts/Layout.astro +26 -0
- package/templates/src/pages/api/auth/logout.ts +35 -2
- 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/api/sales/list.ts +66 -0
- package/templates/src/pages/api/sales/metrics.ts +60 -0
- package/templates/src/pages/context/[...contextSlug].astro +50 -31
- package/templates/src/pages/privacy.astro +84 -0
- package/templates/src/pages/storykeep/advanced.astro +4 -1
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/nodes.ts +8 -0
- package/templates/src/stores/shopify.ts +5 -0
- package/templates/src/types/tractstack.ts +87 -0
- package/templates/src/utils/api/advancedConfig.ts +2 -1
- package/templates/src/utils/api/advancedHelpers.ts +20 -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 +14 -1
- package/templates/src/utils/api/salesHelpers.ts +21 -0
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +287 -2
- package/utils/inject-files.ts +47 -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
|
@@ -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
|
}
|
|
@@ -205,6 +207,7 @@ export interface BrandConfig {
|
|
|
205
207
|
HAS_HYDRATION_TOKEN?: boolean;
|
|
206
208
|
SCHEDULING?: SchedulingConfig;
|
|
207
209
|
ADMIN_EMAIL?: string;
|
|
210
|
+
ADMIN_EMAIL_NAME?: string;
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
export interface BrandConfigState {
|
|
@@ -244,6 +247,7 @@ export interface BrandConfigState {
|
|
|
244
247
|
hasHydrationToken: boolean;
|
|
245
248
|
scheduling: SchedulingConfig;
|
|
246
249
|
adminEmail: string;
|
|
250
|
+
adminEmailName: string;
|
|
247
251
|
}
|
|
248
252
|
|
|
249
253
|
// Form validation types
|
|
@@ -285,6 +289,14 @@ export interface AdvancedConfigStatus {
|
|
|
285
289
|
resendApiKeySet: boolean;
|
|
286
290
|
shopifyAdminSlugSet: boolean;
|
|
287
291
|
userSetupWebhooks: boolean;
|
|
292
|
+
googleOauthClientIdSet: boolean;
|
|
293
|
+
googleOauthClientSecretSet: boolean;
|
|
294
|
+
googleCalendarIdSet: boolean;
|
|
295
|
+
googleAccessTokenSet: boolean;
|
|
296
|
+
googleRefreshTokenSet: boolean;
|
|
297
|
+
googleTokenExpirySet: boolean;
|
|
298
|
+
hasGoogleSync: boolean;
|
|
299
|
+
hasResend: boolean;
|
|
288
300
|
}
|
|
289
301
|
|
|
290
302
|
export interface AdvancedConfigState {
|
|
@@ -301,6 +313,11 @@ export interface AdvancedConfigState {
|
|
|
301
313
|
shopifyAdminSlug: string;
|
|
302
314
|
userSetupWebhooks: boolean;
|
|
303
315
|
resendApiKey: string;
|
|
316
|
+
adminEmail: string;
|
|
317
|
+
adminEmailName: string;
|
|
318
|
+
googleOauthClientId: string;
|
|
319
|
+
googleOauthClientSecret: string;
|
|
320
|
+
googleCalendarId: string;
|
|
304
321
|
}
|
|
305
322
|
|
|
306
323
|
export interface AdvancedConfigUpdateRequest {
|
|
@@ -318,6 +335,9 @@ export interface AdvancedConfigUpdateRequest {
|
|
|
318
335
|
SHOPIFY_ADMIN_SLUG?: string;
|
|
319
336
|
USER_SETUP_WEBHOOKS?: boolean;
|
|
320
337
|
RESEND_API_KEY?: string;
|
|
338
|
+
GOOGLE_OAUTH_CLIENT_ID?: string;
|
|
339
|
+
GOOGLE_OAUTH_CLIENT_SECRET?: string;
|
|
340
|
+
GOOGLE_CALENDAR_ID?: string;
|
|
321
341
|
}
|
|
322
342
|
|
|
323
343
|
export interface MenuNodeState {
|
|
@@ -520,6 +540,14 @@ export interface CategorizedResults {
|
|
|
520
540
|
}
|
|
521
541
|
|
|
522
542
|
export type BookingStatus = 'PENDING' | 'CONFIRMED' | 'CANCELLED';
|
|
543
|
+
export type AppointmentMode = 'IN_PERSON' | 'REMOTE';
|
|
544
|
+
export type GoogleSyncStatus =
|
|
545
|
+
| 'NOT_SYNCED'
|
|
546
|
+
| 'PENDING'
|
|
547
|
+
| 'SYNCED'
|
|
548
|
+
| 'DELETE_PENDING'
|
|
549
|
+
| 'DELETE_SYNCED'
|
|
550
|
+
| 'FAILED';
|
|
523
551
|
|
|
524
552
|
export interface BookingEntity {
|
|
525
553
|
id: string; // traceId
|
|
@@ -528,7 +556,14 @@ export interface BookingEntity {
|
|
|
528
556
|
startTime: string; // ISO-8601 UTC string
|
|
529
557
|
endTime: string; // ISO-8601 UTC string
|
|
530
558
|
status: BookingStatus;
|
|
559
|
+
appointmentMode: AppointmentMode;
|
|
531
560
|
shopifyOrderId?: string;
|
|
561
|
+
googleEventId?: string;
|
|
562
|
+
googleMeetURL?: string;
|
|
563
|
+
googleSyncStatus: GoogleSyncStatus;
|
|
564
|
+
googleLastError?: string;
|
|
565
|
+
confirmationEmailSent: boolean;
|
|
566
|
+
linkAddedEmailSent: boolean;
|
|
532
567
|
createdAt: string; // ISO-8601 UTC string
|
|
533
568
|
leadEmail?: string;
|
|
534
569
|
leadName?: string;
|
|
@@ -539,6 +574,58 @@ export interface BookingListResponse {
|
|
|
539
574
|
totalCount: number;
|
|
540
575
|
}
|
|
541
576
|
|
|
577
|
+
export type SaleStatus = 'PAID';
|
|
578
|
+
export type SaleTag = 'local-pickup' | 'orphan' | 'in-person' | 'remote';
|
|
579
|
+
|
|
580
|
+
export interface SaleProductLine {
|
|
581
|
+
resourceId: string;
|
|
582
|
+
gid: string;
|
|
583
|
+
variantId: string;
|
|
584
|
+
quantity: number;
|
|
585
|
+
title: string;
|
|
586
|
+
price: string;
|
|
587
|
+
currencyCode: string;
|
|
588
|
+
isLocalPickup: boolean;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export interface SaleEntity {
|
|
592
|
+
id: string; // traceId
|
|
593
|
+
leadId: string;
|
|
594
|
+
leadEmail?: string;
|
|
595
|
+
leadName?: string;
|
|
596
|
+
bookingId: string | null;
|
|
597
|
+
shopifyOrderId: string;
|
|
598
|
+
totalAmount: string;
|
|
599
|
+
status: SaleStatus;
|
|
600
|
+
products: SaleProductLine[];
|
|
601
|
+
appointmentIntent: boolean;
|
|
602
|
+
tags: SaleTag[];
|
|
603
|
+
booking: BookingEntity | null;
|
|
604
|
+
createdAt: string; // ISO-8601 UTC string
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export interface SaleListResponse {
|
|
608
|
+
data: SaleEntity[];
|
|
609
|
+
totalCount: number;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export interface SaleMetricsResponse {
|
|
613
|
+
paidOrderTotalMonth: string;
|
|
614
|
+
paidOrderTotalYear: string;
|
|
615
|
+
paidOrderTotalAllTime: string;
|
|
616
|
+
paidOrdersMonth: number;
|
|
617
|
+
paidOrdersYear: number;
|
|
618
|
+
paidOrdersAllTime: number;
|
|
619
|
+
averagePaidOrderMonth: string;
|
|
620
|
+
uniquePayingCustomers: number;
|
|
621
|
+
localPickupLineTotalMonth: string;
|
|
622
|
+
localPickupOrdersMonth: number;
|
|
623
|
+
appointmentOrdersMonth: number;
|
|
624
|
+
productOnlyOrdersMonth: number;
|
|
625
|
+
orphanOrdersMonth: number;
|
|
626
|
+
currencyCode: string;
|
|
627
|
+
}
|
|
628
|
+
|
|
542
629
|
export interface BookingMetricsResponse {
|
|
543
630
|
totalMonthlyConfirmed: number;
|
|
544
631
|
totalAnnualConfirmed: number;
|
|
@@ -34,7 +34,8 @@ export async function getAdvancedConfigStatus(
|
|
|
34
34
|
typeof data.shopifyStoreDomainSet !== 'boolean' ||
|
|
35
35
|
typeof data.shopifyAdminSlugSet !== 'boolean' ||
|
|
36
36
|
typeof data.userSetupWebhooks !== 'boolean' ||
|
|
37
|
-
typeof data.resendApiKeySet !== 'boolean'
|
|
37
|
+
typeof data.resendApiKeySet !== 'boolean' ||
|
|
38
|
+
typeof data.hasResend !== 'boolean'
|
|
38
39
|
) {
|
|
39
40
|
throw new Error('Invalid response format from server');
|
|
40
41
|
}
|
|
@@ -27,6 +27,11 @@ export function convertToLocalState(
|
|
|
27
27
|
resendApiKey: '',
|
|
28
28
|
shopifyAdminSlug: '',
|
|
29
29
|
userSetupWebhooks: false,
|
|
30
|
+
googleOauthClientId: '',
|
|
31
|
+
googleOauthClientSecret: '',
|
|
32
|
+
googleCalendarId: '',
|
|
33
|
+
adminEmail: '',
|
|
34
|
+
adminEmailName: '',
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -47,6 +52,11 @@ export function convertToLocalState(
|
|
|
47
52
|
resendApiKey: '',
|
|
48
53
|
shopifyAdminSlug: '',
|
|
49
54
|
userSetupWebhooks: status.userSetupWebhooks,
|
|
55
|
+
googleOauthClientId: '',
|
|
56
|
+
googleOauthClientSecret: '',
|
|
57
|
+
googleCalendarId: '',
|
|
58
|
+
adminEmail: '',
|
|
59
|
+
adminEmailName: '',
|
|
50
60
|
};
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -108,6 +118,16 @@ export function convertToBackendFormat(
|
|
|
108
118
|
request.RESEND_API_KEY = state.resendApiKey.trim();
|
|
109
119
|
}
|
|
110
120
|
|
|
121
|
+
if (state.googleOauthClientId?.trim()) {
|
|
122
|
+
request.GOOGLE_OAUTH_CLIENT_ID = state.googleOauthClientId.trim();
|
|
123
|
+
}
|
|
124
|
+
if (state.googleOauthClientSecret?.trim()) {
|
|
125
|
+
request.GOOGLE_OAUTH_CLIENT_SECRET = state.googleOauthClientSecret.trim();
|
|
126
|
+
}
|
|
127
|
+
if (state.googleCalendarId?.trim()) {
|
|
128
|
+
request.GOOGLE_CALENDAR_ID = state.googleCalendarId.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
111
131
|
return request;
|
|
112
132
|
}
|
|
113
133
|
|
|
@@ -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();
|
|
@@ -67,6 +67,7 @@ export async function getBrandConfig(tenantId: string): Promise<BrandConfig> {
|
|
|
67
67
|
DESIGN_LIBRARY: [],
|
|
68
68
|
HAS_AAI: false,
|
|
69
69
|
ADMIN_EMAIL: '',
|
|
70
|
+
ADMIN_EMAIL_NAME: '',
|
|
70
71
|
} as BrandConfig;
|
|
71
72
|
}
|
|
72
73
|
throw new Error(response.error || 'Failed to get brand configuration');
|
|
@@ -102,6 +103,7 @@ export async function getBrandConfig(tenantId: string): Promise<BrandConfig> {
|
|
|
102
103
|
DESIGN_LIBRARY: [],
|
|
103
104
|
HAS_AAI: false,
|
|
104
105
|
ADMIN_EMAIL: '',
|
|
106
|
+
ADMIN_EMAIL_NAME: '',
|
|
105
107
|
} as BrandConfig;
|
|
106
108
|
}
|
|
107
109
|
throw error;
|
|
@@ -45,10 +45,13 @@ export function convertToLocalState(
|
|
|
45
45
|
hasResend: brandConfig.HAS_RESEND ?? false,
|
|
46
46
|
hasHydrationToken: brandConfig.HAS_HYDRATION_TOKEN ?? false,
|
|
47
47
|
adminEmail: brandConfig.ADMIN_EMAIL ?? '',
|
|
48
|
+
adminEmailName: brandConfig.ADMIN_EMAIL_NAME ?? '',
|
|
48
49
|
scheduling: brandConfig.SCHEDULING ?? {
|
|
49
50
|
timezone: 'UTC',
|
|
50
51
|
bufferGapsMinutes: 15,
|
|
51
52
|
maxLengthMinutes: 0,
|
|
53
|
+
allowRemote: false,
|
|
54
|
+
remoteOnly: false,
|
|
52
55
|
businessHours: {},
|
|
53
56
|
unavailableHours: [],
|
|
54
57
|
},
|
|
@@ -62,6 +65,11 @@ export function convertToLocalState(
|
|
|
62
65
|
export function convertToBackendFormat(
|
|
63
66
|
localState: BrandConfigState
|
|
64
67
|
): BrandConfig {
|
|
68
|
+
const scheduling = { ...localState.scheduling };
|
|
69
|
+
if (scheduling.remoteOnly) {
|
|
70
|
+
scheduling.allowRemote = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
65
73
|
return {
|
|
66
74
|
TENANT_ID: localState.tenantId,
|
|
67
75
|
SITE_INIT: localState.siteInit,
|
|
@@ -86,8 +94,9 @@ export function convertToBackendFormat(
|
|
|
86
94
|
HAS_SHOPIFY: localState.hasShopify,
|
|
87
95
|
SHOW_SHOPIFY_HELPER: localState.showShopifyHelper,
|
|
88
96
|
HAS_RESEND: localState.hasResend,
|
|
89
|
-
SCHEDULING:
|
|
97
|
+
SCHEDULING: scheduling,
|
|
90
98
|
ADMIN_EMAIL: localState.adminEmail,
|
|
99
|
+
ADMIN_EMAIL_NAME: localState.adminEmailName,
|
|
91
100
|
|
|
92
101
|
// ALWAYS send asset paths (current state)
|
|
93
102
|
LOGO: localState.logo,
|
|
@@ -132,6 +141,10 @@ export function validateBrandConfig(state: BrandConfigState): FieldErrors {
|
|
|
132
141
|
errors.adminEmail = 'Please enter a valid email address';
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
if (!state.adminEmailName?.trim()) {
|
|
145
|
+
errors.adminEmailName = 'Admin Email Name is required';
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
// Validate brand colors (must have exactly 8)
|
|
136
149
|
if (!state.brandColours || state.brandColours.length !== 8) {
|
|
137
150
|
errors.brandColours = 'Must have exactly 8 brand colors';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const salesHelpers = {
|
|
2
|
+
/**
|
|
3
|
+
* Retrieves a paginated list of paid Shopify sales for the administrative dashboard.
|
|
4
|
+
*/
|
|
5
|
+
listSales: async (limit: number = 50, offset: number = 0) => {
|
|
6
|
+
const query = new URLSearchParams({
|
|
7
|
+
limit: limit.toString(),
|
|
8
|
+
offset: offset.toString(),
|
|
9
|
+
});
|
|
10
|
+
const response = await fetch(`/api/sales/list?${query.toString()}`);
|
|
11
|
+
return await response.json();
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Retrieves paid Shopify sales metrics for the administrative dashboard.
|
|
16
|
+
*/
|
|
17
|
+
getMetrics: async () => {
|
|
18
|
+
const response = await fetch('/api/sales/metrics');
|
|
19
|
+
return await response.json();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -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
|
+
}
|