@open-mercato/core 0.4.5-develop-3ce83a8b24 → 0.4.5-develop-4849712ccb

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 (163) 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/generated/entities/catalog_product/index.ts +8 -0
  94. package/generated/entities/catalog_product_unit_conversion/index.ts +12 -0
  95. package/generated/entities/sales_credit_memo_line/index.ts +3 -0
  96. package/generated/entities/sales_invoice_line/index.ts +3 -0
  97. package/generated/entities/sales_order_line/index.ts +3 -0
  98. package/generated/entities/sales_quote_line/index.ts +3 -0
  99. package/generated/entities.ids.generated.ts +1 -0
  100. package/generated/entity-fields-registry.ts +2 -0
  101. package/package.json +2 -2
  102. package/src/modules/auth/i18n/de.json +1 -1
  103. package/src/modules/auth/i18n/en.json +1 -1
  104. package/src/modules/auth/i18n/es.json +1 -1
  105. package/src/modules/auth/i18n/pl.json +1 -1
  106. package/src/modules/catalog/api/prices/route.ts +213 -81
  107. package/src/modules/catalog/api/product-unit-conversions/route.ts +195 -0
  108. package/src/modules/catalog/api/products/route.ts +638 -402
  109. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2085 -1072
  110. package/src/modules/catalog/backend/catalog/products/create/page.tsx +1288 -593
  111. package/src/modules/catalog/commands/index.ts +1 -0
  112. package/src/modules/catalog/commands/productUnitConversions.ts +626 -0
  113. package/src/modules/catalog/commands/products.ts +1151 -693
  114. package/src/modules/catalog/commands/shared.ts +19 -5
  115. package/src/modules/catalog/components/products/ProductUomSection.tsx +745 -0
  116. package/src/modules/catalog/components/products/productForm.ts +369 -256
  117. package/src/modules/catalog/components/products/productFormUtils.ts +82 -0
  118. package/src/modules/catalog/data/entities.ts +82 -1
  119. package/src/modules/catalog/data/validators.ts +118 -34
  120. package/src/modules/catalog/events.ts +3 -0
  121. package/src/modules/catalog/i18n/de.json +56 -0
  122. package/src/modules/catalog/i18n/en.json +56 -0
  123. package/src/modules/catalog/i18n/es.json +56 -0
  124. package/src/modules/catalog/i18n/pl.json +56 -0
  125. package/src/modules/catalog/lib/unitCodes.ts +1 -0
  126. package/src/modules/catalog/lib/unitResolution.ts +62 -0
  127. package/src/modules/catalog/migrations/.snapshot-open-mercato.json +245 -0
  128. package/src/modules/catalog/migrations/Migration20260218225422.ts +21 -0
  129. package/src/modules/catalog/migrations/Migration20260219084500.ts +26 -0
  130. package/src/modules/catalog/search.ts +73 -1
  131. package/src/modules/catalog/seed/examples.ts +552 -479
  132. package/src/modules/dashboards/i18n/de.json +1 -1
  133. package/src/modules/dashboards/i18n/en.json +1 -1
  134. package/src/modules/dashboards/i18n/es.json +1 -1
  135. package/src/modules/dashboards/i18n/pl.json +1 -1
  136. package/src/modules/dashboards/seed/analytics.ts +3 -0
  137. package/src/modules/sales/api/order-lines/route.ts +158 -68
  138. package/src/modules/sales/api/quote-lines/route.ts +161 -67
  139. package/src/modules/sales/api/quotes/public/[token]/route.ts +122 -36
  140. package/src/modules/sales/commands/documents.ts +4250 -2424
  141. package/src/modules/sales/commands/shared.ts +7 -2
  142. package/src/modules/sales/components/documents/ItemsSection.tsx +580 -310
  143. package/src/modules/sales/components/documents/LineItemDialog.tsx +1988 -833
  144. package/src/modules/sales/components/documents/ShipmentsSection.tsx +17 -3
  145. package/src/modules/sales/components/documents/lineItemTypes.ts +6 -0
  146. package/src/modules/sales/data/entities.ts +53 -0
  147. package/src/modules/sales/data/validators.ts +36 -0
  148. package/src/modules/sales/frontend/quote/[token]/page.tsx +25 -1
  149. package/src/modules/sales/i18n/de.json +23 -3
  150. package/src/modules/sales/i18n/en.json +23 -3
  151. package/src/modules/sales/i18n/es.json +23 -3
  152. package/src/modules/sales/i18n/pl.json +23 -3
  153. package/src/modules/sales/lib/types.ts +30 -0
  154. package/src/modules/sales/migrations/.snapshot-open-mercato.json +172 -0
  155. package/src/modules/sales/migrations/Migration20260218225423.ts +37 -0
  156. package/src/modules/sales/migrations/Migration20260219084501.ts +73 -0
  157. package/src/modules/sales/search.ts +28 -0
  158. package/src/modules/sales/seed/examples.ts +20 -1
  159. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +1 -1
  160. package/src/modules/workflows/i18n/de.json +4 -4
  161. package/src/modules/workflows/i18n/en.json +4 -4
  162. package/src/modules/workflows/i18n/es.json +4 -4
  163. package/src/modules/workflows/i18n/pl.json +4 -4
@@ -1,25 +1,44 @@
1
- "use client"
2
-
3
- import * as React from 'react'
4
- import dynamic from 'next/dynamic'
5
- import { useRouter, useSearchParams } from 'next/navigation'
6
- import type { ZodType } from 'zod'
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 { createCrud } from '@open-mercato/ui/backend/utils/crud'
10
- import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
11
- import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
- import { TagsInput } from '@open-mercato/ui/backend/inputs/TagsInput'
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 { cn } from '@open-mercato/shared/lib/utils'
17
- import { Plus, Trash2, FileText, AlignLeft, ChevronLeft, ChevronRight, AlertCircle, Settings } from 'lucide-react'
18
- import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
19
- import { useT } from '@open-mercato/shared/lib/i18n/context'
20
- import { E } from '#generated/entities.ids.generated'
21
- import { ProductMediaManager, type ProductMediaItem } from '@open-mercato/core/modules/catalog/components/products/ProductMediaManager'
22
- import { ProductCategorizeSection } from '@open-mercato/core/modules/catalog/components/products/ProductCategorizeSection'
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import dynamic from "next/dynamic";
5
+ import { useRouter, useSearchParams } from "next/navigation";
6
+ import type { ZodType } from "zod";
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 { createCrud } from "@open-mercato/ui/backend/utils/crud";
14
+ import { createCrudFormError } from "@open-mercato/ui/backend/utils/serverErrors";
15
+ import { flash } from "@open-mercato/ui/backend/FlashMessages";
16
+ import { TagsInput } from "@open-mercato/ui/backend/inputs/TagsInput";
17
+ import { Button } from "@open-mercato/ui/primitives/button";
18
+ import { Input } from "@open-mercato/ui/primitives/input";
19
+ import { Label } from "@open-mercato/ui/primitives/label";
20
+ import { cn } from "@open-mercato/shared/lib/utils";
21
+ import {
22
+ Plus,
23
+ Trash2,
24
+ FileText,
25
+ AlignLeft,
26
+ ChevronLeft,
27
+ ChevronRight,
28
+ AlertCircle,
29
+ Settings,
30
+ } from "lucide-react";
31
+ import {
32
+ apiCall,
33
+ readApiResultOrThrow,
34
+ } from "@open-mercato/ui/backend/utils/apiCall";
35
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
36
+ import { E } from "#generated/entities.ids.generated";
37
+ import {
38
+ ProductMediaManager,
39
+ type ProductMediaItem,
40
+ } from "@open-mercato/core/modules/catalog/components/products/ProductMediaManager";
41
+ import { ProductCategorizeSection } from "@open-mercato/core/modules/catalog/components/products/ProductCategorizeSection";
23
42
  import {
24
43
  PRODUCT_FORM_STEPS,
25
44
  type PriceKindSummary,
@@ -29,6 +48,9 @@ import {
29
48
  type VariantPriceValue,
30
49
  type VariantDraft,
31
50
  type ProductFormValues,
51
+ type ProductUnitConversionDraft,
52
+ type ProductUnitPriceReferenceUnit,
53
+ type ProductUnitRoundingMode,
32
54
  productFormSchema,
33
55
  createInitialProductFormValues,
34
56
  createVariantDraft,
@@ -46,258 +68,452 @@ import {
46
68
  sanitizeProductWeight,
47
69
  updateDimensionValue,
48
70
  updateWeightValue,
49
- } from '@open-mercato/core/modules/catalog/components/products/productForm'
50
- import { buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
51
-
52
- const productFormTypedSchema = productFormSchema as unknown as ZodType<ProductFormValues>
71
+ } from "@open-mercato/core/modules/catalog/components/products/productForm";
72
+ import {
73
+ buildAttachmentImageUrl,
74
+ slugifyAttachmentFileName,
75
+ } from "@open-mercato/core/modules/attachments/lib/imageUrls";
76
+ import { ProductUomSection } from "@open-mercato/core/modules/catalog/components/products/ProductUomSection";
77
+ import { canonicalizeUnitCode } from "@open-mercato/core/modules/catalog/lib/unitCodes";
78
+ import {
79
+ UNIT_PRICE_REFERENCE_UNITS,
80
+ toTrimmedOrNull,
81
+ parseNumericInput,
82
+ toPositiveNumberOrNull,
83
+ toIntegerInRangeOrDefault,
84
+ normalizeProductConversionInputs,
85
+ type ProductUnitConversionInput,
86
+ } from "@open-mercato/core/modules/catalog/components/products/productFormUtils";
87
+
88
+ const productFormTypedSchema =
89
+ productFormSchema as unknown as ZodType<ProductFormValues>;
53
90
 
54
91
  type VariantPriceRequest = {
55
- variantDraftId: string
56
- priceKindId: string
57
- currencyCode: string
58
- amount: number
59
- displayMode: PriceKindSummary['displayMode']
60
- taxRateId: string | null
61
- taxRateValue: number | null
62
- }
92
+ variantDraftId: string;
93
+ priceKindId: string;
94
+ currencyCode: string;
95
+ amount: number;
96
+ displayMode: PriceKindSummary["displayMode"];
97
+ taxRateId: string | null;
98
+ taxRateValue: number | null;
99
+ };
63
100
 
64
101
  type UiMarkdownEditorProps = {
65
- value?: string
66
- height?: number
67
- onChange?: (value?: string) => void
68
- previewOptions?: { remarkPlugins?: unknown[] }
69
- }
102
+ value?: string;
103
+ height?: number;
104
+ onChange?: (value?: string) => void;
105
+ previewOptions?: { remarkPlugins?: unknown[] };
106
+ };
70
107
 
71
- const MarkdownEditor = dynamic(() => import('@uiw/react-md-editor'), {
108
+ const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), {
72
109
  ssr: false,
73
- loading: () => <div className="flex h-48 items-center justify-center text-sm text-muted-foreground">Loading editor…</div>,
74
- }) as unknown as React.ComponentType<UiMarkdownEditorProps>
110
+ loading: () => (
111
+ <div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
112
+ Loading editor…
113
+ </div>
114
+ ),
115
+ }) as unknown as React.ComponentType<UiMarkdownEditorProps>;
75
116
 
76
- type ProductFormStep = (typeof PRODUCT_FORM_STEPS)[number]
117
+ type ProductFormStep = (typeof PRODUCT_FORM_STEPS)[number];
77
118
 
78
- const TRUE_BOOLEAN_VALUES = new Set(['true', '1', 'yes', 'y', 't'])
119
+ const TRUE_BOOLEAN_VALUES = new Set(["true", "1", "yes", "y", "t"]);
79
120
 
80
121
  const matchField = (fieldId: string) => (value: string) =>
81
- value === fieldId || value.startsWith(`${fieldId}.`) || value.startsWith(`${fieldId}[`)
82
- const matchPrefix = (prefix: string) => (value: string) => value.startsWith(prefix)
83
-
84
- const STEP_FIELD_MATCHERS: Record<ProductFormStep, ((value: string) => boolean)[]> = {
122
+ value === fieldId ||
123
+ value.startsWith(`${fieldId}.`) ||
124
+ value.startsWith(`${fieldId}[`);
125
+ const matchPrefix = (prefix: string) => (value: string) =>
126
+ value.startsWith(prefix);
127
+
128
+ const STEP_FIELD_MATCHERS: Record<
129
+ ProductFormStep,
130
+ ((value: string) => boolean)[]
131
+ > = {
85
132
  general: [
86
- matchField('title'),
87
- matchField('description'),
88
- matchField('mediaItems'),
89
- matchField('mediaDraftId'),
90
- matchPrefix('defaultMedia'),
91
- matchPrefix('dimensions'),
92
- matchPrefix('weight'),
133
+ matchField("title"),
134
+ matchField("description"),
135
+ matchField("mediaItems"),
136
+ matchField("mediaDraftId"),
137
+ matchPrefix("defaultMedia"),
138
+ matchPrefix("dimensions"),
139
+ matchPrefix("weight"),
93
140
  ],
94
- organize: [matchField('categoryIds'), matchField('channelIds'), matchField('tags')],
95
- variants: [matchField('hasVariants'), matchPrefix('options'), matchPrefix('variants')],
96
- }
141
+ organize: [
142
+ matchField("categoryIds"),
143
+ matchField("channelIds"),
144
+ matchField("tags"),
145
+ ],
146
+ uom: [
147
+ matchField("defaultUnit"),
148
+ matchField("defaultSalesUnit"),
149
+ matchField("defaultSalesUnitQuantity"),
150
+ matchField("uomRoundingScale"),
151
+ matchField("uomRoundingMode"),
152
+ matchField("unitPriceEnabled"),
153
+ matchField("unitPriceReferenceUnit"),
154
+ matchField("unitPriceBaseQuantity"),
155
+ matchPrefix("unitConversions"),
156
+ ],
157
+ variants: [
158
+ matchField("hasVariants"),
159
+ matchPrefix("options"),
160
+ matchPrefix("variants"),
161
+ ],
162
+ };
97
163
 
