@open-mercato/core 0.4.5-develop-3ce83a8b24 → 0.4.5-develop-539cff4960

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 (184) hide show
  1. package/dist/generated/entities/catalog_product/index.js +16 -0
  2. package/dist/generated/entities/catalog_product/index.js.map +2 -2
  3. package/dist/generated/entities/catalog_product_unit_conversion/index.js +27 -0
  4. package/dist/generated/entities/catalog_product_unit_conversion/index.js.map +7 -0
  5. package/dist/generated/entities/sales_credit_memo_line/index.js +7 -1
  6. package/dist/generated/entities/sales_credit_memo_line/index.js.map +2 -2
  7. package/dist/generated/entities/sales_invoice_line/index.js +7 -1
  8. package/dist/generated/entities/sales_invoice_line/index.js.map +2 -2
  9. package/dist/generated/entities/sales_order_line/index.js +6 -0
  10. package/dist/generated/entities/sales_order_line/index.js.map +2 -2
  11. package/dist/generated/entities/sales_quote_line/index.js +6 -0
  12. package/dist/generated/entities/sales_quote_line/index.js.map +2 -2
  13. package/dist/generated/entities.ids.generated.js +1 -0
  14. package/dist/generated/entities.ids.generated.js.map +2 -2
  15. package/dist/generated/entity-fields-registry.js +2 -0
  16. package/dist/generated/entity-fields-registry.js.map +2 -2
  17. package/dist/modules/catalog/api/prices/route.js +123 -8
  18. package/dist/modules/catalog/api/prices/route.js.map +2 -2
  19. package/dist/modules/catalog/api/product-unit-conversions/route.js +194 -0
  20. package/dist/modules/catalog/api/product-unit-conversions/route.js.map +7 -0
  21. package/dist/modules/catalog/api/products/route.js +351 -201
  22. package/dist/modules/catalog/api/products/route.js.map +2 -2
  23. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +1267 -497
  24. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  25. package/dist/modules/catalog/backend/catalog/products/create/page.js +733 -210
  26. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  27. package/dist/modules/catalog/commands/index.js +1 -0
  28. package/dist/modules/catalog/commands/index.js.map +2 -2
  29. package/dist/modules/catalog/commands/productUnitConversions.js +503 -0
  30. package/dist/modules/catalog/commands/productUnitConversions.js.map +7 -0
  31. package/dist/modules/catalog/commands/products.js +355 -73
  32. package/dist/modules/catalog/commands/products.js.map +2 -2
  33. package/dist/modules/catalog/commands/shared.js +18 -4
  34. package/dist/modules/catalog/commands/shared.js.map +2 -2
  35. package/dist/modules/catalog/components/products/ProductUomSection.js +591 -0
  36. package/dist/modules/catalog/components/products/ProductUomSection.js.map +7 -0
  37. package/dist/modules/catalog/components/products/productForm.js +66 -5
  38. package/dist/modules/catalog/components/products/productForm.js.map +2 -2
  39. package/dist/modules/catalog/components/products/productFormUtils.js +68 -0
  40. package/dist/modules/catalog/components/products/productFormUtils.js.map +7 -0
  41. package/dist/modules/catalog/data/entities.js +86 -0
  42. package/dist/modules/catalog/data/entities.js.map +2 -2
  43. package/dist/modules/catalog/data/validators.js +65 -3
  44. package/dist/modules/catalog/data/validators.js.map +2 -2
  45. package/dist/modules/catalog/events.js +3 -0
  46. package/dist/modules/catalog/events.js.map +2 -2
  47. package/dist/modules/catalog/lib/unitCodes.js +7 -0
  48. package/dist/modules/catalog/lib/unitCodes.js.map +7 -0
  49. package/dist/modules/catalog/lib/unitResolution.js +53 -0
  50. package/dist/modules/catalog/lib/unitResolution.js.map +7 -0
  51. package/dist/modules/catalog/migrations/Migration20260218225422.js +19 -0
  52. package/dist/modules/catalog/migrations/Migration20260218225422.js.map +7 -0
  53. package/dist/modules/catalog/migrations/Migration20260219084500.js +27 -0
  54. package/dist/modules/catalog/migrations/Migration20260219084500.js.map +7 -0
  55. package/dist/modules/catalog/search.js +69 -1
  56. package/dist/modules/catalog/search.js.map +2 -2
  57. package/dist/modules/catalog/seed/examples.js +91 -42
  58. package/dist/modules/catalog/seed/examples.js.map +2 -2
  59. package/dist/modules/dashboards/seed/analytics.js +3 -0
  60. package/dist/modules/dashboards/seed/analytics.js.map +2 -2
  61. package/dist/modules/sales/api/order-lines/route.js +98 -15
  62. package/dist/modules/sales/api/order-lines/route.js.map +2 -2
  63. package/dist/modules/sales/api/quote-lines/route.js +101 -14
  64. package/dist/modules/sales/api/quote-lines/route.js.map +2 -2
  65. package/dist/modules/sales/api/quotes/public/[token]/route.js +87 -12
  66. package/dist/modules/sales/api/quotes/public/[token]/route.js.map +2 -2
  67. package/dist/modules/sales/commands/documents.js +1424 -260
  68. package/dist/modules/sales/commands/documents.js.map +3 -3
  69. package/dist/modules/sales/commands/shared.js +6 -2
  70. package/dist/modules/sales/commands/shared.js.map +2 -2
  71. package/dist/modules/sales/components/documents/ItemsSection.js +216 -86
  72. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  73. package/dist/modules/sales/components/documents/LineItemDialog.js +913 -241
  74. package/dist/modules/sales/components/documents/LineItemDialog.js.map +3 -3
  75. package/dist/modules/sales/components/documents/ShipmentsSection.js +15 -3
  76. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  77. package/dist/modules/sales/data/entities.js +59 -3
  78. package/dist/modules/sales/data/entities.js.map +2 -2
  79. package/dist/modules/sales/data/validators.js +35 -0
  80. package/dist/modules/sales/data/validators.js.map +2 -2
  81. package/dist/modules/sales/frontend/quote/[token]/page.js +15 -1
  82. package/dist/modules/sales/frontend/quote/[token]/page.js.map +2 -2
  83. package/dist/modules/sales/migrations/Migration20260218225423.js +31 -0
  84. package/dist/modules/sales/migrations/Migration20260218225423.js.map +7 -0
  85. package/dist/modules/sales/migrations/Migration20260219084501.js +71 -0
  86. package/dist/modules/sales/migrations/Migration20260219084501.js.map +7 -0
  87. package/dist/modules/sales/search.js +28 -0
  88. package/dist/modules/sales/search.js.map +2 -2
  89. package/dist/modules/sales/seed/examples.js +14 -1
  90. package/dist/modules/sales/seed/examples.js.map +2 -2
  91. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +1 -1
  92. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  93. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +28 -15
  94. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  95. package/dist/modules/staff/translations.js +9 -0
  96. package/dist/modules/staff/translations.js.map +7 -0
  97. package/dist/modules/translations/components/TranslationDrawerAction.js +97 -0
  98. package/dist/modules/translations/components/TranslationDrawerAction.js.map +7 -0
  99. package/dist/modules/translations/lib/extract-record-id.js +31 -2
  100. package/dist/modules/translations/lib/extract-record-id.js.map +2 -2
  101. package/dist/modules/translations/lib/resolve-field-list.js +3 -0
  102. package/dist/modules/translations/lib/resolve-field-list.js.map +2 -2
  103. package/dist/modules/translations/widgets/injection/translation-manager/widget.client.js +105 -36
  104. package/dist/modules/translations/widgets/injection/translation-manager/widget.client.js.map +2 -2
  105. package/dist/modules/translations/widgets/injection-table.js +18 -29
  106. package/dist/modules/translations/widgets/injection-table.js.map +2 -2
  107. package/generated/entities/catalog_product/index.ts +8 -0
  108. package/generated/entities/catalog_product_unit_conversion/index.ts +12 -0
  109. package/generated/entities/sales_credit_memo_line/index.ts +3 -0
  110. package/generated/entities/sales_invoice_line/index.ts +3 -0
  111. package/generated/entities/sales_order_line/index.ts +3 -0
  112. package/generated/entities/sales_quote_line/index.ts +3 -0
  113. package/generated/entities.ids.generated.ts +1 -0
  114. package/generated/entity-fields-registry.ts +2 -0
  115. package/package.json +2 -2
  116. package/src/modules/auth/i18n/de.json +1 -1
  117. package/src/modules/auth/i18n/en.json +1 -1
  118. package/src/modules/auth/i18n/es.json +1 -1
  119. package/src/modules/auth/i18n/pl.json +1 -1
  120. package/src/modules/catalog/api/prices/route.ts +213 -81
  121. package/src/modules/catalog/api/product-unit-conversions/route.ts +195 -0
  122. package/src/modules/catalog/api/products/route.ts +638 -402
  123. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2085 -1072
  124. package/src/modules/catalog/backend/catalog/products/create/page.tsx +1288 -593
  125. package/src/modules/catalog/commands/index.ts +1 -0
  126. package/src/modules/catalog/commands/productUnitConversions.ts +626 -0
  127. package/src/modules/catalog/commands/products.ts +1151 -693
  128. package/src/modules/catalog/commands/shared.ts +19 -5
  129. package/src/modules/catalog/components/products/ProductUomSection.tsx +745 -0
  130. package/src/modules/catalog/components/products/productForm.ts +369 -256
  131. package/src/modules/catalog/components/products/productFormUtils.ts +82 -0
  132. package/src/modules/catalog/data/entities.ts +82 -1
  133. package/src/modules/catalog/data/validators.ts +118 -34
  134. package/src/modules/catalog/events.ts +3 -0
  135. package/src/modules/catalog/i18n/de.json +56 -0
  136. package/src/modules/catalog/i18n/en.json +56 -0
  137. package/src/modules/catalog/i18n/es.json +56 -0
  138. package/src/modules/catalog/i18n/pl.json +56 -0
  139. package/src/modules/catalog/lib/unitCodes.ts +1 -0
  140. package/src/modules/catalog/lib/unitResolution.ts +62 -0
  141. package/src/modules/catalog/migrations/.snapshot-open-mercato.json +245 -0
  142. package/src/modules/catalog/migrations/Migration20260218225422.ts +21 -0
  143. package/src/modules/catalog/migrations/Migration20260219084500.ts +26 -0
  144. package/src/modules/catalog/search.ts +73 -1
  145. package/src/modules/catalog/seed/examples.ts +552 -479
  146. package/src/modules/dashboards/i18n/de.json +1 -1
  147. package/src/modules/dashboards/i18n/en.json +1 -1
  148. package/src/modules/dashboards/i18n/es.json +1 -1
  149. package/src/modules/dashboards/i18n/pl.json +1 -1
  150. package/src/modules/dashboards/seed/analytics.ts +3 -0
  151. package/src/modules/sales/api/order-lines/route.ts +158 -68
  152. package/src/modules/sales/api/quote-lines/route.ts +161 -67
  153. package/src/modules/sales/api/quotes/public/[token]/route.ts +122 -36
  154. package/src/modules/sales/commands/documents.ts +4250 -2424
  155. package/src/modules/sales/commands/shared.ts +7 -2
  156. package/src/modules/sales/components/documents/ItemsSection.tsx +580 -310
  157. package/src/modules/sales/components/documents/LineItemDialog.tsx +1988 -833
  158. package/src/modules/sales/components/documents/ShipmentsSection.tsx +17 -3
  159. package/src/modules/sales/components/documents/lineItemTypes.ts +6 -0
  160. package/src/modules/sales/data/entities.ts +53 -0
  161. package/src/modules/sales/data/validators.ts +36 -0
  162. package/src/modules/sales/frontend/quote/[token]/page.tsx +25 -1
  163. package/src/modules/sales/i18n/de.json +23 -3
  164. package/src/modules/sales/i18n/en.json +23 -3
  165. package/src/modules/sales/i18n/es.json +23 -3
  166. package/src/modules/sales/i18n/pl.json +23 -3
  167. package/src/modules/sales/lib/types.ts +30 -0
  168. package/src/modules/sales/migrations/.snapshot-open-mercato.json +172 -0
  169. package/src/modules/sales/migrations/Migration20260218225423.ts +37 -0
  170. package/src/modules/sales/migrations/Migration20260219084501.ts +73 -0
  171. package/src/modules/sales/search.ts +28 -0
  172. package/src/modules/sales/seed/examples.ts +20 -1
  173. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +1 -1
  174. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +8 -0
  175. package/src/modules/staff/translations.ts +5 -0
  176. package/src/modules/translations/components/TranslationDrawerAction.tsx +107 -0
  177. package/src/modules/translations/lib/extract-record-id.ts +47 -3
  178. package/src/modules/translations/lib/resolve-field-list.ts +4 -0
  179. package/src/modules/translations/widgets/injection/translation-manager/widget.client.tsx +108 -36
  180. package/src/modules/translations/widgets/injection-table.ts +19 -33
  181. package/src/modules/workflows/i18n/de.json +4 -4
  182. package/src/modules/workflows/i18n/en.json +4 -4
  183. package/src/modules/workflows/i18n/es.json +4 -4
  184. package/src/modules/workflows/i18n/pl.json +4 -4
@@ -1,38 +1,56 @@
1
- "use client"
2
-
3
- import * as React from 'react'
4
- import Link from 'next/link'
5
- import dynamic from 'next/dynamic'
6
- import { useRouter } from 'next/navigation'
7
- import { Page, PageBody } from '@open-mercato/ui/backend/Page'
8
- import { CrudForm, type CrudFormGroup, type CrudFormGroupComponentProps } from '@open-mercato/ui/backend/CrudForm'
9
- import { updateCrud, createCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
10
- import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
11
- import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
12
- import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
- import { Button } from '@open-mercato/ui/primitives/button'
14
- import { Input } from '@open-mercato/ui/primitives/input'
15
- import { Label } from '@open-mercato/ui/primitives/label'
16
- import { TagsInput } from '@open-mercato/ui/backend/inputs/TagsInput'
17
- import { Textarea } from '@open-mercato/ui/primitives/textarea'
18
- import { DataLoader } from '@open-mercato/ui/primitives/DataLoader'
19
- import { cn } from '@open-mercato/shared/lib/utils'
20
- import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
21
- import { useT } from '@open-mercato/shared/lib/i18n/context'
22
- import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
23
- import { E } from '#generated/entities.ids.generated'
24
- import { ProductMediaManager, type ProductMediaItem } from '@open-mercato/core/modules/catalog/components/products/ProductMediaManager'
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import Link from "next/link";
5
+ import dynamic from "next/dynamic";
6
+ import { useRouter } from "next/navigation";
7
+ import { Page, PageBody } from "@open-mercato/ui/backend/Page";
8
+ import {
9
+ CrudForm,
10
+ type CrudFormGroup,
11
+ type CrudFormGroupComponentProps,
12
+ } from "@open-mercato/ui/backend/CrudForm";
13
+ import {
14
+ updateCrud,
15
+ createCrud,
16
+ deleteCrud,
17
+ } from "@open-mercato/ui/backend/utils/crud";
18
+ import { createCrudFormError } from "@open-mercato/ui/backend/utils/serverErrors";
19
+ import { collectCustomFieldValues } from "@open-mercato/ui/backend/utils/customFieldValues";
20
+ import { flash } from "@open-mercato/ui/backend/FlashMessages";
21
+ import { Button } from "@open-mercato/ui/primitives/button";
22
+ import { Input } from "@open-mercato/ui/primitives/input";
23
+ import { Label } from "@open-mercato/ui/primitives/label";
24
+ import { TagsInput } from "@open-mercato/ui/backend/inputs/TagsInput";
25
+ import { Textarea } from "@open-mercato/ui/primitives/textarea";
26
+ import { DataLoader } from "@open-mercato/ui/primitives/DataLoader";
27
+ import { cn } from "@open-mercato/shared/lib/utils";
28
+ import { Spinner } from "@open-mercato/ui/primitives/spinner";
29
+ import {
30
+ apiCall,
31
+ readApiResultOrThrow,
32
+ } from "@open-mercato/ui/backend/utils/apiCall";
33
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
34
+ import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
35
+ import { E } from "#generated/entities.ids.generated";
36
+ import {
37
+ ProductMediaManager,
38
+ type ProductMediaItem,
39
+ } from "@open-mercato/core/modules/catalog/components/products/ProductMediaManager";
25
40
  import {
26
41
  fetchOptionSchemaTemplate,
27
42
  type OptionSchemaRecord,
28
43
  type OptionSchemaTemplateSummary,
29
- } from '../optionSchemaClient'
44
+ } from "../optionSchemaClient";
30
45
  import {
31
46
  type ProductFormValues,
32
47
  type TaxRateSummary,
33
48
  type ProductOptionInput,
34
49
  type PriceKindSummary,
35
50
  type PriceKindApiPayload,
51
+ type ProductUnitConversionDraft,
52
+ type ProductUnitPriceReferenceUnit,
53
+ type ProductUnitRoundingMode,
36
54
  productFormSchema,
37
55
  BASE_INITIAL_VALUES,
38
56
  createLocalId,
@@ -49,226 +67,511 @@ import {
49
67
  sanitizeProductWeight,
50
68
  updateDimensionValue,
51
69
  updateWeightValue,
52
- } from '@open-mercato/core/modules/catalog/components/products/productForm'
53
- import type { CatalogProductOptionSchema } from '@open-mercato/core/modules/catalog/data/types'
54
- import { MetadataEditor } from '@open-mercato/core/modules/catalog/components/products/MetadataEditor'
55
- import { buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
70
+ } from "@open-mercato/core/modules/catalog/components/products/productForm";
71
+ import type { CatalogProductOptionSchema } from "@open-mercato/core/modules/catalog/data/types";
72
+ import { MetadataEditor } from "@open-mercato/core/modules/catalog/components/products/MetadataEditor";
73
+ import {
74
+ buildAttachmentImageUrl,
75
+ slugifyAttachmentFileName,
76
+ } from "@open-mercato/core/modules/attachments/lib/imageUrls";
56
77
  import {
57
78
  ProductCategorizeSection,
58
79
  type ProductCategorizePickerOption,
59
- } from '@open-mercato/core/modules/catalog/components/products/ProductCategorizeSection'
60
- import { AlignLeft, BookMarked, ExternalLink, FileText, Layers, Plus, Save, Trash2 } from 'lucide-react'
61
- import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
62
-
63
- const MarkdownEditor = dynamic(() => import('@uiw/react-md-editor'), {
80
+ } from "@open-mercato/core/modules/catalog/components/products/ProductCategorizeSection";
81
+ import { ProductUomSection } from "@open-mercato/core/modules/catalog/components/products/ProductUomSection";
82
+ import { canonicalizeUnitCode } from "@open-mercato/core/modules/catalog/lib/unitCodes";
83
+ import {
84
+ UNIT_PRICE_REFERENCE_UNITS,
85
+ toTrimmedOrNull,
86
+ parseNumericInput,
87
+ toPositiveNumberOrNull,
88
+ toIntegerInRangeOrDefault,
89
+ normalizeProductConversionInputs,
90
+ type ProductUnitConversionInput,
91
+ } from "@open-mercato/core/modules/catalog/components/products/productFormUtils";
92
+ import {
93
+ AlignLeft,
94
+ BookMarked,
95
+ ExternalLink,
96
+ FileText,
97
+ Layers,
98
+ Plus,
99
+ Save,
100
+ Trash2,
101
+ } from "lucide-react";
102
+ import {
103
+ Dialog,
104
+ DialogContent,
105
+ DialogFooter,
106
+ DialogHeader,
107
+ DialogTitle,
108
+ } from "@open-mercato/ui/primitives/dialog";
109
+
110
+ const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), {
64
111
  ssr: false,
65
- loading: () => <div className="flex h-48 items-center justify-center text-sm text-muted-foreground">Loading editor…</div>,
66
- }) as unknown as React.ComponentType<{ value?: string; height?: number; onChange?: (value?: string) => void; previewOptions?: { remarkPlugins?: unknown[] } }>
112
+ loading: () => (
113
+ <div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
114
+ <Spinner />
115
+ </div>
116
+ ),
117
+ }) as unknown as React.ComponentType<{
118
+ value?: string;
119
+ height?: number;
120
+ onChange?: (value?: string) => void;
121
+ previewOptions?: { remarkPlugins?: unknown[] };
122
+ }>;
67
123
 
68
124
  type ProductResponse = {
69
- items?: Array<Record<string, unknown>>
70
- }
125
+ items?: Array<Record<string, unknown>>;
126
+ };
71
127
 
72
128
  type VariantListResponse = {
73
- items?: VariantSummaryApi[]
74
- }
129
+ items?: VariantSummaryApi[];
130
+ };
75
131
 
76
132
  type VariantSummaryApi = {
77
- id?: string
78
- name?: string | null
79
- sku?: string | null
80
- is_default?: boolean
81
- isDefault?: boolean
82
- metadata?: Record<string, unknown> | null
83
- }
133
+ id?: string;
134
+ name?: string | null;
135
+ sku?: string | null;
136
+ is_default?: boolean;
137
+ isDefault?: boolean;
138
+ metadata?: Record<string, unknown> | null;
139
+ };
84
140
 
85
141
  type AttachmentListResponse = {
86
142
  items?: {
87
- id: string
88
- url: string
89
- fileName: string
90
- fileSize: number
91
- thumbnailUrl?: string | null
92
- }[]
93
- }
143
+ id: string;
144
+ url: string;
145
+ fileName: string;
146
+ fileSize: number;
147
+ thumbnailUrl?: string | null;
148
+ }[];
149
+ };
94
150
 
95
151
  type OptionSchemaTemplateListResponse = {
96
- items?: OptionSchemaTemplateSummary[]
97
- }
152
+ items?: OptionSchemaTemplateSummary[];
153
+ };
98
154
 
99
155
  type VariantSummary = {
100
- id: string
101
- name: string
102
- sku: string
103
- isDefault: boolean
104
- prices: VariantPriceSummary[]
105
- optionValues: Record<string, string> | null
106
- }
156
+ id: string;
157
+ name: string;
158
+ sku: string;
159
+ isDefault: boolean;
160
+ prices: VariantPriceSummary[];
161
+ optionValues: Record<string, string> | null;
162
+ };
107
163
 
108
164
  type VariantPriceListResponse = {
109
- items?: VariantPriceSummaryApi[]
110
- }
165
+ items?: VariantPriceSummaryApi[];
166
+ };
111
167
 
112
168
  type VariantPriceSummaryApi = {
113
- id?: string
114
- variant_id?: string | null
115
- variantId?: string | null
116
- price_kind_id?: string | null
117
- priceKindId?: string | null
118
- currency_code?: string | null
119
- currencyCode?: string | null
120
- unit_price_net?: string | null
121
- unitPriceNet?: string | null
122
- unit_price_gross?: string | null
123
- unitPriceGross?: string | null
124
- }
169
+ id?: string;
170
+ variant_id?: string | null;
171
+ variantId?: string | null;
172
+ price_kind_id?: string | null;
173
+ priceKindId?: string | null;
174
+ currency_code?: string | null;
175
+ currencyCode?: string | null;
176
+ unit_price_net?: string | null;
177
+ unitPriceNet?: string | null;
178
+ unit_price_gross?: string | null;
179
+ unitPriceGross?: string | null;
180
+ };
125
181
 
