astro-tractstack 2.3.2 → 2.3.4

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 (58) hide show
  1. package/bin/create-tractstack.js +7 -4
  2. package/dist/index.js +51 -8
  3. package/package.json +1 -1
  4. package/templates/custom/shopify/Cart.tsx +279 -118
  5. package/templates/custom/shopify/CartIcon.tsx +8 -8
  6. package/templates/custom/shopify/CheckoutModal.tsx +328 -65
  7. package/templates/custom/shopify/ShopifyCartManager.tsx +117 -60
  8. package/templates/custom/shopify/ShopifyCheckout.tsx +2 -26
  9. package/templates/custom/shopify/ShopifyProductGrid.tsx +8 -0
  10. package/templates/custom/shopify/ShopifyServiceList.tsx +25 -37
  11. package/templates/custom/shopify/cart.astro +7 -1
  12. package/templates/src/components/Header.astro +4 -2
  13. package/templates/src/components/compositor/Node.tsx +39 -9
  14. package/templates/src/components/compositor/nodes/CreativePane.tsx +175 -88
  15. package/templates/src/components/edit/pane/AddPanePanel.tsx +2 -2
  16. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +6 -5
  17. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +9 -15
  18. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
  19. package/templates/src/components/form/advanced/APIConfigSection.tsx +249 -4
  20. package/templates/src/components/form/brand/SiteConfigSection.tsx +9 -0
  21. package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
  22. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +66 -21
  23. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +266 -18
  24. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
  25. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +38 -24
  26. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +240 -65
  27. package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +175 -48
  28. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +91 -10
  29. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Sales.tsx +479 -0
  30. package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +7 -3
  31. package/templates/src/constants.ts +2 -0
  32. package/templates/src/layouts/Layout.astro +26 -0
  33. package/templates/src/pages/api/auth/logout.ts +35 -2
  34. package/templates/src/pages/api/google/oauth/callback.ts +50 -0
  35. package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
  36. package/templates/src/pages/api/google/oauth/start.ts +32 -0
  37. package/templates/src/pages/api/google/oauth/status.ts +32 -0
  38. package/templates/src/pages/api/sales/list.ts +66 -0
  39. package/templates/src/pages/api/sales/metrics.ts +60 -0
  40. package/templates/src/pages/context/[...contextSlug].astro +50 -31
  41. package/templates/src/pages/privacy.astro +84 -0
  42. package/templates/src/pages/storykeep/advanced.astro +4 -1
  43. package/templates/src/pages/terms.astro +47 -0
  44. package/templates/src/stores/nodes.ts +8 -0
  45. package/templates/src/stores/shopify.ts +5 -0
  46. package/templates/src/types/tractstack.ts +87 -0
  47. package/templates/src/utils/api/advancedConfig.ts +2 -1
  48. package/templates/src/utils/api/advancedHelpers.ts +20 -0
  49. package/templates/src/utils/api/bookingHelpers.ts +3 -1
  50. package/templates/src/utils/api/brandConfig.ts +2 -0
  51. package/templates/src/utils/api/brandHelpers.ts +14 -1
  52. package/templates/src/utils/api/salesHelpers.ts +21 -0
  53. package/templates/src/utils/booking/appointmentMode.ts +135 -0
  54. package/templates/src/utils/customHelpers.ts +287 -2
  55. package/utils/inject-files.ts +47 -4
  56. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
  57. package/templates/src/utils/actions/actionButton.ts +0 -103
  58. package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
