astro-tractstack 2.2.10 → 2.3.0

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 +2 -2
  2. package/dist/index.js +89 -8
  3. package/package.json +3 -1
  4. package/templates/custom/minimal/CodeHook.astro +14 -5
  5. package/templates/custom/shopify/CalDotComBooking.tsx +44 -0
  6. package/templates/custom/shopify/Cart.tsx +345 -0
  7. package/templates/custom/shopify/CartIcon.tsx +47 -0
  8. package/templates/custom/shopify/CartModal.tsx +63 -0
  9. package/templates/custom/shopify/CheckoutModal.tsx +187 -0
  10. package/templates/custom/shopify/ShopifyCartManager.tsx +145 -0
  11. package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
  12. package/templates/custom/shopify/ShopifyProductGrid.tsx +281 -0
  13. package/templates/custom/shopify/ShopifyServiceList.tsx +118 -0
  14. package/templates/custom/shopify/cart.astro +23 -0
  15. package/templates/custom/with-examples/CodeHook.astro +9 -1
  16. package/templates/custom/with-examples/ProductGrid.astro +1 -1
  17. package/templates/src/client/app.js +4 -2
  18. package/templates/src/components/Header.astro +37 -11
  19. package/templates/src/components/form/advanced/APIConfigSection.tsx +165 -38
  20. package/templates/src/components/storykeep/Dashboard.tsx +17 -3
  21. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
  22. package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
  23. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +525 -0
  24. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
  25. package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
  26. package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
  27. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
  28. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +254 -0
  29. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
  30. package/templates/src/lib/resources.ts +11 -21
  31. package/templates/src/pages/api/shopify/createCart.ts +73 -0
  32. package/templates/src/pages/api/shopify/getProducts.ts +64 -0
  33. package/templates/src/pages/storykeep/login.astro +5 -10
  34. package/templates/src/pages/storykeep/logout.astro +1 -10
  35. package/templates/src/pages/storykeep/manage.astro +69 -0
  36. package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
  37. package/templates/src/pages/storykeep/shopify.astro +101 -0
  38. package/templates/src/stores/navigation.ts +3 -42
  39. package/templates/src/stores/nodes.ts +3 -1
  40. package/templates/src/stores/resources.ts +7 -10
  41. package/templates/src/stores/shopify.ts +210 -0
  42. package/templates/src/types/tractstack.ts +21 -0
  43. package/templates/src/utils/api/advancedConfig.ts +5 -1
  44. package/templates/src/utils/api/advancedHelpers.ts +48 -5
  45. package/templates/src/utils/api/brandHelpers.ts +4 -0
  46. package/templates/src/utils/api/resourceConfig.ts +13 -5
  47. package/templates/src/utils/customHelpers.ts +70 -0
  48. package/templates/src/utils/helpers.ts +59 -0
  49. package/utils/inject-files.ts +83 -2
