@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,152 +1,227 @@
1
- import { z } from 'zod'
2
- import { slugify } from '@open-mercato/shared/lib/slugify'
3
- import { parseObjectLike } from '@open-mercato/shared/lib/json/parseObjectLike'
4
- import type { CatalogProductOptionSchema } from '../../data/types'
5
- import type { ProductMediaItem } from './ProductMediaManager'
1
+ import { z } from "zod";
2
+ import { slugify } from "@open-mercato/shared/lib/slugify";
3
+ import { parseObjectLike } from "@open-mercato/shared/lib/json/parseObjectLike";
4
+ import type { ReferenceUnitCode } from "@open-mercato/shared/lib/units/unitCodes";
5
+ import type { CatalogProductOptionSchema } from "../../data/types";
6
+ import type { ProductMediaItem } from "./ProductMediaManager";
6
7
 
7
- export { slugify }
8
+ export { slugify };
8
9
 
9
10
  export type PriceKindSummary = {
10
- id: string
11
- code: string
12
- title: string
13
- currencyCode: string | null
14
- displayMode: 'including-tax' | 'excluding-tax'
15
- }
11
+ id: string;
12
+ code: string;
13
+ title: string;
14
+ currencyCode: string | null;
15
+ displayMode: "including-tax" | "excluding-tax";
16
+ };
16
17
 
17
18
  export type PriceKindApiPayload = {
18
- id?: string | number
19
- code?: string
20
- title?: string
21
- currencyCode?: string | null
22
- currency_code?: string | null
23
- displayMode?: string | null
24
- display_mode?: string | null
25
- }
19
+ id?: string | number;
20
+ code?: string;
21
+ title?: string;
22
+ currencyCode?: string | null;
23
+ currency_code?: string | null;
24
+ displayMode?: string | null;
25
+ display_mode?: string | null;
26
+ };
26
27
 
27
28
  export type TaxRateSummary = {
28
- id: string
29
- name: string
30
- code: string | null
31
- rate: number | null
32
- isDefault: boolean
33
- }
29
+ id: string;
30
+ name: string;
31
+ code: string | null;
32
+ rate: number | null;
33
+ isDefault: boolean;
34
+ };
34
35
 
35
36
  export type ProductOptionInput = {
36
- id: string
37
- title: string
38
- values: Array<{ id: string; label: string }>
39
- }
37
+ id: string;
38
+ title: string;
39
+ values: Array<{ id: string; label: string }>;
40
+ };
40
41
 
41
42
  export type ProductDimensions = {
42
- width?: number
43
- height?: number
44
- depth?: number
45
- unit?: string | null
46
- } | null
43
+ width?: number;
44
+ height?: number;
45
+ depth?: number;
46
+ unit?: string | null;
47
+ } | null;
47
48
 
48
49
  export type ProductWeight = {
49
- value?: number
50
- unit?: string | null
51
- } | null
50
+ value?: number;
51
+ unit?: string | null;
52
+ } | null;
52
53
 
53
54
  export type VariantPriceValue = {
54
- amount: string
55
- }
55
+ amount: string;
56
+ };
57
+
58
+ export type ProductUnitRoundingMode = "half_up" | "down" | "up";
59
+ export type ProductUnitPriceReferenceUnit = ReferenceUnitCode;
60
+
61
+ export type ProductUnitConversionDraft = {
62
+ id: string | null;
63
+ unitCode: string;
64
+ toBaseFactor: string;
65
+ sortOrder: string;
66
+ isActive: boolean;
67
+ };
56
68
 
57
69
  export type VariantDraft = {
58
- id: string
59
- title: string
60
- sku: string
61
- isDefault: boolean
62
- taxRateId: string | null
63
- manageInventory: boolean
64
- allowBackorder: boolean
65
- hasInventoryKit: boolean
66
- optionValues: Record<string, string>
67
- prices: Record<string, VariantPriceValue>
68
- }
70
+ id: string;
71
+ title: string;
72
+ sku: string;
73
+ isDefault: boolean;
74
+ taxRateId: string | null;
75
+ manageInventory: boolean;
76
+ allowBackorder: boolean;
77
+ hasInventoryKit: boolean;
78
+ optionValues: Record<string, string>;
79
+ prices: Record<string, VariantPriceValue>;
80
+ };
69
81
 