@@ -52,9 +52,12 @@ export default function ShopifyDashboard_Bookings({
52
52
  setBookings(response.data || []);
53
53
  setTotalCount(response.totalCount || 0);
54
54
 
55
+ const availabilityStart = new Date();
56
+ const availabilityEnd = new Date(availabilityStart);
57
+ availabilityEnd.setDate(availabilityEnd.getDate() + 30);
55
58
  const availability = await bookingHelpers.getAvailability(
56
- new Date().toISOString(),
57
- new Date().toISOString()
59
+ availabilityStart.toISOString(),
60
+ availabilityEnd.toISOString()
58
61
  );
59
62
  if (availability?.scheduling?.timezone) {
60
63
  setShopTimezone(availability.scheduling.timezone);
@@ -118,6 +121,26 @@ export default function ShopifyDashboard_Bookings({
118
121
  }
119
122
  };
120
123
 
124
+ const getModeColor = (mode?: string) => {
125
+ if (mode === 'REMOTE') return 'bg-violet-100 text-violet-800';
126
+ return 'bg-slate-100 text-slate-700';
127
+ };
128
+
129
+ const getSyncColor = (syncStatus?: string) => {
130
+ switch (syncStatus) {
131
+ case 'SYNCED':
132
+ case 'DELETE_SYNCED':
133
+ return 'bg-green-100 text-green-800';
134
+ case 'FAILED':
135
+ return 'bg-red-100 text-red-800';
136
+ case 'PENDING':
137
+ case 'DELETE_PENDING':
138
+ return 'bg-amber-100 text-amber-800';
139
+ default:
140
+ return 'bg-gray-100 text-gray-700';
141
+ }
142
+ };
143
+
121
144
  const todayStr = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`;
122
145
 
123
146
  const renderCustomerInfo = (booking: BookingEntity) => {
@@ -213,15 +236,31 @@ export default function ShopifyDashboard_Bookings({
213
236
  dayBookings.map((booking) => (
214
237
  <div
215
238
  key={booking.id}
216
- className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-colors hover:border-cyan-200"
239
+ className={`rounded-lg border p-4 shadow-sm transition-colors hover:border-cyan-200 ${
240
+ booking.appointmentMode === 'REMOTE'
241
+ ? 'border-violet-200 bg-violet-50'
242
+ : 'border-gray-200 bg-white'
243
+ }`}
217
244
  >
218
245
  <div className="flex items-start justify-between">
219
246
  <div className="space-y-1">
220
- <span
221
- className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getStatusColor(booking.status)}`}
222
- >
223
- {booking.status}
224
- </span>
247
+ <div className="flex flex-wrap items-center gap-2">
248
+ <span
249
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getStatusColor(booking.status)}`}
250
+ >
251
+ {booking.status}
252
+ </span>
253
+ <span
254
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getModeColor(booking.appointmentMode)}`}
255
+ >
256
+ {booking.appointmentMode || 'IN_PERSON'}
257
+ </span>
258
+ <span
259
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getSyncColor(booking.googleSyncStatus)}`}
260
+ >
261
+ {booking.googleSyncStatus || 'NOT_SYNCED'}
262
+ </span>
263
+ </div>
225
264
  <div className="text-sm font-bold text-gray-900">
226
265
  {new Date(booking.startTime).toLocaleTimeString(
227
266
  'en-US',
@@ -273,6 +312,21 @@ export default function ShopifyDashboard_Bookings({
273
312
  )
274
313
  .join(', ')}
275
314
  </div>
315
+ {booking.googleMeetURL && (
316
+ <a
317
+ className="text-cyan-700 underline"
318
+ href={booking.googleMeetURL}
319
+ target="_blank"
320
+ rel="noreferrer"
321
+ >
322
+ Open Meet Link
323
+ </a>
324
+ )}
325
+ {booking.googleLastError && (
326
+ <div className="text-xs font-bold text-red-700">
327
+ Google sync error: {booking.googleLastError}
328
+ </div>
329
+ )}
276
330
  </div>
277
331
  </div>
278
332
  ))
@@ -295,6 +349,9 @@ export default function ShopifyDashboard_Bookings({
295
349
  <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
296
350
  Status
297
351
  </th>
352
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
353
+ Mode / Sync
354
+ </th>
298
355
  <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
299
356
  Service(s)
300
357
  </th>
@@ -312,13 +369,13 @@ export default function ShopifyDashboard_Bookings({
312
369
  <tbody className="divide-y divide-gray-200 bg-white">
313
370
  {isLoading ? (
314
371
  <tr>
315
- <td colSpan={5} className="py-12 text-center">
372
+ <td colSpan={6} className="py-12 text-center">
316
373
  <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
317
374
  </td>
318
375
  </tr>
319
376
  ) : bookings.length === 0 ? (
320
377
  <tr>
321
- <td colSpan={5} className="py-12 text-center text-gray-500">
378
+ <td colSpan={6} className="py-12 text-center text-gray-500">
322
379
  No bookings found.
323
380
  </td>
324
381
  </tr>
@@ -334,6 +391,30 @@ export default function ShopifyDashboard_Bookings({
334
391
  {booking.status}
335
392
  </span>
336
393
  </td>
394
+ <td className="whitespace-nowrap px-6 py-4 text-xs">
395
+ <div className="flex flex-col gap-1">
396
+ <span
397
+ className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getModeColor(booking.appointmentMode)}`}
398
+ >
399
+ {booking.appointmentMode || 'IN_PERSON'}
400
+ </span>
401
+ <span
402
+ className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getSyncColor(booking.googleSyncStatus)}`}
403
+ >
404
+ {booking.googleSyncStatus || 'NOT_SYNCED'}
405
+ </span>
406
+ {booking.googleMeetURL && (
407
+ <a
408
+ className="text-cyan-700 underline"
409
+ href={booking.googleMeetURL}
410
+ target="_blank"
411
+ rel="noreferrer"
412
+ >
413
+ Meet link
414
+ </a>
415
+ )}
416
+ </div>
417
+ </td>
337
418
  <td className="px-6 py-4 text-sm text-gray-900">
338
419
  {booking.resourceIds
339
420
  .map((id) => resourceMap.get(id) || 'Unknown Service')
@@ -0,0 +1,479 @@
1
+ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
2
+ import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon';
3
+ import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon';
4
+ import { salesHelpers } from '@/utils/api/salesHelpers';
5
+ import type { SaleEntity, SaleProductLine } from '@/types/tractstack';
6
+ import type { ResourceNode } from '@/types/compositorTypes';
7
+ import {
8
+ getServiceLinkedProduct,
9
+ isSharedFeeService,
10
+ parsePrimaryShopifyProductData,
11
+ } from '@/utils/customHelpers';
12
+
13
+ interface ShopifyDashboardSalesProps {
14
+ existingResources: ResourceNode[];
15
+ }
16
+
17
+ const ITEMS_PER_PAGE = 10;
18
+
19
+ export default function ShopifyDashboard_Sales({
20
+ existingResources,
21
+ }: ShopifyDashboardSalesProps) {
22
+ const [sales, setSales] = useState<SaleEntity[]>([]);
23
+ const [totalCount, setTotalCount] = useState(0);
24
+ const [currentPage, setCurrentPage] = useState(0);
25
+ const [isLoading, setIsLoading] = useState(true);
26
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
27
+
28
+ const resourceById = useMemo(() => {
29
+ return new Map(
30
+ existingResources.map((resource) => [resource.id, resource])
31
+ );
32
+ }, [existingResources]);
33
+
34
+ const resourceBySlug = useMemo(() => {
35
+ return new Map(
36
+ existingResources.map((resource) => [resource.slug, resource])
37
+ );
38
+ }, [existingResources]);
39
+
40
+ const fetchSales = useCallback(async () => {
41
+ setIsLoading(true);
42
+ try {
43
+ const response = await salesHelpers.listSales(
44
+ ITEMS_PER_PAGE,
45
+ currentPage * ITEMS_PER_PAGE
46
+ );
47
+ setSales(response.data || []);
48
+ setTotalCount(response.totalCount || 0);
49
+ } catch (error) {
50
+ console.error('Failed to fetch sales:', error);
51
+ } finally {
52
+ setIsLoading(false);
53
+ }
54
+ }, [currentPage]);
55
+
56
+ useEffect(() => {
57
+ fetchSales();
58
+ }, [fetchSales]);
59
+
60
+ const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
61
+
62
+ const toggleExpanded = (saleId: string) => {
63
+ setExpandedRows((current) => {
64
+ const next = new Set(current);
65
+ if (next.has(saleId)) {
66
+ next.delete(saleId);
67
+ } else {
68
+ next.add(saleId);
69
+ }
70
+ return next;
71
+ });
72
+ };
73
+
74
+ const getStatusColor = (status: string) => {
75
+ if (status === 'PAID') return 'bg-green-100 text-green-800';
76
+ return 'bg-gray-100 text-gray-800';
77
+ };
78
+
79
+ const getTagColor = (tag: string) => {
80
+ switch (tag) {
81
+ case 'local-pickup':
82
+ return 'bg-cyan-100 text-cyan-800';
83
+ case 'orphan':
84
+ return 'bg-red-100 text-red-800';
85
+ case 'remote':
86
+ return 'bg-violet-100 text-violet-800';
87
+ case 'in-person':
88
+ return 'bg-slate-100 text-slate-700';
89
+ default:
90
+ return 'bg-gray-100 text-gray-700';
91
+ }
92
+ };
93
+
94
+ const formatMoney = (amount: string, currencyCode?: string) => {
95
+ const parsed = parseFloat(amount || '0');
96
+ const safeAmount = Number.isFinite(parsed) ? parsed : 0;
97
+ return `${safeAmount.toFixed(2)} ${currencyCode || 'USD'}`;
98
+ };
99
+
100
+ const formatLineTotal = (line: SaleProductLine) => {
101
+ const parsed = parseFloat(line.price || '0');
102
+ const safeAmount = Number.isFinite(parsed) ? parsed : 0;
103
+ return formatMoney(
104
+ (safeAmount * (line.quantity || 1)).toString(),
105
+ line.currencyCode
106
+ );
107
+ };
108
+
109
+ const productSummary = (sale: SaleEntity) => {
110
+ if (sale.products.length === 0) return 'No line items';
111
+ const first = sale.products[0];
112
+ const suffix =
113
+ sale.products.length > 1 ? `, +${sale.products.length - 1} more` : '';
114
+ return `${first.title} x${first.quantity || 1}${suffix}`;
115
+ };
116
+
117
+ const customerLabel = (sale: SaleEntity) => {
118
+ if (sale.leadName && sale.leadEmail) {
119
+ return `${sale.leadName} (${sale.leadEmail})`;
120
+ }
121
+ return sale.leadName || sale.leadEmail || 'Guest';
122
+ };
123
+
124
+ const variantTitle = (line: SaleProductLine) => {
125
+ const product = resourceById.get(line.resourceId);
126
+ const parsed = parsePrimaryShopifyProductData(product);
127
+ const variant = parsed?.variants?.find(
128
+ (v: any) => v?.id === line.variantId
129
+ );
130
+ return variant?.title && variant.title !== 'Default Title'
131
+ ? variant.title
132
+ : '';
133
+ };
134
+
135
+ const resolveBookingServices = (sale: SaleEntity) => {
136
+ const serviceMap = new Map<string, ResourceNode>();
137
+ const bookingResources =
138
+ sale.booking?.resourceIds
139
+ ?.map((id) => resourceById.get(id))
140
+ .filter((resource): resource is ResourceNode => Boolean(resource)) ||
141
+ [];
142
+
143
+ bookingResources.forEach((resource) => {
144
+ if (
145
+ resource.categorySlug === 'service' ||
146
+ resource.optionsPayload?.bookingLengthMinutes
147
+ ) {
148
+ serviceMap.set(resource.id, resource);
149
+ }
150
+
151
+ const boundSlug = resource.optionsPayload?.serviceBound;
152
+ if (typeof boundSlug === 'string' && boundSlug.trim()) {
153
+ const boundService = resourceBySlug.get(boundSlug);
154
+ if (boundService) {
155
+ serviceMap.set(boundService.id, boundService);
156
+ }
157
+ }
158
+ });
159
+
160
+ return Array.from(serviceMap.values()).sort((a, b) =>
161
+ a.title.localeCompare(b.title)
162
+ );
163
+ };
164
+
165
+ const renderSharedFeeBlock = (sale: SaleEntity) => {
166
+ if (!sale.booking) return null;
167
+ const services = resolveBookingServices(sale);
168
+ const sharedServices = services.filter((service) =>
169
+ isSharedFeeService(service, existingResources)
170
+ );
171
+ if (sharedServices.length === 0) return null;
172
+
173
+ const chargeLine = sale.products.find((line) => {
174
+ const product = resourceById.get(line.resourceId);
175
+ return product?.optionsPayload?.sharedServiceFee === true;
176
+ });
177
+
178
+ return (
179
+ <div className="rounded-md border border-cyan-100 bg-cyan-50 p-3">
180
+ <div className="text-xs font-bold uppercase text-cyan-700">
181
+ Shared service charge
182
+ </div>
183
+ <ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-cyan-950">
184
+ {sharedServices.map((service) => (
185
+ <li key={service.id}>{service.title}</li>
186
+ ))}
187
+ </ul>
188
+ {chargeLine && (
189
+ <div className="mt-3 border-t border-cyan-200 pt-2 text-sm font-bold text-cyan-950">
190
+ Charge: {formatLineTotal(chargeLine)}
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ };
196
+
197
+ const renderNonSharedServices = (sale: SaleEntity) => {
198
+ if (!sale.booking) return null;
199
+ const services = resolveBookingServices(sale).filter(
200
+ (service) => !isSharedFeeService(service, existingResources)
201
+ );
202
+ if (services.length === 0) return null;
203
+
204
+ return (
205
+ <div className="rounded-md border border-gray-200 bg-white p-3">
206
+ <div className="text-xs font-bold uppercase text-gray-500">
207
+ Service charges
208
+ </div>
209
+ <div className="mt-2 space-y-2">
210
+ {services.map((service) => {
211
+ const product = getServiceLinkedProduct(service, existingResources);
212
+ const line = sale.products.find(
213
+ (candidate) => candidate.gid === product?.optionsPayload?.gid
214
+ );
215
+ return (
216
+ <div
217
+ key={service.id}
218
+ className="flex justify-between gap-4 text-sm text-gray-700"
219
+ >
220
+ <span>{service.title}</span>
221
+ {line && (
222
+ <span className="font-bold">{formatLineTotal(line)}</span>
223
+ )}
224
+ </div>
225
+ );
226
+ })}
227
+ </div>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ const renderAppointment = (sale: SaleEntity) => {
233
+ if (!sale.booking) return null;
234
+ const services = resolveBookingServices(sale);
235
+ return (
236
+ <div className="rounded-md border border-gray-200 bg-white p-3">
237
+ <div className="text-xs font-bold uppercase text-gray-500">
238
+ Appointment
239
+ </div>
240
+ <div className="mt-2 grid gap-2 text-sm text-gray-700 md:grid-cols-2">
241
+ <div>
242
+ <span className="font-bold">Status:</span> {sale.booking.status}
243
+ </div>
244
+ <div>
245
+ <span className="font-bold">Mode:</span>{' '}
246
+ {sale.booking.appointmentMode || 'IN_PERSON'}
247
+ </div>
248
+ <div>
249
+ <span className="font-bold">Date:</span>{' '}
250
+ {new Date(sale.booking.startTime).toLocaleDateString()}
251
+ </div>
252
+ <div>
253
+ <span className="font-bold">Time:</span>{' '}
254
+ {new Date(sale.booking.startTime).toLocaleTimeString([], {
255
+ hour: '2-digit',
256
+ minute: '2-digit',
257
+ })}{' '}
258
+ -{' '}
259
+ {new Date(sale.booking.endTime).toLocaleTimeString([], {
260
+ hour: '2-digit',
261
+ minute: '2-digit',
262
+ })}
263
+ </div>
264
+ <div>
265
+ <span className="font-bold">Google sync:</span>{' '}
266
+ {sale.booking.googleSyncStatus || 'NOT_SYNCED'}
267
+ </div>
268
+ <div>
269
+ <span className="font-bold">Customer:</span> {customerLabel(sale)}
270
+ </div>
271
+ </div>
272
+ {services.length > 0 && (
273
+ <div className="mt-3 text-sm text-gray-700">
274
+ <span className="font-bold">Services:</span>{' '}
275
+ {services.map((service) => service.title).join(', ')}
276
+ </div>
277
+ )}
278
+ {sale.booking.googleMeetURL && (
279
+ <a
280
+ className="mt-2 inline-block text-sm text-cyan-700 underline"
281
+ href={sale.booking.googleMeetURL}
282
+ target="_blank"
283
+ rel="noreferrer"
284
+ >
285
+ Meet link
286
+ </a>
287
+ )}
288
+ </div>
289
+ );
290
+ };
291
+
292
+ const renderExpandedRow = (sale: SaleEntity) => (
293
+ <tr>
294
+ <td colSpan={5} className="bg-gray-50 px-6 py-4">
295
+ <div className="space-y-4">
296
+ <div className="rounded-md border border-gray-200 bg-white p-3">
297
+ <div className="text-xs font-bold uppercase text-gray-500">
298
+ Products
299
+ </div>
300
+ <div className="mt-2 divide-y divide-gray-100">
301
+ {sale.products.map((line) => {
302
+ const product = resourceById.get(line.resourceId);
303
+ const variant = variantTitle(line);
304
+ return (
305
+ <div
306
+ key={`${line.resourceId}-${line.variantId}`}
307
+ className="flex flex-wrap items-center justify-between gap-3 py-2 text-sm"
308
+ >
309
+ <div>
310
+ <div className="font-bold text-gray-900">
311
+ {line.title || product?.title || 'Product'}
312
+ </div>
313
+ {variant && (
314
+ <div className="text-xs text-gray-500">{variant}</div>
315
+ )}
316
+ {line.isLocalPickup && (
317
+ <span className="mt-1 inline-flex rounded-full bg-cyan-100 px-2 py-0.5 text-xs font-bold text-cyan-800">
318
+ Local pickup
319
+ </span>
320
+ )}
321
+ </div>
322
+ <div className="text-right">
323
+ <div className="font-bold text-gray-900">
324
+ {formatLineTotal(line)}
325
+ </div>
326
+ <div className="text-xs text-gray-500">
327
+ Qty {line.quantity || 1}
328
+ </div>
329
+ </div>
330
+ </div>
331
+ );
332
+ })}
333
+ </div>
334
+ </div>
335
+ {renderSharedFeeBlock(sale)}
336
+ {renderNonSharedServices(sale)}
337
+ {renderAppointment(sale)}
338
+ {sale.tags.includes('orphan') && (
339
+ <div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm font-bold text-red-800">
340
+ Orphaned payment: appointment payment received with no active
341
+ booking row.
342
+ </div>
343
+ )}
344
+ </div>
345
+ </td>
346
+ </tr>
347
+ );
348
+
349
+ return (
350
+ <div className="space-y-4">
351
+ <div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
352
+ <table className="min-w-full divide-y divide-gray-200">
353
+ <thead className="bg-gray-50">
354
+ <tr>
355
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
356
+ Sale
357
+ </th>
358
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
359
+ Status / Tags
360
+ </th>
361
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
362
+ Customer
363
+ </th>
364
+ <th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
365
+ Products
366
+ </th>
367
+ <th className="px-6 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-500">
368
+ Total
369
+ </th>
370
+ </tr>
371
+ </thead>
372
+ <tbody className="divide-y divide-gray-200 bg-white">
373
+ {isLoading ? (
374
+ <tr>
375
+ <td colSpan={5} className="py-12 text-center">
376
+ <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
377
+ </td>
378
+ </tr>
379
+ ) : sales.length === 0 ? (
380
+ <tr>
381
+ <td colSpan={5} className="py-12 text-center text-gray-500">
382
+ No sales found.
383
+ </td>
384
+ </tr>
385
+ ) : (
386
+ sales.map((sale) => {
387
+ const expanded = expandedRows.has(sale.id);
388
+ return (
389
+ <Fragment key={sale.id}>
390
+ <tr className="hover:bg-gray-50">
391
+ <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
392
+ <button
393
+ onClick={() => toggleExpanded(sale.id)}
394
+ className="inline-flex items-center gap-2 font-bold text-gray-900"
395
+ >
396
+ {expanded ? (
397
+ <ChevronDownIcon className="h-4 w-4" />
398
+ ) : (
399
+ <ChevronRightIcon className="h-4 w-4" />
400
+ )}
401
+ {new Date(sale.createdAt).toLocaleDateString()}
402
+ </button>
403
+ <div className="mt-1">
404
+ <a
405
+ href={`https://admin.shopify.com/orders/${sale.shopifyOrderId}`}
406
+ target="_blank"
407
+ rel="noopener noreferrer"
408
+ className="text-xs font-bold text-cyan-600 hover:text-cyan-800 hover:underline"
409
+ >
410
+ Order #{sale.shopifyOrderId}
411
+ </a>
412
+ </div>
413
+ </td>
414
+ <td className="px-6 py-4 text-xs">
415
+ <div className="flex flex-col gap-1">
416
+ <span
417
+ className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getStatusColor(
418
+ sale.status
419
+ )}`}
420
+ >
421
+ {sale.status}
422
+ </span>
423
+ <div className="flex flex-wrap gap-1">
424
+ {sale.tags.map((tag) => (
425
+ <span
426
+ key={tag}
427
+ className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getTagColor(
428
+ tag
429
+ )}`}
430
+ >
431
+ {tag}
432
+ </span>
433
+ ))}
434
+ </div>
435
+ </div>
436
+ </td>
437
+ <td className="px-6 py-4 text-sm text-gray-500">
438
+ {customerLabel(sale)}
439
+ </td>
440
+ <td className="px-6 py-4 text-sm text-gray-900">
441
+ {productSummary(sale)}
442
+ </td>
443
+ <td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold text-gray-900">
444
+ {formatMoney(sale.totalAmount)}
445
+ </td>
446
+ </tr>
447
+ {expanded && renderExpandedRow(sale)}
448
+ </Fragment>
449
+ );
450
+ })
451
+ )}
452
+ </tbody>
453
+ </table>
454
+ </div>
455
+
456
+ {totalPages > 1 && (
457
+ <div className="flex justify-center gap-2 pt-4">
458
+ <button
459
+ onClick={() => setCurrentPage((page) => page - 1)}
460
+ disabled={currentPage === 0 || isLoading}
461
+ className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
462
+ >
463
+ Previous
464
+ </button>
465
+ <span className="flex items-center text-sm text-gray-600">
466
+ Page {currentPage + 1} of {totalPages}
467
+ </span>
468
+ <button
469
+ onClick={() => setCurrentPage((page) => page + 1)}
470
+ disabled={currentPage === totalPages - 1 || isLoading}
471
+ className="rounded border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm hover:bg-gray-50 disabled:opacity-50"
472
+ >
473
+ Next
474
+ </button>
475
+ </div>
476
+ )}
477
+ </div>
478
+ );
479
+ }
@@ -7,19 +7,22 @@ import {
7
7
  } from '@/stores/shopify';