126
182
  type VariantPriceSummary = {
127
- id: string
128
- variantId: string
129
- priceKindId: string | null
130
- currencyCode: string | null
131
- amount: string | null
132
- displayMode: 'including-tax' | 'excluding-tax'
133
- }
183
+ id: string;
184
+ variantId: string;
185
+ priceKindId: string | null;
186
+ currencyCode: string | null;
187
+ amount: string | null;
188
+ displayMode: "including-tax" | "excluding-tax";
189
+ };
134
190
 
135
191
  type OfferSnapshot = {
136
- id: string | null
137
- channelId: string
138
- title: string | null
139
- description: string | null
140
- defaultMediaId: string | null
141
- defaultMediaUrl: string | null
142
- metadata: Record<string, unknown> | null
143
- isActive: boolean
144
- channelName: string | null
145
- channelCode: string | null
192
+ id: string | null;
193
+ channelId: string;
194
+ title: string | null;
195
+ description: string | null;
196
+ defaultMediaId: string | null;
197
+ defaultMediaUrl: string | null;
198
+ metadata: Record<string, unknown> | null;
199
+ isActive: boolean;
200
+ channelName: string | null;
201
+ channelCode: string | null;
202
+ };
203
+
204
+ function mapVariantPriceSummary(
205
+ item: VariantPriceSummaryApi | undefined,
206
+ ): VariantPriceSummary | null {
207
+ if (!item) return null;
208
+ const getString = (value: unknown): string | null => {
209
+ if (typeof value === "string" && value.trim().length) return value.trim();
210
+ if (typeof value === "number" || typeof value === "bigint")
211
+ return String(value);
212
+ return null;
213
+ };
214
+ const variantId = getString(item.variant_id ?? item.variantId);
215
+ const id = getString(item.id);
216
+ if (!variantId || !id) return null;
217
+ const priceKindId = getString(item.price_kind_id ?? item.priceKindId);
218
+ const currencyCode = getString(item.currency_code ?? item.currencyCode);
219
+ const unitGross = getString(item.unit_price_gross ?? item.unitPriceGross);
220
+ const unitNet = getString(item.unit_price_net ?? item.unitPriceNet);
221
+ const amount = unitGross ?? unitNet ?? null;
222
+ return {
223
+ id,
224
+ variantId,
225
+ priceKindId,
226
+ currencyCode,
227
+ amount,
228
+ displayMode: unitGross ? "including-tax" : "excluding-tax",
229
+ };
146
230
  }
147
231
 
148
- export default function EditCatalogProductPage({ params }: { params?: { id?: string } }) {
149
- const productId = params?.id ? String(params.id) : null
150
- const t = useT()
151
- const router = useRouter()
152
- const [taxRates, setTaxRates] = React.useState<TaxRateSummary[]>([])
153
- const [variants, setVariants] = React.useState<VariantSummary[]>([])
154
- const [priceKinds, setPriceKinds] = React.useState<PriceKindSummary[]>([])
155
- const [initialValues, setInitialValues] = React.useState<Partial<ProductFormValues> | null>(null)
156
- const [loading, setLoading] = React.useState(true)
157
- const [error, setError] = React.useState<string | null>(null)
158
- const offerSnapshotsRef = React.useRef<OfferSnapshot[]>([])
232
+ function normalizeVariantOptionValues(
233
+ input: unknown,
234
+ ): Record<string, string> | null {
235
+ if (!input || typeof input !== "object") return null;
236
+ const result: Record<string, string> = {};
237
+ Object.entries(input as Record<string, unknown>).forEach(([key, value]) => {
238
+ if (
239
+ typeof key === "string" &&
240
+ typeof value === "string" &&
241
+ key.trim().length
242
+ ) {
243
+ result[key] = value;
244
+ }
245
+ });
246
+ return Object.keys(result).length ? result : null;
247
+ }
248
+
249
+ function readProductConversionRows(
250
+ items: Array<Record<string, unknown>> | undefined,
251
+ ): ProductUnitConversionDraft[] {
252
+ const rows = Array.isArray(items) ? items : [];
253
+ return rows
254
+ .map((item) => {
255
+ const id = toTrimmedOrNull(item.id);
256
+ const unitCode = canonicalizeUnitCode(item.unit_code ?? item.unitCode);
257
+ const factor = toPositiveNumberOrNull(
258
+ item.to_base_factor ?? item.toBaseFactor,
259
+ );
260
+ if (!unitCode || factor === null) return null;
261
+ const sortOrderRaw = toIntegerInRangeOrDefault(
262
+ item.sort_order ?? item.sortOrder,
263
+ 0,
264
+ 100000,
265
+ 0,
266
+ );
267
+ const isActive =
268
+ typeof item.is_active === "boolean"
269
+ ? item.is_active
270
+ : typeof item.isActive === "boolean"
271
+ ? item.isActive
272
+ : true;
273
+ return {
274
+ id: id ?? null,
275
+ unitCode,
276
+ toBaseFactor: String(factor),
277
+ sortOrder: String(sortOrderRaw),
278
+ isActive,
279
+ } satisfies ProductUnitConversionDraft;
280
+ })
281
+ .filter((entry): entry is ProductUnitConversionDraft => Boolean(entry));
282
+ }
283
+
284
+ export default function EditCatalogProductPage({
285
+ params,
286
+ }: {
287
+ params?: { id?: string };
288
+ }) {
289
+ const productId = params?.id ? String(params.id) : null;
290
+ const t = useT();
291
+ const router = useRouter();
292
+ const [taxRates, setTaxRates] = React.useState<TaxRateSummary[]>([]);
293
+ const [variants, setVariants] = React.useState<VariantSummary[]>([]);
294
+ const [priceKinds, setPriceKinds] = React.useState<PriceKindSummary[]>([]);
295
+ const [initialValues, setInitialValues] =
296
+ React.useState<Partial<ProductFormValues> | null>(null);
297
+ const [loading, setLoading] = React.useState(true);
298
+ const [error, setError] = React.useState<string | null>(null);
299
+ const offerSnapshotsRef = React.useRef<OfferSnapshot[]>([]);
300
+ const initialConversionsRef = React.useRef<ProductUnitConversionDraft[]>([]);
159
301
  const [categorizeOptions, setCategorizeOptions] = React.useState<{
160
- categories: ProductCategorizePickerOption[]
161
- channels: ProductCategorizePickerOption[]
162
- tags: ProductCategorizePickerOption[]
163
- }>({ categories: [], channels: [], tags: [] })
302
+ categories: ProductCategorizePickerOption[];
303
+ channels: ProductCategorizePickerOption[];
304
+ tags: ProductCategorizePickerOption[];
305
+ }>({ categories: [], channels: [], tags: [] });
306
+
307
+ const loadVariants = React.useCallback(async (id: string) => {
308
+ try {
309
+ const [variantsRes, pricesRes] = await Promise.all([
310
+ apiCall<VariantListResponse>(
311
+ `/api/catalog/variants?productId=${encodeURIComponent(id)}&page=1&pageSize=100`,
312
+ ),
313
+ apiCall<VariantPriceListResponse>(
314
+ `/api/catalog/prices?productId=${encodeURIComponent(id)}&page=1&pageSize=100`,
315
+ ),
316
+ ]);
317
+ if (!variantsRes.ok) {
318
+ setVariants([]);
319
+ return;
320
+ }
321
+ const priceMap: Record<string, VariantPriceSummary[]> = {};
322
+ if (pricesRes.ok) {
323
+ const priceItems = Array.isArray(pricesRes.result?.items)
324
+ ? pricesRes.result?.items
325
+ : [];
326
+ for (const item of priceItems) {
327
+ const summary = mapVariantPriceSummary(item);
328
+ if (!summary) continue;
329
+ if (!priceMap[summary.variantId]) {
330
+ priceMap[summary.variantId] = [];
331
+ }
332
+ priceMap[summary.variantId].push(summary);
333
+ }
334
+ Object.keys(priceMap).forEach((key) => {
335
+ priceMap[key].sort((left, right) => {
336
+ const leftKey =
337
+ (left.priceKindId ?? "") + (left.currencyCode ?? "");
338
+ const rightKey =
339
+ (right.priceKindId ?? "") + (right.currencyCode ?? "");
340
+ return leftKey.localeCompare(rightKey);
341
+ });
342
+ });
343
+ }
344
+ const items = Array.isArray(variantsRes.result?.items)
345
+ ? variantsRes.result?.items
346
+ : [];
347
+ setVariants(
348
+ items
349
+ .map((variant) => {
350
+ const variantId =
351
+ typeof variant.id === "string" ? variant.id : null;
352
+ if (!variantId) return null;
353
+ const variantRecord = variant as Record<string, unknown>;
354
+ const optionValues =
355
+ normalizeVariantOptionValues(variantRecord?.["option_values"]) ??
356
+ normalizeVariantOptionValues(variantRecord?.optionValues);
357
+ return {
358
+ id: variantId,
359
+ name:
360
+ typeof variant.name === "string" && variant.name.trim().length
361
+ ? variant.name
362
+ : (variant.sku ?? variantId),
363
+ sku: typeof variant.sku === "string" ? variant.sku : "",
364
+ isDefault: Boolean(variant.is_default ?? variant.isDefault),
365
+ prices: priceMap[variantId] ?? [],
366
+ optionValues,
367
+ };
368
+ })
369
+ .filter((entry): entry is VariantSummary => Boolean(entry)),
370
+ );
371
+ } catch (err) {
372
+ console.error("catalog.variants.fetch failed", err);
373
+ setVariants([]);
374
+ }
375
+ }, []);
376
+
377
+ const refreshVariants = React.useCallback(async () => {
378
+ if (!productId) return;
379
+ await loadVariants(productId);
380
+ }, [loadVariants, productId]);
381
+
382
+ const fetchAttachments = React.useCallback(
383
+ async (id: string): Promise<ProductMediaItem[]> => {
384
+ try {
385
+ const res = await apiCall<AttachmentListResponse>(
386
+ `/api/attachments?entityId=${encodeURIComponent(E.catalog.catalog_product)}&recordId=${encodeURIComponent(id)}`,
387
+ );
388
+ if (!res.ok) return [];
389
+ return (res.result?.items ?? []).map((item) => ({
390
+ id: item.id,
391
+ url: item.url,
392
+ fileName: item.fileName,
393
+ fileSize: item.fileSize,
394
+ thumbnailUrl: item.thumbnailUrl ?? undefined,
395
+ }));
396
+ } catch (err) {
397
+ console.error("attachments.fetch failed", err);
398
+ return [];
399
+ }
400
+ },
401
+ [],
402
+ );
164
403
 
165
404
  React.useEffect(() => {
166
405
  const loadTaxRates = async () => {
167
406
  try {
168
- const payload = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
169
- '/api/sales/tax-rates?pageSize=200',
170
- undefined,
171
- { errorMessage: t('catalog.products.create.taxRates.error', 'Failed to load tax rates.'), fallback: { items: [] } },
172
- )
173
- const items = Array.isArray(payload.items) ? payload.items : []
407
+ const payload = await readApiResultOrThrow<{
408
+ items?: Array<Record<string, unknown>>;
409
+ }>("/api/sales/tax-rates?pageSize=100", undefined, {
410
+ errorMessage: t(
411
+ "catalog.products.create.taxRates.error",
412
+ "Failed to load tax rates.",
413
+ ),
414
+ fallback: { items: [] },
415
+ });
416
+ const items = Array.isArray(payload.items) ? payload.items : [];
174
417
  setTaxRates(
175
418
  items.map((item) => {
176
- const rawRate = typeof item.rate === 'number' ? item.rate : Number(item.rate ?? Number.NaN)
419
+ const rawRate =
420
+ typeof item.rate === "number"
421
+ ? item.rate
422
+ : Number(item.rate ?? Number.NaN);
177
423
  return {
178
424
  id: String(item.id),
179
425
  name:
180
- typeof item.name === 'string' && item.name.trim().length
426
+ typeof item.name === "string" && item.name.trim().length
181
427
  ? item.name
182
- : t('catalog.products.create.taxRates.unnamed', 'Untitled tax rate'),
183
- code: typeof item.code === 'string' && item.code.trim().length ? item.code : null,
428
+ : t(
429
+ "catalog.products.create.taxRates.unnamed",
430
+ "Untitled tax rate",
431
+ ),
432
+ code:
433
+ typeof item.code === "string" && item.code.trim().length
434
+ ? item.code
435
+ : null,
184
436
  rate: Number.isFinite(rawRate) ? rawRate : null,
185
437
  isDefault: Boolean(
186
- typeof item.isDefault === 'boolean'
438
+ typeof item.isDefault === "boolean"
187
439
  ? item.isDefault
188
- : typeof item.is_default === 'boolean'
440
+ : typeof item.is_default === "boolean"
189
441
  ? item.is_default
190
442
  : false,
191
443
  ),
192
- }
444
+ };
193
445
  }),
194
- )
446
+ );
195
447
  } catch (err) {
196
- console.error('sales.tax-rates.fetch failed', err)
197
- setTaxRates([])
448
+ console.error("sales.tax-rates.fetch failed", err);
449
+ setTaxRates([]);
198
450
  }
199
- }
200
- loadTaxRates().catch(() => {})
201
- }, [t])
451
+ };
452
+ loadTaxRates().catch(() => {});
453
+ }, [t]);
202
454
 
203
455
  React.useEffect(() => {
204
456
  if (!productId) {
205
- setLoading(false)
206
- setError(t('catalog.products.edit.errors.idMissing', 'Product identifier is missing.'))
207
- return
457
+ setLoading(false);
458
+ setError(
459
+ t(
460
+ "catalog.products.edit.errors.idMissing",
461
+ "Product identifier is missing.",
462
+ ),
463
+ );
464
+ return;
208
465
  }
209
- let cancelled = false
466
+ let cancelled = false;
210
467
  async function loadProduct() {
211
- setLoading(true)
212
- setError(null)
468
+ setLoading(true);
469
+ setError(null);
213
470
  try {
214
471
  const productRes = await apiCall<ProductResponse>(
215
472
  `/api/catalog/products?id=${encodeURIComponent(productId!)}&page=1&pageSize=1&withDeleted=false`,
216
- )
217
- if (!productRes.ok) throw new Error('load_failed')
218
- const record = Array.isArray(productRes.result?.items) ? productRes.result?.items?.[0] : undefined
219
- if (!record) throw new Error(t('catalog.products.edit.errors.notFound', 'Product not found.'))
473
+ );
474
+ if (!productRes.ok) throw new Error("load_failed");
475
+ const record = Array.isArray(productRes.result?.items)
476
+ ? productRes.result?.items?.[0]
477
+ : undefined;
478
+ if (!record)
479
+ throw new Error(
480
+ t("catalog.products.edit.errors.notFound", "Product not found."),
481
+ );
220
482
  const rawMetadata = isRecord(record.metadata)
221
483
  ? (record.metadata as Record<string, unknown>)
222
- : isRecord((record as any).metadata)
223
- ? ((record as any).metadata as Record<string, unknown>)
224
- : null
484
+ : null;
225
485
  const dimensions = normalizeProductDimensions(
226
- (record as any).dimensions ?? (rawMetadata as any)?.dimensions ?? null,
227
- )
486
+ record.dimensions ?? rawMetadata?.dimensions ?? null,
487
+ );
488
+ const rawWeightMeta = isRecord(rawMetadata?.weight)
489
+ ? (rawMetadata.weight as Record<string, unknown>)
490
+ : null;
228
491
  const weight = normalizeProductWeight({
229
492
  value:
230
- (record as any).weight_value ??
231
- (record as any).weightValue ??
232
- (isRecord((rawMetadata as any)?.weight) ? (rawMetadata as any).weight.value : undefined),
493
+ record.weight_value ??
494
+ record.weightValue ??
495
+ rawWeightMeta?.value,
233
496
  unit:
234
- (record as any).weight_unit ??
235
- (record as any).weightUnit ??
236
- (isRecord((rawMetadata as any)?.weight) ? (rawMetadata as any).weight.unit : undefined),
237
- })
238
- const metadata = normalizeMetadata(rawMetadata)
497
+ record.weight_unit ??
498
+ record.weightUnit ??
499
+ rawWeightMeta?.unit,
500
+ });
501
+ const metadata = normalizeMetadata(rawMetadata);
239
502
  const optionSchemaId =
240
- typeof record.option_schema_id === 'string'
503
+ typeof record.option_schema_id === "string"
241
504
  ? record.option_schema_id
242
- : typeof (record as any).optionSchemaId === 'string'
243
- ? (record as any).optionSchemaId
244
- : null
505
+ : typeof record.optionSchemaId === "string"
506
+ ? record.optionSchemaId
507
+ : null;
245
508
  const taxRateId =
246
- typeof (record as any).tax_rate_id === 'string'
247
- ? (record as any).tax_rate_id
248
- : typeof (record as any).taxRateId === 'string'
249
- ? (record as any).taxRateId
250
- : null
251
- const optionSchemaTemplate = optionSchemaId ? await fetchOptionSchemaTemplate(optionSchemaId) : null
252
- const normalizedSchema = normalizeOptionSchemaRecord(optionSchemaTemplate?.schema)
253
- let optionInputs = normalizedSchema ? convertSchemaToProductOptions(normalizedSchema) : []
509
+ typeof record.tax_rate_id === "string"
510
+ ? record.tax_rate_id
511
+ : typeof record.taxRateId === "string"
512
+ ? record.taxRateId
513
+ : null;
514
+ const optionSchemaTemplate = optionSchemaId
515
+ ? await fetchOptionSchemaTemplate(optionSchemaId)
516
+ : null;
517
+ const normalizedSchema = normalizeOptionSchemaRecord(
518
+ optionSchemaTemplate?.schema,
519
+ );
520
+ let optionInputs = normalizedSchema
521
+ ? convertSchemaToProductOptions(normalizedSchema)
522
+ : [];
254
523
  if (!optionInputs.length) {
255
- optionInputs = readOptionSchema(metadata)
524
+ optionInputs = readOptionSchema(metadata);
256
525
  }
257
- const attachments = await fetchAttachments(productId!)
258
- const { customValues } = extractCustomFields(record)
259
- const offers = readOfferSnapshots(record)
260
- offerSnapshotsRef.current = offers
261
- const channelIds = extractChannelIds(offers)
262
- const channelOptionEntries = buildChannelOptions(offers)
263
- const { ids: categoryIds, options: categoryOptions } = readCategorySelections(record)
264
- const { values: tagValues, options: tagOptions } = readTagSelections(record)
265
- const defaultMediaId = typeof record.default_media_id === 'string' ? record.default_media_id : (record as any).defaultMediaId ?? null
266
- const defaultMediaUrl = typeof record.default_media_url === 'string' ? record.default_media_url : (record as any).defaultMediaUrl ?? ''
526
+ const [attachments, conversionsRes] = await Promise.all([
527
+ fetchAttachments(productId!),
528
+ apiCall<{ items?: Array<Record<string, unknown>> }>(
529
+ `/api/catalog/product-unit-conversions?productId=${encodeURIComponent(productId!)}&page=1&pageSize=100`,
530
+ undefined,
531
+ { fallback: { items: [] } },
532
+ ),
533
+ ]);
534
+ const conversionRows = conversionsRes.ok
535
+ ? readProductConversionRows(conversionsRes.result?.items)
536
+ : [];
537
+ initialConversionsRef.current = conversionRows;
538
+ const { customValues } = extractCustomFields(record);
539
+ const offers = readOfferSnapshots(record);
540
+ offerSnapshotsRef.current = offers;
541
+ const channelIds = extractChannelIds(offers);
542
+ const channelOptionEntries = buildChannelOptions(offers);
543
+ const { ids: categoryIds, options: categoryOptions } =
544
+ readCategorySelections(record);
545
+ const { values: tagValues, options: tagOptions } =
546
+ readTagSelections(record);
547
+ const defaultMediaId =
548
+ typeof record.default_media_id === "string"
549
+ ? record.default_media_id
550
+ : typeof record.defaultMediaId === "string"
551
+ ? record.defaultMediaId
552
+ : null;
553
+ const defaultMediaUrl =
554
+ typeof record.default_media_url === "string"
555
+ ? record.default_media_url
556
+ : typeof record.defaultMediaUrl === "string"
557
+ ? record.defaultMediaUrl
558
+ : "";
559
+ const {
560
+ defaultUnit,
561
+ defaultSalesUnit,
562
+ defaultSalesUnitQuantity,
563
+ roundingScale,
564
+ roundingMode,
565
+ unitPriceEnabled,
566
+ unitPriceReferenceUnit,
567
+ unitPriceBaseQuantity,
568
+ } = extractUomFields(record);
267
569
  const initial: ProductFormValues = {
268
- title: typeof record.title === 'string' ? record.title : '',
269
- subtitle: typeof record.subtitle === 'string' ? record.subtitle : '',
270
- handle: typeof record.handle === 'string' ? record.handle : '',
271
- description: typeof record.description === 'string' ? record.description : '',
570
+ title: typeof record.title === "string" ? record.title : "",
571
+ subtitle: typeof record.subtitle === "string" ? record.subtitle : "",
572
+ handle: typeof record.handle === "string" ? record.handle : "",
573
+ description:
574
+ typeof record.description === "string" ? record.description : "",
272
575
  useMarkdown: Boolean(metadata.__useMarkdown),
273
576
  taxRateId,
274
577
  mediaDraftId: productId!,
@@ -282,544 +585,776 @@ export default function EditCatalogProductPage({ params }: { params?: { id?: str
282
585
  metadata,
283
586
  dimensions: sanitizeProductDimensions(dimensions),
284
587
  weight: sanitizeProductWeight(weight),
588
+ defaultUnit: defaultUnit ?? null,
589
+ defaultSalesUnit: defaultSalesUnit ?? defaultUnit ?? null,
590
+ defaultSalesUnitQuantity: String(defaultSalesUnitQuantity ?? 1),
591
+ uomRoundingScale: String(roundingScale),
592
+ uomRoundingMode: roundingMode,
593
+ unitPriceEnabled,
594
+ unitPriceReferenceUnit:
595
+ unitPriceReferenceUnit &&
596
+ UNIT_PRICE_REFERENCE_UNITS.has(
597
+ unitPriceReferenceUnit as ProductUnitPriceReferenceUnit,
598
+ )
599
+ ? (unitPriceReferenceUnit as ProductUnitPriceReferenceUnit)
600
+ : null,
601
+ unitPriceBaseQuantity:
602
+ unitPriceBaseQuantity === null ? "" : String(unitPriceBaseQuantity),
603
+ unitConversions: conversionRows,
285
604
  customFieldsetCode:
286
- typeof record.custom_fieldset_code === 'string'
605
+ typeof record.custom_fieldset_code === "string"
287
606
  ? record.custom_fieldset_code
288
- : typeof (record as any).customFieldsetCode === 'string'
289
- ? (record as any).customFieldsetCode
607
+ : typeof record.customFieldsetCode === "string"
608
+ ? record.customFieldsetCode
290
609
  : null,
291
610
  categoryIds,
292
611
  channelIds,
293
612
  tags: tagValues,
294
- }
613
+ };
295
614
  if (!cancelled) {
296
- setInitialValues({ ...initial, ...customValues })
615
+ setInitialValues({ ...initial, ...customValues });
297
616
  setCategorizeOptions({
298
617
  categories: categoryOptions,
299
618
  channels: channelOptionEntries,
300
619
  tags: tagOptions,
301
- })
620
+ });
302
621
  }
