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
@@ -302,22 +302,45 @@ const KnownResourceFormRenderer = ({
302
302
  }
303
303
  disabled={locked}
304
304
  />
305
- {fieldDef.type === 'categoryReference' && (
306
- <EnumSelect
307
- label="Reference Category"
308
- value={fieldDef.belongsToCategory || ''}
309
- onChange={(value) =>
310
- updateField(fieldName, {
311
- belongsToCategory: value,
312
- })
313
- }
314
- options={availableCategories.map((cat) => ({
315
- value: cat,
316
- label: cat,
317
- }))}
305
+ {fieldDef.type === 'string' && (
306
+ <BooleanToggle
307
+ label="Reference Category?"
308
+ value={!!fieldDef.belongsToCategory}
309
+ onChange={(checked) => {
310
+ if (checked) {
311
+ // Enable: set to first available category or empty
312
+ updateField(fieldName, {
313
+ belongsToCategory:
314
+ availableCategories[0] || '',
315
+ });
316
+ } else {
317
+ // Disable: remove the property
318
+ updateField(fieldName, {
319
+ belongsToCategory: undefined,
320
+ });
321
+ }
322
+ }}
318
323
  disabled={locked}
319
324
  />
320
325
  )}
326
+
327
+ {fieldDef.type === 'string' &&
328
+ fieldDef.belongsToCategory !== undefined && (
329
+ <EnumSelect
330
+ label="Target Category"
331
+ value={fieldDef.belongsToCategory}
332
+ onChange={(value) =>
333
+ updateField(fieldName, {
334
+ belongsToCategory: value,
335
+ })
336
+ }
337
+ options={availableCategories.map((cat) => ({
338
+ value: cat,
339
+ label: cat,
340
+ }))}
341
+ disabled={locked}
342
+ />
343
+ )}
321
344
  {fieldDef.type === 'number' && (
322
345
  <>
323
346
  <NumberInput
@@ -332,17 +332,10 @@ const ManageContent = ({
332
332
 
333
333
  case 'resources':
334
334
  return (
335
- <div className="space-y-6">
336
- <div>
337
- <h3 className="mb-4 text-lg font-bold text-gray-900">
338
- Resource Categories
339
- </h3>
340
- <KnownResourceTable
341
- contentMap={currentContentMap}
342
- onEdit={handleEditKnownResource}
343
- />
344
- </div>
345
- </div>
335
+ <KnownResourceTable
336
+ contentMap={currentContentMap}
337
+ onEdit={handleEditKnownResource}
338
+ />
346
339
  );
347
340
 
348
341
  case 'beliefs':
@@ -0,0 +1,254 @@
1
+ import { useState } from 'react';
2
+ import { Pagination } from '@ark-ui/react/pagination';
3
+ import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon';
4
+ import ChevronLeftIcon from '@heroicons/react/24/outline/ChevronLeftIcon';
5
+ import ArrowPathIcon from '@heroicons/react/24/outline/ArrowPathIcon';
6
+ import MagnifyingGlassIcon from '@heroicons/react/24/outline/MagnifyingGlassIcon';
7
+ import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
8
+ import TrashIcon from '@heroicons/react/24/outline/TrashIcon';
9
+ import PencilIcon from '@heroicons/react/24/outline/PencilIcon';
10
+ import type { ShopifyProduct } from '@/stores/shopify';
11
+ import type { ResourceNode } from '@/types/compositorTypes';
12
+
13
+ interface ProductTableProps {
14
+ products: ShopifyProduct[];
15
+ linkedResourceMap: Map<string, ResourceNode>;
16
+ onRefresh: () => void;
17
+ isRefreshing: boolean;
18
+ onSelectProduct: (product: ShopifyProduct) => void;
19
+ onLink: (product: ShopifyProduct) => void;
20
+ onUnlink: (resourceId: string) => void;
21
+ onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
22
+ }
23
+
24
+ const ITEMS_PER_PAGE = 10;
25
+
26
+ export default function ProductTable({
27
+ products,
28
+ linkedResourceMap,
29
+ onRefresh,
30
+ isRefreshing,
31
+ onSelectProduct,
32
+ onLink,
33
+ onUnlink,
34
+ onEdit,
35
+ }: ProductTableProps) {
36
+ const [searchTerm, setSearchTerm] = useState('');
37
+ const [currentPage, setCurrentPage] = useState(1);
38
+
39
+ const filteredProducts = products.filter(
40
+ (product) =>
41
+ product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
42
+ product.handle.toLowerCase().includes(searchTerm.toLowerCase())
43
+ );
44
+
45
+ const totalResults = filteredProducts.length;
46
+ const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE);
47
+ const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
48
+ const paginatedProducts = filteredProducts.slice(
49
+ startIndex,
50
+ startIndex + ITEMS_PER_PAGE
51
+ );
52
+
53
+ const handlePageChange = (page: number) => {
54
+ setCurrentPage(page);
55
+ };
56
+
57
+ return (
58
+ <div className="space-y-4">
59
+ <div className="flex items-center gap-4">
60
+ <div className="flex-1">
61
+ <input
62
+ type="text"
63
+ placeholder="Search products..."
64
+ value={searchTerm}
65
+ onChange={(e) => {
66
+ setSearchTerm(e.target.value);
67
+ setCurrentPage(1);
68
+ }}
69
+ className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-cyan-700 focus:ring-cyan-700"
70
+ />
71
+ </div>
72
+ <button
73
+ onClick={onRefresh}
74
+ disabled={isRefreshing}
75
+ className="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-bold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50"
76
+ title="Refresh from Shopify"
77
+ >
78
+ <ArrowPathIcon
79
+ className={`mr-1.5 h-5 w-5 ${isRefreshing ? 'animate-spin' : ''}`}
80
+ />
81
+ Sync
82
+ </button>
83
+ </div>
84
+
85
+ <div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
86
+ <table className="min-w-full divide-y divide-gray-300">
87
+ <thead className="bg-gray-50">
88
+ <tr>
89
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wide text-gray-500">
90
+ Product
91
+ </th>
92
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wide text-gray-500">
93
+ Handle
94
+ </th>
95
+ <th className="relative px-6 py-3">
96
+ <span className="sr-only">Actions</span>
97
+ </th>
98
+ </tr>
99
+ </thead>
100
+ <tbody className="divide-y divide-gray-200 bg-white">
101
+ {paginatedProducts.length === 0 ? (
102
+ <tr>
103
+ <td colSpan={3} className="px-6 py-12 text-center">
104
+ <div className="text-gray-500">
105
+ {products.length === 0
106
+ ? 'No products found in store'
107
+ : 'No products match your search'}
108
+ </div>
109
+ </td>
110
+ </tr>
111
+ ) : (
112
+ paginatedProducts.map((product) => {
113
+ const linkedResource = linkedResourceMap.get(product.id);
114
+ const isLinked = !!linkedResource;
115
+
116
+ return (
117
+ <tr key={product.id} className="hover:bg-gray-50">
118
+ <td className="px-6 py-4">
119
+ <div className="font-bold text-gray-900">
120
+ {product.title}
121
+ </div>
122
+ {isLinked && (
123
+ <span
124
+ className={`mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ring-1 ring-inset ${
125
+ linkedResource.categorySlug === 'service'
126
+ ? 'bg-indigo-50 text-indigo-700 ring-indigo-600/20'
127
+ : 'bg-cyan-50 text-cyan-700 ring-cyan-600/20'
128
+ }`}
129
+ >
130
+ Synced: {linkedResource.title}
131
+ </span>
132
+ )}
133
+ </td>
134
+ <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
135
+ {product.handle}
136
+ </td>
137
+ <td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold">
138
+ <div className="flex items-center justify-end space-x-2">
139
+ {isLinked ? (
140
+ <>
141
+ <button
142
+ onClick={() => onEdit(product, linkedResource)}
143
+ className="text-cyan-600 hover:text-cyan-900"
144
+ title="Edit Resource"
145
+ >
146
+ <PencilIcon
147
+ className="h-5 w-5"
148
+ aria-hidden="true"
149
+ />
150
+ <span className="sr-only">
151
+ Edit {product.title}
152
+ </span>
153
+ </button>
154
+ <button
155
+ onClick={() => onUnlink(linkedResource.id)}
156
+ className="text-red-600 hover:text-red-900"
157
+ title="Unlink Resource"
158
+ >
159
+ <TrashIcon
160
+ className="h-5 w-5"
161
+ aria-hidden="true"
162
+ />
163
+ <span className="sr-only">
164
+ Unlink {product.title}
165
+ </span>
166
+ </button>
167
+ </>
168
+ ) : (
169
+ <button
170
+ onClick={() => onLink(product)}
171
+ className="text-cyan-600 hover:text-cyan-900"
172
+ title="Create Resource"
173
+ >
174
+ <PlusIcon className="h-5 w-5" aria-hidden="true" />
175
+ <span className="sr-only">
176
+ Link {product.title}
177
+ </span>
178
+ </button>
179
+ )}
180
+ <button
181
+ onClick={() => onSelectProduct(product)}
182
+ className="text-gray-400 hover:text-gray-600"
183
+ >
184
+ <MagnifyingGlassIcon
185
+ className="h-5 w-5"
186
+ aria-hidden="true"
187
+ />
188
+ <span className="sr-only">
189
+ Inspect {product.title}
190
+ </span>
191
+ </button>
192
+ </div>
193
+ </td>
194
+ </tr>
195
+ );
196
+ })
197
+ )}
198
+ </tbody>
199
+ </table>
200
+ </div>
201
+
202
+ {totalPages > 1 && (
203
+ <div className="flex justify-center pt-4">
204
+ <Pagination.Root
205
+ count={totalResults}
206
+ pageSize={ITEMS_PER_PAGE}
207
+ page={currentPage}
208
+ onPageChange={(details) => handlePageChange(details.page)}
209
+ >
210
+ <Pagination.PrevTrigger className="mr-2 flex items-center gap-1 rounded px-3 py-2 text-sm font-bold text-mydarkgrey transition-colors hover:text-myblue disabled:opacity-50">
211
+ <ChevronLeftIcon className="h-4 w-4" />
212
+ Previous
213
+ </Pagination.PrevTrigger>
214
+
215
+ <div className="flex items-center gap-1">
216
+ <Pagination.Context>
217
+ {(pagination) =>
218
+ pagination.pages.map((page, index) =>
219
+ page.type === 'page' ? (
220
+ <Pagination.Item
221
+ key={index}
222
+ type="page"
223
+ value={page.value}
224
+ className={`rounded px-3 py-2 text-sm font-bold transition-colors ${
225
+ page.value === currentPage
226
+ ? 'bg-myblue text-white'
227
+ : 'text-mydarkgrey hover:text-myblue'
228
+ }`}
229
+ >
230
+ {page.value}
231
+ </Pagination.Item>
232
+ ) : (
233
+ <span
234
+ key={index}
235
+ className="px-2 text-sm text-mydarkgrey"
236
+ >
237
+ {page.type === 'ellipsis' ? '...' : ''}
238
+ </span>
239
+ )
240
+ )
241
+ }
242
+ </Pagination.Context>
243
+ </div>
244
+
245
+ <Pagination.NextTrigger className="ml-2 flex items-center gap-1 rounded px-3 py-2 text-sm font-bold text-mydarkgrey transition-colors hover:text-myblue disabled:opacity-50">
246
+ Next
247
+ <ChevronRightIcon className="h-4 w-4" />
248
+ </Pagination.NextTrigger>
249
+ </Pagination.Root>
250
+ </div>
251
+ )}
252
+ </div>
253
+ );
254
+ }
@@ -9,6 +9,10 @@ import BooleanToggle from '@/components/form/BooleanToggle';
9
9
  import DateTimeInput from '@/components/form/DateTimeInput';
10
10
  import FileUpload from '@/components/form/FileUpload';
11
11
  import EnumSelect from '@/components/form/EnumSelect';
12
+ import {
13
+ resourceFormHideFields,
14
+ resourceJsonifyFields,
15
+ } from '@/utils/customHelpers';
12
16
  import type {
13
17
  ResourceConfig,
14
18
  ResourceState,
@@ -46,15 +50,16 @@ export default function ResourceForm({
46
50
  actionLisp: '',
47
51
  };
48
52
 
49
- // Initialize optionsPayload with default values for all schema fields
53
+ // 1. Initialize optionsPayload with default values for all schema fields
54
+ // (Only runs if NO existing data is provided)
50
55
  if (!resourceData) {
51
- // Only for new resources
52
56
  const defaultOptionsPayload: Record<string, any> = {};
53
57
 
54
58
  Object.entries(categorySchema).forEach(([fieldName, fieldDef]) => {
55
59
  switch (fieldDef.type) {
56
60
  case 'number':
57
- defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? 0;
61
+ defaultOptionsPayload[fieldName] =
62
+ fieldDef.defaultValue ?? fieldDef.minNumber ?? 0;
58
63
  break;
59
64
  case 'boolean':
60
65
  defaultOptionsPayload[fieldName] = fieldDef.defaultValue ?? false;
@@ -79,6 +84,27 @@ export default function ResourceForm({
79
84
  initialData.optionsPayload = defaultOptionsPayload;
80
85
  }
81
86
 
87
+ // 2. Pre-process JSON fields for display (Pretty Print)
88
+ // This runs for both new and existing records to ensure readability
89
+ if (initialData.optionsPayload) {
90
+ resourceJsonifyFields.forEach((field) => {
91
+ const val = initialData.optionsPayload[field];
92
+ if (val && typeof val === 'string') {
93
+ try {
94
+ // Parse and re-stringify with indentation
95
+ initialData.optionsPayload[field] = JSON.stringify(
96
+ JSON.parse(val),
97
+ null,
98
+ 2
99
+ );
100
+ } catch (e) {
101
+ // If it's not valid JSON, leave it as is
102
+ console.warn(`Failed to pretty-print field ${field}`, e);
103
+ }
104
+ }
105
+ });
106
+ }
107
+
82
108
  const validator = (state: ResourceState): FieldErrors => {
83
109
  const errors: FieldErrors = {};
84
110
 
@@ -114,9 +140,34 @@ export default function ResourceForm({
114
140
  validator,
115
141
  onSave: async (data) => {
116
142
  try {
143
+ // 3. Post-process JSON fields for saving (Minify)
144
+ const dataToSave = { ...data };
145
+ if (dataToSave.optionsPayload) {
146
+ // Create a shallow copy of optionsPayload to avoid mutating form state directly
147
+ dataToSave.optionsPayload = { ...dataToSave.optionsPayload };
148
+
149
+ resourceJsonifyFields.forEach((field) => {
150
+ const val = dataToSave.optionsPayload[field];
151
+ if (val && typeof val === 'string') {
152
+ try {
153
+ // Minify back to a compact string
154
+ dataToSave.optionsPayload[field] = JSON.stringify(
155
+ JSON.parse(val)
156
+ );
157
+ } catch (e) {
158
+ console.error(`Failed to minify field ${field}`, e);
159
+ // Throwing here would stop the save if the user typed invalid JSON
160
+ throw new Error(
161
+ `Invalid JSON in field "${field}". Please check your syntax.`
162
+ );
163
+ }
164
+ }
165
+ });
166
+ }
167
+
117
168
  const updatedState = await saveResourceWithStateUpdate(
118
169
  window.TRACTSTACK_CONFIG?.tenantId || 'default',
119
- data
170
+ dataToSave
120
171
  );
121
172
 
122
173
  // Call success callback after save (original pattern)
@@ -137,14 +188,19 @@ export default function ResourceForm({
137
188
  // Helper to get category reference options for a field
138
189
  const getCategoryReferenceOptions = (belongsToCategory: string) => {
139
190
  return fullContentMap
140
- .filter((item) => item.categorySlug === belongsToCategory)
191
+ .filter(
192
+ (item) =>
193
+ item.categorySlug === belongsToCategory &&
194
+ !(
195
+ belongsToCategory === 'service' && (item as any).optionsPayload?.gid
196
+ )
197
+ )
141
198
  .map((item) => ({
142
199
  value: item.slug,
143
200
  label: item.title,
144
201
  }));
145
202
  };
146
203
 
147
- // Helper to update optionsPayload field
148
204
  const updateOptionsField = (fieldName: string, value: any) => {
149
205
  updateField('optionsPayload', {
150
206
  ...state.optionsPayload,
@@ -156,11 +212,51 @@ export default function ResourceForm({
156
212
  onClose?.(false);
157
213
  };
158
214
 
159
- // Render dynamic field based on field definition
160
215
  const renderDynamicField = (fieldName: string, fieldDef: FieldDefinition) => {
216
+ if (
217
+ resourceFormHideFields.includes(fieldName)
218
+ // && initialData.optionsPayload?.[fieldName]
219
+ ) {
220
+ return null;
221
+ }
222
+
161
223
  const fieldValue = state.optionsPayload[fieldName];
162
224
  const fieldError = errors?.[`optionsPayload.${fieldName}`];
163
225
 
226
+ if (resourceJsonifyFields.includes(fieldName)) {
227
+ return (
228
+ <div key={fieldName} className="space-y-1">
229
+ <label
230
+ htmlFor={`field-${fieldName}`}
231
+ className="block text-sm font-bold text-gray-700"
232
+ >
233
+ {fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}
234
+ </label>
235
+ <div className="relative">
236
+ <textarea
237
+ id={`field-${fieldName}`}
238
+ rows={12}
239
+ className={`block w-full rounded-md font-mono text-xs shadow-sm ${
240
+ fieldError
241
+ ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
242
+ : 'border-gray-300 focus:border-cyan-500 focus:ring-cyan-500'
243
+ }`}
244
+ value={fieldValue || ''}
245
+ onChange={(e) => updateOptionsField(fieldName, e.target.value)}
246
+ placeholder="{}"
247
+ />
248
+ </div>
249
+ {fieldError ? (
250
+ <p className="mt-1 text-sm text-red-600">{fieldError}</p>
251
+ ) : (
252
+ <p className="mt-1 text-xs text-gray-500">
253
+ Raw JSON configuration. Edits are validated on save.
254
+ </p>
255
+ )}
256
+ </div>
257
+ );
258
+ }
259
+
164
260
  switch (fieldDef.type) {
165
261
  case 'string':
166
262
  // Check if this string field references another category
@@ -349,7 +445,11 @@ export default function ResourceForm({
349
445
 
350
446
  {/* Save/Cancel Bar */}
351
447
  <UnsavedChangesBar
352
- formState={formState}
448
+ formState={{
449
+ ...formState,
450
+ isDirty: isCreate || formState.isDirty,
451
+ cancel: handleCancel,
452
+ }}
353
453
  message="You have unsaved resource changes"
354
454
  saveLabel="Save Resource"
355
455
  cancelLabel="Discard Changes"
@@ -1,15 +1,6 @@
1
1
  import { headerResourcesStore, HEADER_RESOURCES_TTL } from '@/stores/resources';
2
2
  import type { ResourceNode } from '@/types/compositorTypes';
3
3
 
4
- /**
5
- * Fetches resource nodes based on categories, with server-side in-memory caching
6
- * to prevent redundant API calls for high-traffic, site-wide components.
7
- *
8
- * @param tenantId The ID of the current tenant.
9
- * @param categories An array of resource category slugs to fetch.
10
- * @param ttl Optional. The Time-To-Live for the cache in milliseconds. Defaults to 5 minutes.
11
- * @returns A promise that resolves to an array of ResourceNode objects.
12
- */
13
4
  export async function getHeaderResources(
14
5
  tenantId: string,
15
6
  categories: string[],
@@ -17,13 +8,16 @@ export async function getHeaderResources(
17
8
  ): Promise<ResourceNode[]> {
18
9
  const cache = headerResourcesStore.get();
19
10
  const now = Date.now();
11
+ const cacheKey = [...categories].sort().join(',');
20
12
 
21
- // If we have fresh data in the cache, return it immediately.
22
- if (cache.data.length > 0 && now - cache.lastFetched < ttl) {
13
+ if (
14
+ cache.key === cacheKey &&
15
+ cache.data.length > 0 &&
16
+ now - cache.lastFetched < ttl
17
+ ) {
23
18
  return cache.data;
24
19
  }
25
20
 
26
- // If no categories are requested, there's nothing to fetch.
27
21
  if (!categories || categories.length === 0) {
28
22
  return [];
29
23
  }
@@ -32,7 +26,6 @@ export async function getHeaderResources(
32
26
  import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
33
27
 
34
28
  try {
35
- // THIS IS THE CORRECTED ENDPOINT
36
29
  const response = await fetch(`${goBackend}/api/v1/nodes/resources`, {
37
30
  method: 'POST',
38
31
  headers: {
@@ -44,26 +37,23 @@ export async function getHeaderResources(
44
37
 
45
38
  if (!response.ok) {
46
39
  console.error(
47
- `Failed to fetch header resources. Status: ${response.status}`
40
+ `Failed to fetch resources for [${cacheKey}]. Status: ${response.status}`
48
41
  );
49
- // Gracefully degrade: return old data if we have it, otherwise an empty array.
50
- return cache.data;
42
+ return cache.key === cacheKey ? cache.data : [];
51
43
  }
52
44
 
53
- // The backend returns a payload like { resources: [...] }
54
45
  const responsePayload = await response.json();
55
46
  const resources: ResourceNode[] = responsePayload.resources || [];
56
47
 
57
- // Update the store with the new data and timestamp.
58
48
  headerResourcesStore.set({
59
49
  data: resources,
60
50
  lastFetched: now,
51
+ key: cacheKey,
61
52
  });
62
53
 
63
54
  return resources;
64
55
  } catch (error) {
65
- console.error('Error fetching header resources:', error);
66
- // On network error, also return stale data if available.
67
- return cache.data;
56
+ console.error('Error fetching resources:', error);
57
+ return cache.key === cacheKey ? cache.data : [];
68
58
  }
69
59
  }
@@ -0,0 +1,73 @@
1
+ import type { APIRoute } from '@/types/astro';
2
+ import { resolveTenantId } from '@/utils/tenantResolver';
3
+
4
+ export const prerender = false;
5
+
6
+ interface CreateCartPayload {
7
+ lines: Array<{
8
+ merchandiseId: string;
9
+ quantity: number;
10
+ }>;
11
+ attributes?: Array<{
12
+ key: string;
13
+ value: string;
14
+ }>;
15
+ email?: string;
16
+ }
17
+
18
+ const getBackendUrl = () => {
19
+ return import.meta.env.PUBLIC_API_URL || 'http://localhost:8080';
20
+ };
21
+
22
+ export const POST: APIRoute = async ({ request }) => {
23
+ const resolution = await resolveTenantId(request);
24
+ const tenantId = resolution.id;
25
+
26
+ const backendEndpoint = `${getBackendUrl()}/api/v1/shopify/checkout`;
27
+ const cookieHeader = request.headers.get('cookie') || '';
28
+
29
+ try {
30
+ const body = (await request.json()) as CreateCartPayload;
31
+
32
+ const payload: CreateCartPayload = {
33
+ lines: body.lines,
34
+ attributes: body.attributes || [],
35
+ email: body.email,
36
+ };
37
+
38
+ const backendResponse = await fetch(backendEndpoint, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ 'X-Tenant-ID': tenantId,
43
+ Cookie: cookieHeader,
44
+ },
45
+ body: JSON.stringify(payload),
46
+ });
47
+
48
+ if (!backendResponse.ok) {
49
+ const errorText = await backendResponse.text();
50
+ throw new Error(
51
+ `Backend Proxy Error: ${backendResponse.status} - ${errorText}`
52
+ );
53
+ }
54
+
55
+ const result = await backendResponse.json();
56
+
57
+ return new Response(JSON.stringify(result), {
58
+ status: 200,
59
+ headers: { 'Content-Type': 'application/json' },
60
+ });
61
+ } catch (error) {
62
+ console.error('Shopify proxy create cart failed:', error);
63
+ return new Response(
64
+ JSON.stringify({
65
+ error: error instanceof Error ? error.message : 'Failed to create cart',
66
+ }),
67
+ {
68
+ status: 500,
69
+ headers: { 'Content-Type': 'application/json' },
70
+ }
71
+ );
72
+ }
73
+ };