8
8
  import ProductTable from '@/components/storykeep/controls/content/ProductTable';
9
9
  import type { ResourceNode } from '@/types/compositorTypes';
10
+ import type { ShopifyLinkedStatus } from '@/components/storykeep/Dashboard_Shopify';
10
11
 
11
12
  interface ShopifyDashboardSearchProps {
12
- linkedResourceMap: Map<string, ResourceNode>;
13
+ linkedStatusMap: Map<string, ShopifyLinkedStatus>;
13
14
  onSelectProduct: (product: ShopifyProduct) => void;
14
15
  onLink: (product: ShopifyProduct) => void;
16
+ onMarkShared: (product: ShopifyProduct) => void;
15
17
  onUnlink: (resourceId: string) => void;
16
18
  onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
17
19
  }
18
20
 
19
21
  export default function ShopifyDashboard_Search({
20
- linkedResourceMap,
22
+ linkedStatusMap,
21
23
  onSelectProduct,
22
24
  onLink,
25
+ onMarkShared,
23
26
  onUnlink,
24
27
  onEdit,
25
28
  }: ShopifyDashboardSearchProps) {
@@ -42,11 +45,12 @@ export default function ShopifyDashboard_Search({
42
45
 
43
46
  <ProductTable
44
47
  products={data.products}
45
- linkedResourceMap={linkedResourceMap}
48
+ linkedStatusMap={linkedStatusMap}
46
49
  onRefresh={handleRefresh}
47
50
  isRefreshing={status.isLoading}
48
51
  onSelectProduct={onSelectProduct}
49
52
  onLink={onLink}
53
+ onMarkShared={onMarkShared}
50
54
  onUnlink={onUnlink}
51
55
  onEdit={onEdit}
52
56
  />
@@ -7,6 +7,8 @@ export const MAX_ANALYTICS_HOURS = 672;
7
7
 
8
8
  export const reservedSlugs = [
9
9
  `api`,
10
+ `privacy`,
11
+ `terms`,
10
12
  `collections`,
11
13
  `create`,
12
14
  `edit`,