@hed-hog/catalog 0.0.293 → 0.0.295

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 (66) hide show
  1. package/README.md +391 -361
  2. package/dist/catalog-resource.config.d.ts.map +1 -1
  3. package/dist/catalog-resource.config.js +51 -24
  4. package/dist/catalog-resource.config.js.map +1 -1
  5. package/dist/catalog.controller.d.ts +420 -0
  6. package/dist/catalog.controller.d.ts.map +1 -1
  7. package/dist/catalog.controller.js +98 -0
  8. package/dist/catalog.controller.js.map +1 -1
  9. package/dist/catalog.module.d.ts.map +1 -1
  10. package/dist/catalog.module.js +5 -1
  11. package/dist/catalog.module.js.map +1 -1
  12. package/dist/catalog.service.d.ts +216 -1
  13. package/dist/catalog.service.d.ts.map +1 -1
  14. package/dist/catalog.service.js +1121 -7
  15. package/dist/catalog.service.js.map +1 -1
  16. package/hedhog/data/catalog_attribute.yaml +202 -0
  17. package/hedhog/data/catalog_attribute_option.yaml +109 -0
  18. package/hedhog/data/catalog_category.yaml +47 -0
  19. package/hedhog/data/catalog_category_attribute.yaml +209 -0
  20. package/hedhog/data/menu.yaml +46 -12
  21. package/hedhog/data/role.yaml +7 -7
  22. package/hedhog/data/route.yaml +64 -0
  23. package/hedhog/frontend/app/[resource]/page.tsx.ejs +358 -0
  24. package/hedhog/frontend/app/_components/catalog-ai-form-assist-dialog.tsx.ejs +340 -0
  25. package/hedhog/frontend/app/_components/catalog-resource-form-sheet.tsx.ejs +815 -0
  26. package/hedhog/frontend/app/_lib/catalog-resources.tsx.ejs +504 -736
  27. package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -83
  28. package/hedhog/frontend/messages/en.json +150 -60
  29. package/hedhog/frontend/messages/pt.json +185 -95
  30. package/hedhog/table/catalog_affiliate_program.yaml +41 -41
  31. package/hedhog/table/catalog_attribute.yaml +22 -7
  32. package/hedhog/table/catalog_attribute_group.yaml +18 -18
  33. package/hedhog/table/catalog_attribute_option.yaml +40 -0
  34. package/hedhog/table/catalog_brand.yaml +34 -34
  35. package/hedhog/table/catalog_category.yaml +40 -0
  36. package/hedhog/table/catalog_category_attribute.yaml +13 -7
  37. package/hedhog/table/catalog_click_event.yaml +50 -50
  38. package/hedhog/table/catalog_comparison.yaml +3 -6
  39. package/hedhog/table/catalog_comparison_highlight.yaml +39 -39
  40. package/hedhog/table/catalog_comparison_item.yaml +30 -30
  41. package/hedhog/table/catalog_content_relation.yaml +42 -42
  42. package/hedhog/table/catalog_import_run.yaml +33 -33
  43. package/hedhog/table/catalog_import_source.yaml +24 -24
  44. package/hedhog/table/catalog_merchant.yaml +29 -29
  45. package/hedhog/table/catalog_offer.yaml +83 -83
  46. package/hedhog/table/catalog_price_history.yaml +34 -34
  47. package/hedhog/table/catalog_product.yaml +5 -3
  48. package/hedhog/table/catalog_product_attribute_value.yaml +15 -2
  49. package/hedhog/table/catalog_product_category.yaml +3 -3
  50. package/hedhog/table/catalog_product_image.yaml +34 -34
  51. package/hedhog/table/catalog_product_score.yaml +38 -38
  52. package/hedhog/table/catalog_product_site.yaml +47 -47
  53. package/hedhog/table/catalog_product_tag.yaml +19 -19
  54. package/hedhog/table/catalog_score_criterion.yaml +25 -8
  55. package/hedhog/table/catalog_seo_page_rule.yaml +2 -2
  56. package/hedhog/table/catalog_similarity_rule.yaml +19 -6
  57. package/hedhog/table/catalog_site.yaml +8 -0
  58. package/hedhog/table/catalog_site_category.yaml +3 -3
  59. package/package.json +7 -7
  60. package/src/catalog-resource.config.ts +51 -24
  61. package/src/catalog.controller.ts +67 -0
  62. package/src/catalog.module.ts +5 -1
  63. package/src/catalog.service.ts +1531 -6
  64. package/src/index.ts +1 -1
  65. package/src/language/en.json +4 -4
  66. package/src/language/pt.json +4 -4