303
- await loadVariants(productId!)
622
+ await loadVariants(productId!);
304
623
  } catch (err) {
305
- console.error('catalog.products.edit.load failed', err)
624
+ console.error("catalog.products.edit.load failed", err);
306
625
  if (!cancelled) {
307
- const message = err instanceof Error && err.message
308
- ? err.message
309
- : t('catalog.products.edit.errors.load', 'Failed to load product details.')
310
- setError(message)
626
+ const message =
627
+ err instanceof Error && err.message
628
+ ? err.message
629
+ : t(
630
+ "catalog.products.edit.errors.load",
631
+ "Failed to load product details.",
632
+ );
633
+ setError(message);
311
634
  }
312
635
  } finally {
313
- if (!cancelled) setLoading(false)
636
+ if (!cancelled) setLoading(false);
314
637
  }
315
638
  }
316
- loadProduct()
317
- return () => { cancelled = true }
318
- }, [productId, t])
639
+ loadProduct();
640
+ return () => {
641
+ cancelled = true;
642
+ };
643
+ }, [fetchAttachments, loadVariants, productId, t]);
319
644
 
320
645
  React.useEffect(() => {
321
- let cancelled = false
646
+ let cancelled = false;
322
647
  async function loadPriceKinds() {
323
648
  try {
324
- const payload = await readApiResultOrThrow<{ items?: PriceKindApiPayload[] }>(
325
- '/api/catalog/price-kinds?pageSize=100',
326
- undefined,
327
- { fallback: { items: [] } },
328
- )
329
- const items = Array.isArray(payload.items) ? payload.items : []
649
+ const payload = await readApiResultOrThrow<{
650
+ items?: PriceKindApiPayload[];
651
+ }>("/api/catalog/price-kinds?pageSize=100", undefined, {
652
+ fallback: { items: [] },
653
+ });
654
+ const items = Array.isArray(payload.items) ? payload.items : [];
330
655
  const summaries = items
331
656
  .map((item) => normalizePriceKindSummary(item))
332
- .filter((entry): entry is PriceKindSummary => !!entry)
657
+ .filter((entry): entry is PriceKindSummary => !!entry);
333
658
  if (!cancelled) {
334
- setPriceKinds(summaries)
659
+ setPriceKinds(summaries);
335
660
  }
336
661
  } catch (err) {
337
- console.error('catalog.price-kinds.fetch failed', err)
662
+ console.error("catalog.price-kinds.fetch failed", err);
338
663
  if (!cancelled) {
339
- setPriceKinds([])
664
+ setPriceKinds([]);
340
665
  }
341
666
  }
342
667
  }
343
- loadPriceKinds().catch(() => {})
668
+ loadPriceKinds().catch(() => {});
344
669
  return () => {
345
- cancelled = true
346
- }
347
- }, [])
670
+ cancelled = true;
671
+ };
672
+ }, []);
348
673
 