98
164
  function resolveStepForField(fieldId: string): ProductFormStep | null {
99
- const normalized = fieldId?.trim()
100
- if (!normalized) return null
165
+ const normalized = fieldId?.trim();
166
+ if (!normalized) return null;
101
167
  for (const step of PRODUCT_FORM_STEPS) {
102
- const matchers = STEP_FIELD_MATCHERS[step]
103
- if (matchers.some((matcher) => matcher(normalized))) return step
168
+ const matchers = STEP_FIELD_MATCHERS[step];
169
+ if (matchers.some((matcher) => matcher(normalized))) return step;
104
170
  }
105
- return null
171
+ return null;
106
172
  }
107
173
 
108
174
  function resolveBooleanFlag(value: unknown): boolean {
109
- if (typeof value === 'boolean') return value
110
- if (typeof value === 'string') {
111
- const normalized = value.trim().toLowerCase()
112
- if (!normalized) return false
113
- if (TRUE_BOOLEAN_VALUES.has(normalized)) return true
114
- if (['false', '0', 'no', 'n', 'f'].includes(normalized)) return false
175
+ if (typeof value === "boolean") return value;
176
+ if (typeof value === "string") {
177
+ const normalized = value.trim().toLowerCase();
178
+ if (!normalized) return false;
179
+ if (TRUE_BOOLEAN_VALUES.has(normalized)) return true;
180
+ if (["false", "0", "no", "n", "f"].includes(normalized)) return false;
115
181
  }
116
- if (typeof value === 'number') return value !== 0
117
- return false
182
+ if (typeof value === "number") return value !== 0;
183
+ return false;
118
184
  }
119
185
 
120
186
 
121
187
  interface InboxProductDraft {
122
- actionId: string
123
- proposalId: string
124
- payload: Record<string, unknown>
188
+ actionId: string;
189
+ proposalId: string;
190
+ payload: Record<string, unknown>;
125
191
  }
126
192
 
127
193
  function readInboxProductDraft(): InboxProductDraft | null {
128
194
  try {
129
- const raw = sessionStorage.getItem('inbox_ops.productDraft')
130
- if (!raw) return null
131
- const parsed = JSON.parse(raw) as InboxProductDraft
132
- if (!parsed.actionId || !parsed.proposalId || !parsed.payload) return null
133
- return parsed
195
+ const raw = sessionStorage.getItem("inbox_ops.productDraft");
196
+ if (!raw) return null;
197
+ const parsed = JSON.parse(raw) as InboxProductDraft;
198
+ if (!parsed.actionId || !parsed.proposalId || !parsed.payload) return null;
199
+ return parsed;
134
200
  } catch {
135
- return null
201
+ return null;
136
202
  }
137
203
  }
138
204
 
139
205
  export default function CreateCatalogProductPage() {
140
- const t = useT()
141
- const router = useRouter()
142
- const searchParams = useSearchParams()
143
- const fromInboxAction = searchParams.get('fromInboxAction')
206
+ const t = useT();
207
+ const router = useRouter();
208
+ const searchParams = useSearchParams();
209
+ const fromInboxAction = searchParams.get("fromInboxAction");
144
210
 
145
211
  const inboxDraft = React.useMemo<InboxProductDraft | null>(() => {
146
- if (!fromInboxAction) return null
147
- return readInboxProductDraft()
148
- }, [fromInboxAction])
212
+ if (!fromInboxAction) return null;
213
+ return readInboxProductDraft();
214
+ }, [fromInboxAction]);
149
215
 
150
- const initialValuesRef = React.useRef<ProductFormValues | null>(null)
216
+ const initialValuesRef = React.useRef<ProductFormValues | null>(null);
151
217
  if (!initialValuesRef.current) {
152
- const initial = createInitialProductFormValues()
218
+ const initial = createInitialProductFormValues();
153
219
  if (inboxDraft?.payload) {
154
- const p = inboxDraft.payload
155
- if (typeof p.title === 'string' && p.title.trim()) initial.title = p.title.trim()
156
- if (typeof p.description === 'string' && p.description.trim()) initial.description = p.description.trim()
220
+ const p = inboxDraft.payload;
221
+ if (typeof p.title === "string" && p.title.trim()) initial.title = p.title.trim();
222
+ if (typeof p.description === "string" && p.description.trim()) initial.description = p.description.trim();
157
223
  }
158
- initialValuesRef.current = initial
224
+ initialValuesRef.current = initial;
159
225
  }
160
- const [priceKinds, setPriceKinds] = React.useState<PriceKindSummary[]>([])
161
- const [taxRates, setTaxRates] = React.useState<TaxRateSummary[]>([])
226
+ const [priceKinds, setPriceKinds] = React.useState<PriceKindSummary[]>([]);
227
+ const [taxRates, setTaxRates] = React.useState<TaxRateSummary[]>([]);
162
228
  React.useEffect(() => {
163
229
  const loadPriceKinds = async () => {
164
230
  try {
165
- const payload = await readApiResultOrThrow<{ items?: PriceKindApiPayload[] }>(
166
- '/api/catalog/price-kinds?pageSize=100',
167
- undefined,
168
- { errorMessage: t('catalog.priceKinds.errors.load', 'Failed to load price kinds.') },
169
- )
170
- const items = Array.isArray(payload.items) ? payload.items : []
231
+ const payload = await readApiResultOrThrow<{
232
+ items?: PriceKindApiPayload[];
233
+ }>("/api/catalog/price-kinds?pageSize=100", undefined, {
234
+ errorMessage: t(
235
+ "catalog.priceKinds.errors.load",
236
+ "Failed to load price kinds.",
237
+ ),
238
+ });
239
+ const items = Array.isArray(payload.items) ? payload.items : [];
171
240
  setPriceKinds(
172
241
  items
173
242
  .map((item) => normalizePriceKindSummary(item))
174
243
  .filter((item): item is PriceKindSummary => item !== null),
175
- )
244
+ );
176
245
  } catch (err) {
177
- console.error('catalog.price-kinds.fetch failed', err)
178
- setPriceKinds([])
246
+ console.error("catalog.price-kinds.fetch failed", err);
247
+ setPriceKinds([]);
179
248
  }
180
- }
181
- loadPriceKinds().catch(() => {})
182
- }, [t])
249
+ };
250
+ loadPriceKinds().catch(() => {});
251
+ }, [t]);
183
252
 
184
253
  React.useEffect(() => {
185
254
  const loadTaxRates = async () => {
186
255
  try {
187
- const payload = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
188
- '/api/sales/tax-rates?pageSize=200',
189
- undefined,
190
- { errorMessage: t('catalog.products.create.taxRates.error', 'Failed to load tax rates.'), fallback: { items: [] } },
191
- )
192
- const items = Array.isArray(payload.items) ? payload.items : []
256
+ const payload = await readApiResultOrThrow<{
257
+ items?: Array<Record<string, unknown>>;
258
+ }>("/api/sales/tax-rates?pageSize=100", undefined, {
259
+ errorMessage: t(
260
+ "catalog.products.create.taxRates.error",
261
+ "Failed to load tax rates.",
262
+ ),
263
+ fallback: { items: [] },
264
+ });
265
+ const items = Array.isArray(payload.items) ? payload.items : [];
193
266
  setTaxRates(
194
267
  items.map((item) => {
195
- const rawRate = typeof item.rate === 'number' ? item.rate : Number(item.rate ?? Number.NaN)
268
+ const rawRate =
269
+ typeof item.rate === "number"
270
+ ? item.rate
271
+ : Number(item.rate ?? Number.NaN);
196
272
  return {
197
273
  id: String(item.id),
198
274
  name:
199
- typeof item.name === 'string' && item.name.trim().length
275
+ typeof item.name === "string" && item.name.trim().length
200
276
  ? item.name
201
- : t('catalog.products.create.taxRates.unnamed', 'Untitled tax rate'),
202
- code: typeof item.code === 'string' && item.code.trim().length ? item.code : null,
277
+ : t(
278
+ "catalog.products.create.taxRates.unnamed",
279
+ "Untitled tax rate",
280
+ ),
281
+ code:
282
+ typeof item.code === "string" && item.code.trim().length
283
+ ? item.code
284
+ : null,
203
285
  rate: Number.isFinite(rawRate) ? rawRate : null,
204
286
  isDefault: resolveBooleanFlag(
205
- typeof item.isDefault !== 'undefined' ? item.isDefault : item.is_default,
287
+ typeof item.isDefault !== "undefined"
288
+ ? item.isDefault
289
+ : item.is_default,
206
290
  ),
207
- }
291
+ };
208
292
  }),
209
- )
293
+ );
210
294
  } catch (err) {
211
- console.error('sales.tax-rates.fetch failed', err)
212
- setTaxRates([])
295
+ console.error("sales.tax-rates.fetch failed", err);
296
+ setTaxRates([]);
213
297
  }
214
- }
215
- loadTaxRates().catch(() => {})
216
- }, [t])
217
-
218
- const groups = React.useMemo<CrudFormGroup[]>(() => [
219
- {
220
- id: 'builder',
221
- column: 1,
222
- component: ({ values, setValue, errors }: CrudFormGroupComponentProps) => (
223
- <ProductBuilder
224
- values={values as ProductFormValues}
225
- setValue={setValue}
226
- errors={errors}
227
- priceKinds={priceKinds}
228
- taxRates={taxRates}
229
- />
230
- ),
231
- },
232
- {
233
- id: 'product-meta',
234
- column: 2,
235
- title: t('catalog.products.create.meta.title', 'Product meta'),
236
- description: t('catalog.products.create.meta.description', 'Manage subtitle and handle for storefronts.'),
237
- component: ({ values, setValue, errors }: CrudFormGroupComponentProps) => (
238
- <ProductMetaSection
239
- values={values as ProductFormValues}
240
- setValue={setValue}
241
- errors={errors}
242
- taxRates={taxRates}
243
- />
244
- ),
245
- },
246
- ], [priceKinds, taxRates, t])
298
+ };
299
+ loadTaxRates().catch(() => {});
300
+ }, [t]);
301
+
302
+ const groups = React.useMemo<CrudFormGroup[]>(
303
+ () => [
304
+ {
305
+ id: "builder",
306
+ column: 1,
307
+ component: ({
308
+ values,
309
+ setValue,
310
+ errors,
311
+ }: CrudFormGroupComponentProps) => (
312
+ <ProductBuilder
313
+ values={values as ProductFormValues}
314
+ setValue={setValue}
315
+ errors={errors}
316
+ priceKinds={priceKinds}
317
+ taxRates={taxRates}
318
+ />
319
+ ),
320
+ },
321
+ {
322
+ id: "product-meta",
323
+ column: 2,
324
+ title: t("catalog.products.create.meta.title", "Product meta"),
325
+ description: t(
326
+ "catalog.products.create.meta.description",
327
+ "Manage subtitle and handle for storefronts.",
328
+ ),
329
+ component: ({
330
+ values,
331
+ setValue,
332
+ errors,
333
+ }: CrudFormGroupComponentProps) => (
334
+ <ProductMetaSection
335
+ values={values as ProductFormValues}
336
+ setValue={setValue}
337
+ errors={errors}
338
+ taxRates={taxRates}
339
+ />
340
+ ),
341
+ },
342
+ ],
343
+ [priceKinds, taxRates, t],
344
+ );
247
345
 
