astro-tractstack 2.3.3 → 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.
Files changed (42) hide show
  1. package/bin/create-tractstack.js +5 -2
  2. package/dist/index.js +18 -0
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +196 -104
  5. package/templates/custom/shopify/CartIcon.tsx +8 -8
  6. package/templates/custom/shopify/CheckoutModal.tsx +143 -66
  7. package/templates/custom/shopify/ShopifyCartManager.tsx +64 -19
  8. package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
  9. package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
  10. package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
  11. package/templates/src/components/Header.astro +1 -1
  12. package/templates/src/components/compositor/Node.tsx +39 -9
  13. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  14. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  15. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  16. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  17. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  18. package/templates/src/components/form/advanced/APIConfigSection.tsx +35 -8
  19. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  20. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
  21. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
  22. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  23. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +161 -66
  24. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  25. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
  26. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  27. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  28. package/templates/src/layouts/Layout.astro +26 -0
  29. package/templates/src/pages/api/auth/logout.ts +35 -2
  30. package/templates/src/pages/api/sales/list.ts +66 -0
  31. package/templates/src/pages/api/sales/metrics.ts +60 -0
  32. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  33. package/templates/src/pages/storykeep/advanced.astro +4 -1
  34. package/templates/src/stores/nodes.ts +8 -0
  35. package/templates/src/types/tractstack.ts +57 -0
  36. package/templates/src/utils/api/advancedConfig.ts +2 -1
  37. package/templates/src/utils/api/advancedHelpers.ts +4 -0
  38. package/templates/src/utils/api/brandConfig.ts +2 -0
  39. package/templates/src/utils/api/brandHelpers.ts +6 -0
  40. package/templates/src/utils/api/salesHelpers.ts +21 -0
  41. package/templates/src/utils/customHelpers.ts +285 -2
  42. package/utils/inject-files.ts +18 -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
+ };
@@ -1,5 +1,6 @@
1
+ import { getCartItemKey as baseGetCartItemKey } from '@/stores/shopify';
2
+ import type { CartItemState, CartKeyParams } from '@/stores/shopify';
1
3
  import type { ResourceNode } from '@/types/compositorTypes';
2
- import type { CartItemState } from '@/stores/shopify';
3
4
 
4
5
  // URL Helper: Strip category prefix from slug
5
6
  // e.g., "people-bleako" -> "bleako"
@@ -41,12 +42,34 @@ export function initSearch(): void {
41
42
  }
42
43
 
43
44
  // Field Visibility Controls for ResourceForm
44
- export const resourceFormHideFields = ['gid', 'shopifyImage'];
45
+ export const resourceFormHideFields = ['shopifyImage'];
45
46
 
46
47
  // Field Formatting Controls for ResourceForm
47
48
  // Fields listed here will be treated as JSON objects but rendered as stringified text areas
48
49
  export const resourceJsonifyFields = ['shopifyData', 'shopifyImage'];
49
50
 
51
+ const SERVICES_ATTR_LIMIT = 255;
52
+
53
+ type CheckoutLineAttribute = { key: string; value: string };
54
+
55
+ export type ShopifyCheckoutLine = {
56
+ merchandiseId: string;
57
+ quantity: number;
58
+ attributes?: CheckoutLineAttribute[];
59
+ };
60
+
61
+ export type DepositSummary = {
62
+ title: string;
63
+ amount: string;
64
+ currencyCode: string;
65
+ variantId: string;
66
+ };
67
+
68
+ export type SharedFeeChargeLineSummary = DepositSummary & {
69
+ servicesCount: number;
70
+ description?: string;
71
+ };
72
+
50
73
  export const RESTRICTION_MESSAGES = {
51
74
  BOOKING: (duration: number) =>
52
75
  `This is a ${duration} minute service. On checkout we'll help you book at your convenience.`,
@@ -87,3 +110,263 @@ export function calculateCartDuration(
87
110
  return total + (isNaN(duration) ? 0 : duration * item.quantity);
88
111
  }, 0);
89
112
  }
