@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.
- package/dist/generated/entities/catalog_product/index.js +16 -0
- package/dist/generated/entities/catalog_product/index.js.map +2 -2
- package/dist/generated/entities/catalog_product_unit_conversion/index.js +27 -0
- package/dist/generated/entities/catalog_product_unit_conversion/index.js.map +7 -0
- package/dist/generated/entities/sales_credit_memo_line/index.js +7 -1
- package/dist/generated/entities/sales_credit_memo_line/index.js.map +2 -2
- package/dist/generated/entities/sales_invoice_line/index.js +7 -1
- package/dist/generated/entities/sales_invoice_line/index.js.map +2 -2
- package/dist/generated/entities/sales_order_line/index.js +6 -0
- package/dist/generated/entities/sales_order_line/index.js.map +2 -2
- package/dist/generated/entities/sales_quote_line/index.js +6 -0
- package/dist/generated/entities/sales_quote_line/index.js.map +2 -2
- package/dist/generated/entities.ids.generated.js +1 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/catalog/api/prices/route.js +123 -8
- package/dist/modules/catalog/api/prices/route.js.map +2 -2
- package/dist/modules/catalog/api/product-unit-conversions/route.js +194 -0
- package/dist/modules/catalog/api/product-unit-conversions/route.js.map +7 -0
- package/dist/modules/catalog/api/products/route.js +351 -201
- package/dist/modules/catalog/api/products/route.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +1267 -497
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/create/page.js +733 -210
- package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
- package/dist/modules/catalog/commands/index.js +1 -0
- package/dist/modules/catalog/commands/index.js.map +2 -2
- package/dist/modules/catalog/commands/productUnitConversions.js +503 -0
- package/dist/modules/catalog/commands/productUnitConversions.js.map +7 -0
- package/dist/modules/catalog/commands/products.js +355 -73
- package/dist/modules/catalog/commands/products.js.map +2 -2
- package/dist/modules/catalog/commands/shared.js +18 -4
- package/dist/modules/catalog/commands/shared.js.map +2 -2
- package/dist/modules/catalog/components/products/ProductUomSection.js +591 -0
- package/dist/modules/catalog/components/products/ProductUomSection.js.map +7 -0
- package/dist/modules/catalog/components/products/productForm.js +66 -5
- package/dist/modules/catalog/components/products/productForm.js.map +2 -2
- package/dist/modules/catalog/components/products/productFormUtils.js +68 -0
- package/dist/modules/catalog/components/products/productFormUtils.js.map +7 -0
- package/dist/modules/catalog/data/entities.js +86 -0
- package/dist/modules/catalog/data/entities.js.map +2 -2
- package/dist/modules/catalog/data/validators.js +65 -3
- package/dist/modules/catalog/data/validators.js.map +2 -2
- package/dist/modules/catalog/events.js +3 -0
- package/dist/modules/catalog/events.js.map +2 -2
- package/dist/modules/catalog/lib/unitCodes.js +7 -0
- package/dist/modules/catalog/lib/unitCodes.js.map +7 -0
- package/dist/modules/catalog/lib/unitResolution.js +53 -0
- package/dist/modules/catalog/lib/unitResolution.js.map +7 -0
- package/dist/modules/catalog/migrations/Migration20260218225422.js +19 -0
- package/dist/modules/catalog/migrations/Migration20260218225422.js.map +7 -0
- package/dist/modules/catalog/migrations/Migration20260219084500.js +27 -0
- package/dist/modules/catalog/migrations/Migration20260219084500.js.map +7 -0
- package/dist/modules/catalog/search.js +69 -1
- package/dist/modules/catalog/search.js.map +2 -2
- package/dist/modules/catalog/seed/examples.js +91 -42
- package/dist/modules/catalog/seed/examples.js.map +2 -2
- package/dist/modules/dashboards/seed/analytics.js +3 -0
- package/dist/modules/dashboards/seed/analytics.js.map +2 -2
- package/dist/modules/sales/api/order-lines/route.js +98 -15
- package/dist/modules/sales/api/order-lines/route.js.map +2 -2
- package/dist/modules/sales/api/quote-lines/route.js +101 -14
- package/dist/modules/sales/api/quote-lines/route.js.map +2 -2
- package/dist/modules/sales/api/quotes/public/[token]/route.js +87 -12
- package/dist/modules/sales/api/quotes/public/[token]/route.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +1424 -260
- package/dist/modules/sales/commands/documents.js.map +3 -3
- package/dist/modules/sales/commands/shared.js +6 -2
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +216 -86
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +913 -241
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +3 -3
- package/dist/modules/sales/components/documents/ShipmentsSection.js +15 -3
- package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
- package/dist/modules/sales/data/entities.js +59 -3
- package/dist/modules/sales/data/entities.js.map +2 -2
- package/dist/modules/sales/data/validators.js +35 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/frontend/quote/[token]/page.js +15 -1
- package/dist/modules/sales/frontend/quote/[token]/page.js.map +2 -2
- package/dist/modules/sales/migrations/Migration20260218225423.js +31 -0
- package/dist/modules/sales/migrations/Migration20260218225423.js.map +7 -0
- package/dist/modules/sales/migrations/Migration20260219084501.js +71 -0
- package/dist/modules/sales/migrations/Migration20260219084501.js.map +7 -0
- package/dist/modules/sales/search.js +28 -0
- package/dist/modules/sales/search.js.map +2 -2
- package/dist/modules/sales/seed/examples.js +14 -1
- package/dist/modules/sales/seed/examples.js.map +2 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js +1 -1
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
- package/generated/entities/catalog_product/index.ts +8 -0
- package/generated/entities/catalog_product_unit_conversion/index.ts +12 -0
- package/generated/entities/sales_credit_memo_line/index.ts +3 -0
- package/generated/entities/sales_invoice_line/index.ts +3 -0
- package/generated/entities/sales_order_line/index.ts +3 -0
- package/generated/entities/sales_quote_line/index.ts +3 -0
- package/generated/entities.ids.generated.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +2 -2
- package/src/modules/auth/i18n/de.json +1 -1
- package/src/modules/auth/i18n/en.json +1 -1
- package/src/modules/auth/i18n/es.json +1 -1
- package/src/modules/auth/i18n/pl.json +1 -1
- package/src/modules/catalog/api/prices/route.ts +213 -81
- package/src/modules/catalog/api/product-unit-conversions/route.ts +195 -0
- package/src/modules/catalog/api/products/route.ts +638 -402
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2085 -1072
- package/src/modules/catalog/backend/catalog/products/create/page.tsx +1288 -593
- package/src/modules/catalog/commands/index.ts +1 -0
- package/src/modules/catalog/commands/productUnitConversions.ts +626 -0
- package/src/modules/catalog/commands/products.ts +1151 -693
- package/src/modules/catalog/commands/shared.ts +19 -5
- package/src/modules/catalog/components/products/ProductUomSection.tsx +745 -0
- package/src/modules/catalog/components/products/productForm.ts +369 -256
- package/src/modules/catalog/components/products/productFormUtils.ts +82 -0
- package/src/modules/catalog/data/entities.ts +82 -1
- package/src/modules/catalog/data/validators.ts +118 -34
- package/src/modules/catalog/events.ts +3 -0
- package/src/modules/catalog/i18n/de.json +56 -0
- package/src/modules/catalog/i18n/en.json +56 -0
- package/src/modules/catalog/i18n/es.json +56 -0
- package/src/modules/catalog/i18n/pl.json +56 -0
- package/src/modules/catalog/lib/unitCodes.ts +1 -0
- package/src/modules/catalog/lib/unitResolution.ts +62 -0
- package/src/modules/catalog/migrations/.snapshot-open-mercato.json +245 -0
- package/src/modules/catalog/migrations/Migration20260218225422.ts +21 -0
- package/src/modules/catalog/migrations/Migration20260219084500.ts +26 -0
- package/src/modules/catalog/search.ts +73 -1
- package/src/modules/catalog/seed/examples.ts +552 -479
- package/src/modules/dashboards/i18n/de.json +1 -1
- package/src/modules/dashboards/i18n/en.json +1 -1
- package/src/modules/dashboards/i18n/es.json +1 -1
- package/src/modules/dashboards/i18n/pl.json +1 -1
- package/src/modules/dashboards/seed/analytics.ts +3 -0
- package/src/modules/sales/api/order-lines/route.ts +158 -68
- package/src/modules/sales/api/quote-lines/route.ts +161 -67
- package/src/modules/sales/api/quotes/public/[token]/route.ts +122 -36
- package/src/modules/sales/commands/documents.ts +4250 -2424
- package/src/modules/sales/commands/shared.ts +7 -2
- package/src/modules/sales/components/documents/ItemsSection.tsx +580 -310
- package/src/modules/sales/components/documents/LineItemDialog.tsx +1988 -833
- package/src/modules/sales/components/documents/ShipmentsSection.tsx +17 -3
- package/src/modules/sales/components/documents/lineItemTypes.ts +6 -0
- package/src/modules/sales/data/entities.ts +53 -0
- package/src/modules/sales/data/validators.ts +36 -0
- package/src/modules/sales/frontend/quote/[token]/page.tsx +25 -1
- package/src/modules/sales/i18n/de.json +23 -3
- package/src/modules/sales/i18n/en.json +23 -3
- package/src/modules/sales/i18n/es.json +23 -3
- package/src/modules/sales/i18n/pl.json +23 -3
- package/src/modules/sales/lib/types.ts +30 -0
- package/src/modules/sales/migrations/.snapshot-open-mercato.json +172 -0
- package/src/modules/sales/migrations/Migration20260218225423.ts +37 -0
- package/src/modules/sales/migrations/Migration20260219084501.ts +73 -0
- package/src/modules/sales/search.ts +28 -0
- package/src/modules/sales/seed/examples.ts +20 -1
- package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +1 -1
- package/src/modules/workflows/i18n/de.json +4 -4
- package/src/modules/workflows/i18n/en.json +4 -4
- package/src/modules/workflows/i18n/es.json +4 -4
- package/src/modules/workflows/i18n/pl.json +4 -4
|
@@ -1,25 +1,44 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import * as React from
|
|
4
|
-
import dynamic from
|
|
5
|
-
import { useRouter, useSearchParams } from
|
|
6
|
-
import type { ZodType } from
|
|
7
|
-
import { Page, PageBody } from
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
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
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
|
|
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[
|
|
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(
|
|
108
|
+
const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), {
|
|
72
109
|
ssr: false,
|
|
73
|
-
loading: () =>
|
|
74
|
-
|
|
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([
|
|
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 ||
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
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(
|
|
87
|
-
matchField(
|
|
88
|
-
matchField(
|
|
89
|
-
matchField(
|
|
90
|
-
matchPrefix(
|
|
91
|
-
matchPrefix(
|
|
92
|
-
matchPrefix(
|
|
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: [
|
|
95
|
-
|
|
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 ===
|
|
110
|
-
if (typeof value ===
|
|
111
|
-
const normalized = value.trim().toLowerCase()
|
|
112
|
-
if (!normalized) return false
|
|
113
|
-
if (TRUE_BOOLEAN_VALUES.has(normalized)) return true
|
|
114
|
-
if ([
|
|
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 ===
|
|
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(
|
|
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(
|
|
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 ===
|
|
156
|
-
if (typeof p.description ===
|
|
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<{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
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<{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 =
|
|
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 ===
|
|
275
|
+
typeof item.name === "string" && item.name.trim().length
|
|
200
276
|
? item.name
|
|
201
|
-
: t(
|
|
202
|
-
|
|
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 !==
|
|
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(
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
values
|
|
225
|
-
setValue
|
|
226
|
-
errors
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
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={
|
|
355
|
+
initialValues={
|
|
356
|
+
initialValuesRef.current ?? createInitialProductFormValues()
|
|
357
|
+
}
|
|
258
358
|
schema={productFormTypedSchema}
|
|
259
|
-
submitLabel={t(
|
|
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(
|
|
265
|
-
|
|
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 ===
|
|
380
|
+
typeof formValues.defaultMediaId === "string" &&
|
|
381
|
+
formValues.defaultMediaId.trim().length
|
|
272
382
|
? formValues.defaultMediaId
|
|
273
|
-
: null
|
|
274
|
-
const mediaItems = Array.isArray(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 ===
|
|
277
|
-
.filter((value): value is string => !!value)
|
|
278
|
-
const mediaDraftId =
|
|
279
|
-
|
|
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(
|
|
286
|
-
|
|
287
|
-
|
|
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 ===
|
|
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 =
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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
|
-
: [
|
|
358
|
-
|
|
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 } =
|
|
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 (
|
|
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(
|
|
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 ===
|
|
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(
|
|
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: {
|
|
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 }>(
|
|
395
|
-
|
|
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(
|
|
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 } =
|
|
668
|
+
const { resolvedVariantTaxRateId, resolvedVariantTaxRate } =
|
|
669
|
+
resolveVariantTax(variant);
|
|
404
670
|
const variantPayload: Record<string, unknown> = {
|
|
405
671
|
productId,
|
|
406
|
-
name:
|
|
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
|
|
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<{
|
|
415
|
-
|
|
416
|
-
|
|
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(
|
|
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 (
|
|
438
|
-
|
|
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 ===
|
|
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(
|
|
724
|
+
await createCrud("catalog/prices", pricePayload);
|
|
446
725
|
}
|
|
447
726
|
|
|
448
727
|
if (mediaDraftId && attachmentIds.length) {
|
|
449
|
-
const transfer = await apiCall<{
|
|
450
|
-
|
|
728
|
+
const transfer = await apiCall<{
|
|
729
|
+
ok?: boolean;
|
|
730
|
+
error?: string;
|
|
731
|
+
}>(
|
|
732
|
+
"/api/attachments/transfer",
|
|
451
733
|
{
|
|
452
|
-
method:
|
|
453
|
-
headers: {
|
|
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(
|
|
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(
|
|
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:
|
|
761
|
+
method: "PATCH",
|
|
477
762
|
body: JSON.stringify({
|
|
478
763
|
createdEntityId: productId,
|
|
479
|
-
createdEntityType:
|
|
764
|
+
createdEntityType: "catalog_product",
|
|
480
765
|
}),
|
|
481
766
|
},
|
|
482
|
-
)
|
|
767
|
+
);
|
|
483
768
|
} catch {
|
|
484
|
-
flash(
|
|
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(
|
|
779
|
+
flash(
|
|
780
|
+
t("catalog.products.create.success", "Product created."),
|
|
781
|
+
"success",
|
|
782
|
+
);
|
|
489
783
|
if (inboxDraft) {
|
|
490
|
-
router.push(
|
|
784
|
+
router.push(
|
|
785
|
+
`/backend/inbox-ops/proposals/${encodeURIComponent(inboxDraft.proposalId)}`,
|
|
786
|
+
);
|
|
491
787
|
} else {
|
|
492
|
-
router.push(
|
|
788
|
+
router.push("/backend/catalog/products");
|
|
493
789
|
}
|
|
494
790
|
} catch (err) {
|
|
495
|
-
await cleanupFailedProduct(
|
|
496
|
-
|
|
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(
|
|
506
|
-
|
|
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)}`, {
|
|
510
|
-
|
|
511
|
-
|
|
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)}`, {
|
|
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({
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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">
|
|
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">
|
|
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) =>
|
|
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">
|
|
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) =>
|
|
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">
|
|
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) =>
|
|
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">
|
|
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) =>
|
|
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">
|
|
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) =>
|
|
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">
|
|
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) =>
|
|
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({
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
() =>
|
|
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(
|
|
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[]>>(
|
|
621
|
-
acc
|
|
622
|
-
|
|
623
|
-
|
|
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 =
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
1041
|
+
}, [currentStep, errorSignature, setCurrentStep, stepErrors, steps]);
|
|
1042
|
+
const defaultTaxRateLabel = defaultTaxRate
|
|
1043
|
+
? formatTaxRateLabel(defaultTaxRate)
|
|
1044
|
+
: null;
|
|
645
1045
|
const inventoryDisabledHint = t(
|
|
646
|
-
|
|
647
|
-
|
|
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(
|
|
662
|
-
const hasCurrent = nextItems.some(
|
|
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(
|
|
1068
|
+
const fallbackId = nextItems[0]?.id ?? null;
|
|
1069
|
+
setValue("defaultMediaId", fallbackId);
|
|
666
1070
|
if (fallbackId && nextItems[0]) {
|
|
667
1071
|
setValue(
|
|
668
|
-
|
|
1072
|
+
"defaultMediaUrl",
|
|
669
1073
|
buildAttachmentImageUrl(fallbackId, {
|
|
670
1074
|
slug: slugifyAttachmentFileName(nextItems[0].fileName),
|
|
671
1075
|
}),
|
|
672
|
-
)
|
|
1076
|
+
);
|
|
673
1077
|
} else {
|
|
674
|
-
setValue(
|
|
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(
|
|
1087
|
+
setValue("defaultMediaId", attachmentId);
|
|
684
1088
|
if (!attachmentId) {
|
|
685
|
-
setValue(
|
|
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
|
-
|
|
692
|
-
buildAttachmentImageUrl(target.id, {
|
|
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)
|
|
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(
|
|
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(
|
|
710
|
-
|
|
711
|
-
|
|
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(
|
|
1153
|
+
setValue("variants", nextVariants);
|
|
739
1154
|
}
|
|
740
|
-
}, [
|
|
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(
|
|
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(
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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(
|
|
774
|
-
const next = (Array.isArray(values.variants) ? values.variants : []).map(
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
setValue(
|
|
1222
|
+
isDefault: variant.id === variantId,
|
|
1223
|
+
}),
|
|
1224
|
+
);
|
|
1225
|
+
setValue("variants", next);
|
|
788
1226
|
},
|
|
789
1227
|
[values.variants, setValue],
|
|
790
|
-
)
|
|
791
|
-
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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:
|
|
829
|
-
]
|
|
830
|
-
setValue(
|
|
831
|
-
}, [values.options, setValue])
|
|
832
|
-
|
|
833
|
-
const removeOption = React.useCallback(
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
850
|
-
currentStep === index
|
|
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 ===
|
|
855
|
-
|
|
856
|
-
{step ===
|
|
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
|
|
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 ===
|
|
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(
|
|
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(
|
|
877
|
-
placeholder={t(
|
|
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 ?
|
|
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>
|
|
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(
|
|
1355
|
+
onClick={() => setValue("useMarkdown", !values.useMarkdown)}
|
|
890
1356
|
className="gap-2 text-xs"
|
|
891
1357
|
>
|
|
892
|
-
{values.useMarkdown ?
|
|
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(
|
|
895
|
-
|
|
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
|
|
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(
|
|
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) =>
|
|
912
|
-
|
|
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
|
|
1410
|
+
<ProductDimensionsFields
|
|
1411
|
+
values={values as ProductFormValues}
|
|
1412
|
+
setValue={setValue}
|
|
1413
|
+
/>
|
|
927
1414
|
</div>
|
|
928
1415
|
) : null}
|
|
929
1416
|
|
|
930
|
-
{currentStepKey ===
|
|
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 ===
|
|
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) =>
|
|
1441
|
+
onChange={(event) =>
|
|
1442
|
+
setValue("hasVariants", event.target.checked)
|
|
1443
|
+
}
|
|
946
1444
|
/>
|
|
947
|
-
{t(
|
|
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">
|
|
954
|
-
|
|
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(
|
|
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(
|
|
960
|
-
|
|
961
|
-
<div className="
|
|
962
|
-
<
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
973
|
-
|
|
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(
|
|
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">
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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(
|
|
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 ===
|
|
1011
|
-
? t(
|
|
1012
|
-
|
|
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 ===
|
|
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(
|
|
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">
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
: [
|
|
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(
|
|
1047
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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) =>
|
|
1059
|
-
|
|
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) =>
|
|
1066
|
-
|
|
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) =>
|
|
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(
|
|
1079
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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(
|
|
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(
|
|
1805
|
+
{t("catalog.products.create.steps.previous", "Previous")}
|
|
1161
1806
|
</Button>
|
|
1162
|
-
{currentStepKey !==
|
|
1807
|
+
{currentStepKey !== "variants" ? (
|
|
1163
1808
|
<Button
|
|
1164
1809
|
type="button"
|
|
1165
1810
|
variant="outline"
|
|
1166
1811
|
size="sm"
|
|
1167
|
-
onClick={() =>
|
|
1812
|
+
onClick={() =>
|
|
1813
|
+
setCurrentStep(Math.min(steps.length - 1, currentStep + 1))
|
|
1814
|
+
}
|
|
1168
1815
|
className="gap-2"
|
|
1169
1816
|
>
|
|
1170
|
-
{t(
|
|
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({
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1872
|
+
<Label>{t("catalog.products.form.subtitle", "Subtitle")}</Label>
|
|
1221
1873
|
<Input
|
|
1222
|
-
value={typeof values.subtitle ===
|
|
1223
|
-
onChange={(event) => setValue(
|
|
1224
|
-
placeholder={t(
|
|
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 ?
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1907
|
+
{t(
|
|
1908
|
+
"catalog.products.create.handleHelp",
|
|
1909
|
+
"Handle is used for URLs and must be unique.",
|
|
1910
|
+
)}
|
|
1248
1911
|
</p>
|
|
1249
|
-
{errors.handle ?
|
|
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>
|
|
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 !==
|
|
1261
|
-
window.open(
|
|
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(
|
|
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">
|
|
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) =>
|
|
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(
|
|
1280
|
-
|
|
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(
|
|
1291
|
-
|
|
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 ?
|
|
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
|
}
|