@voyantjs/products-ui 0.102.0 → 0.104.0
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/components/product-detail/product-detail-form.d.ts +3 -0
- package/dist/components/product-detail/product-detail-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-detail-form.js +25 -1
- package/dist/components/product-detail/product-detail-page.d.ts.map +1 -1
- package/dist/components/product-detail/product-detail-page.js +1 -2
- package/dist/components/product-detail/product-extra-dialog.d.ts +21 -0
- package/dist/components/product-detail/product-extra-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-extra-dialog.js +131 -0
- package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -1
- package/dist/components/product-detail/product-option-pricing-grid.js +48 -7
- package/dist/components/product-detail/product-options-pricing.d.ts +13 -1
- package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-pricing.js +119 -36
- package/dist/components/product-detail/product-translation-popover.d.ts +4 -1
- package/dist/components/product-detail/product-translation-popover.d.ts.map +1 -1
- package/dist/components/product-detail/product-translation-popover.js +28 -8
- package/package.json +29 -29
- package/dist/components/product-detail/product-extras-section.d.ts +0 -4
- package/dist/components/product-detail/product-extras-section.d.ts.map +0 -1
- package/dist/components/product-detail/product-extras-section.js +0 -141
|
@@ -3,6 +3,9 @@ export type ProductData = {
|
|
|
3
3
|
name: string;
|
|
4
4
|
status: "draft" | "active" | "archived";
|
|
5
5
|
description: string | null;
|
|
6
|
+
inclusionsHtml: string | null;
|
|
7
|
+
exclusionsHtml: string | null;
|
|
8
|
+
termsHtml: string | null;
|
|
6
9
|
bookingMode: "date" | "date_time" | "open" | "stay" | "transfer" | "itinerary" | "other";
|
|
7
10
|
productTypeId: string | null;
|
|
8
11
|
taxClassId: string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-detail-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-form.tsx"],"names":[],"mappings":"AAuCA,MAAM,MAAM,WAAW,GAAG;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAA;IACvC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,CAAA;IACxF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC,CAAA;AAgBD,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,SAAS,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;
|
|
1
|
+
{"version":3,"file":"product-detail-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-form.tsx"],"names":[],"mappings":"AAuCA,MAAM,MAAM,WAAW,GAAG;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAA;IACvC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,CAAA;IACxF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC,CAAA;AAgBD,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,SAAS,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAmCD,wBAAgB,iBAAiB,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CA8ZzF"}
|
|
@@ -20,6 +20,9 @@ function initialValues(product) {
|
|
|
20
20
|
name: product.name,
|
|
21
21
|
status: product.status,
|
|
22
22
|
description: product.description ?? "",
|
|
23
|
+
inclusionsHtml: product.inclusionsHtml ?? "",
|
|
24
|
+
exclusionsHtml: product.exclusionsHtml ?? "",
|
|
25
|
+
termsHtml: product.termsHtml ?? "",
|
|
23
26
|
bookingMode: product.bookingMode,
|
|
24
27
|
productTypeId: product.productTypeId ?? "",
|
|
25
28
|
taxClassId: product.taxClassId ?? "",
|
|
@@ -32,6 +35,9 @@ function initialValues(product) {
|
|
|
32
35
|
name: "",
|
|
33
36
|
status: "draft",
|
|
34
37
|
description: "",
|
|
38
|
+
inclusionsHtml: "",
|
|
39
|
+
exclusionsHtml: "",
|
|
40
|
+
termsHtml: "",
|
|
35
41
|
bookingMode: "itinerary",
|
|
36
42
|
productTypeId: "",
|
|
37
43
|
taxClassId: "",
|
|
@@ -49,6 +55,9 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
49
55
|
name: z.string().min(1, productMessages.validationNameRequired),
|
|
50
56
|
status: z.enum(["draft", "active", "archived"]),
|
|
51
57
|
description: z.string().optional().nullable(),
|
|
58
|
+
inclusionsHtml: z.string().optional().nullable(),
|
|
59
|
+
exclusionsHtml: z.string().optional().nullable(),
|
|
60
|
+
termsHtml: z.string().optional().nullable(),
|
|
52
61
|
bookingMode: z.enum(["date", "date_time", "open", "stay", "transfer", "itinerary", "other"]),
|
|
53
62
|
productTypeId: z.string().optional().nullable(),
|
|
54
63
|
taxClassId: z.string().optional().nullable(),
|
|
@@ -111,6 +120,9 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
111
120
|
name: values.name,
|
|
112
121
|
status: values.status,
|
|
113
122
|
description: values.description || null,
|
|
123
|
+
inclusionsHtml: values.inclusionsHtml || null,
|
|
124
|
+
exclusionsHtml: values.exclusionsHtml || null,
|
|
125
|
+
termsHtml: values.termsHtml || null,
|
|
114
126
|
bookingMode: values.bookingMode,
|
|
115
127
|
productTypeId: values.productTypeId || null,
|
|
116
128
|
taxClassId: values.taxClassId || null,
|
|
@@ -122,6 +134,9 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
122
134
|
defaultLanguageTag: resolvedDefaultLanguage,
|
|
123
135
|
baseName: values.name,
|
|
124
136
|
baseDescription: values.description ?? "",
|
|
137
|
+
baseInclusionsHtml: values.inclusionsHtml ?? "",
|
|
138
|
+
baseExclusionsHtml: values.exclusionsHtml ?? "",
|
|
139
|
+
baseTermsHtml: values.termsHtml ?? "",
|
|
125
140
|
};
|
|
126
141
|
if (isEditing) {
|
|
127
142
|
await api.patch(`/v1/products/${product.id}`, payload);
|
|
@@ -147,7 +162,16 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
147
162
|
}, translations: translations, messages: productMessages, placeholder: productMessages.namePlaceholder, autoFocus: true, error: form.formState.errors.name?.message }), _jsx(TranslatableField, { label: productMessages.descriptionLabel, type: "richtext", field: "description", activeLanguage: activeLanguage, defaultLanguageTag: defaultLanguageTag, base: {
|
|
148
163
|
value: form.watch("description") ?? "",
|
|
149
164
|
onChange: (value) => form.setValue("description", value, { shouldDirty: true }),
|
|
150
|
-
}, translations: translations, messages: productMessages, placeholder: productMessages.descriptionPlaceholder }), _jsx(TranslatableField, { label: productMessages.slugLabel, type: "text", field: "slug", activeLanguage: activeLanguage, defaultLanguageTag: defaultLanguageTag, translations: translations, messages: productMessages, placeholder: productMessages.slugPlaceholder }),
|
|
165
|
+
}, translations: translations, messages: productMessages, placeholder: productMessages.descriptionPlaceholder }), _jsx(TranslatableField, { label: productMessages.slugLabel, type: "text", field: "slug", activeLanguage: activeLanguage, defaultLanguageTag: defaultLanguageTag, translations: translations, messages: productMessages, placeholder: productMessages.slugPlaceholder }), _jsx(TranslatableField, { label: productMessages.inclusionsLabel, type: "richtext", field: "inclusionsHtml", activeLanguage: activeLanguage, defaultLanguageTag: defaultLanguageTag, base: {
|
|
166
|
+
value: form.watch("inclusionsHtml") ?? "",
|
|
167
|
+
onChange: (value) => form.setValue("inclusionsHtml", value, { shouldDirty: true }),
|
|
168
|
+
}, translations: translations, messages: productMessages, placeholder: productMessages.inclusionsPlaceholder }), _jsx(TranslatableField, { label: productMessages.exclusionsLabel, type: "richtext", field: "exclusionsHtml", activeLanguage: activeLanguage, defaultLanguageTag: defaultLanguageTag, base: {
|
|
169
|
+
value: form.watch("exclusionsHtml") ?? "",
|
|
170
|
+
onChange: (value) => form.setValue("exclusionsHtml", value, { shouldDirty: true }),
|
|
171
|
+
}, translations: translations, messages: productMessages, placeholder: productMessages.exclusionsPlaceholder }), _jsx(TranslatableField, { label: productMessages.termsLabel, type: "richtext", field: "termsHtml", activeLanguage: activeLanguage, defaultLanguageTag: defaultLanguageTag, base: {
|
|
172
|
+
value: form.watch("termsHtml") ?? "",
|
|
173
|
+
onChange: (value) => form.setValue("termsHtml", value, { shouldDirty: true }),
|
|
174
|
+
}, translations: translations, messages: productMessages, placeholder: productMessages.termsPlaceholder }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: productMessages.defaultLanguageLabel }), _jsx(LanguageCombobox, { value: form.watch("defaultLanguageTag")?.trim() || adminBaseLocale, onValueChange: (code) => form.setValue("defaultLanguageTag", code, { shouldDirty: true }), placeholder: productMessages.translationLanguageSearch, emptyLabel: productMessages.translationLanguageEmpty }), _jsx("p", { className: "text-xs text-muted-foreground", children: productMessages.defaultLanguageHint })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: productMessages.tagsLabel }), _jsx("div", { className: "flex flex-wrap gap-1.5", children: (form.watch("tags") ?? []).map((tag) => (_jsxs(Badge, { variant: "secondary", className: "gap-1 text-xs", children: [tag, _jsx("button", { type: "button", className: "ml-0.5 rounded-full hover:text-destructive", onClick: () => {
|
|
151
175
|
const current = form.getValues("tags") ?? [];
|
|
152
176
|
form.setValue("tags", current.filter((t) => t !== tag), { shouldDirty: true });
|
|
153
177
|
}, children: _jsx(X, { className: "h-3 w-3" }) })] }, tag))) }), _jsx(Input, { value: tagInput, onChange: (e) => setTagInput(e.target.value), onKeyDown: (e) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-detail-page.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-page.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"product-detail-page.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-page.tsx"],"names":[],"mappings":"AAkCA,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,2CA8MvD"}
|
|
@@ -14,7 +14,6 @@ import { ProductDetailHeader } from "./product-detail-header.js";
|
|
|
14
14
|
import { ProductDetailItinerarySection } from "./product-detail-itinerary-section.js";
|
|
15
15
|
import { ProductBrochureSection, ProductChannelsSection, ProductDeparturesSection, ProductDetailsSection, ProductMediaSection, ProductOrganizeSection, ProductSchedulesSection, } from "./product-detail-sections.js";
|
|
16
16
|
import { ProductDetailSkeleton } from "./product-detail-skeleton.js";
|
|
17
|
-
import { ProductExtrasSection } from "./product-extras-section.js";
|
|
18
17
|
import { ProductMarketRulesSection } from "./product-market-rules-section.js";
|
|
19
18
|
import { PricingPanel } from "./product-options-pricing.js";
|
|
20
19
|
import { deriveOptionPricingLayout, getDeparturePriceOverridesQueryOptions, } from "./product-options-shared.js";
|
|
@@ -82,7 +81,7 @@ export function ProductDetailPage({ id }) {
|
|
|
82
81
|
if (confirm(productMessages.deleteScheduleConfirm)) {
|
|
83
82
|
mutations.deleteRule.mutate(ruleId);
|
|
84
83
|
}
|
|
85
|
-
} }), _jsx(ProductDetailItinerarySection, { productId: id }), _jsx(ProductsUiMessagesProvider, { locale: resolvedLocale, children: _jsx(ProductOptionsSection, { productId: id, renderOptionDetails: (option) => (_jsx(PricingPanel, { productId: id, optionId: option.id, optionName: option.name, productCurrency: product.sellCurrency, layout: deriveOptionPricingLayout(product.bookingMode), extras: renderOptionExtras?.(id, option.id) })) }) }), _jsx(
|
|
84
|
+
} }), _jsx(ProductDetailItinerarySection, { productId: id }), _jsx(ProductsUiMessagesProvider, { locale: resolvedLocale, children: _jsx(ProductOptionsSection, { productId: id, renderOptionDetails: (option) => (_jsx(PricingPanel, { productId: id, optionId: option.id, optionName: option.name, productCurrency: product.sellCurrency, layout: deriveOptionPricingLayout(product.bookingMode), extras: renderOptionExtras?.(id, option.id) })) }) }), _jsx(ProductPaymentPolicySection, { product: product, onSuccess: invalidateProduct }), _jsx(ProductMarketRulesSection, { productId: id })] }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsx(ProductChannelsSection, { allChannels: channels, mappings: mappings, onAddChannel: (channelId) => mutations.addChannelMapping.mutate(channelId), onRemoveChannel: (mappingId) => mutations.removeChannelMapping.mutate(mappingId) }), _jsx(ProductOrganizeSection, { product: product, onEdit: dialogs.edit.openNow }), _jsx(ProductBrochureSection, { brochure: brochure, isGenerating: mutations.generateBrochure.isPending, onGenerate: () => mutations.generateBrochure.mutate() }), _jsx(ProductActivitySection, { productId: id })] })] }), _jsx(ProductDialog, { open: dialogs.edit.open, onOpenChange: dialogs.edit.setOpen, product: product, onSuccess: () => {
|
|
86
85
|
dialogs.edit.close();
|
|
87
86
|
invalidateProduct();
|
|
88
87
|
} }), _jsx(DepartureDialog, { open: dialogs.departure.open, onOpenChange: dialogs.departure.setOpen, productId: id, slot: dialogs.departure.editing, onSuccess: () => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ProductExtraRecord } from "@voyantjs/extras-react";
|
|
2
|
+
import { useProductDetailMessages } from "./host.js";
|
|
3
|
+
type ExtraMessages = ReturnType<typeof useProductDetailMessages>["products"]["operations"]["extras"];
|
|
4
|
+
export declare function getExtraPricingModeLabel(value: ProductExtraRecord["pricingMode"], messages: ExtraMessages): string;
|
|
5
|
+
export interface ProductExtraDialogProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
onOpenChange: (open: boolean) => void;
|
|
8
|
+
productId: string;
|
|
9
|
+
extra?: ProductExtraRecord;
|
|
10
|
+
/** Sort order to use when creating a new extra. */
|
|
11
|
+
nextSortOrder?: number;
|
|
12
|
+
onSuccess: () => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create / edit the *definition* of a product extra (name, selection,
|
|
16
|
+
* pricing mode, quantities). The extra's actual price is set separately, per
|
|
17
|
+
* booking option, via the extra-price-rule editor.
|
|
18
|
+
*/
|
|
19
|
+
export declare function ProductExtraDialog({ open, onOpenChange, productId, extra, nextSortOrder, onSuccess, }: ProductExtraDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=product-extra-dialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-extra-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-extra-dialog.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,kBAAkB,EAA2B,MAAM,wBAAwB,CAAA;AAqBzF,OAAO,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AA0DpD,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC,UAAU,CAAC,CAAC,YAAY,CAAC,CAAC,QAAQ,CAAC,CAAA;AAiBpG,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,kBAAkB,CAAC,aAAa,CAAC,EACxC,QAAQ,EAAE,aAAa,UAkBxB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,kBAAkB,CAAA;IAC1B,mDAAmD;IACnD,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,KAAK,EACL,aAAiB,EACjB,SAAS,GACV,EAAE,uBAAuB,2CA4KzB"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useProductExtraMutation } from "@voyantjs/extras-react";
|
|
3
|
+
import { Button, Checkbox, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@voyantjs/ui/components";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useProductDetailMessages } from "./host.js";
|
|
6
|
+
const selectionTypes = ["optional", "required", "default_selected", "unavailable"];
|
|
7
|
+
const pricingModes = [
|
|
8
|
+
"included",
|
|
9
|
+
"per_person",
|
|
10
|
+
"per_booking",
|
|
11
|
+
"quantity_based",
|
|
12
|
+
"on_request",
|
|
13
|
+
"free",
|
|
14
|
+
];
|
|
15
|
+
const emptyForm = {
|
|
16
|
+
name: "",
|
|
17
|
+
code: "",
|
|
18
|
+
description: "",
|
|
19
|
+
selectionType: "optional",
|
|
20
|
+
pricingMode: "per_booking",
|
|
21
|
+
pricedPerPerson: false,
|
|
22
|
+
minQuantity: "",
|
|
23
|
+
maxQuantity: "",
|
|
24
|
+
defaultQuantity: "",
|
|
25
|
+
active: true,
|
|
26
|
+
};
|
|
27
|
+
function formFromExtra(extra) {
|
|
28
|
+
return {
|
|
29
|
+
name: extra.name,
|
|
30
|
+
code: extra.code ?? "",
|
|
31
|
+
description: extra.description ?? "",
|
|
32
|
+
selectionType: extra.selectionType,
|
|
33
|
+
pricingMode: extra.pricingMode,
|
|
34
|
+
pricedPerPerson: extra.pricedPerPerson,
|
|
35
|
+
minQuantity: extra.minQuantity == null ? "" : String(extra.minQuantity),
|
|
36
|
+
maxQuantity: extra.maxQuantity == null ? "" : String(extra.maxQuantity),
|
|
37
|
+
defaultQuantity: extra.defaultQuantity == null ? "" : String(extra.defaultQuantity),
|
|
38
|
+
active: extra.active,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function parseNullableInt(value) {
|
|
42
|
+
const parsed = Number.parseInt(value, 10);
|
|
43
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
44
|
+
}
|
|
45
|
+
function getSelectionTypeLabel(value, messages) {
|
|
46
|
+
switch (value) {
|
|
47
|
+
case "optional":
|
|
48
|
+
return messages.selectionOptional;
|
|
49
|
+
case "required":
|
|
50
|
+
return messages.selectionRequired;
|
|
51
|
+
case "default_selected":
|
|
52
|
+
return messages.selectionDefaultSelected;
|
|
53
|
+
case "unavailable":
|
|
54
|
+
return messages.selectionUnavailable;
|
|
55
|
+
default:
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function getExtraPricingModeLabel(value, messages) {
|
|
60
|
+
switch (value) {
|
|
61
|
+
case "included":
|
|
62
|
+
return messages.pricingIncluded;
|
|
63
|
+
case "per_person":
|
|
64
|
+
return messages.pricingPerPerson;
|
|
65
|
+
case "per_booking":
|
|
66
|
+
return messages.pricingPerBooking;
|
|
67
|
+
case "quantity_based":
|
|
68
|
+
return messages.pricingQuantityBased;
|
|
69
|
+
case "on_request":
|
|
70
|
+
return messages.pricingOnRequest;
|
|
71
|
+
case "free":
|
|
72
|
+
return messages.pricingFree;
|
|
73
|
+
default:
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Create / edit the *definition* of a product extra (name, selection,
|
|
79
|
+
* pricing mode, quantities). The extra's actual price is set separately, per
|
|
80
|
+
* booking option, via the extra-price-rule editor.
|
|
81
|
+
*/
|
|
82
|
+
export function ProductExtraDialog({ open, onOpenChange, productId, extra, nextSortOrder = 0, onSuccess, }) {
|
|
83
|
+
const messages = useProductDetailMessages();
|
|
84
|
+
const extraMessages = messages.products.operations.extras;
|
|
85
|
+
const { create, update } = useProductExtraMutation();
|
|
86
|
+
const [form, setForm] = React.useState(emptyForm);
|
|
87
|
+
const isEditing = !!extra;
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
if (open)
|
|
90
|
+
setForm(extra ? formFromExtra(extra) : emptyForm);
|
|
91
|
+
}, [open, extra]);
|
|
92
|
+
const save = async () => {
|
|
93
|
+
const payload = {
|
|
94
|
+
productId,
|
|
95
|
+
name: form.name.trim(),
|
|
96
|
+
code: form.code.trim() || null,
|
|
97
|
+
description: form.description.trim() || null,
|
|
98
|
+
selectionType: form.selectionType,
|
|
99
|
+
pricingMode: form.pricingMode,
|
|
100
|
+
pricedPerPerson: form.pricedPerPerson,
|
|
101
|
+
minQuantity: parseNullableInt(form.minQuantity),
|
|
102
|
+
maxQuantity: parseNullableInt(form.maxQuantity),
|
|
103
|
+
defaultQuantity: parseNullableInt(form.defaultQuantity),
|
|
104
|
+
active: form.active,
|
|
105
|
+
sortOrder: extra?.sortOrder ?? nextSortOrder,
|
|
106
|
+
};
|
|
107
|
+
if (!payload.name)
|
|
108
|
+
return;
|
|
109
|
+
if (extra)
|
|
110
|
+
await update.mutateAsync({ id: extra.id, input: payload });
|
|
111
|
+
else
|
|
112
|
+
await create.mutateAsync(payload);
|
|
113
|
+
onSuccess();
|
|
114
|
+
};
|
|
115
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { size: "lg", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEditing ? extraMessages.editTitle : extraMessages.newTitle }), _jsx(DialogDescription, { children: extraMessages.dialogDescription })] }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-3 md:grid-cols-2", children: [_jsx(Field, { label: extraMessages.nameLabel, children: _jsx(Input, { value: form.name, onChange: (event) => setForm({ ...form, name: event.target.value }) }) }), _jsx(Field, { label: extraMessages.codeLabel, children: _jsx(Input, { value: form.code, onChange: (event) => setForm({ ...form, code: event.target.value }) }) })] }), _jsx(Field, { label: extraMessages.descriptionLabel, children: _jsx(Textarea, { value: form.description, onChange: (event) => setForm({ ...form, description: event.target.value }) }) }), _jsxs("div", { className: "grid gap-3 md:grid-cols-3", children: [_jsx(Field, { label: extraMessages.selectionLabel, children: _jsxs(Select, { value: form.selectionType, onValueChange: (value) => setForm({
|
|
116
|
+
...form,
|
|
117
|
+
selectionType: (value ?? "optional"),
|
|
118
|
+
}), items: selectionTypes.map((type) => ({
|
|
119
|
+
value: type,
|
|
120
|
+
label: getSelectionTypeLabel(type, extraMessages),
|
|
121
|
+
})), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: selectionTypes.map((type) => (_jsx(SelectItem, { value: type, children: getSelectionTypeLabel(type, extraMessages) }, type))) })] }) }), _jsx(Field, { label: extraMessages.pricingLabel, children: _jsxs(Select, { value: form.pricingMode, onValueChange: (value) => setForm({
|
|
122
|
+
...form,
|
|
123
|
+
pricingMode: (value ?? "per_booking"),
|
|
124
|
+
}), items: pricingModes.map((mode) => ({
|
|
125
|
+
value: mode,
|
|
126
|
+
label: getExtraPricingModeLabel(mode, extraMessages),
|
|
127
|
+
})), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: pricingModes.map((mode) => (_jsx(SelectItem, { value: mode, children: getExtraPricingModeLabel(mode, extraMessages) }, mode))) })] }) }), _jsx(Field, { label: extraMessages.defaultQuantityLabel, children: _jsx(Input, { value: form.defaultQuantity, type: "number", min: "0", onChange: (event) => setForm({ ...form, defaultQuantity: event.target.value }) }) }), _jsx(Field, { label: extraMessages.minQuantityLabel, children: _jsx(Input, { value: form.minQuantity, type: "number", min: "0", onChange: (event) => setForm({ ...form, minQuantity: event.target.value }) }) }), _jsx(Field, { label: extraMessages.maxQuantityLabel, children: _jsx(Input, { value: form.maxQuantity, type: "number", min: "0", onChange: (event) => setForm({ ...form, maxQuantity: event.target.value }) }) })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "product-extra-priced-per-person", checked: form.pricedPerPerson, onCheckedChange: (checked) => setForm({ ...form, pricedPerPerson: checked === true }) }), _jsx(Label, { htmlFor: "product-extra-priced-per-person", children: extraMessages.perTravelerLabel })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "product-extra-active", checked: form.active, onCheckedChange: (checked) => setForm({ ...form, active: checked === true }) }), _jsx(Label, { htmlFor: "product-extra-active", children: extraMessages.activeLabel })] })] })] }), _jsxs(DialogFooter, { className: "-mx-6 -mb-6", children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: extraMessages.cancel }), _jsx(Button, { onClick: () => void save(), disabled: !form.name.trim(), children: isEditing ? extraMessages.saveChanges : extraMessages.create })] })] }) }));
|
|
128
|
+
}
|
|
129
|
+
function Field({ label, children }) {
|
|
130
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: label }), children] }));
|
|
131
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-option-pricing-grid.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-option-pricing-grid.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"product-option-pricing-grid.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-option-pricing-grid.tsx"],"names":[],"mappings":"AAyBA,OAAO,EAML,KAAK,mBAAmB,EACzB,MAAM,6BAA6B,CAAA;AA8BpC,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,MAAM,EAAE,mBAAmB,CAAA;CAC5B;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,QAAQ,EACR,UAAU,EACV,eAAe,EACf,MAAM,GACP,EAAE,sBAAsB,2CAybxB"}
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { formatMessage } from "@voyantjs/i18n";
|
|
5
|
-
import { useOptionPriceRuleMutation, useOptionUnitPriceRuleMutation, usePriceCatalogMutation, } from "@voyantjs/pricing-react";
|
|
5
|
+
import { useOptionPriceRuleMutation, useOptionUnitPriceRuleMutation, usePriceCatalogMutation, usePricingCategoryMutation, } from "@voyantjs/pricing-react";
|
|
6
6
|
import { useOptionUnitMutation, useVoyantProductsContext } from "@voyantjs/products-react";
|
|
7
7
|
import { Button } from "@voyantjs/ui/components/button";
|
|
8
8
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
9
9
|
import { useState } from "react";
|
|
10
10
|
import { useProductDetailMessages } from "./host.js";
|
|
11
|
-
import { categoryAppliesToUnit, formatProductMoney, getCategoryCondition, TravelerCategoryDialog, } from "./product-options-pricing.js";
|
|
11
|
+
import { categoryAppliesToUnit, ExtraPriceRulesPanel, formatProductMoney, getCategoryCondition, isTravelerCategory, TravelerCategoryDialog, } from "./product-options-pricing.js";
|
|
12
12
|
import { getOptionPriceRulesQueryOptions, getOptionUnitPriceRulesQueryOptions, getOptionUnitsQueryOptions, getPriceCatalogsQueryOptions, getPricingCategoriesQueryOptions, } from "./product-options-shared.js";
|
|
13
13
|
import { UnitDialog } from "./product-unit-dialog.js";
|
|
14
14
|
import { UnitPriceRuleDialog, } from "./product-unit-price-rule-dialog.js";
|
|
@@ -38,6 +38,7 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
38
38
|
const client = useVoyantProductsContext();
|
|
39
39
|
const messages = useProductDetailMessages();
|
|
40
40
|
const t = messages.products.operations.pricingGrid;
|
|
41
|
+
const priceRuleMessages = messages.products.operations.priceRules;
|
|
41
42
|
const { data: unitsData, refetch: refetchUnits } = useQuery(getOptionUnitsQueryOptions(client, optionId));
|
|
42
43
|
const { data: rulesData, refetch: refetchRules } = useQuery(getOptionPriceRulesQueryOptions(client, optionId));
|
|
43
44
|
const { data: categoriesData, refetch: refetchCategories } = useQuery(getPricingCategoriesQueryOptions(client));
|
|
@@ -52,6 +53,7 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
52
53
|
const { remove: removeCell } = useOptionUnitPriceRuleMutation();
|
|
53
54
|
const { create: createRule } = useOptionPriceRuleMutation();
|
|
54
55
|
const { create: createCatalog } = usePriceCatalogMutation();
|
|
56
|
+
const { remove: removeCategory } = usePricingCategoryMutation();
|
|
55
57
|
const deleteUnitMutation = useMutation({
|
|
56
58
|
mutationFn: (id) => removeUnit.mutateAsync(id),
|
|
57
59
|
onSuccess: () => {
|
|
@@ -67,6 +69,7 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
67
69
|
const [editingUnit, setEditingUnit] = useState();
|
|
68
70
|
const [defaultUnitType, setDefaultUnitType] = useState("room");
|
|
69
71
|
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
|
72
|
+
const [editingCategory, setEditingCategory] = useState();
|
|
70
73
|
const [cellDialogOpen, setCellDialogOpen] = useState(false);
|
|
71
74
|
const [cellRuleId, setCellRuleId] = useState();
|
|
72
75
|
const [editingCell, setEditingCell] = useState();
|
|
@@ -91,7 +94,8 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
91
94
|
const referencedCategoryIds = new Set(cells.flatMap((cell) => (cell.pricingCategoryId ? [cell.pricingCategoryId] : [])));
|
|
92
95
|
const categories = (categoriesData?.data ?? [])
|
|
93
96
|
.filter((category) => category.active &&
|
|
94
|
-
(((category
|
|
97
|
+
((isTravelerCategory(category) &&
|
|
98
|
+
(category.productId == null || category.productId === productId) &&
|
|
95
99
|
(category.optionId == null || category.optionId === optionId)) ||
|
|
96
100
|
referencedCategoryIds.has(category.id)))
|
|
97
101
|
.slice()
|
|
@@ -151,10 +155,42 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
151
155
|
setDefaultUnitType(effectiveLayout === "rooms" ? "room" : "person");
|
|
152
156
|
setUnitDialogOpen(true);
|
|
153
157
|
};
|
|
158
|
+
const editTravelerType = (category) => {
|
|
159
|
+
setEditingCategory(category);
|
|
160
|
+
setCategoryDialogOpen(true);
|
|
161
|
+
};
|
|
162
|
+
async function removeTravelerType(category) {
|
|
163
|
+
if (!confirm(formatMessage(messages.products.operations.priceRules.travelerCategoryDeleteConfirm, {
|
|
164
|
+
name: category.name,
|
|
165
|
+
}))) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Categories this product/option owns are deleted outright. A global
|
|
169
|
+
// category only shows here because some cell references it, so removing
|
|
170
|
+
// its prices drops the column from this option without touching the
|
|
171
|
+
// shared category.
|
|
172
|
+
if (category.productId === productId || category.optionId === optionId) {
|
|
173
|
+
await removeCategory.mutateAsync(category.id);
|
|
174
|
+
void refetchCategories();
|
|
175
|
+
void refetchCells();
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
for (const cell of cells.filter((entry) => entry.pricingCategoryId === category.id)) {
|
|
179
|
+
await removeCell.mutateAsync(cell.id);
|
|
180
|
+
}
|
|
181
|
+
void refetchCells();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
154
184
|
const unitColumnLabel = effectiveLayout === "rooms" ? t.roomColumn : t.travelerColumn;
|
|
155
|
-
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: effectiveLayout === "rooms" ? t.roomsTitle : t.seatsTitle }), _jsx("p", { className: "text-xs text-muted-foreground", children: effectiveLayout === "rooms" ? t.roomsDescription : t.seatsDescription })] }), _jsxs("div", { className: "flex items-center gap-2", children: [effectiveLayout === "rooms" ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () =>
|
|
185
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: effectiveLayout === "rooms" ? t.roomsTitle : t.seatsTitle }), _jsx("p", { className: "text-xs text-muted-foreground", children: effectiveLayout === "rooms" ? t.roomsDescription : t.seatsDescription })] }), _jsxs("div", { className: "flex items-center gap-2", children: [effectiveLayout === "rooms" ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
|
|
186
|
+
setEditingCategory(undefined);
|
|
187
|
+
setCategoryDialogOpen(true);
|
|
188
|
+
}, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), t.addTravelerType] })) : null, _jsxs(Button, { variant: "outline", size: "sm", onClick: addRoomOrTraveler, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), effectiveLayout === "rooms" ? t.addRoom : t.addTravelerType] })] })] }), units.length === 0 ? (_jsx("p", { className: "rounded-md border bg-background px-3 py-6 text-center text-sm text-muted-foreground", children: effectiveLayout === "rooms" ? t.emptyRooms : t.emptySeats })) : (_jsx("div", { className: "overflow-x-auto rounded-md border bg-background", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b bg-muted/40 text-muted-foreground", children: [_jsx("th", { className: "p-2.5 text-left font-medium", children: unitColumnLabel }), _jsx("th", { className: "p-2.5 text-left font-medium", children: t.availableColumn }), columns.map((column) => {
|
|
156
189
|
const condition = getCategoryCondition(column.metadata);
|
|
157
|
-
|
|
190
|
+
const category = column.id
|
|
191
|
+
? categories.find((entry) => entry.id === column.id)
|
|
192
|
+
: undefined;
|
|
193
|
+
return (_jsx("th", { className: "group p-2.5 text-left font-medium", children: _jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsxs("div", { children: [_jsx("div", { children: column.name }), condition ? (_jsx("div", { className: "mt-0.5 max-w-[220px] text-[10px] font-normal normal-case leading-snug text-muted-foreground", children: condition })) : null] }), category ? (_jsxs("div", { className: "flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100", children: [_jsx("button", { type: "button", "aria-label": priceRuleMessages.travelerCategoryEdit, onClick: () => editTravelerType(category), className: "text-muted-foreground hover:text-foreground", children: _jsx(Pencil, { className: "h-3 w-3" }) }), _jsx("button", { type: "button", "aria-label": priceRuleMessages.travelerCategoryDelete, onClick: () => void removeTravelerType(category), className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3 w-3" }) })] })) : null] }) }, column.id ?? "__base__"));
|
|
158
194
|
}), _jsx("th", { className: "w-[72px] p-2.5 text-right font-medium" })] }) }), _jsx("tbody", { children: units.map((unit) => {
|
|
159
195
|
const subtitle = unitSubtitle(unit, effectiveLayout, t);
|
|
160
196
|
return (_jsxs("tr", { className: "border-b last:border-b-0", children: [_jsxs("td", { className: "p-2.5", children: [_jsx("div", { className: "font-medium", children: unit.name }), subtitle ? (_jsx("div", { className: "text-[11px] text-muted-foreground", children: subtitle })) : null] }), _jsx("td", { className: "p-2.5 text-muted-foreground", children: formatAvailability(unit, t) }), columns.map((column) => {
|
|
@@ -176,12 +212,17 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
176
212
|
deleteUnitMutation.mutate(unit.id);
|
|
177
213
|
}
|
|
178
214
|
}, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }) })] }, unit.id));
|
|
179
|
-
}) })] }) })), _jsx(UnitDialog, { open: unitDialogOpen, onOpenChange: setUnitDialogOpen, optionId: optionId, unit: editingUnit, defaultUnitType: editingUnit ? undefined : defaultUnitType, lockUnitType: true, nextSortOrder: nextUnitSortOrder, onSuccess: () => {
|
|
215
|
+
}) })] }) })), _jsx(ExtraPriceRulesPanel, { productId: productId, optionId: optionId, optionPriceRuleId: defaultRule?.id, ensureOptionPriceRuleId: ensureRatePlanId, productCurrency: productCurrency }), _jsx(UnitDialog, { open: unitDialogOpen, onOpenChange: setUnitDialogOpen, optionId: optionId, unit: editingUnit, defaultUnitType: editingUnit ? undefined : defaultUnitType, lockUnitType: true, nextSortOrder: nextUnitSortOrder, onSuccess: () => {
|
|
180
216
|
setUnitDialogOpen(false);
|
|
181
217
|
setEditingUnit(undefined);
|
|
182
218
|
void refetchUnits();
|
|
183
|
-
} }), _jsx(TravelerCategoryDialog, { open: categoryDialogOpen, onOpenChange:
|
|
219
|
+
} }), _jsx(TravelerCategoryDialog, { open: categoryDialogOpen, onOpenChange: (open) => {
|
|
220
|
+
setCategoryDialogOpen(open);
|
|
221
|
+
if (!open)
|
|
222
|
+
setEditingCategory(undefined);
|
|
223
|
+
}, productId: productId, units: units, category: editingCategory, nextSortOrder: categories.length > 0 ? Math.max(...categories.map((c) => c.sortOrder)) + 1 : 0, onSuccess: () => {
|
|
184
224
|
setCategoryDialogOpen(false);
|
|
225
|
+
setEditingCategory(undefined);
|
|
185
226
|
void refetchCategories();
|
|
186
227
|
} }), _jsx(UnitPriceRuleDialog, { open: cellDialogOpen, onOpenChange: setCellDialogOpen, optionPriceRuleId: cellRuleId ?? defaultRule?.id ?? "", optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
|
|
187
228
|
setCellDialogOpen(false);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { type PricingCategoryRecord } from "@voyantjs/pricing-react";
|
|
1
2
|
import type * as React from "react";
|
|
2
3
|
import { useProductDetailMessages } from "./host.js";
|
|
3
4
|
import { type OptionPricingLayout } from "./product-options-shared.js";
|
|
4
5
|
import type { OptionUnitData } from "./product-unit-dialog.js";
|
|
5
6
|
export declare function getUnitTypeLabel(type: OptionUnitData["unitType"], messages: ReturnType<typeof useProductDetailMessages>["products"]["operations"]["units"]): string;
|
|
7
|
+
export declare function isTravelerCategory(category: {
|
|
8
|
+
categoryType: PricingCategoryRecord["categoryType"];
|
|
9
|
+
}): boolean;
|
|
6
10
|
export declare function getCategoryCondition(metadata: Record<string, unknown> | null | undefined): string | null;
|
|
7
11
|
export declare function categoryAppliesToUnit(category: {
|
|
8
12
|
id: string | null;
|
|
@@ -22,13 +26,21 @@ export declare function PricingPanel({ productId, optionId, optionName, productC
|
|
|
22
26
|
layout: OptionPricingLayout;
|
|
23
27
|
extras?: React.ReactNode;
|
|
24
28
|
}): import("react/jsx-runtime").JSX.Element;
|
|
25
|
-
export declare function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }: {
|
|
29
|
+
export declare function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, category, onSuccess, }: {
|
|
26
30
|
open: boolean;
|
|
27
31
|
onOpenChange: (open: boolean) => void;
|
|
28
32
|
productId: string;
|
|
29
33
|
units: OptionUnitData[];
|
|
30
34
|
nextSortOrder: number;
|
|
35
|
+
category?: PricingCategoryRecord;
|
|
31
36
|
onSuccess: () => void;
|
|
32
37
|
}): import("react/jsx-runtime").JSX.Element;
|
|
38
|
+
export declare function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, ensureOptionPriceRuleId, productCurrency, }: {
|
|
39
|
+
productId: string;
|
|
40
|
+
optionId: string;
|
|
41
|
+
optionPriceRuleId?: string;
|
|
42
|
+
ensureOptionPriceRuleId?: () => Promise<string>;
|
|
43
|
+
productCurrency: string;
|
|
44
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
33
45
|
export declare function formatProductMoney(amountCents: number | null | undefined, currency: string): string;
|
|
34
46
|
//# sourceMappingURL=product-options-pricing.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-options-pricing.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-pricing.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"product-options-pricing.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-pricing.tsx"],"names":[],"mappings":"AAOA,OAAO,EAEL,KAAK,qBAAqB,EAM3B,MAAM,yBAAyB,CAAA;AA4BhC,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAEnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAOpD,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,6BAA6B,CAAA;AACpC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AA0B9D,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,QAAQ,EAAE,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC,UAAU,CAAC,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,UAkBzF;AAcD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE;IAC3C,YAAY,EAAE,qBAAqB,CAAC,cAAc,CAAC,CAAA;CACpD,WAEA;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,iBAGxF;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE;IAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CAAE,EAC1E,IAAI,EAAE,cAAc,WAMrB;AAeD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,QAAQ,EACR,UAAU,EACV,eAAe,EACf,MAAM,EACN,MAAM,GACP,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,MAAM,EAAE,mBAAmB,CAAA;IAC3B,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CACzB,2CA+CA;AAydD,wBAAgB,sBAAsB,CAAC,EACrC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,KAAK,EACL,aAAa,EACb,QAAQ,EACR,SAAS,GACV,EAAE;IACD,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,qBAAqB,CAAA;IAChC,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,2CAiOA;AAED,wBAAgB,oBAAoB,CAAC,EACnC,SAAS,EACT,QAAQ,EACR,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAIhB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,uBAAuB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IAC/C,eAAe,EAAE,MAAM,CAAA;CACxB,2CAwJA;AAkID,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,UAG1F"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
-
import { useProductExtras } from "@voyantjs/extras-react";
|
|
3
|
+
import { useProductExtraMutation, useProductExtras, } from "@voyantjs/extras-react";
|
|
4
4
|
import { formatMessage } from "@voyantjs/i18n";
|
|
5
5
|
import { useExtraPriceRuleMutation, useExtraPriceRules, useOptionPriceRuleMutation, useOptionUnitPriceRuleMutation, usePricingCategoryMutation, } from "@voyantjs/pricing-react";
|
|
6
6
|
import { useVoyantProductsContext } from "@voyantjs/products-react";
|
|
@@ -9,6 +9,7 @@ import { Checkbox } from "@voyantjs/ui/components/checkbox";
|
|
|
9
9
|
import { ChevronDown, ChevronRight, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
|
|
10
10
|
import { useEffect, useState } from "react";
|
|
11
11
|
import { useProductDetailMessages } from "./host.js";
|
|
12
|
+
import { getExtraPricingModeLabel, ProductExtraDialog } from "./product-extra-dialog.js";
|
|
12
13
|
import { OptionPriceRuleDialog, } from "./product-option-price-rule-dialog.js";
|
|
13
14
|
import { OptionPricingGrid } from "./product-option-pricing-grid.js";
|
|
14
15
|
import { getOptionPriceRulesQueryOptions, getOptionUnitPriceRulesQueryOptions, getOptionUnitsQueryOptions, getPricingCategoriesQueryOptions, } from "./product-options-shared.js";
|
|
@@ -47,6 +48,20 @@ export function getUnitTypeLabel(type, messages) {
|
|
|
47
48
|
return type;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
// Pricing categories that describe the *unit* dimension (room/vehicle — already
|
|
52
|
+
// the grid's rows) or a standalone add-on (`service`, handled by the extras
|
|
53
|
+
// panel) are not per-traveler price columns. Excluding them stops a product
|
|
54
|
+
// whose data carries such categories — e.g. legacy data migrated with a
|
|
55
|
+
// "Double room" pricing category alongside the real Adult/Child split — from
|
|
56
|
+
// rendering one bogus price column per room next to the traveler columns.
|
|
57
|
+
const NON_TRAVELER_CATEGORY_TYPES = new Set([
|
|
58
|
+
"room",
|
|
59
|
+
"vehicle",
|
|
60
|
+
"service",
|
|
61
|
+
]);
|
|
62
|
+
export function isTravelerCategory(category) {
|
|
63
|
+
return !NON_TRAVELER_CATEGORY_TYPES.has(category.categoryType);
|
|
64
|
+
}
|
|
50
65
|
export function getCategoryCondition(metadata) {
|
|
51
66
|
const condition = metadata?.condition;
|
|
52
67
|
return typeof condition === "string" && condition.trim().length > 0 ? condition : null;
|
|
@@ -139,7 +154,8 @@ function UnitPriceMatrix({ productId, optionPriceRuleId, optionId, pricingMode,
|
|
|
139
154
|
const cells = cellsData?.data ?? [];
|
|
140
155
|
const referencedCategoryIds = new Set(cells.flatMap((cell) => (cell.pricingCategoryId ? [cell.pricingCategoryId] : [])));
|
|
141
156
|
const categories = (categoriesData?.data ?? []).filter((category) => category.active &&
|
|
142
|
-
(((category
|
|
157
|
+
((isTravelerCategory(category) &&
|
|
158
|
+
(category.productId == null || category.productId === productId) &&
|
|
143
159
|
(category.optionId == null || category.optionId === optionId)) ||
|
|
144
160
|
referencedCategoryIds.has(category.id)));
|
|
145
161
|
const isPersonOnly = units.length > 0 && units.every((unit) => unit.unitType === "person");
|
|
@@ -217,6 +233,21 @@ function initialTravelerCategoryState() {
|
|
|
217
233
|
allowedUnitIds: [],
|
|
218
234
|
};
|
|
219
235
|
}
|
|
236
|
+
function stateFromCategory(category) {
|
|
237
|
+
const metadata = category.metadata ?? {};
|
|
238
|
+
const allowedUnitIds = Array.isArray(metadata.allowedUnitIds)
|
|
239
|
+
? metadata.allowedUnitIds.filter((id) => typeof id === "string")
|
|
240
|
+
: [];
|
|
241
|
+
return {
|
|
242
|
+
name: category.name,
|
|
243
|
+
code: category.code ?? "",
|
|
244
|
+
categoryType: category.categoryType,
|
|
245
|
+
minAge: category.minAge != null ? String(category.minAge) : "",
|
|
246
|
+
maxAge: category.maxAge != null ? String(category.maxAge) : "",
|
|
247
|
+
condition: typeof metadata.condition === "string" ? metadata.condition : "",
|
|
248
|
+
allowedUnitIds,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
220
251
|
function parseOptionalInteger(value) {
|
|
221
252
|
const trimmed = value.trim();
|
|
222
253
|
if (!trimmed)
|
|
@@ -224,11 +255,12 @@ function parseOptionalInteger(value) {
|
|
|
224
255
|
const parsed = Number(trimmed);
|
|
225
256
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
|
226
257
|
}
|
|
227
|
-
export function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }) {
|
|
258
|
+
export function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, category, onSuccess, }) {
|
|
228
259
|
const messages = useProductDetailMessages();
|
|
229
260
|
const priceRuleMessages = messages.products.operations.priceRules;
|
|
230
261
|
const pricingCategoryMessages = messages.pricing.categories;
|
|
231
|
-
const { create } = usePricingCategoryMutation();
|
|
262
|
+
const { create, update } = usePricingCategoryMutation();
|
|
263
|
+
const isEditing = !!category;
|
|
232
264
|
const [state, setState] = useState(() => initialTravelerCategoryState());
|
|
233
265
|
const [error, setError] = useState(null);
|
|
234
266
|
const travelerCategoryTypes = [
|
|
@@ -241,10 +273,10 @@ export function TravelerCategoryDialog({ open, onOpenChange, productId, units, n
|
|
|
241
273
|
];
|
|
242
274
|
useEffect(() => {
|
|
243
275
|
if (open) {
|
|
244
|
-
setState(initialTravelerCategoryState());
|
|
276
|
+
setState(category ? stateFromCategory(category) : initialTravelerCategoryState());
|
|
245
277
|
setError(null);
|
|
246
278
|
}
|
|
247
|
-
}, [open]);
|
|
279
|
+
}, [open, category]);
|
|
248
280
|
const toggleUnit = (unitId, checked) => {
|
|
249
281
|
setState((prev) => ({
|
|
250
282
|
...prev,
|
|
@@ -271,59 +303,110 @@ export function TravelerCategoryDialog({ open, onOpenChange, productId, units, n
|
|
|
271
303
|
metadata.allowedUnitCodes = selectedUnits.map((unit) => unit.code).filter(Boolean);
|
|
272
304
|
metadata.allowedUnitNames = selectedUnits.map((unit) => unit.name);
|
|
273
305
|
}
|
|
306
|
+
const payload = {
|
|
307
|
+
// On edit, preserve the category's existing scope — re-stamping a shared
|
|
308
|
+
// (global) category with this product's id would silently steal it from
|
|
309
|
+
// every other product that relies on it. Only a freshly created category
|
|
310
|
+
// is scoped to the current product.
|
|
311
|
+
productId: category ? (category.productId ?? null) : productId,
|
|
312
|
+
optionId: category ? (category.optionId ?? null) : null,
|
|
313
|
+
unitId: null,
|
|
314
|
+
name,
|
|
315
|
+
code: state.code.trim() || null,
|
|
316
|
+
categoryType: state.categoryType,
|
|
317
|
+
seatOccupancy: 1,
|
|
318
|
+
isAgeQualified: minAge != null || maxAge != null,
|
|
319
|
+
minAge,
|
|
320
|
+
maxAge,
|
|
321
|
+
internalUseOnly: false,
|
|
322
|
+
active: true,
|
|
323
|
+
sortOrder: category?.sortOrder ?? nextSortOrder,
|
|
324
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
|
325
|
+
};
|
|
274
326
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
categoryType: state.categoryType,
|
|
282
|
-
seatOccupancy: 1,
|
|
283
|
-
isAgeQualified: minAge != null || maxAge != null,
|
|
284
|
-
minAge,
|
|
285
|
-
maxAge,
|
|
286
|
-
internalUseOnly: false,
|
|
287
|
-
active: true,
|
|
288
|
-
sortOrder: nextSortOrder,
|
|
289
|
-
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
|
290
|
-
});
|
|
327
|
+
if (category) {
|
|
328
|
+
await update.mutateAsync({ id: category.id, input: payload });
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
await create.mutateAsync(payload);
|
|
332
|
+
}
|
|
291
333
|
onSuccess();
|
|
292
334
|
}
|
|
293
335
|
catch (err) {
|
|
294
336
|
setError(err instanceof Error ? err.message : priceRuleMessages.travelerCategorySaveFailed);
|
|
295
337
|
}
|
|
296
338
|
};
|
|
297
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children:
|
|
339
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEditing
|
|
340
|
+
? priceRuleMessages.travelerCategoryEditTitle
|
|
341
|
+
: priceRuleMessages.travelerCategoryDialogTitle }), _jsx(DialogDescription, { children: priceRuleMessages.travelerCategoryDialogDescription })] }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-name", children: pricingCategoryMessages.nameLabel }), _jsx(Input, { id: "traveler-category-name", autoFocus: true, value: state.name, placeholder: priceRuleMessages.travelerCategoryNamePlaceholder, onChange: (event) => setState((prev) => ({ ...prev, name: event.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-code", children: pricingCategoryMessages.codeLabel }), _jsx(Input, { id: "traveler-category-code", value: state.code, placeholder: priceRuleMessages.travelerCategoryCodePlaceholder, onChange: (event) => setState((prev) => ({ ...prev, code: event.target.value })) })] })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: pricingCategoryMessages.typeLabel }), _jsxs(Select, { value: state.categoryType, onValueChange: (value) => setState((prev) => ({
|
|
298
342
|
...prev,
|
|
299
343
|
categoryType: (value ?? "child"),
|
|
300
344
|
})), items: travelerCategoryTypes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: travelerCategoryTypes.map((type) => (_jsx(SelectItem, { value: type.value, children: type.label }, type.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-min-age", children: pricingCategoryMessages.minAgeLabel }), _jsx(Input, { id: "traveler-category-min-age", type: "number", min: "0", value: state.minAge, onChange: (event) => setState((prev) => ({ ...prev, minAge: event.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-max-age", children: pricingCategoryMessages.maxAgeLabel }), _jsx(Input, { id: "traveler-category-max-age", type: "number", min: "0", value: state.maxAge, onChange: (event) => setState((prev) => ({ ...prev, maxAge: event.target.value })) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: priceRuleMessages.travelerCategoryAppliesToLabel }), _jsx("div", { className: "grid gap-2 rounded border p-3 sm:grid-cols-3", children: units.map((unit) => {
|
|
301
345
|
const checkboxId = `traveler-category-unit-${unit.id}`;
|
|
302
346
|
return (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: checkboxId, checked: state.allowedUnitIds.includes(unit.id), onCheckedChange: (checked) => toggleUnit(unit.id, checked === true) }), _jsx(Label, { htmlFor: checkboxId, className: "font-normal", children: unit.name })] }, unit.id));
|
|
303
|
-
}) }), _jsx("p", { className: "text-muted-foreground text-xs", children: priceRuleMessages.travelerCategoryAppliesToHint })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-condition", children: priceRuleMessages.travelerCategoryConditionLabel }), _jsx(Textarea, { id: "traveler-category-condition", value: state.condition, placeholder: priceRuleMessages.travelerCategoryConditionPlaceholder, onChange: (event) => setState((prev) => ({ ...prev, condition: event.target.value })) })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null] }), _jsxs(DialogFooter, { className: "-mx-6 -mb-6", children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: pricingCategoryMessages.cancel }), _jsx(Button, { onClick: () => void save(), disabled: create.isPending, children:
|
|
347
|
+
}) }), _jsx("p", { className: "text-muted-foreground text-xs", children: priceRuleMessages.travelerCategoryAppliesToHint })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-condition", children: priceRuleMessages.travelerCategoryConditionLabel }), _jsx(Textarea, { id: "traveler-category-condition", value: state.condition, placeholder: priceRuleMessages.travelerCategoryConditionPlaceholder, onChange: (event) => setState((prev) => ({ ...prev, condition: event.target.value })) })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null] }), _jsxs(DialogFooter, { className: "-mx-6 -mb-6", children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: pricingCategoryMessages.cancel }), _jsx(Button, { onClick: () => void save(), disabled: create.isPending || update.isPending, children: isEditing
|
|
348
|
+
? priceRuleMessages.updateTravelerCategory
|
|
349
|
+
: priceRuleMessages.createTravelerCategory })] })] }) }));
|
|
304
350
|
}
|
|
305
|
-
function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, productCurrency, }) {
|
|
351
|
+
export function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, ensureOptionPriceRuleId, productCurrency, }) {
|
|
306
352
|
const messages = useProductDetailMessages();
|
|
307
353
|
const extraPriceMessages = messages.products.operations.extraPrices;
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
354
|
+
const extraMessages = messages.products.operations.extras;
|
|
355
|
+
const extrasQuery = useProductExtras({ productId, limit: 100 });
|
|
356
|
+
const rulesQuery = useExtraPriceRules({
|
|
357
|
+
optionPriceRuleId: optionPriceRuleId ?? "__none__",
|
|
358
|
+
optionId,
|
|
359
|
+
active: true,
|
|
360
|
+
limit: 100,
|
|
361
|
+
enabled: !!optionPriceRuleId,
|
|
362
|
+
});
|
|
363
|
+
const { remove: removeExtra } = useProductExtraMutation();
|
|
311
364
|
const [pricingExtraId, setPricingExtraId] = useState(null);
|
|
312
|
-
const
|
|
365
|
+
const [pricingRuleId, setPricingRuleId] = useState(optionPriceRuleId);
|
|
366
|
+
const [definitionDialogOpen, setDefinitionDialogOpen] = useState(false);
|
|
367
|
+
const [editingExtra, setEditingExtra] = useState();
|
|
368
|
+
const extras = (extrasQuery.data?.data ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
|
|
313
369
|
const rules = rulesQuery.data?.data ?? [];
|
|
314
370
|
const ruleByExtraId = new Map(rules.flatMap((rule) => (rule.productExtraId ? [[rule.productExtraId, rule]] : [])));
|
|
315
|
-
if (extras.length === 0)
|
|
316
|
-
return null;
|
|
317
371
|
const pricingExtra = extras.find((extra) => extra.id === pricingExtraId) ?? null;
|
|
318
|
-
return (_jsxs("div", { className: "mt-4 border-t pt-3", children: [
|
|
372
|
+
return (_jsxs("div", { className: "mt-4 border-t pt-3", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between gap-2", children: [_jsx("div", { className: "text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: extraMessages.sectionTitle }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
|
|
373
|
+
setEditingExtra(undefined);
|
|
374
|
+
setDefinitionDialogOpen(true);
|
|
375
|
+
}, children: [_jsx(Plus, { className: "mr-1 h-3 w-3" }), extraMessages.addAction] })] }), extras.length === 0 ? (_jsx("p", { className: "py-2 text-center text-xs text-muted-foreground", children: extraMessages.empty })) : (_jsx("div", { className: "flex flex-col gap-2", children: extras.map((extra) => {
|
|
319
376
|
const rule = ruleByExtraId.get(extra.id);
|
|
320
|
-
return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded border px-2 py-1.5 text-xs", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("span", { className: "font-medium", children: extra.name }), extra.pricedPerPerson ? (_jsx("span", { className: "
|
|
377
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded border px-2 py-1.5 text-xs", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: "font-medium", children: extra.name }), _jsx(Badge, { variant: "secondary", className: "text-[10px]", children: getExtraPricingModeLabel(extra.pricingMode, extraMessages) }), extra.pricedPerPerson ? (_jsx("span", { className: "text-muted-foreground", children: extraPriceMessages.perTraveler })) : null] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-mono", children: rule?.sellAmountCents != null
|
|
321
378
|
? formatProductMoney(rule.sellAmountCents, productCurrency)
|
|
322
|
-
: extraPriceMessages.noAmount }), _jsx(Button, { variant: "outline", size: "sm", onClick: () =>
|
|
323
|
-
|
|
379
|
+
: extraPriceMessages.noAmount }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => {
|
|
380
|
+
void (async () => {
|
|
381
|
+
const ruleId = optionPriceRuleId ??
|
|
382
|
+
(ensureOptionPriceRuleId ? await ensureOptionPriceRuleId() : undefined);
|
|
383
|
+
if (!ruleId)
|
|
384
|
+
return;
|
|
385
|
+
setPricingRuleId(ruleId);
|
|
386
|
+
setPricingExtraId(extra.id);
|
|
387
|
+
})();
|
|
388
|
+
}, children: extraPriceMessages.setPrice }), _jsx("button", { type: "button", "aria-label": extraMessages.editAction, onClick: () => {
|
|
389
|
+
setEditingExtra(extra);
|
|
390
|
+
setDefinitionDialogOpen(true);
|
|
391
|
+
}, className: "text-muted-foreground hover:text-foreground", children: _jsx(Pencil, { className: "h-3 w-3" }) }), _jsx("button", { type: "button", "aria-label": extraMessages.deleteAction, onClick: () => {
|
|
392
|
+
if (confirm(formatMessage(extraMessages.deleteConfirm, { name: extra.name }))) {
|
|
393
|
+
removeExtra.mutate(extra.id, {
|
|
394
|
+
onSuccess: () => void extrasQuery.refetch(),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}, className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3 w-3" }) })] })] }, extra.id));
|
|
398
|
+
}) })), _jsx(ProductExtraDialog, { open: definitionDialogOpen, onOpenChange: (open) => {
|
|
399
|
+
setDefinitionDialogOpen(open);
|
|
400
|
+
if (!open)
|
|
401
|
+
setEditingExtra(undefined);
|
|
402
|
+
}, productId: productId, extra: editingExtra, nextSortOrder: extras.length, onSuccess: () => {
|
|
403
|
+
setDefinitionDialogOpen(false);
|
|
404
|
+
setEditingExtra(undefined);
|
|
405
|
+
void extrasQuery.refetch();
|
|
406
|
+
} }), pricingExtra && (pricingRuleId ?? optionPriceRuleId) ? (_jsx(ExtraPriceRuleDialog, { open: !!pricingExtra, onOpenChange: (open) => {
|
|
324
407
|
if (!open)
|
|
325
408
|
setPricingExtraId(null);
|
|
326
|
-
}, optionPriceRuleId: optionPriceRuleId, optionId: optionId, extra: pricingExtra, existingRule: ruleByExtraId.get(pricingExtra.id), nextSortOrder: rules.length, productCurrency: productCurrency, onSuccess: () => {
|
|
409
|
+
}, optionPriceRuleId: (pricingRuleId ?? optionPriceRuleId), optionId: optionId, extra: pricingExtra, existingRule: ruleByExtraId.get(pricingExtra.id), nextSortOrder: rules.length, productCurrency: productCurrency, onSuccess: () => {
|
|
327
410
|
setPricingExtraId(null);
|
|
328
411
|
void rulesQuery.refetch();
|
|
329
412
|
} })) : null] }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { useProductDetailMessages } from "./host.js";
|
|
2
2
|
type ProductCoreMessages = ReturnType<typeof useProductDetailMessages>["products"]["core"];
|
|
3
|
-
export type TranslatableField = "name" | "description" | "slug";
|
|
3
|
+
export type TranslatableField = "name" | "description" | "slug" | "inclusionsHtml" | "exclusionsHtml" | "termsHtml";
|
|
4
4
|
export type TranslationDraft = {
|
|
5
5
|
id: string | null;
|
|
6
6
|
languageTag: string;
|
|
@@ -20,6 +20,9 @@ export interface PersistTranslationsOptions {
|
|
|
20
20
|
defaultLanguageTag: string;
|
|
21
21
|
baseName: string;
|
|
22
22
|
baseDescription: string;
|
|
23
|
+
baseInclusionsHtml: string;
|
|
24
|
+
baseExclusionsHtml: string;
|
|
25
|
+
baseTermsHtml: string;
|
|
23
26
|
}
|
|
24
27
|
export interface ProductTranslationDrafts {
|
|
25
28
|
drafts: TranslationDraft[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-translation-popover.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-translation-popover.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAEzD,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,CAAA;AAE1F,MAAM,MAAM,iBAAiB,
|
|
1
|
+
{"version":3,"file":"product-translation-popover.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-translation-popover.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAEzD,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,CAAA;AAE1F,MAAM,MAAM,iBAAiB,GACzB,MAAM,GACN,aAAa,GACb,MAAM,GACN,gBAAgB,GAChB,gBAAgB,GAChB,WAAW,CAAA;AASf,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IAEZ,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B,CAAA;AAmCD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAOxD;AAQD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGjD;AAED,MAAM,WAAW,0BAA0B;IACzC,kBAAkB,EAAE,MAAM,CAAA;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,kBAAkB,EAAE,MAAM,CAAA;IAC1B,aAAa,EAAE,MAAM,CAAA;CACtB;AAUD,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,gBAAgB,EAAE,CAAA;IAC1B,SAAS,EAAE,OAAO,CAAA;IAClB,aAAa,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACrF,WAAW,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,cAAc,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7C,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,0BAA0B,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACnF;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,wBAAwB,CA+H9F;AAED,MAAM,WAAW,4BAA4B;IAC3C,cAAc,EAAE,MAAM,CAAA;IACtB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,yFAAyF;IACzF,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,QAAQ,EAAE,mBAAmB,CAAA;IAC7B,QAAQ,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAA;IACvC,aAAa,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5C,gBAAgB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAA;CAChD;AAED,kFAAkF;AAClF,wBAAgB,uBAAuB,CAAC,EACtC,cAAc,EACd,kBAAkB,EAClB,YAAY,EACZ,QAAQ,EACR,QAAQ,EACR,aAAa,EACb,gBAAgB,GACjB,EAAE,4BAA4B,2CAqD9B;AAiDD,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,UAAU,CAAA;IACzB,KAAK,EAAE,iBAAiB,CAAA;IACxB,cAAc,EAAE,MAAM,CAAA;IACtB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,4FAA4F;IAC5F,IAAI,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAA;IAC3D,YAAY,EAAE,wBAAwB,CAAA;IACtC,QAAQ,EAAE,mBAAmB,CAAA;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,IAAI,EACJ,KAAK,EACL,cAAc,EACd,kBAAkB,EAClB,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,WAAW,EACX,SAAS,EACT,KAAK,GACN,EAAE,sBAAsB,2CA0CxB;AAED,wBAAgB,oBAAoB,CAAC,EACnC,SAAS,EAAE,mBAAmB,EAC9B,QAAQ,GACT,EAAE;IACD,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,QAAQ,EAAE,mBAAmB,CAAA;CAC9B,2CA4BA;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,KAAK,EACL,aAAa,EACb,OAAY,EACZ,WAAW,EACX,UAAU,GACX,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,aAAa,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,2CA0BA"}
|
|
@@ -9,6 +9,12 @@ import { cn } from "@voyantjs/ui/lib/utils";
|
|
|
9
9
|
import { languages } from "@voyantjs/utils/languages";
|
|
10
10
|
import { Globe, Plus, X } from "lucide-react";
|
|
11
11
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
const RICH_TEXT_FIELDS = new Set([
|
|
13
|
+
"description",
|
|
14
|
+
"inclusionsHtml",
|
|
15
|
+
"exclusionsHtml",
|
|
16
|
+
"termsHtml",
|
|
17
|
+
]);
|
|
12
18
|
function recordToDraft(record) {
|
|
13
19
|
return {
|
|
14
20
|
id: record.id,
|
|
@@ -47,14 +53,23 @@ export function richTextHasContent(html) {
|
|
|
47
53
|
.trim().length > 0);
|
|
48
54
|
}
|
|
49
55
|
function fieldHasContent(draft, field) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
const value = draft[field] ?? "";
|
|
57
|
+
if (RICH_TEXT_FIELDS.has(field))
|
|
58
|
+
return richTextHasContent(value);
|
|
59
|
+
return value.trim().length > 0;
|
|
53
60
|
}
|
|
54
61
|
export function languageLabel(tag) {
|
|
55
62
|
const base = tag.split("-")[0]?.toLowerCase() ?? tag;
|
|
56
63
|
return languages[base] ?? tag;
|
|
57
64
|
}
|
|
65
|
+
// Resolve a rich-text translation column: the default-language row mirrors the
|
|
66
|
+
// base product column (when it has content), every row otherwise uses its own
|
|
67
|
+
// draft, and empty markup collapses to null so the column stays clean.
|
|
68
|
+
function resolveRichText(isDefault, baseValue, draftValue) {
|
|
69
|
+
if (isDefault && richTextHasContent(baseValue))
|
|
70
|
+
return baseValue;
|
|
71
|
+
return richTextHasContent(draftValue ?? "") ? (draftValue ?? "") : null;
|
|
72
|
+
}
|
|
58
73
|
/**
|
|
59
74
|
* Manages an in-memory draft of a product's translations so Name/Description/
|
|
60
75
|
* Slug can be edited in context from the edit sheet. Seeds from the saved
|
|
@@ -102,7 +117,7 @@ export function useProductTranslationDrafts(productId) {
|
|
|
102
117
|
setDrafts((prev) => prev.filter((draft) => draft.languageTag !== languageTag));
|
|
103
118
|
}, []);
|
|
104
119
|
const persist = useCallback(async (resolvedProductId, options) => {
|
|
105
|
-
const { defaultLanguageTag, baseName, baseDescription } = options;
|
|
120
|
+
const { defaultLanguageTag, baseName, baseDescription, baseInclusionsHtml, baseExclusionsHtml, baseTermsHtml, } = options;
|
|
106
121
|
const original = existingRef.current;
|
|
107
122
|
const currentLanguages = new Set(drafts.map((draft) => draft.languageTag));
|
|
108
123
|
const deletes = original
|
|
@@ -128,22 +143,27 @@ export function useProductTranslationDrafts(productId) {
|
|
|
128
143
|
? draft.description
|
|
129
144
|
: null;
|
|
130
145
|
const slug = draft.slug.trim() ? draft.slug.trim() : null;
|
|
146
|
+
const inclusionsHtml = resolveRichText(isDefault, baseInclusionsHtml, draft.inclusionsHtml);
|
|
147
|
+
const exclusionsHtml = resolveRichText(isDefault, baseExclusionsHtml, draft.exclusionsHtml);
|
|
148
|
+
const termsHtml = resolveRichText(isDefault, baseTermsHtml, draft.termsHtml);
|
|
149
|
+
const richInput = { inclusionsHtml, exclusionsHtml, termsHtml };
|
|
131
150
|
if (draft.id) {
|
|
132
151
|
return mutations.update.mutateAsync({
|
|
133
152
|
productId: resolvedProductId,
|
|
134
153
|
translationId: draft.id,
|
|
135
|
-
input: { name, description, slug },
|
|
154
|
+
input: { name, description, slug, ...richInput },
|
|
136
155
|
});
|
|
137
156
|
}
|
|
138
157
|
// A brand-new row is only worth creating once it carries content.
|
|
158
|
+
const hasRichContent = !!inclusionsHtml || !!exclusionsHtml || !!termsHtml;
|
|
139
159
|
const isEmpty = isDefault
|
|
140
|
-
? !slug
|
|
141
|
-
: !draft.name.trim() && !richTextHasContent(draft.description) && !slug;
|
|
160
|
+
? !slug && !hasRichContent
|
|
161
|
+
: !draft.name.trim() && !richTextHasContent(draft.description) && !slug && !hasRichContent;
|
|
142
162
|
if (isEmpty)
|
|
143
163
|
return Promise.resolve(null);
|
|
144
164
|
return mutations.create.mutateAsync({
|
|
145
165
|
productId: resolvedProductId,
|
|
146
|
-
input: { languageTag: draft.languageTag, name, description, slug },
|
|
166
|
+
input: { languageTag: draft.languageTag, name, description, slug, ...richInput },
|
|
147
167
|
});
|
|
148
168
|
});
|
|
149
169
|
await Promise.all([...deletes, ...upserts]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/products-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.104.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,26 +50,26 @@
|
|
|
50
50
|
"react-dom": "^19.0.0",
|
|
51
51
|
"react-hook-form": "^7.60.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@voyantjs/availability": "0.
|
|
54
|
-
"@voyantjs/availability-react": "0.
|
|
55
|
-
"@voyantjs/catalog-react": "0.
|
|
56
|
-
"@voyantjs/extras-react": "0.
|
|
57
|
-
"@voyantjs/finance": "0.
|
|
58
|
-
"@voyantjs/finance-ui": "0.
|
|
59
|
-
"@voyantjs/markets-react": "0.
|
|
60
|
-
"@voyantjs/pricing-react": "0.
|
|
61
|
-
"@voyantjs/pricing-ui": "0.
|
|
62
|
-
"@voyantjs/products-react": "0.
|
|
63
|
-
"@voyantjs/suppliers-react": "0.
|
|
64
|
-
"@voyantjs/ui": "0.
|
|
65
|
-
"@voyantjs/utils": "0.
|
|
53
|
+
"@voyantjs/availability": "0.104.0",
|
|
54
|
+
"@voyantjs/availability-react": "0.104.0",
|
|
55
|
+
"@voyantjs/catalog-react": "0.104.0",
|
|
56
|
+
"@voyantjs/extras-react": "0.104.0",
|
|
57
|
+
"@voyantjs/finance": "0.104.0",
|
|
58
|
+
"@voyantjs/finance-ui": "0.104.0",
|
|
59
|
+
"@voyantjs/markets-react": "0.104.0",
|
|
60
|
+
"@voyantjs/pricing-react": "0.104.0",
|
|
61
|
+
"@voyantjs/pricing-ui": "0.104.0",
|
|
62
|
+
"@voyantjs/products-react": "0.104.0",
|
|
63
|
+
"@voyantjs/suppliers-react": "0.104.0",
|
|
64
|
+
"@voyantjs/ui": "0.104.0",
|
|
65
|
+
"@voyantjs/utils": "0.104.0"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"date-fns": "^4.1.0",
|
|
69
69
|
"motion": "^12.38.0",
|
|
70
70
|
"react-day-picker": "^9.8.0",
|
|
71
71
|
"sonner": "^2.0.7",
|
|
72
|
-
"@voyantjs/i18n": "0.
|
|
72
|
+
"@voyantjs/i18n": "0.104.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@tanstack/react-query": "^5.100.11",
|
|
@@ -82,20 +82,20 @@
|
|
|
82
82
|
"typescript": "^6.0.2",
|
|
83
83
|
"vitest": "^4.1.2",
|
|
84
84
|
"zod": "^4.3.6",
|
|
85
|
-
"@voyantjs/availability": "0.
|
|
86
|
-
"@voyantjs/availability-react": "0.
|
|
87
|
-
"@voyantjs/catalog-react": "0.
|
|
88
|
-
"@voyantjs/extras-react": "0.
|
|
89
|
-
"@voyantjs/finance": "0.
|
|
90
|
-
"@voyantjs/finance-ui": "0.
|
|
91
|
-
"@voyantjs/i18n": "0.
|
|
92
|
-
"@voyantjs/markets-react": "0.
|
|
93
|
-
"@voyantjs/pricing-react": "0.
|
|
94
|
-
"@voyantjs/pricing-ui": "0.
|
|
95
|
-
"@voyantjs/products-react": "0.
|
|
96
|
-
"@voyantjs/suppliers-react": "0.
|
|
97
|
-
"@voyantjs/ui": "0.
|
|
98
|
-
"@voyantjs/utils": "0.
|
|
85
|
+
"@voyantjs/availability": "0.104.0",
|
|
86
|
+
"@voyantjs/availability-react": "0.104.0",
|
|
87
|
+
"@voyantjs/catalog-react": "0.104.0",
|
|
88
|
+
"@voyantjs/extras-react": "0.104.0",
|
|
89
|
+
"@voyantjs/finance": "0.104.0",
|
|
90
|
+
"@voyantjs/finance-ui": "0.104.0",
|
|
91
|
+
"@voyantjs/i18n": "0.104.0",
|
|
92
|
+
"@voyantjs/markets-react": "0.104.0",
|
|
93
|
+
"@voyantjs/pricing-react": "0.104.0",
|
|
94
|
+
"@voyantjs/pricing-ui": "0.104.0",
|
|
95
|
+
"@voyantjs/products-react": "0.104.0",
|
|
96
|
+
"@voyantjs/suppliers-react": "0.104.0",
|
|
97
|
+
"@voyantjs/ui": "0.104.0",
|
|
98
|
+
"@voyantjs/utils": "0.104.0",
|
|
99
99
|
"@voyantjs/voyant-typescript-config": "0.1.0"
|
|
100
100
|
},
|
|
101
101
|
"files": [
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"product-extras-section.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-extras-section.tsx"],"names":[],"mappings":"AAmEA,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,2CAiQxE"}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useProductExtraMutation, useProductExtras, } from "@voyantjs/extras-react";
|
|
3
|
-
import { formatMessage } from "@voyantjs/i18n";
|
|
4
|
-
import { Badge, Button, Checkbox, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@voyantjs/ui/components";
|
|
5
|
-
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
6
|
-
import * as React from "react";
|
|
7
|
-
import { useProductDetailMessages } from "./host.js";
|
|
8
|
-
import { ActionMenu, EmptyState, Section } from "./product-detail-sections.js";
|
|
9
|
-
const selectionTypes = ["optional", "required", "default_selected", "unavailable"];
|
|
10
|
-
const pricingModes = [
|
|
11
|
-
"included",
|
|
12
|
-
"per_person",
|
|
13
|
-
"per_booking",
|
|
14
|
-
"quantity_based",
|
|
15
|
-
"on_request",
|
|
16
|
-
"free",
|
|
17
|
-
];
|
|
18
|
-
const emptyForm = {
|
|
19
|
-
name: "",
|
|
20
|
-
code: "",
|
|
21
|
-
description: "",
|
|
22
|
-
selectionType: "optional",
|
|
23
|
-
pricingMode: "per_booking",
|
|
24
|
-
pricedPerPerson: false,
|
|
25
|
-
minQuantity: "",
|
|
26
|
-
maxQuantity: "",
|
|
27
|
-
defaultQuantity: "",
|
|
28
|
-
active: true,
|
|
29
|
-
};
|
|
30
|
-
export function ProductExtrasSection({ productId }) {
|
|
31
|
-
const messages = useProductDetailMessages();
|
|
32
|
-
const extraMessages = messages.products.operations.extras;
|
|
33
|
-
const [open, setOpen] = React.useState(false);
|
|
34
|
-
const [editing, setEditing] = React.useState(null);
|
|
35
|
-
const [form, setForm] = React.useState(emptyForm);
|
|
36
|
-
const { data, isPending, refetch } = useProductExtras({ productId, limit: 100 });
|
|
37
|
-
const { create, update, remove } = useProductExtraMutation();
|
|
38
|
-
const rows = data?.data ?? [];
|
|
39
|
-
const startCreate = () => {
|
|
40
|
-
setEditing(null);
|
|
41
|
-
setForm(emptyForm);
|
|
42
|
-
setOpen(true);
|
|
43
|
-
};
|
|
44
|
-
const startEdit = (extra) => {
|
|
45
|
-
setEditing(extra);
|
|
46
|
-
setForm({
|
|
47
|
-
name: extra.name,
|
|
48
|
-
code: extra.code ?? "",
|
|
49
|
-
description: extra.description ?? "",
|
|
50
|
-
selectionType: extra.selectionType,
|
|
51
|
-
pricingMode: extra.pricingMode,
|
|
52
|
-
pricedPerPerson: extra.pricedPerPerson,
|
|
53
|
-
minQuantity: extra.minQuantity == null ? "" : String(extra.minQuantity),
|
|
54
|
-
maxQuantity: extra.maxQuantity == null ? "" : String(extra.maxQuantity),
|
|
55
|
-
defaultQuantity: extra.defaultQuantity == null ? "" : String(extra.defaultQuantity),
|
|
56
|
-
active: extra.active,
|
|
57
|
-
});
|
|
58
|
-
setOpen(true);
|
|
59
|
-
};
|
|
60
|
-
const save = async () => {
|
|
61
|
-
const payload = {
|
|
62
|
-
productId,
|
|
63
|
-
name: form.name.trim(),
|
|
64
|
-
code: form.code.trim() || null,
|
|
65
|
-
description: form.description.trim() || null,
|
|
66
|
-
selectionType: form.selectionType,
|
|
67
|
-
pricingMode: form.pricingMode,
|
|
68
|
-
pricedPerPerson: form.pricedPerPerson,
|
|
69
|
-
minQuantity: parseNullableInt(form.minQuantity),
|
|
70
|
-
maxQuantity: parseNullableInt(form.maxQuantity),
|
|
71
|
-
defaultQuantity: parseNullableInt(form.defaultQuantity),
|
|
72
|
-
active: form.active,
|
|
73
|
-
sortOrder: editing?.sortOrder ?? rows.length,
|
|
74
|
-
};
|
|
75
|
-
if (!payload.name)
|
|
76
|
-
return;
|
|
77
|
-
if (editing)
|
|
78
|
-
await update.mutateAsync({ id: editing.id, input: payload });
|
|
79
|
-
else
|
|
80
|
-
await create.mutateAsync(payload);
|
|
81
|
-
setOpen(false);
|
|
82
|
-
setEditing(null);
|
|
83
|
-
setForm(emptyForm);
|
|
84
|
-
void refetch();
|
|
85
|
-
};
|
|
86
|
-
return (_jsxs(Section, { title: extraMessages.sectionTitle, actions: _jsxs(Button, { variant: "outline", size: "sm", onClick: startCreate, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), extraMessages.addAction] }), contentClassName: "px-6 py-4", children: [rows.length === 0 ? (_jsx(EmptyState, { message: isPending ? extraMessages.loading : extraMessages.empty })) : (_jsx("div", { className: "flex flex-col gap-2", children: rows.map((extra) => (_jsxs("div", { className: "flex items-start justify-between gap-3 rounded-md border px-3 py-2", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-medium text-sm", children: extra.name }), _jsx(Badge, { variant: extra.active ? "default" : "outline", children: extra.active ? extraMessages.activeBadge : extraMessages.inactiveBadge }), _jsx(Badge, { variant: "secondary", children: getPricingModeLabel(extra.pricingMode, extraMessages) }), extra.pricedPerPerson ? (_jsx(Badge, { variant: "outline", children: extraMessages.perTravelerBadge })) : null] }), extra.description ? (_jsx("p", { className: "text-muted-foreground text-xs", children: extra.description })) : null] }), _jsxs(ActionMenu, { children: [_jsxs("button", { type: "button", className: "flex w-full items-center gap-2 px-2 py-1.5 text-sm", onClick: () => startEdit(extra), children: [_jsx(Pencil, { className: "h-4 w-4" }), extraMessages.editAction] }), _jsxs("button", { type: "button", className: "flex w-full items-center gap-2 px-2 py-1.5 text-destructive text-sm", onClick: () => {
|
|
87
|
-
if (confirm(formatMessage(extraMessages.deleteConfirm, { name: extra.name })))
|
|
88
|
-
remove.mutate(extra.id, { onSuccess: () => void refetch() });
|
|
89
|
-
}, children: [_jsx(Trash2, { className: "h-4 w-4" }), extraMessages.deleteAction] })] })] }, extra.id))) })), _jsx(Dialog, { open: open, onOpenChange: setOpen, children: _jsxs(DialogContent, { size: "lg", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: editing ? extraMessages.editTitle : extraMessages.newTitle }), _jsx(DialogDescription, { children: extraMessages.dialogDescription })] }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-3 md:grid-cols-2", children: [_jsx(Field, { label: extraMessages.nameLabel, children: _jsx(Input, { value: form.name, onChange: (event) => setForm({ ...form, name: event.target.value }) }) }), _jsx(Field, { label: extraMessages.codeLabel, children: _jsx(Input, { value: form.code, onChange: (event) => setForm({ ...form, code: event.target.value }) }) })] }), _jsx(Field, { label: extraMessages.descriptionLabel, children: _jsx(Textarea, { value: form.description, onChange: (event) => setForm({ ...form, description: event.target.value }) }) }), _jsxs("div", { className: "grid gap-3 md:grid-cols-3", children: [_jsx(Field, { label: extraMessages.selectionLabel, children: _jsxs(Select, { value: form.selectionType, onValueChange: (value) => setForm({
|
|
90
|
-
...form,
|
|
91
|
-
selectionType: (value ?? "optional"),
|
|
92
|
-
}), items: selectionTypes.map((type) => ({
|
|
93
|
-
value: type,
|
|
94
|
-
label: getSelectionTypeLabel(type, extraMessages),
|
|
95
|
-
})), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: selectionTypes.map((type) => (_jsx(SelectItem, { value: type, children: getSelectionTypeLabel(type, extraMessages) }, type))) })] }) }), _jsx(Field, { label: extraMessages.pricingLabel, children: _jsxs(Select, { value: form.pricingMode, onValueChange: (value) => setForm({
|
|
96
|
-
...form,
|
|
97
|
-
pricingMode: (value ?? "per_booking"),
|
|
98
|
-
}), items: pricingModes.map((mode) => ({
|
|
99
|
-
value: mode,
|
|
100
|
-
label: getPricingModeLabel(mode, extraMessages),
|
|
101
|
-
})), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: pricingModes.map((mode) => (_jsx(SelectItem, { value: mode, children: getPricingModeLabel(mode, extraMessages) }, mode))) })] }) }), _jsx(Field, { label: extraMessages.defaultQuantityLabel, children: _jsx(Input, { value: form.defaultQuantity, type: "number", min: "0", onChange: (event) => setForm({ ...form, defaultQuantity: event.target.value }) }) }), _jsx(Field, { label: extraMessages.minQuantityLabel, children: _jsx(Input, { value: form.minQuantity, type: "number", min: "0", onChange: (event) => setForm({ ...form, minQuantity: event.target.value }) }) }), _jsx(Field, { label: extraMessages.maxQuantityLabel, children: _jsx(Input, { value: form.maxQuantity, type: "number", min: "0", onChange: (event) => setForm({ ...form, maxQuantity: event.target.value }) }) })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "product-extra-priced-per-person", checked: form.pricedPerPerson, onCheckedChange: (checked) => setForm({ ...form, pricedPerPerson: checked === true }) }), _jsx(Label, { htmlFor: "product-extra-priced-per-person", children: extraMessages.perTravelerLabel })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: "product-extra-active", checked: form.active, onCheckedChange: (checked) => setForm({ ...form, active: checked === true }) }), _jsx(Label, { htmlFor: "product-extra-active", children: extraMessages.activeLabel })] })] })] }), _jsxs(DialogFooter, { className: "-mx-6 -mb-6", children: [_jsx(Button, { variant: "ghost", onClick: () => setOpen(false), children: extraMessages.cancel }), _jsx(Button, { onClick: () => void save(), disabled: !form.name.trim(), children: editing ? extraMessages.saveChanges : extraMessages.create })] })] }) })] }));
|
|
102
|
-
}
|
|
103
|
-
function Field({ label, children }) {
|
|
104
|
-
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: label }), children] }));
|
|
105
|
-
}
|
|
106
|
-
function parseNullableInt(value) {
|
|
107
|
-
const parsed = Number.parseInt(value, 10);
|
|
108
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
109
|
-
}
|
|
110
|
-
function getSelectionTypeLabel(value, messages) {
|
|
111
|
-
switch (value) {
|
|
112
|
-
case "optional":
|
|
113
|
-
return messages.selectionOptional;
|
|
114
|
-
case "required":
|
|
115
|
-
return messages.selectionRequired;
|
|
116
|
-
case "default_selected":
|
|
117
|
-
return messages.selectionDefaultSelected;
|
|
118
|
-
case "unavailable":
|
|
119
|
-
return messages.selectionUnavailable;
|
|
120
|
-
default:
|
|
121
|
-
return value;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function getPricingModeLabel(value, messages) {
|
|
125
|
-
switch (value) {
|
|
126
|
-
case "included":
|
|
127
|
-
return messages.pricingIncluded;
|
|
128
|
-
case "per_person":
|
|
129
|
-
return messages.pricingPerPerson;
|
|
130
|
-
case "per_booking":
|
|
131
|
-
return messages.pricingPerBooking;
|
|
132
|
-
case "quantity_based":
|
|
133
|
-
return messages.pricingQuantityBased;
|
|
134
|
-
case "on_request":
|
|
135
|
-
return messages.pricingOnRequest;
|
|
136
|
-
case "free":
|
|
137
|
-
return messages.pricingFree;
|
|
138
|
-
default:
|
|
139
|
-
return value;
|
|
140
|
-
}
|
|
141
|
-
}
|