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.
- package/bin/create-tractstack.js +5 -2
- package/dist/index.js +18 -0
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +196 -104
- package/templates/custom/shopify/CartIcon.tsx +8 -8
- package/templates/custom/shopify/CheckoutModal.tsx +143 -66
- package/templates/custom/shopify/ShopifyCartManager.tsx +64 -19
- 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/src/components/Header.astro +1 -1
- 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 +35 -8
- package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +59 -23
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +257 -18
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +161 -66
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +5 -2
- 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/layouts/Layout.astro +26 -0
- package/templates/src/pages/api/auth/logout.ts +35 -2
- 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/storykeep/advanced.astro +4 -1
- package/templates/src/stores/nodes.ts +8 -0
- package/templates/src/types/tractstack.ts +57 -0
- package/templates/src/utils/api/advancedConfig.ts +2 -1
- package/templates/src/utils/api/advancedHelpers.ts +4 -0
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +6 -0
- package/templates/src/utils/api/salesHelpers.ts +21 -0
- package/templates/src/utils/customHelpers.ts +285 -2
- 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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -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 = ['
|
|
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
|
+
}
|