248
346
  return (
249
347
  <Page>
250
348
  <PageBody>
251
349
  <CrudForm<ProductFormValues>
252
- title={t('catalog.products.create.title', 'Create product')}
350
+ title={t("catalog.products.create.title", "Create product")}
253
351
  backHref="/backend/catalog/products"
254
352
  fields={[]}
255
353
  groups={groups}
256
354
  injectionSpotId="crud-form:catalog.product"
257
- initialValues={initialValuesRef.current ?? createInitialProductFormValues()}
355
+ initialValues={
356
+ initialValuesRef.current ?? createInitialProductFormValues()
357
+ }
258
358
  schema={productFormTypedSchema}
259
- submitLabel={t('catalog.products.create.submit', 'Create')}
359
+ submitLabel={t("catalog.products.create.submit", "Create")}
260
360
  cancelHref="/backend/catalog/products"
261
361
  onSubmit={async (formValues) => {
262
- const title = formValues.title?.trim()
362
+ const title = formValues.title?.trim();
263
363
  if (!title) {
264
- throw createCrudFormError(t('catalog.products.create.errors.title', 'Provide a product title.'), {
265
- title: t('catalog.products.create.errors.title', 'Provide a product title.'),
266
- })
364
+ throw createCrudFormError(
365
+ t(
366
+ "catalog.products.create.errors.title",
367
+ "Provide a product title.",
368
+ ),
369
+ {
370
+ title: t(
371
+ "catalog.products.create.errors.title",
372
+ "Provide a product title.",
373
+ ),
374
+ },
375
+ );
267
376
  }
268
- const handle = formValues.handle?.trim() || undefined
269
- const description = formValues.description?.trim() || undefined
377
+ const handle = formValues.handle?.trim() || undefined;
378
+ const description = formValues.description?.trim() || undefined;
270
379
  const defaultMediaId =
271
- typeof formValues.defaultMediaId === 'string' && formValues.defaultMediaId.trim().length
380
+ typeof formValues.defaultMediaId === "string" &&
381
+ formValues.defaultMediaId.trim().length
272
382
  ? formValues.defaultMediaId
273
- : null
274
- const mediaItems = Array.isArray(formValues.mediaItems) ? formValues.mediaItems : []
383
+ : null;
384
+ const mediaItems = Array.isArray(formValues.mediaItems)
385
+ ? formValues.mediaItems
386
+ : [];
275
387
  const attachmentIds = mediaItems
276
- .map((item) => (typeof item.id === 'string' ? item.id : null))
277
- .filter((value): value is string => !!value)
278
- const mediaDraftId = typeof formValues.mediaDraftId === 'string' ? formValues.mediaDraftId : ''
279
- const defaultMediaEntry = defaultMediaId ? mediaItems.find((item) => item.id === defaultMediaId) : null
388
+ .map((item) => (typeof item.id === "string" ? item.id : null))
389
+ .filter((value): value is string => !!value);
390
+ const mediaDraftId =
391
+ typeof formValues.mediaDraftId === "string"
392
+ ? formValues.mediaDraftId
393
+ : "";
394
+ const defaultMediaEntry = defaultMediaId
395
+ ? mediaItems.find((item) => item.id === defaultMediaId)
396
+ : null;
280
397
  const defaultMediaUrl = defaultMediaEntry
281
398
  ? buildAttachmentImageUrl(defaultMediaEntry.id, {
282
399
  slug: slugifyAttachmentFileName(defaultMediaEntry.fileName),
283
400
  })
284
- : null
285
- const optionSchemaDefinition = buildOptionSchemaDefinition(formValues.options, title)
286
- const dimensions = sanitizeProductDimensions(formValues.dimensions ?? null)
287
- const weight = sanitizeProductWeight(formValues.weight ?? null)
401
+ : null;
402
+ const optionSchemaDefinition = buildOptionSchemaDefinition(
403
+ formValues.options,
404
+ title,
405
+ );
406
+ const dimensions = sanitizeProductDimensions(
407
+ formValues.dimensions ?? null,
408
+ );
409
+ const weight = sanitizeProductWeight(formValues.weight ?? null);
288
410
  const resolveTaxRateValue = (taxRateId?: string | null) => {
289
- if (!taxRateId) return null
290
- const match = taxRates.find((rate) => rate.id === taxRateId)
291
- return typeof match?.rate === 'number' ? match.rate : null
292
- }
293
- const productLevelTaxRateId = formValues.taxRateId ?? null
294
- const productTaxRate = resolveTaxRateValue(productLevelTaxRateId)
411
+ if (!taxRateId) return null;
412
+ const match = taxRates.find((rate) => rate.id === taxRateId);
413
+ return typeof match?.rate === "number" ? match.rate : null;
414
+ };
415
+ const productLevelTaxRateId = formValues.taxRateId ?? null;
416
+ const productTaxRate = resolveTaxRateValue(productLevelTaxRateId);
295
417
  const resolveVariantTax = (variant: VariantDraft) => {
296
- const resolvedVariantTaxRateId = variant.taxRateId ?? productLevelTaxRateId
418
+ const resolvedVariantTaxRateId =
419
+ variant.taxRateId ?? productLevelTaxRateId;
297
420
  const resolvedVariantTaxRate =
298
421
  resolveTaxRateValue(resolvedVariantTaxRateId) ??
299
- (resolvedVariantTaxRateId ? null : productTaxRate ?? null)
300
- return { resolvedVariantTaxRateId, resolvedVariantTaxRate }
422
+ (resolvedVariantTaxRateId ? null : (productTaxRate ?? null));
423
+ return { resolvedVariantTaxRateId, resolvedVariantTaxRate };
424
+ };
425
+ const defaultUnit = canonicalizeUnitCode(formValues.defaultUnit);
426
+ const defaultSalesUnit = canonicalizeUnitCode(
427
+ formValues.defaultSalesUnit,
428
+ );
429
+ const defaultSalesUnitQuantity =
430
+ toPositiveNumberOrNull(formValues.defaultSalesUnitQuantity) ?? 1;
431
+ const uomRoundingScale = toIntegerInRangeOrDefault(
432
+ formValues.uomRoundingScale,
433
+ 0,
434
+ 6,
435
+ 4,
436
+ );
437
+ const uomRoundingMode: ProductUnitRoundingMode =
438
+ formValues.uomRoundingMode === "down" ||
439
+ formValues.uomRoundingMode === "up"
440
+ ? formValues.uomRoundingMode
441
+ : "half_up";
442
+ const unitPriceEnabled = Boolean(formValues.unitPriceEnabled);
443
+ const unitPriceReferenceUnit = canonicalizeUnitCode(
444
+ formValues.unitPriceReferenceUnit,
445
+ );
446
+ const unitPriceBaseQuantity = toPositiveNumberOrNull(
447
+ formValues.unitPriceBaseQuantity,
448
+ );
449
+ if (defaultSalesUnit && !defaultUnit) {
450
+ const message = t(
451
+ "catalog.products.uom.errors.baseRequired",
452
+ "Base unit is required when default sales unit is set.",
453
+ );
454
+ throw createCrudFormError(message, { defaultSalesUnit: message });
455
+ }
456
+ const conversionInputs = normalizeProductConversionInputs(
457
+ formValues.unitConversions,
458
+ t(
459
+ "catalog.products.uom.errors.duplicateConversion",
460
+ "Duplicate conversion unit is not allowed.",
461
+ ),
462
+ );
463
+ if (conversionInputs.length && !defaultUnit) {
464
+ const message = t(
465
+ "catalog.products.uom.errors.baseRequiredForConversions",
466
+ "Base unit is required when conversions are configured.",
467
+ );
468
+ throw createCrudFormError(message, { defaultUnit: message });
469
+ }
470
+ const defaultUnitKey = defaultUnit?.toLowerCase() ?? null;
471
+ const defaultSalesUnitKey = defaultSalesUnit?.toLowerCase() ?? null;
472
+ if (
473
+ defaultUnitKey &&
474
+ defaultSalesUnitKey &&
475
+ defaultSalesUnitKey !== defaultUnitKey
476
+ ) {
477
+ const hasDefaultSalesConversion = conversionInputs.some(
478
+ (entry) =>
479
+ entry.isActive &&
480
+ entry.unitCode.toLowerCase() === defaultSalesUnitKey,
481
+ );
482
+ if (!hasDefaultSalesConversion) {
483
+ const message = t(
484
+ "catalog.products.uom.errors.defaultSalesConversionRequired",
485
+ "Active conversion for default sales unit is required when it differs from base unit.",
486
+ );
487
+ throw createCrudFormError(message, {
488
+ defaultSalesUnit: message,
489
+ unitConversions: message,
490
+ });
491
+ }
492
+ }
493
+ if (unitPriceEnabled) {
494
+ if (
495
+ !unitPriceReferenceUnit ||
496
+ !UNIT_PRICE_REFERENCE_UNITS.has(
497
+ unitPriceReferenceUnit as ProductUnitPriceReferenceUnit,
498
+ )
499
+ ) {
500
+ const message = t(
501
+ "catalog.products.unitPrice.errors.referenceUnit",
502
+ "Reference unit is required when unit price display is enabled.",
503
+ );
504
+ throw createCrudFormError(message, {
505
+ unitPriceReferenceUnit: message,
506
+ });
507
+ }
508
+ if (unitPriceBaseQuantity === null) {
509
+ const message = t(
510
+ "catalog.products.unitPrice.errors.baseQuantity",
511
+ "Base quantity is required when unit price display is enabled.",
512
+ );
513
+ throw createCrudFormError(message, {
514
+ unitPriceBaseQuantity: message,
515
+ });
516
+ }
301
517
  }
302
518
  const productPayload: Record<string, unknown> = {
303
519
  title,
@@ -312,35 +528,47 @@ export default function CreateCatalogProductPage() {
312
528
  dimensions,
313
529
  weightValue: weight?.value ?? null,
314
530
  weightUnit: weight?.unit ?? null,
315
- }
531
+ defaultUnit: defaultUnit ?? null,
532
+ defaultSalesUnit: defaultSalesUnit ?? defaultUnit ?? null,
533
+ defaultSalesUnitQuantity,
534
+ uomRoundingScale,
535
+ uomRoundingMode,
536
+ unitPriceEnabled,
537
+ unitPriceReferenceUnit: unitPriceEnabled
538
+ ? unitPriceReferenceUnit
539
+ : undefined,
540
+ unitPriceBaseQuantity: unitPriceEnabled
541
+ ? unitPriceBaseQuantity
542
+ : undefined,
543
+ };
316
544
  if (optionSchemaDefinition) {
317
- productPayload.optionSchema = optionSchemaDefinition
545
+ productPayload.optionSchema = optionSchemaDefinition;
318
546
  }
319
547
  const categoryIds = Array.isArray(formValues.categoryIds)
320
548
  ? formValues.categoryIds
321
- .map((id) => (typeof id === 'string' ? id.trim() : ''))
549
+ .map((id) => (typeof id === "string" ? id.trim() : ""))
322
550
  .filter((id) => id.length)
323
- : []
551
+ : [];
324
552
  if (categoryIds.length) {
325
- productPayload.categoryIds = Array.from(new Set(categoryIds))
553
+ productPayload.categoryIds = Array.from(new Set(categoryIds));
326
554
  }
327
555
  const tags = Array.isArray(formValues.tags)
328
556
  ? Array.from(
329
557
  new Set(
330
558
  formValues.tags
331
- .map((tag) => (typeof tag === 'string' ? tag.trim() : ''))
559
+ .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
332
560
  .filter((tag) => tag.length),
333
561
  ),
334
562
  )
335
- : []
563
+ : [];
336
564
  if (tags.length) {
337
- productPayload.tags = tags
565
+ productPayload.tags = tags;
338
566
  }
339
567
  const channelIds = Array.isArray(formValues.channelIds)
340
568
  ? formValues.channelIds
341
- .map((id) => (typeof id === 'string' ? id.trim() : ''))
569
+ .map((id) => (typeof id === "string" ? id.trim() : ""))
342
570
  .filter((id) => id.length)
343
- : []
571
+ : [];
344
572
  if (channelIds.length) {
345
573
  productPayload.offers = channelIds.map((channelId) => ({
346
574
  channelId,
@@ -348,34 +576,50 @@ export default function CreateCatalogProductPage() {
348
576
  description,
349
577
  defaultMediaId: defaultMediaId ?? undefined,
350
578
  defaultMediaUrl: defaultMediaUrl ?? undefined,
351
- }))
579
+ }));
352
580
  }
353
581
 
354
582
  const variantDrafts =
355
583
  (Array.isArray(formValues.variants) && formValues.variants.length
356
584
  ? formValues.variants
357
- : [createVariantDraft(formValues.taxRateId ?? null, { isDefault: true })]) ?? []
358
- const priceRequests: VariantPriceRequest[] = []
585
+ : [
586
+ createVariantDraft(formValues.taxRateId ?? null, {
587
+ isDefault: true,
588
+ }),
589
+ ]) ?? [];
590
+ const priceRequests: VariantPriceRequest[] = [];
359
591
  for (const variant of variantDrafts) {
360
- const { resolvedVariantTaxRateId, resolvedVariantTaxRate } = resolveVariantTax(variant)
592
+ const { resolvedVariantTaxRateId, resolvedVariantTaxRate } =
593
+ resolveVariantTax(variant);
361
594
  for (const priceKind of priceKinds) {
362
- const value = variant.prices?.[priceKind.id]?.amount?.trim()
363
- if (!value) continue
364
- const numeric = Number(value)
365
- if (Number.isNaN(numeric) || !Number.isFinite(numeric) || numeric < 0) {
595
+ const value = variant.prices?.[priceKind.id]?.amount?.trim();
596
+ if (!value) continue;
597
+ const numeric = Number(value);
598
+ if (
599
+ Number.isNaN(numeric) ||
600
+ !Number.isFinite(numeric) ||
601
+ numeric < 0
602
+ ) {
366
603
  throw createCrudFormError(
367
- t('catalog.products.create.errors.priceNonNegative', 'Prices must be zero or greater.'),
368
- )
604
+ t(
605
+ "catalog.products.create.errors.priceNonNegative",
606
+ "Prices must be zero or greater.",
607
+ ),
608
+ );
369
609
  }
370
610
  const currencyCode =
371
- typeof priceKind.currencyCode === 'string' && priceKind.currencyCode.trim().length
611
+ typeof priceKind.currencyCode === "string" &&
612
+ priceKind.currencyCode.trim().length
372
613
  ? priceKind.currencyCode.trim().toUpperCase()
373
- : ''
614
+ : "";
374
615
  if (!currencyCode) {
375
616
  throw createCrudFormError(
376
- t('catalog.products.create.errors.currency', 'Provide a currency for all price kinds.'),
617
+ t(
618
+ "catalog.products.create.errors.currency",
619
+ "Provide a currency for all price kinds.",
620
+ ),
377
621
  {},
378
- )
622
+ );
379
623
  }
380
624
  priceRequests.push({
381
625
  variantDraftId: variant.id,
@@ -385,72 +629,110 @@ export default function CreateCatalogProductPage() {
385
629
  displayMode: priceKind.displayMode,
386
630
  taxRateId: resolvedVariantTaxRateId ?? null,
387
631
  taxRateValue: resolvedVariantTaxRate ?? null,
388
- })
632
+ });
389
633
  }
390
634
  }
391
635
 
