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,298 @@
1
+ import { getCartItemKey as baseGetCartItemKey } from '@/stores/shopify';
2
+ import type { CartItemState, CartKeyParams } from '@/stores/shopify';
3
+ import type { ResourceNode } from '@/types/compositorTypes';
4
+
5
+ const SERVICES_ATTR_LIMIT = 255;
6
+
7
+ type CheckoutLineAttribute = { key: string; value: string };
8
+
9
+ export type ShopifyCheckoutLine = {
10
+ merchandiseId: string;
11
+ quantity: number;
12
+ attributes?: CheckoutLineAttribute[];
13
+ };
14
+
15
+ export type DepositSummary = {
16
+ title: string;
17
+ amount: string;
18
+ currencyCode: string;
19
+ variantId: string;
20
+ };
21
+
22
+ export type SharedFeeChargeLineSummary = DepositSummary & {
23
+ servicesCount: number;
24
+ description?: string;
25
+ };
26
+
27
+ export function calculateCartDuration(
28
+ cart: Record<string, CartItemState>,
29
+ resources: ResourceNode[]
30
+ ): number {
31
+ return Object.values(cart).reduce((total, item) => {
32
+ const resource = resources.find((r) => r.id === item.resourceId);
33
+ const duration = Number(
34
+ resource?.optionsPayload?.bookingLengthMinutes || 0
35
+ );
36
+ return total + (isNaN(duration) ? 0 : duration * item.quantity);
37
+ }, 0);
38
+ }
39
+
40
+ export function getProductByGid(
41
+ resources: ResourceNode[],
42
+ gid?: string
43
+ ): ResourceNode | undefined {
44
+ if (!gid) return undefined;
45
+ return resources.find(
46
+ (r) => r.categorySlug === 'product' && r.optionsPayload?.gid === gid
47
+ );
48
+ }
49
+
50
+ export function getServiceLinkedProduct(
51
+ service: ResourceNode,
52
+ resources: ResourceNode[]
53
+ ): ResourceNode | undefined {
54
+ const gid =
55
+ typeof service.optionsPayload?.gid === 'string'
56
+ ? service.optionsPayload.gid
57
+ : undefined;
58
+ return getProductByGid(resources, gid);
59
+ }
60
+
61
+ export function parsePrimaryShopifyProductData(
62
+ resource?: ResourceNode
63
+ ): any | null {
64
+ if (!resource?.optionsPayload?.shopifyData) {
65
+ return null;
66
+ }
67
+ try {
68
+ const parsed = JSON.parse(resource.optionsPayload.shopifyData);
69
+ return parsed.products?.[0] || parsed;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function extractVariantForResource(resource?: ResourceNode): any | null {
76
+ const parsed = parsePrimaryShopifyProductData(resource);
77
+ if (!parsed) return null;
78
+ const variants = parsed.variants || [];
79
+ return variants[0] || null;
80
+ }
81
+
82
+ export function parseDepositFromProductResource(
83
+ product?: ResourceNode
84
+ ): DepositSummary | null {
85
+ if (!product) return null;
86
+ const parsed = parsePrimaryShopifyProductData(product);
87
+ const variant = extractVariantForResource(product);
88
+ const variantId = variant?.id;
89
+ if (!variantId) return null;
90
+ return {
91
+ title: parsed?.title || product.title,
92
+ amount: variant?.price?.amount || '0.00',
93
+ currencyCode: variant?.price?.currencyCode || 'USD',
94
+ variantId,
95
+ };
96
+ }
97
+
98
+ export function isSharedFeeService(
99
+ service: ResourceNode | undefined,
100
+ resources: ResourceNode[]
101
+ ): boolean {
102
+ if (!service || service.categorySlug !== 'service') return false;
103
+ const product = getServiceLinkedProduct(service, resources);
104
+ return product?.optionsPayload?.sharedServiceFee === true;
105
+ }
106
+
107
+ export function getServiceDisplayTitle(
108
+ service: ResourceNode | undefined,
109
+ resources: ResourceNode[]
110
+ ): string {
111
+ if (!service) return 'Service';
112
+ const product = getServiceLinkedProduct(service, resources);
113
+ const parsed = parsePrimaryShopifyProductData(product);
114
+ return parsed?.title || service.title;
115
+ }
116
+
117
+ export function getServiceVariantIdFromCanonicalProduct(
118
+ service: ResourceNode | undefined,
119
+ resources: ResourceNode[]
120
+ ): string | undefined {
121
+ if (!service || service.categorySlug !== 'service') {
122
+ return undefined;
123
+ }
124
+ const product = getServiceLinkedProduct(service, resources);
125
+ const variant = extractVariantForResource(product);
126
+ return typeof variant?.id === 'string' ? variant.id : undefined;
127
+ }
128
+
129
+ export function getCartItemKey(
130
+ params: CartKeyParams,
131
+ resource?: ResourceNode,
132
+ resources: ResourceNode[] = []
133
+ ): string {
134
+ if (resource && isSharedFeeService(resource, resources)) {
135
+ return params.resourceId;
136
+ }
137
+ return baseGetCartItemKey(params);
138
+ }
139
+
140
+ export function collectServiceGids(services: ResourceNode[]): Set<string> {
141
+ const gids = new Set<string>();
142
+ services.forEach((service) => {
143
+ if (typeof service.optionsPayload?.gid === 'string') {
144
+ gids.add(service.optionsPayload.gid);
145
+ }
146
+ });
147
+ return gids;
148
+ }
149
+
150
+ function formatServicesAttribute(
151
+ services: ResourceNode[]
152
+ ): CheckoutLineAttribute {
153
+ const titles = services.map((s) => s.title);
154
+ const joined = titles.join(', ');
155
+ if (joined.length <= SERVICES_ATTR_LIMIT) {
156
+ return { key: 'Services', value: joined };
157
+ }
158
+ return { key: 'Services', value: `${services.length} services` };
159
+ }
160
+
161
+ export function getDepositLineSummary(
162
+ cart: Record<string, CartItemState>,
163
+ resources: ResourceNode[]
164
+ ): DepositSummary | null {
165
+ const chargeLine = getSharedFeeChargeLineSummary(cart, resources);
166
+ if (!chargeLine) {
167
+ return null;
168
+ }
169
+ return {
170
+ title: chargeLine.title,
171
+ amount: chargeLine.amount,
172
+ currencyCode: chargeLine.currencyCode,
173
+ variantId: chargeLine.variantId,
174
+ };
175
+ }
176
+
177
+ export function getSharedFeeChargeLineSummary(
178
+ cart: Record<string, CartItemState>,
179
+ resources: ResourceNode[]
180
+ ): SharedFeeChargeLineSummary | null {
181
+ const serviceIds = new Set(
182
+ Object.values(cart).map((item) => item.resourceId)
183
+ );
184
+ const sharedServices = resources.filter(
185
+ (r) => serviceIds.has(r.id) && isSharedFeeService(r, resources)
186
+ );
187
+ if (sharedServices.length === 0) {
188
+ return null;
189
+ }
190
+ const canonicalProduct = getServiceLinkedProduct(
191
+ sharedServices[0],
192
+ resources
193
+ );
194
+ const deposit = parseDepositFromProductResource(canonicalProduct);
195
+ const canonicalProductData = parsePrimaryShopifyProductData(canonicalProduct);
196
+ const description =
197
+ typeof canonicalProductData?.description === 'string' &&
198
+ canonicalProductData.description.trim().length > 0
199
+ ? canonicalProductData.description
200
+ : undefined;
201
+ if (!deposit) {
202
+ return null;
203
+ }
204
+ return {
205
+ ...deposit,
206
+ servicesCount: sharedServices.length,
207
+ description,
208
+ };
209
+ }
210
+
211
+ export function buildShopifyCheckoutLines(
212
+ cart: Record<string, CartItemState>,
213
+ resources: ResourceNode[]
214
+ ): ShopifyCheckoutLine[] {
215
+ const lines: ShopifyCheckoutLine[] = [];
216
+ const cartItems = Object.values(cart);
217
+ const sharedFeeServices: ResourceNode[] = [];
218
+ const sharedFeeServiceIds = new Set<string>();
219
+
220
+ cartItems.forEach((item) => {
221
+ const resource = resources.find((r) => r.id === item.resourceId);
222
+ if (isSharedFeeService(resource, resources) && resource) {
223
+ sharedFeeServices.push(resource);
224
+ sharedFeeServiceIds.add(resource.id);
225
+ }
226
+ });
227
+
228
+ if (sharedFeeServices.length > 0) {
229
+ const canonicalProduct = getServiceLinkedProduct(
230
+ sharedFeeServices[0],
231
+ resources
232
+ );
233
+ const deposit = parseDepositFromProductResource(canonicalProduct);
234
+ if (deposit?.variantId) {
235
+ lines.push({
236
+ merchandiseId: deposit.variantId,
237
+ quantity: 1,
238
+ attributes: [formatServicesAttribute(sharedFeeServices)],
239
+ });
240
+ }
241
+ }
242
+
243
+ cartItems.forEach((item) => {
244
+ const resource = resources.find((r) => r.id === item.resourceId);
245
+ if (!resource) return;
246
+ if (sharedFeeServiceIds.has(resource.id)) return;
247
+ const nonSharedServiceVariant =
248
+ resource.categorySlug === 'service'
249
+ ? getServiceVariantIdFromCanonicalProduct(resource, resources)
250
+ : undefined;
251
+ const merchandiseId = item.variantId || nonSharedServiceVariant;
252
+ if (!merchandiseId) return;
253
+ lines.push({
254
+ merchandiseId,
255
+ quantity: item.quantity || 1,
256
+ });
257
+ });
258
+
259
+ return lines;
260
+ }
261
+
262
+ export function hasGidBackedCheckout(
263
+ cart: Record<string, CartItemState>,
264
+ resources: ResourceNode[]
265
+ ): boolean {
266
+ const cartItems = Object.values(cart);
267
+ return cartItems.some((item) => {
268
+ const resource = resources.find((r) => r.id === item.resourceId);
269
+ if (!resource) return false;
270
+ return (
271
+ typeof resource.optionsPayload?.gid === 'string' &&
272
+ !!resource.optionsPayload.gid
273
+ );
274
+ });
275
+ }
276
+
277
+ export function getCartIconCount(
278
+ cart: Record<string, CartItemState>,
279
+ resources: ResourceNode[]
280
+ ): number {
281
+ const cartValues = Object.values(cart);
282
+ const boundServiceIds = new Set(
283
+ cartValues.map((item) => item.boundResourceId).filter(Boolean)
284
+ );
285
+ let sharedFeeAdded = false;
286
+
287
+ return cartValues
288
+ .filter((item) => !boundServiceIds.has(item.resourceId))
289
+ .reduce((total, item) => {
290
+ const resource = resources.find((r) => r.id === item.resourceId);
291
+ if (isSharedFeeService(resource, resources)) {
292
+ if (sharedFeeAdded) return total;
293
+ sharedFeeAdded = true;
294
+ return total + 1;
295
+ }
296
+ return total + item.quantity;
297
+ }, 0);
298
+ }
@@ -76,7 +76,7 @@ if (hasShopify) {
76
76
  <>
77
77
  {slug !== `cart` ? (
78
78
  <div class="flex w-full justify-end px-4 py-2 md:px-8">
79
- <CartIcon client:only="react" />
79
+ <CartIcon client:only="react" resources={shopifyResources} />
80
80
  </div>
81
81
  ) : (
82
82
  <CheckoutModal
@@ -141,7 +141,7 @@ if (hasShopify) {
141
141
  <div
142
142
  class="flex flex-row flex-nowrap justify-between bg-mywhite px-4 pb-3 pt-4 shadow-inner md:px-8"
143
143
  >
144
- <h1 class="truncate text-xl text-mydarkgrey">{title}</h1>
144
+ <h1 class="truncate text-2xl text-mydarkgrey">{title}</h1>
145
145
  <div class="flex flex-row flex-nowrap items-center gap-x-2">
146
146
  {
147
147
  !isHome ? (
@@ -16,7 +16,7 @@ import {
16
16
  getResourceUrl,
17
17
  getResourceImage,
18
18
  getResourceDescription,
19
- } from '@/utils/customHelpers';
19
+ } from '@/custom/customHelpers';
20
20
 
21
21
  // --- TYPES ---
22
22
  interface SearchWidgetProps {
@@ -8,6 +8,7 @@ import {
8
8
  isTopLevelBlockNode,
9
9
  parseCodeHook,
10
10
  } from '@/utils/compositor/nodesHelper';
11
+ import { isStoryFragmentNode } from '@/utils/compositor/typeGuards';
11
12
  import { PaneAddMode } from '@/types/compositorTypes';
12
13
  import { NodeOverlay } from './tools/NodeOverlay';
13
14
  import PanelVisibilityWrapper from '@/components/compositor/PanelVisibilityWrapper';
@@ -108,14 +109,6 @@ export const Node = memo((props: NodeProps) => {
108
109
  case 'Pane':
109
110
  {
110
111
  const paneNode = node as PaneNode;
111
- const storyfragmentNodeId = ctx.getClosestNodeTypeFromId(
112
- node.id,
113
- 'StoryFragment'
114
- );
115
- const storyfragmentNode = ctx.allNodes
116
- .get()
117
- .get(storyfragmentNodeId) as StoryFragmentNode;
118
- const first = paneNode.id === storyfragmentNode.paneIds?.[0];
119
112
  const isHtmlAstPane = !!paneNode.htmlAst;
120
113
  const paneNodes = ctx.getChildNodeIDs(node.id);
121
114
 
@@ -140,7 +133,7 @@ export const Node = memo((props: NodeProps) => {
140
133
  );
141
134
  }
142
135
 
143
- if (!isPreview && !paneNodes.length) {
136
+ if (!isPreview && !paneNodes.length && !isHtmlAstPane) {
144
137
  return (
145
138
  <>
146
139
  <ContextPanePanel nodeId={node.id} />
@@ -160,8 +153,45 @@ export const Node = memo((props: NodeProps) => {
160
153
  </>
161
154
  );
162
155
  }
156
+
157
+ const contextContent = isHtmlAstPane ? (
158
+ <CreativePane nodeId={props.nodeId} htmlAst={paneNode.htmlAst!} />
159
+ ) : (
160
+ <Pane {...props} />
161
+ );
162
+
163
+ element = (
164
+ <>
165
+ <div className="py-0.5">
166
+ <ConfigPanePanel
167
+ nodeId={props.nodeId}
168
+ isHtmlAstPane={isHtmlAstPane}
169
+ isSandboxMode={props.isSandboxMode || false}
170
+ />
171
+ <PanelVisibilityWrapper
172
+ nodeId={props.nodeId}
173
+ panelType="settings"
174
+ ctx={ctx}
175
+ >
176
+ {contextContent}
177
+ </PanelVisibilityWrapper>
178
+ </div>
179
+ </>
180
+ );
181
+ break;
163
182
  }
164
183
 
184
+ const storyfragmentNodeId = ctx.getClosestNodeTypeFromId(
185
+ node.id,
186
+ 'StoryFragment'
187
+ );
188
+ const storyfragmentNode = storyfragmentNodeId
189
+ ? (ctx.allNodes.get().get(storyfragmentNodeId) ?? null)
190
+ : null;
191
+ const first =
192
+ isStoryFragmentNode(storyfragmentNode) &&
193
+ paneNode.id === storyfragmentNode.paneIds?.[0];
194
+
165
195
  const content = isHtmlAstPane ? (
166
196
  <CreativePane nodeId={props.nodeId} htmlAst={paneNode.htmlAst!} />
167
197
  ) : (