astro-tractstack 2.3.0 → 2.3.2

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 (95) hide show
  1. package/README.md +1 -1
  2. package/bin/create-tractstack.js +2 -2
  3. package/dist/index.js +130 -19
  4. package/package.json +2 -2
  5. package/templates/custom/minimal/CodeHook.astro +10 -2
  6. package/templates/custom/shopify/Cart.tsx +115 -77
  7. package/templates/custom/shopify/CheckoutModal.tsx +509 -120
  8. package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
  9. package/templates/custom/shopify/ShopifyCartManager.tsx +91 -45
  10. package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
  11. package/templates/custom/shopify/ShopifyProductGrid.tsx +170 -176
  12. package/templates/custom/shopify/ShopifyServiceList.tsx +112 -51
  13. package/templates/custom/with-examples/CodeHook.astro +10 -2
  14. package/templates/src/components/Footer.astro +6 -6
  15. package/templates/src/components/Header.astro +23 -11
  16. package/templates/src/components/Menu.tsx +157 -135
  17. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
  19. package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
  20. package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
  21. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
  22. package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
  23. package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
  24. package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
  25. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
  26. package/templates/src/components/edit/ToolBar.tsx +2 -1
  27. package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
  28. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
  29. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
  30. package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
  31. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
  32. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  33. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
  34. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
  35. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
  36. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
  37. package/templates/src/components/edit/state/SaveModal.tsx +1 -1
  38. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
  39. package/templates/src/components/form/DateTimeInput.tsx +10 -3
  40. package/templates/src/components/form/FileUpload.tsx +11 -5
  41. package/templates/src/components/form/NumberInput.tsx +2 -2
  42. package/templates/src/components/form/advanced/APIConfigSection.tsx +208 -2
  43. package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
  44. package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
  45. package/templates/src/components/storykeep/Dashboard.tsx +1 -1
  46. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +252 -110
  47. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
  48. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
  49. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +5 -2
  50. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
  51. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +180 -101
  52. package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +88 -56
  53. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +14 -4
  54. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
  55. package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
  56. package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
  57. package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
  58. package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
  59. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +104 -0
  60. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +419 -0
  61. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
  62. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
  63. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
  64. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
  65. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
  66. package/templates/src/layouts/Layout.astro +8 -5
  67. package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
  68. package/templates/src/pages/api/booking/availability.ts +72 -0
  69. package/templates/src/pages/api/booking/cancel.ts +73 -0
  70. package/templates/src/pages/api/booking/confirm.ts +82 -0
  71. package/templates/src/pages/api/booking/hold.ts +75 -0
  72. package/templates/src/pages/api/booking/list.ts +66 -0
  73. package/templates/src/pages/api/booking/metrics.ts +60 -0
  74. package/templates/src/pages/api/booking/release.ts +76 -0
  75. package/templates/src/pages/api/sandbox.ts +2 -2
  76. package/templates/src/pages/api/shopify/createCart.ts +4 -8
  77. package/templates/src/pages/api/shopify/getProducts.ts +15 -15
  78. package/templates/src/pages/storykeep/login.astro +21 -14
  79. package/templates/src/stores/shopify.ts +97 -25
  80. package/templates/src/types/formTypes.ts +4 -2
  81. package/templates/src/types/tractstack.ts +59 -2
  82. package/templates/src/utils/api/advancedConfig.ts +2 -0
  83. package/templates/src/utils/api/advancedHelpers.ts +40 -3
  84. package/templates/src/utils/api/bookingHelpers.ts +125 -0
  85. package/templates/src/utils/api/brandConfig.ts +2 -0
  86. package/templates/src/utils/api/brandHelpers.ts +26 -0
  87. package/templates/src/utils/api/emailHelpers.ts +105 -0
  88. package/templates/src/utils/auth.ts +29 -9
  89. package/templates/src/utils/compositor/aiGeneration.ts +3 -3
  90. package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
  91. package/templates/src/utils/customHelpers.ts +0 -21
  92. package/templates/src/utils/profileStorage.ts +5 -0
  93. package/templates/src/utils/tenantResolver.ts +3 -2
  94. package/utils/inject-files.ts +116 -5
  95. package/templates/custom/shopify/CalDotComBooking.tsx +0 -44