392
- const cleanupState: { productId: string | null; variantIds: string[] } = { productId: null, variantIds: [] }
636
+ const cleanupState: {
637
+ productId: string | null;
638
+ variantIds: string[];
639
+ } = { productId: null, variantIds: [] };
393
640
  try {
394
- const { result: created } = await createCrud<{ id?: string }>('catalog/products', productPayload)
395
- const productId = created?.id
641
+ const { result: created } = await createCrud<{ id?: string }>(
642
+ "catalog/products",
643
+ productPayload,
644
+ );
645
+ const productId = created?.id;
396
646
  if (!productId) {
397
- throw createCrudFormError(t('catalog.products.create.errors.id', 'Product id missing after create.'))
647
+ throw createCrudFormError(
648
+ t(
649
+ "catalog.products.create.errors.id",
650
+ "Product id missing after create.",
651
+ ),
652
+ );
653
+ }
654
+ cleanupState.productId = productId;
655
+
656
+ for (const conversion of conversionInputs) {
657
+ await createCrud("catalog/product-unit-conversions", {
658
+ productId,
659
+ unitCode: conversion.unitCode,
660
+ toBaseFactor: conversion.toBaseFactor,
661
+ sortOrder: conversion.sortOrder,
662
+ isActive: conversion.isActive,
663
+ });
398
664
  }
399
- cleanupState.productId = productId
400
665
 
401
- const variantIdMap: Record<string, string> = {}
666
+ const variantIdMap: Record<string, string> = {};
402
667
  for (const variant of variantDrafts) {
403
- const { resolvedVariantTaxRateId, resolvedVariantTaxRate } = resolveVariantTax(variant)
668
+ const { resolvedVariantTaxRateId, resolvedVariantTaxRate } =
669
+ resolveVariantTax(variant);
404
670
  const variantPayload: Record<string, unknown> = {
405
671
  productId,
406
- name: variant.title?.trim() || Object.values(variant.optionValues).join(' / ') || 'Variant',
672
+ name:
673
+ variant.title?.trim() ||
674
+ Object.values(variant.optionValues).join(" / ") ||
675
+ "Variant",
407
676
  sku: variant.sku?.trim() || undefined,
408
677
  isDefault: Boolean(variant.isDefault),
409
678
  isActive: true,
410
- optionValues: Object.keys(variant.optionValues).length ? variant.optionValues : undefined,
679
+ optionValues: Object.keys(variant.optionValues).length
680
+ ? variant.optionValues
681
+ : undefined,
411
682
  taxRateId: resolvedVariantTaxRateId ?? null,
412
683
  taxRate: resolvedVariantTaxRate ?? null,
413
- }
414
- const { result: variantResult } = await createCrud<{ id?: string; variantId?: string }>(
415
- 'catalog/variants',
416
- variantPayload,
417
- )
418
- const variantId = variantResult?.variantId ?? variantResult?.id
684
+ };
685
+ const { result: variantResult } = await createCrud<{
686
+ id?: string;
687
+ variantId?: string;
688
+ }>("catalog/variants", variantPayload);
689
+ const variantId = variantResult?.variantId ?? variantResult?.id;
419
690
  if (!variantId) {
420
- throw createCrudFormError(t('catalog.products.create.errors.variant', 'Failed to create variant.'))
691
+ throw createCrudFormError(
692
+ t(
693
+ "catalog.products.create.errors.variant",
694
+ "Failed to create variant.",
695
+ ),
696
+ );
421
697
  }
422
- variantIdMap[variant.id] = variantId
423
- cleanupState.variantIds.push(variantId)
698
+ variantIdMap[variant.id] = variantId;
699
+ cleanupState.variantIds.push(variantId);
424
700
  }
425
701
 
426
702
  for (const draft of priceRequests) {
427
- const variantId = variantIdMap[draft.variantDraftId]
428
- if (!variantId) continue
703
+ const variantId = variantIdMap[draft.variantDraftId];
704
+ if (!variantId) continue;
429
705
  const pricePayload: Record<string, unknown> = {
430
706
  productId,
431
707
  variantId,
432
708
  currencyCode: draft.currencyCode,
433
709
  priceKindId: draft.priceKindId,
434
- }
710
+ };
435
711
  if (draft.taxRateId) {
436
- pricePayload.taxRateId = draft.taxRateId
437
- } else if (typeof draft.taxRateValue === 'number' && Number.isFinite(draft.taxRateValue)) {
438
- pricePayload.taxRate = draft.taxRateValue
712
+ pricePayload.taxRateId = draft.taxRateId;
713
+ } else if (
714
+ typeof draft.taxRateValue === "number" &&
715
+ Number.isFinite(draft.taxRateValue)
716
+ ) {
717
+ pricePayload.taxRate = draft.taxRateValue;
439
718
  }
440
- if (draft.displayMode === 'including-tax') {
441
- pricePayload.unitPriceGross = draft.amount
719
+ if (draft.displayMode === "including-tax") {
720
+ pricePayload.unitPriceGross = draft.amount;
442
721
  } else {
443
- pricePayload.unitPriceNet = draft.amount
722
+ pricePayload.unitPriceNet = draft.amount;
444
723
  }
445
- await createCrud('catalog/prices', pricePayload)
724
+ await createCrud("catalog/prices", pricePayload);
446
725
  }
447
726
 
448
727
  if (mediaDraftId && attachmentIds.length) {
449
- const transfer = await apiCall<{ ok?: boolean; error?: string }>(
450
- '/api/attachments/transfer',
728
+ const transfer = await apiCall<{
729
+ ok?: boolean;
730
+ error?: string;
731
+ }>(
732
+ "/api/attachments/transfer",
451
733
  {
452
- method: 'POST',
453
- headers: { 'content-type': 'application/json' },
734
+ method: "POST",
735
+ headers: { "content-type": "application/json" },
454
736
  body: JSON.stringify({
455
737
  entityId: E.catalog.catalog_product,
456
738
  attachmentIds,
@@ -459,382 +741,549 @@ export default function CreateCatalogProductPage() {
459
741
  }),
460
742
  },
461
743
  { fallback: null },
462
- )
744
+ );
463
745
  if (!transfer.ok) {
464
- console.error('attachments.transfer.failed', transfer.result?.error)
746
+ console.error(
747
+ "attachments.transfer.failed",
748
+ transfer.result?.error,
749
+ );
465
750
  }
466
751
  }
467
752
 
468
753
  if (inboxDraft) {
469
754
  try {
470
- sessionStorage.removeItem('inbox_ops.productDraft')
755
+ sessionStorage.removeItem("inbox_ops.productDraft");
471
756
  } catch { /* ignore */ }
472
757
  try {
473
758
  await apiCall(
474
759
  `/api/inbox_ops/proposals/${inboxDraft.proposalId}/actions/${inboxDraft.actionId}/complete`,
475
760
  {
476
- method: 'PATCH',
761
+ method: "PATCH",
477
762
  body: JSON.stringify({
478
763
  createdEntityId: productId,
479
- createdEntityType: 'catalog_product',
764
+ createdEntityType: "catalog_product",
480
765
  }),
481
766
  },
482
- )
767
+ );
483
768
  } catch {
484
- flash(t('inbox_ops.flash.complete_failed', 'Product created but failed to update inbox action status.'), 'warning')
769
+ flash(
770
+ t(
771
+ "inbox_ops.flash.complete_failed",
772
+ "Product created but failed to update inbox action status.",
773
+ ),
774
+ "warning",
775
+ );
485
776
  }
486
777
  }
487
778
 
488
- flash(t('catalog.products.create.success', 'Product created.'), 'success')
779
+ flash(
780
+ t("catalog.products.create.success", "Product created."),
781
+ "success",
782
+ );
489
783
  if (inboxDraft) {
490
- router.push(`/backend/inbox-ops/proposals/${encodeURIComponent(inboxDraft.proposalId)}`)
784
+ router.push(
785
+ `/backend/inbox-ops/proposals/${encodeURIComponent(inboxDraft.proposalId)}`,
786
+ );
491
787
  } else {
492
- router.push('/backend/catalog/products')
788
+ router.push("/backend/catalog/products");
493
789
  }
494
790
  } catch (err) {
495
- await cleanupFailedProduct(cleanupState.productId, cleanupState.variantIds)
496
- throw err
791
+ await cleanupFailedProduct(
792
+ cleanupState.productId,
793
+ cleanupState.variantIds,
794
+ );
795
+ throw err;
497
796
  }
498
797
  }}
499
798
  />
500
799
  </PageBody>
501
800
  </Page>
502
- )
801
+ );
503
802
  }
504
803
 
505
- async function cleanupFailedProduct(productId: string | null, variantIds: string[]): Promise<void> {
506
- if (!productId && variantIds.length === 0) return
804
+ async function cleanupFailedProduct(
805
+ productId: string | null,
806
+ variantIds: string[],
807
+ ): Promise<void> {
808
+ if (!productId && variantIds.length === 0) return;
507
809
  if (variantIds.length) {
508
810
  const variantDeletes = variantIds.map((variantId) =>
509
- apiCall(`/api/catalog/variants?id=${encodeURIComponent(variantId)}`, { method: 'DELETE' }).catch(() => null),
510
- )
511
- await Promise.allSettled(variantDeletes)
811
+ apiCall(`/api/catalog/variants?id=${encodeURIComponent(variantId)}`, {
812
+ method: "DELETE",
813
+ }).catch(() => null),
814
+ );
815
+ await Promise.allSettled(variantDeletes);
512
816
  }
513
817
  if (productId) {
514
- await apiCall(`/api/catalog/products?id=${encodeURIComponent(productId)}`, { method: 'DELETE' }).catch(() => null)
818
+ await apiCall(`/api/catalog/products?id=${encodeURIComponent(productId)}`, {
819
+ method: "DELETE",
820
+ }).catch(() => null);
515
821
  }
516
822
  }
517
823
 
518
824
  type ProductBuilderProps = {
519
- values: ProductFormValues
520
- setValue: (id: string, value: unknown) => void
521
- errors: Record<string, string>
522
- priceKinds: PriceKindSummary[]
523
- taxRates: TaxRateSummary[]
524
- }
825
+ values: ProductFormValues;
826
+ setValue: (id: string, value: unknown) => void;
827
+ errors: Record<string, string>;
828
+ priceKinds: PriceKindSummary[];
829
+ taxRates: TaxRateSummary[];
830
+ };
525
831
 
526
832
  type ProductMetaSectionProps = {
527
- values: ProductFormValues
528
- setValue: (id: string, value: unknown) => void
529
- errors: Record<string, string>
530
- taxRates: TaxRateSummary[]
531
- }
833
+ values: ProductFormValues;
834
+ setValue: (id: string, value: unknown) => void;
835
+ errors: Record<string, string>;
836
+ taxRates: TaxRateSummary[];
837
+ };
532
838
 
533
839
  type ProductDimensionsSectionProps = {
534
- values: ProductFormValues
535
- setValue: (id: string, value: unknown) => void
536
- }
537
-
538
- function ProductDimensionsFields({ values, setValue }: ProductDimensionsSectionProps) {
539
- const t = useT()
540
- const dimensionValues = normalizeProductDimensions(values.dimensions)
541
- const weightValues = normalizeProductWeight(values.weight)
840
+ values: ProductFormValues;
841
+ setValue: (id: string, value: unknown) => void;
842
+ };
843
+
844
+ function ProductDimensionsFields({
845
+ values,
846
+ setValue,
847
+ }: ProductDimensionsSectionProps) {
848
+ const t = useT();
849
+ const dimensionValues = normalizeProductDimensions(values.dimensions);
850
+ const weightValues = normalizeProductWeight(values.weight);
542
851
 
543
852
  return (
544
853
  <div className="space-y-4 rounded-lg border p-4">
545
- <h3 className="text-sm font-semibold">{t('catalog.products.edit.dimensions', 'Dimensions & weight')}</h3>
854
+ <h3 className="text-sm font-semibold">
855
+ {t("catalog.products.edit.dimensions", "Dimensions & weight")}
856
+ </h3>
546
857
  <div className="grid gap-4 md:grid-cols-2">
547
858
  <div className="space-y-2">
548
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.width', 'Width')}</Label>
859
+ <Label className="text-xs uppercase text-muted-foreground">
860
+ {t("catalog.products.edit.dimensions.width", "Width")}
861
+ </Label>
549
862
  <Input
550
863
  type="number"
551
- value={dimensionValues?.width ?? ''}
552
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'width', event.target.value))}
864
+ value={dimensionValues?.width ?? ""}
865
+ onChange={(event) =>
866
+ setValue(
867
+ "dimensions",
868
+ updateDimensionValue(
869
+ values.dimensions ?? null,
870
+ "width",
871
+ event.target.value,
872
+ ),
873
+ )
874
+ }
553
875
  placeholder="0"
554
876
  />
555
877
  </div>
556
878
  <div className="space-y-2">
557
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.height', 'Height')}</Label>
879
+ <Label className="text-xs uppercase text-muted-foreground">
880
+ {t("catalog.products.edit.dimensions.height", "Height")}
881
+ </Label>
558
882
  <Input
559
883
  type="number"
560
- value={dimensionValues?.height ?? ''}
561
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'height', event.target.value))}
884
+ value={dimensionValues?.height ?? ""}
885
+ onChange={(event) =>
886
+ setValue(
887
+ "dimensions",
888
+ updateDimensionValue(
889
+ values.dimensions ?? null,
890
+ "height",
891
+ event.target.value,
892
+ ),
893
+ )
894
+ }
562
895
  placeholder="0"
563
896
  />
564
897
  </div>
565
898
  <div className="space-y-2">
566
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.depth', 'Depth')}</Label>
899
+ <Label className="text-xs uppercase text-muted-foreground">
900
+ {t("catalog.products.edit.dimensions.depth", "Depth")}
901
+ </Label>
567
902
  <Input
568
903
  type="number"
569
- value={dimensionValues?.depth ?? ''}
570
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'depth', event.target.value))}
904
+ value={dimensionValues?.depth ?? ""}
905
+ onChange={(event) =>
906
+ setValue(
907
+ "dimensions",
908
+ updateDimensionValue(
909
+ values.dimensions ?? null,
910
+ "depth",
911
+ event.target.value,
912
+ ),
913
+ )
914
+ }
571
915
  placeholder="0"
572
916
  />
573
917
  </div>
574
918
  <div className="space-y-2">
575
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.dimensions.unit', 'Size unit')}</Label>
919
+ <Label className="text-xs uppercase text-muted-foreground">
920
+ {t("catalog.products.edit.dimensions.unit", "Size unit")}
921
+ </Label>
576
922
  <Input
577
- value={dimensionValues?.unit ?? ''}
578
- onChange={(event) => setValue('dimensions', updateDimensionValue(values.dimensions ?? null, 'unit', event.target.value))}
923
+ value={dimensionValues?.unit ?? ""}
924
+ onChange={(event) =>
925
+ setValue(
926
+ "dimensions",
927
+ updateDimensionValue(
928
+ values.dimensions ?? null,
929
+ "unit",
930
+ event.target.value,
931
+ ),
932
+ )
933
+ }
579
934
  placeholder="cm"
580
935
  />
581
936
  </div>
582
937
  <div className="space-y-2">
583
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.weight.value', 'Weight')}</Label>
938
+ <Label className="text-xs uppercase text-muted-foreground">
939
+ {t("catalog.products.edit.weight.value", "Weight")}
940
+ </Label>
584
941
  <Input
585
942
  type="number"
586
- value={weightValues?.value ?? ''}
587
- onChange={(event) => setValue('weight', updateWeightValue(values.weight ?? null, 'value', event.target.value))}
943
+ value={weightValues?.value ?? ""}
944
+ onChange={(event) =>
945
+ setValue(
946
+ "weight",
947
+ updateWeightValue(
948
+ values.weight ?? null,
949
+ "value",
950
+ event.target.value,
951
+ ),
952
+ )
953
+ }
588
954
  placeholder="0"
