astro-tractstack 2.3.3 → 2.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bin/create-tractstack.js +5 -2
  2. package/dist/index.js +32 -4
  3. package/package.json +1 -1
  4. package/templates/custom/customHelpers.ts +45 -0
  5. package/templates/custom/shopify/Cart.tsx +197 -105
  6. package/templates/custom/shopify/CartIcon.tsx +8 -8
  7. package/templates/custom/shopify/CheckoutModal.tsx +145 -68
  8. package/templates/custom/shopify/ShopifyCartManager.tsx +67 -22
  9. package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
  10. package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
  11. package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
  12. package/templates/custom/shopify/shopifyCustomHelper.ts +10 -0
  13. package/templates/custom/shopify/shopifyHelpers.ts +298 -0
  14. package/templates/src/components/Header.astro +2 -2
  15. package/templates/src/components/codehooks/SearchWidget.tsx +1 -1
  16. package/templates/src/components/compositor/Node.tsx +39 -9
  17. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  18. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  19. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  20. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  21. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  22. package/templates/src/components/form/advanced/APIConfigSection.tsx +35 -8
  23. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  24. package/templates/src/components/search/SearchResults.tsx +1 -1
  25. package/templates/src/components/search/SearchWrapper.tsx +1 -1
  26. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
  27. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
  28. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  29. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +162 -67
  30. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  31. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
  32. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  33. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  34. package/templates/src/layouts/Layout.astro +26 -0
  35. package/templates/src/pages/api/auth/logout.ts +35 -2
  36. package/templates/src/pages/api/sales/list.ts +66 -0
  37. package/templates/src/pages/api/sales/metrics.ts +60 -0
  38. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  39. package/templates/src/pages/storykeep/advanced.astro +4 -1
  40. package/templates/src/stores/nodes.ts +8 -0
  41. package/templates/src/types/tractstack.ts +57 -0
  42. package/templates/src/utils/api/advancedConfig.ts +2 -1
  43. package/templates/src/utils/api/advancedHelpers.ts +4 -0
  44. package/templates/src/utils/api/brandConfig.ts +2 -0
  45. package/templates/src/utils/api/brandHelpers.ts +6 -0
  46. package/templates/src/utils/api/salesHelpers.ts +21 -0
  47. package/utils/inject-files.ts +32 -4
  48. package/templates/src/utils/customHelpers.ts +0 -89
  49. /package/templates/{src/utils/booking → custom/shopify}/appointmentMode.ts +0 -0
@@ -0,0 +1,60 @@
1
+ import type { APIRoute } from '@/types/astro';
2
+ import { getAdminToken } from '@/utils/auth';
3
+
4
+ export const GET: APIRoute = async (context) => {
5
+ const { request, locals } = context;
6
+ const GO_BACKEND =
7
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
8
+
9
+ try {
10
+ const controller = new AbortController();
11
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
12
+ const tenantId =
13
+ locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
14
+ const token = getAdminToken(context);
15
+
16
+ try {
17
+ const response = await fetch(`${GO_BACKEND}/api/v1/sales/metrics`, {
18
+ method: 'GET',
19
+ headers: {
20
+ 'Content-Type': 'application/json',
21
+ 'X-Tenant-ID': tenantId,
22
+ ...(token && { Authorization: `Bearer ${token}` }),
23
+ ...(request.headers.get('Authorization') && {
24
+ Authorization: request.headers.get('Authorization')!,
25
+ }),
26
+ },
27
+ signal: controller.signal,
28
+ });
29
+
30
+ clearTimeout(timeoutId);
31
+ const data = await response.json();
32
+
33
+ return new Response(JSON.stringify(data), {
34
+ status: response.status,
35
+ headers: { 'Content-Type': 'application/json' },
36
+ });
37
+ } catch (fetchError) {
38
+ clearTimeout(timeoutId);
39
+ if (fetchError instanceof Error && fetchError.name === 'AbortError') {
40
+ return new Response(
41
+ JSON.stringify({
42
+ success: false,
43
+ error: 'Sales metrics lookup timeout',
44
+ }),
45
+ { status: 408, headers: { 'Content-Type': 'application/json' } }
46
+ );
47
+ }
48
+ throw fetchError;
49
+ }
50
+ } catch (error) {
51
+ console.error('Sales metrics API proxy error:', error);
52
+ return new Response(
53
+ JSON.stringify({
54
+ success: false,
55
+ error: 'Failed to connect to backend service',
56
+ }),
57
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
58
+ );
59
+ }
60
+ };
@@ -1,9 +1,7 @@
1
1
  ---