349
- const loadVariants = React.useCallback(async (id: string) => {
350
- try {
351
- const [variantsRes, pricesRes] = await Promise.all([
352
- apiCall<VariantListResponse>(`/api/catalog/variants?productId=${encodeURIComponent(id)}&page=1&pageSize=100`),
353
- apiCall<VariantPriceListResponse>(`/api/catalog/prices?productId=${encodeURIComponent(id)}&page=1&pageSize=100`),
354
- ])
355
- if (!variantsRes.ok) {
356
- setVariants([])
357
- return
674
+ const handleVariantDeleted = React.useCallback((variantId: string) => {
675
+ setVariants((prev) => prev.filter((variant) => variant.id !== variantId));
676
+ }, []);
677
+
678
+ const groups = React.useMemo<CrudFormGroup[]>(
679
+ () => [
680
+ {
681
+ id: "details",
682
+ column: 1,
683
+ component: ({ values, setValue, errors }) => (
684
+ <ProductDetailsSection
685
+ values={values as ProductFormValues}
686
+ setValue={setValue}
687
+ errors={errors}
688
+ productId={productId ?? ""}
689
+ />
690
+ ),
691
+ },
692
+ {
693
+ id: "dimensions",
694
+ column: 1,
695
+ component: ({ values, setValue, errors }) => (
696
+ <ProductDimensionsSection
697
+ values={values as ProductFormValues}
698
+ setValue={setValue}
699
+ errors={errors}
700
+ />
701
+ ),
702
+ },
703
+ {
704
+ id: "metadata",
705
+ column: 1,
706
+ component: ({ values, setValue, errors }) => (
707
+ <ProductMetadataSection
708
+ values={values as ProductFormValues}
709
+ setValue={setValue}
710
+ errors={errors}
711
+ />
712
+ ),
713
+ },
714
+ {
715
+ id: "options",
716
+ column: 1,
717
+ component: ({ values, setValue, errors }) => (
718
+ <ProductOptionsSection
719
+ values={values as ProductFormValues}
720
+ setValue={setValue}
721
+ errors={errors}
722
+ />
723
+ ),
724
+ },
725
+ {
726
+ id: "product-uom",
727
+ column: 1,
728
+ bare: true,
729
+ component: ({ values, setValue, errors }) => (
730
+ <ProductUomSection
731
+ values={values as ProductFormValues}
732
+ setValue={setValue}
733
+ errors={errors}
734
+ />
735
+ ),
736
+ },
737
+ {
738
+ id: "variants",
739
+ column: 1,
740
+ component: ({ values, setValue, errors }) => (
741
+ <ProductVariantsSection
742
+ values={values as ProductFormValues}
743
+ setValue={setValue}
744
+ errors={errors}
745
+ productId={productId ?? ""}
746
+ variants={variants}
747
+ priceKinds={priceKinds}
748
+ onVariantDeleted={handleVariantDeleted}
749
+ onVariantsReload={refreshVariants}
750
+ />
751
+ ),
752
+ },
753
+ {
754
+ id: "meta",
755
+ column: 2,
756
+ title: t("catalog.products.create.meta.title", "Product meta"),
757
+ description: t(
758
+ "catalog.products.create.meta.description",
759
+ "Manage subtitle and handle for storefronts.",
760
+ ),
761
+ component: ({ values, setValue, errors }) => (
762
+ <ProductMetaSection
763
+ values={values as ProductFormValues}
764
+ setValue={setValue}
765
+ errors={errors}
766
+ taxRates={taxRates}
767
+ />
768
+ ),
769
+ },
770
+ {
771
+ id: "categorize",
772
+ column: 2,
773
+ title: t("catalog.products.create.organize.title", "Categorize"),
774
+ description: t(
775
+ "catalog.products.create.organize.description",
776
+ "Assign categories, sales channels, and tags.",
777
+ ),
778
+ component: ({ values, setValue, errors }) => (
779
+ <ProductCategorizeSection
780
+ values={values as ProductFormValues}
781
+ setValue={setValue}
782
+ errors={errors}
783
+ initialCategoryOptions={categorizeOptions.categories}
784
+ initialChannelOptions={categorizeOptions.channels}
785
+ initialTagOptions={categorizeOptions.tags}
786
+ />
787
+ ),
788
+ },
789
+ {
790
+ id: "custom-fields",
791
+ column: 2,
792
+ title: t("catalog.products.edit.custom.title", "Custom attributes"),
793
+ kind: "customFields",
794
+ },
795
+ ],
796
+ [
797
+ categorizeOptions,
798
+ handleVariantDeleted,
799
+ priceKinds,
800
+ productId,
801
+ refreshVariants,
802
+ t,
803
+ taxRates,
804
+ variants,
805
+ ],
806
+ );
807
+
808
+ const handleSubmit = React.useCallback(
809
+ async (formValues: ProductFormValues) => {
810
+ if (!productId) {
811
+ throw createCrudFormError(
812
+ t(
813
+ "catalog.products.edit.errors.idMissing",
814
+ "Product identifier is missing.",
815
+ ),
816
+ );
358
817
  }
359
- const priceMap: Record<string, VariantPriceSummary[]> = {}
360
- if (pricesRes.ok) {
361
- const priceItems = Array.isArray(pricesRes.result?.items) ? pricesRes.result?.items : []
362
- for (const item of priceItems) {
363
- const summary = mapVariantPriceSummary(item)
364
- if (!summary) continue
365
- if (!priceMap[summary.variantId]) {
366
- priceMap[summary.variantId] = []
367
- }
368
- priceMap[summary.variantId].push(summary)
369
- }
370
- Object.keys(priceMap).forEach((key) => {
371
- priceMap[key].sort((a, b) => {
372
- const left = (a.priceKindId ?? '') + (a.currencyCode ?? '')
373
- const right = (b.priceKindId ?? '') + (b.currencyCode ?? '')
374
- return left.localeCompare(right)
375
- })
376
- })
818
+ const parsed = productFormSchema.safeParse(formValues);
819
+ if (!parsed.success) {
820
+ const issues = parsed.error.issues;
821
+ const fieldErrors: Record<string, string> = {};
822
+ issues.forEach((issue) => {
823
+ const path = issue.path.join(".") || "form";
824
+ if (!fieldErrors[path]) fieldErrors[path] = issue.message;
825
+ });
826
+ const message =
827
+ issues[0]?.message ??
828
+ t(
829
+ "catalog.products.edit.errors.validation",
830
+ "Fix highlighted fields.",
831
+ );
832
+ throw createCrudFormError(message, fieldErrors);
377
833
  }
378
- const items = Array.isArray(variantsRes.result?.items) ? variantsRes.result?.items : []
379
- setVariants(
380
- items
381
- .map((variant) => {
382
- const variantId = typeof variant.id === 'string' ? variant.id : null
383
- if (!variantId) return null
384
- const variantRecord = variant as Record<string, unknown>
385
- const optionValues =
386
- normalizeVariantOptionValues(variantRecord?.['option_values']) ??
387
- normalizeVariantOptionValues(variantRecord?.optionValues)
388
- return {
389
- id: variantId,
390
- name: typeof variant.name === 'string' && variant.name.trim().length ? variant.name : variant.sku ?? variantId,
391
- sku: typeof variant.sku === 'string' ? variant.sku : '',
392
- isDefault: Boolean(variant.is_default ?? variant.isDefault),
393
- prices: priceMap[variantId] ?? [],
394
- optionValues,
395
- }
834
+ const values: ProductFormValues = {
835
+ ...BASE_INITIAL_VALUES,
836
+ ...parsed.data,
837
+ title: parsed.data.title ?? "",
838
+ subtitle: parsed.data.subtitle ?? "",
839
+ handle: parsed.data.handle ?? "",
840
+ description: parsed.data.description ?? "",
841
+ useMarkdown: parsed.data.useMarkdown ?? false,
842
+ taxRateId: parsed.data.taxRateId ?? null,
843
+ mediaDraftId: parsed.data.mediaDraftId ?? productId!,
844
+ mediaItems: Array.isArray(parsed.data.mediaItems)
845
+ ? parsed.data.mediaItems
846
+ : [],
847
+ defaultMediaId: parsed.data.defaultMediaId ?? null,
848
+ defaultMediaUrl: parsed.data.defaultMediaUrl ?? "",
849
+ hasVariants: parsed.data.hasVariants ?? false,
850
+ options: Array.isArray(parsed.data.options) ? parsed.data.options : [],
851
+ variants: Array.isArray(parsed.data.variants)
852
+ ? parsed.data.variants
853
+ : [],
854
+ metadata: parsed.data.metadata ?? {},
855
+ dimensions: parsed.data.dimensions ?? null,
856
+ weight: parsed.data.weight ?? null,
857
+ defaultUnit: parsed.data.defaultUnit ?? null,
858
+ defaultSalesUnit: parsed.data.defaultSalesUnit ?? null,
859
+ defaultSalesUnitQuantity:
860
+ parsed.data.defaultSalesUnitQuantity?.toString() ?? "1",
861
+ uomRoundingScale: parsed.data.uomRoundingScale?.toString() ?? "4",
862
+ uomRoundingMode: parsed.data.uomRoundingMode ?? "half_up",
863
+ unitPriceEnabled: Boolean(parsed.data.unitPriceEnabled),
864
+ unitPriceReferenceUnit: parsed.data.unitPriceReferenceUnit ?? null,
865
+ unitPriceBaseQuantity:
866
+ parsed.data.unitPriceBaseQuantity?.toString() ?? "",
867
+ unitConversions: Array.isArray(parsed.data.unitConversions)
868
+ ? parsed.data.unitConversions.map((entry) => ({
869
+ id: toTrimmedOrNull(entry.id) ?? null,
870
+ unitCode: toTrimmedOrNull(entry.unitCode) ?? "",
871
+ toBaseFactor:
872
+ toPositiveNumberOrNull(entry.toBaseFactor) === null
873
+ ? ""
874
+ : String(toPositiveNumberOrNull(entry.toBaseFactor)),
875
+ sortOrder:
876
+ typeof entry.sortOrder === "number" &&
877
+ Number.isFinite(entry.sortOrder)
878
+ ? String(entry.sortOrder)
879
+ : "",
880
+ isActive: entry.isActive !== false,
881
+ }))
882
+ : [],
883
+ customFieldsetCode: parsed.data.customFieldsetCode ?? null,
884
+ categoryIds: parsed.data.categoryIds ?? [],
885
+ channelIds: parsed.data.channelIds ?? [],
886
+ tags: parsed.data.tags ?? [],
887
+ optionSchemaId: parsed.data.optionSchemaId ?? null,
888
+ };
889
+ const title = values.title?.trim();
890
+ if (!title) {
891
+ const message = t(
892
+ "catalog.products.create.errors.title",
893
+ "Provide a product title.",
894
+ );
895
+ throw createCrudFormError(message, { title: message });
896
+ }
897
+ const handle = values.handle?.trim() || undefined;
898
+ const description = values.description?.trim() || undefined;
899
+ const metadata = buildMetadataPayload(values);
900
+ const dimensions = sanitizeProductDimensions(values.dimensions ?? null);
901
+ const weight = sanitizeProductWeight(values.weight ?? null);
902
+ const resolveTaxRateValue = (taxRateId?: string | null) => {
903
+ if (!taxRateId) return null;
904
+ const match = taxRates.find((rate) => rate.id === taxRateId);
905
+ return typeof match?.rate === "number" && Number.isFinite(match.rate)
906
+ ? match.rate
907
+ : null;
908
+ };
909
+ const productTaxRateValue = resolveTaxRateValue(values.taxRateId ?? null);
910
+ const defaultMediaId =
911
+ typeof values.defaultMediaId === "string" &&
912
+ values.defaultMediaId.trim().length
913
+ ? values.defaultMediaId
914
+ : null;
915
+ const defaultMediaEntry = defaultMediaId
916
+ ? values.mediaItems.find((item) => item.id === defaultMediaId)
917
+ : null;
918
+ const defaultMediaUrl = defaultMediaEntry
919
+ ? buildAttachmentImageUrl(defaultMediaEntry.id, {
920
+ slug: slugifyAttachmentFileName(defaultMediaEntry.fileName),
396
921
  })
397
- .filter((entry): entry is VariantSummary => !!entry),
398
- )
399
- } catch (err) {
400
- console.error('catalog.variants.fetch failed', err)
401
- setVariants([])
402
- }
403
- }, [])
404
-
405
- const refreshVariants = React.useCallback(async () => {
406
- if (!productId) return
407
- await loadVariants(productId)
408
- }, [loadVariants, productId])
409
-
410
- const fetchAttachments = React.useCallback(async (id: string): Promise<ProductMediaItem[]> => {
411
- try {
412
- const res = await apiCall<AttachmentListResponse>(
413
- `/api/attachments?entityId=${encodeURIComponent(E.catalog.catalog_product)}&recordId=${encodeURIComponent(id)}`,
414
- )
415
- if (!res.ok) return []
416
- return (res.result?.items ?? []).map((item) => ({
417
- id: item.id,
418
- url: item.url,
419
- fileName: item.fileName,
420
- fileSize: item.fileSize,
421
- thumbnailUrl: item.thumbnailUrl ?? undefined,
422
- }))
423
- } catch (err) {
424
- console.error('attachments.fetch failed', err)
425
- return []
426
- }
427
- }, [])
428
-
429
- function mapVariantPriceSummary(item: VariantPriceSummaryApi | undefined): VariantPriceSummary | null {
430
- if (!item) return null
431
- const getString = (value: unknown): string | null => {
432
- if (typeof value === 'string' && value.trim().length) return value.trim()
433
- if (typeof value === 'number' || typeof value === 'bigint') return String(value)
434
- return null
435
- }
436
- const variantId = getString(item.variant_id ?? item.variantId)
437
- const id = getString(item.id)
438
- if (!variantId || !id) return null
439
- const priceKindId = getString(item.price_kind_id ?? item.priceKindId)
440
- const currencyCode = getString(item.currency_code ?? item.currencyCode)
441
- const unitGross = getString(item.unit_price_gross ?? item.unitPriceGross)
442
- const unitNet = getString(item.unit_price_net ?? item.unitPriceNet)
443
- const amount = unitGross ?? unitNet ?? null
444
- return {
445
- id,
446
- variantId,
447
- priceKindId,
448
- currencyCode,
449
- amount,
450
- displayMode: unitGross ? 'including-tax' : 'excluding-tax',
451
- }
452
- }
453
-
454
- function normalizeVariantOptionValues(input: unknown): Record<string, string> | null {
455
- if (!input || typeof input !== 'object') return null
456
- const result: Record<string, string> = {}
457
- Object.entries(input as Record<string, unknown>).forEach(([key, value]) => {
458
- if (typeof key === 'string' && typeof value === 'string' && key.trim().length) {
459
- result[key] = value
460
- }
461
- })
462
- return Object.keys(result).length ? result : null
463
- }
464
-
465
- const handleVariantDeleted = React.useCallback((variantId: string) => {
466
- setVariants((prev) => prev.filter((variant) => variant.id !== variantId))
467
- }, [])
468
-
469
- const groups = React.useMemo<CrudFormGroup[]>(() => [
470
- {
471
- id: 'details',
472
- column: 1,
473
- component: ({ values, setValue, errors }) => (
474
- <ProductDetailsSection
475
- values={values as ProductFormValues}
476
- setValue={setValue}
477
- errors={errors}
478
- productId={productId ?? ''}
479
- />
480
- ),
481
- },
482
- {
483
- id: 'dimensions',
484
- column: 1,
485
- component: ({ values, setValue, errors }) => (
486
- <ProductDimensionsSection values={values as ProductFormValues} setValue={setValue} errors={errors} />
487
- ),
488
- },
489
- {
490
- id: 'metadata',
491
- column: 1,
492
- component: ({ values, setValue, errors }) => (
493
- <ProductMetadataSection values={values as ProductFormValues} setValue={setValue} errors={errors} />
494
- ),
495
- },
496
- {
497
- id: 'options',
498
- column: 1,
499
- component: ({ values, setValue, errors }) => (
500
- <ProductOptionsSection values={values as ProductFormValues} setValue={setValue} errors={errors} />
501
- ),
502
- },
503
- {
504
- id: 'variants',
505
- column: 1,
506
- component: ({ values, setValue, errors }) => (
507
- <ProductVariantsSection
508
- values={values as ProductFormValues}
509
- setValue={setValue}
510
- errors={errors}
511
- productId={productId ?? ''}
512
- variants={variants}
513
- priceKinds={priceKinds}
514
- onVariantDeleted={handleVariantDeleted}
515
- onVariantsReload={refreshVariants}
516
- />
517
- ),
518
- },
519
- {
520
- id: 'meta',
521
- column: 2,
522
- title: t('catalog.products.create.meta.title', 'Product meta'),
523
- description: t('catalog.products.create.meta.description', 'Manage subtitle and handle for storefronts.'),
524
- component: ({ values, setValue, errors }) => (
525
- <ProductMetaSection values={values as ProductFormValues} setValue={setValue} errors={errors} taxRates={taxRates} />
526
- ),
527
- },
528
- {
529
- id: 'categorize',
530
- column: 2,
531
- title: t('catalog.products.create.organize.title', 'Categorize'),
532
- description: t('catalog.products.create.organize.description', 'Assign categories, sales channels, and tags.'),
533
- component: ({ values, setValue, errors }) => (
534
- <ProductCategorizeSection
535
- values={values as ProductFormValues}
536
- setValue={setValue}
537
- errors={errors}
538
- initialCategoryOptions={categorizeOptions.categories}
539
- initialChannelOptions={categorizeOptions.channels}
540
- initialTagOptions={categorizeOptions.tags}
541
- />
542
- ),
543
- },
544
- {
545
- id: 'custom-fields',
546
- column: 2,
547
- title: t('catalog.products.edit.custom.title', 'Custom attributes'),
548
- kind: 'customFields',
549
- },
550
- ], [categorizeOptions, handleVariantDeleted, priceKinds, productId, t, taxRates, variants])
551
-
552
- const handleSubmit = React.useCallback(async (formValues: ProductFormValues) => {
553
- if (!productId) {
554
- throw createCrudFormError(t('catalog.products.edit.errors.idMissing', 'Product identifier is missing.'))
555
- }
556
- const parsed = productFormSchema.safeParse(formValues)
557
- if (!parsed.success) {
558
- const issues = parsed.error.issues
559
- const fieldErrors: Record<string, string> = {}
560
- issues.forEach((issue) => {
561
- const path = issue.path.join('.') || 'form'
562
- if (!fieldErrors[path]) fieldErrors[path] = issue.message
563
- })
564
- const message = issues[0]?.message ?? t('catalog.products.edit.errors.validation', 'Fix highlighted fields.')
565
- throw createCrudFormError(message, fieldErrors)
566
- }
567
- const values: ProductFormValues = {
568
- ...BASE_INITIAL_VALUES,
569
- ...parsed.data,
570
- title: parsed.data.title ?? '',
571
- subtitle: parsed.data.subtitle ?? '',
572
- handle: parsed.data.handle ?? '',
573
- description: parsed.data.description ?? '',
574
- useMarkdown: parsed.data.useMarkdown ?? false,
575
- taxRateId: parsed.data.taxRateId ?? null,
576
- mediaDraftId: parsed.data.mediaDraftId ?? productId!,
577
- mediaItems: Array.isArray(parsed.data.mediaItems) ? parsed.data.mediaItems : [],
578
- defaultMediaId: parsed.data.defaultMediaId ?? null,
579
- defaultMediaUrl: parsed.data.defaultMediaUrl ?? '',
580
- hasVariants: parsed.data.hasVariants ?? false,
581
- options: Array.isArray(parsed.data.options) ? parsed.data.options : [],
582
- variants: Array.isArray(parsed.data.variants) ? parsed.data.variants : [],
583
- metadata: parsed.data.metadata ?? {},
584
- dimensions: parsed.data.dimensions ?? null,
585
- weight: parsed.data.weight ?? null,
586
- customFieldsetCode: parsed.data.customFieldsetCode ?? null,
587
- categoryIds: parsed.data.categoryIds ?? [],
588
- channelIds: parsed.data.channelIds ?? [],
589
- tags: parsed.data.tags ?? [],
590
- optionSchemaId: parsed.data.optionSchemaId ?? null,
591
- }
592
- const title = values.title?.trim()
593
- if (!title) {
594
- const message = t('catalog.products.create.errors.title', 'Provide a product title.')
595
- throw createCrudFormError(message, { title: message })
596
- }
597
- const handle = values.handle?.trim() || undefined
598
- const description = values.description?.trim() || undefined
599
- const metadata = buildMetadataPayload(values)
600
- const dimensions = sanitizeProductDimensions(values.dimensions ?? null)
601
- const weight = sanitizeProductWeight(values.weight ?? null)
602
- const resolveTaxRateValue = (taxRateId?: string | null) => {
603
- if (!taxRateId) return null
604
- const match = taxRates.find((rate) => rate.id === taxRateId)
605
- return typeof match?.rate === 'number' && Number.isFinite(match.rate) ? match.rate : null
606
- }
607
- const productTaxRateValue = resolveTaxRateValue(values.taxRateId ?? null)
608
- const defaultMediaId = typeof values.defaultMediaId === 'string' && values.defaultMediaId.trim().length
609
- ? values.defaultMediaId
610
- : null
611
- const defaultMediaEntry = defaultMediaId ? values.mediaItems.find((item) => item.id === defaultMediaId) : null
612
- const defaultMediaUrl = defaultMediaEntry
613
- ? buildAttachmentImageUrl(defaultMediaEntry.id, {
614
- slug: slugifyAttachmentFileName(defaultMediaEntry.fileName),
615
- })
616
- : null
617
- const payload: Record<string, unknown> = {
618
- id: productId,
619
- title,
620
- subtitle: values.subtitle?.trim() || undefined,
621
- description,
622
- handle,
623
- taxRateId: values.taxRateId ?? null,
624
- taxRate: productTaxRateValue ?? null,
625
- isConfigurable: Boolean(values.hasVariants),
626
- metadata,
627
- dimensions,
628
- weightValue: weight?.value ?? null,
629
- weightUnit: weight?.unit ?? null,
630
- defaultMediaId: defaultMediaId ?? undefined,
631
- defaultMediaUrl: defaultMediaUrl ?? undefined,
632
- customFieldsetCode: values.customFieldsetCode?.trim().length ? values.customFieldsetCode : undefined,
633
- }
634
- const categoryIds = normalizeIdList(values.categoryIds)
635
- const channelIds = normalizeIdList(values.channelIds)
636
- const tags = normalizeTagValues(values.tags)
637
- payload.categoryIds = categoryIds
638
- payload.tags = tags
639
- const optionSchemaDefinition = buildOptionSchemaDefinition(values.options, title)
640
- if (optionSchemaDefinition) {
641
- payload.optionSchema = optionSchemaDefinition
642
- } else if (values.optionSchemaId) {
643
- payload.optionSchemaId = null
644
- }
645
- const customFields = collectCustomFieldValues(values)
646
- if (Object.keys(customFields).length) {
647
- payload.customFields = customFields
648
- }
649
- const previousSnapshots = offerSnapshotsRef.current
650
- const offersPayload = buildOfferPayloads({
651
- channelIds,
652
- offerSnapshots: previousSnapshots,
653
- fallback: {
922
+ : null;
923
+ const defaultUnit = canonicalizeUnitCode(values.defaultUnit);
924
+ const defaultSalesUnit = canonicalizeUnitCode(values.defaultSalesUnit);
925
+ const defaultSalesUnitQuantity =
926
+ toPositiveNumberOrNull(values.defaultSalesUnitQuantity) ?? 1;
927
+ const uomRoundingScale = toIntegerInRangeOrDefault(
928
+ values.uomRoundingScale,
929
+ 0,
930
+ 6,
931
+ 4,
932
+ );
933
+ const uomRoundingMode: ProductUnitRoundingMode =
934
+ values.uomRoundingMode === "down" || values.uomRoundingMode === "up"
935
+ ? values.uomRoundingMode
936
+ : "half_up";
937
+ const unitPriceEnabled = Boolean(values.unitPriceEnabled);
938
+ const unitPriceReferenceUnit = canonicalizeUnitCode(
939
+ values.unitPriceReferenceUnit,
940
+ );
941
+ const unitPriceBaseQuantity = toPositiveNumberOrNull(
942
+ values.unitPriceBaseQuantity,
943
+ );
944
+ if (defaultSalesUnit && !defaultUnit) {
945
+ const message = t(
946
+ "catalog.products.uom.errors.baseRequired",
947
+ "Base unit is required when default sales unit is set.",
948
+ );
949
+ throw createCrudFormError(message, { defaultSalesUnit: message });
950
+ }
951
+ const conversionInputs = normalizeProductConversionInputs(
952
+ values.unitConversions,
953
+ t(
954
+ "catalog.products.uom.errors.duplicateConversion",
955
+ "Duplicate conversion unit is not allowed.",
956
+ ),
957
+ );
958
+ if (conversionInputs.length && !defaultUnit) {
959
+ const message = t(
960
+ "catalog.products.uom.errors.baseRequiredForConversions",
961
+ "Base unit is required when conversions are configured.",
962
+ );
963
+ throw createCrudFormError(message, { defaultUnit: message });
964
+ }
965
+ const defaultUnitKey = defaultUnit?.toLowerCase() ?? null;
966
+ const defaultSalesUnitKey = defaultSalesUnit?.toLowerCase() ?? null;
967
+ if (
968
+ defaultUnitKey &&
969
+ defaultSalesUnitKey &&
970
+ defaultSalesUnitKey !== defaultUnitKey
971
+ ) {
972
+ const hasDefaultSalesConversion = conversionInputs.some(
973
+ (entry) =>
974
+ entry.isActive &&
975
+ entry.unitCode.toLowerCase() === defaultSalesUnitKey,
976
+ );
977
+ if (!hasDefaultSalesConversion) {
978
+ const message = t(
979
+ "catalog.products.uom.errors.defaultSalesConversionRequired",
980
+ "Active conversion for default sales unit is required when it differs from base unit.",
981
+ );
982
+ throw createCrudFormError(message, {
983
+ defaultSalesUnit: message,
984
+ unitConversions: message,
985
+ });
986
+ }
987
+ }
988
+ if (unitPriceEnabled) {
989
+ if (
990
+ !unitPriceReferenceUnit ||
991
+ !UNIT_PRICE_REFERENCE_UNITS.has(
992
+ unitPriceReferenceUnit as ProductUnitPriceReferenceUnit,
993
+ )
994
+ ) {
995
+ const message = t(
996
+ "catalog.products.unitPrice.errors.referenceUnit",
997
+ "Reference unit is required when unit price display is enabled.",
998
+ );
999
+ throw createCrudFormError(message, {
1000
+ unitPriceReferenceUnit: message,
1001
+ });
1002
+ }
1003
+ if (unitPriceBaseQuantity === null) {
1004
+ const message = t(
1005
+ "catalog.products.unitPrice.errors.baseQuantity",
1006
+ "Base quantity is required when unit price display is enabled.",
1007
+ );
1008
+ throw createCrudFormError(message, {
1009
+ unitPriceBaseQuantity: message,
1010
+ });
1011
+ }
1012
+ }
1013
+ const payload: Record<string, unknown> = {
1014
+ id: productId,
654
1015
  title,
655
- description: description ?? undefined,
656
- defaultMediaId,
1016
+ subtitle: values.subtitle?.trim() || undefined,
1017
+ description,
1018
+ handle,
1019
+ taxRateId: values.taxRateId ?? null,
1020
+ taxRate: productTaxRateValue ?? null,
1021
+ isConfigurable: Boolean(values.hasVariants),
1022
+ metadata,
1023
+ dimensions,
1024
+ weightValue: weight?.value ?? null,
1025
+ weightUnit: weight?.unit ?? null,
1026
+ defaultMediaId: defaultMediaId ?? undefined,
657
1027
  defaultMediaUrl: defaultMediaUrl ?? undefined,
658
- },
659
- })
660
- payload.offers = offersPayload
661
- const removedOffers = previousSnapshots.filter(
662
- (offer) => typeof offer.id === 'string' && !channelIds.includes(offer.channelId),
663
- )
664
- if (removedOffers.length) {
665
- try {
666
- for (const offer of removedOffers) {
667
- if (!offer.id) continue
668
- await deleteCrud('catalog/offers', offer.id, {
669
- errorMessage: t('catalog.products.edit.offers.deleteError', 'Failed to remove sales channel offer.'),
670
- })
1028
+ defaultUnit: defaultUnit ?? null,
1029
+ defaultSalesUnit: defaultSalesUnit ?? defaultUnit ?? null,
1030
+ defaultSalesUnitQuantity,
1031
+ uomRoundingScale,
1032
+ uomRoundingMode,
1033
+ unitPriceEnabled,
1034
+ unitPriceReferenceUnit: unitPriceEnabled
1035
+ ? unitPriceReferenceUnit
1036
+ : undefined,
1037
+ unitPriceBaseQuantity: unitPriceEnabled
1038
+ ? unitPriceBaseQuantity
1039
+ : undefined,
1040
+ customFieldsetCode: values.customFieldsetCode?.trim().length
1041
+ ? values.customFieldsetCode
1042
+ : undefined,
1043
+ };
1044
+ const categoryIds = normalizeIdList(values.categoryIds);
1045
+ const channelIds = normalizeIdList(values.channelIds);
1046
+ const tags = normalizeTagValues(values.tags);
1047
+ payload.categoryIds = categoryIds;
1048
+ payload.tags = tags;
1049
+ const optionSchemaDefinition = buildOptionSchemaDefinition(
1050
+ values.options,
1051
+ title,
1052
+ );
1053
+ if (optionSchemaDefinition) {
1054
+ payload.optionSchema = optionSchemaDefinition;
1055
+ } else if (values.optionSchemaId) {
1056
+ payload.optionSchemaId = null;
1057
+ }
1058
+ const customFields = collectCustomFieldValues(values);
1059
+ if (Object.keys(customFields).length) {
1060
+ payload.customFields = customFields;
1061
+ }
1062
+ const previousSnapshots = offerSnapshotsRef.current;
1063
+ const offersPayload = buildOfferPayloads({
1064
+ channelIds,
1065
+ offerSnapshots: previousSnapshots,
1066
+ fallback: {
1067
+ title,
1068
+ description: description ?? undefined,
1069
+ defaultMediaId,
1070
+ defaultMediaUrl: defaultMediaUrl ?? undefined,
1071
+ },
1072
+ });
1073
+ payload.offers = offersPayload;
1074
+ const removedOffers = previousSnapshots.filter(
1075
+ (offer) =>
1076
+ typeof offer.id === "string" && !channelIds.includes(offer.channelId),
1077
+ );
1078
+ if (removedOffers.length) {
1079
+ try {
1080
+ for (const offer of removedOffers) {
1081
+ if (!offer.id) continue;
1082
+ await deleteCrud("catalog/offers", offer.id, {
1083
+ errorMessage: t(
1084
+ "catalog.products.edit.offers.deleteError",
1085
+ "Failed to remove sales channel offer.",
1086
+ ),
1087
+ });
1088
+ }
1089
+ } catch (err) {
1090
+ console.error("catalog.products.edit.offers.delete", err);
1091
+ throw createCrudFormError(
1092
+ t(
1093
+ "catalog.products.edit.offers.deleteError",
1094
+ "Failed to remove sales channel offer.",
1095
+ ),
1096
+ );
671
1097
  }
672
- } catch (err) {
673
- console.error('catalog.products.edit.offers.delete', err)
674
- throw createCrudFormError(
675
- t('catalog.products.edit.offers.deleteError', 'Failed to remove sales channel offer.'),
676
- )
677
1098
  }
678
- }
679
- await updateCrud('catalog/products', payload)
680
- offerSnapshotsRef.current = mergeOfferSnapshots(previousSnapshots, offersPayload)
681
- flash(t('catalog.products.edit.success', 'Product updated.'), 'success')
682
- router.push('/backend/catalog/products')
683
- }, [productId, t, taxRates, router])
1099
+ await updateCrud("catalog/products", payload);
1100
+ const previousConversionIds = new Set(
1101
+ initialConversionsRef.current
1102
+ .map((entry) => toTrimmedOrNull(entry.id))
1103
+ .filter((id): id is string => Boolean(id)),
1104
+ );
1105
+ const nextConversionIds = new Set(
1106
+ conversionInputs
1107
+ .map((entry) =>
1108
+ entry.id && entry.id.trim().length ? entry.id : null,
1109
+ )
1110
+ .filter((id): id is string => Boolean(id)),
1111
+ );
1112
+ const removedConversionIds = Array.from(previousConversionIds).filter(
1113
+ (id) => !nextConversionIds.has(id),
1114
+ );
1115
+ for (const conversionId of removedConversionIds) {
1116
+ await deleteCrud("catalog/product-unit-conversions", conversionId, {
1117
+ errorMessage: t(
1118
+ "catalog.products.uom.errors.sync",
1119
+ "Failed to synchronize product conversions.",
1120
+ ),
1121
+ });
1122
+ }
1123
+ const persistedConversions: ProductUnitConversionDraft[] = [];
1124
+ for (const conversion of conversionInputs) {
1125
+ if (conversion.id) {
1126
+ await updateCrud("catalog/product-unit-conversions", {
1127
+ id: conversion.id,
1128
+ unitCode: conversion.unitCode,
1129
+ toBaseFactor: conversion.toBaseFactor,
1130
+ sortOrder: conversion.sortOrder,
1131
+ isActive: conversion.isActive,
1132
+ });
1133
+ persistedConversions.push({
1134
+ id: conversion.id,
1135
+ unitCode: conversion.unitCode,
1136
+ toBaseFactor: String(conversion.toBaseFactor),
1137
+ sortOrder: String(conversion.sortOrder),
1138
+ isActive: conversion.isActive,
1139
+ });
1140
+ continue;
1141
+ }
1142
+ const created = await createCrud<{ id?: string }>(
1143
+ "catalog/product-unit-conversions",
1144
+ {
1145
+ productId,
1146
+ unitCode: conversion.unitCode,
1147
+ toBaseFactor: conversion.toBaseFactor,
1148
+ sortOrder: conversion.sortOrder,
1149
+ isActive: conversion.isActive,
1150
+ },
1151
+ );
1152
+ const createdId =
1153
+ created.result &&
1154
+ typeof created.result === "object" &&
1155
+ typeof (created.result as { id?: unknown }).id === "string"
1156
+ ? (created.result as { id: string }).id
1157
+ : null;
1158
+ persistedConversions.push({
1159
+ id: createdId,
1160
+ unitCode: conversion.unitCode,
1161
+ toBaseFactor: String(conversion.toBaseFactor),
1162
+ sortOrder: String(conversion.sortOrder),
1163
+ isActive: conversion.isActive,
1164
+ });
1165
+ }
1166
+ initialConversionsRef.current = persistedConversions;
1167
+ offerSnapshotsRef.current = mergeOfferSnapshots(
1168
+ previousSnapshots,
1169
+ offersPayload,
1170
+ );
1171
+ flash(t("catalog.products.edit.success", "Product updated."), "success");
1172
+ router.push("/backend/catalog/products");
1173
+ },
1174
+ [productId, t, taxRates, router],
1175
+ );
684
1176
 
685
1177
  if (!productId) {
686
1178
  return (
687
1179
  <Page>
688
1180
  <PageBody>
689
1181
  <div className="rounded border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
690
- {t('catalog.products.edit.errors.idMissing', 'Product identifier is missing.')}
1182
+ {t(
1183
+ "catalog.products.edit.errors.idMissing",
1184
+ "Product identifier is missing.",
1185
+ )}
691
1186
  </div>
692
1187
  </PageBody>
693
1188
  </Page>
694
- )
1189
+ );
695
1190
  }
696
1191
 
697
1192
  return (
698
1193
  <Page>
699
1194
  <PageBody>
700
1195
  {error ? (
701
- <div className="mb-4 rounded border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</div>
1196
+ <div className="mb-4 rounded border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
1197
+ {error}
1198
+ </div>
702
1199
  ) : null}
703
1200
  <CrudForm<ProductFormValues>
704
- title={t('catalog.products.edit.title', 'Edit product')}
1201
+ title={t("catalog.products.edit.title", "Edit product")}
705
1202
  backHref="/backend/catalog/products"
706
- versionHistory={{ resourceKind: 'catalog.product', resourceId: productId ? String(productId) : '' }}
1203
+ versionHistory={{
1204
+ resourceKind: "catalog.product",
1205
+ resourceId: productId ? String(productId) : "",
1206
+ }}
707
1207
  fields={[]}
708
1208
  groups={groups}
709
1209
  injectionSpotId="crud-form:catalog.product"
710
1210
  entityId={E.catalog.catalog_product}
711
- customFieldsetBindings={{ [E.catalog.catalog_product]: { valueKey: 'customFieldsetCode' } }}
1211
+ customFieldsetBindings={{
1212
+ [E.catalog.catalog_product]: { valueKey: "customFieldsetCode" },
1213
+ }}
712
1214
  initialValues={initialValues ?? undefined}
713
1215
  isLoading={loading}
714
- loadingMessage={t('catalog.products.edit.loading', 'Loading product')}
715
- submitLabel={t('catalog.products.edit.save', 'Save changes')}
1216
+ loadingMessage={t("catalog.products.edit.loading", "Loading product")}
1217
+ submitLabel={t("catalog.products.edit.save", "Save changes")}
716
1218
  cancelHref="/backend/catalog/products"
717
1219
  onSubmit={handleSubmit}
718
1220
  />
719
1221
  </PageBody>
720
1222
  </Page>
721
- )
1223
+ );
722
1224
  }
723
1225
 
1226
+ type ProductFormGroupProps = CrudFormGroupComponentProps & {
1227
+ values: ProductFormValues;
1228
+ };
724
1229
 
725
- type ProductFormGroupProps = CrudFormGroupComponentProps & { values: ProductFormValues }
1230
+ type ProductDetailsSectionProps = ProductFormGroupProps & { productId: string };
726
1231
 
727
- type ProductDetailsSectionProps = ProductFormGroupProps & { productId: string }
1232
+ type ProductMetaSectionProps = ProductFormGroupProps & {
1233
+ taxRates: TaxRateSummary[];
1234
+ };
728
1235
 
729
- type ProductMetaSectionProps = ProductFormGroupProps & { taxRates: TaxRateSummary[] }
730
-
731
- type ProductVariantsSectionProps = Omit<CrudFormGroupComponentProps, 'values'> & {
732
- values: ProductFormValues
733
- productId: string
734
- variants: VariantSummary[]
735
- priceKinds: PriceKindSummary[]
736
- onVariantDeleted: (variantId: string) => void
737
- onVariantsReload?: () => Promise<void> | void
738
- }
1236
+ type ProductVariantsSectionProps = Omit<
1237
+ CrudFormGroupComponentProps,
1238
+ "values"
1239
+ > & {
1240
+ values: ProductFormValues;
1241
+ productId: string;
1242
+ variants: VariantSummary[];
1243
+ priceKinds: PriceKindSummary[];
1244
+ onVariantDeleted: (variantId: string) => void;
1245
+ onVariantsReload?: () => Promise<void> | void;
1246
+ };
739
1247
 
740
- type ProductDimensionsSectionProps = ProductFormGroupProps
1248
+ type ProductDimensionsSectionProps = ProductFormGroupProps;
741
1249
 
742
- function ProductDetailsSection({ values, setValue, errors, productId }: ProductDetailsSectionProps) {
743
- const t = useT()
744
- const mediaItems = Array.isArray(values.mediaItems) ? values.mediaItems : []
1250
+ function ProductDetailsSection({
1251
+ values,
1252
+ setValue,
1253
+ errors,
1254
+ productId,
1255
+ }: ProductDetailsSectionProps) {
1256
+ const t = useT();
1257
+ const mediaItems = React.useMemo(
1258
+ () => (Array.isArray(values.mediaItems) ? values.mediaItems : []),
1259
+ [values.mediaItems],
1260
+ );
745
1261
 
746
1262
  const handleMediaItemsChange = React.useCallback(
747
1263
  (nextItems: ProductMediaItem[]) => {
748
- setValue('mediaItems', nextItems)
749
- const hasCurrent = nextItems.some((item) => item.id === values.defaultMediaId)
1264
+ setValue("mediaItems", nextItems);
1265
+ const hasCurrent = nextItems.some(
1266
+ (item) => item.id === values.defaultMediaId,
1267
+ );
750
1268
  if (!hasCurrent) {
751
- const fallbackId = nextItems[0]?.id ?? null
752
- setValue('defaultMediaId', fallbackId)
1269
+ const fallbackId = nextItems[0]?.id ?? null;
1270
+ setValue("defaultMediaId", fallbackId);
753
1271
  if (fallbackId && nextItems[0]) {
754
1272
  setValue(
755
- 'defaultMediaUrl',
1273
+ "defaultMediaUrl",
756
1274
  buildAttachmentImageUrl(fallbackId, {
757
1275
  slug: slugifyAttachmentFileName(nextItems[0].fileName),
758
1276
  }),
759
- )
1277
+ );
760
1278
  } else {
761
- setValue('defaultMediaUrl', '')
1279
+ setValue("defaultMediaUrl", "");
762
1280
  }
763
1281
  }
764
1282
  },
765
1283
  [setValue, values.defaultMediaId],
766
- )
1284
+ );
767
1285
 
768
1286
  const handleDefaultMediaChange = React.useCallback(
769
1287
  (attachmentId: string | null) => {
770
- setValue('defaultMediaId', attachmentId)
1288
+ setValue("defaultMediaId", attachmentId);
771
1289
  if (!attachmentId) {
772
- setValue('defaultMediaUrl', '')
773
- return
1290
+ setValue("defaultMediaUrl", "");
1291
+ return;
774
1292
  }
775
- const target = mediaItems.find((item) => item.id === attachmentId)
1293
+ const target = mediaItems.find((item) => item.id === attachmentId);
776
1294
  if (target) {
777
1295
  setValue(
778
- 'defaultMediaUrl',
779
- buildAttachmentImageUrl(target.id, { slug: slugifyAttachmentFileName(target.fileName) }),
780
- )
1296
+ "defaultMediaUrl",
1297
+ buildAttachmentImageUrl(target.id, {
1298
+ slug: slugifyAttachmentFileName(target.fileName),
1299
+ }),
1300
+ );
781
1301
  }
782
1302
  },
783
1303
  [mediaItems, setValue],
784
- )
1304
+ );
785
1305
 
786
1306
  return (
787
1307
  <div className="space-y-6">
788
1308
  <div className="space-y-2">
789
1309
  <Label className="flex items-center gap-1">
790
- {t('catalog.products.form.title', 'Title')}
1310
+ {t("catalog.products.form.title", "Title")}
791
1311
  <span className="text-red-600">*</span>
792
1312
  </Label>
793
1313
  <Input
794
1314
  value={values.title}
795
- onChange={(event) => setValue('title', event.target.value)}
796
- placeholder={t('catalog.products.create.placeholders.title', 'e.g., Summer sneaker')}
1315
+ onChange={(event) => setValue("title", event.target.value)}
1316
+ placeholder={t(
1317
+ "catalog.products.create.placeholders.title",
1318
+ "e.g., Summer sneaker",
1319
+ )}
797
1320
  />
798
- {errors.title ? <p className="text-xs text-red-600">{errors.title}</p> : null}
1321
+ {errors.title ? (
1322
+ <p className="text-xs text-red-600">{errors.title}</p>
1323
+ ) : null}
799
1324
  </div>
800
1325
 
801
1326
  <div className="space-y-2">
802
1327
  <div className="flex items-center justify-between">
803
- <Label>{t('catalog.products.form.description', 'Description')}</Label>
1328
+ <Label>{t("catalog.products.form.description", "Description")}</Label>
804
1329
  <Button
805
1330
  type="button"
806
1331
  variant="ghost"
807
1332
  size="sm"
808
- onClick={() => setValue('useMarkdown', !values.useMarkdown)}
1333
+ onClick={() => setValue("useMarkdown", !values.useMarkdown)}
809
1334
  className="gap-2 text-xs"
810
1335
  >
811
- {values.useMarkdown ? <AlignLeft className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
1336
+ {values.useMarkdown ? (
1337
+ <AlignLeft className="h-4 w-4" />
1338
+ ) : (
1339
+ <FileText className="h-4 w-4" />
1340
+ )}
812
1341
  {values.useMarkdown
813
- ? t('catalog.products.create.actions.usePlain', 'Use plain text')
814
- : t('catalog.products.create.actions.useMarkdown', 'Use markdown')}
1342
+ ? t("catalog.products.create.actions.usePlain", "Use plain text")
1343
+ : t(
1344
+ "catalog.products.create.actions.useMarkdown",
1345
+ "Use markdown",
1346
+ )}
815
1347
  </Button>
816
1348
  </div>
817
1349
  {values.useMarkdown ? (
818
- <div data-color-mode="light" className="overflow-hidden rounded-md border">
1350
+ <div
1351
+ data-color-mode="light"
1352
+ className="overflow-hidden rounded-md border"
1353
+ >
819
1354
  <MarkdownEditor
820
1355
  value={values.description}
821
1356
  height={260}
822
- onChange={(val) => setValue('description', val ?? '')}
1357
+ onChange={(val) => setValue("description", val ?? "")}
823
1358
  previewOptions={{ remarkPlugins: [] }}
824
1359
  />
825
1360
  </div>
@@ -827,8 +1362,11 @@ function ProductDetailsSection({ values, setValue, errors, productId }: ProductD
827
1362
  <Textarea
828
1363
  className="min-h-[180px]"
829
1364
  value={values.description}
830
- onChange={(event) => setValue('description', event.target.value)}
831
- placeholder={t('catalog.products.create.placeholders.description', 'Describe the product...')}
1365
+ onChange={(event) => setValue("description", event.target.value)}
1366
+ placeholder={t(
1367
+ "catalog.products.create.placeholders.description",
1368
+ "Describe the product...",
1369
+ )}
832
1370
  />
833
1371
  )}
834
1372
  </div>
@@ -842,195 +1380,334 @@ function ProductDetailsSection({ values, setValue, errors, productId }: ProductD
842
1380
  onDefaultChange={handleDefaultMediaChange}
843
1381
  />
844
1382
  </div>
845
- )
1383
+ );
846
1384
  }
847
1385
 
848
- function ProductDimensionsSection({ values, setValue }: ProductDimensionsSectionProps) {
849
- const t = useT()
850
- const dimensionValues = normalizeProductDimensions(values.dimensions)
851
- const weightValues = normalizeProductWeight(values.weight)
1386
+ function ProductDimensionsSection({
1387
+ values,
1388
+ setValue,
1389
+ }: ProductDimensionsSectionProps) {
1390
+ const t = useT();
1391
+ const dimensionValues = normalizeProductDimensions(values.dimensions);
1392
+ const weightValues = normalizeProductWeight(values.weight);
852
1393
 
853
1394
  return (
854
1395
  <div className="space-y-4">
855
- <h3 className="text-sm font-semibold">{t('catalog.products.edit.dimensions', 'Dimensions & weight')}</h3>
1396
+ <h3 className="text-sm font-semibold">
1397
+ {t("catalog.products.edit.dimensions", "Dimensions & weight")}
1398
+ </h3>
856
1399
  <div className="grid gap-4 sm:grid-cols-2">
857
1400
  <div className="space-y-2">
858
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.width', 'Width')}</Label>
1401
+ <Label className="text-xs uppercase text-muted-foreground">
1402
+ {t("catalog.products.edit.dimensions.width", "Width")}
1403
+ </Label>
859
1404
  <Input
860
1405
  type="number"
861
- value={dimensionValues?.width ?? ''}
862
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'width', event.target.value))}
1406
+ value={dimensionValues?.width ?? ""}
1407
+ onChange={(event) =>
1408
+ setValue(
1409
+ "dimensions",
1410
+ updateDimensionValue(
1411
+ values.dimensions ?? null,
1412
+ "width",
1413
+ event.target.value,
1414
+ ),
1415
+ )
1416
+ }
863
1417
  placeholder="0"
864
1418
  />
865
1419
  </div>
866
1420
  <div className="space-y-2">
867
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.height', 'Height')}</Label>
1421
+ <Label className="text-xs uppercase text-muted-foreground">
1422
+ {t("catalog.products.edit.dimensions.height", "Height")}
1423
+ </Label>
868
1424
  <Input
869
1425
  type="number"
870
- value={dimensionValues?.height ?? ''}
871
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'height', event.target.value))}
1426
+ value={dimensionValues?.height ?? ""}
1427
+ onChange={(event) =>
1428
+ setValue(
1429
+ "dimensions",
1430
+ updateDimensionValue(
1431
+ values.dimensions ?? null,
1432
+ "height",
1433
+ event.target.value,
1434
+ ),
1435
+ )
1436
+ }
872
1437
  placeholder="0"