589
955
  />
590
956
  </div>
591
957
  <div className="space-y-2">
592
- <Label className="text-xs uppercase text-muted-foreground">{t('catalog.products.edit.weight.unit', 'Weight unit')}</Label>
958
+ <Label className="text-xs uppercase text-muted-foreground">
959
+ {t("catalog.products.edit.weight.unit", "Weight unit")}
960
+ </Label>
593
961
  <Input
594
- value={weightValues?.unit ?? ''}
595
- onChange={(event) => setValue('weight', updateWeightValue(values.weight ?? null, 'unit', event.target.value))}
962
+ value={weightValues?.unit ?? ""}
963
+ onChange={(event) =>
964
+ setValue(
965
+ "weight",
966
+ updateWeightValue(
967
+ values.weight ?? null,
968
+ "unit",
969
+ event.target.value,
970
+ ),
971
+ )
972
+ }
596
973
  placeholder="kg"
597
974
  />
598
975
  </div>
599
976
  </div>
600
977
  </div>
601
- )
978
+ );
602
979
  }
603
980
 
604
- function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: ProductBuilderProps) {
605
- const t = useT()
606
- const steps = PRODUCT_FORM_STEPS
607
- const [currentStep, setCurrentStep] = React.useState(0)
981
+ function ProductBuilder({
982
+ values,
983
+ setValue,
984
+ errors,
985
+ priceKinds,
986
+ taxRates,
987
+ }: ProductBuilderProps) {
988
+ const t = useT();
989
+ const steps = PRODUCT_FORM_STEPS;
990
+ const [currentStep, setCurrentStep] = React.useState(0);
608
991
  const defaultTaxRate = React.useMemo(
609
- () => (values.taxRateId ? taxRates.find((rate) => rate.id === values.taxRateId) ?? null : null),
992
+ () =>
993
+ values.taxRateId
994
+ ? (taxRates.find((rate) => rate.id === values.taxRateId) ?? null)
995
+ : null,
610
996
  [taxRates, values.taxRateId],
611
- )
997
+ );
612
998
  React.useEffect(() => {
613
- if (values.taxRateId) return
614
- if (!taxRates.length) return
615
- const fallback = taxRates.find((rate) => rate.isDefault)
616
- if (!fallback) return
617
- setValue('taxRateId', fallback.id)
618
- }, [taxRates, setValue, values.taxRateId])
999
+ if (values.taxRateId) return;
1000
+ if (!taxRates.length) return;
1001
+ const fallback = taxRates.find((rate) => rate.isDefault);
1002
+ if (!fallback) return;
1003
+ setValue("taxRateId", fallback.id);
1004
+ }, [taxRates, setValue, values.taxRateId]);
619
1005
  const stepErrors = React.useMemo(() => {
620
- const map = steps.reduce<Record<ProductFormStep, string[]>>((acc, step) => {
621
- acc[step] = []
622
- return acc
623
- }, {} as Record<ProductFormStep, string[]>)
1006
+ const map = steps.reduce<Record<ProductFormStep, string[]>>(
1007
+ (acc, step) => {
1008
+ acc[step] = [];
1009
+ return acc;
1010
+ },
1011
+ {} as Record<ProductFormStep, string[]>,
1012
+ );
624
1013
  Object.entries(errors).forEach(([fieldId, message]) => {
625
- const step = resolveStepForField(fieldId)
626
- if (!step) return
627
- const text = typeof message === 'string' && message.trim().length ? message.trim() : null
628
- if (text) map[step] = [...map[step], text]
629
- })
630
- return map
631
- }, [errors, steps])
632
- const errorSignature = React.useMemo(() => Object.keys(errors).sort().join('|'), [errors])
633
- const lastErrorSignatureRef = React.useRef<string | null>(null)
1014
+ const step = resolveStepForField(fieldId);
1015
+ if (!step) return;
1016
+ const text =
1017
+ typeof message === "string" && message.trim().length
1018
+ ? message.trim()
1019
+ : null;
1020
+ if (text) map[step] = [...map[step], text];
1021
+ });
1022
+ return map;
1023
+ }, [errors, steps]);
1024
+ const errorSignature = React.useMemo(
1025
+ () => Object.keys(errors).sort().join("|"),
1026
+ [errors],
1027
+ );
1028
+ const lastErrorSignatureRef = React.useRef<string | null>(null);
634
1029
  React.useEffect(() => {
635
- if (!errorSignature || errorSignature === lastErrorSignatureRef.current) return
636
- lastErrorSignatureRef.current = errorSignature
637
- const currentStepKey = steps[currentStep]
638
- if (currentStepKey && stepErrors[currentStepKey]?.length) return
639
- const fallbackIndex = steps.findIndex((step) => (stepErrors[step] ?? []).length > 0)
1030
+ if (!errorSignature || errorSignature === lastErrorSignatureRef.current)
1031
+ return;
1032
+ lastErrorSignatureRef.current = errorSignature;
1033
+ const currentStepKey = steps[currentStep];
1034
+ if (currentStepKey && stepErrors[currentStepKey]?.length) return;
1035
+ const fallbackIndex = steps.findIndex(
1036
+ (step) => (stepErrors[step] ?? []).length > 0,
1037
+ );
640
1038
  if (fallbackIndex >= 0 && fallbackIndex !== currentStep) {
641
- setCurrentStep(fallbackIndex)
1039
+ setCurrentStep(fallbackIndex);
642
1040
  }
643
- }, [currentStep, errorSignature, setCurrentStep, stepErrors, steps])
644
- const defaultTaxRateLabel = defaultTaxRate ? formatTaxRateLabel(defaultTaxRate) : null
1041
+ }, [currentStep, errorSignature, setCurrentStep, stepErrors, steps]);
1042
+ const defaultTaxRateLabel = defaultTaxRate
1043
+ ? formatTaxRateLabel(defaultTaxRate)
1044
+ : null;
645
1045
  const inventoryDisabledHint = t(
646
- 'catalog.products.create.variantsBuilder.inventoryDisabled',
647
- 'Inventory tracking controls are not available yet.',
648
- )
1046
+ "catalog.products.create.variantsBuilder.inventoryDisabled",
1047
+ "Inventory tracking controls are not available yet.",
1048
+ );
649
1049
 
650
1050
  React.useEffect(() => {
651
- if (currentStep >= steps.length) setCurrentStep(0)
652
- }, [currentStep, steps.length])
1051
+ if (currentStep >= steps.length) setCurrentStep(0);
1052
+ }, [currentStep, steps.length]);
653
1053
 
654
- const currentStepKey = steps[currentStep] ?? steps[0]
655
-
656
- const mediaItems = Array.isArray(values.mediaItems) ? values.mediaItems : []
1054
+ const currentStepKey = steps[currentStep] ?? steps[0];
657
1055
 
1056
+ const mediaItems = React.useMemo(
1057
+ () => (Array.isArray(values.mediaItems) ? values.mediaItems : []),
1058
+ [values.mediaItems],
1059
+ );
658
1060
 
659
1061
  const handleMediaItemsChange = React.useCallback(
660
1062
  (nextItems: ProductMediaItem[]) => {
661
- setValue('mediaItems', nextItems)
662
- const hasCurrent = nextItems.some((item) => item.id === values.defaultMediaId)
1063
+ setValue("mediaItems", nextItems);
1064
+ const hasCurrent = nextItems.some(
1065
+ (item) => item.id === values.defaultMediaId,
1066
+ );
663
1067
  if (!hasCurrent) {
664
- const fallbackId = nextItems[0]?.id ?? null
665
- setValue('defaultMediaId', fallbackId)
1068
+ const fallbackId = nextItems[0]?.id ?? null;
1069
+ setValue("defaultMediaId", fallbackId);
666
1070
  if (fallbackId && nextItems[0]) {
667
1071
  setValue(
668
- 'defaultMediaUrl',
1072
+ "defaultMediaUrl",
669
1073
  buildAttachmentImageUrl(fallbackId, {
670
1074
  slug: slugifyAttachmentFileName(nextItems[0].fileName),
671
1075
  }),
672
- )
1076
+ );
673
1077
  } else {
674
- setValue('defaultMediaUrl', '')
1078
+ setValue("defaultMediaUrl", "");
675
1079
  }
676
1080
  }
677
1081
  },
678
1082
  [setValue, values.defaultMediaId],
679
- )
1083
+ );
680
1084
 
681
1085
  const handleDefaultMediaChange = React.useCallback(
682
1086
  (attachmentId: string | null) => {
683
- setValue('defaultMediaId', attachmentId)
1087
+ setValue("defaultMediaId", attachmentId);
684
1088
  if (!attachmentId) {
685
- setValue('defaultMediaUrl', '')
686
- return
1089
+ setValue("defaultMediaUrl", "");
1090
+ return;
687
1091
  }
688
- const target = mediaItems.find((item) => item.id === attachmentId)
1092
+ const target = mediaItems.find((item) => item.id === attachmentId);
689
1093
  if (target) {
690
1094
  setValue(
691
- 'defaultMediaUrl',
692
- buildAttachmentImageUrl(target.id, { slug: slugifyAttachmentFileName(target.fileName) }),
693
- )
1095
+ "defaultMediaUrl",
1096
+ buildAttachmentImageUrl(target.id, {
1097
+ slug: slugifyAttachmentFileName(target.fileName),
1098
+ }),
1099
+ );
694
1100
  }
695
1101
  },
696
1102
  [mediaItems, setValue],
697
- )
1103
+ );
698
1104
 
699
1105
  const ensureVariants = React.useCallback(() => {
700
- const optionDefinitions = Array.isArray(values.options) ? values.options : []
1106
+ const optionDefinitions = Array.isArray(values.options)
1107
+ ? values.options
1108
+ : [];
701
1109
  if (!values.hasVariants || !optionDefinitions.length) {
702
1110
  if (!values.variants || !values.variants.length) {
703
- setValue('variants', [createVariantDraft(values.taxRateId ?? null, { isDefault: true })])
1111
+ setValue("variants", [
1112
+ createVariantDraft(values.taxRateId ?? null, { isDefault: true }),
1113
+ ]);
704
1114
  }
705
- return
1115
+ return;
706
1116
  }
707
- const combos = buildVariantCombinations(optionDefinitions)
708
- const existing = Array.isArray(values.variants) ? values.variants : []
709
- const existingByKey = new Map(existing.map((variant) => [buildOptionValuesKey(variant.optionValues), variant]))
710
- let hasDefault = existing.some((variant) => variant.isDefault)
711
- let changed = existing.length !== combos.length
1117
+ const combos = buildVariantCombinations(optionDefinitions);
1118
+ const existing = Array.isArray(values.variants) ? values.variants : [];
1119
+ const existingByKey = new Map(
1120
+ existing.map((variant) => [
1121
+ buildOptionValuesKey(variant.optionValues),
1122
+ variant,
1123
+ ]),
1124
+ );
1125
+ let hasDefault = existing.some((variant) => variant.isDefault);
1126
+ let changed = existing.length !== combos.length;
712
1127
  const nextVariants: VariantDraft[] = combos.map((combo, index) => {
713
- const key = buildOptionValuesKey(combo)
714
- const existingMatch = existingByKey.get(key)
1128
+ const key = buildOptionValuesKey(combo);
1129
+ const existingMatch = existingByKey.get(key);
715
1130
  if (existingMatch) {
716
- if (existingMatch.isDefault) hasDefault = true
1131
+ if (existingMatch.isDefault) hasDefault = true;
717
1132
  if (!haveSameOptionValues(existingMatch.optionValues, combo)) {
718
- changed = true
719
- return { ...existingMatch, optionValues: combo }
1133
+ changed = true;
1134
+ return { ...existingMatch, optionValues: combo };
720
1135
  }
721
1136
  if (existing[index] !== existingMatch) {
722
- changed = true
1137
+ changed = true;
723
1138
  }
724
- return existingMatch
1139
+ return existingMatch;
725
1140
  }
726
- changed = true
1141
+ changed = true;
727
1142
  return createVariantDraft(values.taxRateId ?? null, {
728
- title: Object.values(combo).join(' / '),
1143
+ title: Object.values(combo).join(" / "),
729
1144
  optionValues: combo,
730
- })
731
- })
732
- if (!nextVariants.length) return
1145
+ });
1146
+ });
1147
+ if (!nextVariants.length) return;
733
1148
  if (!hasDefault) {
734
- changed = true
735
- nextVariants[0] = { ...nextVariants[0], isDefault: true }
1149
+ changed = true;
1150
+ nextVariants[0] = { ...nextVariants[0], isDefault: true };
736
1151
  }
737
1152
  if (changed) {
738
- setValue('variants', nextVariants)
1153
+ setValue("variants", nextVariants);
739
1154
  }
740
- }, [values.options, values.variants, values.hasVariants, setValue])
1155
+ }, [
1156
+ values.options,
1157
+ values.variants,
1158
+ values.hasVariants,
1159
+ values.taxRateId,
1160
+ setValue,
1161
+ ]);
741
1162
 
742
1163
  React.useEffect(() => {
743
- ensureVariants()
744
- }, [ensureVariants])
1164
+ ensureVariants();
1165
+ }, [ensureVariants]);
745
1166
 
746
1167
  React.useEffect(() => {
747
- if (!values.taxRateId) return
748
- const variants = Array.isArray(values.variants) ? values.variants : []
749
- if (!variants.length) return
750
- let changed = false
1168
+ if (!values.taxRateId) return;
1169
+ const variants = Array.isArray(values.variants) ? values.variants : [];
1170
+ if (!variants.length) return;
1171
+ let changed = false;
751
1172
  const nextVariants = variants.map((variant) => {
752
- if (variant.taxRateId) return variant
753
- changed = true
754
- return { ...variant, taxRateId: values.taxRateId }
755
- })
1173
+ if (variant.taxRateId) return variant;
1174
+ changed = true;
1175
+ return { ...variant, taxRateId: values.taxRateId };
1176
+ });
756
1177
  if (changed) {
757
- setValue('variants', nextVariants)
1178
+ setValue("variants", nextVariants);
758
1179
  }