70
82
  export type ProductFormValues = {
71
- title: string
72
- subtitle: string
73
- handle: string
74
- description: string
75
- useMarkdown: boolean
76
- taxRateId: string | null
77
- mediaDraftId: string
78
- mediaItems: ProductMediaItem[]
79
- defaultMediaId: string | null
80
- defaultMediaUrl: string
81
- hasVariants: boolean
82
- options: ProductOptionInput[]
83
- variants: VariantDraft[]
84
- metadata?: Record<string, unknown> | null
85
- dimensions?: ProductDimensions
86
- weight?: ProductWeight
87
- customFieldsetCode?: string | null
88
- categoryIds: string[]
89
- channelIds: string[]
90
- tags: string[]
91
- optionSchemaId?: string | null
92
- }
83
+ title: string;
84
+ subtitle: string;
85
+ handle: string;
86
+ description: string;
87
+ useMarkdown: boolean;
88
+ taxRateId: string | null;
89
+ mediaDraftId: string;
90
+ mediaItems: ProductMediaItem[];
91
+ defaultMediaId: string | null;
92
+ defaultMediaUrl: string;
93
+ hasVariants: boolean;
94
+ options: ProductOptionInput[];
95
+ variants: VariantDraft[];
96
+ metadata?: Record<string, unknown> | null;
97
+ dimensions?: ProductDimensions;
98
+ weight?: ProductWeight;
99
+ defaultUnit: string | null;
100
+ defaultSalesUnit: string | null;
101
+ defaultSalesUnitQuantity: string;
102
+ uomRoundingScale: string;
103
+ uomRoundingMode: ProductUnitRoundingMode;
104
+ unitPriceEnabled: boolean;
105
+ unitPriceReferenceUnit: string | null;
106
+ unitPriceBaseQuantity: string;
107
+ unitConversions: ProductUnitConversionDraft[];
108
+ customFieldsetCode?: string | null;
109
+ categoryIds: string[];
110
+ channelIds: string[];
111
+ tags: string[];
112
+ optionSchemaId?: string | null;
113
+ };
93
114
 
94
- export const productFormSchema = z.object({
95
- title: z.string().trim().min(1, 'Title is required'),
96
- subtitle: z.string().optional(),
97
- handle: z
98
- .string()
99
- .trim()
100
- .regex(/^[a-z0-9\-_]*$/, 'Handle must include lowercase letters, digits, hyphen, or underscore')
101
- .max(150)
102
- .optional(),
103
- description: z.string().optional(),
104
- useMarkdown: z.boolean().optional(),
105
- taxRateId: z.string().uuid().nullable().optional(),
106
- hasVariants: z.boolean().optional(),
107
- mediaDraftId: z.string().optional(),
108
- mediaItems: z.any().optional(),
109
- defaultMediaId: z.string().uuid().nullable().optional(),
110
- defaultMediaUrl: z.string().trim().max(500).nullable().optional(),
111
- options: z.any().optional(),
112
- variants: z.any().optional(),
113
- // Use a permissive schema to avoid zod classic `_zod` runtime crashes on records in edge builds.
114
- metadata: z.custom<Record<string, unknown>>(() => true).nullable().optional(),
115
- dimensions: z
116
- .object({
117
- width: z.coerce.number().min(0).optional(),
118
- height: z.coerce.number().min(0).optional(),
119
- depth: z.coerce.number().min(0).optional(),
120
- unit: z.string().trim().max(25).optional(),
121
- })
122
- .nullable()
123
- .optional(),
124
- weight: z
125
- .object({
126
- value: z.coerce.number().min(0).optional(),
127
- unit: z.string().trim().max(25).optional(),
128
- })
129
- .nullable()
130
- .optional(),
131
- customFieldsetCode: z.string().optional().nullable(),
132
- categoryIds: z.array(z.string().uuid()).optional(),
133
- channelIds: z.array(z.string().uuid()).optional(),
134
- tags: z.array(z.string().trim().min(1).max(100)).optional(),
135
- optionSchemaId: z.string().uuid().nullable().optional(),
136
- }).passthrough()
115
+ const optionalPositiveNumberInput = z.preprocess((value) => {
116
+ if (value === null || value === undefined) return undefined;
117
+ if (typeof value === "string" && value.trim().length === 0) return undefined;
118
+ return value;
119
+ }, z.coerce.number().positive().optional());
120
+
121
+ const optionalBoundedIntegerInput = (min: number, max: number) =>
122
+ z.preprocess((value) => {
123
+ if (value === null || value === undefined) return undefined;
124
+ if (typeof value === "string" && value.trim().length === 0)
125
+ return undefined;
126
+ return value;
127
+ }, z.coerce.number().int().min(min).max(max).optional());
137
128
 
