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
@@ -11,6 +11,7 @@ import {
11
11
  import ResourceForm from './controls/content/ResourceForm';
12
12
  import { saveBrandConfigWithStateUpdate } from '@/utils/api/brandConfig';
13
13
  import {
14
+ createResource,
14
15
  deleteResource,
15
16
  getResource,
16
17
  getResourcesByCategory,
@@ -27,6 +28,7 @@ import ShopifyDashboard_Schedule from './shopify/ShopifyDashboard_Schedule';
27
28
  import ShopifyDashboard_Search from './shopify/ShopifyDashboard_Search';
28
29
  import ShopifyDashboard_Bookings from './shopify/ShopifyDashboard_Bookings';
29
30
  import ShopifyDashboard_Emails from './shopify/ShopifyDashboard_Emails';
31
+ import ShopifyDashboard_Sales from './shopify/ShopifyDashboard_Sales';
30
32
 
31
33
  interface DashboardShopifyProps {
32
34
  brandConfig: BrandConfig;
@@ -34,6 +36,12 @@ interface DashboardShopifyProps {
34
36
  }
35
37
 
36
38
  type MachineState = 'INIT' | 'CONFIG' | 'UPDATE' | 'READY';
39
+ type ImportCategory = 'product' | 'service' | 'shared';
40
+
41
+ export interface ShopifyLinkedStatus {
42
+ canonicalProduct: ResourceNode | null;
43
+ linkedServices: ResourceNode[];
44
+ }
37
45
 
38
46
  export default function StoryKeepDashboard_Shopify({
39
47
  brandConfig,
@@ -60,7 +68,7 @@ export default function StoryKeepDashboard_Shopify({
60
68
 
61
69
  const [showSmartCartWarning, setShowSmartCartWarning] = useState(false);
62
70
  const [pendingImport, setPendingImport] = useState<{
63
- category: string;
71
+ category: ImportCategory;
64
72
  product: ShopifyProduct;
65
73
  } | null>(null);
66
74
 
@@ -72,10 +80,56 @@ export default function StoryKeepDashboard_Shopify({
72
80
  const [wantService, setWantService] = useState(true);
73
81
  const [isSaving, setIsSaving] = useState(false);
74
82
 
83
+ useEffect(() => {
84
+ const hasOpenModal =
85
+ Boolean(selectedProduct) ||
86
+ (showTypeSelector && Boolean(targetProduct)) ||
87
+ (showSmartCartWarning && Boolean(pendingImport)) ||
88
+ (showResourceModal && Boolean(draftResource));
89
+ if (!hasOpenModal) return;
90
+
91
+ const handleKeyDown = (event: KeyboardEvent) => {
92
+ if (event.key !== 'Escape') return;
93
+
94
+ if (showResourceModal && draftResource) {
95
+ setShowResourceModal(false);
96
+ setIsCreateMode(true);
97
+ return;
98
+ }
99
+ if (showSmartCartWarning && pendingImport) {
100
+ setShowSmartCartWarning(false);
101
+ setPendingImport(null);
102
+ return;
103
+ }
104
+ if (showTypeSelector && targetProduct) {
105
+ setShowTypeSelector(false);
106
+ setTargetProduct(null);
107
+ return;
108
+ }
109
+ if (selectedProduct) {
110
+ setSelectedProduct(null);
111
+ }
112
+ };
113
+
114
+ window.addEventListener('keydown', handleKeyDown);
115
+ return () => {
116
+ window.removeEventListener('keydown', handleKeyDown);
117
+ };
118
+ }, [
119
+ selectedProduct,
120
+ showTypeSelector,
121
+ targetProduct,
122
+ showSmartCartWarning,
123
+ pendingImport,
124
+ showResourceModal,
125
+ draftResource,
126
+ ]);
127
+
75
128
  // Tab definitions
76
129
  const tabs = [
77
130
  { id: 'dashboards', name: 'Dashboard' },
78
131
  { id: 'bookings', name: 'Bookings' },
132
+ { id: 'sales', name: 'Sales' },
79
133
  { id: 'products', name: 'Products' },
80
134
  { id: 'services', name: 'Services' },
81
135
  { id: 'schedule', name: 'Schedule' },
@@ -97,12 +151,27 @@ export default function StoryKeepDashboard_Shopify({
97
151
  }
98
152
  }, [brandConfig]);
99
153
 
100
- const linkedResourceMap = useMemo(() => {
101
- const map = new Map<string, ResourceNode>();
102
- resources.forEach((r) => {
103
- if (r.optionsPayload?.gid) {
104
- map.set(r.optionsPayload.gid, r);
154
+ const linkedStatusMap = useMemo(() => {
155
+ const map = new Map<string, ShopifyLinkedStatus>();
156
+ resources.forEach((resource) => {
157
+ const gid =
158
+ typeof resource.optionsPayload?.gid === 'string'
159
+ ? resource.optionsPayload.gid
160
+ : '';
161
+ if (!gid) return;
162
+
163
+ const current = map.get(gid) || {
164
+ canonicalProduct: null,
165
+ linkedServices: [],
166
+ };
167
+
168
+ if (resource.categorySlug === 'product' && !current.canonicalProduct) {
169
+ current.canonicalProduct = resource;
170
+ }
171
+ if (resource.categorySlug === 'service') {
172
+ current.linkedServices.push(resource);
105
173
  }
174
+ map.set(gid, current);
106
175
  });
107
176
  return map;
108
177
  }, [resources]);
@@ -139,6 +208,8 @@ export default function StoryKeepDashboard_Shopify({
139
208
  if (hasProductSchema && hasServiceSchema) {
140
209
  setTargetProduct(product);
141
210
  setShowTypeSelector(true);
211
+ } else if (hasProductSchema) {
212
+ executePreFlightCheck('product', product);
142
213
  } else if (hasServiceSchema) {
143
214
  executePreFlightCheck('service', product);
144
215
  } else {
@@ -171,10 +242,17 @@ export default function StoryKeepDashboard_Shopify({
171
242
  }
172
243
  };
173
244
 
174
- const executePreFlightCheck = (category: string, product: ShopifyProduct) => {
245
+ const handleMarkShared = (product: ShopifyProduct) => {
246
+ void startCreateFlow('shared', product);
247
+ };
248
+
249
+ const executePreFlightCheck = (
250
+ category: ImportCategory,
251
+ product: ShopifyProduct
252
+ ) => {
175
253
  const hasMode = product.options.some((opt) => opt.name === 'Mode');
176
- if (category === 'service' || hasMode) {
177
- startCreateFlow(category, product);
254
+ if (category === 'service' || category === 'shared' || hasMode) {
255
+ void startCreateFlow(category, product);
178
256
  } else {
179
257
  setPendingImport({ category, product });
180
258
  setShowSmartCartWarning(true);
@@ -182,12 +260,112 @@ export default function StoryKeepDashboard_Shopify({
182
260
  }
183
261
  };
184
262
 
185
- const startCreateFlow = (category: string, product: ShopifyProduct) => {
186
- const schema = internalBrandConfig?.knownResources[category] || {};
263
+ const startCreateFlow = async (
264
+ category: ImportCategory,
265
+ product: ShopifyProduct
266
+ ) => {
267
+ const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
268
+ const productSchema = internalBrandConfig?.knownResources['product'] || {};
269
+ const existingCanonical = resources.find(
270
+ (r) =>
271
+ r.categorySlug === 'product' && r.optionsPayload?.gid === product.id
272
+ );
273
+
274
+ if (category === 'shared') {
275
+ if (existingCanonical) {
276
+ setDraftResource({
277
+ ...existingCanonical,
278
+ optionsPayload: {
279
+ ...(existingCanonical.optionsPayload || {}),
280
+ gid: product.id,
281
+ shopifyData:
282
+ existingCanonical.optionsPayload?.shopifyData ||
283
+ JSON.stringify(product),
284
+ sharedServiceFee: true,
285
+ },
286
+ });
287
+ setIsCreateMode(false);
288
+ } else {
289
+ const mergedOptions: Record<string, any> = {
290
+ gid: product.id,
291
+ shopifyData: JSON.stringify(product),
292
+ sharedServiceFee: true,
293
+ };
294
+
295
+ Object.entries(productSchema).forEach(([key, def]) => {
296
+ if (mergedOptions[key] !== undefined) return;
297
+ if (def.type === 'number') {
298
+ mergedOptions[key] = def.defaultValue ?? def.minNumber ?? 0;
299
+ } else if (def.type === 'boolean') {
300
+ mergedOptions[key] = def.defaultValue ?? false;
301
+ } else if (def.type === 'string') {
302
+ mergedOptions[key] = def.defaultValue ?? '';
303
+ } else if (def.type === 'multi') {
304
+ mergedOptions[key] = def.defaultValue ?? [];
305
+ }
306
+ });
307
+
308
+ setDraftResource({
309
+ title: product.title,
310
+ oneliner: product.description || '',
311
+ slug: `product-${product.handle}`.toLowerCase(),
312
+ categorySlug: 'product',
313
+ optionsPayload: mergedOptions,
314
+ });
315
+ setIsCreateMode(true);
316
+ }
317
+ setShowTypeSelector(false);
318
+ setShowResourceModal(true);
319
+ return;
320
+ }
321
+
322
+ if (category === 'service') {
323
+ if (!existingCanonical) {
324
+ const productOptions: Record<string, any> = {
325
+ gid: product.id,
326
+ shopifyData: JSON.stringify(product),
327
+ };
328
+
329
+ Object.entries(productSchema).forEach(([key, def]) => {
330
+ if (productOptions[key] !== undefined) return;
331
+ if (def.type === 'number') {
332
+ productOptions[key] = def.defaultValue ?? def.minNumber ?? 0;
333
+ } else if (def.type === 'boolean') {
334
+ productOptions[key] = def.defaultValue ?? false;
335
+ } else if (def.type === 'string') {
336
+ productOptions[key] = def.defaultValue ?? '';
337
+ } else if (def.type === 'multi') {
338
+ productOptions[key] = def.defaultValue ?? [];
339
+ }
340
+ });
341
+
342
+ try {
343
+ await createResource(tenantId, {
344
+ title: product.title,
345
+ oneliner: product.description || '',
346
+ slug: `product-${product.handle}`.toLowerCase(),
347
+ categorySlug: 'product',
348
+ optionsPayload: productOptions,
349
+ } as any);
350
+ await refreshResources();
351
+ } catch (error) {
352
+ console.error('Failed to ensure canonical product', error);
353
+ alert('Failed to create canonical product for this service.');
354
+ return;
355
+ }
356
+ }
357
+ }
358
+
359
+ const schema =
360
+ category === 'product'
361
+ ? internalBrandConfig?.knownResources['product'] || {}
362
+ : internalBrandConfig?.knownResources['service'] || {};
187
363
  const mergedOptions: Record<string, any> = {
188
364
  gid: product.id,
189
- shopifyData: JSON.stringify(product),
190
365
  };
366
+ if (category === 'product') {
367
+ mergedOptions.shopifyData = JSON.stringify(product);
368
+ }
191
369
 
192
370
  Object.entries(schema).forEach(([key, def]) => {
193
371
  if (mergedOptions[key] === undefined) {
@@ -226,11 +404,53 @@ export default function StoryKeepDashboard_Shopify({
226
404
  }
227
405
 
228
406
  try {
407
+ const target = resources.find((r) => r.id === resourceId);
408
+ const targetGid =
409
+ typeof target?.optionsPayload?.gid === 'string'
410
+ ? target.optionsPayload.gid
411
+ : '';
412
+
413
+ if (target?.categorySlug === 'product' && targetGid) {
414
+ const linkedServices = resources.filter(
415
+ (r) =>
416
+ r.categorySlug === 'service' && r.optionsPayload?.gid === targetGid
417
+ );
418
+ if (linkedServices.length > 0) {
419
+ alert(
420
+ 'Cannot delete a product linked to services. Delete linked services first.'
421
+ );
422
+ return;
423
+ }
424
+ }
425
+
229
426
  await deleteResource(
230
427
  window.TRACTSTACK_CONFIG?.tenantId || 'default',
231
428
  resourceId
232
429
  );
233
- setResources((prev) => prev.filter((r) => r.id !== resourceId));
430
+
431
+ if (target?.categorySlug === 'service' && targetGid) {
432
+ const remainingLinked = resources.filter(
433
+ (r) =>
434
+ r.id !== resourceId &&
435
+ r.categorySlug === 'service' &&
436
+ r.optionsPayload?.gid === targetGid
437
+ );
438
+ if (remainingLinked.length === 0) {
439
+ const canonicalProduct = resources.find(
440
+ (r) =>
441
+ r.categorySlug === 'product' &&
442
+ r.optionsPayload?.gid === targetGid
443
+ );
444
+ if (canonicalProduct) {
445
+ await deleteResource(
446
+ window.TRACTSTACK_CONFIG?.tenantId || 'default',
447
+ canonicalProduct.id
448
+ );
449
+ }
450
+ }
451
+ }
452
+
453
+ await refreshResources();
234
454
  } catch (error) {
235
455
  console.error('Unlink failed', error);
236
456
  alert('Failed to delete resource');
@@ -258,6 +478,11 @@ export default function StoryKeepDashboard_Shopify({
258
478
  gid: { type: 'string', optional: false },
259
479
  allowMultiple: { type: 'boolean', optional: false },
260
480
  group: { type: 'string', optional: true },
481
+ sharedServiceFee: {
482
+ type: 'boolean',
483
+ optional: false,
484
+ defaultValue: false,
485
+ },
261
486
  shopifyData: { type: 'string', optional: false },
262
487
  shopifyImage: { type: 'string', optional: true, defaultValue: '{}' },
263
488
  ...(wantService
@@ -276,8 +501,12 @@ export default function StoryKeepDashboard_Shopify({
276
501
  updatedKnownResources['service'] = {
277
502
  gid: { type: 'string', optional: true },
278
503
  group: { type: 'string', optional: true },
279
- shopifyData: { type: 'string', optional: true },
280
- shopifyImage: { type: 'string', optional: true, defaultValue: '{}' },
504
+ allowRemote: {
505
+ type: 'boolean',
506
+ optional: false,
507
+ defaultValue: false,
508
+ },
509
+ remoteOnly: { type: 'boolean', optional: false, defaultValue: false },
281
510
  bookingLengthMinutes: {
282
511
  type: 'number',
283
512
  optional: false,
@@ -473,6 +702,10 @@ export default function StoryKeepDashboard_Shopify({
473
702
  <ShopifyDashboard_Bookings existingResources={resources} />
474
703
  )}
475
704
 
705
+ {activeTab === 'sales' && (
706
+ <ShopifyDashboard_Sales existingResources={resources} />
707
+ )}
708
+
476
709
  {activeTab === 'products' && (
477
710
  <ShopifyDashboard_Products
478
711
  resources={resources}
@@ -497,9 +730,10 @@ export default function StoryKeepDashboard_Shopify({
497
730
 
498
731
  {activeTab === 'search' && (
499
732
  <ShopifyDashboard_Search
500
- linkedResourceMap={linkedResourceMap}
733
+ linkedStatusMap={linkedStatusMap}
501
734
  onSelectProduct={setSelectedProduct}
502
735
  onLink={handleLink}
736
+ onMarkShared={handleMarkShared}
503
737
  onUnlink={handleUnlink}
504
738
  onEdit={handleEditFromCatalog}
505
739
  />
@@ -568,7 +802,7 @@ export default function StoryKeepDashboard_Shopify({
568
802
  Import as...
569
803
  </h3>
570
804
  <p className="mt-2 text-sm text-gray-500">
571
- Should "{targetProduct.title}" be a Product or Service?
805
+ Choose import type for "{targetProduct.title}".
572
806
  </p>
573
807
  <div className="mt-6 flex flex-col gap-3">
574
808
  <button
@@ -579,6 +813,14 @@ export default function StoryKeepDashboard_Shopify({
579
813
  >
580
814
  Product
581
815
  </button>
816
+ <button
817
+ onClick={() =>
818
+ executePreFlightCheck('shared', targetProduct)
819
+ }
820
+ className="flex w-full items-center justify-center rounded-md border border-cyan-300 bg-cyan-50 px-4 py-2 text-sm font-bold text-cyan-700 shadow-sm hover:bg-cyan-100"
821
+ >
822
+ Shared Canonical Product
823
+ </button>
582
824
  <button
583
825
  onClick={() =>
584
826
  executePreFlightCheck('service', targetProduct)
@@ -612,7 +854,7 @@ export default function StoryKeepDashboard_Shopify({
612
854
  <button
613
855
  onClick={() => {
614
856
  setShowSmartCartWarning(false);
615
- startCreateFlow(
857
+ void startCreateFlow(
616
858
  pendingImport.category,
617
859
  pendingImport.product
618
860
  );
@@ -651,7 +893,13 @@ export default function StoryKeepDashboard_Shopify({
651
893
  draftResource.categorySlug || ''
652
894
  ] || {}
653
895
  }
896
+ tenantRemoteOnly={Boolean(
897
+ internalBrandConfig?.scheduling?.remoteOnly
898
+ )}
654
899
  isCreate={isCreateMode}
900
+ onOpenLinkedProduct={(resourceId) => {
901
+ void handleEditResource(resourceId);
902
+ }}
655
903
  onClose={(saved) => {
656
904
  setShowResourceModal(false);
657
905
  setIsCreateMode(true);
@@ -278,6 +278,7 @@ const ManageContent = ({
278
278
  fullContentMap={currentContentMap}
279
279
  categorySlug={activeResourceForm.category}
280
280
  categorySchema={knownResources[activeResourceForm.category] || {}}
281
+ tenantRemoteOnly={Boolean(brandConfig?.SCHEDULING?.remoteOnly)}
281
282
  onClose={async (saved: boolean) => {
282
283
  setActiveResourceForm(null);
283
284
  if (saved) await refreshData();
@@ -15,23 +15,26 @@ import {
15
15
  type ShopifyProduct,
16
16
  } from '@/stores/shopify';
17
17
  import type { ResourceNode } from '@/types/compositorTypes';
18
+ import type { ShopifyLinkedStatus } from '@/components/storykeep/Dashboard_Shopify';
18
19
 
19
20
  interface ProductTableProps {
20
21
  products: ShopifyProduct[];
21
- linkedResourceMap: Map<string, ResourceNode>;
22
+ linkedStatusMap: Map<string, ShopifyLinkedStatus>;
22
23
  onRefresh: () => void;
23
24
  isRefreshing: boolean;
24
25
  onSelectProduct: (product: ShopifyProduct) => void;
25
26
  onLink: (product: ShopifyProduct) => void;
27
+ onMarkShared: (product: ShopifyProduct) => void;
26
28
  onUnlink: (resourceId: string) => void;
27
29
  onEdit: (product: ShopifyProduct, resource: ResourceNode) => void;
28
30
  }
29
31
 
30
32
  export default function ProductTable({
31
33
  products,
32
- linkedResourceMap,
34
+ linkedStatusMap,
33
35
  onSelectProduct,
34
36
  onLink,
37
+ onMarkShared,
35
38
  onUnlink,
36
39
  onEdit,
37
40
  }: ProductTableProps) {
@@ -203,8 +206,10 @@ export default function ProductTable({
203
206
  </tr>
204
207
  ) : (
205
208
  products.map((product) => {
206
- const linkedResource = linkedResourceMap.get(product.id);
207
- const isLinked = !!linkedResource;
209
+ const linkedStatus = linkedStatusMap.get(product.id);
210
+ const canonicalProduct = linkedStatus?.canonicalProduct || null;
211
+ const linkedServiceCount =
212
+ linkedStatus?.linkedServices.length || 0;
208
213
 
209
214
  return (
210
215
  <tr key={product.id} className="hover:bg-gray-50">
@@ -215,22 +220,24 @@ export default function ProductTable({
215
220
  >
216
221
  {product.title}
217
222
  </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}
223
+ {canonicalProduct && (
224
+ <div className="mt-1 flex flex-wrap items-center gap-1">
225
+ <span className="inline-flex items-center rounded-full bg-cyan-50 px-2 py-0.5 text-xs font-bold text-cyan-700 ring-1 ring-inset ring-cyan-600/20">
226
+ Synced canonical:{' '}
227
+ <span
228
+ className="ml-1 max-w-xs truncate"
229
+ title={canonicalProduct.title}
230
+ >
231
+ {canonicalProduct.title}
232
+ </span>
232
233
  </span>
233
- </span>
234
+ {linkedServiceCount > 0 && (
235
+ <span className="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-bold text-indigo-700 ring-1 ring-inset ring-indigo-600/20">
236
+ {linkedServiceCount} service
237
+ {linkedServiceCount === 1 ? '' : 's'}
238
+ </span>
239
+ )}
240
+ </div>
234
241
  )}
235
242
  </td>
236
243
  <td
@@ -241,12 +248,12 @@ export default function ProductTable({
241
248
  </td>
242
249
  <td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold">
243
250
  <div className="flex items-center justify-end space-x-2">
244
- {isLinked ? (
251
+ {canonicalProduct ? (
245
252
  <>
246
253
  <button
247
- onClick={() => onEdit(product, linkedResource)}
254
+ onClick={() => onEdit(product, canonicalProduct)}
248
255
  className="text-cyan-600 hover:text-cyan-900"
249
- title="Edit Resource"
256
+ title="Edit Canonical Product"
250
257
  >
251
258
  <PencilIcon
252
259
  className="h-5 w-5"
@@ -257,9 +264,16 @@ export default function ProductTable({
257
264
  </span>
258
265
  </button>
259
266
  <button
260
- onClick={() => onUnlink(linkedResource.id)}
267
+ onClick={() => onMarkShared(product)}
268
+ className="rounded border border-cyan-200 bg-cyan-50 px-2 py-0.5 text-xs font-bold text-cyan-700 hover:bg-cyan-100"
269
+ title="Tag Canonical Product as Shared"
270
+ >
271
+ Shared
272
+ </button>
273
+ <button
274
+ onClick={() => onUnlink(canonicalProduct.id)}
261
275
  className="text-red-600 hover:text-red-900"
262
- title="Unlink Resource"
276
+ title="Unlink Canonical Product"
263
277
  >
264
278
  <TrashIcon
265
279
  className="h-5 w-5"