@@ -28,6 +28,23 @@
28
28
  - where:
29
29
  slug: admin-catalog
30
30
 
31
+ - menu_id:
32
+ where:
33
+ slug: /catalog
34
+ icon: folders
35
+ url: /catalog/categories
36
+ name:
37
+ en: Categories
38
+ pt: Categorias
39
+ slug: /catalog/categories
40
+ order: 72
41
+ relations:
42
+ role:
43
+ - where:
44
+ slug: admin
45
+ - where:
46
+ slug: admin-catalog
47
+
31
48
  - menu_id:
32
49
  where:
33
50
  slug: /catalog
@@ -37,7 +54,7 @@
37
54
  en: Brands
38
55
  pt: Marcas
39
56
  slug: /catalog/brands
40
- order: 72
57
+ order: 73
41
58
  relations:
42
59
  role:
43
60
  - where:
@@ -54,7 +71,7 @@
54
71
  en: Sites
55
72
  pt: Sites
56
73
  slug: /catalog/sites
57
- order: 73
74
+ order: 74
58
75
  relations:
59
76
  role:
60
77
  - where:
@@ -71,7 +88,7 @@
71
88
  en: Products
72
89
  pt: Produtos
73
90
  slug: /catalog/products
74
- order: 74
91
+ order: 75
75
92
  relations:
76
93
  role:
77
94
  - where:
@@ -88,7 +105,7 @@
88
105
  en: Attribute Groups
89
106
  pt: Grupos de atributos
90
107
  slug: /catalog/attribute-groups
91
- order: 75
108
+ order: 76
92
109
  relations:
93
110
  role:
94
111
  - where:
@@ -105,7 +122,24 @@
105
122
  en: Attributes
106
123
  pt: Atributos
107
124
  slug: /catalog/attributes
108
- order: 76
125
+ order: 77
126
+ relations:
127
+ role:
128
+ - where:
129
+ slug: admin
130
+ - where:
131
+ slug: admin-catalog
132
+
133
+ - menu_id:
134
+ where:
135
+ slug: /catalog
136
+ icon: list-filter
137
+ url: /catalog/attribute-options
138
+ name:
139
+ en: Attribute Options
140
+ pt: Opcoes de atributos
141
+ slug: /catalog/attribute-options
142
+ order: 78
109
143
  relations:
110
144
  role:
111
145
  - where:
@@ -122,7 +156,7 @@
122
156
  en: Category Attributes
123
157
  pt: Atributos por categoria
124
158
  slug: /catalog/category-attributes
125
- order: 77
159
+ order: 79
126
160
  relations:
127
161
  role:
128
162
  - where:
@@ -139,7 +173,7 @@
139
173
  en: Comparisons
140
174
  pt: Comparacoes
141
175
  slug: /catalog/comparisons
142
- order: 78
176
+ order: 80
143
177
  relations:
144
178
  role:
145
179
  - where:
@@ -156,7 +190,7 @@
156
190
  en: Offers
157
191
  pt: Ofertas
158
192
  slug: /catalog/offers
159
- order: 79
193
+ order: 81
160
194
  relations:
161
195
  role:
162
196
  - where:
@@ -173,7 +207,7 @@
173
207
  en: Merchants
174
208
  pt: Lojistas
175
209
  slug: /catalog/merchants
176
- order: 80
210
+ order: 82
177
211
  relations:
178
212
  role:
179
213
  - where:
@@ -190,7 +224,7 @@
190
224
  en: Affiliate Programs
191
225
  pt: Programas de afiliacao
192
226
  slug: /catalog/affiliate-programs
193
- order: 81
227
+ order: 83
194
228
  relations:
195
229
  role:
196
230
  - where:
@@ -207,7 +241,7 @@
207
241
  en: SEO Rules
208
242
  pt: Regras de SEO
209
243
  slug: /catalog/seo-rules
210
- order: 82
244
+ order: 84
211
245
  relations:
212
246
  role:
213
247
  - where:
@@ -224,7 +258,7 @@
224
258
  en: Import Sources
225
259
  pt: Fontes de importacao
226
260
  slug: /catalog/import-sources
227
- order: 83
261
+ order: 85
228
262
  relations:
229
263
  role:
230
264
  - where:
@@ -1,7 +1,7 @@
1
- - slug: admin-catalog
2
- name:
3
- en: Catalog Administrator
4
- pt: Administrador de Catálogo
5
- description:
6
- en: Administrator with access to catalog, comparisons, offers and SEO rules.
7
- pt: Administrador com acesso ao catálogo, comparações, ofertas e regras de SEO.
1
+ - slug: admin-catalog
2
+ name:
3
+ en: Catalog Administrator
4
+ pt: Administrador de Catálogo
5
+ description:
6
+ en: Administrator with access to catalog, comparisons, offers and SEO rules.
7
+ pt: Administrador com acesso ao catálogo, comparações, ofertas e regras de SEO.
@@ -54,3 +54,67 @@
54
54
  slug: admin
55
55
  - where:
56
56
  slug: admin-catalog
57
+ - url: /catalog/categories/tree
58
+ method: GET
59
+ relations:
60
+ role:
61
+ - where:
62
+ slug: admin
63
+ - where:
64
+ slug: admin-catalog
65
+ - url: /catalog/categories/:id/attributes
66
+ method: GET
67
+ relations:
68
+ role:
69
+ - where:
70
+ slug: admin
71
+ - where:
72
+ slug: admin-catalog
73
+ - url: /catalog/products/:id/attributes
74
+ method: GET
75
+ relations:
76
+ role:
77
+ - where:
78
+ slug: admin
79
+ - where:
80
+ slug: admin-catalog
81
+ - url: /catalog/products/:id/structured
82
+ method: GET
83
+ relations:
84
+ role:
85
+ - where:
86
+ slug: admin
87
+ - where:
88
+ slug: admin-catalog
89
+ - url: /catalog/products/:id/comparison-payload
90
+ method: GET
91
+ relations:
92
+ role:
93
+ - where:
94
+ slug: admin
95
+ - where:
96
+ slug: admin-catalog
97
+ - url: /catalog/forms/:resource/ai-assist
98
+ method: POST
99
+ relations:
100
+ role:
101
+ - where:
102
+ slug: admin
103
+ - where:
104
+ slug: admin-catalog
105
+ - url: /catalog/products/:id/attributes
106
+ method: PUT
107
+ relations:
108
+ role:
109
+ - where:
110
+ slug: admin
111
+ - where:
112
+ slug: admin-catalog
113
+ - url: /catalog/products/:id/materialize-snapshots
114
+ method: POST
115
+ relations:
116
+ role:
117
+ - where:
118
+ slug: admin
119
+ - where:
120
+ slug: admin-catalog
@@ -26,8 +26,16 @@ import {
26
26
  AlertDialogHeader,
27
27
  AlertDialogTitle,
28
28
  } from '@/components/ui/alert-dialog';
29
+ import { Badge } from '@/components/ui/badge';
29
30
  import { Button } from '@/components/ui/button';