138
- export const PRODUCT_FORM_STEPS = ['general', 'organize', 'variants'] as const
129
+ export const productFormSchema = z
130
+ .object({
131
+ title: z.string().trim().min(1, "catalog.products.validation.titleRequired"),
132
+ subtitle: z.string().optional(),
133
+ handle: z
134
+ .string()
135
+ .trim()
136
+ .regex(
137
+ /^[a-z0-9\-_]*$/,
138
+ "catalog.products.validation.handleFormat",
139
+ )
140
+ .max(150)
141
+ .optional(),
142
+ description: z.string().optional(),
143
+ useMarkdown: z.boolean().optional(),
144
+ taxRateId: z.string().uuid().nullable().optional(),
145
+ hasVariants: z.boolean().optional(),
146
+ mediaDraftId: z.string().optional(),
147
+ mediaItems: z.any().optional(),
148
+ defaultMediaId: z.string().uuid().nullable().optional(),
149
+ defaultMediaUrl: z.string().trim().max(500).nullable().optional(),
150
+ options: z.any().optional(),
151
+ variants: z.any().optional(),
152
+ // Use a permissive schema to avoid zod classic `_zod` runtime crashes on records in edge builds.
153
+ metadata: z
154
+ .custom<Record<string, unknown>>(() => true)
155
+ .nullable()
156
+ .optional(),
157
+ dimensions: z
158
+ .object({
159
+ width: z.coerce.number().min(0).optional(),
160
+ height: z.coerce.number().min(0).optional(),
161
+ depth: z.coerce.number().min(0).optional(),
162
+ unit: z.string().trim().max(25).optional(),
163
+ })
164
+ .nullable()
165
+ .optional(),
166
+ weight: z
167
+ .object({
168
+ value: z.coerce.number().min(0).optional(),
169
+ unit: z.string().trim().max(25).optional(),
170
+ })
171
+ .nullable()
172
+ .optional(),
173
+ defaultUnit: z.string().trim().max(50).nullable().optional(),
174
+ defaultSalesUnit: z.string().trim().max(50).nullable().optional(),
175
+ defaultSalesUnitQuantity: optionalPositiveNumberInput,
176
+ uomRoundingScale: optionalBoundedIntegerInput(0, 6),
177
+ uomRoundingMode: z.enum(["half_up", "down", "up"]).optional(),
178
+ unitPriceEnabled: z.boolean().optional(),
179
+ unitPriceReferenceUnit: z.string().trim().max(50).nullable().optional(),
180
+ unitPriceBaseQuantity: optionalPositiveNumberInput,
181
+ unitConversions: z
182
+ .array(
183
+ z.object({
184
+ id: z.string().nullable().optional(),
185
+ unitCode: z.string().trim().max(50),
186
+ toBaseFactor: z.coerce.number().positive(),
187
+ sortOrder: z.coerce.number().int().min(0).max(100000).optional(),
188
+ isActive: z.boolean().optional(),
189
+ }),
190
+ )
191
+ .optional(),
192
+ customFieldsetCode: z.string().optional().nullable(),
193
+ categoryIds: z.array(z.string().uuid()).optional(),
194
+ channelIds: z.array(z.string().uuid()).optional(),
195
+ tags: z.array(z.string().trim().min(1).max(100)).optional(),
196
+ optionSchemaId: z.string().uuid().nullable().optional(),
197
+ })
198
+ .passthrough()
199
+ .refine(
200
+ (data) => !data.unitPriceEnabled || (data.unitPriceReferenceUnit != null && data.unitPriceReferenceUnit.length > 0),
201
+ { message: 'catalog.products.validation.referenceUnitRequired', path: ['unitPriceReferenceUnit'] }
202
+ )
203
+ .refine(
204
+ (data) => !data.defaultSalesUnit || (data.defaultUnit != null && data.defaultUnit.length > 0),
205
+ { message: 'catalog.products.validation.baseUnitRequired', path: ['defaultUnit'] }
206
+ );
207
+
208
+ export const PRODUCT_FORM_STEPS = [
209
+ "general",
210
+ "organize",
211
+ "uom",
212
+ "variants",
213
+ ] as const;
139
214
 