759
- }, [values.taxRateId, values.variants, setValue])
1180
+ }, [values.taxRateId, values.variants, setValue]);
760
1181
  const setVariantField = React.useCallback(
761
1182
  (variantId: string, field: keyof VariantDraft, value: unknown) => {
762
- const next = (Array.isArray(values.variants) ? values.variants : []).map((variant) => {
763
- if (variant.id !== variantId) return variant
764
- return { ...variant, [field]: value }
765
- })
766
- setValue('variants', next)
1183
+ const next = (Array.isArray(values.variants) ? values.variants : []).map(
1184
+ (variant) => {
1185
+ if (variant.id !== variantId) return variant;
1186
+ return { ...variant, [field]: value };
1187
+ },
1188
+ );
1189
+ setValue("variants", next);
767
1190
  },
768
1191
  [values.variants, setValue],
769
- )
1192
+ );
770
1193
 
771
1194
  const setVariantPrice = React.useCallback(
772
1195
  (variantId: string, priceKindId: string, amount: string) => {
773
- if (amount.trim().startsWith('-')) return
774
- const next = (Array.isArray(values.variants) ? values.variants : []).map((variant) => {
775
- if (variant.id !== variantId) return variant
776
- const nextPrices = { ...(variant.prices ?? {}) }
777
- if (amount === '') {
778
- delete nextPrices[priceKindId]
779
- } else {
780
- nextPrices[priceKindId] = { amount }
781
- }
782
- return {
1196
+ if (amount.trim().startsWith("-")) return;
1197
+ const next = (Array.isArray(values.variants) ? values.variants : []).map(
1198
+ (variant) => {
1199
+ if (variant.id !== variantId) return variant;
1200
+ const nextPrices = { ...(variant.prices ?? {}) };
1201
+ if (amount === "") {
1202
+ delete nextPrices[priceKindId];
1203
+ } else {
1204
+ nextPrices[priceKindId] = { amount };
1205
+ }
1206
+ return {
1207
+ ...variant,
1208
+ prices: nextPrices,
1209
+ };
1210
+ },
1211
+ );
1212
+ setValue("variants", next);
1213
+ },
1214
+ [values.variants, setValue],
1215
+ );
1216
+
1217
+ const markDefaultVariant = React.useCallback(
1218
+ (variantId: string) => {
1219
+ const next = (Array.isArray(values.variants) ? values.variants : []).map(
1220
+ (variant) => ({
783
1221
  ...variant,
784
- prices: nextPrices,
785
- }
786
- })
787
- setValue('variants', next)
1222
+ isDefault: variant.id === variantId,
1223
+ }),
1224
+ );
1225
+ setValue("variants", next);
788
1226
  },
789
1227
  [values.variants, setValue],
790
- )
791
-
792
- const markDefaultVariant = React.useCallback((variantId: string) => {
793
- const next = (Array.isArray(values.variants) ? values.variants : []).map((variant) => ({
794
- ...variant,
795
- isDefault: variant.id === variantId,
796
- }))
797
- setValue('variants', next)
798
- }, [values.variants, setValue])
799
-
800
- const handleOptionTitleChange = React.useCallback((optionId: string, title: string) => {
801
- const next = (Array.isArray(values.options) ? values.options : []).map((option) => {
802
- if (option.id !== optionId) return option
803
- return { ...option, title }
804
- })
805
- setValue('options', next)
806
- }, [values.options, setValue])
807
-
808
- const setOptionValues = React.useCallback((optionId: string, labels: string[]) => {
809
- const normalized = labels
810
- .map((label) => label.trim())
811
- .filter((label) => label.length)
812
- const unique = Array.from(new Set(normalized))
813
- const next = (Array.isArray(values.options) ? values.options : []).map((option) => {
814
- if (option.id !== optionId) return option
815
- const existingByLabel = new Map(option.values.map((value) => [value.label, value]))
816
- const nextValues = unique.map((label) => existingByLabel.get(label) ?? { id: createLocalId(), label })
817
- return {
818
- ...option,
819
- values: nextValues,
820
- }
821
- })
822
- setValue('options', next)
823
- }, [values.options, setValue])
1228
+ );
1229
+
1230
+ const handleOptionTitleChange = React.useCallback(
1231
+ (optionId: string, title: string) => {
1232
+ const next = (Array.isArray(values.options) ? values.options : []).map(
1233
+ (option) => {
1234
+ if (option.id !== optionId) return option;
1235
+ return { ...option, title };
1236
+ },
1237
+ );
1238
+ setValue("options", next);
1239
+ },
1240
+ [values.options, setValue],
1241
+ );
1242
+
1243
+ const setOptionValues = React.useCallback(
1244
+ (optionId: string, labels: string[]) => {
1245
+ const normalized = labels
1246
+ .map((label) => label.trim())
1247
+ .filter((label) => label.length);
1248
+ const unique = Array.from(new Set(normalized));
1249
+ const next = (Array.isArray(values.options) ? values.options : []).map(
1250
+ (option) => {
1251
+ if (option.id !== optionId) return option;
1252
+ const existingByLabel = new Map(
1253
+ option.values.map((value) => [value.label, value]),
1254
+ );
1255
+ const nextValues = unique.map(
1256
+ (label) =>
1257
+ existingByLabel.get(label) ?? { id: createLocalId(), label },
1258
+ );
1259
+ return {
1260
+ ...option,
1261
+ values: nextValues,
1262
+ };
1263
+ },
1264
+ );
1265
+ setValue("options", next);
1266
+ },
1267
+ [values.options, setValue],
1268
+ );
824
1269
 
825
1270
  const addOption = React.useCallback(() => {
826
1271
  const next = [
827
1272
  ...(Array.isArray(values.options) ? values.options : []),
828
- { id: createLocalId(), title: '', values: [] },
829
- ]
830
- setValue('options', next)
831
- }, [values.options, setValue])
832
-
833
- const removeOption = React.useCallback((optionId: string) => {
834
- const next = (Array.isArray(values.options) ? values.options : []).filter((option) => option.id !== optionId)
835
- setValue('options', next)
836
- }, [values.options, setValue])
837
-
1273
+ { id: createLocalId(), title: "", values: [] },
1274
+ ];
1275
+ setValue("options", next);
1276
+ }, [values.options, setValue]);
1277
+
1278
+ const removeOption = React.useCallback(
1279
+ (optionId: string) => {
1280
+ const next = (Array.isArray(values.options) ? values.options : []).filter(
1281
+ (option) => option.id !== optionId,
1282
+ );
1283
+ setValue("options", next);
1284
+ },
1285
+ [values.options, setValue],
1286
+ );
838
1287
 
839
1288
  return (
840
1289
  <div className="space-y-6">
@@ -846,16 +1295,26 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
846
1295
  variant="ghost"
847
1296
  size="sm"
848
1297
  className={cn(
849
- 'relative h-auto rounded-none px-0 py-1 pb-2 font-medium',
850
- currentStep === index ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
1298
+ "relative h-auto rounded-none px-0 py-1 pb-2 font-medium",
1299
+ currentStep === index
1300
+ ? "text-foreground"
1301
+ : "text-muted-foreground hover:text-foreground",
851
1302
  )}
852
1303
  onClick={() => setCurrentStep(index)}
853
1304
  >
854
- {step === 'general' && t('catalog.products.create.steps.general', 'General data')}
855
- {step === 'organize' && t('catalog.products.create.steps.organize', 'Organize')}
856
- {step === 'variants' && t('catalog.products.create.steps.variants', 'Variants')}
1305
+ {step === "general" &&
1306
+ t("catalog.products.create.steps.general", "General data")}
1307
+ {step === "organize" &&
1308
+ t("catalog.products.create.steps.organize", "Organize")}
1309
+ {step === "uom" &&
1310
+ t("catalog.products.uom.title", "Units of measure")}
1311
+ {step === "variants" &&
1312
+ t("catalog.products.create.steps.variants", "Variants")}
857
1313
  {(stepErrors[step]?.length ?? 0) > 0 ? (
858
- <span className="absolute -right-2 top-0 h-2 w-2 rounded-full bg-destructive" aria-hidden="true" />
1314
+ <span
1315
+ className="absolute -right-2 top-0 h-2 w-2 rounded-full bg-destructive"
1316
+ aria-hidden="true"
1317
+ />
859
1318
  ) : null}
860
1319
  {currentStep === index ? (
861
1320
  <span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground rounded-full" />
@@ -864,43 +1323,63 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
864
1323
  ))}
865
1324
  </nav>
866
1325
 