2
2
  import Layout from '@/layouts/Layout.astro';
3
- import CodeHook from '@/custom/CodeHook.astro';
4
3
  import { getBrandConfig } from '@/utils/api/brandConfig';
5
4
  import { handleFailedResponse } from '@/utils/backend';
6
- import { getFullContentMap } from '@/stores/analytics';
7
5
  import { preHealthCheck } from '@/utils/backend';
8
6
 
9
7
  const tenantId =
@@ -58,8 +56,6 @@ try {
58
56
 
59
57
  const paneId = contextPaneData.id;
60
58
  const paneTitle = contextPaneData.title || 'Context';
61
- const codeHookTarget = contextPaneData.codeHookTarget || null;
62
- const resourcesPayload = storyData.resourcesPayload || {};
63
59
 
64
60
  // Get rendered fragment for the context pane
65
61
  let fragmentData = '';
@@ -93,8 +89,6 @@ try {
93
89
  );
94
90
  }
95
91
 
96
- // Get supporting data
97
- const fullContentMap = await getFullContentMap(tenantId);
98
92
  const brandConfig = await getBrandConfig(tenantId);
99
93
 
100
94
  if (!brandConfig.SITE_INIT) {
@@ -122,18 +116,7 @@ if (!brandConfig.SITE_INIT) {
122
116
  hx-trigger="refresh"
123
117
  hx-swap="innerHTML scroll:none"
124
118
  >
125
- {
126
- codeHookTarget ? (
127
- <CodeHook
128
- target={codeHookTarget}
129
- paneId={paneId}
130
- resourcesPayload={resourcesPayload}
131
- fullContentMap={fullContentMap}
132
- />
133
- ) : (
134
- <Fragment set:html={fragmentData} />
135
- )
136
- }
119
+ <Fragment set:html={fragmentData} />
137
120
  </div>
138
121
 
139
122
  <div class="py-12 text-center text-2xl md:text-3xl">
@@ -148,20 +131,56 @@ if (!brandConfig.SITE_INIT) {
148
131
  </Layout>
149
132
 
150
133
  <script>
151
- document.addEventListener('DOMContentLoaded', function () {
134
+ const IN_SITE_FROM_KEY = 'tractstack:inSiteFrom';
135
+
136
+ function getInSiteFrom() {
137
+ const rawFromPath = sessionStorage.getItem(IN_SITE_FROM_KEY);
138
+ if (!rawFromPath) {
139
+ return null;
140
+ }
141
+
142
+ try {
143
+ const fromUrl = new URL(rawFromPath, window.location.origin);
144
+ if (fromUrl.origin !== window.location.origin) {
145
+ return null;
146
+ }
147
+
148
+ const fromPath = fromUrl.pathname + fromUrl.search + fromUrl.hash;
149
+ const currentPath =
150
+ window.location.pathname +
151
+ window.location.search +
152
+ window.location.hash;
153
+
154
+ if (fromPath === currentPath) {
155
+ return null;
156
+ }
157
+
158
+ return fromPath;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ function setupContextCloseButton() {
152
165
  const closeBtn = document.getElementById('context-close-btn');
153
166
 
154
- if (closeBtn) {
155
- closeBtn.addEventListener('click', function () {
156
- // Check if we have history to go back to
157
- if (window.history.length > 1 && document.referrer) {
158
- // Go back to previous page
159
- window.history.back();
160
- } else {
161
- // Fallback to home page
162
- window.location.href = '/';
163
- }
164
- });
167
+ if (!closeBtn) {
168
+ return;
165
169
  }
166
- });
170
+
171
+ const fromPath = getInSiteFrom();
172
+ closeBtn.textContent = fromPath ? 'Close' : 'Home';
173
+
174
+ closeBtn.onclick = function () {
175
+ if (fromPath) {
176
+ window.location.assign(fromPath);
177
+ return;
178
+ }
179
+
180
+ window.location.assign('/');
181
+ };
182
+ }
183
+
184
+ setupContextCloseButton();
185
+ document.addEventListener('astro:page-load', setupContextCloseButton);
167
186
  </script>
@@ -64,7 +64,10 @@ try {
64
64
  role={role}
65
65
  initializing={initializing}
66
66
  />
67
- <StoryKeepDashboard_Advanced client:only="react" />
67
+ <StoryKeepDashboard_Advanced
68
+ client:only="react"
69
+ brandConfig={brandConfig}
70
+ />
68
71
  </div>
69
72
  </main>
70
73
  </Layout>
@@ -1876,6 +1876,14 @@ export class NodesContext {
1876
1876
  if (ownerNode && 'slug' in ownerNode && typeof ownerNode.slug === `string`)
1877
1877
  duplicatedPane.slug = ownerNode.slug;
1878
1878
 
1879
+ this.deleteChildren(ownerId);
1880
+
1881
+ if (pane.htmlAst) {
1882
+ duplicatedPane.htmlAst = pane.htmlAst;
1883
+ } else {
1884
+ delete duplicatedPane.htmlAst;
1885
+ }
1886
+
1879
1887
  // Track all nodes that need to be added
1880
1888
  // Call the new helper to process markdown, gridLayout, and bgPane
1881
1889
  const allNodes: BaseNode[] = this._processPaneTemplate(
@@ -207,6 +207,7 @@ export interface BrandConfig {
207
207
  HAS_HYDRATION_TOKEN?: boolean;
208
208
  SCHEDULING?: SchedulingConfig;
209
209
  ADMIN_EMAIL?: string;
210
+ ADMIN_EMAIL_NAME?: string;
210
211
  }
211
212
 
212
213
  export interface BrandConfigState {
@@ -246,6 +247,7 @@ export interface BrandConfigState {
246
247
  hasHydrationToken: boolean;
247
248
  scheduling: SchedulingConfig;
248
249
  adminEmail: string;
250
+ adminEmailName: string;
249
251
  }
250
252
 
251
253
  // Form validation types
@@ -294,6 +296,7 @@ export interface AdvancedConfigStatus {
294
296
  googleRefreshTokenSet: boolean;
295
297
  googleTokenExpirySet: boolean;
296
298
  hasGoogleSync: boolean;
299
+ hasResend: boolean;
297
300
  }
298
301
 
299
302
  export interface AdvancedConfigState {
@@ -310,6 +313,8 @@ export interface AdvancedConfigState {
310
313
  shopifyAdminSlug: string;
311
314
  userSetupWebhooks: boolean;
312
315
  resendApiKey: string;
316
+ adminEmail: string;
317
+ adminEmailName: string;
313
318
  googleOauthClientId: string;
314
319
  googleOauthClientSecret: string;
315
320
  googleCalendarId: string;
@@ -569,6 +574,58 @@ export interface BookingListResponse {
569
574
  totalCount: number;
570
575
  }
571
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
+
572
629
  export interface BookingMetricsResponse {
573
630
  totalMonthlyConfirmed: number;
574
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
  }
@@ -30,6 +30,8 @@ export function convertToLocalState(
30
30
  googleOauthClientId: '',
31
31
  googleOauthClientSecret: '',
32
32
  googleCalendarId: '',
33
+ adminEmail: '',
34
+ adminEmailName: '',
33
35
  };
34
36
  }
35
37
 
@@ -53,6 +55,8 @@ export function convertToLocalState(
53
55
  googleOauthClientId: '',
54
56
  googleOauthClientSecret: '',
55
57
  googleCalendarId: '',
58
+ adminEmail: '',
59
+ adminEmailName: '',
56
60
  };
57
61
  }
58
62
 
@@ -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,6 +45,7 @@ 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,
@@ -95,6 +96,7 @@ export function convertToBackendFormat(
95
96
  HAS_RESEND: localState.hasResend,
96
97
  SCHEDULING: scheduling,
97
98
  ADMIN_EMAIL: localState.adminEmail,
99
+ ADMIN_EMAIL_NAME: localState.adminEmailName,
98
100
 
99
101
  // ALWAYS send asset paths (current state)
100
102
  LOGO: localState.logo,
@@ -139,6 +141,10 @@ export function validateBrandConfig(state: BrandConfigState): FieldErrors {
139
141
  errors.adminEmail = 'Please enter a valid email address';
140
142
  }
141
143
 
144
+ if (!state.adminEmailName?.trim()) {
145
+ errors.adminEmailName = 'Admin Email Name is required';
146
+ }
147
+
142
148
  // Validate brand colors (must have exactly 8)
143
149
  if (!state.brandColours || state.brandColours.length !== 8) {
144
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
+ };
@@ -822,6 +822,10 @@ export async function injectTemplateFiles(
822
822
  src: resolve('../templates/src/utils/api/bookingHelpers.ts'),
823
823
  dest: 'src/utils/api/bookingHelpers.ts',
824
824
  },
825
+ {
826
+ src: resolve('../templates/src/utils/api/salesHelpers.ts'),
827
+ dest: 'src/utils/api/salesHelpers.ts',
828
+ },
825
829
  {
826
830
  src: resolve('../templates/src/utils/api/menuHelpers.ts'),
827
831
  dest: 'src/utils/api/menuHelpers.ts',
@@ -960,6 +964,14 @@ export async function injectTemplateFiles(
960
964
  src: resolve('../templates/src/pages/api/booking/list.ts'),
961
965
  dest: 'src/pages/api/booking/list.ts',
962
966
  },
967
+ {
968
+ src: resolve('../templates/src/pages/api/sales/list.ts'),
969
+ dest: 'src/pages/api/sales/list.ts',
970
+ },
971
+ {
972
+ src: resolve('../templates/src/pages/api/sales/metrics.ts'),
973
+ dest: 'src/pages/api/sales/metrics.ts',
974
+ },
963
975
  {
964
976
  src: resolve('../templates/src/pages/api/booking/metrics.ts'),
965
977
  dest: 'src/pages/api/booking/metrics.ts',
@@ -1284,6 +1296,12 @@ export async function injectTemplateFiles(
1284
1296
  ),
1285
1297
  dest: 'src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx',
1286
1298
  },
1299
+ {
1300
+ src: resolve(
1301
+ '../templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx'
1302
+ ),
1303
+ dest: 'src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx',
1304
+ },
1287
1305
  {
1288
1306
  src: resolve(
1289
1307
  '../templates/src/components/storykeep/email-builder/EmailBuilder.tsx'
@@ -2367,13 +2385,23 @@ export async function injectTemplateFiles(
2367
2385
  protected: true,
2368
2386
  },
2369
2387
  {
2370
- src: resolve('../templates/src/utils/customHelpers.ts'),
2371
- dest: 'src/utils/customHelpers.ts',
2388
+ src: resolve('../templates/custom/customHelpers.ts'),
2389
+ dest: 'src/custom/customHelpers.ts',
2390
+ protected: true,
2391
+ },
2392
+ {
2393
+ src: resolve('../templates/custom/shopify/shopifyCustomHelper.ts'),
2394
+ dest: 'src/custom/shopify/shopifyCustomHelper.ts',
2395
+ protected: true,
2396
+ },
2397
+ {
2398
+ src: resolve('../templates/custom/shopify/shopifyHelpers.ts'),
2399
+ dest: 'src/custom/shopify/shopifyHelpers.ts',
2372
2400
  protected: true,
2373
2401
  },
2374
2402
  {
2375
- src: resolve('../templates/src/utils/booking/appointmentMode.ts'),
2376
- dest: 'src/utils/booking/appointmentMode.ts',
2403
+ src: resolve('../templates/custom/shopify/appointmentMode.ts'),
2404
+ dest: 'src/custom/shopify/appointmentMode.ts',
2377
2405
  protected: true,
2378
2406
  },
2379
2407
  {
@@ -1,89 +0,0 @@
1
- import type { ResourceNode } from '@/types/compositorTypes';
2
- import type { CartItemState } from '@/stores/shopify';
3
-
4
- // URL Helper: Strip category prefix from slug
5
- // e.g., "people-bleako" -> "bleako"
6
- export function getCleanSlug(categorySlug: string, fullSlug: string): string {
7
- const prefix = `${categorySlug}-`;
8
- return fullSlug.startsWith(prefix) ? fullSlug.slice(prefix.length) : fullSlug;
9
- }
10
-
11
- // Build proper URL for resource
12
- // e.g., category="people", slug="people-bleako" -> "/people/bleako"
13
- export function getResourceUrl(categorySlug: string, fullSlug: string): string {
14
- const cleanSlug = getCleanSlug(categorySlug, fullSlug);
15
- return `/${categorySlug}/${cleanSlug}`;
16
- }
17
-
18
- // Image Helper: Placeholder implementation
19
- export function getResourceImage(
20
- id: string,
21
- slug: string,
22
- category: string
23
- ): string {
24
- console.log(`please define getResourceImage`, id, slug, category);
25
- return '/static.jpg';
26
- }
27
-
28
- export function getResourceDescription(
29
- id: string,
30
- slug: string,
31
- category: string
32
- ): string | null {
33
- console.log(`please define getResourceDescription`, id, slug, category);
34
- return null;
35
- }
36
-
37
- // Initialize search data - override in custom implementation
38
- export function initSearch(): void {
39
- // Default implementation does nothing
40
- // Override this function in your custom implementation to load search data
41
- }
42
-
43
- // Field Visibility Controls for ResourceForm
44
- export const resourceFormHideFields = ['gid', 'shopifyImage'];
45
-
46
- // Field Formatting Controls for ResourceForm
47
- // Fields listed here will be treated as JSON objects but rendered as stringified text areas
48
- export const resourceJsonifyFields = ['shopifyData', 'shopifyImage'];
49
-
50
- export const RESTRICTION_MESSAGES = {
51
- BOOKING: (duration: number) =>
52
- `This is a ${duration} minute service. On checkout we'll help you book at your convenience.`,
53
- TERMS: 'Please review the terms for this item before adding it to your cart.',
54
- MAX_DURATION: (max: number) =>
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.',
58
- DEFAULT_ADD: (title: string) => `${title} has been added to your cart.`,
59
- };
60
-
61
- // For CartModal.tsx
62
- export function checkRestrictions(resource: ResourceNode): boolean {
63
- // 1. Service / Booking Requirement
64
- // We check for the explicit option payload value used by services
65
- if (resource.optionsPayload?.bookingLengthMinutes) {
66
- return true;
67
- }
68
-
69
- // 2. Final Sale / Terms Check
70
- // Placeholder: In the future, check for flags like resource.optionsPayload?.finalSale
71
- // if (resource.optionsPayload?.finalSale) {
72
- // return true;
73
- // }
74
-
75
- return false;
76
- }
77
-
78
- export function calculateCartDuration(
79
- cart: Record<string, CartItemState>,
80
- resources: ResourceNode[]
81
- ): number {
82
- return Object.values(cart).reduce((total, item) => {
83
- const resource = resources.find((r) => r.id === item.resourceId);
84
- const duration = Number(
85
- resource?.optionsPayload?.bookingLengthMinutes || 0
86
- );
87
- return total + (isNaN(duration) ? 0 : duration * item.quantity);
88
- }, 0);
89
- }