873
1438
  />
874
1439
  </div>
875
1440
  <div className="space-y-2">
876
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.depth', 'Depth')}</Label>
1441
+ <Label className="text-xs uppercase text-muted-foreground">
1442
+ {t("catalog.products.edit.dimensions.depth", "Depth")}
1443
+ </Label>
877
1444
  <Input
878
1445
  type="number"
879
- value={dimensionValues?.depth ?? ''}
880
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'depth', event.target.value))}
1446
+ value={dimensionValues?.depth ?? ""}
1447
+ onChange={(event) =>
1448
+ setValue(
1449
+ "dimensions",
1450
+ updateDimensionValue(
1451
+ values.dimensions ?? null,
1452
+ "depth",
1453
+ event.target.value,
1454
+ ),
1455
+ )
1456
+ }
881
1457
  placeholder="0"
882
1458
  />
883
1459
  </div>
884
1460
  <div className="space-y-2">
885
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.unit', 'Size unit')}</Label>
1461
+ <Label className="text-xs uppercase text-muted-foreground">
1462
+ {t("catalog.products.edit.dimensions.unit", "Size unit")}
1463
+ </Label>
886
1464
  <Input
887
- value={dimensionValues?.unit ?? ''}
888
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'unit', event.target.value))}
1465
+ value={dimensionValues?.unit ?? ""}
1466
+ onChange={(event) =>
1467
+ setValue(
1468
+ "dimensions",
1469
+ updateDimensionValue(
1470
+ values.dimensions ?? null,
1471
+ "unit",
1472
+ event.target.value,
1473
+ ),
1474
+ )
1475
+ }
889
1476
  placeholder="cm"
890
1477
  />
891
1478
  </div>
892
1479
  <div className="space-y-2">
893
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.weight.value', 'Weight')}</Label>
1480
+ <Label className="text-xs uppercase text-muted-foreground">
1481
+ {t("catalog.products.edit.weight.value", "Weight")}
1482
+ </Label>
894
1483
  <Input
895
1484
  type="number"
896
- value={weightValues?.value ?? ''}
897
- onChange={(event) => setValue('weight', updateWeightValue(values.weight ?? null, 'value', event.target.value))}
1485
+ value={weightValues?.value ?? ""}
1486
+ onChange={(event) =>
1487
+ setValue(
1488
+ "weight",
1489
+ updateWeightValue(
1490
+ values.weight ?? null,
1491
+ "value",
1492
+ event.target.value,
1493
+ ),
1494
+ )
1495
+ }
898
1496
  placeholder="0"
899
1497
  />
900
1498
  </div>
901
1499
  <div className="space-y-2">
902
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.weight.unit', 'Weight unit')}</Label>
1500
+ <Label className="text-xs uppercase text-muted-foreground">
1501
+ {t("catalog.products.edit.weight.unit", "Weight unit")}
1502
+ </Label>
903
1503
  <Input
904
- value={weightValues?.unit ?? ''}
905
- onChange={(event) => setValue('weight', updateWeightValue(values.weight ?? null, 'unit', event.target.value))}
1504
+ value={weightValues?.unit ?? ""}
1505
+ onChange={(event) =>
1506
+ setValue(
1507
+ "weight",
1508
+ updateWeightValue(
1509
+ values.weight ?? null,
1510
+ "unit",
1511
+ event.target.value,
1512
+ ),
1513
+ )
1514
+ }
906
1515
  placeholder="kg"
907
1516
  />
908
1517
  </div>
909
1518
  </div>
910
1519
  </div>
911
- )
1520
+ );
912
1521
  }
913
1522
 
914
1523
  function ProductMetadataSection({ values, setValue }: ProductFormGroupProps) {
915
- const metadata = normalizeMetadata(values.metadata)
916
- const handleMetadataChange = React.useCallback((next: Record<string, unknown>) => {
917
- setValue('metadata', next)
918
- }, [setValue])
1524
+ const metadata = normalizeMetadata(values.metadata);
1525
+ const handleMetadataChange = React.useCallback(
1526
+ (next: Record<string, unknown>) => {
1527
+ setValue("metadata", next);
1528
+ },
1529
+ [setValue],
1530
+ );
919
1531
 
920
- return <MetadataEditor value={metadata} onChange={handleMetadataChange} embedded />
1532
+ return (
1533
+ <MetadataEditor value={metadata} onChange={handleMetadataChange} embedded />
1534
+ );
921
1535
  }
922
1536
 
923
1537
  function ProductOptionsSection({ values, setValue }: ProductFormGroupProps) {
924
- const t = useT()
925
- const [schemaDialogOpen, setSchemaDialogOpen] = React.useState(false)
926
- const [schemaTemplates, setSchemaTemplates] = React.useState<OptionSchemaTemplateSummary[]>([])
927
- const [schemaLoading, setSchemaLoading] = React.useState(false)
928
- const [saveSchemaOpen, setSaveSchemaOpen] = React.useState(false)
929
- const [schemaToEdit, setSchemaToEdit] = React.useState<OptionSchemaTemplateSummary | null>(null)
1538
+ const t = useT();
1539
+ const [schemaDialogOpen, setSchemaDialogOpen] = React.useState(false);
1540
+ const [schemaTemplates, setSchemaTemplates] = React.useState<
1541
+ OptionSchemaTemplateSummary[]
1542
+ >([]);
1543
+ const [schemaLoading, setSchemaLoading] = React.useState(false);
1544
+ const [saveSchemaOpen, setSaveSchemaOpen] = React.useState(false);
1545
+ const [schemaToEdit, setSchemaToEdit] =
1546
+ React.useState<OptionSchemaTemplateSummary | null>(null);
930
1547
 
931
1548
  const loadSchemas = React.useCallback(async () => {
932
- setSchemaLoading(true)
1549
+ setSchemaLoading(true);
933
1550
  try {
934
- const res = await apiCall<OptionSchemaTemplateListResponse>('/api/catalog/option-schemas?page=1&pageSize=100')
1551
+ const res = await apiCall<OptionSchemaTemplateListResponse>(
1552
+ "/api/catalog/option-schemas?page=1&pageSize=100",
1553
+ );
935
1554
  if (res.ok) {
936
- setSchemaTemplates(Array.isArray(res.result?.items) ? res.result?.items ?? [] : [])
1555
+ setSchemaTemplates(
1556
+ Array.isArray(res.result?.items) ? (res.result?.items ?? []) : [],
1557
+ );
937
1558
  } else {
938
- setSchemaTemplates([])
1559
+ setSchemaTemplates([]);
939
1560
  }
940
1561
  } catch (err) {
941
- console.error('catalog.option-schemas.list failed', err)
942
- setSchemaTemplates([])
1562
+ console.error("catalog.option-schemas.list failed", err);
1563
+ setSchemaTemplates([]);
943
1564
  } finally {
944
- setSchemaLoading(false)
1565
+ setSchemaLoading(false);
945
1566
  }
946
- }, [])
1567
+ }, []);
947
1568
 
948
- const handleDeleteSchema = React.useCallback(async (id: string) => {
949
- try {
950
- await deleteCrud('catalog/option-schemas', id, {
951
- errorMessage: t('catalog.products.edit.schemas.deleteError', 'Failed to delete schema.'),
952
- })
953
- flash(t('catalog.products.edit.schemas.deleted', 'Schema deleted.'), 'success')
954
- void loadSchemas()
955
- } catch (err) {
956
- console.error('catalog.option-schemas.delete failed', err)
957
- }
958
- }, [loadSchemas, t])
959
-
960
- const handleSaveSchema = React.useCallback(async (name: string) => {
961
- if (!name.trim().length) {
962
- const message = t('catalog.products.edit.schemas.nameRequired', 'Provide a schema name.')
963
- throw createCrudFormError(message, { name: message })
964
- }
965
- const schemaPayload = buildSchemaFromOptions(Array.isArray(values.options) ? values.options : [], name)
966
- if (!schemaPayload.options?.length) {
967
- throw createCrudFormError(t('catalog.products.edit.schemas.empty', 'Add at least one option before saving.'), {})
968
- }
969
- const payload: Record<string, unknown> = {
970
- name: name.trim(),
971
- code: slugify(name.trim()),
972
- schema: schemaPayload,
973
- isActive: true,
974
- }
975
- if (schemaToEdit?.id) payload.id = schemaToEdit.id
976
- if (schemaToEdit?.id) await updateCrud('catalog/option-schemas', payload)
977
- else await createCrud('catalog/option-schemas', payload)
978
- flash(t('catalog.products.edit.schemas.saved', 'Schema saved.'), 'success')
979
- setSaveSchemaOpen(false)
980
- setSchemaToEdit(null)
981
- void loadSchemas()
982
- }, [schemaToEdit, t, values.options, loadSchemas])
983
-
984
- const handleOptionTitleChange = React.useCallback((optionId: string, nextTitle: string) => {
985
- const next = (Array.isArray(values.options) ? values.options : []).map((option) =>
986
- option.id === optionId ? { ...option, title: nextTitle } : option,
987
- )
988
- setValue('options', next)
989
- }, [setValue, values.options])
990
-
991
- const setOptionValues = React.useCallback((optionId: string, labels: string[]) => {
992
- const normalized = labels.map((label) => label.trim()).filter((label) => label.length)
993
- const unique = Array.from(new Set(normalized))
994
- const next = (Array.isArray(values.options) ? values.options : []).map((option) => {
995
- if (option.id !== optionId) return option
996
- const existingByLabel = new Map(option.values.map((value) => [value.label, value]))
997
- const nextValues = unique.map((label) => existingByLabel.get(label) ?? { id: createLocalId(), label })
998
- return {
999
- ...option,
1000
- values: nextValues,
1569
+ const handleDeleteSchema = React.useCallback(
1570
+ async (id: string) => {
1571
+ try {
1572
+ await deleteCrud("catalog/option-schemas", id, {
1573
+ errorMessage: t(
1574
+ "catalog.products.edit.schemas.deleteError",
1575
+ "Failed to delete schema.",
1576
+ ),
1577
+ });
1578
+ flash(
1579
+ t("catalog.products.edit.schemas.deleted", "Schema deleted."),
1580
+ "success",
1581
+ );
1582
+ void loadSchemas();
1583
+ } catch (err) {
1584
+ console.error("catalog.option-schemas.delete failed", err);
1001
1585
  }
1002
- })
1003
- setValue('options', next)
1004
- }, [setValue, values.options])
1586
+ },
1587
+ [loadSchemas, t],
1588
+ );
1589
+
1590
+ const handleSaveSchema = React.useCallback(
1591
+ async (name: string) => {
1592
+ if (!name.trim().length) {
1593
+ const message = t(
1594
+ "catalog.products.edit.schemas.nameRequired",
1595
+ "Provide a schema name.",
1596
+ );
1597
+ throw createCrudFormError(message, { name: message });
1598
+ }
1599
+ const schemaPayload = buildSchemaFromOptions(
1600
+ Array.isArray(values.options) ? values.options : [],
1601
+ name,
1602
+ );
1603
+ if (!schemaPayload.options?.length) {
1604
+ throw createCrudFormError(
1605
+ t(
1606
+ "catalog.products.edit.schemas.empty",
1607
+ "Add at least one option before saving.",
1608
+ ),
1609
+ {},
1610
+ );
1611
+ }
1612
+ const payload: Record<string, unknown> = {
1613
+ name: name.trim(),
1614
+ code: slugify(name.trim()),
1615
+ schema: schemaPayload,
1616
+ isActive: true,
1617
+ };
1618
+ if (schemaToEdit?.id) payload.id = schemaToEdit.id;
1619
+ if (schemaToEdit?.id) await updateCrud("catalog/option-schemas", payload);
1620
+ else await createCrud("catalog/option-schemas", payload);
1621
+ flash(
1622
+ t("catalog.products.edit.schemas.saved", "Schema saved."),
1623
+ "success",
1624
+ );
1625
+ setSaveSchemaOpen(false);
1626
+ setSchemaToEdit(null);
1627
+ void loadSchemas();
1628
+ },
1629
+ [schemaToEdit, t, values.options, loadSchemas],
1630
+ );
1631
+
1632
+ const handleOptionTitleChange = React.useCallback(
1633
+ (optionId: string, nextTitle: string) => {
1634
+ const next = (Array.isArray(values.options) ? values.options : []).map(
1635
+ (option) =>
1636
+ option.id === optionId ? { ...option, title: nextTitle } : option,
1637
+ );
1638
+ setValue("options", next);
1639
+ },
1640
+ [setValue, values.options],
1641
+ );
1642
+
1643
+ const setOptionValues = React.useCallback(
1644
+ (optionId: string, labels: string[]) => {
1645
+ const normalized = labels
1646
+ .map((label) => label.trim())
1647
+ .filter((label) => label.length);
1648
+ const unique = Array.from(new Set(normalized));
1649
+ const next = (Array.isArray(values.options) ? values.options : []).map(
1650
+ (option) => {
1651
+ if (option.id !== optionId) return option;
1652
+ const existingByLabel = new Map(
1653
+ option.values.map((value) => [value.label, value]),
1654
+ );
1655
+ const nextValues = unique.map(
1656
+ (label) =>
1657
+ existingByLabel.get(label) ?? { id: createLocalId(), label },
1658
+ );
1659
+ return {
1660
+ ...option,
1661
+ values: nextValues,
1662
+ };
1663
+ },
1664
+ );
1665
+ setValue("options", next);
1666
+ },
1667
+ [setValue, values.options],
1668
+ );
1005
1669
 
1006
1670
  const addOption = React.useCallback(() => {
1007
1671
  const next = [
1008
1672
  ...(Array.isArray(values.options) ? values.options : []),
1009
- { id: createLocalId(), title: '', values: [] },
1010
- ]
1011
- setValue('options', next)
1012
- }, [setValue, values.options])
1013
-
1014
- const removeOption = React.useCallback((optionId: string) => {
1015
- const next = (Array.isArray(values.options) ? values.options : []).filter((option) => option.id !== optionId)
1016
- setValue('options', next)
1017
- }, [setValue, values.options])
1673
+ { id: createLocalId(), title: "", values: [] },
1674
+ ];
1675
+ setValue("options", next);
1676
+ }, [setValue, values.options]);
1677
+
1678
+ const removeOption = React.useCallback(
1679
+ (optionId: string) => {
1680
+ const next = (Array.isArray(values.options) ? values.options : []).filter(
1681
+ (option) => option.id !== optionId,
1682
+ );
1683
+ setValue("options", next);
1684
+ },
1685
+ [setValue, values.options],
1686
+ );
1018
1687
 
1019
1688
  return (
1020
1689
  <>
1021
1690
  <div className="space-y-4">
1022
1691
  <div className="flex flex-wrap items-center justify-between gap-2">
1023
- <h3 className="text-sm font-semibold">{t('catalog.products.create.optionsBuilder.title', 'Product options')}</h3>
1692
+ <h3 className="text-sm font-semibold">
1693
+ {t(
1694
+ "catalog.products.create.optionsBuilder.title",
1695
+ "Product options",
1696
+ )}
1697
+ </h3>
1024
1698
  <div className="flex flex-wrap items-center gap-2">
1025
1699
  <Button
1026
1700
  type="button"
1027
1701
  variant="ghost"
1028
1702
  size="icon"
1029
1703
  onClick={() => {
1030
- setSchemaDialogOpen(true)
1031
- void loadSchemas()
1704
+ setSchemaDialogOpen(true);
1705
+ void loadSchemas();
1032
1706
  }}
1033
- title={t('catalog.products.edit.schemas.manage', 'Open schema library')}
1707
+ title={t(
1708
+ "catalog.products.edit.schemas.manage",
1709
+ "Open schema library",
1710
+ )}
1034
1711
  >
1035
1712
  <BookMarked className="h-4 w-4" />
1036
1713
  </Button>
@@ -1039,16 +1716,21 @@ function ProductOptionsSection({ values, setValue }: ProductFormGroupProps) {
1039
1716
  variant="ghost"
1040
1717
  size="icon"
1041
1718
  onClick={() => {
1042
- setSchemaToEdit(null)
1043
- setSaveSchemaOpen(true)
1719
+ setSchemaToEdit(null);
1720
+ setSaveSchemaOpen(true);
1044
1721
  }}
1045
- title={t('catalog.products.edit.schemas.save', 'Save as schema')}
1722
+ title={t("catalog.products.edit.schemas.save", "Save as schema")}
1046
1723
  >
1047
1724
  <Save className="h-4 w-4" />
1048
1725
  </Button>
1049
- <Button type="button" variant="outline" size="sm" onClick={addOption}>
1726
+ <Button
1727
+ type="button"
1728
+ variant="outline"
1729
+ size="sm"
1730
+ onClick={addOption}
1731
+ >
1050
1732
  <Plus className="mr-2 h-4 w-4" />
1051
- {t('catalog.products.create.optionsBuilder.add', 'Add option')}
1733
+ {t("catalog.products.create.optionsBuilder.add", "Add option")}
1052
1734
  </Button>
1053
1735
  </div>
1054
1736
  </div>
@@ -1057,29 +1739,45 @@ function ProductOptionsSection({ values, setValue }: ProductFormGroupProps) {
1057
1739
  <div className="flex items-center gap-2">
1058
1740
  <Input
1059
1741
  value={option.title}
1060
- onChange={(event) => handleOptionTitleChange(option.id, event.target.value)}
1061
- placeholder={t('catalog.products.create.optionsBuilder.placeholder', 'e.g., Color')}
1742
+ onChange={(event) =>
1743
+ handleOptionTitleChange(option.id, event.target.value)
1744
+ }
1745
+ placeholder={t(
1746
+ "catalog.products.create.optionsBuilder.placeholder",
1747
+ "e.g., Color",
1748
+ )}
1062
1749
  className="flex-1"
1063
1750
  />
1064
- <Button variant="ghost" size="icon" type="button" onClick={() => removeOption(option.id)}>
1751
+ <Button
1752
+ variant="ghost"
1753
+ size="icon"
1754
+ type="button"
1755
+ onClick={() => removeOption(option.id)}
1756
+ >
1065
1757
  <Trash2 className="h-4 w-4" />
1066
1758
  </Button>
1067
1759
  </div>
1068
1760
  <div className="mt-3 space-y-2">
1069
1761
  <Label className="text-xs uppercase text-muted-foreground">
1070
- {t('catalog.products.create.optionsBuilder.values', 'Values')}
1762
+ {t("catalog.products.create.optionsBuilder.values", "Values")}
1071
1763
  </Label>
1072
1764
  <TagsInput
1073
1765
  value={option.values.map((value) => value.label)}
1074
1766
  onChange={(labels) => setOptionValues(option.id, labels)}
1075
- placeholder={t('catalog.products.create.optionsBuilder.valuePlaceholder', 'Type a value and press Enter')}
1767
+ placeholder={t(
1768
+ "catalog.products.create.optionsBuilder.valuePlaceholder",
1769
+ "Type a value and press Enter",
1770
+ )}
1076
1771
  />
1077
1772
  </div>
1078
1773
  </div>
1079
1774
  ))}
