@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
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { RichTextEditor } from '@/components/rich-text-editor';
4
+ import { Badge } from '@/components/ui/badge';
4
5
  import { Button } from '@/components/ui/button';
5
6
  import {
6
7
  Command,
@@ -52,6 +53,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
52
53
  import { useForm } from 'react-hook-form';
53
54
  import { toast } from 'sonner';
54
55
  import { z } from 'zod';
56
+ import { CatalogAiFormAssistDialog } from './catalog-ai-form-assist-dialog';
55
57
  import {
56
58
  catalogResourceMap,
57
59
  getCatalogLocalizedText,
@@ -63,6 +65,40 @@ import {
63
65
  type CatalogRecord = Record<string, unknown>;
64
66
  type CatalogFormValues = Record<string, unknown>;
65
67
  type RelationOption = { id: number; label: string };
68
+ type ProductAttributeMetadata = CatalogRecord & {
69
+ id: number;
70
+ data_type?: string;
71
+ unit?: string | null;
72
+ options?: CatalogRecord[];
73
+ category_attribute?: CatalogRecord;
74
+ };
75
+ type ProductAttributeDraft = {
76
+ attribute_id: number;
77
+ data_type: string;
78
+ attribute_option_id: number | null;
79
+ value_text: string;
80
+ value_number: number | null;
81
+ value_boolean: boolean | null;
82
+ value_unit: string;
83
+ source_type: string;
84
+ confidence_score: number | null;
85
+ is_verified: boolean;
86
+ };
87
+ type CatalogAttributePayload = {
88
+ category?: CatalogRecord;
89
+ groups?: Array<{
90
+ id: number | null;
91
+ name: string;
92
+ slug: string;
93
+ attributes: Array<
94
+ CatalogRecord & {
95
+ options?: CatalogRecord[];
96
+ value?: CatalogRecord | null;
97
+ category_attribute?: CatalogRecord;
98
+ }
99
+ >;
100
+ }>;
101
+ };
66
102
  type FormSheetProps = {
67
103
  open: boolean;
68
104
  onOpenChange: (open: boolean) => void;
@@ -225,6 +261,25 @@ function buildDefaultValues(
225
261
  return defaults;
226
262
  }
227
263
 
264
+ function buildCurrentFormPayload(
265
+ resourceConfig: CatalogResourceDefinition,
266
+ values: CatalogFormValues,
267
+ ) {
268
+ const payload: CatalogRecord = {};
269
+
270
+ for (const section of resourceConfig.formSections) {
271
+ for (const field of section.fields) {
272
+ try {
273
+ payload[field.key] = normalizeSubmitValue(field, values[field.key]);
274
+ } catch {
275
+ payload[field.key] = values[field.key] ?? null;
276
+ }
277
+ }
278
+ }
279
+
280
+ return payload;
281
+ }
282
+
228
283
  function normalizeSubmitValue(
229
284
  field: CatalogFormFieldDefinition,
230
285
  rawValue: unknown
@@ -271,6 +326,225 @@ function normalizeSubmitValue(
271
326
  return rawValue;
272
327
  }
273
328
 
329
+ function buildAttributeDrafts(payload?: CatalogAttributePayload | null) {
330
+ const drafts: Record<number, ProductAttributeDraft> = {};
331
+
332
+ for (const group of payload?.groups ?? []) {
333
+ for (const attribute of group.attributes ?? []) {
334
+ const attributeId = Number(attribute.id ?? 0);
335
+ const value = (attribute.value ?? null) as CatalogRecord | null;
336
+
337
+ if (!attributeId) {
338
+ continue;
339
+ }
340
+
341
+ drafts[attributeId] = {
342
+ attribute_id: attributeId,
343
+ data_type: String(attribute.data_type ?? 'text'),
344
+ attribute_option_id:
345
+ value?.attribute_option_id === null ||
346
+ value?.attribute_option_id === undefined
347
+ ? null
348
+ : Number(value.attribute_option_id),
349
+ value_text: String(value?.value_text ?? ''),
350
+ value_number:
351
+ value?.value_number === null || value?.value_number === undefined
352
+ ? null
353
+ : Number(value.value_number),
354
+ value_boolean:
355
+ typeof value?.value_boolean === 'boolean' ? value.value_boolean : null,
356
+ value_unit: String(value?.value_unit ?? attribute.unit ?? ''),
357
+ source_type: String(value?.source_type ?? 'manual'),
358
+ confidence_score:
359
+ value?.confidence_score === null ||
360
+ value?.confidence_score === undefined
361
+ ? null
362
+ : Number(value.confidence_score),
363
+ is_verified: Boolean(value?.is_verified ?? false),
364
+ };
365
+ }
366
+ }
367
+
368
+ return drafts;
369
+ }
370
+
371
+ function createEmptyProductAttributeDraft(
372
+ attributeId: number,
373
+ dataType = 'text',
374
+ unit = '',
375
+ ): ProductAttributeDraft {
376
+ return {
377
+ attribute_id: attributeId,
378
+ data_type: dataType,
379
+ attribute_option_id: null,
380
+ value_text: '',
381
+ value_number: null,
382
+ value_boolean: null,
383
+ value_unit: unit,
384
+ source_type: 'manual',
385
+ confidence_score: null,
386
+ is_verified: false,
387
+ };
388
+ }
389
+
390
+ function normalizeProductAttributeDraft(
391
+ attribute: ProductAttributeMetadata,
392
+ draft?: ProductAttributeDraft,
393
+ ) {
394
+ const dataType = String(attribute.data_type ?? draft?.data_type ?? 'text');
395
+ const normalized = {
396
+ ...(draft ?? createEmptyProductAttributeDraft(Number(attribute.id), dataType, String(attribute.unit ?? ''))),
397
+ attribute_id: Number(attribute.id),
398
+ data_type: dataType,
399
+ value_unit: String(draft?.value_unit ?? attribute.unit ?? ''),
400
+ } satisfies ProductAttributeDraft;
401
+
402
+ if (dataType === 'option') {
403
+ return {
404
+ ...normalized,
405
+ value_text: '',
406
+ value_number: null,
407
+ value_boolean: null,
408
+ value_unit: '',
409
+ };
410
+ }
411
+
412
+ if (dataType === 'number') {
413
+ return {
414
+ ...normalized,
415
+ attribute_option_id: null,
416
+ value_text: '',
417
+ value_boolean: null,
418
+ };
419
+ }
420
+
421
+ if (dataType === 'boolean') {
422
+ return {
423
+ ...normalized,
424
+ attribute_option_id: null,
425
+ value_text: '',
426
+ value_number: null,
427
+ value_unit: '',
428
+ };
429
+ }
430
+
431
+ return {
432
+ ...normalized,
433
+ attribute_option_id: null,
434
+ value_number: null,
435
+ value_boolean: null,
436
+ value_unit: dataType === 'text' || dataType === 'long_text' ? '' : normalized.value_unit,
437
+ };
438
+ }
439
+
440
+ function hasMeaningfulProductAttributeValue(
441
+ attribute: ProductAttributeMetadata,
442
+ draft: ProductAttributeDraft,
443
+ ) {
444
+ const dataType = String(attribute.data_type ?? draft.data_type ?? 'text');
445
+
446
+ if (dataType === 'option') {
447
+ return draft.attribute_option_id !== null && draft.attribute_option_id !== undefined;
448
+ }
449
+
450
+ if (dataType === 'number') {
451
+ return draft.value_number !== null && draft.value_number !== undefined;
452
+ }
453
+
454
+ if (dataType === 'boolean') {
455
+ return draft.value_boolean !== null && draft.value_boolean !== undefined;
456
+ }
457
+
458
+ return String(draft.value_text ?? '').trim().length > 0;
459
+ }
460
+
461
+ function buildProductAttributeSubmission(
462
+ payload: CatalogAttributePayload | null,
463
+ drafts: Record<number, ProductAttributeDraft>,
464
+ localeCode?: string | null,
465
+ ) {
466
+ const errors: Record<number, string> = {};
467
+ const values: Array<Record<string, unknown>> = [];
468
+ const isPt = localeCode?.startsWith('pt');
469
+
470
+ for (const group of payload?.groups ?? []) {
471
+ for (const attribute of group.attributes as ProductAttributeMetadata[]) {
472
+ const attributeId = Number(attribute.id ?? 0);
473
+
474
+ if (!attributeId) {
475
+ continue;
476
+ }
477
+
478
+ const normalized = normalizeProductAttributeDraft(attribute, drafts[attributeId]);
479
+ const hasValue = hasMeaningfulProductAttributeValue(attribute, normalized);
480
+ const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
481
+ const optionIds = new Set(
482
+ (attribute.options ?? [])
483
+ .map((option) => Number(option.id ?? 0))
484
+ .filter((value) => value > 0),
485
+ );
486
+ const attributeLabel = String(
487
+ attribute.name ?? attribute.label ?? attribute.slug ?? `#${attributeId}`,
488
+ );
489
+
490
+ if (
491
+ normalized.attribute_option_id !== null &&
492
+ normalized.attribute_option_id !== undefined &&
493
+ !optionIds.has(Number(normalized.attribute_option_id))
494
+ ) {
495
+ errors[attributeId] = isPt
496
+ ? `Selecione uma opção válida para ${attributeLabel}.`
497
+ : `Select a valid option for ${attributeLabel}.`;
498
+ continue;
499
+ }
500
+
501
+ if (relation.is_required === true && !hasValue) {
502
+ errors[attributeId] = isPt
503
+ ? `${attributeLabel} é obrigatório para esta categoria.`
504
+ : `${attributeLabel} is required for this category.`;
505
+ continue;
506
+ }
507
+
508
+ if (!hasValue) {
509
+ continue;
510
+ }
511
+
512
+ values.push({
513
+ attribute_id: attributeId,
514
+ attribute_option_id: normalized.attribute_option_id,
515
+ value_text: String(normalized.value_text ?? '').trim() || null,
516
+ value_number: normalized.value_number,
517
+ value_boolean: normalized.value_boolean,
518
+ value_unit: String(normalized.value_unit ?? '').trim() || null,
519
+ source_type: normalized.source_type,
520
+ confidence_score: normalized.confidence_score,
521
+ is_verified: normalized.is_verified,
522
+ });
523
+ }
524
+ }
525
+
526
+ return {
527
+ errors,
528
+ values,
529
+ };
530
+ }
531
+
532
+ function buildCurrentProductAttributeValues(
533
+ drafts: Record<number, ProductAttributeDraft>,
534
+ ) {
535
+ return Object.values(drafts).map((draft) => ({
536
+ attribute_id: draft.attribute_id,
537
+ attribute_option_id: draft.attribute_option_id,
538
+ value_text: draft.value_text,
539
+ value_number: draft.value_number,
540
+ value_boolean: draft.value_boolean,
541
+ value_unit: draft.value_unit,
542
+ source_type: draft.source_type,
543
+ confidence_score: draft.confidence_score,
544
+ is_verified: draft.is_verified,
545
+ }));
546
+ }
547
+
274
548
  function extractRecordId(record: CatalogRecord) {
275
549
  return Number(record.id ?? record.category_id ?? record.content_id ?? 0);
276
550
  }
@@ -661,6 +935,383 @@ function CatalogUploadField({
661
935
  );
662
936
  }
663
937
 
938
+ function ProductAttributesSection({
939
+ open,
940
+ recordId,
941
+ categoryId,
942
+ initialCategoryId,
943
+ values,
944
+ onChange,
945
+ errors,
946
+ onSchemaChange,
947
+ }: {
948
+ open: boolean;
949
+ recordId: number | null;
950
+ categoryId: number | null;
951
+ initialCategoryId: number | null;
952
+ values: Record<number, ProductAttributeDraft>;
953
+ onChange: (next: Record<number, ProductAttributeDraft>) => void;
954
+ errors: Record<number, string>;
955
+ onSchemaChange: (payload: CatalogAttributePayload | null) => void;
956
+ }) {
957
+ const { request, currentLocaleCode } = useApp();
958
+ const loadExistingValues =
959
+ Boolean(recordId) && categoryId !== null && categoryId === initialCategoryId;
960
+
961
+ const { data, isLoading } = useQuery<CatalogAttributePayload | null>({
962
+ queryKey: [
963
+ 'catalog-product-attributes-editor',
964
+ recordId,
965
+ categoryId,
966
+ initialCategoryId,
967
+ ],
968
+ queryFn: async () => {
969
+ if (!categoryId) {
970
+ return null;
971
+ }
972
+
973
+ const response = await request({
974
+ url: loadExistingValues
975
+ ? `/catalog/products/${recordId}/attributes`
976
+ : `/catalog/categories/${categoryId}/attributes`,
977
+ method: 'GET',
978
+ });
979
+
980
+ return (response.data ?? null) as CatalogAttributePayload | null;
981
+ },
982
+ enabled: open && Boolean(categoryId),
983
+ });
984
+
985
+ useEffect(() => {
986
+ onSchemaChange(data ?? null);
987
+ }, [data, onSchemaChange]);
988
+
989
+ useEffect(() => {
990
+ if (!categoryId) {
991
+ onSchemaChange(null);
992
+ onChange({});
993
+ return;
994
+ }
995
+
996
+ onChange(buildAttributeDrafts(data));
997
+ }, [categoryId, data, onChange, onSchemaChange]);
998
+
999
+ const updateDraft = (attributeId: number, patch: Partial<ProductAttributeDraft>) => {
1000
+ const baseDraft = values[attributeId] ?? createEmptyProductAttributeDraft(attributeId);
1001
+ onChange({
1002
+ ...values,
1003
+ [attributeId]: {
1004
+ ...baseDraft,
1005
+ ...patch,
1006
+ },
1007
+ });
1008
+ };
1009
+
1010
+ if (!categoryId) {
1011
+ return (
1012
+ <section className="space-y-2 rounded-xl border border-dashed p-4">
1013
+ <h3 className="text-sm font-semibold">
1014
+ {currentLocaleCode?.startsWith('pt')
1015
+ ? 'Atributos estruturados'
1016
+ : 'Structured attributes'}
1017
+ </h3>
1018
+ <p className="text-sm text-muted-foreground">
1019
+ {currentLocaleCode?.startsWith('pt')
1020
+ ? 'Selecione uma categoria para carregar os atributos do produto.'
1021
+ : 'Select a category to load the product attributes.'}
1022
+ </p>
1023
+ </section>
1024
+ );
1025
+ }
1026
+
1027
+ return (
1028
+ <section className="space-y-4 rounded-xl border bg-muted/10 p-4">
1029
+ <div className="space-y-1">
1030
+ <h3 className="text-sm font-semibold">
1031
+ {currentLocaleCode?.startsWith('pt')
1032
+ ? 'Atributos estruturados'
1033
+ : 'Structured attributes'}
1034
+ </h3>
1035
+ <p className="text-sm text-muted-foreground">
1036
+ {currentLocaleCode?.startsWith('pt')
1037
+ ? 'A categoria define os campos comparáveis e filtráveis do produto.'
1038
+ : 'The category defines the product fields used for comparison and filters.'}
1039
+ </p>
1040
+ </div>
1041
+
1042
+ {isLoading ? (
1043
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
1044
+ <Loader2 className="h-4 w-4 animate-spin" />
1045
+ {currentLocaleCode?.startsWith('pt')
1046
+ ? 'Carregando atributos...'
1047
+ : 'Loading attributes...'}
1048
+ </div>
1049
+ ) : !(data?.groups?.length) ? (
1050
+ <p className="text-sm text-muted-foreground">
1051
+ {currentLocaleCode?.startsWith('pt')
1052
+ ? 'Nenhum atributo configurado para esta categoria.'
1053
+ : 'No attributes configured for this category.'}
1054
+ </p>
1055
+ ) : (
1056
+ data.groups.map((group) => (
1057
+ <div key={`${group.slug}-${group.id ?? 'general'}`} className="space-y-3">
1058
+ <div>
1059
+ <h4 className="text-sm font-medium">{group.name}</h4>
1060
+ </div>
1061
+
1062
+ <div className="grid gap-4">
1063
+ {group.attributes.map((attribute) => {
1064
+ const attributeId = Number(attribute.id);
1065
+ const draft = normalizeProductAttributeDraft(
1066
+ attribute as ProductAttributeMetadata,
1067
+ values[attributeId],
1068
+ );
1069
+ const dataType = String(attribute.data_type ?? 'text');
1070
+ const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
1071
+ const attributeBadges = [
1072
+ relation.is_required
1073
+ ? currentLocaleCode?.startsWith('pt')
1074
+ ? 'Obrigatório'
1075
+ : 'Required'
1076
+ : null,
1077
+ relation.is_highlight
1078
+ ? currentLocaleCode?.startsWith('pt')
1079
+ ? 'Destaque'
1080
+ : 'Highlight'
1081
+ : null,
1082
+ relation.is_filter_visible
1083
+ ? currentLocaleCode?.startsWith('pt')
1084
+ ? 'Filtro'
1085
+ : 'Filter'
1086
+ : null,
1087
+ relation.is_comparison_visible
1088
+ ? currentLocaleCode?.startsWith('pt')
1089
+ ? 'Comparação'
1090
+ : 'Comparison'
1091
+ : null,
1092
+ ].filter((item): item is string => Boolean(item));
1093
+
1094
+ return (
1095
+ <div key={attributeId} className="rounded-lg border bg-background p-3">
1096
+ <div className="mb-3 space-y-1">
1097
+ <div className="flex items-center justify-between gap-3">
1098
+ <div className="space-y-1">
1099
+ <div className="font-medium">
1100
+ {String(attribute.name ?? attribute.label ?? attribute.slug)}
1101
+ {relation.is_required ? (
1102
+ <span className="ml-1 text-destructive">*</span>
1103
+ ) : null}
1104
+ </div>
1105
+ {attributeBadges.length ? (
1106
+ <div className="flex flex-wrap gap-2">
1107
+ {attributeBadges.map((badge) => (
1108
+ <Badge key={`${attributeId}-${badge}`} variant="secondary">
1109
+ {badge}
1110
+ </Badge>
1111
+ ))}
1112
+ </div>
1113
+ ) : null}
1114
+ </div>
1115
+ {attribute.unit ? (
1116
+ <span className="text-xs text-muted-foreground">
1117
+ {String(attribute.unit)}
1118
+ </span>
1119
+ ) : null}
1120
+ </div>
1121
+ {attribute.description ? (
1122
+ <p className="text-xs text-muted-foreground">
1123
+ {String(attribute.description)}
1124
+ </p>
1125
+ ) : null}
1126
+ {errors[attributeId] ? (
1127
+ <p className="text-xs font-medium text-destructive">
1128
+ {errors[attributeId]}
1129
+ </p>
1130
+ ) : null}
1131
+ </div>
1132
+
1133
+ <div className="grid gap-3 sm:grid-cols-2">
1134
+ {dataType === 'option' ? (
1135
+ <div className="space-y-2 sm:col-span-2">
1136
+ <FormLabel>
1137
+ {currentLocaleCode?.startsWith('pt') ? 'Opção' : 'Option'}
1138
+ </FormLabel>
1139
+ <Select
1140
+ value={
1141
+ draft?.attribute_option_id
1142
+ ? String(draft.attribute_option_id)
1143
+ : ''
1144
+ }
1145
+ onValueChange={(next) =>
1146
+ updateDraft(attributeId, {
1147
+ data_type: dataType,
1148
+ attribute_option_id: next ? Number(next) : null,
1149
+ })
1150
+ }
1151
+ >
1152
+ <SelectTrigger>
1153
+ <SelectValue
1154
+ placeholder={
1155
+ currentLocaleCode?.startsWith('pt')
1156
+ ? 'Selecione uma opção'
1157
+ : 'Select an option'
1158
+ }
1159
+ />
1160
+ </SelectTrigger>
1161
+ <SelectContent>
1162
+ {(attribute.options ?? []).map((option) => (
1163
+ <SelectItem key={String(option.id)} value={String(option.id)}>
1164
+ {String(option.label ?? option.option_value)}
1165
+ </SelectItem>
1166
+ ))}
1167
+ </SelectContent>
1168
+ </Select>
1169
+ </div>
1170
+ ) : null}
1171
+
1172
+ {dataType === 'text' || dataType === 'long_text' ? (
1173
+ <div className="space-y-2 sm:col-span-2">
1174
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Valor' : 'Value'}</FormLabel>
1175
+ {dataType === 'long_text' ? (
1176
+ <Textarea
1177
+ value={draft?.value_text ?? ''}
1178
+ onChange={(event) =>
1179
+ updateDraft(attributeId, {
1180
+ data_type: dataType,
1181
+ value_text: event.target.value,
1182
+ })
1183
+ }
1184
+ />
1185
+ ) : (
1186
+ <Input
1187
+ value={draft?.value_text ?? ''}
1188
+ onChange={(event) =>
1189
+ updateDraft(attributeId, {
1190
+ data_type: dataType,
1191
+ value_text: event.target.value,
1192
+ })
1193
+ }
1194
+ />
1195
+ )}
1196
+ </div>
1197
+ ) : null}
1198
+
1199
+ {dataType === 'number' ? (
1200
+ <>
1201
+ <div className="space-y-2">
1202
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Número' : 'Number'}</FormLabel>
1203
+ <Input
1204
+ type="number"
1205
+ value={
1206
+ draft?.value_number === null ||
1207
+ draft?.value_number === undefined
1208
+ ? ''
1209
+ : String(draft.value_number)
1210
+ }
1211
+ onChange={(event) =>
1212
+ updateDraft(attributeId, {
1213
+ data_type: dataType,
1214
+ value_number:
1215
+ event.target.value === ''
1216
+ ? null
1217
+ : Number(event.target.value),
1218
+ })
1219
+ }
1220
+ />
1221
+ </div>
1222
+ <div className="space-y-2">
1223
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Unidade' : 'Unit'}</FormLabel>
1224
+ <Input
1225
+ value={draft?.value_unit ?? ''}
1226
+ onChange={(event) =>
1227
+ updateDraft(attributeId, {
1228
+ data_type: dataType,
1229
+ value_unit: event.target.value,
1230
+ })
1231
+ }
1232
+ />
1233
+ </div>
1234
+ </>
1235
+ ) : null}
1236
+
1237
+ {dataType === 'boolean' ? (
1238
+ <div className="space-y-2 sm:col-span-2">
1239
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Valor' : 'Value'}</FormLabel>
1240
+ <Select
1241
+ value={
1242
+ draft?.value_boolean === null ||
1243
+ draft?.value_boolean === undefined
1244
+ ? ''
1245
+ : draft.value_boolean
1246
+ ? 'true'
1247
+ : 'false'
1248
+ }
1249
+ onValueChange={(next) =>
1250
+ updateDraft(attributeId, {
1251
+ data_type: dataType,
1252
+ value_boolean:
1253
+ next === ''
1254
+ ? null
1255
+ : next === 'true',
1256
+ })
1257
+ }
1258
+ >
1259
+ <SelectTrigger>
1260
+ <SelectValue
1261
+ placeholder={
1262
+ currentLocaleCode?.startsWith('pt')
1263
+ ? 'Selecione um valor'
1264
+ : 'Select a value'
1265
+ }
1266
+ />
1267
+ </SelectTrigger>
1268
+ <SelectContent>
1269
+ <SelectItem value="true">
1270
+ {currentLocaleCode?.startsWith('pt') ? 'Sim' : 'Yes'}
1271
+ </SelectItem>
1272
+ <SelectItem value="false">
1273
+ {currentLocaleCode?.startsWith('pt') ? 'Não' : 'No'}
1274
+ </SelectItem>
1275
+ </SelectContent>
1276
+ </Select>
1277
+ </div>
1278
+ ) : null}
1279
+
1280
+ <div className="flex items-center justify-between rounded-md border px-3 py-2 sm:col-span-2">
1281
+ <div>
1282
+ <div className="text-sm font-medium">
1283
+ {currentLocaleCode?.startsWith('pt')
1284
+ ? 'Valor verificado'
1285
+ : 'Verified value'}
1286
+ </div>
1287
+ <div className="text-xs text-muted-foreground">
1288
+ {currentLocaleCode?.startsWith('pt')
1289
+ ? 'Marque quando o dado já tiver sido validado.'
1290
+ : 'Enable when the value has been validated.'}
1291
+ </div>
1292
+ </div>
1293
+ <Switch
1294
+ checked={Boolean(draft?.is_verified)}
1295
+ onCheckedChange={(checked) =>
1296
+ updateDraft(attributeId, {
1297
+ data_type: dataType,
1298
+ is_verified: checked,
1299
+ })
1300
+ }
1301
+ />
1302
+ </div>
1303
+ </div>
1304
+ </div>
1305
+ );
1306
+ })}
1307
+ </div>
1308
+ </div>
1309
+ ))
1310
+ )}
1311
+ </section>
1312
+ );
1313
+ }
1314
+
664
1315
  export function CatalogResourceFormSheet({
665
1316
  open,
666
1317
  onOpenChange,
@@ -674,6 +1325,14 @@ export function CatalogResourceFormSheet({
674
1325
  const { request, currentLocaleCode } = useApp();
675
1326
  const t = useTranslations('catalog');
676
1327
  const isEditing = Boolean(recordId);
1328
+ const [productAttributeValues, setProductAttributeValues] = useState<
1329
+ Record<number, ProductAttributeDraft>
1330
+ >({});
1331
+ const [productAttributeSchema, setProductAttributeSchema] =
1332
+ useState<CatalogAttributePayload | null>(null);
1333
+ const [productAttributeErrors, setProductAttributeErrors] = useState<
1334
+ Record<number, string>
1335
+ >({});
677
1336
  const formSchema = useMemo(
678
1337
  () => buildFormSchema(resourceConfig),
679
1338
  [resourceConfig]
@@ -696,9 +1355,20 @@ export function CatalogResourceFormSheet({
696
1355
  },
697
1356
  enabled: open && Boolean(recordId),
698
1357
  });
1358
+ const selectedCategoryId =
1359
+ resource === 'products'
1360
+ ? Number(form.watch('catalog_category_id') ?? 0) || null
1361
+ : null;
1362
+ const initialCategoryId =
1363
+ resource === 'products'
1364
+ ? Number(currentRecord?.catalog_category_id ?? 0) || null
1365
+ : null;
699
1366
 
700
1367
  useEffect(() => {
701
1368
  if (!open) {
1369
+ setProductAttributeValues({});
1370
+ setProductAttributeSchema(null);
1371
+ setProductAttributeErrors({});
702
1372
  return;
703
1373
  }
704
1374
 
@@ -706,6 +1376,10 @@ export function CatalogResourceFormSheet({
706
1376
  form.reset(buildDefaultValues(resourceConfig, payload));
707
1377
  }, [currentRecord, form, open, resourceConfig]);
708
1378
 
1379
+ useEffect(() => {
1380
+ setProductAttributeErrors({});
1381
+ }, [selectedCategoryId]);
1382
+
709
1383
  const submitLabel = isEditing
710
1384
  ? getCatalogLocalizedText(resourceConfig.editActionLabel, currentLocaleCode)
711
1385
  : getCatalogLocalizedText(
@@ -723,6 +1397,35 @@ export function CatalogResourceFormSheet({
723
1397
  }
724
1398
  }
725
1399
 
1400
+ let attributeSubmission:
1401
+ | ReturnType<typeof buildProductAttributeSubmission>
1402
+ | null = null;
1403
+
1404
+ if (resource === 'products') {
1405
+ if (selectedCategoryId && !productAttributeSchema) {
1406
+ toast.error(
1407
+ currentLocaleCode?.startsWith('pt')
1408
+ ? 'Aguarde o carregamento dos atributos da categoria antes de salvar.'
1409
+ : 'Wait for the category attributes to load before saving.',
1410
+ );
1411
+ return;
1412
+ }
1413
+
1414
+ attributeSubmission = buildProductAttributeSubmission(
1415
+ productAttributeSchema,
1416
+ productAttributeValues,
1417
+ currentLocaleCode,
1418
+ );
1419
+
1420
+ if (Object.keys(attributeSubmission.errors).length > 0) {
1421
+ setProductAttributeErrors(attributeSubmission.errors);
1422
+ toast.error(
1423
+ Object.values(attributeSubmission.errors)[0] ?? t('toasts.saveError'),
1424
+ );
1425
+ return;
1426
+ }
1427
+ }
1428
+
726
1429
  const response = await request({
727
1430
  url: isEditing
728
1431
  ? `/catalog/${resource}/${recordId}`
@@ -732,6 +1435,23 @@ export function CatalogResourceFormSheet({
732
1435
  });
733
1436
 
734
1437
  const savedRecord = (response.data || payload) as CatalogRecord;
1438
+
1439
+ if (resource === 'products') {
1440
+ const savedId = extractRecordId(savedRecord);
1441
+
1442
+ if (savedId) {
1443
+ await request({
1444
+ url: `/catalog/products/${savedId}/attributes`,
1445
+ method: 'PUT',
1446
+ data: {
1447
+ values: attributeSubmission?.values ?? [],
1448
+ },
1449
+ });
1450
+
1451
+ setProductAttributeErrors({});
1452
+ }
1453
+ }
1454
+
735
1455
  toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
736
1456
  onOpenChange(false);
737
1457
  await onSuccess(savedRecord);
@@ -742,6 +1462,73 @@ export function CatalogResourceFormSheet({
742
1462
  }
743
1463
  };
744
1464
 
1465
+ const handleApplyAiSuggestion = (suggestion: {
1466
+ fields?: Record<string, unknown>;
1467
+ product_attributes?: Array<Record<string, unknown>>;
1468
+ }) => {
1469
+ const fieldMap = new Map(
1470
+ resourceConfig.formSections.flatMap((section) =>
1471
+ section.fields.map((field) => [field.key, field] as const),
1472
+ ),
1473
+ );
1474
+
1475
+ for (const [key, value] of Object.entries(suggestion.fields ?? {})) {
1476
+ const field = fieldMap.get(key);
1477
+
1478
+ if (!field) {
1479
+ continue;
1480
+ }
1481
+
1482
+ form.setValue(
1483
+ key,
1484
+ serializeInitialValue(field, value) as never,
1485
+ { shouldDirty: true, shouldTouch: true },
1486
+ );
1487
+ }
1488
+
1489
+ if (resource === 'products' && suggestion.product_attributes?.length) {
1490
+ const nextDrafts = { ...productAttributeValues };
1491
+
1492
+ for (const attribute of suggestion.product_attributes) {
1493
+ const attributeId = Number(attribute.attribute_id ?? 0);
1494
+
1495
+ if (!attributeId) {
1496
+ continue;
1497
+ }
1498
+
1499
+ nextDrafts[attributeId] = {
1500
+ attribute_id: attributeId,
1501
+ data_type: String(attribute.data_type ?? nextDrafts[attributeId]?.data_type ?? 'text'),
1502
+ attribute_option_id:
1503
+ attribute.attribute_option_id === null ||
1504
+ attribute.attribute_option_id === undefined
1505
+ ? null
1506
+ : Number(attribute.attribute_option_id),
1507
+ value_text: String(attribute.value_text ?? ''),
1508
+ value_number:
1509
+ attribute.value_number === null || attribute.value_number === undefined
1510
+ ? null
1511
+ : Number(attribute.value_number),
1512
+ value_boolean:
1513
+ typeof attribute.value_boolean === 'boolean'
1514
+ ? attribute.value_boolean
1515
+ : null,
1516
+ value_unit: String(attribute.value_unit ?? ''),
1517
+ source_type: String(attribute.source_type ?? 'manual'),
1518
+ confidence_score:
1519
+ attribute.confidence_score === null ||
1520
+ attribute.confidence_score === undefined
1521
+ ? null
1522
+ : Number(attribute.confidence_score),
1523
+ is_verified: Boolean(attribute.is_verified ?? false),
1524
+ };
1525
+ }
1526
+
1527
+ setProductAttributeValues(nextDrafts);
1528
+ setProductAttributeErrors({});
1529
+ }
1530
+ };
1531
+
745
1532
  return (
746
1533
  <Sheet open={open} onOpenChange={onOpenChange}>
747
1534
  <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-2xl">
@@ -762,6 +1549,21 @@ export function CatalogResourceFormSheet({
762
1549
  className="min-w-0 space-y-6 px-4 pb-4"
763
1550
  onSubmit={form.handleSubmit(handleSubmit)}
764
1551
  >
1552
+ <div className="flex justify-end">
1553
+ <CatalogAiFormAssistDialog
1554
+ resource={resource}
1555
+ resourceConfig={resourceConfig}
1556
+ disabled={form.formState.isSubmitting || isLoading}
1557
+ getCurrentValues={() =>
1558
+ buildCurrentFormPayload(resourceConfig, form.getValues())
1559
+ }
1560
+ getCurrentAttributeValues={() =>
1561
+ buildCurrentProductAttributeValues(productAttributeValues)
1562
+ }
1563
+ onApply={handleApplyAiSuggestion}
1564
+ />
1565
+ </div>
1566
+
765
1567
  {resourceConfig.formSections.map((section) => (
766
1568
  <section
767
1569
  key={section.title.en}
@@ -946,6 +1748,19 @@ export function CatalogResourceFormSheet({
946
1748
  </div>
947
1749
  </section>
948
1750
  ))}
1751
+
1752
+ {resource === 'products' ? (
1753
+ <ProductAttributesSection
1754
+ open={open}
1755
+ recordId={recordId}
1756
+ categoryId={selectedCategoryId}
1757
+ initialCategoryId={initialCategoryId}
1758
+ values={productAttributeValues}
1759
+ onChange={setProductAttributeValues}
1760
+ errors={productAttributeErrors}
1761
+ onSchemaChange={setProductAttributeSchema}
1762
+ />
1763
+ ) : null}
949
1764
  </form>
950
1765
  </Form>
951
1766
  )}