140
215
  export const BASE_INITIAL_VALUES: ProductFormValues = {
141
- title: '',
142
- subtitle: '',
143
- handle: '',
144
- description: '',
216
+ title: "",
217
+ subtitle: "",
218
+ handle: "",
219
+ description: "",
145
220
  useMarkdown: false,
146
- mediaDraftId: '',
221
+ mediaDraftId: "",
147
222
  mediaItems: [],
148
223
  defaultMediaId: null,
149
- defaultMediaUrl: '',
224
+ defaultMediaUrl: "",
150
225
  taxRateId: null,
151
226
  hasVariants: false,
152
227
  options: [],
@@ -154,26 +229,35 @@ export const BASE_INITIAL_VALUES: ProductFormValues = {
154
229
  metadata: {},
155
230
  dimensions: null,
156
231
  weight: null,
232
+ defaultUnit: null,
233
+ defaultSalesUnit: null,
234
+ defaultSalesUnitQuantity: "1",
235
+ uomRoundingScale: "4",
236
+ uomRoundingMode: "half_up",
237
+ unitPriceEnabled: false,
238
+ unitPriceReferenceUnit: null,
239
+ unitPriceBaseQuantity: "",
240
+ unitConversions: [],
157
241
  customFieldsetCode: null,
158
242
  categoryIds: [],
159
243
  channelIds: [],
160
244
  tags: [],
161
245
  optionSchemaId: null,
162
- }
246
+ };
163
247
 
164
248
  export const createInitialProductFormValues = (): ProductFormValues => ({
165
249
  ...BASE_INITIAL_VALUES,
166
250
  mediaDraftId: createLocalId(),
167
251
  variants: [createVariantDraft(null, { isDefault: true })],
168
- })
252
+ });
169
253
 
170
254
  export const createVariantDraft = (
171
255
  productTaxRateId: string | null,
172
256
  overrides: Partial<VariantDraft> = {},
173
257
  ): VariantDraft => ({
174
258
  id: createLocalId(),
175
- title: 'Default variant',
176
- sku: '',
259
+ title: "Default variant",
260
+ sku: "",
177
261
  isDefault: false,
178
262
  taxRateId: productTaxRateId ?? null,
179
263
  manageInventory: false,
@@ -182,224 +266,253 @@ export const createVariantDraft = (
182
266
  optionValues: {},
183
267
  prices: {},
184
268
  ...overrides,
185
- })
269
+ });
270
+
271
+ export const createProductUnitConversionDraft = (
272
+ overrides: Partial<ProductUnitConversionDraft> = {},
273
+ ): ProductUnitConversionDraft => ({
274
+ id: null,
275
+ unitCode: "",
276
+ toBaseFactor: "",
277
+ sortOrder: "",
278
+ isActive: true,
279
+ ...overrides,
280
+ });
186
281
 