1080
1775
  {!values.options?.length ? (
1081
1776
  <p className="text-sm text-muted-foreground">
1082
- {t('catalog.products.create.optionsBuilder.empty', 'No options yet. Add your first option to generate variants.')}
1777
+ {t(
1778
+ "catalog.products.create.optionsBuilder.empty",
1779
+ "No options yet. Add your first option to generate variants.",
1780
+ )}
1083
1781
  </p>
1084
1782
  ) : null}
1085
1783
  </div>
@@ -1087,35 +1785,35 @@ function ProductOptionsSection({ values, setValue }: ProductFormGroupProps) {
1087
1785
  <OptionSchemaDialog
1088
1786
  open={schemaDialogOpen}
1089
1787
  onOpenChange={(next) => {
1090
- setSchemaDialogOpen(next)
1091
- if (next) void loadSchemas()
1788
+ setSchemaDialogOpen(next);
1789
+ if (next) void loadSchemas();
1092
1790
  }}
1093
1791
  isLoading={schemaLoading}
1094
1792
  templates={schemaTemplates}
1095
1793
  onSelect={(template) => {
1096
- setSchemaDialogOpen(false)
1097
- if (!template) return
1098
- const options = extractOptionsFromTemplate(template)
1099
- setValue('options', options)
1794
+ setSchemaDialogOpen(false);
1795
+ if (!template) return;
1796
+ const options = extractOptionsFromTemplate(template);
1797
+ setValue("options", options);
1100
1798
  }}
1101
1799
  onDelete={handleDeleteSchema}
1102
1800
  onEdit={(template) => {
1103
- setSchemaToEdit(template)
1104
- setSaveSchemaOpen(true)
1801
+ setSchemaToEdit(template);
1802
+ setSaveSchemaOpen(true);
1105
1803
  }}
1106
1804
  />
1107
1805
 
1108
1806
  <SaveSchemaDialog
1109
1807
  open={saveSchemaOpen}
1110
1808
  onOpenChange={(next) => {
1111
- setSaveSchemaOpen(next)
1112
- if (!next) setSchemaToEdit(null)
1809
+ setSaveSchemaOpen(next);
1810
+ if (!next) setSchemaToEdit(null);
1113
1811
  }}
1114
- defaultName={schemaToEdit?.name ?? ''}
1812
+ defaultName={schemaToEdit?.name ?? ""}
1115
1813
  onSubmit={handleSaveSchema}
1116
1814
  />
1117
1815
  </>
1118
- )
1816
+ );
1119
1817
  }
1120
1818
 
1121
1819
  function ProductVariantsSection({
@@ -1126,174 +1824,235 @@ function ProductVariantsSection({
1126
1824
  onVariantDeleted,
1127
1825
  onVariantsReload,
1128
1826
  }: ProductVariantsSectionProps) {
1129
- const t = useT()
1130
- const { confirm, ConfirmDialogElement } = useConfirmDialog()
1131
- const [deletingId, setDeletingId] = React.useState<string | null>(null)
1132
- const [generating, setGenerating] = React.useState(false)
1133
- const [checkingVariantFeature, setCheckingVariantFeature] = React.useState(true)
1134
- const [canManageVariants, setCanManageVariants] = React.useState(false)
1827
+ const t = useT();
1828
+ const { confirm, ConfirmDialogElement } = useConfirmDialog();
1829
+ const [deletingId, setDeletingId] = React.useState<string | null>(null);
1830
+ const [generating, setGenerating] = React.useState(false);
1831
+ const [checkingVariantFeature, setCheckingVariantFeature] =
1832
+ React.useState(true);
1833
+ const [canManageVariants, setCanManageVariants] = React.useState(false);
1135
1834
  const optionDefinitions = React.useMemo(
1136
1835
  () => (Array.isArray(values.options) ? values.options : []),
1137
1836
  [values.options],
1138
- )
1139
- const combos = React.useMemo(() => buildVariantCombinations(optionDefinitions), [optionDefinitions])
1837
+ );
1838
+ const combos = React.useMemo(
1839
+ () => buildVariantCombinations(optionDefinitions),
1840
+ [optionDefinitions],
1841
+ );
1140
1842
  const existingKeys = React.useMemo(() => {
1141
- const set = new Set<string>()
1843
+ const set = new Set<string>();
1142
1844
  variants.forEach((variant) => {
1143
- const key = buildOptionValuesKey(variant.optionValues ?? undefined)
1144
- if (key) set.add(key)
1145
- })
1146
- return set
1147
- }, [variants])
1845
+ const key = buildOptionValuesKey(variant.optionValues ?? undefined);
1846
+ if (key) set.add(key);
1847
+ });
1848
+ return set;
1849
+ }, [variants]);
1148
1850
  const missingCombos = React.useMemo(
1149
1851
  () =>
1150
1852
  combos.filter((combo) => {
1151
- const key = buildOptionValuesKey(combo)
1152
- if (!key) return false
1153
- return !existingKeys.has(key)
1853
+ const key = buildOptionValuesKey(combo);
1854
+ if (!key) return false;
1855
+ return !existingKeys.has(key);
1154
1856
  }),
1155
1857
  [combos, existingKeys],
1156
- )
1858
+ );
1157
1859
  const priceKindLookup = React.useMemo(() => {
1158
- const map = new Map<string, PriceKindSummary>()
1860
+ const map = new Map<string, PriceKindSummary>();
1159
1861
  for (const kind of priceKinds) {
1160
- map.set(kind.id, kind)
1862
+ map.set(kind.id, kind);
1161
1863
  }
1162
- return map
1163
- }, [priceKinds])
1864
+ return map;
1865
+ }, [priceKinds]);
1164
1866
  React.useEffect(() => {
1165
- let cancelled = false
1867
+ let cancelled = false;
1166
1868
  async function checkVariantFeature() {
1167
1869
  try {
1168
- const payload = await readApiResultOrThrow<{ ok?: boolean; granted?: unknown }>(
1169
- '/api/auth/feature-check',
1870
+ const payload = await readApiResultOrThrow<{
1871
+ ok?: boolean;
1872
+ granted?: unknown;
1873
+ }>(
1874
+ "/api/auth/feature-check",
1170
1875
  {
1171
- method: 'POST',
1172
- headers: { 'content-type': 'application/json' },
1173
- body: JSON.stringify({ features: ['catalog.variants.manage'] }),
1876
+ method: "POST",
1877
+ headers: { "content-type": "application/json" },
1878
+ body: JSON.stringify({ features: ["catalog.variants.manage"] }),
1174
1879
  },
1175
1880
  { allowNullResult: true },
1176
- )
1177
- if (cancelled) return
1178
- const sourceGranted = Array.isArray(payload?.granted) ? payload?.granted : []
1881
+ );
1882
+ if (cancelled) return;
1883
+ const sourceGranted = Array.isArray(payload?.granted)
1884
+ ? payload?.granted
1885
+ : [];
1179
1886
  const granted = (sourceGranted as unknown[]).filter(
1180
- (entry): entry is string => typeof entry === 'string' && entry.length > 0,
1181
- )
1182
- const hasFeature = payload?.ok === true || granted.includes('catalog.variants.manage')
1183
- setCanManageVariants(hasFeature)
1887
+ (entry): entry is string =>
1888
+ typeof entry === "string" && entry.length > 0,
1889
+ );
1890
+ const hasFeature =
1891
+ payload?.ok === true || granted.includes("catalog.variants.manage");
1892
+ setCanManageVariants(hasFeature);
1184
1893
  } catch {
1185
- if (!cancelled) setCanManageVariants(false)
1894
+ if (!cancelled) setCanManageVariants(false);
1186
1895
  } finally {
1187
- if (!cancelled) setCheckingVariantFeature(false)
1896
+ if (!cancelled) setCheckingVariantFeature(false);
1188
1897
  }
1189
1898
  }
1190
- checkVariantFeature().catch(() => {})
1899
+ checkVariantFeature().catch(() => {});
1191
1900
  return () => {
1192
- cancelled = true
1193
- }
1194
- }, [])
1195
- const allowVariantActions = canManageVariants && !checkingVariantFeature
1901
+ cancelled = true;
1902
+ };
1903
+ }, []);
1904
+ const allowVariantActions = canManageVariants && !checkingVariantFeature;
1196
1905
  const formatPriceLabel = React.useCallback(
1197
1906
  (price: VariantPriceSummary): string => {
1198
- const kind = price.priceKindId ? priceKindLookup.get(price.priceKindId) : null
1199
- if (kind?.title) return kind.title
1200
- if (kind?.code) return kind.code.toUpperCase()
1201
- return t('catalog.products.edit.variantList.priceFallback', 'Price')
1907
+ const kind = price.priceKindId
1908
+ ? priceKindLookup.get(price.priceKindId)
1909
+ : null;
1910
+ if (kind?.title) return kind.title;
1911
+ if (kind?.code) return kind.code.toUpperCase();
1912
+ return t("catalog.products.edit.variantList.priceFallback", "Price");
1202
1913
  },
1203
1914
  [priceKindLookup, t],
1204
- )
1205
- const formatPriceAmount = React.useCallback((price: VariantPriceSummary): string => {
1206
- const amount = typeof price.amount === 'string' && price.amount.trim().length ? price.amount.trim() : ''
1207
- if (!amount) return '—'
1208
- if (!price.currencyCode) return amount
1209
- return `${price.currencyCode.toUpperCase()} ${amount}`
1210
- }, [])
1915
+ );
1916
+ const formatPriceAmount = React.useCallback(
1917
+ (price: VariantPriceSummary): string => {
1918
+ const amount =
1919
+ typeof price.amount === "string" && price.amount.trim().length
1920
+ ? price.amount.trim()
1921
+ : "";
1922
+ if (!amount) return "—";
1923
+ if (!price.currencyCode) return amount;
1924
+ return `${price.currencyCode.toUpperCase()} ${amount}`;
1925
+ },
1926
+ [],
1927
+ );
1211
1928
  const handleDeleteVariant = React.useCallback(
1212
1929
  async (variant: VariantSummary) => {
1213
- if (!allowVariantActions) return
1214
- const label = variant.name || variant.sku || variant.id
1215
- const confirmMessage = t('catalog.products.edit.variantList.deleteConfirm', 'Delete variant "{{name}}"?').replace(
1216
- '{{name}}',
1217
- label,
1218
- )
1930
+ if (!allowVariantActions) return;
1931
+ const label = variant.name || variant.sku || variant.id;
1932
+ const confirmMessage = t(
1933
+ "catalog.products.edit.variantList.deleteConfirm",
1934
+ 'Delete variant "{{name}}"?',
1935
+ ).replace("{{name}}", label);
1219
1936
  const confirmed = await confirm({
1220
1937
  title: confirmMessage,
1221
- variant: 'destructive',
1222
- })
1223
- if (!confirmed) return
1224
- setDeletingId(variant.id)
1938
+ variant: "destructive",
1939
+ });
1940
+ if (!confirmed) return;
1941
+ setDeletingId(variant.id);
1225
1942
  try {
1226
- await deleteCrud('catalog/variants', variant.id, {
1227
- errorMessage: t('catalog.variants.form.deleteError', 'Failed to delete variant.'),
1228
- })
1229
- flash(t('catalog.variants.form.deleted', 'Variant deleted.'), 'success')
1230
- onVariantDeleted(variant.id)
1943
+ await deleteCrud("catalog/variants", variant.id, {
1944
+ errorMessage: t(
1945
+ "catalog.variants.form.deleteError",
1946
+ "Failed to delete variant.",
1947
+ ),
1948
+ });
1949
+ flash(
1950
+ t("catalog.variants.form.deleted", "Variant deleted."),
1951
+ "success",
1952
+ );
1953
+ onVariantDeleted(variant.id);
1231
1954
  } catch (err) {
1232
- console.error('catalog.products.edit.variants.delete', err)
1233
- flash(t('catalog.variants.form.deleteError', 'Failed to delete variant.'), 'error')
1955
+ console.error("catalog.products.edit.variants.delete", err);
1956
+ flash(
1957
+ t("catalog.variants.form.deleteError", "Failed to delete variant."),
1958
+ "error",
1959
+ );
1234
1960
  } finally {
1235
- setDeletingId(null)
1961
+ setDeletingId(null);
1236
1962
  }
1237
1963
  },
1238
1964
  [allowVariantActions, confirm, onVariantDeleted, t],
1239
- )
1965
+ );
1240
1966
  const handleGenerateVariants = React.useCallback(async () => {
1241
- if (!productId || !allowVariantActions) return
1967
+ if (!productId || !allowVariantActions) return;
1242
1968
  if (!missingCombos.length) {
1243
1969
  flash(
1244
- t('catalog.products.edit.variantList.generateEmpty', 'All option combinations already exist.'),
1245
- 'info',
1246
- )
1247
- return
1970
+ t(
1971
+ "catalog.products.edit.variantList.generateEmpty",
1972
+ "All option combinations already exist.",
1973
+ ),
1974
+ "info",
1975
+ );
1976
+ return;
1248
1977
  }
1249
- setGenerating(true)
1978
+ setGenerating(true);
1250
1979
  try {
1251
1980
  for (const combo of missingCombos) {
1252
1981
  const title =
1253
1982
  Object.values(combo)
1254
1983
  .map((value) => value?.trim())
1255
1984
  .filter((value) => value && value.length)
1256
- .join(' / ') || t('catalog.products.edit.variantList.defaultTitle', 'Variant')
1257
- await createCrud('catalog/variants', {
1985
+ .join(" / ") ||
1986
+ t("catalog.products.edit.variantList.defaultTitle", "Variant");
1987
+ await createCrud("catalog/variants", {
1258
1988
  productId,
1259
1989
  name: title,
1260
1990
  optionValues: combo,
1261
1991
  isDefault: false,
1262
1992
  isActive: true,
1263
- })
1993
+ });
1264
1994
  }
1265
- flash(t('catalog.products.edit.variantList.generateSuccess', 'Missing variants generated.'), 'success')
1266
- if (onVariantsReload) await onVariantsReload()
1995
+ flash(
1996
+ t(
1997
+ "catalog.products.edit.variantList.generateSuccess",
1998
+ "Missing variants generated.",
1999
+ ),
2000
+ "success",
2001
+ );
2002
+ if (onVariantsReload) await onVariantsReload();
1267
2003
  } catch (err) {
1268
- console.error('catalog.products.edit.variantList.generate', err)
1269
- flash(t('catalog.products.edit.variantList.generateError', 'Failed to generate variants.'), 'error')
2004
+ console.error("catalog.products.edit.variantList.generate", err);
2005
+ flash(
2006
+ t(
2007
+ "catalog.products.edit.variantList.generateError",
2008
+ "Failed to generate variants.",
2009
+ ),
2010
+ "error",
2011
+ );
1270
2012
  } finally {
1271
- setGenerating(false)
2013
+ setGenerating(false);
1272
2014
  }
1273
- }, [allowVariantActions, missingCombos, onVariantsReload, productId, t])
2015
+ }, [allowVariantActions, missingCombos, onVariantsReload, productId, t]);
1274
2016
 
1275
- const showGenerateButton = optionDefinitions.length > 0 && allowVariantActions
2017
+ const showGenerateButton =
2018
+ optionDefinitions.length > 0 && allowVariantActions;
1276
2019
 
1277
2020
  return (
1278
2021
  <>
1279
2022
  <div className="space-y-3">
1280
2023
  <div className="flex flex-wrap items-center justify-between gap-3">
1281
2024
  <h3 id="variants" className="text-sm font-semibold">
1282
- {t('catalog.products.edit.variants', 'Variants')}
2025
+ {t("catalog.products.edit.variants", "Variants")}
1283
2026
  </h3>
1284
2027
  <div className="flex flex-wrap items-center gap-2">
1285
2028
  {showGenerateButton ? (
1286
- <Button type="button" size="sm" variant="outline" disabled={generating} onClick={() => { void handleGenerateVariants() }}>
2029
+ <Button
2030
+ type="button"
2031
+ size="sm"
2032
+ variant="outline"
2033
+ disabled={generating}
2034
+ onClick={() => {
2035
+ void handleGenerateVariants();
2036
+ }}
2037
+ >
1287
2038
  {generating
1288
- ? t('catalog.products.edit.variantList.generating', 'Generating…')
1289
- : t('catalog.products.edit.variantList.generate', 'Generate variants')}
2039
+ ? t(
2040
+ "catalog.products.edit.variantList.generating",
2041
+ "Generating…",
2042
+ )
2043
+ : t(
2044
+ "catalog.products.edit.variantList.generate",
2045
+ "Generate variants",
2046
+ )}
1290
2047
  </Button>
1291
2048
  ) : null}
1292
2049
  {allowVariantActions ? (
1293
2050
  <Button asChild size="sm">
1294
- <Link href={`/backend/catalog/products/${productId}/variants/create`}>
2051
+ <Link
2052
+ href={`/backend/catalog/products/${productId}/variants/create`}
2053
+ >
1295
2054
  <Plus className="mr-2 h-4 w-4" />
1296
- {t('catalog.products.edit.variants.add', 'Add variant')}
2055
+ {t("catalog.products.edit.variants.add", "Add variant")}
1297
2056
  </Link>
1298
2057
  </Button>
1299
2058
  ) : null}
