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