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.
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +89 -8
- package/package.json +3 -1
- package/templates/custom/minimal/CodeHook.astro +14 -5
- package/templates/custom/shopify/CalDotComBooking.tsx +44 -0
- package/templates/custom/shopify/Cart.tsx +345 -0
- package/templates/custom/shopify/CartIcon.tsx +47 -0
- package/templates/custom/shopify/CartModal.tsx +63 -0
- package/templates/custom/shopify/CheckoutModal.tsx +187 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +145 -0
- package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
- package/templates/custom/shopify/ShopifyProductGrid.tsx +281 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +118 -0
- package/templates/custom/shopify/cart.astro +23 -0
- package/templates/custom/with-examples/CodeHook.astro +9 -1
- package/templates/custom/with-examples/ProductGrid.astro +1 -1
- package/templates/src/client/app.js +4 -2
- package/templates/src/components/Header.astro +37 -11
- package/templates/src/components/form/advanced/APIConfigSection.tsx +165 -38
- package/templates/src/components/storykeep/Dashboard.tsx +17 -3
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
- package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +525 -0
- package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
- package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
- package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +254 -0
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
- package/templates/src/lib/resources.ts +11 -21
- package/templates/src/pages/api/shopify/createCart.ts +73 -0
- package/templates/src/pages/api/shopify/getProducts.ts +64 -0
- package/templates/src/pages/storykeep/login.astro +5 -10
- package/templates/src/pages/storykeep/logout.astro +1 -10
- package/templates/src/pages/storykeep/manage.astro +69 -0
- package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
- package/templates/src/pages/storykeep/shopify.astro +101 -0
- package/templates/src/stores/navigation.ts +3 -42
- package/templates/src/stores/nodes.ts +3 -1
- package/templates/src/stores/resources.ts +7 -10
- package/templates/src/stores/shopify.ts +210 -0
- package/templates/src/types/tractstack.ts +21 -0
- package/templates/src/utils/api/advancedConfig.ts +5 -1
- package/templates/src/utils/api/advancedHelpers.ts +48 -5
- package/templates/src/utils/api/brandHelpers.ts +4 -0
- package/templates/src/utils/api/resourceConfig.ts +13 -5
- package/templates/src/utils/customHelpers.ts +70 -0
- package/templates/src/utils/helpers.ts +59 -0
- package/utils/inject-files.ts +83 -2
|
@@ -302,22 +302,45 @@ const KnownResourceFormRenderer = ({
|
|
|
302
302
|
}
|
|
303
303
|
disabled={locked}
|
|
304
304
|
/>
|
|
305
|
-
{fieldDef.type === '
|
|
306
|
-
<
|
|
307
|
-
label="Reference Category"
|
|
308
|
-
value={fieldDef.belongsToCategory
|
|
309
|
-
onChange={(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
<
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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] =
|
|
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
|
-
|
|
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(
|
|
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={
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
40
|
+
`Failed to fetch resources for [${cacheKey}]. Status: ${response.status}`
|
|
48
41
|
);
|
|
49
|
-
|
|
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
|
|
66
|
-
|
|
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
|
+
};
|