187
- export const buildOptionValuesKey = (optionValues?: Record<string, string>): string => {
188
- if (!optionValues) return ''
282
+ export const buildOptionValuesKey = (
283
+ optionValues?: Record<string, string>,
284
+ ): string => {
285
+ if (!optionValues) return "";
189
286
  return Object.keys(optionValues)
190
287
  .sort()
191
- .map((key) => `${key}:${optionValues[key] ?? ''}`)
192
- .join('|')
193
- }
288
+ .map((key) => `${key}:${optionValues[key] ?? ""}`)
289
+ .join("|");
290
+ };
194
291
 
195
292
  export const haveSameOptionValues = (
196
293
  current: Record<string, string> | undefined,
197
294
  next: Record<string, string>,
198
295
  ): boolean => {
199
- const a = current ?? {}
200
- const keys = new Set([...Object.keys(a), ...Object.keys(next)])
296
+ const a = current ?? {};
297
+ const keys = new Set([...Object.keys(a), ...Object.keys(next)]);
201
298
  for (const key of keys) {
202
- if ((a[key] ?? '') !== (next[key] ?? '')) return false
299
+ if ((a[key] ?? "") !== (next[key] ?? "")) return false;
203
300
  }
204
- return true
205
- }
301
+ return true;
302
+ };
206
303
 
207
304
  const parseNumeric = (input: unknown): number | null => {
208
- const numeric = typeof input === 'number' ? input : Number(input)
209
- if (!Number.isFinite(numeric) || numeric < 0) return null
210
- return numeric
211
- }
305
+ const numeric = typeof input === "number" ? input : Number(input);
306
+ if (!Number.isFinite(numeric) || numeric < 0) return null;
307
+ return numeric;
308
+ };
212
309
 
213
310
  export const normalizeProductDimensions = (raw: unknown): ProductDimensions => {
214
- const source = parseObjectLike(raw)
215
- if (!source) return null
216
- const width = parseNumeric(source.width)
217
- const height = parseNumeric(source.height)
218
- const depth = parseNumeric(source.depth)
219
- const unit = typeof source.unit === 'string' && source.unit.trim().length ? source.unit.trim() : null
220
- const clean: Record<string, unknown> = {}
221
- if (width !== null) clean.width = width
222
- if (height !== null) clean.height = height
223
- if (depth !== null) clean.depth = depth
224
- if (unit) clean.unit = unit
225
- return Object.keys(clean).length ? (clean as ProductDimensions) : null
226
- }
311
+ const source = parseObjectLike(raw);
312
+ if (!source) return null;
313
+ const width = parseNumeric(source.width);
314
+ const height = parseNumeric(source.height);
315
+ const depth = parseNumeric(source.depth);
316
+ const unit =
317
+ typeof source.unit === "string" && source.unit.trim().length
318
+ ? source.unit.trim()
319
+ : null;
320
+ const clean: Record<string, unknown> = {};
321
+ if (width !== null) clean.width = width;
322
+ if (height !== null) clean.height = height;
323
+ if (depth !== null) clean.depth = depth;
324
+ if (unit) clean.unit = unit;
325
+ return Object.keys(clean).length ? (clean as ProductDimensions) : null;
326
+ };
227
327
 