@@ -1304,14 +2063,21 @@ function ProductVariantsSection({
1304
2063
  <table className="w-full table-auto text-sm">
1305
2064
  <thead className="bg-muted/40 text-left text-xs uppercase text-muted-foreground">
1306
2065
  <tr>
1307
- <th className="px-3 py-2 font-normal">{t('catalog.products.form.variants', 'Variant')}</th>
2066
+ <th className="px-3 py-2 font-normal">
2067
+ {t("catalog.products.form.variants", "Variant")}
2068
+ </th>
1308
2069
  <th className="px-3 py-2 font-normal">SKU</th>
1309
2070
  <th className="px-3 py-2 font-normal">
1310
- {t('catalog.products.edit.variantList.pricesHeading', 'Prices')}
2071
+ {t(
2072
+ "catalog.products.edit.variantList.pricesHeading",
2073
+ "Prices",
2074
+ )}
2075
+ </th>
2076
+ <th className="px-3 py-2 font-normal">
2077
+ {t("catalog.products.edit.variants.default", "Default")}
1311
2078
  </th>
1312
- <th className="px-3 py-2 font-normal">{t('catalog.products.edit.variants.default', 'Default')}</th>
1313
2079
  <th className="px-3 py-2 font-normal text-right">
1314
- {t('catalog.products.edit.variantList.actions', 'Actions')}
2080
+ {t("catalog.products.edit.variantList.actions", "Actions")}
1315
2081
  </th>
1316
2082
  </tr>
1317
2083
  </thead>
@@ -1326,29 +2092,43 @@ function ProductVariantsSection({
1326
2092
  {variant.name || variant.id}
1327
2093
  </Link>
1328
2094
  </td>
1329
- <td className="px-3 py-2 text-muted-foreground">{variant.sku || '—'}</td>
2095
+ <td className="px-3 py-2 text-muted-foreground">
2096
+ {variant.sku || "—"}
2097
+ </td>
1330
2098
  <td className="px-3 py-2">
1331
2099
  {variant.prices.length ? (
1332
2100
  <ul className="space-y-1">
1333
2101
  {variant.prices.map((price) => (
1334
- <li key={price.id} className="text-xs text-muted-foreground">
1335
- <span className="font-medium text-foreground">{formatPriceLabel(price)}</span>{' '}
2102
+ <li
2103
+ key={price.id}
2104
+ className="text-xs text-muted-foreground"
2105
+ >
2106
+ <span className="font-medium text-foreground">
2107
+ {formatPriceLabel(price)}
2108
+ </span>{" "}
1336
2109
  <span>{formatPriceAmount(price)}</span>
1337
2110
  </li>
1338
2111
  ))}
1339
2112
  </ul>
1340
2113
  ) : (
1341
2114
  <span className="text-xs text-muted-foreground">
1342
- {t('catalog.products.edit.variantList.pricesEmpty', 'No prices yet.')}
2115
+ {t(
2116
+ "catalog.products.edit.variantList.pricesEmpty",
2117
+ "No prices yet.",
2118
+ )}
1343
2119
  </span>
1344
2120
  )}
1345
2121
  </td>
1346
- <td className="px-3 py-2 text-muted-foreground">{variant.isDefault ? t('common.yes', 'Yes') : '—'}</td>
2122
+ <td className="px-3 py-2 text-muted-foreground">
2123
+ {variant.isDefault ? t("common.yes", "Yes") : "—"}
2124
+ </td>
1347
2125
  <td className="px-3 py-2">
1348
2126
  <div className="flex flex-wrap justify-end gap-2">
1349
2127
  <Button asChild size="sm" variant="outline">
1350
- <Link href={`/backend/catalog/products/${productId}/variants/${variant.id}`}>
1351
- {t('catalog.products.list.actions.edit', 'Edit')}
2128
+ <Link
2129
+ href={`/backend/catalog/products/${productId}/variants/${variant.id}`}
2130
+ >
2131
+ {t("catalog.products.list.actions.edit", "Edit")}
1352
2132
  </Link>
1353
2133
  </Button>
1354
2134
  {allowVariantActions ? (
@@ -1358,11 +2138,19 @@ function ProductVariantsSection({
1358
2138
  variant="ghost"
1359
2139
  className="text-destructive hover:text-destructive"
1360
2140
  disabled={deletingId === variant.id}
1361
- onClick={() => { void handleDeleteVariant(variant) }}
2141
+ onClick={() => {
2142
+ void handleDeleteVariant(variant);
2143
+ }}
1362
2144
  >
1363
2145
  {deletingId === variant.id
1364
- ? t('catalog.products.edit.variantList.deleting', 'Deleting…')
1365
- : t('catalog.products.list.actions.delete', 'Delete')}
2146
+ ? t(
2147
+ "catalog.products.edit.variantList.deleting",
2148
+ "Deleting…",
2149
+ )
2150
+ : t(
2151
+ "catalog.products.list.actions.delete",
2152
+ "Delete",
2153
+ )}
1366
2154
  </Button>
1367
2155
  ) : null}
1368
2156
  </div>
@@ -1374,115 +2162,160 @@ function ProductVariantsSection({
1374
2162
  </div>
1375
2163
  ) : (
1376
2164
  <p className="text-xs text-muted-foreground">
1377
- {t('catalog.products.edit.variants.empty', 'No variants defined yet.')}
2165
+ {t(
2166
+ "catalog.products.edit.variants.empty",
2167
+ "No variants defined yet.",
2168
+ )}
1378
2169
  </p>
1379
2170
  )}
1380
2171
  </div>
1381
2172
  {ConfirmDialogElement}
1382
2173
  </>
1383
- )
2174
+ );
1384
2175
  }
1385
2176
 
1386
- function ProductMetaSection({ values, setValue, errors, taxRates }: ProductMetaSectionProps) {
1387
- const t = useT()
1388
- const handleValue = typeof values.handle === 'string' ? values.handle : ''
1389
- const titleSource = typeof values.title === 'string' ? values.title : ''
1390
- const autoHandleEnabledRef = React.useRef(handleValue.trim().length === 0)
2177
+ function ProductMetaSection({
2178
+ values,
2179
+ setValue,
2180
+ errors,
2181
+ taxRates,
2182
+ }: ProductMetaSectionProps) {
2183
+ const t = useT();
2184
+ const handleValue = typeof values.handle === "string" ? values.handle : "";
2185
+ const titleSource = typeof values.title === "string" ? values.title : "";
2186
+ const autoHandleEnabledRef = React.useRef(handleValue.trim().length === 0);
1391
2187
 
1392
2188
  React.useEffect(() => {
1393
- if (!autoHandleEnabledRef.current) return
1394
- const normalizedTitle = titleSource.trim()
2189
+ if (!autoHandleEnabledRef.current) return;
2190
+ const normalizedTitle = titleSource.trim();
1395
2191
  if (!normalizedTitle) {
1396
2192
  if (handleValue) {
1397
- setValue('handle', '')
2193
+ setValue("handle", "");
1398
2194
  }
1399
- return
2195
+ return;
1400
2196
  }
1401
- const nextHandle = slugify(normalizedTitle)
2197
+ const nextHandle = slugify(normalizedTitle);
1402
2198
  if (nextHandle !== handleValue) {
1403
- setValue('handle', nextHandle)
2199
+ setValue("handle", nextHandle);
1404
2200
  }
1405
- }, [titleSource, handleValue, setValue])
2201
+ }, [titleSource, handleValue, setValue]);
1406
2202
 
1407
2203
  const handleHandleInputChange = React.useCallback(
1408
2204
  (event: React.ChangeEvent<HTMLInputElement>) => {
1409
- const nextValue = event.target.value
1410
- autoHandleEnabledRef.current = nextValue.trim().length === 0
1411
- setValue('handle', nextValue)
2205
+ const nextValue = event.target.value;
2206
+ autoHandleEnabledRef.current = nextValue.trim().length === 0;
2207
+ setValue("handle", nextValue);
1412
2208
  },
1413
2209
  [setValue],
1414
- )
2210
+ );
1415
2211
 
1416
2212
  const handleGenerateHandle = React.useCallback(() => {
1417
- const slug = slugify(titleSource)
1418
- autoHandleEnabledRef.current = true
1419
- setValue('handle', slug)
1420
- }, [titleSource, setValue])
2213
+ const slug = slugify(titleSource);
2214
+ autoHandleEnabledRef.current = true;
2215
+ setValue("handle", slug);
2216
+ }, [titleSource, setValue]);
1421
2217
 
1422
2218
  return (
1423
2219
  <div className="space-y-4">
1424
2220
  <div className="space-y-2">
1425
- <Label>{t('catalog.products.form.subtitle', 'Subtitle')}</Label>
2221
+ <Label>{t("catalog.products.form.subtitle", "Subtitle")}</Label>
1426
2222
  <Input
1427
- value={typeof values.subtitle === 'string' ? values.subtitle : ''}
1428
- onChange={(event) => setValue('subtitle', event.target.value)}
1429
- placeholder={t('catalog.products.create.placeholders.subtitle', 'Optional subtitle')}
2223
+ value={typeof values.subtitle === "string" ? values.subtitle : ""}
2224
+ onChange={(event) => setValue("subtitle", event.target.value)}
2225
+ placeholder={t(
2226
+ "catalog.products.create.placeholders.subtitle",
2227
+ "Optional subtitle",
2228
+ )}
1430
2229
  />
1431
- {errors.subtitle ? <p className="text-xs text-red-600">{errors.subtitle}</p> : null}
2230
+ {errors.subtitle ? (
2231
+ <p className="text-xs text-red-600">{errors.subtitle}</p>
2232
+ ) : null}
1432
2233
  </div>
1433
2234
 
1434
2235
  <div className="space-y-2">
1435
2236
  <div className="flex items-center justify-between gap-2">
1436
- <Label>{t('catalog.products.create.meta.handleLabel', 'Handle')}</Label>
2237
+ <Label>
2238
+ {t("catalog.products.create.meta.handleLabel", "Handle")}
2239
+ </Label>
1437
2240
  <Button
1438
2241
  type="button"
1439
2242
  variant="outline"
1440
2243
  onClick={handleGenerateHandle}
1441
2244
  >
1442
- {t('catalog.products.create.actions.generateHandle', 'Generate')}
2245
+ {t("catalog.products.create.actions.generateHandle", "Generate")}
1443
2246
  </Button>
1444
2247
  </div>
1445
2248
  <Input
1446
2249
  value={handleValue}
1447
2250
  onChange={handleHandleInputChange}
1448
- placeholder={t('catalog.products.create.placeholders.handle', 'e.g., summer-sneaker')}
2251
+ placeholder={t(
2252
+ "catalog.products.create.placeholders.handle",
2253
+ "e.g., summer-sneaker",
2254
+ )}
1449
2255
  className="font-mono lowercase"
1450
2256
  />
1451
2257
  <p className="text-xs text-muted-foreground">
1452
- {t('catalog.products.create.handleHelp', 'Handle is used for URLs and must be unique.')}
2258
+ {t(
2259
+ "catalog.products.create.handleHelp",
2260
+ "Handle is used for URLs and must be unique.",
2261
+ )}
1453
2262
  </p>
1454
- {errors.handle ? <p className="text-xs text-red-600">{errors.handle}</p> : null}
2263
+ {errors.handle ? (
2264
+ <p className="text-xs text-red-600">{errors.handle}</p>
2265
+ ) : null}
1455
2266
  </div>
1456
2267
 
1457
2268
  <div className="space-y-2">
1458
2269
  <div className="flex items-center justify-between gap-2">
1459
- <Label>{t('catalog.products.create.taxRates.label', 'Tax class')}</Label>
2270
+ <Label>
2271
+ {t("catalog.products.create.taxRates.label", "Tax class")}
2272
+ </Label>
1460
2273
  <Button
1461
2274
  type="button"
1462
2275
  variant="ghost"
1463
2276
  size="icon"
1464
2277
  onClick={() => {
1465
- if (typeof window !== 'undefined') {
1466
- window.open('/backend/config/sales?section=tax-rates', '_blank', 'noopener,noreferrer')
2278
+ if (typeof window !== "undefined") {
2279
+ window.open(
2280
+ "/backend/config/sales?section=tax-rates",
2281
+ "_blank",
2282
+ "noopener,noreferrer",
2283
+ );
1467
2284
  }
1468
2285
  }}
1469
- title={t('catalog.products.create.taxRates.manage', 'Manage tax classes')}
2286
+ title={t(
2287
+ "catalog.products.create.taxRates.manage",
2288
+ "Manage tax classes",
2289
+ )}
1470
2290
  className="text-muted-foreground hover:text-foreground"
1471
2291
  >
1472
2292
  <Layers className="h-4 w-4" />
1473
- <span className="sr-only">{t('catalog.products.create.taxRates.manage', 'Manage tax classes')}</span>
2293
+ <span className="sr-only">
2294
+ {t(
2295
+ "catalog.products.create.taxRates.manage",
2296
+ "Manage tax classes",
2297
+ )}
2298
+ </span>
1474
2299
  </Button>
1475
2300
  </div>
1476
2301
  <select
1477
2302
  className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
1478
- value={values.taxRateId ?? ''}
1479
- onChange={(event) => setValue('taxRateId', event.target.value || null)}
2303
+ value={values.taxRateId ?? ""}
2304
+ onChange={(event) =>
2305
+ setValue("taxRateId", event.target.value || null)
2306
+ }
1480
2307
  disabled={!taxRates.length}
1481
2308
  >
1482
2309
  <option value="">
1483
2310
  {taxRates.length
1484
- ? t('catalog.products.create.taxRates.noneSelected', 'No tax class selected')
1485
- : t('catalog.products.create.taxRates.emptyOption', 'No tax classes available')}
2311
+ ? t(
2312
+ "catalog.products.create.taxRates.noneSelected",
2313
+ "No tax class selected",
2314
+ )
2315
+ : t(
2316
+ "catalog.products.create.taxRates.emptyOption",
2317
+ "No tax classes available",
2318
+ )}
1486
2319
  </option>
1487
2320
  {taxRates.map((rate) => (
1488
2321
  <option key={rate.id} value={rate.id}>
@@ -1492,20 +2325,27 @@ function ProductMetaSection({ values, setValue, errors, taxRates }: ProductMetaS
1492
2325
  </select>
1493
2326
  <p className="text-xs text-muted-foreground">
1494
2327
  {taxRates.length
1495
- ? t('catalog.products.create.taxRates.help', 'Applied to prices unless overridden per variant.')
1496
- : t('catalog.products.create.taxRates.empty', 'Define tax classes under Sales → Configuration.')}
2328
+ ? t(
2329
+ "catalog.products.create.taxRates.help",
2330
+ "Applied to prices unless overridden per variant.",
2331
+ )
2332
+ : t(
2333
+ "catalog.products.create.taxRates.empty",
2334
+ "Define tax classes under Sales → Configuration.",
2335
+ )}
1497
2336
  </p>
1498
2337
  </div>
1499
2338
  </div>
1500
- )
2339
+ );
1501
2340
  }
1502
2341
 
1503
2342
  function normalizeMetadata(value: unknown): Record<string, any> {
1504
- if (!value || typeof value !== 'object') return {}
2343
+ if (!value || typeof value !== "object") return {};
1505
2344
  const entries = Object.entries(value as Record<string, unknown>).filter(
1506
- ([key]) => !key.startsWith('cf') && key !== 'dimensions' && key !== 'weight',
1507
- )
1508
- return Object.fromEntries(entries)
2345
+ ([key]) =>
2346
+ !key.startsWith("cf") && key !== "dimensions" && key !== "weight",
2347
+ );
2348
+ return Object.fromEntries(entries);
1509
2349
  }
1510
2350
 
1511
2351
  function readOptionSchema(metadata: Record<string, any>): ProductOptionInput[] {
@@ -1513,15 +2353,18 @@ function readOptionSchema(metadata: Record<string, any>): ProductOptionInput[] {
1513
2353
  ? metadata.optionSchema
1514
2354
  : Array.isArray(metadata.option_schema)
1515
2355
  ? metadata.option_schema
1516
- : []
2356
+ : [];
1517
2357
  return raw
1518
2358
  .map((option) => {
1519
- if (!option || typeof option !== 'object') return null
1520
- const values = Array.isArray((option as any).values)
1521
- ? (option as any).values
2359
+ if (!option || typeof option !== "object") return null;
2360
+ const values = Array.isArray(option.values)
2361
+ ? option.values
1522
2362
  .map((value: any) =>
1523
- value && typeof value === 'object'
1524
- ? { id: String(value.id ?? createLocalId()), label: typeof value.label === 'string' ? value.label : '' }
2363
+ value && typeof value === "object"
2364
+ ? {
2365
+ id: String(value.id ?? createLocalId()),
2366
+ label: typeof value.label === "string" ? value.label : "",
2367
+ }
1525
2368
  : null,
1526
2369
  )
1527
2370
  .filter(
@@ -1529,274 +2372,386 @@ function readOptionSchema(metadata: Record<string, any>): ProductOptionInput[] {
1529
2372
  entry: { id?: string; label?: string } | null,
1530
2373
  ): entry is { id: string; label: string } => !!entry,
1531
2374
  )
1532
- : []
2375
+ : [];
1533
2376
  return {
1534
- id: String((option as any).id ?? createLocalId()),
1535
- title: typeof (option as any).title === 'string' ? (option as any).title : '',
2377
+ id: String(option.id ?? createLocalId()),
2378
+ title: typeof option.title === "string" ? option.title : "",
1536
2379
  values,
1537
- }
2380
+ };
1538
2381
  })
1539
- .filter((entry): entry is ProductOptionInput => !!entry)
2382
+ .filter((entry): entry is ProductOptionInput => !!entry);
1540
2383
  }
1541
2384
 
1542
-
1543
- function extractCustomFields(record: Record<string, unknown>): { customValues: Record<string, unknown> } {
1544
- const customValues: Record<string, unknown> = {}
2385
+ function extractCustomFields(record: Record<string, unknown>): {
2386
+ customValues: Record<string, unknown>;
2387
+ } {
2388
+ const customValues: Record<string, unknown> = {};
1545
2389
  Object.entries(record).forEach(([key, value]) => {
1546
- if (key.startsWith('cf_')) customValues[key] = value
1547
- else if (key.startsWith('cf:')) customValues[`cf_${key.slice(3)}`] = value
1548
- })
1549
- return { customValues }
2390
+ if (key.startsWith("cf_")) customValues[key] = value;
2391
+ else if (key.startsWith("cf:")) customValues[`cf_${key.slice(3)}`] = value;
2392
+ });
2393
+ return { customValues };
1550
2394
  }
1551
2395
 
1552
2396
  function normalizeIdList(value: unknown): string[] {
1553
2397
  const ids = Array.isArray(value)
1554
2398
  ? value
1555
- .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
2399
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
1556
2400
  .filter((entry) => entry.length)
1557
- : []
1558
- return Array.from(new Set(ids))
2401
+ : [];
2402
+ return Array.from(new Set(ids));
1559
2403
  }
1560
2404
 
1561
2405
  function normalizeTagValues(value: unknown): string[] {
1562
2406
  const tags = Array.isArray(value)
1563
2407
  ? value
1564
- .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
2408
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
1565
2409
  .filter((entry) => entry.length)
1566
- : []
1567
- return Array.from(new Set(tags))
2410
+ : [];
2411
+ return Array.from(new Set(tags));
1568
2412
  }
1569
2413
 
1570
- function formatCategoryLabel(name: string | null, fallback: string, parentName: string | null): string {
1571
- const base = name ?? fallback
1572
- return parentName ? `${base} / ${parentName}` : base
2414
+ function formatCategoryLabel(
2415
+ name: string | null,
2416
+ fallback: string,
2417
+ parentName: string | null,
2418
+ ): string {
2419
+ const base = name ?? fallback;
2420
+ return parentName ? `${base} / ${parentName}` : base;
1573
2421
  }
1574
2422
 
1575
2423
  function readCategorySelections(record: Record<string, unknown>): {
1576
- ids: string[]
1577
- options: ProductCategorizePickerOption[]
2424
+ ids: string[];
2425
+ options: ProductCategorizePickerOption[];
1578
2426
  } {
1579
- const rawCategories = Array.isArray((record as Record<string, unknown>).categories)
1580
- ? ((record as Record<string, unknown>).categories as Array<Record<string, unknown>>)
1581
- : []
2427
+ const rawCategories = Array.isArray(
2428
+ (record as Record<string, unknown>).categories,
2429
+ )
2430
+ ? ((record as Record<string, unknown>).categories as Array<
2431
+ Record<string, unknown>
2432
+ >)
2433
+ : [];
1582
2434
  const options = rawCategories
1583
2435
  .map((entry) => {
1584
- const value = getString(entry.id) ?? getString(entry.categoryId) ?? getString(entry.category_id)
1585
- if (!value) return null
1586
- const name = getString(entry.name)
1587
- const parentName = getString(entry.parentName)
1588
- const label = formatCategoryLabel(name, value, parentName)
2436
+ const value =
2437
+ getString(entry.id) ??
2438
+ getString(entry.categoryId) ??
2439
+ getString(entry.category_id);
2440
+ if (!value) return null;
2441
+ const name = getString(entry.name);
2442
+ const parentName = getString(entry.parentName);
2443
+ const label = formatCategoryLabel(name, value, parentName);
1589
2444
  const description =
1590
- parentName && !label.toLowerCase().includes(parentName.toLowerCase()) ? parentName : null
1591
- return { value, label, description }
2445
+ parentName && !label.toLowerCase().includes(parentName.toLowerCase())
2446
+ ? parentName
2447
+ : null;
2448
+ return { value, label, description };
1592
2449
  })
1593
2450
  .filter(
1594
2451
  (
1595
- option: { value: string; label: string; description: string | null } | null,
1596
- ): option is { value: string; label: string; description: string | null } => !!option,
1597
- )
1598
- const fallbackIds = normalizeIdList((record as Record<string, unknown>).categoryIds)
1599
- const combinedIds = Array.from(new Set([...options.map((option) => option.value), ...fallbackIds]))
1600
- return { ids: combinedIds, options }
2452
+ option: {
2453
+ value: string;
2454
+ label: string;
2455
+ description: string | null;
2456
+ } | null,
2457
+ ): option is {
2458
+ value: string;
2459
+ label: string;
2460
+ description: string | null;
2461
+ } => !!option,
2462
+ );
2463
+ const fallbackIds = normalizeIdList(
2464
+ (record as Record<string, unknown>).categoryIds,
2465
+ );
2466
+ const combinedIds = Array.from(
2467
+ new Set([...options.map((option) => option.value), ...fallbackIds]),
2468
+ );
2469
+ return { ids: combinedIds, options };
1601
2470
  }
1602
2471
 
1603
2472
  function readTagSelections(record: Record<string, unknown>): {
1604
- values: string[]
1605
- options: ProductCategorizePickerOption[]
2473
+ values: string[];
2474
+ options: ProductCategorizePickerOption[];
1606
2475
  } {
1607
- const values = normalizeTagValues((record as Record<string, unknown>).tags)
1608
- const options = values.map((label) => ({ value: label, label }))
1609
- return { values, options }
2476
+ const values = normalizeTagValues((record as Record<string, unknown>).tags);
2477
+ const options = values.map((label) => ({ value: label, label }));
2478
+ return { values, options };
1610
2479
  }
1611
2480
 
1612
2481
  function readOfferSnapshots(record: Record<string, unknown>): OfferSnapshot[] {
1613
2482
  const rawOffers = Array.isArray((record as Record<string, unknown>).offers)
1614
- ? ((record as Record<string, unknown>).offers as Array<Record<string, unknown>>)
1615
- : []
1616
- const snapshots: OfferSnapshot[] = []
2483
+ ? ((record as Record<string, unknown>).offers as Array<
2484
+ Record<string, unknown>
2485
+ >)
2486
+ : [];
2487
+ const snapshots: OfferSnapshot[] = [];
1617
2488
  for (const offer of rawOffers) {
1618
- if (!offer || typeof offer !== 'object') continue
2489
+ if (!offer || typeof offer !== "object") continue;
1619
2490
  const channelId =
1620
- getString(offer.channelId) ??
1621
- getString(offer.channel_id) ??
1622
- null
1623
- if (!channelId) continue
2491
+ getString(offer.channelId) ?? getString(offer.channel_id) ?? null;
2492
+ if (!channelId) continue;
1624
2493
  snapshots.push({
1625
2494
  id: getString(offer.id),
1626
2495
  channelId,
1627
2496
  title: getString(offer.title) ?? null,
1628
2497
  description: getString(offer.description) ?? null,
1629
- defaultMediaId: getString(offer.defaultMediaId) ?? getString(offer.default_media_id),
1630
- defaultMediaUrl: getString(offer.defaultMediaUrl) ?? getString(offer.default_media_url),
1631
- metadata: isRecord(offer.metadata) ? (offer.metadata as Record<string, unknown>) : null,
2498
+ defaultMediaId:
2499
+ getString(offer.defaultMediaId) ?? getString(offer.default_media_id),
2500
+ defaultMediaUrl:
2501
+ getString(offer.defaultMediaUrl) ?? getString(offer.default_media_url),
2502
+ metadata: isRecord(offer.metadata)
2503
+ ? (offer.metadata as Record<string, unknown>)
2504
+ : null,
1632
2505
  isActive: offer.isActive !== false,
1633
- channelName: getString(offer.channelName) ?? getString(offer.channel_name),
1634
- channelCode: getString(offer.channelCode) ?? getString(offer.channel_code),
1635
- })
2506
+ channelName:
2507
+ getString(offer.channelName) ?? getString(offer.channel_name),
2508
+ channelCode:
2509
+ getString(offer.channelCode) ?? getString(offer.channel_code),
2510
+ });
1636
2511
  }
1637
- return snapshots
2512
+ return snapshots;
1638
2513
  }
1639
2514
 
1640
2515
  function extractChannelIds(offers: OfferSnapshot[]): string[] {
1641
- return Array.from(new Set(offers.map((offer) => offer.channelId)))
2516
+ return Array.from(new Set(offers.map((offer) => offer.channelId)));
1642
2517
  }
1643
2518
 
1644
- function buildChannelOptions(offers: OfferSnapshot[]): ProductCategorizePickerOption[] {
1645
- const seen = new Set<string>()
1646
- const options: ProductCategorizePickerOption[] = []
2519
+ function buildChannelOptions(
2520
+ offers: OfferSnapshot[],
2521
+ ): ProductCategorizePickerOption[] {
2522
+ const seen = new Set<string>();
2523
+ const options: ProductCategorizePickerOption[] = [];
1647
2524
  for (const offer of offers) {
1648
- if (seen.has(offer.channelId)) continue
1649
- seen.add(offer.channelId)
1650
- const label = offer.channelName || offer.channelCode || offer.channelId
2525
+ if (seen.has(offer.channelId)) continue;
2526
+ seen.add(offer.channelId);
2527
+ const label = offer.channelName || offer.channelCode || offer.channelId;
1651
2528
  const description =
1652
2529
  offer.channelCode && offer.channelCode !== label
1653
2530
  ? offer.channelCode
1654
- : null
2531
+ : null;
1655
2532
  options.push({
1656
2533
  value: offer.channelId,
1657
2534
  label,
1658
2535
  description,
1659
- })
2536
+ });
1660
2537
  }
