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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +177 -18
  4. package/package.json +4 -2
  5. package/templates/custom/minimal/CodeHook.astro +22 -5
  6. package/templates/custom/shopify/Cart.tsx +372 -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 +576 -0
  10. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  11. package/templates/custom/shopify/ShopifyCartManager.tsx +200 -0
  12. package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
  13. package/templates/custom/shopify/ShopifyProductGrid.tsx +247 -0
  14. package/templates/custom/shopify/ShopifyServiceList.tsx +135 -0
  15. package/templates/custom/shopify/cart.astro +23 -0
  16. package/templates/custom/with-examples/CodeHook.astro +17 -1
  17. package/templates/custom/with-examples/ProductGrid.astro +1 -1
  18. package/templates/src/client/app.js +4 -2
  19. package/templates/src/components/Footer.astro +4 -4
  20. package/templates/src/components/Header.astro +44 -12
  21. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  22. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  23. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  24. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  25. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  26. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  27. package/templates/src/components/form/advanced/APIConfigSection.tsx +407 -38
  28. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  29. package/templates/src/components/storykeep/Dashboard.tsx +18 -4
  30. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
  31. package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
  32. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +668 -0
  33. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
  34. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  35. package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
  36. package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
  37. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  38. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
  39. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  40. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +333 -0
  41. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +9 -5
  42. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
  43. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
  44. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  45. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
  46. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -0
  47. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  48. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  49. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  50. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  51. package/templates/src/lib/resources.ts +11 -21
  52. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  53. package/templates/src/pages/api/booking/availability.ts +72 -0
  54. package/templates/src/pages/api/booking/cancel.ts +73 -0
  55. package/templates/src/pages/api/booking/confirm.ts +82 -0
  56. package/templates/src/pages/api/booking/hold.ts +75 -0
  57. package/templates/src/pages/api/booking/list.ts +66 -0
  58. package/templates/src/pages/api/booking/metrics.ts +60 -0
  59. package/templates/src/pages/api/booking/release.ts +76 -0
  60. package/templates/src/pages/api/sandbox.ts +2 -2
  61. package/templates/src/pages/api/shopify/createCart.ts +69 -0
  62. package/templates/src/pages/api/shopify/getProducts.ts +64 -0
  63. package/templates/src/pages/storykeep/login.astro +26 -24
  64. package/templates/src/pages/storykeep/logout.astro +1 -10
  65. package/templates/src/pages/storykeep/manage.astro +69 -0
  66. package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
  67. package/templates/src/pages/storykeep/shopify.astro +101 -0
  68. package/templates/src/stores/navigation.ts +3 -42
  69. package/templates/src/stores/nodes.ts +3 -1
  70. package/templates/src/stores/resources.ts +7 -10
  71. package/templates/src/stores/shopify.ts +266 -0
  72. package/templates/src/types/tractstack.ts +75 -0
  73. package/templates/src/utils/api/advancedConfig.ts +7 -1
  74. package/templates/src/utils/api/advancedHelpers.ts +87 -7
  75. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  76. package/templates/src/utils/api/brandHelpers.ts +14 -0
  77. package/templates/src/utils/api/resourceConfig.ts +13 -5
  78. package/templates/src/utils/auth.ts +29 -9
  79. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  80. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  81. package/templates/src/utils/customHelpers.ts +49 -0
  82. package/templates/src/utils/helpers.ts +59 -0
  83. package/templates/src/utils/profileStorage.ts +5 -0
  84. package/templates/src/utils/tenantResolver.ts +2 -1
  85. 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; // More rows to extend beyond boundaries
32
- const cols = 12; // More cols to extend beyond boundaries
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
- // Allow logos to extend beyond container edges (no margins)
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: `${rotation}deg`,
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
- {logoPositions.map((position) => (
73
- <img
74
- src={assetUrl}
75
- style={{
76
- position: 'absolute',
77
- top: position.top,
78
- left: position.left,
79
- width: '120px',
80
- height: 'auto',
81
- transform: `rotate(${position.rotation})`,
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-hidden rounded-lg border border-gray-200 bg-white shadow">
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="overflow-hidden">
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 className="text-sm font-bold text-gray-900">
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 className="text-sm text-gray-500 md:hidden">
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 className="hidden whitespace-nowrap px-3 py-4 text-sm text-gray-500 md:table-cell md:px-6">
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 === '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
@@ -142,7 +142,7 @@ const KnownResourceTable = ({
142
142
  </div>
143
143
  </div>
144
144
 
145
- <div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
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 className="whitespace-nowrap px-6 py-4 text-sm font-bold text-gray-900">
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
- <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':
@@ -183,7 +183,7 @@ export default function MenuTable({
183
183
  </div>
184
184
 
185
185
  {/* Table Container */}
186
- <div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow">
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="overflow-hidden">
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 className="text-sm font-bold text-gray-900">
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 className="text-sm text-gray-500 md:hidden">
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 className="hidden whitespace-nowrap px-3 py-4 text-sm text-gray-500 md:table-cell md:px-6">
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
+ }