228
328
  export const normalizeProductWeight = (raw: unknown): ProductWeight => {
229
- const source = parseObjectLike(raw)
230
- if (!source) return null
231
- const value = parseNumeric(source.value)
232
- const unit = typeof source.unit === 'string' && source.unit.trim().length ? source.unit.trim() : null
233
- if (value === null && !unit) return null
234
- const clean: Record<string, unknown> = {}
235
- if (value !== null) clean.value = value
236
- if (unit) clean.unit = unit
237
- return clean as ProductWeight
238
- }
329
+ const source = parseObjectLike(raw);
330
+ if (!source) return null;
331
+ const value = parseNumeric(source.value);
332
+ const unit =
333
+ typeof source.unit === "string" && source.unit.trim().length
334
+ ? source.unit.trim()
335
+ : null;
336
+ if (value === null && !unit) return null;
337
+ const clean: Record<string, unknown> = {};
338
+ if (value !== null) clean.value = value;
339
+ if (unit) clean.unit = unit;
340
+ return clean as ProductWeight;
341
+ };
239
342
 
240
- export const sanitizeProductDimensions = (raw: ProductDimensions): ProductDimensions => {
241
- return normalizeProductDimensions(raw ?? null)
242
- }
343
+ export const sanitizeProductDimensions = (
344
+ raw: ProductDimensions,
345
+ ): ProductDimensions => {
346
+ return normalizeProductDimensions(raw ?? null);
347
+ };
243
348
 
244
349
  export const sanitizeProductWeight = (raw: ProductWeight): ProductWeight => {
245
- return normalizeProductWeight(raw ?? null)
246
- }
350
+ return normalizeProductWeight(raw ?? null);
351
+ };
247
352
 
248
353
  export const updateDimensionValue = (
249
354
  current: ProductDimensions,
250
- field: 'width' | 'height' | 'depth' | 'unit',
251
- raw: string
355
+ field: "width" | "height" | "depth" | "unit",
356
+ raw: string,
252
357
  ): ProductDimensions => {
253
- const base = normalizeProductDimensions(current) ?? {}
254
- if (field === 'unit') {
255
- base.unit = raw
358
+ const base = normalizeProductDimensions(current) ?? {};
359
+ if (field === "unit") {
360
+ base.unit = raw;
256
361
  } else {
257
- const numeric = parseNumeric(raw)
362
+ const numeric = parseNumeric(raw);
258
363
  if (numeric === null) {
259
- delete base[field]
364
+ delete base[field];
260
365
  } else {
261
- base[field] = numeric
366
+ base[field] = numeric;
262
367
  }
263
368
  }
264
- return sanitizeProductDimensions(base)
265
- }
369
+ return sanitizeProductDimensions(base);
370
+ };
266
371
 
267
372
  export const updateWeightValue = (
268
373
  current: ProductWeight,
269
- field: 'value' | 'unit',
270
- raw: string
374
+ field: "value" | "unit",
375
+ raw: string,
271
376
  ): ProductWeight => {
272
- const base = normalizeProductWeight(current) ?? {}
273
- if (field === 'unit') {
274
- base.unit = raw
377
+ const base = normalizeProductWeight(current) ?? {};
378
+ if (field === "unit") {
379
+ base.unit = raw;
275
380
  } else {
276
- const numeric = parseNumeric(raw)
381
+ const numeric = parseNumeric(raw);
277
382
  if (numeric === null) {
278
- delete (base as Record<string, unknown>).value
383
+ delete (base as Record<string, unknown>).value;
279
384
  } else {
280
- base.value = numeric
385
+ base.value = numeric;
281
386
  }
282
387
  }
283
- return sanitizeProductWeight(base)
284
- }
388
+ return sanitizeProductWeight(base);
389
+ };
285
390
 