867
- {currentStepKey === 'general' ? (
1326
+ {currentStepKey === "general" ? (
868
1327
  <div className="space-y-6">
869
1328
  <div className="space-y-2">
870
1329
  <Label className="flex items-center gap-1">
871
- {t('catalog.products.form.title', 'Title')}
1330
+ {t("catalog.products.form.title", "Title")}
872
1331
  <span className="text-red-600">*</span>
873
1332
  </Label>
874
1333
  <Input
875
1334
  value={values.title}
876
- onChange={(event) => setValue('title', event.target.value)}
877
- placeholder={t('catalog.products.create.placeholders.title', 'e.g., Summer sneaker')}
1335
+ onChange={(event) => setValue("title", event.target.value)}
1336
+ placeholder={t(
1337
+ "catalog.products.create.placeholders.title",
1338
+ "e.g., Summer sneaker",
1339
+ )}
878
1340
  />
879
- {errors.title ? <p className="text-xs text-red-600">{errors.title}</p> : null}
1341
+ {errors.title ? (
1342
+ <p className="text-xs text-red-600">{errors.title}</p>
1343
+ ) : null}
880
1344
  </div>
881
1345
 
882
1346
  <div className="space-y-2">
883
1347
  <div className="flex items-center justify-between">
884
- <Label>{t('catalog.products.form.description', 'Description')}</Label>
1348
+ <Label>
1349
+ {t("catalog.products.form.description", "Description")}
1350
+ </Label>
885
1351
  <Button
886
1352
  type="button"
887
1353
  variant="ghost"
888
1354
  size="sm"
889
- onClick={() => setValue('useMarkdown', !values.useMarkdown)}
1355
+ onClick={() => setValue("useMarkdown", !values.useMarkdown)}
890
1356
  className="gap-2 text-xs"
891
1357
  >
892
- {values.useMarkdown ? <AlignLeft className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
1358
+ {values.useMarkdown ? (
1359
+ <AlignLeft className="h-4 w-4" />
1360
+ ) : (
1361
+ <FileText className="h-4 w-4" />
1362
+ )}
893
1363
  {values.useMarkdown
894
- ? t('catalog.products.create.actions.usePlain', 'Use plain text')
895
- : t('catalog.products.create.actions.useMarkdown', 'Use markdown')}
1364
+ ? t(
1365
+ "catalog.products.create.actions.usePlain",
1366
+ "Use plain text",
1367
+ )
1368
+ : t(
1369
+ "catalog.products.create.actions.useMarkdown",
1370
+ "Use markdown",
1371
+ )}
896
1372
  </Button>
897
1373
  </div>
898
1374
  {values.useMarkdown ? (
899
- <div data-color-mode="light" className="overflow-hidden rounded-md border">
1375
+ <div
1376
+ data-color-mode="light"
1377
+ className="overflow-hidden rounded-md border"
1378
+ >
900
1379
  <MarkdownEditor
901
1380
  value={values.description}
902
1381
  height={260}
903
- onChange={(val) => setValue('description', val ?? '')}
1382
+ onChange={(val) => setValue("description", val ?? "")}
904
1383
  previewOptions={{ remarkPlugins: [] }}
905
1384
  />
906
1385
  </div>
@@ -908,8 +1387,13 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
908
1387
  <textarea
909
1388
  className="min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
910
1389
  value={values.description}
911
- onChange={(event) => setValue('description', event.target.value)}
912
- placeholder={t('catalog.products.create.placeholders.description', 'Describe the product...')}
1390
+ onChange={(event) =>
1391
+ setValue("description", event.target.value)
1392
+ }
1393
+ placeholder={t(
1394
+ "catalog.products.create.placeholders.description",
1395
+ "Describe the product...",
1396
+ )}
913
1397
  />
914
1398
  )}
915
1399
  </div>
@@ -923,11 +1407,14 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
923
1407
  onDefaultChange={handleDefaultMediaChange}
924
1408
  />
925
1409
 
926
- <ProductDimensionsFields values={values as ProductFormValues} setValue={setValue} />
1410
+ <ProductDimensionsFields
1411
+ values={values as ProductFormValues}
1412
+ setValue={setValue}
1413
+ />
927
1414
  </div>
928
1415
  ) : null}
929
1416
 
930
- {currentStepKey === 'organize' ? (
1417
+ {currentStepKey === "organize" ? (
931
1418
  <ProductCategorizeSection
932
1419
  values={values as ProductFormValues}
933
1420
  setValue={setValue}
@@ -935,55 +1422,105 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
935
1422
  />
936
1423
  ) : null}
937
1424
 
938
- {currentStepKey === 'variants' ? (
1425
+ {currentStepKey === "uom" ? (
1426
+ <ProductUomSection
1427
+ values={values as ProductFormValues}
1428
+ setValue={setValue}
1429
+ errors={errors}
1430
+ embedded
1431
+ />
1432
+ ) : null}
1433
+
1434
+ {currentStepKey === "variants" ? (
939
1435
  <div className="space-y-6">
940
1436
  <label className="flex items-center gap-2 text-sm font-medium">
941
1437
  <input
942
1438
  type="checkbox"
943
1439
  className="h-4 w-4 rounded border"
944
1440
  checked={values.hasVariants}
945
- onChange={(event) => setValue('hasVariants', event.target.checked)}
1441
+ onChange={(event) =>
1442
+ setValue("hasVariants", event.target.checked)
1443
+ }
946
1444
  />
947
- {t('catalog.products.create.variantsBuilder.toggle', 'Yes, this is a product with variants')}
1445
+ {t(
1446
+ "catalog.products.create.variantsBuilder.toggle",
1447
+ "Yes, this is a product with variants",
1448
+ )}
948
1449
  </label>
949
1450
 
950
1451
  {values.hasVariants ? (
951
1452
  <div className="space-y-4 rounded-lg border p-4">
952
1453
  <div className="flex items-center justify-between">
953
- <h3 className="text-sm font-semibold">{t('catalog.products.create.optionsBuilder.title', 'Product options')}</h3>
954
- <Button type="button" variant="outline" size="sm" onClick={addOption}>
1454
+ <h3 className="text-sm font-semibold">
1455
+ {t(
1456
+ "catalog.products.create.optionsBuilder.title",
1457
+ "Product options",
1458
+ )}
1459
+ </h3>
1460
+ <Button
1461
+ type="button"
1462
+ variant="outline"
1463
+ size="sm"
1464
+ onClick={addOption}
1465
+ >
955
1466
  <Plus className="mr-2 h-4 w-4" />
956
- {t('catalog.products.create.optionsBuilder.add', 'Add option')}
1467
+ {t(
1468
+ "catalog.products.create.optionsBuilder.add",
1469
+ "Add option",
1470
+ )}
957
1471
  </Button>
958
1472
  </div>
959
- {(Array.isArray(values.options) ? values.options : []).map((option) => (
960
- <div key={option.id} className="rounded-md bg-muted/40 p-4">
961
- <div className="flex items-center gap-2">
962
- <Input
963
- value={option.title}
964
- onChange={(event) => handleOptionTitleChange(option.id, event.target.value)}
965
- placeholder={t('catalog.products.create.optionsBuilder.placeholder', 'e.g., Color')}
966
- className="flex-1"
967
- />
968
- <Button variant="ghost" size="icon" type="button" onClick={() => removeOption(option.id)}>
969
- <Trash2 className="h-4 w-4" />
970
- </Button>
1473
+ {(Array.isArray(values.options) ? values.options : []).map(
1474
+ (option) => (
1475
+ <div key={option.id} className="rounded-md bg-muted/40 p-4">
1476
+ <div className="flex items-center gap-2">
1477
+ <Input
1478
+ value={option.title}
1479
+ onChange={(event) =>
1480
+ handleOptionTitleChange(option.id, event.target.value)
1481
+ }
1482
+ placeholder={t(
1483
+ "catalog.products.create.optionsBuilder.placeholder",
1484
+ "e.g., Color",
1485
+ )}
1486
+ className="flex-1"
1487
+ />
1488
+ <Button
1489
+ variant="ghost"
1490
+ size="icon"
1491
+ type="button"
1492
+ onClick={() => removeOption(option.id)}
1493
+ >
1494
+ <Trash2 className="h-4 w-4" />
1495
+ </Button>
1496
+ </div>
1497
+ <div className="mt-3 space-y-2">
1498
+ <Label className="text-xs uppercase text-muted-foreground">
1499
+ {t(
1500
+ "catalog.products.create.optionsBuilder.values",
1501
+ "Values",
1502
+ )}
1503
+ </Label>
1504
+ <TagsInput
1505
+ value={option.values.map((value) => value.label)}
1506
+ onChange={(labels) =>
1507
+ setOptionValues(option.id, labels)
1508
+ }
1509
+ placeholder={t(
1510
+ "catalog.products.create.optionsBuilder.valuePlaceholder",
1511
+ "Type a value and press Enter",
1512
+ )}
1513
+ />
1514
+ </div>
971
1515
  </div>
972
- <div className="mt-3 space-y-2">
973
- <Label className="text-xs uppercase text-muted-foreground">
974
- {t('catalog.products.create.optionsBuilder.values', 'Values')}
975
- </Label>
976
- <TagsInput
977
- value={option.values.map((value) => value.label)}
978
- onChange={(labels) => setOptionValues(option.id, labels)}
979
- placeholder={t('catalog.products.create.optionsBuilder.valuePlaceholder', 'Type a value and press Enter')}
980
- />
981
- </div>
982
- </div>
983
- ))}
1516
+ ),
1517
+ )}
984
1518
  {!values.options?.length ? (
985
1519
  <p className="text-sm text-muted-foreground">
986
- {t('catalog.products.create.optionsBuilder.empty', 'No options yet. Add your first option to generate variants.')}
1520
+ {t(
1521
+ "catalog.products.create.optionsBuilder.empty",
1522
+ "No options yet. Add your first option to generate variants.",
1523
+ )}
987
1524
  </p>
988
1525
  ) : null}
989
1526
  </div>
@@ -994,44 +1531,89 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
994
1531
  <table className="w-full min-w-[900px] table-fixed border-collapse text-sm">
995
1532
  <thead className="bg-muted/40">
996
1533
  <tr>
997
- <th className="px-3 py-2 text-left">{t('catalog.products.create.variantsBuilder.defaultOption', 'Default option')}</th>
998
- <th className="px-3 py-2 text-left">{t('catalog.products.form.variants', 'Variant title')}</th>
999
- <th className="px-3 py-2 text-left">{t('catalog.products.create.variantsBuilder.sku', 'SKU')}</th>
1000
- <th className="px-3 py-2 text-left">{t('catalog.products.create.variantsBuilder.vatColumn', 'Tax class')}</th>
1534
+ <th className="px-3 py-2 text-left">
1535
+ {t(
1536
+ "catalog.products.create.variantsBuilder.defaultOption",
1537
+ "Default option",
1538
+ )}
1539
+ </th>
1540
+ <th className="px-3 py-2 text-left">
1541
+ {t("catalog.products.form.variants", "Variant title")}
1542
+ </th>
1543
+ <th className="px-3 py-2 text-left">
1544
+ {t("catalog.products.create.variantsBuilder.sku", "SKU")}
1545
+ </th>
1546
+ <th className="px-3 py-2 text-left">
1547
+ {t(
1548
+ "catalog.products.create.variantsBuilder.vatColumn",
1549
+ "Tax class",
1550
+ )}
1551
+ </th>
1001
1552
  {priceKinds.map((kind) => (
1002
1553
  <th key={kind.id} className="px-3 py-2 text-left">
1003
1554
  <div className="flex flex-col gap-1">
1004
1555
  <div className="flex items-center gap-1">
1005
1556
  <span>
1006
- {t('catalog.products.create.variantsBuilder.priceColumn', 'Price {{title}}').replace('{{title}}', kind.title)}
1557
+ {t(
1558
+ "catalog.products.create.variantsBuilder.priceColumn",
1559
+ "Price {{title}}",
1560
+ ).replace("{{title}}", kind.title)}
1007
1561
  </span>
1008
1562
  <small
1009
1563
  title={
1010
- kind.displayMode === 'including-tax'
1011
- ? t('catalog.priceKinds.form.displayMode.include', 'Including tax')
1012
- : t('catalog.priceKinds.form.displayMode.exclude', 'Excluding tax')
1564
+ kind.displayMode === "including-tax"
1565
+ ? t(
1566
+ "catalog.priceKinds.form.displayMode.include",
1567
+ "Including tax",
1568
+ )
1569
+ : t(
1570
+ "catalog.priceKinds.form.displayMode.exclude",
1571
+ "Excluding tax",
1572
+ )
1013
1573
  }
1014
1574
  className="text-xs text-muted-foreground"
1015
1575
  >
1016
- {kind.displayMode === 'including-tax' ? '' : ''}
1576
+ {kind.displayMode === "including-tax" ? "" : ""}
1017
1577
  </small>
1018
1578
  </div>
1019
1579
  <span className="text-xs text-muted-foreground">
1020
1580
  {kind.currencyCode?.toUpperCase() ??
1021
- t('catalog.products.create.variantsBuilder.currencyMissing', 'Currency missing')}
1581
+ t(
1582
+ "catalog.products.create.variantsBuilder.currencyMissing",
1583
+ "Currency missing",
1584
+ )}
1022
1585
  </span>
1023
1586
  </div>
1024
1587
  </th>
1025
1588
  ))}
1026
- <th className="px-3 py-2 text-center">{t('catalog.products.create.variantsBuilder.manageInventory', 'Managed inventory')}</th>
1027
- <th className="px-3 py-2 text-center">{t('catalog.products.create.variantsBuilder.allowBackorder', 'Allow backorder')}</th>
1028
- <th className="px-3 py-2 text-center">{t('catalog.products.create.variantsBuilder.inventoryKit', 'Has inventory kit')}</th>
1589
+ <th className="px-3 py-2 text-center">
1590
+ {t(
1591
+ "catalog.products.create.variantsBuilder.manageInventory",
1592
+ "Managed inventory",
1593
+ )}
1594
+ </th>
1595
+ <th className="px-3 py-2 text-center">
1596
+ {t(
1597
+ "catalog.products.create.variantsBuilder.allowBackorder",
1598
+ "Allow backorder",
1599
+ )}
1600
+ </th>
1601
+ <th className="px-3 py-2 text-center">
1602
+ {t(
1603
+ "catalog.products.create.variantsBuilder.inventoryKit",
1604
+ "Has inventory kit",
1605
+ )}
1606
+ </th>
1029
1607
  </tr>
1030
1608
  </thead>
1031
1609
  <tbody>
1032
1610
  {(Array.isArray(values.variants) && values.variants.length
1033
1611
  ? values.variants
1034
- : [createVariantDraft(values.taxRateId ?? null, { isDefault: true })]
1612
+ : [
1613
+ createVariantDraft(values.taxRateId ?? null, {
1614
+ isDefault: true,
1615
+ }),
1616
+ ]
1035
1617
  ).map((variant) => (
1036
1618
  <tr key={variant.id} className="border-t">
1037
1619
  <td className="px-3 py-2">
@@ -1043,40 +1625,76 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1043
1625
  onChange={() => markDefaultVariant(variant.id)}
1044
1626
  />
1045
1627
  {variant.isDefault
1046
- ? t('catalog.products.create.variantsBuilder.defaultLabel', 'Default option value')
1047
- : t('catalog.products.create.variantsBuilder.makeDefault', 'Set as default')}
1628
+ ? t(
1629
+ "catalog.products.create.variantsBuilder.defaultLabel",
1630
+ "Default option value",
1631
+ )
1632
+ : t(
1633
+ "catalog.products.create.variantsBuilder.makeDefault",
1634
+ "Set as default",
1635
+ )}
1048
1636
  </label>
1049
- {values.hasVariants && variant.optionValues
1050
- ? (
1051
- <p className="text-xs text-muted-foreground">{Object.values(variant.optionValues).join(' / ')}</p>
1052
- )
1053
- : null}
1637
+ {values.hasVariants && variant.optionValues ? (
1638
+ <p className="text-xs text-muted-foreground">
1639
+ {Object.values(variant.optionValues).join(" / ")}
1640
+ </p>
1641
+ ) : null}
1054
1642
  </td>
1055
1643
  <td className="px-3 py-2">
1056
1644
  <Input
1057
1645
  value={variant.title}
1058
- onChange={(event) => setVariantField(variant.id, 'title', event.target.value)}
1059
- placeholder={t('catalog.products.create.variantsBuilder.titlePlaceholder', 'Variant title')}
1646
+ onChange={(event) =>
1647
+ setVariantField(
1648
+ variant.id,
1649
+ "title",
1650
+ event.target.value,
1651
+ )
1652
+ }
1653
+ placeholder={t(
1654
+ "catalog.products.create.variantsBuilder.titlePlaceholder",
1655
+ "Variant title",
1656
+ )}
1060
1657
  />
1061
1658
  </td>
1062
1659
  <td className="px-3 py-2">
1063
1660
  <Input
1064
1661
  value={variant.sku}
1065
- onChange={(event) => setVariantField(variant.id, 'sku', event.target.value)}
1066
- placeholder={t('catalog.products.create.variantsBuilder.skuPlaceholder', 'e.g., SKU-001')}
1662
+ onChange={(event) =>
1663
+ setVariantField(
1664
+ variant.id,
1665
+ "sku",
1666
+ event.target.value,
1667
+ )
1668
+ }
1669
+ placeholder={t(
1670
+ "catalog.products.create.variantsBuilder.skuPlaceholder",
1671
+ "e.g., SKU-001",
1672
+ )}
1067
1673
  />
1068
1674
  </td>
1069
1675
  <td className="px-3 py-2">
1070
1676
  <select
1071
1677
  className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
1072
- value={variant.taxRateId ?? ''}
1073
- onChange={(event) => setVariantField(variant.id, 'taxRateId', event.target.value || null)}
1678
+ value={variant.taxRateId ?? ""}
1679
+ onChange={(event) =>
1680
+ setVariantField(
1681
+ variant.id,
1682
+ "taxRateId",
1683
+ event.target.value || null,
1684
+ )
1685
+ }
1074
1686
  disabled={!taxRates.length}
1075
1687
  >
1076
1688
  <option value="">
1077
1689
  {defaultTaxRateLabel
1078
- ? t('catalog.products.create.variantsBuilder.vatOptionDefault', 'Use product tax class ({{label}})').replace('{{label}}', defaultTaxRateLabel)
1079
- : t('catalog.products.create.variantsBuilder.vatOptionNone', 'No tax class')}
1690
+ ? t(
1691
+ "catalog.products.create.variantsBuilder.vatOptionDefault",
1692
+ "Use product tax class ({{label}})",
1693
+ ).replace("{{label}}", defaultTaxRateLabel)
1694
+ : t(
1695
+ "catalog.products.create.variantsBuilder.vatOptionNone",
1696
+ "No tax class",
1697
+ )}
1080
1698
  </option>
1081
1699
  {taxRates.map((rate) => (
1082
1700
  <option key={rate.id} value={rate.id}>
@@ -1089,13 +1707,19 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1089
1707
  <td key={kind.id} className="px-3 py-2">
1090
1708
  <div className="flex items-center gap-2">
1091
1709
  <span className="text-xs text-muted-foreground">
1092
- {kind.currencyCode ?? ''}
1710
+ {kind.currencyCode ?? ""}
1093
1711
  </span>
1094
1712
  <input
1095
1713
  type="number"
1096
1714
  className="w-full rounded-md border px-2 py-1"
1097
- value={variant.prices?.[kind.id]?.amount ?? ''}
1098
- onChange={(event) => setVariantPrice(variant.id, kind.id, event.target.value)}
1715
+ value={variant.prices?.[kind.id]?.amount ?? ""}
1716
+ onChange={(event) =>
1717
+ setVariantPrice(
1718
+ variant.id,
1719
+ kind.id,
1720
+ event.target.value,
1721
+ )
1722
+ }
1099
1723
  placeholder="0.00"
1100
1724
  min={0}
1101
1725
  />
@@ -1107,7 +1731,13 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1107
1731
  type="checkbox"
1108
1732
  className="h-4 w-4 rounded border disabled:cursor-not-allowed disabled:opacity-60"
1109
1733
  checked={variant.manageInventory}
1110
- onChange={(event) => setVariantField(variant.id, 'manageInventory', event.target.checked)}
1734
+ onChange={(event) =>
1735
+ setVariantField(
1736
+ variant.id,
1737
+ "manageInventory",
1738
+ event.target.checked,
1739
+ )
1740
+ }
1111
1741
  disabled
1112
1742
  title={inventoryDisabledHint}
1113
1743
  />
@@ -1117,7 +1747,13 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1117
1747
  type="checkbox"
1118
1748
  className="h-4 w-4 rounded border disabled:cursor-not-allowed disabled:opacity-60"
1119
1749
  checked={variant.allowBackorder}
1120
- onChange={(event) => setVariantField(variant.id, 'allowBackorder', event.target.checked)}
1750
+ onChange={(event) =>
1751
+ setVariantField(
1752
+ variant.id,
1753
+ "allowBackorder",
1754
+ event.target.checked,
1755
+ )
1756
+ }
1121
1757
  disabled
1122
1758
  title={inventoryDisabledHint}
1123
1759
  />
@@ -1127,7 +1763,13 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1127
1763
  type="checkbox"
1128
1764
  className="h-4 w-4 rounded border disabled:cursor-not-allowed disabled:opacity-60"
1129
1765
  checked={variant.hasInventoryKit}
1130
- onChange={(event) => setVariantField(variant.id, 'hasInventoryKit', event.target.checked)}
1766
+ onChange={(event) =>
1767
+ setVariantField(
1768
+ variant.id,
1769
+ "hasInventoryKit",
1770
+ event.target.checked,
1771
+ )
1772
+ }
1131
1773
  disabled
1132
1774
  title={inventoryDisabledHint}
1133
1775
  />
@@ -1140,7 +1782,10 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1140
1782
  {!priceKinds.length ? (
1141
1783
  <div className="flex items-center gap-2 border-t px-4 py-3 text-sm text-muted-foreground">
1142
1784
  <AlertCircle className="h-4 w-4" />
1143
- {t('catalog.products.create.variantsBuilder.noPriceKinds', 'Configure price kinds in Catalog settings to add price columns.')}
1785
+ {t(
1786
+ "catalog.products.create.variantsBuilder.noPriceKinds",
1787
+ "Configure price kinds in Catalog settings to add price columns.",
1788
+ )}
1144
1789
  </div>
1145
1790
  ) : null}
1146
1791
  </div>
@@ -1157,17 +1802,19 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1157
1802
  className="gap-2"
1158
1803
  >
1159
1804
  <ChevronLeft className="h-4 w-4" />
1160
- {t('catalog.products.create.steps.previous', 'Previous')}
1805
+ {t("catalog.products.create.steps.previous", "Previous")}
1161
1806
  </Button>
1162
- {currentStepKey !== 'variants' ? (
1807
+ {currentStepKey !== "variants" ? (
1163
1808
  <Button
1164
1809
  type="button"
1165
1810
  variant="outline"
1166
1811
  size="sm"
1167
- onClick={() => setCurrentStep(Math.min(steps.length - 1, currentStep + 1))}
1812
+ onClick={() =>
1813
+ setCurrentStep(Math.min(steps.length - 1, currentStep + 1))
1814
+ }
1168
1815
  className="gap-2"
1169
1816
  >
1170
- {t('catalog.products.create.steps.continue', 'Continue')}
1817
+ {t("catalog.products.create.steps.continue", "Continue")}
1171
1818
  <ChevronRight className="h-4 w-4" />
1172
1819
  </Button>
1173
1820
  ) : (
@@ -1175,64 +1822,77 @@ function ProductBuilder({ values, setValue, errors, priceKinds, taxRates }: Prod
1175
1822
  )}
1176
1823
  </div>
1177
1824
  </div>
1178
- )
1825
+ );
1179
1826
  }
1180
1827
 
1181
- function ProductMetaSection({ values, setValue, errors, taxRates }: ProductMetaSectionProps) {
1182
- const t = useT()
1183
- const handleValue = typeof values.handle === 'string' ? values.handle : ''
1184
- const titleSource = typeof values.title === 'string' ? values.title : ''
1185
- const autoHandleEnabledRef = React.useRef(handleValue.trim().length === 0)
1828
+ function ProductMetaSection({
1829
+ values,
1830
+ setValue,
1831
+ errors,
1832
+ taxRates,
1833
+ }: ProductMetaSectionProps) {
1834
+ const t = useT();
1835
+ const handleValue = typeof values.handle === "string" ? values.handle : "";
1836
+ const titleSource = typeof values.title === "string" ? values.title : "";
1837
+ const autoHandleEnabledRef = React.useRef(handleValue.trim().length === 0);
1186
1838
 
1187
1839
  React.useEffect(() => {
1188
- if (!autoHandleEnabledRef.current) return
1189
- const normalizedTitle = titleSource.trim()
1840
+ if (!autoHandleEnabledRef.current) return;
1841
+ const normalizedTitle = titleSource.trim();
1190
1842
  if (!normalizedTitle) {
1191
1843
  if (handleValue) {
1192
- setValue('handle', '')
1844
+ setValue("handle", "");
1193
1845
  }
1194
- return
1846
+ return;
1195
1847
  }
1196
- const nextHandle = slugify(normalizedTitle)
1848
+ const nextHandle = slugify(normalizedTitle);
1197
1849
  if (nextHandle !== handleValue) {
1198
- setValue('handle', nextHandle)
1850
+ setValue("handle", nextHandle);
1199
1851
  }
1200
- }, [titleSource, handleValue, setValue])
1852
+ }, [titleSource, handleValue, setValue]);
1201
1853
 
1202
1854
  const handleHandleInputChange = React.useCallback(
1203
1855
  (event: React.ChangeEvent<HTMLInputElement>) => {
1204
- const nextValue = event.target.value
1205
- autoHandleEnabledRef.current = nextValue.trim().length === 0
1206
- setValue('handle', nextValue)
1856
+ const nextValue = event.target.value;
1857
+ autoHandleEnabledRef.current = nextValue.trim().length === 0;
1858
+ setValue("handle", nextValue);
1207
1859
  },
1208
1860
  [setValue],
1209
- )
1861
+ );
1210
1862
 
1211
1863
  const handleGenerateHandle = React.useCallback(() => {
1212
- const slug = slugify(titleSource)
1213
- autoHandleEnabledRef.current = true
1214
- setValue('handle', slug)
1215
- }, [titleSource, setValue])
1864
+ const slug = slugify(titleSource);
1865
+ autoHandleEnabledRef.current = true;
1866
+ setValue("handle", slug);
1867
+ }, [titleSource, setValue]);
1216
1868
 
1217
1869
  return (
1218
1870
  <div className="space-y-4">
1219
1871
  <div className="space-y-2">
1220
- <Label>{t('catalog.products.form.subtitle', 'Subtitle')}</Label>
1872
+ <Label>{t("catalog.products.form.subtitle", "Subtitle")}</Label>
1221
1873
  <Input
1222
- value={typeof values.subtitle === 'string' ? values.subtitle : ''}
1223
- onChange={(event) => setValue('subtitle', event.target.value)}
1224
- placeholder={t('catalog.products.create.placeholders.subtitle', 'Optional subtitle')}
1874
+ value={typeof values.subtitle === "string" ? values.subtitle : ""}
1875
+ onChange={(event) => setValue("subtitle", event.target.value)}
1876
+ placeholder={t(
1877
+ "catalog.products.create.placeholders.subtitle",
1878
+ "Optional subtitle",
1879
+ )}
1225
1880
  />
1226
- {errors.subtitle ? <p className="text-xs text-red-600">{errors.subtitle}</p> : null}
1881
+ {errors.subtitle ? (
1882
+ <p className="text-xs text-red-600">{errors.subtitle}</p>
1883
+ ) : null}
1227
1884
  </div>
1228
1885
 
1229
1886
  <div className="space-y-2">
1230
- <Label>{t('catalog.products.form.handle', 'Handle')}</Label>
1887
+ <Label>{t("catalog.products.form.handle", "Handle")}</Label>
1231
1888
  <div className="flex gap-2">
1232
1889
  <Input
1233
1890
  value={handleValue}
1234
1891
  onChange={handleHandleInputChange}
1235
- placeholder={t('catalog.products.create.placeholders.handle', 'e.g., summer-sneaker')}
1892
+ placeholder={t(
1893
+ "catalog.products.create.placeholders.handle",
1894
+ "e.g., summer-sneaker",
1895
+ )}
1236
1896
  className="font-mono lowercase"
1237
1897
  />
1238
1898
  <Button
@@ -1240,44 +1900,71 @@ function ProductMetaSection({ values, setValue, errors, taxRates }: ProductMetaS
1240
1900
  variant="outline"
1241
1901
  onClick={handleGenerateHandle}
1242
1902
  >
1243
- {t('catalog.products.create.actions.generateHandle', 'Generate')}
1903
+ {t("catalog.products.create.actions.generateHandle", "Generate")}
1244
1904
  </Button>
1245
1905
  </div>
1246
1906
  <p className="text-xs text-muted-foreground">
1247
- {t('catalog.products.create.handleHelp', 'Handle is used for URLs and must be unique.')}
1907
+ {t(
1908
+ "catalog.products.create.handleHelp",
1909
+ "Handle is used for URLs and must be unique.",
1910
+ )}
1248
1911
  </p>
1249
- {errors.handle ? <p className="text-xs text-red-600">{errors.handle}</p> : null}
1912
+ {errors.handle ? (
1913
+ <p className="text-xs text-red-600">{errors.handle}</p>
1914
+ ) : null}
1250
1915
  </div>
1251
1916
 
1252
1917
  <div className="space-y-2">
1253
1918
  <div className="flex items-center justify-between gap-2">
1254
- <Label>{t('catalog.products.create.taxRates.label', 'Tax class')}</Label>
1919
+ <Label>
1920
+ {t("catalog.products.create.taxRates.label", "Tax class")}
1921
+ </Label>
1255
1922
  <Button
1256
1923
  type="button"
1257
1924
  variant="ghost"
1258
1925
  size="icon"
1259
1926
  onClick={() => {
1260
- if (typeof window !== 'undefined') {
1261
- window.open('/backend/config/sales?section=tax-rates', '_blank', 'noopener,noreferrer')
1927
+ if (typeof window !== "undefined") {
1928
+ window.open(
1929
+ "/backend/config/sales?section=tax-rates",
1930
+ "_blank",
1931
+ "noopener,noreferrer",
1932
+ );
1262
1933
  }
1263
1934
  }}
1264
- title={t('catalog.products.create.taxRates.manage', 'Manage tax classes')}
1935
+ title={t(
1936
+ "catalog.products.create.taxRates.manage",
1937
+ "Manage tax classes",
1938
+ )}
1265
1939
  className="text-muted-foreground hover:text-foreground"
1266
1940
  >
1267
1941
  <Settings className="h-4 w-4" />
1268
- <span className="sr-only">{t('catalog.products.create.taxRates.manage', 'Manage tax classes')}</span>
1942
+ <span className="sr-only">
1943
+ {t(
1944
+ "catalog.products.create.taxRates.manage",
1945
+ "Manage tax classes",
1946
+ )}
1947
+ </span>
1269
1948
  </Button>
1270
1949
  </div>
1271
1950
  <select
1272
1951
  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"
1273
- value={values.taxRateId ?? ''}
1274
- onChange={(event) => setValue('taxRateId', event.target.value || null)}
1952
+ value={values.taxRateId ?? ""}
1953
+ onChange={(event) =>
1954
+ setValue("taxRateId", event.target.value || null)
1955
+ }
1275
1956
  disabled={!taxRates.length}
1276
1957
  >
1277
1958
  <option value="">
1278
1959
  {taxRates.length
1279
- ? t('catalog.products.create.taxRates.noneSelected', 'No tax class selected')
1280
- : t('catalog.products.create.taxRates.emptyOption', 'No tax classes available')}
1960
+ ? t(
1961
+ "catalog.products.create.taxRates.noneSelected",
1962
+ "No tax class selected",
1963
+ )
1964
+ : t(
1965
+ "catalog.products.create.taxRates.emptyOption",
1966
+ "No tax classes available",
1967
+ )}
1281
1968
  </option>
1282
1969
  {taxRates.map((rate) => (
1283
1970
  <option key={rate.id} value={rate.id}>
@@ -1287,11 +1974,19 @@ function ProductMetaSection({ values, setValue, errors, taxRates }: ProductMetaS
1287
1974
  </select>
1288
1975
  <p className="text-xs text-muted-foreground">
1289
1976
  {taxRates.length
1290
- ? t('catalog.products.create.taxRates.help', 'Applied to new prices unless overridden per variant.')
1291
- : t('catalog.products.create.taxRates.empty', 'Define tax classes under Sales → Configuration.')}
1977
+ ? t(
1978
+ "catalog.products.create.taxRates.help",
1979
+ "Applied to new prices unless overridden per variant.",
1980
+ )
1981
+ : t(
1982
+ "catalog.products.create.taxRates.empty",
1983
+ "Define tax classes under Sales → Configuration.",
1984
+ )}
1292
1985
  </p>
1293
- {errors.taxRateId ? <p className="text-xs text-red-600">{errors.taxRateId}</p> : null}
1986
+ {errors.taxRateId ? (
1987
+ <p className="text-xs text-red-600">{errors.taxRateId}</p>
1988
+ ) : null}
1294
1989
  </div>
1295
1990
  </div>
1296
- )
1991
+ );
1297
1992
  }