@hed-hog/catalog 0.0.293 → 0.0.294

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 (45) hide show
  1. package/README.md +409 -379
  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 +1111 -5
  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 +133 -99
  21. package/hedhog/data/route.yaml +72 -8
  22. package/hedhog/frontend/app/[resource]/page.tsx.ejs +391 -33
  23. package/hedhog/frontend/app/_components/catalog-ai-form-assist-dialog.tsx.ejs +340 -0
  24. package/hedhog/frontend/app/_components/catalog-resource-form-sheet.tsx.ejs +907 -92
  25. package/hedhog/frontend/app/_lib/catalog-resources.tsx.ejs +929 -1161
  26. package/hedhog/frontend/messages/en.json +389 -299
  27. package/hedhog/frontend/messages/pt.json +389 -299
  28. package/hedhog/table/catalog_attribute.yaml +67 -52
  29. package/hedhog/table/catalog_attribute_option.yaml +40 -0
  30. package/hedhog/table/catalog_category.yaml +40 -0
  31. package/hedhog/table/catalog_category_attribute.yaml +37 -31
  32. package/hedhog/table/catalog_comparison.yaml +19 -22
  33. package/hedhog/table/catalog_product.yaml +30 -28
  34. package/hedhog/table/catalog_product_attribute_value.yaml +44 -31
  35. package/hedhog/table/catalog_product_category.yaml +13 -13
  36. package/hedhog/table/catalog_score_criterion.yaml +42 -25
  37. package/hedhog/table/catalog_seo_page_rule.yaml +10 -10
  38. package/hedhog/table/catalog_similarity_rule.yaml +33 -20
  39. package/hedhog/table/catalog_site.yaml +21 -13
  40. package/hedhog/table/catalog_site_category.yaml +12 -12
  41. package/package.json +6 -6
  42. package/src/catalog-resource.config.ts +132 -105
  43. package/src/catalog.controller.ts +91 -24
  44. package/src/catalog.module.ts +16 -12
  45. package/src/catalog.service.ts +1569 -56
@@ -16,8 +16,8 @@ import {
16
16
  SearchBar,
17
17
  StatsCards,
18
18
  } from '@/components/entity-list';
19
- import {
20
- AlertDialog,
19
+ import {
20
+ AlertDialog,
21
21
  AlertDialogAction,
22
22
  AlertDialogCancel,
23
23
  AlertDialogContent,
@@ -25,11 +25,19 @@ import {
25
25
  AlertDialogFooter,
26
26
  AlertDialogHeader,
27
27
  AlertDialogTitle,
28
- } from '@/components/ui/alert-dialog';
29
- import { Button } from '@/components/ui/button';
30
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
31
- import {
32
- Table,
28
+ } from '@/components/ui/alert-dialog';
29
+ import { Badge } from '@/components/ui/badge';
30
+ import { Button } from '@/components/ui/button';
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';
39
+ import {
40
+ Table,
33
41
  TableBody,
34
42
  TableCell,
35
43
  TableHead,
@@ -57,13 +65,327 @@ type ResourceStats = {
57
65
  active?: number;
58
66
  };
59
67
 
60
- type CatalogRecord = Record<string, unknown>;
61
-
62
- function humanizeValue(value: string) {
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
+ };
83
+
84
+ function humanizeValue(value: string) {
63
85
  return value
64
86
  .replace(/[_-]+/g, ' ')
65
- .replace(/\b\w/g, (char) => char.toUpperCase());
66
- }
87
+ .replace(/\b\w/g, (char) => char.toUpperCase());
88
+ }
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
+ }
67
389
 
68
390
  export default function CatalogResourcePage() {
69
391
  const params = useParams<{ resource: string }>();
@@ -76,9 +398,10 @@ export default function CatalogResourcePage() {
76
398
  const [filterValue, setFilterValue] = useState('all');
77
399
  const [page, setPage] = useState(1);
78
400
  const [pageSize, setPageSize] = useState(12);
79
- const [sheetOpen, setSheetOpen] = useState(false);
80
- const [deleteId, setDeleteId] = useState<number | null>(null);
81
- const [editingRecordId, setEditingRecordId] = useState<number | null>(null);
401
+ const [sheetOpen, setSheetOpen] = useState(false);
402
+ const [deleteId, setDeleteId] = useState<number | null>(null);
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');
@@ -359,10 +682,14 @@ export default function CatalogResourcePage() {
359
682
  setSheetOpen(true);
360
683
  };
361
684
 
362
- const openEdit = (record: CatalogRecord) => {
363
- setEditingRecordId(Number(record.id));
364
- setSheetOpen(true);
365
- };
685
+ const openEdit = (record: CatalogRecord) => {
686
+ setEditingRecordId(Number(record.id));
687
+ setSheetOpen(true);
688
+ };
689
+
690
+ const openView = (record: CatalogRecord) => {
691
+ setViewingRecordId(Number(record.id));
692
+ };
366
693
 
367
694
  const handleDelete = async () => {
368
695
  if (!deleteId) {
@@ -526,11 +853,20 @@ export default function CatalogResourcePage() {
526
853
  </TableCell>
527
854
  ))}
528
855
  <TableCell className="text-right">
529
- <div className="flex justify-end gap-2">
530
- <Button
531
- variant="outline"
532
- size="icon"
533
- onClick={() => openEdit(record)}
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}
866
+ <Button
867
+ variant="outline"
868
+ size="icon"
869
+ onClick={() => openEdit(record)}
534
870
  >
535
871
  <Pencil className="size-4" />
536
872
  </Button>
@@ -573,11 +909,21 @@ export default function CatalogResourcePage() {
573
909
  description={getCardDescription(record)}
574
910
  badges={getBadges(record)}
575
911
  metadata={getCardMetadata(record)}
576
- actions={[
577
- {
578
- label: t('edit'),
579
- onClick: () => openEdit(record),
580
- variant: 'outline',
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
+ : []),
923
+ {
924
+ label: t('edit'),
925
+ onClick: () => openEdit(record),
926
+ variant: 'outline',
581
927
  icon: <Pencil className="size-4" />,
582
928
  },
583
929
  {
@@ -604,7 +950,7 @@ export default function CatalogResourcePage() {
604
950
  </div>
605
951
  )}
606
952
  </div>
607
- <CatalogResourceFormSheet
953
+ <CatalogResourceFormSheet
608
954
  open={sheetOpen}
609
955
  onOpenChange={(open) => {
610
956
  setSheetOpen(open);
@@ -621,9 +967,21 @@ export default function CatalogResourcePage() {
621
967
  setEditingRecordId(null);
622
968
  await refetch();
623
969
  }}
624
- />
625
-
626
- <AlertDialog
970
+ />
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
+
984
+ <AlertDialog
627
985
  open={deleteId !== null}
628
986
  onOpenChange={(open) => !open && setDeleteId(null)}
629
987
  >