286
- export const normalizePriceKindSummary = (input: PriceKindApiPayload | undefined | null): PriceKindSummary | null => {
287
- if (!input) return null
391
+ export const normalizePriceKindSummary = (
392
+ input: PriceKindApiPayload | undefined | null,
393
+ ): PriceKindSummary | null => {
394
+ if (!input) return null;
288
395
  const getString = (value: unknown): string | null => {
289
- if (typeof value === 'string' && value.trim().length) return value.trim()
290
- if (typeof value === 'number' || typeof value === 'bigint') return String(value)
291
- return null
292
- }
293
- const id = getString(input.id)
294
- const code = getString(input.code)
295
- const title = getString(input.title)
296
- if (!id || !code || !title) return null
297
- const currency = getString(input.currencyCode) ?? getString(input.currency_code)
298
- const displayRaw = getString(input.displayMode) ?? getString(input.display_mode)
299
- const displayMode: PriceKindSummary['displayMode'] =
300
- displayRaw === 'including-tax' ? 'including-tax' : 'excluding-tax'
396
+ if (typeof value === "string" && value.trim().length) return value.trim();
397
+ if (typeof value === "number" || typeof value === "bigint")
398
+ return String(value);
399
+ return null;
400
+ };
401
+ const id = getString(input.id);
402
+ const code = getString(input.code);
403
+ const title = getString(input.title);
404
+ if (!id || !code || !title) return null;
405
+ const currency =
406
+ getString(input.currencyCode) ?? getString(input.currency_code);
407
+ const displayRaw =
408
+ getString(input.displayMode) ?? getString(input.display_mode);
409
+ const displayMode: PriceKindSummary["displayMode"] =
410
+ displayRaw === "including-tax" ? "including-tax" : "excluding-tax";
301
411
  return {
302
412
  id,
303
413
  code,
304
414
  title,
305
415
  currencyCode: currency,
306
416
  displayMode,
307
- }
308
- }
417
+ };
418
+ };
309
419
 
310
420
  export const formatTaxRateLabel = (rate: TaxRateSummary): string => {
311
- const extras: string[] = []
312
- if (typeof rate.rate === 'number' && Number.isFinite(rate.rate)) {
313
- extras.push(`${rate.rate}%`)
421
+ const extras: string[] = [];
422
+ if (typeof rate.rate === "number" && Number.isFinite(rate.rate)) {
423
+ extras.push(`${rate.rate}%`);
314
424
  }
315
425
  if (rate.code) {
316
- extras.push(rate.code.toUpperCase())
426
+ extras.push(rate.code.toUpperCase());
317
427
  }
318
- if (!extras.length) return rate.name
319
- return `${rate.name} • ${extras.join(' · ')}`
320
- }
428
+ if (!extras.length) return rate.name;
429
+ return `${rate.name} • ${extras.join(" · ")}`;
430
+ };
321
431
 
322
432
  export function createLocalId(): string {
323
- return Math.random().toString(36).slice(2, 10)
433
+ return Math.random().toString(36).slice(2, 10);
324
434
  }
325
435
 
326
436
  export function buildOptionSchemaDefinition(
327
437
  options: ProductOptionInput[] | undefined,
328
- name: string
438
+ name: string,
329
439
  ): CatalogProductOptionSchema | null {
330
- const list = Array.isArray(options) ? options : []
331
- if (!list.length) return null
332
- const normalizedName = name && name.trim().length ? name.trim() : 'Product options'
440
+ const list = Array.isArray(options) ? options : [];
441
+ if (!list.length) return null;
442
+ const normalizedName =
443
+ name && name.trim().length ? name.trim() : "Product options";
333
444
  const schemaOptions = list
334
445
  .map((option) => {
335
- const title = option.title?.trim() || ''
336
- const code = resolveOptionCode(option)
337
- const values = Array.isArray(option.values) ? option.values : []
446
+ const title = option.title?.trim() || "";
447
+ const code = resolveOptionCode(option);
448
+ const values = Array.isArray(option.values) ? option.values : [];
338
449
  return {
339
450
  code: code || slugify(createLocalId()),
340
- label: title || code || 'Option',
341
- inputType: 'select' as const,
451
+ label: title || code || "Option",
452
+ inputType: "select" as const,
342
453
  choices: values
343
454
  .map((value) => {
344
- const label = value.label?.trim() || ''
345
- const valueCode = slugify(label || value.id || createLocalId())
346
- if (!label && !valueCode) return null
455
+ const label = value.label?.trim() || "";
456
+ const valueCode = slugify(label || value.id || createLocalId());
457
+ if (!label && !valueCode) return null;
347
458
  return {
348
459
  code: valueCode || slugify(createLocalId()),
349
- label: label || valueCode || 'Choice',
350
- }
460
+ label: label || valueCode || "Choice",
461
+ };
351
462
  })
352
463
  .filter((entry): entry is { code: string; label: string } => !!entry),
353
- }
464
+ };
354
465
  })