@@ -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">
@@ -1,5 +1,5 @@
1
- import { useState } from 'react';
2
- import { Pagination } from '@ark-ui/react/pagination';
1
+ import { useState, useRef, useEffect, type ChangeEvent } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
3
  import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon';
4
4
  import ChevronLeftIcon from '@heroicons/react/24/outline/ChevronLeftIcon';
5
5
  import ArrowPathIcon from '@heroicons/react/24/outline/ArrowPathIcon';
@@ -7,7 +7,13 @@ import MagnifyingGlassIcon from '@heroicons/react/24/outline/MagnifyingGlassIcon
7
7
  import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
8
8
  import TrashIcon from '@heroicons/react/24/outline/TrashIcon';
9
9
  import PencilIcon from '@heroicons/react/24/outline/PencilIcon';
10
- import type { ShopifyProduct } from '@/stores/shopify';
10
+ import {
11
+ shopifyData,
12
+ shopifyStatus,
13
+ fetchShopifyProducts,
14
+ clearShopifySearch,
15
+ type ShopifyProduct,
16
+ } from '@/stores/shopify';
11
17
  import type { ResourceNode } from '@/types/compositorTypes';
12
18
 
13
19
  interface ProductTableProps {
@@ -21,68 +27,120 @@ interface ProductTableProps {
21
27
  onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
22
28
  }
23
29
 
24
- const ITEMS_PER_PAGE = 10;
25
-
26
30
  export default function ProductTable({
27
31
  products,
28
32
  linkedResourceMap,
29
- onRefresh,
30
- isRefreshing,
31
33
  onSelectProduct,
32
34
  onLink,
33
35
  onUnlink,
34
36
  onEdit,
35
37
  }: ProductTableProps) {
38
+ const data = useStore(shopifyData);
39
+ const status = useStore(shopifyStatus);
40
+
41
+ const [inputValue, setInputValue] = useState('');
36
42
  const [searchTerm, setSearchTerm] = useState('');
37
- const [currentPage, setCurrentPage] = useState(1);
43
+ const [isDebouncing, setIsDebouncing] = useState(false);
38
44
 
39
- const filteredProducts = products.filter(
40
- (product) =>
41
- product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
42
- product.handle.toLowerCase().includes(searchTerm.toLowerCase())
43
- );
45
+ const [cursorStack, setCursorStack] = useState<string[]>([]);
46
+ const [currentCursor, setCurrentCursor] = useState<string | null>(null);
44
47
 
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
- );
48
+ const debounceTimer = useRef<NodeJS.Timeout | null>(null);
49
+
50
+ useEffect(() => {
51
+ return () => {
52
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
53
+ };
54
+ }, []);
52
55
 
53
- const handlePageChange = (page: number) => {
54
- setCurrentPage(page);
56
+ const handleSearchChange = (e: 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);
55
86
  };
56
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
+
57
115
  return (
58
116
  <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
- />
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>
71
139
  </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>
140
+ <p className="text-xs text-gray-500">Search by product title.</p>
83
141
  </div>
84
142
 
85
- <div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
143
+ <div className="overflow-x-auto shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
86
144
  <table className="min-w-full divide-y divide-gray-300">
87
145
  <thead className="bg-gray-50">
88
146
  <tr>
@@ -98,25 +156,63 @@ export default function ProductTable({
98
156
  </tr>
99
157
  </thead>
100
158
  <tbody className="divide-y divide-gray-200 bg-white">
101
- {paginatedProducts.length === 0 ? (
159
+ {status.error ? (
102
160
  <tr>
103
161
  <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'}
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.
108
165
  </div>
109
166
  </td>
110
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>
111
204
  ) : (
112
- paginatedProducts.map((product) => {
205
+ products.map((product) => {
113
206
  const linkedResource = linkedResourceMap.get(product.id);
114
207
  const isLinked = !!linkedResource;
115
208
 
116
209
  return (
117
210
  <tr key={product.id} className="hover:bg-gray-50">
118
211
  <td className="px-6 py-4">
119
- <div className="font-bold text-gray-900">
212
+ <div
213
+ className="max-w-xs truncate font-bold text-gray-900"
214
+ title={product.title}
215
+ >
120
216
  {product.title}
121
217
  </div>
122
218
  {isLinked && (
@@ -127,11 +223,20 @@ export default function ProductTable({
127
223
  : 'bg-cyan-50 text-cyan-700 ring-cyan-600/20'
128
224
  }`}
129
225
  >
130
- Synced: {linkedResource.title}
226
+ Synced:{' '}
227
+ <span
228
+ className="max-w-xs truncate"
229
+ title={linkedResource.title}
230
+ >
231
+ {linkedResource.title}
232
+ </span>
131
233
  </span>
132
234
  )}
133
235
  </td>
134
- <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
236
+ <td
237
+ className="max-w-xs truncate whitespace-nowrap px-6 py-4 text-sm text-gray-500"
238
+ title={product.handle}
239
+ >
135
240
  {product.handle}
136
241
  </td>
137
242
  <td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold">
@@ -199,56 +304,30 @@ export default function ProductTable({
199
304
  </table>
200
305
  </div>
201
306
 
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">
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
+ >
211
317
  <ChevronLeftIcon className="h-4 w-4" />
212
318
  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">
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
+ >
246
326
  Next
247
327
  <ChevronRightIcon className="h-4 w-4" />
248
- </Pagination.NextTrigger>
249
- </Pagination.Root>
250
- </div>
251
- )}
328
+ </button>
329
+ </div>
330
+ )}
252
331
  </div>
253
332
  );
254
333
  }
@@ -7,6 +7,8 @@ interface ResourceBulkIngestProps {
7
7
  onClose: (saved: boolean) => void;
8
8
  onRefresh: () => void;
9
9
  fullContentMap: FullContentMapItem[];
10
+ /** When set, placeholder shows one example for this category only (validation still accepts all types). */
11
+ exampleCategorySlug?: string;
10
12
  }
11
13
 
12
14
  interface ParsedResource {
@@ -40,10 +42,63 @@ interface FieldDefinition {
40
42
  maxNumber?: number;
41
43
  }
42
44
 
45
+ function buildExampleObjectForCategory(
46
+ categorySlug: string,
47
+ index: number,
48
+ knownResources: Record<string, Record<string, FieldDefinition>>,
49
+ categoryKeys: string[]
50
+ ): Record<string, unknown> {
51
+ const schema = knownResources[categorySlug];
52
+ const example: Record<string, unknown> = {
53
+ title: `Example ${categorySlug.charAt(0).toUpperCase() + categorySlug.slice(1)} ${index + 1}`,
54
+ slug: `${categorySlug}-example-${index + 1}`,
55
+ category: categorySlug,
56
+ oneliner: `A brief description of this ${categorySlug}`,
57
+ };
58
+
59
+ Object.entries(schema).forEach(([key, def]: [string, FieldDefinition]) => {
60
+ switch (def.type) {
61
+ case 'string':
62
+ if (
63
+ def.belongsToCategory &&
64
+ categoryKeys.includes(def.belongsToCategory)
65
+ ) {
66
+ example[key] = `${def.belongsToCategory}-example-slug`;
67
+ } else {
68
+ example[key] = def.defaultValue || `example ${key}`;
69
+ }
70
+ break;
71
+ case 'number':
72
+ example[key] = def.defaultValue ?? (def.minNumber || 0);
73
+ break;
74
+ case 'boolean':
75
+ example[key] = def.defaultValue ?? true;
76
+ break;
77
+ case 'multi':
78
+ example[key] = def.defaultValue || [
79
+ `example ${key} 1`,
80
+ `example ${key} 2`,
81
+ ];
82
+ break;
83
+ case 'date':
84
+ example[key] = new Date().toISOString();
85
+ break;
86
+ case 'image':
87
+ example[key] = 'file-id-placeholder';
88
+ break;
89
+ default:
90
+ example[key] = def.defaultValue || `example ${key}`;
91
+ }
92
+ });
93
+
94
+ return example;
95
+ }
96
+
43
97
  export default function ResourceBulkIngest({
44
98
  onClose,
45
99
  onRefresh,
46
100
  fullContentMap,
101
+ exampleCategorySlug,
47
102
  }: ResourceBulkIngestProps) {
48
103
  const [brandConfig, setBrandConfig] = useState<BrandConfig | null>(null);
49
104
  const [loading, setLoading] = useState(false);
@@ -418,7 +473,7 @@ export default function ResourceBulkIngest({
418
473
  };
419
474
  }, [jsonInput, knownResources, fullContentMap]);
420
475
 
421
- // Generate example JSON based on available categories - ENHANCED VERSION
476
+ // Placeholder JSON: one category when exampleCategorySlug is set, else one object per known category
422
477
  const exampleJson = useMemo(() => {
423
478
  const categories = Object.keys(knownResources);
424
479
  if (categories.length === 0) {
@@ -436,59 +491,32 @@ export default function ResourceBulkIngest({
436
491
  );
437
492
  }
438
493
 
439
- // Create examples for ALL categories, not just the first one
440
- const examples = categories.map((categorySlug, index) => {
441
- const schema = knownResources[categorySlug];
442
- const example: any = {
443
- title: `Example ${categorySlug.charAt(0).toUpperCase() + categorySlug.slice(1)} ${index + 1}`,
444
- slug: `${categorySlug}-example-${index + 1}`,
445
- category: categorySlug,
446
- oneliner: `A brief description of this ${categorySlug}`,
447
- };
448
-
449
- // Add example values for schema fields
450
- Object.entries(schema).forEach(
451
- ([key, def]: [string, FieldDefinition]) => {
452
- switch (def.type) {
453
- case 'string':
454
- if (
455
- def.belongsToCategory &&
456
- categories.includes(def.belongsToCategory)
457
- ) {
458
- example[key] = `${def.belongsToCategory}-example-slug`;
459
- } else {
460
- example[key] = def.defaultValue || `example ${key}`;
461
- }
462
- break;
463
- case 'number':
464
- example[key] = def.defaultValue ?? (def.minNumber || 0);
465
- break;
466
- case 'boolean':
467
- example[key] = def.defaultValue ?? true;
468
- break;
469
- case 'multi':
470
- example[key] = def.defaultValue || [
471
- `example ${key} 1`,
472
- `example ${key} 2`,
473
- ];
474
- break;
475
- case 'date':
476
- example[key] = new Date().toISOString();
477
- break;
478
- case 'image':
479
- example[key] = 'file-id-placeholder';
480
- break;
481
- default:
482
- example[key] = def.defaultValue || `example ${key}`;
483
- }
484
- }
485
- );
494
+ if (
495
+ exampleCategorySlug &&
496
+ knownResources[exampleCategorySlug] !== undefined
497
+ ) {
498
+ const examples = [
499
+ buildExampleObjectForCategory(
500
+ exampleCategorySlug,
501
+ 0,
502
+ knownResources,
503
+ categories
504
+ ),
505
+ ];
506
+ return JSON.stringify(examples, null, 2);
507
+ }
486
508
 
487
- return example;
488
- });
509
+ const examples = categories.map((categorySlug, index) =>
510
+ buildExampleObjectForCategory(
511
+ categorySlug,
512
+ index,
513
+ knownResources,
514
+ categories
515
+ )
516
+ );
489
517
 
490
518
  return JSON.stringify(examples, null, 2);
491
- }, [knownResources]);
519
+ }, [knownResources, exampleCategorySlug]);
492
520
 
493
521
  const handleSave = useCallback(async () => {
494
522
  if (validationResult.validResources.length === 0 || isProcessing) return;
@@ -595,7 +623,7 @@ export default function ResourceBulkIngest({
595
623
 
596
624
  {/* Status Display */}
597
625
  <div className="mb-6 rounded-md border border-gray-200 bg-gray-50 p-4">
598
- <div className="mb-2 flex items-center justify-between">
626
+ <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
599
627
  <span className="font-bold text-gray-900">
600
628
  {validationResult.resources.length} resources found
601
629
  </span>
@@ -638,7 +666,7 @@ export default function ResourceBulkIngest({
638
666
  Validation Errors:
639
667
  </p>
640
668
  <div className="max-h-32 overflow-y-auto">
641
- <ul className="space-y-1 text-sm text-red-600">
669
+ <ul className="space-y-1 break-words text-sm text-red-600">
642
670
  {validationResult.errors
643
671
  .slice(0, 10)
644
672
  .map((error, idx) => (
@@ -648,8 +676,12 @@ export default function ResourceBulkIngest({
648
676
  ? `Item ${error.index + 1}:`
649
677
  : 'JSON:'}
650
678
  </span>
651
- <span>
652
- {error.field} - {error.message}
679
+ <span
680
+ className="truncate"
681
+ title={`${error.field} - ${error.message}`}
682
+ >
683
+ <span className="font-bold">{error.field}</span> -{' '}
684
+ {error.message}
653
685
  </span>
654
686
  </li>
655
687
  ))}
@@ -674,7 +706,7 @@ export default function ResourceBulkIngest({
674
706
  {/* Progress indicator */}
675
707
  {isProcessing && progress && (
676
708
  <div className="mb-6">
677
- <div className="mb-2 flex items-center justify-between text-sm">
709
+ <div className="mb-2 flex flex-wrap items-center justify-between gap-2 text-sm">
678
710
  <span>
679
711
  Processing resource {progress.current + 1} of{' '}
680
712
  {progress.total}
@@ -117,7 +117,7 @@ export default function ResourceTable({
117
117
  </div>
118
118
 
119
119
  {/* Table */}
120
- <div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
120
+ <div className="overflow-x-auto shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
121
121
  <table className="min-w-full divide-y divide-gray-300">
122
122
  <thead className="bg-gray-50">
123
123
  <tr>
@@ -153,13 +153,22 @@ export default function ResourceTable({
153
153
  className="cursor-pointer hover:bg-gray-50"
154
154
  onClick={() => onEdit(resource.id)}
155
155
  >
156
- <td className="whitespace-nowrap px-6 py-4 text-sm font-bold text-gray-900">
156
+ <td
157
+ className="max-w-xs truncate whitespace-nowrap px-6 py-4 text-sm font-bold text-gray-900"
158
+ title={resource.title}
159
+ >
157
160
  {resource.title}
158
161
  </td>
159
- <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
162
+ <td
163
+ className="max-w-xs truncate whitespace-nowrap px-6 py-4 text-sm text-gray-500"
164
+ title={resource.slug}
165
+ >
160
166
  {resource.slug}
161
167
  </td>
162
- <td className="px-6 py-4 text-sm text-gray-500">
168
+ <td
169
+ className="max-w-xs truncate px-6 py-4 text-sm text-gray-500"
170
+ title={(resource as any).oneliner || '-'}
171
+ >
163
172
  {(resource as any).oneliner || '-'}
164
173
  </td>
165
174
  <td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-bold md:pr-6">
@@ -207,6 +216,7 @@ export default function ResourceTable({
207
216
  </div>
208
217
  {showBulkIngest && (
209
218
  <ResourceBulkIngest
219
+ exampleCategorySlug={categorySlug}
210
220
  fullContentMap={fullContentMap}
211
221
  onClose={(saved) => {
212
222
  setShowBulkIngest(false);