astro-tractstack 2.2.10 → 2.3.1
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/README.md +1 -1
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +177 -18
- package/package.json +4 -2
- package/templates/custom/minimal/CodeHook.astro +22 -5
- package/templates/custom/shopify/Cart.tsx +372 -0
- package/templates/custom/shopify/CartIcon.tsx +47 -0
- package/templates/custom/shopify/CartModal.tsx +63 -0
- package/templates/custom/shopify/CheckoutModal.tsx +576 -0
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +200 -0
- package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
- package/templates/custom/shopify/ShopifyProductGrid.tsx +247 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +135 -0
- package/templates/custom/shopify/cart.astro +23 -0
- package/templates/custom/with-examples/CodeHook.astro +17 -1
- package/templates/custom/with-examples/ProductGrid.astro +1 -1
- package/templates/src/client/app.js +4 -2
- package/templates/src/components/Footer.astro +4 -4
- package/templates/src/components/Header.astro +44 -12
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
- package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
- package/templates/src/components/form/advanced/APIConfigSection.tsx +407 -38
- package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
- package/templates/src/components/storykeep/Dashboard.tsx +18 -4
- 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 +668 -0
- package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
- package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
- 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/KnownResourceTable.tsx +5 -2
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
- package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +333 -0
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +9 -5
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
- package/templates/src/lib/resources.ts +11 -21
- package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
- package/templates/src/pages/api/booking/availability.ts +72 -0
- package/templates/src/pages/api/booking/cancel.ts +73 -0
- package/templates/src/pages/api/booking/confirm.ts +82 -0
- package/templates/src/pages/api/booking/hold.ts +75 -0
- package/templates/src/pages/api/booking/list.ts +66 -0
- package/templates/src/pages/api/booking/metrics.ts +60 -0
- package/templates/src/pages/api/booking/release.ts +76 -0
- package/templates/src/pages/api/sandbox.ts +2 -2
- package/templates/src/pages/api/shopify/createCart.ts +69 -0
- package/templates/src/pages/api/shopify/getProducts.ts +64 -0
- package/templates/src/pages/storykeep/login.astro +26 -24
- 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 +266 -0
- package/templates/src/types/tractstack.ts +75 -0
- package/templates/src/utils/api/advancedConfig.ts +7 -1
- package/templates/src/utils/api/advancedHelpers.ts +87 -7
- package/templates/src/utils/api/bookingHelpers.ts +125 -0
- package/templates/src/utils/api/brandHelpers.ts +14 -0
- package/templates/src/utils/api/resourceConfig.ts +13 -5
- package/templates/src/utils/auth.ts +29 -9
- package/templates/src/utils/compositor/aiGeneration.ts +3 -3
- package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
- package/templates/src/utils/customHelpers.ts +49 -0
- package/templates/src/utils/helpers.ts +59 -0
- package/templates/src/utils/profileStorage.ts +5 -0
- package/templates/src/utils/tenantResolver.ts +2 -1
- package/utils/inject-files.ts +161 -2
|
@@ -25,26 +25,21 @@ if (MODE === `wordmark`)
|
|
|
25
25
|
assetUrl = getAssetPath(brandConfig?.WORDMARK, '/brand/wordmark.svg');
|
|
26
26
|
else assetUrl = getAssetPath(brandConfig?.LOGO, '/brand/logo.svg');
|
|
27
27
|
|
|
28
|
-
// Generate positions programmatically for triple density
|
|
29
28
|
const generatePositions = () => {
|
|
30
29
|
const positions = [];
|
|
31
|
-
const rows = 15;
|
|
32
|
-
const cols = 12;
|
|
30
|
+
const rows = 15;
|
|
31
|
+
const cols = 12;
|
|
33
32
|
|
|
34
33
|
for (let row = 0; row < rows; row++) {
|
|
35
34
|
for (let col = 0; col < cols; col++) {
|
|
36
|
-
// Skip some positions for natural spacing
|
|
37
35
|
if ((row + col) % 3 !== 0) continue;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const top = (row / (rows - 1)) * 120 - 10; // Extend 10% beyond top/bottom
|
|
41
|
-
const left = (col / (cols - 1)) * 120 - 10; // Extend 10% beyond left/right
|
|
36
|
+
const top = (row / (rows - 1)) * 120 - 10;
|
|
37
|
+
const left = (col / (cols - 1)) * 120 - 10;
|
|
42
38
|
const rotation = -45 + Math.random() * 90;
|
|
43
|
-
|
|
44
39
|
positions.push({
|
|
45
40
|
top: `${top}%`,
|
|
46
41
|
left: `${left}%`,
|
|
47
|
-
rotation:
|
|
42
|
+
rotation: rotation,
|
|
48
43
|
});
|
|
49
44
|
}
|
|
50
45
|
}
|
|
@@ -69,19 +64,44 @@ const logoPositions = generatePositions();
|
|
|
69
64
|
border: '2px dashed rgba(0, 0, 0, 1)',
|
|
70
65
|
}}
|
|
71
66
|
>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
height
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
67
|
+
<svg
|
|
68
|
+
width="100%"
|
|
69
|
+
height="100%"
|
|
70
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
71
|
+
style="overflow: visible;"
|
|
72
|
+
>
|
|
73
|
+
<defs>
|
|
74
|
+
<symbol id="brand-logo-symbol" viewBox="0 0 120 120">
|
|
75
|
+
<image href={assetUrl} width="120" height="120" />
|
|
76
|
+
</symbol>
|
|
77
|
+
</defs>
|
|
78
|
+
{logoPositions.map((pos) => (
|
|
79
|
+
<g
|
|
80
|
+
transform={`translate(${parseFloat(pos.left) * 0.01 * 100} ${parseFloat(pos.top) * 0.01 * 100})`}
|
|
81
|
+
>
|
|
82
|
+
<use
|
|
83
|
+
href="#brand-logo-symbol"
|
|
84
|
+
x={pos.left}
|
|
85
|
+
y={pos.top}
|
|
86
|
+
width="120"
|
|
87
|
+
height="120"
|
|
88
|
+
transform={`rotate(${pos.rotation}, 60, 60)`}
|
|
89
|
+
transform-origin="center"
|
|
90
|
+
/>
|
|
91
|
+
</g>
|
|
92
|
+
))}
|
|
93
|
+
{/* Simplified positioning to ensure browser compatibility with percentages */}
|
|
94
|
+
{logoPositions.map((pos) => (
|
|
95
|
+
<use
|
|
96
|
+
href="#brand-logo-symbol"
|
|
97
|
+
x={pos.left}
|
|
98
|
+
y={pos.top}
|
|
99
|
+
width="120"
|
|
100
|
+
height="120"
|
|
101
|
+
style={`transform: rotate(${pos.rotation}deg); transform-origin: center; transform-box: fill-box;`}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</svg>
|
|
85
105
|
</div>
|
|
86
106
|
)
|
|
87
107
|
}
|
|
@@ -170,7 +170,7 @@ export default function BeliefTable({
|
|
|
170
170
|
</div>
|
|
171
171
|
|
|
172
172
|
{/* Table Container */}
|
|
173
|
-
<div className="overflow-
|
|
173
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow">
|
|
174
174
|
{filteredBeliefs.length === 0 ? (
|
|
175
175
|
<div className="px-6 py-12 text-center">
|
|
176
176
|
<svg
|
|
@@ -204,7 +204,7 @@ export default function BeliefTable({
|
|
|
204
204
|
)}
|
|
205
205
|
</div>
|
|
206
206
|
) : (
|
|
207
|
-
<div className="
|
|
207
|
+
<div className="inline-block min-w-full align-middle">
|
|
208
208
|
<table className="min-w-full divide-y divide-gray-200">
|
|
209
209
|
<thead className="bg-gray-50">
|
|
210
210
|
<tr>
|
|
@@ -237,15 +237,24 @@ export default function BeliefTable({
|
|
|
237
237
|
<tr key={belief.id} className="hover:bg-gray-50">
|
|
238
238
|
<td className="px-3 py-4 md:px-6">
|
|
239
239
|
<div className="flex flex-col">
|
|
240
|
-
<div
|
|
240
|
+
<div
|
|
241
|
+
className="max-w-xs truncate text-sm font-bold text-gray-900"
|
|
242
|
+
title={belief.title}
|
|
243
|
+
>
|
|
241
244
|
{belief.title}
|
|
242
245
|
</div>
|
|
243
|
-
<div
|
|
246
|
+
<div
|
|
247
|
+
className="max-w-xs truncate text-sm text-gray-500 md:hidden"
|
|
248
|
+
title={belief.slug}
|
|
249
|
+
>
|
|
244
250
|
{belief.slug}
|
|
245
251
|
</div>
|
|
246
252
|
</div>
|
|
247
253
|
</td>
|
|
248
|
-
<td
|
|
254
|
+
<td
|
|
255
|
+
className="hidden max-w-xs truncate whitespace-nowrap px-3 py-4 text-sm text-gray-500 md:table-cell md:px-6"
|
|
256
|
+
title={belief.slug}
|
|
257
|
+
>
|
|
249
258
|
{belief.slug}
|
|
250
259
|
</td>
|
|
251
260
|
<td className="hidden whitespace-nowrap px-3 py-4 text-sm md:table-cell md:px-6">
|
|
@@ -171,20 +171,6 @@ const ContentBrowser = ({
|
|
|
171
171
|
|
|
172
172
|
return (
|
|
173
173
|
<div className="w-full">
|
|
174
|
-
<div className="mb-6">
|
|
175
|
-
<h2 className="font-action text-2xl font-bold text-gray-900">
|
|
176
|
-
Content Management
|
|
177
|
-
{(analytics.isLoading || analytics.status === 'loading') && (
|
|
178
|
-
<span className="ml-2 text-sm font-normal text-gray-500">
|
|
179
|
-
(Loading data...)
|
|
180
|
-
</span>
|
|
181
|
-
)}
|
|
182
|
-
</h2>
|
|
183
|
-
<p className="mt-1 text-sm text-gray-600">
|
|
184
|
-
Browse and manage your StoryKeep content pages
|
|
185
|
-
</p>
|
|
186
|
-
</div>
|
|
187
|
-
|
|
188
174
|
{analytics.error && (
|
|
189
175
|
<div className="mb-6 rounded-lg bg-red-50 p-4 text-red-800">
|
|
190
176
|
<h4 className="font-bold">Content Error</h4>
|
|
@@ -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
|
|
@@ -142,7 +142,7 @@ const KnownResourceTable = ({
|
|
|
142
142
|
</div>
|
|
143
143
|
</div>
|
|
144
144
|
|
|
145
|
-
<div className="overflow-
|
|
145
|
+
<div className="overflow-x-auto shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
|
146
146
|
<table className="min-w-full divide-y divide-gray-300">
|
|
147
147
|
<thead className="bg-gray-50">
|
|
148
148
|
<tr>
|
|
@@ -185,7 +185,10 @@ const KnownResourceTable = ({
|
|
|
185
185
|
className="cursor-pointer hover:bg-gray-50"
|
|
186
186
|
onClick={() => onEdit(categorySlug)}
|
|
187
187
|
>
|
|
188
|
-
<td
|
|
188
|
+
<td
|
|
189
|
+
className="max-w-xs truncate whitespace-nowrap px-6 py-4 text-sm font-bold text-gray-900"
|
|
190
|
+
title={categorySlug}
|
|
191
|
+
>
|
|
189
192
|
{categorySlug}
|
|
190
193
|
</td>
|
|
191
194
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
|
@@ -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':
|
|
@@ -183,7 +183,7 @@ export default function MenuTable({
|
|
|
183
183
|
</div>
|
|
184
184
|
|
|
185
185
|
{/* Table Container */}
|
|
186
|
-
<div className="overflow-
|
|
186
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow">
|
|
187
187
|
{filteredMenus.length === 0 ? (
|
|
188
188
|
<div className="px-6 py-12 text-center">
|
|
189
189
|
<svg
|
|
@@ -217,7 +217,7 @@ export default function MenuTable({
|
|
|
217
217
|
)}
|
|
218
218
|
</div>
|
|
219
219
|
) : (
|
|
220
|
-
<div className="
|
|
220
|
+
<div className="inline-block min-w-full align-middle">
|
|
221
221
|
<table className="min-w-full divide-y divide-gray-200">
|
|
222
222
|
<thead className="bg-gray-50">
|
|
223
223
|
<tr>
|
|
@@ -247,15 +247,24 @@ export default function MenuTable({
|
|
|
247
247
|
<tr key={menu.id} className="hover:bg-gray-50">
|
|
248
248
|
<td className="px-3 py-4 md:px-6">
|
|
249
249
|
<div className="flex flex-col">
|
|
250
|
-
<div
|
|
250
|
+
<div
|
|
251
|
+
className="max-w-xs truncate text-sm font-bold text-gray-900"
|
|
252
|
+
title={menu.title}
|
|
253
|
+
>
|
|
251
254
|
{menu.title}
|
|
252
255
|
</div>
|
|
253
|
-
<div
|
|
256
|
+
<div
|
|
257
|
+
className="max-w-xs truncate text-sm text-gray-500 md:hidden"
|
|
258
|
+
title={menu.theme || 'No theme'}
|
|
259
|
+
>
|
|
254
260
|
{menu.theme || 'No theme'}
|
|
255
261
|
</div>
|
|
256
262
|
</div>
|
|
257
263
|
</td>
|
|
258
|
-
<td
|
|
264
|
+
<td
|
|
265
|
+
className="hidden max-w-xs truncate whitespace-nowrap px-3 py-4 text-sm text-gray-500 md:table-cell md:px-6"
|
|
266
|
+
title={menu.theme || 'No theme'}
|
|
267
|
+
>
|
|
259
268
|
{menu.theme || 'No theme'}
|
|
260
269
|
</td>
|
|
261
270
|
<td className="hidden whitespace-nowrap px-3 py-4 text-sm xl:table-cell xl:px-6">
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { useStore } from '@nanostores/react';
|
|
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 {
|
|
11
|
+
shopifyData,
|
|
12
|
+
shopifyStatus,
|
|
13
|
+
fetchShopifyProducts,
|
|
14
|
+
clearShopifySearch,
|
|
15
|
+
type ShopifyProduct,
|
|
16
|
+
} from '@/stores/shopify';
|
|
17
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
18
|
+
|
|
19
|
+
interface ProductTableProps {
|
|
20
|
+
products: ShopifyProduct[];
|
|
21
|
+
linkedResourceMap: Map<string, ResourceNode>;
|
|
22
|
+
onRefresh: () => void;
|
|
23
|
+
isRefreshing: boolean;
|
|
24
|
+
onSelectProduct: (product: ShopifyProduct) => void;
|
|
25
|
+
onLink: (product: ShopifyProduct) => void;
|
|
26
|
+
onUnlink: (resourceId: string) => void;
|
|
27
|
+
onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function ProductTable({
|
|
31
|
+
products,
|
|
32
|
+
linkedResourceMap,
|
|
33
|
+
onSelectProduct,
|
|
34
|
+
onLink,
|
|
35
|
+
onUnlink,
|
|
36
|
+
onEdit,
|
|
37
|
+
}: ProductTableProps) {
|
|
38
|
+
const data = useStore(shopifyData);
|
|
39
|
+
const status = useStore(shopifyStatus);
|
|
40
|
+
|
|
41
|
+
const [inputValue, setInputValue] = useState('');
|
|
42
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
43
|
+
const [isDebouncing, setIsDebouncing] = useState(false);
|
|
44
|
+
|
|
45
|
+
const [cursorStack, setCursorStack] = useState<string[]>([]);
|
|
46
|
+
const [currentCursor, setCurrentCursor] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
return () => {
|
|
52
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
57
|
+
const val = e.target.value;
|
|
58
|
+
setInputValue(val);
|
|
59
|
+
setIsDebouncing(true);
|
|
60
|
+
|
|
61
|
+
if (debounceTimer.current) {
|
|
62
|
+
clearTimeout(debounceTimer.current);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (val.length === 0) {
|
|
66
|
+
setIsDebouncing(false);
|
|
67
|
+
setSearchTerm('');
|
|
68
|
+
setCursorStack([]);
|
|
69
|
+
setCurrentCursor(null);
|
|
70
|
+
clearShopifySearch();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
debounceTimer.current = setTimeout(() => {
|
|
75
|
+
setIsDebouncing(false);
|
|
76
|
+
setSearchTerm(val);
|
|
77
|
+
setCursorStack([]);
|
|
78
|
+
setCurrentCursor(null);
|
|
79
|
+
|
|
80
|
+
if (val.length >= 3) {
|
|
81
|
+
fetchShopifyProducts(val, null);
|
|
82
|
+
} else {
|
|
83
|
+
clearShopifySearch();
|
|
84
|
+
}
|
|
85
|
+
}, 1000);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleRefreshSearch = () => {
|
|
89
|
+
if (searchTerm.length >= 3) {
|
|
90
|
+
fetchShopifyProducts(searchTerm, currentCursor);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleNext = () => {
|
|
95
|
+
if (data.pageInfo?.hasNextPage && data.pageInfo?.endCursor) {
|
|
96
|
+
const nextCursor = data.pageInfo.endCursor;
|
|
97
|
+
setCursorStack((prev) => [...prev, currentCursor || '']);
|
|
98
|
+
setCurrentCursor(nextCursor);
|
|
99
|
+
fetchShopifyProducts(searchTerm, nextCursor);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handlePrev = () => {
|
|
104
|
+
if (cursorStack.length > 0) {
|
|
105
|
+
const newStack = [...cursorStack];
|
|
106
|
+
const prevCursor = newStack.pop() || null;
|
|
107
|
+
setCursorStack(newStack);
|
|
108
|
+
setCurrentCursor(prevCursor || null);
|
|
109
|
+
fetchShopifyProducts(searchTerm, prevCursor || null);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const isLoading = status.isLoading || isDebouncing;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="space-y-4">
|
|
117
|
+
<div className="flex flex-col gap-1">
|
|
118
|
+
<div className="flex items-center gap-4">
|
|
119
|
+
<div className="flex-1">
|
|
120
|
+
<input
|
|
121
|
+
type="text"
|
|
122
|
+
placeholder="Search products..."
|
|
123
|
+
value={inputValue}
|
|
124
|
+
onChange={handleSearchChange}
|
|
125
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-cyan-700 focus:ring-cyan-700"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
<button
|
|
129
|
+
onClick={handleRefreshSearch}
|
|
130
|
+
disabled={isLoading || searchTerm.length < 3}
|
|
131
|
+
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"
|
|
132
|
+
title="Refresh current search"
|
|
133
|
+
>
|
|
134
|
+
<ArrowPathIcon
|
|
135
|
+
className={`mr-1.5 h-5 w-5 ${isLoading ? 'animate-spin' : ''}`}
|
|
136
|
+
/>
|
|
137
|
+
Sync
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
<p className="text-xs text-gray-500">Search by product title.</p>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="overflow-x-auto shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
|
144
|
+
<table className="min-w-full divide-y divide-gray-300">
|
|
145
|
+
<thead className="bg-gray-50">
|
|
146
|
+
<tr>
|
|
147
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
148
|
+
Product
|
|
149
|
+
</th>
|
|
150
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
151
|
+
Handle
|
|
152
|
+
</th>
|
|
153
|
+
<th className="relative px-6 py-3">
|
|
154
|
+
<span className="sr-only">Actions</span>
|
|
155
|
+
</th>
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody className="divide-y divide-gray-200 bg-white">
|
|
159
|
+
{status.error ? (
|
|
160
|
+
<tr>
|
|
161
|
+
<td colSpan={3} className="px-6 py-12 text-center">
|
|
162
|
+
<div className="font-bold text-red-600">{status.error}</div>
|
|
163
|
+
<div className="mt-1 text-sm text-red-500">
|
|
164
|
+
Please check your Shopify integration settings or API token.
|
|
165
|
+
</div>
|
|
166
|
+
</td>
|
|
167
|
+
</tr>
|
|
168
|
+
) : isLoading ? (
|
|
169
|
+
<tr>
|
|
170
|
+
<td colSpan={3} className="px-6 py-12 text-center">
|
|
171
|
+
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-cyan-600"></div>
|
|
172
|
+
<p className="mt-2 text-sm text-gray-500">
|
|
173
|
+
Searching Shopify...
|
|
174
|
+
</p>
|
|
175
|
+
</td>
|
|
176
|
+
</tr>
|
|
177
|
+
) : inputValue.length === 0 ? (
|
|
178
|
+
<tr>
|
|
179
|
+
<td
|
|
180
|
+
colSpan={3}
|
|
181
|
+
className="px-6 py-12 text-center text-gray-500"
|
|
182
|
+
>
|
|
183
|
+
Search to discover products.
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
) : inputValue.length < 3 ? (
|
|
187
|
+
<tr>
|
|
188
|
+
<td
|
|
189
|
+
colSpan={3}
|
|
190
|
+
className="px-6 py-12 text-center text-gray-500"
|
|
191
|
+
>
|
|
192
|
+
Please enter at least 3 characters to search.
|
|
193
|
+
</td>
|
|
194
|
+
</tr>
|
|
195
|
+
) : products.length === 0 ? (
|
|
196
|
+
<tr>
|
|
197
|
+
<td
|
|
198
|
+
colSpan={3}
|
|
199
|
+
className="px-6 py-12 text-center text-gray-500"
|
|
200
|
+
>
|
|
201
|
+
No products match your search.
|
|
202
|
+
</td>
|
|
203
|
+
</tr>
|
|
204
|
+
) : (
|
|
205
|
+
products.map((product) => {
|
|
206
|
+
const linkedResource = linkedResourceMap.get(product.id);
|
|
207
|
+
const isLinked = !!linkedResource;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<tr key={product.id} className="hover:bg-gray-50">
|
|
211
|
+
<td className="px-6 py-4">
|
|
212
|
+
<div
|
|
213
|
+
className="max-w-xs truncate font-bold text-gray-900"
|
|
214
|
+
title={product.title}
|
|
215
|
+
>
|
|
216
|
+
{product.title}
|
|
217
|
+
</div>
|
|
218
|
+
{isLinked && (
|
|
219
|
+
<span
|
|
220
|
+
className={`mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ring-1 ring-inset ${
|
|
221
|
+
linkedResource.categorySlug === 'service'
|
|
222
|
+
? 'bg-indigo-50 text-indigo-700 ring-indigo-600/20'
|
|
223
|
+
: 'bg-cyan-50 text-cyan-700 ring-cyan-600/20'
|
|
224
|
+
}`}
|
|
225
|
+
>
|
|
226
|
+
Synced:{' '}
|
|
227
|
+
<span
|
|
228
|
+
className="max-w-xs truncate"
|
|
229
|
+
title={linkedResource.title}
|
|
230
|
+
>
|
|
231
|
+
{linkedResource.title}
|
|
232
|
+
</span>
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
</td>
|
|
236
|
+
<td
|
|
237
|
+
className="max-w-xs truncate whitespace-nowrap px-6 py-4 text-sm text-gray-500"
|
|
238
|
+
title={product.handle}
|
|
239
|
+
>
|
|
240
|
+
{product.handle}
|
|
241
|
+
</td>
|
|
242
|
+
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold">
|
|
243
|
+
<div className="flex items-center justify-end space-x-2">
|
|
244
|
+
{isLinked ? (
|
|
245
|
+
<>
|
|
246
|
+
<button
|
|
247
|
+
onClick={() => onEdit(product, linkedResource)}
|
|
248
|
+
className="text-cyan-600 hover:text-cyan-900"
|
|
249
|
+
title="Edit Resource"
|
|
250
|
+
>
|
|
251
|
+
<PencilIcon
|
|
252
|
+
className="h-5 w-5"
|
|
253
|
+
aria-hidden="true"
|
|
254
|
+
/>
|
|
255
|
+
<span className="sr-only">
|
|
256
|
+
Edit {product.title}
|
|
257
|
+
</span>
|
|
258
|
+
</button>
|
|
259
|
+
<button
|
|
260
|
+
onClick={() => onUnlink(linkedResource.id)}
|
|
261
|
+
className="text-red-600 hover:text-red-900"
|
|
262
|
+
title="Unlink Resource"
|
|
263
|
+
>
|
|
264
|
+
<TrashIcon
|
|
265
|
+
className="h-5 w-5"
|
|
266
|
+
aria-hidden="true"
|
|
267
|
+
/>
|
|
268
|
+
<span className="sr-only">
|
|
269
|
+
Unlink {product.title}
|
|
270
|
+
</span>
|
|
271
|
+
</button>
|
|
272
|
+
</>
|
|
273
|
+
) : (
|
|
274
|
+
<button
|
|
275
|
+
onClick={() => onLink(product)}
|
|
276
|
+
className="text-cyan-600 hover:text-cyan-900"
|
|
277
|
+
title="Create Resource"
|
|
278
|
+
>
|
|
279
|
+
<PlusIcon className="h-5 w-5" aria-hidden="true" />
|
|
280
|
+
<span className="sr-only">
|
|
281
|
+
Link {product.title}
|
|
282
|
+
</span>
|
|
283
|
+
</button>
|
|
284
|
+
)}
|
|
285
|
+
<button
|
|
286
|
+
onClick={() => onSelectProduct(product)}
|
|
287
|
+
className="text-gray-400 hover:text-gray-600"
|
|
288
|
+
>
|
|
289
|
+
<MagnifyingGlassIcon
|
|
290
|
+
className="h-5 w-5"
|
|
291
|
+
aria-hidden="true"
|
|
292
|
+
/>
|
|
293
|
+
<span className="sr-only">
|
|
294
|
+
Inspect {product.title}
|
|
295
|
+
</span>
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
</td>
|
|
299
|
+
</tr>
|
|
300
|
+
);
|
|
301
|
+
})
|
|
302
|
+
)}
|
|
303
|
+
</tbody>
|
|
304
|
+
</table>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
{(cursorStack.length > 0 || data.pageInfo?.hasNextPage) &&
|
|
308
|
+
!isLoading &&
|
|
309
|
+
inputValue.length >= 3 &&
|
|
310
|
+
products.length > 0 && (
|
|
311
|
+
<div className="flex items-center justify-center space-x-4 pt-4">
|
|
312
|
+
<button
|
|
313
|
+
onClick={handlePrev}
|
|
314
|
+
disabled={cursorStack.length === 0}
|
|
315
|
+
className="flex items-center gap-1 rounded px-3 py-2 text-sm font-bold text-gray-700 transition-colors hover:text-cyan-600 disabled:opacity-30 disabled:hover:text-gray-700"
|
|
316
|
+
>
|
|
317
|
+
<ChevronLeftIcon className="h-4 w-4" />
|
|
318
|
+
Previous
|
|
319
|
+
</button>
|
|
320
|
+
|
|
321
|
+
<button
|
|
322
|
+
onClick={handleNext}
|
|
323
|
+
disabled={!data.pageInfo?.hasNextPage}
|
|
324
|
+
className="flex items-center gap-1 rounded px-3 py-2 text-sm font-bold text-gray-700 transition-colors hover:text-cyan-600 disabled:opacity-30 disabled:hover:text-gray-700"
|
|
325
|
+
>
|
|
326
|
+
Next
|
|
327
|
+
<ChevronRightIcon className="h-4 w-4" />
|
|
328
|
+
</button>
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|