@@ -0,0 +1,281 @@
1
+ import { useState } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import { cartStore, addQueue, type CartAction } from '@/stores/shopify';
4
+ import { getShopifyImage } from '@/utils/helpers';
5
+ import type { ResourceNode } from '@/types/compositorTypes';
6
+
7
+ interface Props {
8
+ resources: Record<string, ResourceNode[]>;
9
+ }
10
+
11
+ interface ShopifyOption {
12
+ name: string;
13
+ values: string[];
14
+ }
15
+
16
+ interface ShopifyVariant {
17
+ id: string;
18
+ title: string;
19
+ price: { amount: string; currencyCode: string };
20
+ selectedOptions: { name: string; value: string }[];
21
+ }
22
+
23
+ interface ProductCardProps {
24
+ resource: ResourceNode;
25
+ allServices: ResourceNode[];
26
+ }
27
+
28
+ function ProductCard({ resource, allServices }: ProductCardProps) {
29
+ const cart = useStore(cartStore);
30
+
31
+ const serviceBoundSlug = resource.optionsPayload?.serviceBound as
32
+ | string
33
+ | undefined;
34
+
35
+ const boundServiceResource = serviceBoundSlug
36
+ ? allServices.find((r) => r.slug === serviceBoundSlug)
37
+ : undefined;
38
+
39
+ let product: any = {};
40
+ try {
41
+ if (resource.optionsPayload?.shopifyData) {
42
+ product = JSON.parse(resource.optionsPayload.shopifyData);
43
+ }
44
+ } catch (e) {
45
+ console.error('Failed to parse Shopify data', resource.id);
46
+ }
47
+
48
+ const options: ShopifyOption[] = product?.options || [];
49
+ const variants: ShopifyVariant[] = product?.variants || [];
50
+
51
+ const isUnconfigured = options.some((o) => o.name === 'Title');
52
+
53
+ if (isUnconfigured) {
54
+ return null;
55
+ }
56
+
57
+ const hasModeOption = options.some((o) => o.name === 'Mode');
58
+ const visibleOptions = options.filter((o) => o.name !== 'Mode');
59
+
60
+ const [selections, setSelections] = useState<Record<string, string>>(() => {
61
+ const initial: Record<string, string> = {};
62
+ visibleOptions.forEach((opt) => {
63
+ initial[opt.name] = opt.values[0];
64
+ });
65
+ return initial;
66
+ });
67
+
68
+ const getVariant = (targetMode: 'Shipped' | 'Pickup' | null) => {
69
+ const found = variants.find((v) => {
70
+ const optionsMatch = visibleOptions.every((opt) => {
71
+ const variantOpt = v.selectedOptions.find((o) => o.name === opt.name);
72
+ return variantOpt?.value === selections[opt.name];
73
+ });
74
+
75
+ if (!optionsMatch) return false;
76
+
77
+ if (hasModeOption && targetMode) {
78
+ const modeOpt = v.selectedOptions.find((o) => o.name === 'Mode');
79
+ return modeOpt?.value === targetMode;
80
+ }
81
+
82
+ return true;
83
+ });
84
+
85
+ return found;
86
+ };
87
+
88
+ const variantShipped = getVariant(hasModeOption ? 'Shipped' : null);
89
+ const variantPickup = getVariant(hasModeOption ? 'Pickup' : null);
90
+ const cartKey = `${resource.id}_${variantShipped?.id || 'null'}_${
91
+ variantPickup?.id || 'null'
92
+ }`;
93
+ const cartItem = cart[cartKey];
94
+ const quantity = cartItem?.quantity || 0;
95
+
96
+ const currentDisplayVariant =
97
+ getVariant('Shipped') || getVariant('Pickup') || variants[0];
98
+ const price = currentDisplayVariant?.price?.amount;
99
+ const currency = currentDisplayVariant?.price?.currencyCode || 'USD';
100
+ const { src, srcSet } = getShopifyImage(
101
+ resource,
102
+ '600',
103
+ currentDisplayVariant?.id
104
+ );
105
+
106
+ const handleAction = (action: 'add' | 'remove') => {
107
+ if (action === 'remove') {
108
+ const queueUpdates: CartAction[] = [];
109
+
110
+ queueUpdates.push({
111
+ resourceId: resource.id,
112
+ variantIdShipped: variantShipped?.id,
113
+ variantIdPickup: variantPickup?.id,
114
+ action: 'remove',
115
+ });
116
+
117
+ if (boundServiceResource) {
118
+ queueUpdates.push({
119
+ resourceId: boundServiceResource.id,
120
+ variantId: boundServiceResource.optionsPayload?.shopifyData
121
+ ? JSON.parse(boundServiceResource.optionsPayload.shopifyData)
122
+ .variants?.[0]?.id
123
+ : undefined,
124
+ action: 'remove',
125
+ });
126
+ }
127
+
128
+ addQueue.set([...addQueue.get(), ...queueUpdates]);
129
+ return;
130
+ }
131
+
132
+ const queueUpdates: CartAction[] = [];
133
+
134
+ const productAction: CartAction & { boundResourceId?: string } = {
135
+ resourceId: resource.id,
136
+ gid: product?.id,
137
+ variantIdShipped: variantShipped?.id,
138
+ variantIdPickup: variantPickup?.id,
139
+ action: 'add',
140
+ boundResourceId: boundServiceResource?.id,
141
+ };
142
+ queueUpdates.push(productAction);
143
+
144
+ if (boundServiceResource) {
145
+ let serviceVariantId = undefined;
146
+ try {
147
+ if (boundServiceResource.optionsPayload?.shopifyData) {
148
+ const serviceData = JSON.parse(
149
+ boundServiceResource.optionsPayload.shopifyData
150
+ );
151
+ serviceVariantId = serviceData.variants?.[0]?.id;
152
+ }
153
+ } catch (e) {}
154
+
155
+ queueUpdates.push({
156
+ resourceId: boundServiceResource.id,
157
+ variantId: serviceVariantId,
158
+ action: 'add',
159
+ });
160
+ } else if (serviceBoundSlug) {
161
+ console.warn(
162
+ `[Shopify] Service bound to slug '${serviceBoundSlug}' was not found in provided resources.`
163
+ );
164
+ }
165
+
166
+ addQueue.set([...addQueue.get(), ...queueUpdates]);
167
+ };
168
+
169
+ return (
170
+ <div className="flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
171
+ <div className="aspect-square w-full overflow-hidden bg-gray-100">
172
+ <img
173
+ src={src}
174
+ srcSet={srcSet}
175
+ alt={resource.title}
176
+ className="h-full w-full object-cover object-center"
177
+ loading="lazy"
178
+ />
179
+ </div>
180
+
181
+ <div className="flex flex-1 flex-col p-6">
182
+ <h3 className="text-lg font-bold text-gray-900">{resource.title}</h3>
183
+ <p className="mt-2 flex-grow text-sm text-gray-500">
184
+ {resource.oneliner}
185
+ </p>
186
+
187
+ {boundServiceResource && (
188
+ <div className="mt-2 w-fit rounded bg-blue-50 p-3 text-xs font-bold text-blue-700">
189
+ Includes {boundServiceResource.title}
190
+ </div>
191
+ )}
192
+
193
+ {visibleOptions.length > 0 && (
194
+ <div className="mt-4 w-fit space-y-3 p-1">
195
+ {visibleOptions.map((opt) => (
196
+ <div key={opt.name}>
197
+ <label className="mb-1 block text-xs font-bold text-gray-700">
198
+ {opt.name}
199
+ </label>
200
+ <select
201
+ value={selections[opt.name]}
202
+ onChange={(e) => {
203
+ const newVal = e.target.value;
204
+ setSelections((prev) => ({
205
+ ...prev,
206
+ [opt.name]: newVal,
207
+ }));
208
+ }}
209
+ className="block w-full rounded-md border-gray-300 p-2 text-sm shadow-sm focus:border-black focus:ring-black"
210
+ >
211
+ {opt.values.map((val) => (
212
+ <option key={val} value={val}>
213
+ {val}
214
+ </option>
215
+ ))}
216
+ </select>
217
+ </div>
218
+ ))}
219
+ </div>
220
+ )}
221
+
222
+ <div className="mt-4 flex items-center justify-between">
223
+ <span className="text-base font-bold text-gray-900">
224
+ {price ? `${price} ${currency}` : ''}
225
+ </span>
226
+ <div className="flex items-center space-x-3">
227
+ {quantity > 0 ? (
228
+ <>
229
+ <button
230
+ onClick={() => handleAction('remove')}
231
+ className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:bg-gray-100"
232
+ aria-label="Remove one"
233
+ >
234
+ -
235
+ </button>
236
+ <span className="text-sm font-bold text-gray-900">
237
+ {quantity}
238
+ </span>
239
+ <button
240
+ onClick={() => handleAction('add')}
241
+ className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:bg-gray-100"
242
+ aria-label="Add one"
243
+ >
244
+ +
245
+ </button>
246
+ </>
247
+ ) : (
248
+ <button
249
+ onClick={() => handleAction('add')}
250
+ className="rounded-md bg-black px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
251
+ >
252
+ Add to Cart
253
+ </button>
254
+ )}
255
+ </div>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ export default function ShopifyProductGrid({ resources = {} }: Props) {
263
+ const products = resources['product'] || [];
264
+ const services = resources['service'] || [];
265
+
266
+ if (products.length === 0) {
267
+ return null;
268
+ }
269
+
270
+ return (
271
+ <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
272
+ {products.map((resource) => (
273
+ <ProductCard
274
+ key={resource.id}
275
+ resource={resource}
276
+ allServices={services}
277
+ />
278
+ ))}
279
+ </div>
280
+ );
281
+ }
@@ -0,0 +1,118 @@
1
+ import { useStore } from '@nanostores/react';
2
+ import { cartStore, addQueue, type CartAction } from '@/stores/shopify';
3
+ import type { ResourceNode } from '@/types/compositorTypes';
4
+
5
+ interface Props {
6
+ resources: Record<string, ResourceNode[]>;
7
+ }
8
+
9
+ export default function ShopifyServiceList({ resources = {} }: Props) {
10
+ const cart = useStore(cartStore);
11
+
12
+ const products = resources['product'] || [];
13
+ const services = resources['service'] || [];
14
+
15
+ const boundServiceSlugs = new Set(
16
+ products
17
+ .map((p) => p.optionsPayload?.serviceBound as string | undefined)
18
+ .filter((s): s is string => !!s)
19
+ );
20
+
21
+ const displayServices = services.filter(
22
+ (s) => !boundServiceSlugs.has(s.slug)
23
+ );
24
+
25
+ const getServiceVariantId = (resource: ResourceNode): string | undefined => {
26
+ try {
27
+ if (resource.optionsPayload?.shopifyData) {
28
+ const data = JSON.parse(resource.optionsPayload.shopifyData);
29
+ // Handle both raw product data and simplified product objects
30
+ const product = data.products?.[0] || data;
31
+ return product?.variants?.[0]?.id;
32
+ }
33
+ } catch (e) {
34
+ return undefined;
35
+ }
36
+ return undefined;
37
+ };
38
+
39
+ const handleToggle = (resource: ResourceNode, currentQuantity: number) => {
40
+ const actionType = currentQuantity > 0 ? 'remove' : 'add';
41
+
42
+ const variantId = getServiceVariantId(resource);
43
+ let gid: string | undefined;
44
+
45
+ try {
46
+ if (resource.optionsPayload?.shopifyData) {
47
+ const data = JSON.parse(resource.optionsPayload.shopifyData);
48
+ const product = data.products?.[0] || data;
49
+ gid = product?.id;
50
+ }
51
+ } catch (e) {}
52
+
53
+ const newAction: CartAction = {
54
+ resourceId: resource.id,
55
+ gid,
56
+ variantId,
57
+ action: actionType,
58
+ };
59
+ addQueue.set([...addQueue.get(), newAction]);
60
+ };
61
+
62
+ if (!displayServices || displayServices.length === 0) {
63
+ return null;
64
+ }
65
+
66
+ return (
67
+ <div className="space-y-4">
68
+ {displayServices.map((resource) => {
69
+ const variantId = getServiceVariantId(resource);
70
+ const key = variantId || `${resource.id}_null_null`;
71
+
72
+ const cartItem = cart[key];
73
+ const isSelected = (cartItem?.quantity || 0) > 0;
74
+ const duration = resource.optionsPayload?.bookingLengthMinutes;
75
+
76
+ return (
77
+ <div
78
+ key={resource.id}
79
+ className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
80
+ isSelected
81
+ ? 'border-black bg-gray-50'
82
+ : 'border-gray-200 bg-white hover:border-gray-300'
83
+ }`}
84
+ >
85
+ <div className="flex-grow">
86
+ <div className="flex items-center gap-2">
87
+ <h3 className="font-bold text-gray-900">{resource.title}</h3>
88
+ {duration && (
89
+ <span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-700">
90
+ {duration} mins
91
+ </span>
92
+ )}
93
+ </div>
94
+ <p className="mt-1 text-sm text-gray-500">{resource.oneliner}</p>
95
+ </div>
96
+
97
+ <div className="ml-4 flex-shrink-0">
98
+ <button
99
+ onClick={() => handleToggle(resource, cartItem?.quantity || 0)}
100
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
101
+ isSelected ? 'bg-black' : 'bg-gray-200'
102
+ }`}
103
+ role="switch"
104
+ aria-checked={isSelected}
105
+ >
106
+ <span
107
+ className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
108
+ isSelected ? 'translate-x-5' : 'translate-x-0'
109
+ }`}
110
+ />
111
+ </button>
112
+ </div>
113
+ </div>
114
+ );
115
+ })}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,23 @@
1
+ ---
2
+ import Layout from '@/layouts/Layout.astro';
3
+ import Cart from '@/custom/shopify/Cart';
4
+ import { getHeaderResources } from '@/lib/resources';
5
+ import { getBrandConfig } from '@/utils/api/brandConfig';
6
+
7
+ const tenantId =
8
+ Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
9
+
10
+ const brandConfig = await getBrandConfig(tenantId);
11
+ if (!brandConfig.HAS_SHOPIFY) {
12
+ return Astro.redirect('/storykeep');
13
+ }
14
+
15
+ const resourceCategories = ['product', 'service'];
16
+ const resources = await getHeaderResources(tenantId, resourceCategories);
17
+ ---
18
+
19
+ <Layout title="Your Cart" slug="cart">
20
+ <main class="mx-auto max-w-7xl px-4 py-16 md:px-6 xl:px-8">
21
+ <Cart resources={resources} embedded={true} client:only="react" />
22
+ </main>
23
+ </Layout>
@@ -8,6 +8,8 @@ import EpinetWrapper from '@/components/codehooks/EpinetWrapper';
8
8
  import ProductCardWrapper from './ProductCardWrapper.astro';
9
9
  import ProductGrid from './ProductGrid.astro';
10
10
  import SandboxLauncher from './SandboxLauncher';
11
+ import ShopifyProductGrid from '@/custom/shopify/ShopifyProductGrid';
12
+ import ShopifyServiceList from '@/custom/shopify/ShopifyServiceList';
11
13
  import type { FullContentMapItem } from '@/types/tractstack';
12
14
  import type { ResourceNode } from '@/types/compositorTypes';
13
15
 
@@ -23,7 +25,7 @@ export interface Props {
23
25
  };
24
26
  }
25
27
 
26
- const { target, options, fullContentMap, resourcesPayload } = Astro.props;
28
+ const { target, options, fullContentMap, resourcesPayload = {} } = Astro.props;
27
29
 
28
30
  export const components = {
29
31
  'custom-hero': true,
@@ -35,6 +37,8 @@ export const components = {
35
37
  'get-crafting': true,
36
38
  'bunny-video': import.meta.env.PUBLIC_ENABLE_BUNNY === 'true',
37
39
  epinet: true,
40
+ 'shopify-product-grid': true,
41
+ 'shopify-service-list': true,
38
42
  };
39
43
  ---
40
44
 
@@ -57,6 +61,10 @@ export const components = {
57
61
  <CustomHero />
58
62
  ) : target === 'epinet' ? (
59
63
  <EpinetWrapper fullContentMap={fullContentMap} client:only="react" />
64
+ ) : target === 'shopify-product-grid' ? (
65
+ <ShopifyProductGrid resources={resourcesPayload} client:only="react" />
66
+ ) : target === 'shopify-service-list' ? (
67
+ <ShopifyServiceList resources={resourcesPayload} client:only="react" />
60
68
  ) : (
61
69
  <div class="rounded-lg bg-gray-50 p-8 text-center">
62
70
  <p class="text-gray-600">CodeHook target "{target}" not found</p>
@@ -53,7 +53,7 @@ if (parsedOptions?.productType) {
53
53
  </div>
54
54
  ) : (
55
55
  <div class="rounded-lg border bg-yellow-50 p-6 text-center shadow-sm">
56
- <p class="font-medium text-yellow-800">No products to display.</p>
56
+ <p class="font-bold text-yellow-800">No products to display.</p>
57
57
  <p class="mt-1 text-sm text-yellow-700">
58
58
  Check the grid configuration or ensure products match the specified
59
59
  filters.
@@ -144,8 +144,10 @@ if (!window.TractStackApp) {
144
144
  }
145
145
 
146
146
  const { backendUrl, sessionId, storyfragmentId, tenantId } = this.config;
147
- if (!sessionId || !tenantId) {
148
- logError('Cannot start SSE connection: missing sessionId or tenantId.');
147
+ if (!sessionId || !tenantId || !storyfragmentId) {
148
+ logError(
149
+ 'Cannot start SSE connection: missing sessionId or tenantId or storyfragmentId.'
150
+ );
149
151
  return;
150
152
  }
151
153
 
@@ -4,8 +4,14 @@ import SearchWrapper from '@/components/search/SearchWrapper';
4
4
  import { getFullContentMap } from '@/stores/analytics';
5
5
  import { isAuthenticated, isAdmin, getUserRole } from '@/utils/auth';
6
6
  import ImpressionWrapper from '@/components/widgets/ImpressionWrapper';
7
- import type { MenuNode } from '@/types/tractstack';
8
7
  import type { ImpressionNode } from '@/types/compositorTypes';
8
+ import { getHeaderResources } from '@/lib/resources';
9
+ import ShopifyCartManager from '@/custom/shopify/ShopifyCartManager';
10
+ import CartIcon from '@/custom/shopify/CartIcon';
11
+ import CartModal from '@/custom/shopify/CartModal';
12
+ import CheckoutModal from '@/custom/shopify/CheckoutModal';
13
+ import type { MenuNode } from '@/types/tractstack';
14
+ import type { ResourceNode } from '@/types/compositorTypes';
9
15
 
10
16
  export interface Props {
11
17
  title: string;
@@ -32,13 +38,11 @@ const {
32
38
  storyfragmentId = undefined,
33
39
  impressions = [],
34
40
  } = Astro.props;
35
-
36
41
  const isHome = slug === brandConfig?.HOME_SLUG;
37
42
 
38
43
  const tenantId =
39
44
  Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
40
45
  const fullContentMap = await getFullContentMap(tenantId);
41
-
42
46
  const getAssetPath = (configPath: string, fallback: string) => {
43
47
  // Always prioritize brandConfig values when they exist
44
48
  if (configPath && configPath !== '') {
@@ -52,17 +56,35 @@ const wordmarkMode =
52
56
  brandConfig?.WORDMARK_MODE && brandConfig.WORDMARK_MODE !== ''
53
57
  ? brandConfig.WORDMARK_MODE
54
58
  : 'default';
55
-
56
59
  // Auth status
57
60
  const authStatus = {
58
61
  isAuthenticated: isAuthenticated(Astro),
59
62
  isAdmin: isAdmin(Astro),
60
63
  userRole: getUserRole(Astro),
61
64
  };
65
+
66
+ const hasShopify = brandConfig?.HAS_SHOPIFY;
67
+ let shopifyResources: ResourceNode[] = [];
68
+ if (hasShopify) {
69
+ shopifyResources = await getHeaderResources(tenantId, ['product', 'service']);
70
+ }
62
71
  ---
63
72
 
64
73
  <header class="relative shadow-inner">
65
- <!-- TOP ROW: Logo/Wordmark + Menu + Auth Controls -->
74
+ {
75
+ hasShopify ? (
76
+ <>
77
+ {slug !== `cart` ? (
78
+ <div class="flex w-full justify-end px-4 py-2 md:px-8">
79
+ <CartIcon client:only="react" />
80
+ </div>
81
+ ) : (
82
+ <CheckoutModal client:only="react" resources={shopifyResources} />
83
+ )}
84
+ </>
85
+ ) : null
86
+ }
87
+
66
88
  <div
67
89
  class="flex flex-row flex-nowrap items-center justify-between px-4 py-3 md:px-8"
68
90
  >
@@ -76,6 +98,7 @@ const authStatus = {
76
98
  alt="Logo"
77
99
  class="pointer-events-none h-8 w-auto"
78
100
  />
101
+
79
102
  <span class="w-2" />
80
103
  </>
81
104
  ) : null
@@ -105,7 +128,6 @@ const authStatus = {
105
128
  }
106
129
  </div>
107
130
 
108
- <!-- BOTTOM ROW: Title + Action Icons -->
109
131
  <div
110
132
  class="flex flex-row flex-nowrap justify-between bg-mywhite px-4 pb-3 pt-4 shadow-inner md:px-8"
111
133
  >
@@ -198,7 +220,6 @@ const authStatus = {
198
220
  localStorage.getItem('tractstack_has_profile') === '1';
199
221
 
200
222
  if (!sessionId) return;
201
-
202
223
  const rememberMeContainer = document.getElementById(
203
224
  'remember-me-container'
204
225
  );
@@ -211,7 +232,6 @@ const authStatus = {
211
232
  consent || hasProfile
212
233
  ? '<svg class="h-6 w-6 text-myblue/80" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" /></svg>'
213
234
  : '<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 3l18 18M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" /></svg>';
214
-
215
235
  rememberMeContainer.innerHTML = `
216
236
  <a href="/storykeep/profile"
217
237
  class="hover:text-myblue hover:rotate-6"
@@ -339,6 +359,15 @@ const authStatus = {
339
359
  )
340
360
  }
341
361
 
362
+ {
363
+ !isStoryKeep && hasShopify && (
364
+ <>
365
+ <ShopifyCartManager resources={shopifyResources} client:only="react" />
366
+ <CartModal client:only="react" />
367
+ </>
368
+ )
369
+ }
370
+
342
371
  <script>
343
372
  if (document.readyState === 'loading') {
344
373
  document.addEventListener('DOMContentLoaded', setupAdminModal);
@@ -383,7 +412,6 @@ const authStatus = {
383
412
  tractStackKeys.forEach((key) => localStorage.removeItem(key));
384
413
 
385
414
  console.log('TractStack: Complete logout finished');
386
-
387
415
  // Redirect to home page
388
416
  window.location.href = '/';
389
417
  } else {
@@ -432,7 +460,6 @@ const authStatus = {
432
460
  return;
433
461
  }
434
462
  });
435
-
436
463
  document.addEventListener('keydown', function (e) {
437
464
  if (e.key === 'Escape') {
438
465
  const modal = document.getElementById('admin-modal');
@@ -441,7 +468,6 @@ const authStatus = {
441
468
  }
442
469
  }
443
470
  });
444
-
445
471
  function closeModal() {
446
472
  const modal = document.getElementById('admin-modal');
447
473
  const heartBtn = document.getElementById('admin-heart-btn');