1661
- return options
2538
+ return options;
1662
2539
  }
1663
2540
 
1664
2541
  type OfferPayload = {
1665
- id?: string
1666
- channelId: string
1667
- title: string
1668
- description?: string
1669
- defaultMediaId?: string | null
1670
- defaultMediaUrl?: string | null
1671
- metadata?: Record<string, unknown> | null
1672
- isActive?: boolean
1673
- }
2542
+ id?: string;
2543
+ channelId: string;
2544
+ title: string;
2545
+ description?: string;
2546
+ defaultMediaId?: string | null;
2547
+ defaultMediaUrl?: string | null;
2548
+ metadata?: Record<string, unknown> | null;
2549
+ isActive?: boolean;
2550
+ };
1674
2551
 
1675
2552
  function buildOfferPayloads(params: {
1676
- channelIds: string[]
1677
- offerSnapshots: OfferSnapshot[]
2553
+ channelIds: string[];
2554
+ offerSnapshots: OfferSnapshot[];
1678
2555
  fallback: {
1679
- title: string
1680
- description?: string
1681
- defaultMediaId?: string | null
1682
- defaultMediaUrl?: string | null
1683
- }
2556
+ title: string;
2557
+ description?: string;
2558
+ defaultMediaId?: string | null;
2559
+ defaultMediaUrl?: string | null;
2560
+ };
1684
2561
  }): OfferPayload[] {
1685
- const { channelIds, offerSnapshots, fallback } = params
1686
- const byChannel = new Map(offerSnapshots.map((offer) => [offer.channelId, offer]))
1687
- const payloads: OfferPayload[] = []
1688
- const fallbackTitle = fallback.title.trim()
2562
+ const { channelIds, offerSnapshots, fallback } = params;
2563
+ const byChannel = new Map(
2564
+ offerSnapshots.map((offer) => [offer.channelId, offer]),
2565
+ );
2566
+ const payloads: OfferPayload[] = [];
2567
+ const fallbackTitle = fallback.title.trim();
1689
2568
  for (const channelId of channelIds) {
1690
- const existing = byChannel.get(channelId)
1691
- const title = ensureString(existing?.title) ?? fallbackTitle
1692
- const description = ensureString(existing?.description) ?? ensureString(fallback.description)
2569
+ const existing = byChannel.get(channelId);
2570
+ const title = ensureString(existing?.title) ?? fallbackTitle;
2571
+ const description =
2572
+ ensureString(existing?.description) ?? ensureString(fallback.description);
1693
2573
  payloads.push({
1694
2574
  id: existing?.id ?? undefined,
1695
2575
  channelId,
1696
2576
  title,
1697
2577
  description,
1698
- defaultMediaId: existing?.defaultMediaId ?? fallback.defaultMediaId ?? null,
1699
- defaultMediaUrl: existing?.defaultMediaUrl ?? fallback.defaultMediaUrl ?? null,
2578
+ defaultMediaId:
2579
+ existing?.defaultMediaId ?? fallback.defaultMediaId ?? null,
2580
+ defaultMediaUrl:
2581
+ existing?.defaultMediaUrl ?? fallback.defaultMediaUrl ?? null,
1700
2582
  metadata: existing?.metadata ?? undefined,
1701
2583
  isActive: existing?.isActive ?? true,
1702
- })
2584
+ });
1703
2585
  }
1704
- return payloads
2586
+ return payloads;
1705
2587
  }
1706
2588
 
1707
- function mergeOfferSnapshots(current: OfferSnapshot[], payloads: OfferPayload[]): OfferSnapshot[] {
1708
- const currentByChannel = new Map(current.map((snapshot) => [snapshot.channelId, snapshot]))
2589
+ function mergeOfferSnapshots(
2590
+ current: OfferSnapshot[],
2591
+ payloads: OfferPayload[],
2592
+ ): OfferSnapshot[] {
2593
+ const currentByChannel = new Map(
2594
+ current.map((snapshot) => [snapshot.channelId, snapshot]),
2595
+ );
1709
2596
  return payloads.map((entry) => {
1710
- const previous = currentByChannel.get(entry.channelId)
2597
+ const previous = currentByChannel.get(entry.channelId);
1711
2598
  return {
1712
2599
  id: entry.id ?? previous?.id ?? null,
1713
2600
  channelId: entry.channelId,
1714
2601
  title: entry.title ?? previous?.title ?? null,
1715
2602
  description: entry.description ?? previous?.description ?? null,
1716
2603
  defaultMediaId: entry.defaultMediaId ?? previous?.defaultMediaId ?? null,
1717
- defaultMediaUrl: entry.defaultMediaUrl ?? previous?.defaultMediaUrl ?? null,
2604
+ defaultMediaUrl:
2605
+ entry.defaultMediaUrl ?? previous?.defaultMediaUrl ?? null,
1718
2606
  metadata: entry.metadata ?? previous?.metadata ?? null,
1719
2607
  isActive: entry.isActive ?? previous?.isActive ?? true,
1720
2608
  channelName: previous?.channelName ?? null,
1721
2609
  channelCode: previous?.channelCode ?? null,
1722
- }
1723
- })
2610
+ };
2611
+ });
1724
2612
  }
1725
2613
 
1726
2614
  function getString(value: unknown): string | null {
1727
- if (typeof value === 'string' && value.trim().length) return value.trim()
1728
- return null
2615
+ if (typeof value === "string" && value.trim().length) return value.trim();
2616
+ return null;
1729
2617
  }
1730
2618
 
1731
2619
  function ensureString(value: unknown): string | undefined {
1732
- const normalized = getString(value)
1733
- return normalized ?? undefined
2620
+ const normalized = getString(value);
2621
+ return normalized ?? undefined;
1734
2622
  }
1735
2623
 
1736
2624
  function isRecord(value: unknown): value is Record<string, unknown> {
1737
- return !!value && typeof value === 'object' && !Array.isArray(value)
2625
+ return !!value && typeof value === "object" && !Array.isArray(value);
1738
2626
  }
1739
2627
 
1740
- function buildMetadataPayload(values: ProductFormValues): Record<string, unknown> {
1741
- const metadata = normalizeMetadata(values.metadata)
1742
- metadata.__useMarkdown = values.useMarkdown ?? false
1743
- delete metadata.optionSchema
1744
- delete metadata.option_schema
1745
- delete metadata.dimensions
1746
- delete metadata.weight
1747
- return metadata
2628
+ function extractUomFields(record: Record<string, unknown>) {
2629
+ const unitPriceObject = isRecord(record.unit_price)
2630
+ ? (record.unit_price as Record<string, unknown>)
2631
+ : null;
2632
+ const defaultUnit = canonicalizeUnitCode(
2633
+ record.default_unit ?? record.defaultUnit,
2634
+ );
2635
+ const defaultSalesUnit = canonicalizeUnitCode(
2636
+ record.default_sales_unit ?? record.defaultSalesUnit,
2637
+ );
2638
+ const defaultSalesUnitQuantity = toPositiveNumberOrNull(
2639
+ record.default_sales_unit_quantity ?? record.defaultSalesUnitQuantity,
2640
+ );
2641
+ const roundingScale = toIntegerInRangeOrDefault(
2642
+ record.uom_rounding_scale ?? record.uomRoundingScale,
2643
+ 0,
2644
+ 6,
2645
+ 4,
2646
+ );
2647
+ const roundingModeRaw = toTrimmedOrNull(
2648
+ record.uom_rounding_mode ?? record.uomRoundingMode,
2649
+ );
2650
+ const roundingMode: ProductUnitRoundingMode =
2651
+ roundingModeRaw === "down" || roundingModeRaw === "up"
2652
+ ? roundingModeRaw
2653
+ : "half_up";
2654
+ const unitPriceEnabled = Boolean(
2655
+ unitPriceObject?.enabled ??
2656
+ record.unit_price_enabled ??
2657
+ record.unitPriceEnabled,
2658
+ );
2659
+ const unitPriceReferenceUnit = canonicalizeUnitCode(
2660
+ unitPriceObject?.reference_unit ??
2661
+ unitPriceObject?.referenceUnit ??
2662
+ record.unit_price_reference_unit ??
2663
+ record.unitPriceReferenceUnit,
2664
+ );
2665
+ const unitPriceBaseQuantity = toPositiveNumberOrNull(
2666
+ unitPriceObject?.base_quantity ??
2667
+ unitPriceObject?.baseQuantity ??
2668
+ record.unit_price_base_quantity ??
2669
+ record.unitPriceBaseQuantity,
2670
+ );
2671
+ return {
2672
+ defaultUnit,
2673
+ defaultSalesUnit,
2674
+ defaultSalesUnitQuantity,
2675
+ roundingScale,
2676
+ roundingMode,
2677
+ unitPriceEnabled,
2678
+ unitPriceReferenceUnit,
2679
+ unitPriceBaseQuantity,
2680
+ };
2681
+ }
2682
+
2683
+ function buildMetadataPayload(
2684
+ values: ProductFormValues,
2685
+ ): Record<string, unknown> {
2686
+ const metadata = normalizeMetadata(values.metadata);
2687
+ metadata.__useMarkdown = values.useMarkdown ?? false;
2688
+ delete metadata.optionSchema;
2689
+ delete metadata.option_schema;
2690
+ delete metadata.dimensions;
2691
+ delete metadata.weight;
2692
+ return metadata;
1748
2693
  }
1749
2694
 
1750
2695
  function normalizeOptionSchemaRecord(
1751
2696
  schema: OptionSchemaRecord | null | undefined,
1752
2697
  ): CatalogProductOptionSchema | null {
1753
- if (!schema || !Array.isArray(schema.options)) return null
2698
+ if (!schema || !Array.isArray(schema.options)) return null;
1754
2699
  return {
1755
- version: typeof schema.version === 'number' ? schema.version : undefined,
2700
+ version: typeof schema.version === "number" ? schema.version : undefined,
1756
2701
  name: schema.name ?? null,
1757
2702
  description: schema.description ?? null,
1758
2703
  options: schema.options.map((option) => {
1759
- const code = option.code?.trim() || createLocalId()
1760
- const label = option.label?.trim() || code
2704
+ const code = option.code?.trim() || createLocalId();
2705
+ const label = option.label?.trim() || code;
1761
2706
  const inputType =
1762
- option.inputType === 'text' || option.inputType === 'textarea' || option.inputType === 'number'
2707
+ option.inputType === "text" ||
2708
+ option.inputType === "textarea" ||
2709
+ option.inputType === "number"
1763
2710
  ? option.inputType
1764
- : 'select'
2711
+ : "select";
1765
2712
  const choices = Array.isArray(option.choices)
1766
2713
  ? option.choices.map((choice) => {
1767
- const choiceCode = choice.code?.trim() || createLocalId()
1768
- const choiceLabel = choice.label?.trim() || choiceCode
1769
- return { code: choiceCode, label: choiceLabel }
2714
+ const choiceCode = choice.code?.trim() || createLocalId();
2715
+ const choiceLabel = choice.label?.trim() || choiceCode;
2716
+ return { code: choiceCode, label: choiceLabel };
1770
2717
  })
1771
- : []
2718
+ : [];
1772
2719
  return {
1773
2720
  code,
1774
2721
  label,
1775
2722
  description: option.description ?? undefined,
1776
2723
  inputType,
1777
2724
  choices,
1778
- }
2725
+ };
1779
2726
  }),
1780
- }
2727
+ };
1781
2728
  }
1782
2729
 
1783
- function buildSchemaFromOptions(options: ProductOptionInput[], name: string): OptionSchemaRecord {
2730
+ function buildSchemaFromOptions(
2731
+ options: ProductOptionInput[],
2732
+ name: string,
2733
+ ): OptionSchemaRecord {
1784
2734
  return {
1785
2735
  version: 1,
1786
2736
  name,
1787
2737
  options: options.map((option) => ({
1788
2738
  code: slugify(option.title || createLocalId()),
1789
2739
  label: option.title,
1790
- inputType: 'select',
1791
- choices: option.values.map((value) => ({ code: slugify(value.label || value.id), label: value.label })),
2740
+ inputType: "select",
2741
+ choices: option.values.map((value) => ({
2742
+ code: slugify(value.label || value.id),
2743
+ label: value.label,
2744
+ })),
1792
2745
  })),
1793
- }
2746
+ };
1794
2747
  }
1795
2748
 
1796
- function extractOptionsFromTemplate(template: OptionSchemaTemplateSummary): ProductOptionInput[] {
1797
- const schema = normalizeOptionSchemaRecord(template?.schema)
1798
- if (!schema) return []
1799
- return convertSchemaToProductOptions(schema)
2749
+ function extractOptionsFromTemplate(
2750
+ template: OptionSchemaTemplateSummary,
2751
+ ): ProductOptionInput[] {
2752
+ const schema = normalizeOptionSchemaRecord(template?.schema);
2753
+ if (!schema) return [];
2754
+ return convertSchemaToProductOptions(schema);
1800
2755
  }
1801
2756
 
1802
2757
  function OptionSchemaDialog({
@@ -1808,18 +2763,23 @@ function OptionSchemaDialog({
1808
2763
  onDelete,
1809
2764
  onEdit,
1810
2765
  }: OptionSchemaDialogProps) {
1811
- const t = useT()
2766
+ const t = useT();
1812
2767
  return (
1813
2768
  <Dialog open={open} onOpenChange={onOpenChange}>
1814
2769
  <DialogContent className="max-w-xl">
1815
2770
  <DialogHeader>
1816
- <DialogTitle>{t('catalog.products.edit.schemas.dialogTitle', 'Option schemas')}</DialogTitle>
2771
+ <DialogTitle>
2772
+ {t("catalog.products.edit.schemas.dialogTitle", "Option schemas")}
2773
+ </DialogTitle>
1817
2774
  </DialogHeader>
1818
2775
  {isLoading ? (
1819
2776
  <div className="py-6">
1820
2777
  <DataLoader
1821
2778
  isLoading
1822
- loadingMessage={t('catalog.products.edit.schemas.loading', 'Loading…')}
2779
+ loadingMessage={t(
2780
+ "catalog.products.edit.schemas.loading",
2781
+ "Loading…",
2782
+ )}
1823
2783
  spinnerSize="md"
1824
2784
  >
1825
2785
  <></>
@@ -1828,103 +2788,156 @@ function OptionSchemaDialog({
1828
2788
  ) : templates.length ? (
1829
2789
  <div className="divide-y rounded-md border">
1830
2790
  {templates.map((template) => {
1831
- const id = typeof template.id === 'string' ? template.id : null
2791
+ const id = typeof template.id === "string" ? template.id : null;
1832
2792
  return (
1833
- <div key={id ?? template.name ?? createLocalId()} className="flex items-center justify-between px-4 py-3">
2793
+ <div
2794
+ key={id ?? template.name ?? createLocalId()}
2795
+ className="flex items-center justify-between px-4 py-3"
2796
+ >
1834
2797
  <div>
1835
- <p className="text-sm font-medium">{template.name ?? template.id ?? 'Schema'}</p>
2798
+ <p className="text-sm font-medium">
2799
+ {template.name ?? template.id ?? "Schema"}
2800
+ </p>
1836
2801
  {template.description ? (
1837
- <p className="text-xs text-muted-foreground">{template.description}</p>
2802
+ <p className="text-xs text-muted-foreground">
2803
+ {template.description}
2804
+ </p>
1838
2805
  ) : null}
1839
2806
  </div>
1840
2807
  <div className="flex items-center gap-2">
1841
- <Button type="button" size="sm" variant="secondary" onClick={() => onSelect(template)}>
1842
- {t('catalog.products.edit.schemas.apply', 'Apply')}
2808
+ <Button
2809
+ type="button"
2810
+ size="sm"
2811
+ variant="secondary"
2812
+ onClick={() => onSelect(template)}
2813
+ >
2814
+ {t("catalog.products.edit.schemas.apply", "Apply")}
1843
2815
  </Button>
1844
2816
  {id ? (
1845
2817
  <>
1846
- <Button type="button" size="icon" variant="ghost" onClick={() => onEdit(template)}>
2818
+ <Button
2819
+ type="button"
2820
+ size="icon"
2821
+ variant="ghost"
2822
+ onClick={() => onEdit(template)}
2823
+ >
1847
2824
  <Save className="h-4 w-4" />
1848
- <span className="sr-only">{t('catalog.products.edit.schemas.edit', 'Edit schema')}</span>
2825
+ <span className="sr-only">
2826
+ {t(
2827
+ "catalog.products.edit.schemas.edit",
2828
+ "Edit schema",
2829
+ )}
2830
+ </span>
1849
2831
  </Button>
1850
- <Button type="button" size="icon" variant="ghost" onClick={() => void onDelete(id)}>
2832
+ <Button
2833
+ type="button"
2834
+ size="icon"
2835
+ variant="ghost"
2836
+ onClick={() => void onDelete(id)}
2837
+ >
1851
2838
  <Trash2 className="h-4 w-4 text-destructive" />
1852
- <span className="sr-only">{t('catalog.products.edit.schemas.delete', 'Delete schema')}</span>
2839
+ <span className="sr-only">
2840
+ {t(
2841
+ "catalog.products.edit.schemas.delete",
2842
+ "Delete schema",
2843
+ )}
2844
+ </span>
1853
2845
  </Button>
1854
2846
  </>
1855
2847
  ) : null}
1856
2848
  </div>
1857
2849
  </div>
1858
- )
2850
+ );
1859
2851
  })}
1860
2852
  </div>
1861
2853
  ) : (
1862
2854
  <p className="py-4 text-sm text-muted-foreground">
1863
- {t('catalog.products.edit.schemas.empty', 'No saved schemas yet.')}
2855
+ {t("catalog.products.edit.schemas.empty", "No saved schemas yet.")}
1864
2856
  </p>
1865
2857
  )}
1866
2858
  </DialogContent>
1867
2859
  </Dialog>
1868
- )
2860
+ );
1869
2861
  }
1870
2862
 
1871
2863
  type OptionSchemaDialogProps = {
1872
- open: boolean
1873
- onOpenChange: (open: boolean) => void
1874
- templates: OptionSchemaTemplateSummary[]
1875
- isLoading: boolean
1876
- onSelect: (template: OptionSchemaTemplateSummary | null) => void
1877
- onDelete: (id: string) => Promise<void> | void
1878
- onEdit: (template: OptionSchemaTemplateSummary) => void
1879
- }
2864
+ open: boolean;
2865
+ onOpenChange: (open: boolean) => void;
2866
+ templates: OptionSchemaTemplateSummary[];
2867
+ isLoading: boolean;
2868
+ onSelect: (template: OptionSchemaTemplateSummary | null) => void;
2869
+ onDelete: (id: string) => Promise<void> | void;
2870
+ onEdit: (template: OptionSchemaTemplateSummary) => void;
2871
+ };
1880
2872
 
1881
2873
  type SaveSchemaDialogProps = {
1882
- open: boolean
1883
- onOpenChange: (open: boolean) => void
1884
- defaultName?: string
1885
- onSubmit: (name: string) => Promise<void>
1886
- }
2874
+ open: boolean;
2875
+ onOpenChange: (open: boolean) => void;
2876
+ defaultName?: string;
2877
+ onSubmit: (name: string) => Promise<void>;
2878
+ };
1887
2879
 
1888
- function SaveSchemaDialog({ open, onOpenChange, defaultName = '', onSubmit }: SaveSchemaDialogProps) {
1889
- const t = useT()
1890
- const [name, setName] = React.useState(defaultName)
1891
- const [saving, setSaving] = React.useState(false)
2880
+ function SaveSchemaDialog({
2881
+ open,
2882
+ onOpenChange,
2883
+ defaultName = "",
2884
+ onSubmit,
2885
+ }: SaveSchemaDialogProps) {
2886
+ const t = useT();
2887
+ const [name, setName] = React.useState(defaultName);
2888
+ const [saving, setSaving] = React.useState(false);
1892
2889
 
1893
2890
  React.useEffect(() => {
1894
- if (open) setName(defaultName)
1895
- }, [defaultName, open])
2891
+ if (open) setName(defaultName);
2892
+ }, [defaultName, open]);
1896
2893
 
1897
2894
  const handleSubmit = React.useCallback(async () => {
1898
- setSaving(true)
2895
+ setSaving(true);
1899
2896
  try {
1900
- await onSubmit(name)
1901
- onOpenChange(false)
2897
+ await onSubmit(name);
2898
+ onOpenChange(false);
1902
2899
  } catch (err) {
1903
- console.error('schema.save.failed', err)
2900
+ console.error("schema.save.failed", err);
1904
2901
  } finally {
1905
- setSaving(false)
2902
+ setSaving(false);
1906
2903
  }
1907
- }, [name, onOpenChange, onSubmit])
2904
+ }, [name, onOpenChange, onSubmit]);
1908
2905
 
1909
2906
  return (
1910
2907
  <Dialog open={open} onOpenChange={onOpenChange}>
1911
2908
  <DialogContent>
1912
2909
  <DialogHeader>
1913
- <DialogTitle>{t('catalog.products.edit.schemas.saveTitle', 'Save option schema')}</DialogTitle>
2910
+ <DialogTitle>
2911
+ {t("catalog.products.edit.schemas.saveTitle", "Save option schema")}
2912
+ </DialogTitle>
1914
2913
  </DialogHeader>
1915
2914
  <div className="space-y-2">
1916
- <Label htmlFor="schemaName">{t('catalog.products.edit.schemas.nameLabel', 'Schema name')}</Label>
1917
- <Input id="schemaName" value={name} onChange={(event) => setName(event.target.value)} placeholder={t('catalog.products.edit.schemas.namePlaceholder', 'e.g., Color + Size set')} />
2915
+ <Label htmlFor="schemaName">
2916
+ {t("catalog.products.edit.schemas.nameLabel", "Schema name")}
2917
+ </Label>
2918
+ <Input
2919
+ id="schemaName"
2920
+ value={name}
2921
+ onChange={(event) => setName(event.target.value)}
2922
+ placeholder={t(
2923
+ "catalog.products.edit.schemas.namePlaceholder",
2924
+ "e.g., Color + Size set",
2925
+ )}
2926
+ />
1918
2927
  </div>
1919
2928
  <DialogFooter>
1920
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
1921
- {t('common.cancel', 'Cancel')}
2929
+ <Button
2930
+ type="button"
2931
+ variant="outline"
2932
+ onClick={() => onOpenChange(false)}
2933
+ >
2934
+ {t("common.cancel", "Cancel")}
1922
2935
  </Button>
1923
2936
  <Button type="button" onClick={handleSubmit} disabled={saving}>
1924
- {saving ? t('common.saving', 'Saving…') : t('common.save', 'Save')}
2937
+ {saving ? t("common.saving", "Saving…") : t("common.save", "Save")}
1925
2938
  </Button>
1926
2939
  </DialogFooter>
1927
2940
  </DialogContent>
1928
2941
  </Dialog>
1929
- )
2942
+ );
1930
2943
  }