30
31
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
32
+ import {
33
+ Dialog,
34
+ DialogContent,
35
+ DialogDescription,
36
+ DialogHeader,
37
+ DialogTitle,
38
+ } from '@/components/ui/dialog';
31
39
  import {
32
40
  Table,
33
41
  TableBody,
@@ -58,6 +66,20 @@ type ResourceStats = {
58
66
  };
59
67
 
60
68
  type CatalogRecord = Record<string, unknown>;
69
+ type ProductAttributePayload = {
70
+ groups?: Array<{
71
+ id: number | null;
72
+ name: string;
73
+ slug: string;
74
+ attributes: Array<
75
+ CatalogRecord & {
76
+ options?: CatalogRecord[];
77
+ value?: CatalogRecord | null;
78
+ category_attribute?: CatalogRecord;
79
+ }
80
+ >;
81
+ }>;
82
+ };
61
83
 
62
84
  function humanizeValue(value: string) {
63
85
  return value
@@ -65,6 +87,306 @@ function humanizeValue(value: string) {
65
87
  .replace(/\b\w/g, (char) => char.toUpperCase());
66
88
  }
67
89
 
90
+ function CatalogProductPreviewDialog({
91
+ open,
92
+ onOpenChange,
93
+ productId,
94
+ }: {
95
+ open: boolean;
96
+ onOpenChange: (open: boolean) => void;
97
+ productId: number | null;
98
+ }) {
99
+ const { request, currentLocaleCode } = useApp();
100
+ const t = useTranslations('catalog');
101
+ const isPt = currentLocaleCode?.startsWith('pt');
102
+ const { data: product, isLoading: isLoadingProduct } = useQuery<CatalogRecord | null>({
103
+ queryKey: ['catalog-product-preview', productId],
104
+ queryFn: async () => {
105
+ if (!productId) {
106
+ return null;
107
+ }
108
+
109
+ const response = await request({
110
+ url: `/catalog/products/${productId}`,
111
+ });
112
+
113
+ return (response.data ?? null) as CatalogRecord | null;
114
+ },
115
+ enabled: open && Boolean(productId),
116
+ });
117
+ const { data: attributes, isLoading: isLoadingAttributes } =
118
+ useQuery<ProductAttributePayload | null>({
119
+ queryKey: ['catalog-product-preview-attributes', productId],
120
+ queryFn: async () => {
121
+ if (!productId) {
122
+ return null;
123
+ }
124
+
125
+ const response = await request({
126
+ url: `/catalog/products/${productId}/attributes`,
127
+ });
128
+
129
+ return (response.data ?? null) as ProductAttributePayload | null;
130
+ },
131
+ enabled: open && Boolean(productId),
132
+ });
133
+
134
+ const formatAttributeValue = (attribute: CatalogRecord & {
135
+ options?: CatalogRecord[];
136
+ value?: CatalogRecord | null;
137
+ }) => {
138
+ const value = (attribute.value ?? null) as CatalogRecord | null;
139
+
140
+ if (!value) {
141
+ return '-';
142
+ }
143
+
144
+ const dataType = String(attribute.data_type ?? 'text');
145
+
146
+ if (dataType === 'option') {
147
+ const option = (attribute.options ?? []).find(
148
+ (item) => Number(item.id ?? 0) === Number(value.attribute_option_id ?? 0),
149
+ );
150
+ return String(option?.label ?? option?.option_value ?? value.value_text ?? '-');
151
+ }
152
+
153
+ if (dataType === 'number') {
154
+ const numberValue =
155
+ value.value_number === null || value.value_number === undefined
156
+ ? null
157
+ : Number(value.value_number);
158
+
159
+ if (numberValue === null) {
160
+ return '-';
161
+ }
162
+
163
+ const unit = String(value.value_unit ?? attribute.unit ?? '').trim();
164
+ return unit ? `${numberValue} ${unit}` : String(numberValue);
165
+ }
166
+
167
+ if (dataType === 'boolean') {
168
+ if (value.value_boolean === null || value.value_boolean === undefined) {
169
+ return '-';
170
+ }
171
+
172
+ return value.value_boolean
173
+ ? t('resource.booleans.true')
174
+ : t('resource.booleans.false');
175
+ }
176
+
177
+ return String(value.value_text ?? '-');
178
+ };
179
+
180
+ const renderJsonBlock = (value: unknown) => {
181
+ if (!value || (typeof value === 'object' && Object.keys(value as object).length === 0)) {
182
+ return null;
183
+ }
184
+
185
+ return (
186
+ <pre className="overflow-x-auto rounded-md border bg-muted/30 p-3 text-xs">
187
+ {JSON.stringify(value, null, 2)}
188
+ </pre>
189
+ );
190
+ };
191
+
192
+ const specSnapshotBlock = renderJsonBlock(product?.spec_snapshot_json);
193
+ const comparisonSnapshotBlock = renderJsonBlock(product?.comparison_snapshot_json);
194
+
195
+ return (
196
+ <Dialog open={open} onOpenChange={onOpenChange}>
197
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
198
+ <DialogHeader>
199
+ <DialogTitle>
200
+ {String(product?.name ?? (isPt ? 'Visualizar produto' : 'View product'))}
201
+ </DialogTitle>
202
+ <DialogDescription>
203
+ {isPt
204
+ ? 'Dados básicos, atributos técnicos por grupo, imagens e snapshots secundários.'
205
+ : 'Basic data, technical attributes by group, images, and secondary snapshots.'}
206
+ </DialogDescription>
207
+ </DialogHeader>
208
+
209
+ {isLoadingProduct || isLoadingAttributes ? (
210
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
211
+ <Eye className="size-4" />
212
+ {isPt ? 'Carregando visualização do produto...' : 'Loading product preview...'}
213
+ </div>
214
+ ) : !product ? (
215
+ <p className="text-sm text-muted-foreground">
216
+ {isPt ? 'Produto não encontrado.' : 'Product not found.'}
217
+ </p>
218
+ ) : (
219
+ <div className="space-y-6">
220
+ <Card>
221
+ <CardHeader>
222
+ <CardTitle>{isPt ? 'Dados básicos do produto' : 'Product basics'}</CardTitle>
223
+ </CardHeader>
224
+ <CardContent className="grid gap-4 sm:grid-cols-2">
225
+ <div>
226
+ <div className="text-xs text-muted-foreground">
227
+ {t('resource.fields.brandId')}
228
+ </div>
229
+ <div className="font-medium">
230
+ {String(product.brand_name ?? product.brand_id ?? '-')}
231
+ </div>
232
+ </div>
233
+ <div>
234
+ <div className="text-xs text-muted-foreground">
235
+ {t('resource.fields.catalogCategoryId')}
236
+ </div>
237
+ <div className="font-medium">
238
+ {String(product.category_name ?? product.catalog_category_id ?? '-')}
239
+ </div>
240
+ </div>
241
+ <div>
242
+ <div className="text-xs text-muted-foreground">{t('resource.fields.status')}</div>
243
+ <div className="font-medium">
244
+ {String(product.status ?? '-')}
245
+ </div>
246
+ </div>
247
+ <div>
248
+ <div className="text-xs text-muted-foreground">{t('resource.fields.isActive')}</div>
249
+ <div className="font-medium">
250
+ {product.is_active === true
251
+ ? t('resource.booleans.true')
252
+ : t('resource.booleans.false')}
253
+ </div>
254
+ </div>
255
+ <div>
256
+ <div className="text-xs text-muted-foreground">{t('resource.fields.sku')}</div>
257
+ <div className="font-medium">{String(product.sku ?? '-')}</div>
258
+ </div>
259
+ <div>
260
+ <div className="text-xs text-muted-foreground">GTIN</div>
261
+ <div className="font-medium">{String(product.gtin ?? '-')}</div>
262
+ </div>
263
+ </CardContent>
264
+ </Card>
265
+
266
+ <Card>
267
+ <CardHeader>
268
+ <CardTitle>{isPt ? 'Atributos técnicos' : 'Technical attributes'}</CardTitle>
269
+ <CardDescription>
270
+ {isPt
271
+ ? 'Organizados pelos grupos configurados na categoria do produto.'
272
+ : 'Organized by the groups configured for the product category.'}
273
+ </CardDescription>
274
+ </CardHeader>
275
+ <CardContent className="space-y-6">
276
+ {!(attributes?.groups?.length) ? (
277
+ <p className="text-sm text-muted-foreground">
278
+ {isPt
279
+ ? 'Nenhum atributo técnico configurado para este produto.'
280
+ : 'No technical attributes configured for this product.'}
281
+ </p>
282
+ ) : (
283
+ attributes.groups.map((group) => (
284
+ <div key={`${group.slug}-${group.id ?? 'general'}`} className="space-y-3">
285
+ <div className="text-sm font-semibold">{group.name}</div>
286
+ <div className="grid gap-3 sm:grid-cols-2">
287
+ {group.attributes.map((attribute) => {
288
+ const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
289
+ const badges = [
290
+ relation.is_highlight
291
+ ? isPt
292
+ ? 'Destaque'
293
+ : 'Highlight'
294
+ : null,
295
+ relation.is_filter_visible
296
+ ? isPt
297
+ ? 'Filtro'
298
+ : 'Filter'
299
+ : null,
300
+ relation.is_comparison_visible
301
+ ? isPt
302
+ ? 'Comparação'
303
+ : 'Comparison'
304
+ : null,
305
+ ].filter((item): item is string => Boolean(item));
306
+
307
+ return (
308
+ <div key={String(attribute.id)} className="rounded-lg border p-3">
309
+ <div className="flex items-start justify-between gap-3">
310
+ <div className="space-y-1">
311
+ <div className="font-medium">
312
+ {String(attribute.name ?? attribute.label ?? attribute.slug)}
313
+ </div>
314
+ <div className="text-sm text-muted-foreground">
315
+ {formatAttributeValue(attribute)}
316
+ </div>
317
+ </div>
318
+ {badges.length ? (
319
+ <div className="flex flex-wrap justify-end gap-2">
320
+ {badges.map((badge) => (
321
+ <Badge key={`${attribute.id}-${badge}`} variant="secondary">
322
+ {badge}
323
+ </Badge>
324
+ ))}
325
+ </div>
326
+ ) : null}
327
+ </div>
328
+ </div>
329
+ );
330
+ })}
331
+ </div>
332
+ </div>
333
+ ))
334
+ )}
335
+ </CardContent>
336
+ </Card>
337
+
338
+ {Array.isArray(product.catalog_product_image) &&
339
+ product.catalog_product_image.length > 0 ? (
340
+ <Card>
341
+ <CardHeader>
342
+ <CardTitle>{isPt ? 'Imagens do produto' : 'Product images'}</CardTitle>
343
+ </CardHeader>
344
+ <CardContent className="grid gap-3 sm:grid-cols-2">
345
+ {product.catalog_product_image.map((image) => (
346
+ <div
347
+ key={String(image.id)}
348
+ className="rounded-lg border p-3 text-sm"
349
+ >
350
+ <div className="font-medium">
351
+ #{String(image.id)} · {String(image.role ?? 'gallery')}
352
+ </div>
353
+ <div className="text-muted-foreground">
354
+ File #{String(image.file_id ?? '-')}
355
+ </div>
356
+ </div>
357
+ ))}
358
+ </CardContent>
359
+ </Card>
360
+ ) : null}
361
+
362
+ <Card>
363
+ <CardHeader>
364
+ <CardTitle>{isPt ? 'Snapshots secundários' : 'Secondary snapshots'}</CardTitle>
365
+ <CardDescription>
366
+ {isPt
367
+ ? 'Mantidos como apoio para cache e leituras legadas.'
368
+ : 'Kept as support for cache and legacy reads.'}
369
+ </CardDescription>
370
+ </CardHeader>
371
+ <CardContent className="space-y-4">
372
+ {specSnapshotBlock}
373
+ {comparisonSnapshotBlock}
374
+ {!specSnapshotBlock && !comparisonSnapshotBlock ? (
375
+ <p className="text-sm text-muted-foreground">
376
+ {isPt
377
+ ? 'Nenhum snapshot secundário disponível.'
378
+ : 'No secondary snapshots available.'}
379
+ </p>
380
+ ) : null}
381
+ </CardContent>
382
+ </Card>
383
+ </div>
384
+ )}
385
+ </DialogContent>
386
+ </Dialog>
387
+ );
388
+ }
389
+
68
390
  export default function CatalogResourcePage() {
69
391
  const params = useParams<{ resource: string }>();
70
392
  const resource = params?.resource ?? '';
@@ -79,6 +401,7 @@ export default function CatalogResourcePage() {
79
401
  const [sheetOpen, setSheetOpen] = useState(false);
80
402
  const [deleteId, setDeleteId] = useState<number | null>(null);
81
403
  const [editingRecordId, setEditingRecordId] = useState<number | null>(null);
404
+ const [viewingRecordId, setViewingRecordId] = useState<number | null>(null);
82
405
  const resourceTitle = config
83
406
  ? t(`resources.${config.translationKey}.title`)
84
407
  : t('unsupportedResource');
@@ -364,6 +687,10 @@ export default function CatalogResourcePage() {
364
687
  setSheetOpen(true);
365
688
  };
366
689
 
690
+ const openView = (record: CatalogRecord) => {
691
+ setViewingRecordId(Number(record.id));
692
+ };
693
+
367
694
  const handleDelete = async () => {
368
695
  if (!deleteId) {
369
696
  return;
@@ -527,6 +854,15 @@ export default function CatalogResourcePage() {
527
854
  ))}
528
855
  <TableCell className="text-right">
529
856
  <div className="flex justify-end gap-2">
857
+ {resource === 'products' ? (
858
+ <Button
859
+ variant="outline"
860
+ size="icon"
861
+ onClick={() => openView(record)}
862
+ >
863
+ <Eye className="size-4" />
864
+ </Button>
865
+ ) : null}
530
866
  <Button
531
867
  variant="outline"
532
868
  size="icon"
@@ -574,6 +910,16 @@ export default function CatalogResourcePage() {
574
910
  badges={getBadges(record)}
575
911
  metadata={getCardMetadata(record)}
576
912
  actions={[
913
+ ...(resource === 'products'
914
+ ? [
915
+ {
916
+ label: t('openResource'),
917
+ onClick: () => openView(record),
918
+ variant: 'outline' as const,
919
+ icon: <Eye className="size-4" />,
920
+ },
921
+ ]
922
+ : []),
577
923
  {
578
924
  label: t('edit'),
579
925
  onClick: () => openEdit(record),
@@ -623,6 +969,18 @@ export default function CatalogResourcePage() {
623
969
  }}
624
970
  />
625
971
 
972
+ {resource === 'products' ? (
973
+ <CatalogProductPreviewDialog
974
+ open={viewingRecordId !== null}
975
+ onOpenChange={(open) => {
976
+ if (!open) {
977
+ setViewingRecordId(null);
978
+ }
979
+ }}
980
+ productId={viewingRecordId}
981
+ />
982
+ ) : null}
983
+
626
984
  <AlertDialog
627
985
  open={deleteId !== null}
628
986
  onOpenChange={(open) => !open && setDeleteId(null)}