113
+
114
+ export function getProductByGid(
115
+ resources: ResourceNode[],
116
+ gid?: string
117
+ ): ResourceNode | undefined {
118
+ if (!gid) return undefined;
119
+ return resources.find(
120
+ (r) => r.categorySlug === 'product' && r.optionsPayload?.gid === gid
121
+ );
122
+ }
123
+
124
+ export function getServiceLinkedProduct(
125
+ service: ResourceNode,
126
+ resources: ResourceNode[]
127
+ ): ResourceNode | undefined {
128
+ const gid =
129
+ typeof service.optionsPayload?.gid === 'string'
130
+ ? service.optionsPayload.gid
131
+ : undefined;
132
+ return getProductByGid(resources, gid);
133
+ }
134
+
135
+ export function parsePrimaryShopifyProductData(
136
+ resource?: ResourceNode
137
+ ): any | null {
138
+ if (!resource?.optionsPayload?.shopifyData) {
139
+ return null;
140
+ }
141
+ try {
142
+ const parsed = JSON.parse(resource.optionsPayload.shopifyData);
143
+ return parsed.products?.[0] || parsed;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function extractVariantForResource(resource?: ResourceNode): any | null {
150
+ const parsed = parsePrimaryShopifyProductData(resource);
151
+ if (!parsed) return null;
152
+ const variants = parsed.variants || [];
153
+ return variants[0] || null;
154
+ }
155
+
156
+ export function parseDepositFromProductResource(
157
+ product?: ResourceNode
158
+ ): DepositSummary | null {
159
+ if (!product) return null;
160
+ const parsed = parsePrimaryShopifyProductData(product);
161
+ const variant = extractVariantForResource(product);
162
+ const variantId = variant?.id;
163
+ if (!variantId) return null;
164
+ return {
165
+ title: parsed?.title || product.title,
166
+ amount: variant?.price?.amount || '0.00',
167
+ currencyCode: variant?.price?.currencyCode || 'USD',
168
+ variantId,
169
+ };
170
+ }
171
+
172
+ export function isSharedFeeService(
173
+ service: ResourceNode | undefined,
174
+ resources: ResourceNode[]
175
+ ): boolean {
176
+ if (!service || service.categorySlug !== 'service') return false;
177
+ const product = getServiceLinkedProduct(service, resources);
178
+ return product?.optionsPayload?.sharedServiceFee === true;
179
+ }
180
+
181
+ export function getServiceDisplayTitle(
182
+ service: ResourceNode | undefined,
183
+ resources: ResourceNode[]
184
+ ): string {
185
+ if (!service) return 'Service';
186
+ const product = getServiceLinkedProduct(service, resources);
187
+ const parsed = parsePrimaryShopifyProductData(product);
188
+ return parsed?.title || service.title;
189
+ }
190
+
191
+ export function getServiceVariantIdFromCanonicalProduct(
192
+ service: ResourceNode | undefined,
193
+ resources: ResourceNode[]
194
+ ): string | undefined {
195
+ if (!service || service.categorySlug !== 'service') {
196
+ return undefined;
197
+ }
198
+ const product = getServiceLinkedProduct(service, resources);
199
+ const variant = extractVariantForResource(product);
200
+ return typeof variant?.id === 'string' ? variant.id : undefined;
201
+ }
202
+
203
+ export function getCartItemKey(
204
+ params: CartKeyParams,
205
+ resource?: ResourceNode,
206
+ resources: ResourceNode[] = []
207
+ ): string {
208
+ if (resource && isSharedFeeService(resource, resources)) {
209
+ return params.resourceId;
210
+ }
211
+ return baseGetCartItemKey(params);
212
+ }
213
+
214
+ export function collectServiceGids(services: ResourceNode[]): Set<string> {
215
+ const gids = new Set<string>();
216
+ services.forEach((service) => {
217
+ if (typeof service.optionsPayload?.gid === 'string') {
218
+ gids.add(service.optionsPayload.gid);
219
+ }
220
+ });
221
+ return gids;
222
+ }
223
+
224
+ function formatServicesAttribute(
225
+ services: ResourceNode[]
226
+ ): CheckoutLineAttribute {
227
+ const titles = services.map((s) => s.title);
228
+ const joined = titles.join(', ');
229
+ if (joined.length <= SERVICES_ATTR_LIMIT) {
230
+ return { key: 'Services', value: joined };
231
+ }
232
+ return { key: 'Services', value: `${services.length} services` };
233
+ }
234
+
235
+ export function getDepositLineSummary(
236
+ cart: Record<string, CartItemState>,
237
+ resources: ResourceNode[]
238
+ ): DepositSummary | null {
239
+ const chargeLine = getSharedFeeChargeLineSummary(cart, resources);
240
+ if (!chargeLine) {
241
+ return null;
242
+ }
243
+ return {
244
+ title: chargeLine.title,
245
+ amount: chargeLine.amount,
246
+ currencyCode: chargeLine.currencyCode,
247
+ variantId: chargeLine.variantId,
248
+ };
249
+ }
250
+
251
+ export function getSharedFeeChargeLineSummary(
252
+ cart: Record<string, CartItemState>,
253
+ resources: ResourceNode[]
254
+ ): SharedFeeChargeLineSummary | null {
255
+ const serviceIds = new Set(
256
+ Object.values(cart).map((item) => item.resourceId)
257
+ );
258
+ const sharedServices = resources.filter(
259
+ (r) => serviceIds.has(r.id) && isSharedFeeService(r, resources)
260
+ );
261
+ if (sharedServices.length === 0) {
262
+ return null;
263
+ }
264
+ const canonicalProduct = getServiceLinkedProduct(
265
+ sharedServices[0],
266
+ resources
267
+ );
268
+ const deposit = parseDepositFromProductResource(canonicalProduct);
269
+ const canonicalProductData = parsePrimaryShopifyProductData(canonicalProduct);
270
+ const description =
271
+ typeof canonicalProductData?.description === 'string' &&
272
+ canonicalProductData.description.trim().length > 0
273
+ ? canonicalProductData.description
274
+ : undefined;
275
+ if (!deposit) {
276
+ return null;
277
+ }
278
+ return {
279
+ ...deposit,
280
+ servicesCount: sharedServices.length,
281
+ description,
282
+ };
283
+ }
284
+
285
+ export function buildShopifyCheckoutLines(
286
+ cart: Record<string, CartItemState>,
287
+ resources: ResourceNode[]
288
+ ): ShopifyCheckoutLine[] {
289
+ const lines: ShopifyCheckoutLine[] = [];
290
+ const cartItems = Object.values(cart);
291
+ const sharedFeeServices: ResourceNode[] = [];
292
+ const sharedFeeServiceIds = new Set<string>();
293
+
294
+ cartItems.forEach((item) => {
295
+ const resource = resources.find((r) => r.id === item.resourceId);
296
+ if (isSharedFeeService(resource, resources) && resource) {
297
+ sharedFeeServices.push(resource);
298
+ sharedFeeServiceIds.add(resource.id);
299
+ }
300
+ });
301
+
302
+ if (sharedFeeServices.length > 0) {
303
+ const canonicalProduct = getServiceLinkedProduct(
304
+ sharedFeeServices[0],
305
+ resources
306
+ );
307
+ const deposit = parseDepositFromProductResource(canonicalProduct);
308
+ if (deposit?.variantId) {
309
+ lines.push({
310
+ merchandiseId: deposit.variantId,
311
+ quantity: 1,
312
+ attributes: [formatServicesAttribute(sharedFeeServices)],
313
+ });
314
+ }
315
+ }
316
+
317
+ cartItems.forEach((item) => {
318
+ const resource = resources.find((r) => r.id === item.resourceId);
319
+ if (!resource) return;
320
+ if (sharedFeeServiceIds.has(resource.id)) return;
321
+ const nonSharedServiceVariant =
322
+ resource.categorySlug === 'service'
323
+ ? getServiceVariantIdFromCanonicalProduct(resource, resources)
324
+ : undefined;
325
+ const merchandiseId = item.variantId || nonSharedServiceVariant;
326
+ if (!merchandiseId) return;
327
+ lines.push({
328
+ merchandiseId,
329
+ quantity: item.quantity || 1,
330
+ });
331
+ });
332
+
333
+ return lines;
334
+ }
335
+
336
+ export function hasGidBackedCheckout(
337
+ cart: Record<string, CartItemState>,
338
+ resources: ResourceNode[]
339
+ ): boolean {
340
+ const cartItems = Object.values(cart);
341
+ return cartItems.some((item) => {
342
+ const resource = resources.find((r) => r.id === item.resourceId);
343
+ if (!resource) return false;
344
+ return (
345
+ typeof resource.optionsPayload?.gid === 'string' &&
346
+ !!resource.optionsPayload.gid
347
+ );
348
+ });
349
+ }
350
+
351
+ export function getCartIconCount(
352
+ cart: Record<string, CartItemState>,
353
+ resources: ResourceNode[]
354
+ ): number {
355
+ const cartValues = Object.values(cart);
356
+ const boundServiceIds = new Set(
357
+ cartValues.map((item) => item.boundResourceId).filter(Boolean)
358
+ );
359
+ let sharedFeeAdded = false;
360
+
361
+ return cartValues
362
+ .filter((item) => !boundServiceIds.has(item.resourceId))
363
+ .reduce((total, item) => {
364
+ const resource = resources.find((r) => r.id === item.resourceId);
365
+ if (isSharedFeeService(resource, resources)) {
366
+ if (sharedFeeAdded) return total;
367
+ sharedFeeAdded = true;
368
+ return total + 1;
369
+ }
370
+ return total + item.quantity;
371
+ }, 0);
372
+ }