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.
Files changed (58) hide show
  1. package/bin/create-tractstack.js +7 -4
  2. package/dist/index.js +51 -8
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +279 -118
  5. package/templates/custom/shopify/CartIcon.tsx +8 -8
  6. package/templates/custom/shopify/CheckoutModal.tsx +328 -65
  7. package/templates/custom/shopify/ShopifyCartManager.tsx +117 -60
  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/custom/shopify/cart.astro +7 -1
  12. package/templates/src/components/Header.astro +4 -2
  13. package/templates/src/components/compositor/Node.tsx +39 -9
  14. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  15. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  16. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  17. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  18. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  19. package/templates/src/components/form/advanced/APIConfigSection.tsx +249 -4
  20. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  21. package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
  22. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +66 -21
  23. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +266 -18
  24. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
  25. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  26. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +240 -65
  27. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  28. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +91 -10
  29. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  30. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  31. package/templates/src/constants.ts +2 -0
  32. package/templates/src/layouts/Layout.astro +26 -0
  33. package/templates/src/pages/api/auth/logout.ts +35 -2
  34. package/templates/src/pages/api/google/oauth/callback.ts +50 -0
  35. package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
  36. package/templates/src/pages/api/google/oauth/start.ts +32 -0
  37. package/templates/src/pages/api/google/oauth/status.ts +32 -0
  38. package/templates/src/pages/api/sales/list.ts +66 -0
  39. package/templates/src/pages/api/sales/metrics.ts +60 -0
  40. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  41. package/templates/src/pages/privacy.astro +84 -0
  42. package/templates/src/pages/storykeep/advanced.astro +4 -1
  43. package/templates/src/pages/terms.astro +47 -0
  44. package/templates/src/stores/nodes.ts +8 -0
  45. package/templates/src/stores/shopify.ts +5 -0
  46. package/templates/src/types/tractstack.ts +87 -0
  47. package/templates/src/utils/api/advancedConfig.ts +2 -1
  48. package/templates/src/utils/api/advancedHelpers.ts +20 -0
  49. package/templates/src/utils/api/bookingHelpers.ts +3 -1
  50. package/templates/src/utils/api/brandConfig.ts +2 -0
  51. package/templates/src/utils/api/brandHelpers.ts +14 -1
  52. package/templates/src/utils/api/salesHelpers.ts +21 -0
  53. package/templates/src/utils/booking/appointmentMode.ts +135 -0
  54. package/templates/src/utils/customHelpers.ts +287 -2
  55. package/utils/inject-files.ts +47 -4
  56. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
  57. package/templates/src/utils/actions/actionButton.ts +0 -103
  58. 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: localState.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
+ }