355
- .filter((entry) => entry.label.trim().length)
356
- if (!schemaOptions.length) return null
466
+ .filter((entry) => entry.label.trim().length);
467
+ if (!schemaOptions.length) return null;
357
468
  return {
358
469
  version: 1,
359
470
  name: normalizedName,
360
471
  options: schemaOptions,
361
- }
472
+ };
362
473
  }
363
474
 
364
475
  export function convertSchemaToProductOptions(
365
- schema: CatalogProductOptionSchema | null | undefined
476
+ schema: CatalogProductOptionSchema | null | undefined,
366
477
  ): ProductOptionInput[] {
367
- if (!schema || !Array.isArray(schema.options)) return []
478
+ if (!schema || !Array.isArray(schema.options)) return [];
368
479
  return schema.options.map((option) => ({
369
480
  id: createLocalId(),
370
- title: option.label ?? option.code ?? 'Option',
481
+ title: option.label ?? option.code ?? "Option",
371
482
  values: Array.isArray(option.choices)
372
483
  ? option.choices.map((choice) => ({
373
484
  id: createLocalId(),
374
- label: choice.label ?? choice.code ?? '',
485
+ label: choice.label ?? choice.code ?? "",
375
486
  }))
376
487
  : [],
377
- }))
488
+ }));
378
489
  }
379
490
 
380
491
  function resolveOptionCode(option: ProductOptionInput): string {
381
- const base = option.title?.trim() || option.id?.trim() || ''
382
- const slugged = slugify(base)
383
- if (slugged.length) return slugged
384
- if (base.length) return base
385
- return createLocalId()
492
+ const base = option.title?.trim() || option.id?.trim() || "";
493
+ const slugged = slugify(base);
494
+ if (slugged.length) return slugged;
495
+ if (base.length) return base;
496
+ return createLocalId();
386
497
  }
387
498
 
388
- export function buildVariantCombinations(options: ProductOptionInput[]): Record<string, string>[] {
389
- if (!options.length) return []
390
- const [first, ...rest] = options
391
- if (!first || !Array.isArray(first.values) || !first.values.length) return []
392
- const firstKey = resolveOptionCode(first)
393
- const initial = first.values.map((value) => ({ [firstKey]: value.label }))
499
+ export function buildVariantCombinations(
500
+ options: ProductOptionInput[],
501
+ ): Record<string, string>[] {
502
+ if (!options.length) return [];
503
+ const [first, ...rest] = options;
504
+ if (!first || !Array.isArray(first.values) || !first.values.length) return [];
505
+ const firstKey = resolveOptionCode(first);
506
+ const initial = first.values.map((value) => ({ [firstKey]: value.label }));
394
507
  return rest.reduce<Record<string, string>[]>((acc, option) => {
395
- if (!Array.isArray(option.values) || !option.values.length) return []
396
- const optionKey = resolveOptionCode(option)
397
- const combos: Record<string, string>[] = []
508
+ if (!Array.isArray(option.values) || !option.values.length) return [];
509
+ const optionKey = resolveOptionCode(option);
510
+ const combos: Record<string, string>[] = [];
398
511
  acc.forEach((partial) => {
399
512
  option.values.forEach((value) => {
400
- combos.push({ ...partial, [optionKey]: value.label })
401
- })
402
- })
403
- return combos
404
- }, initial)
513
+ combos.push({ ...partial, [optionKey]: value.label });
514
+ });
515
+ });
516
+ return combos;
517
+ }, initial);
405
518
  }