@voyantjs/products-ui 0.102.0 → 0.103.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 +46 -6
- package/dist/components/product-detail/product-options-pricing.d.ts +10 -1
- package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-pricing.js +103 -35
- 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":"AAwBA,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,2CAwbxB"}
|
|
@@ -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, 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();
|
|
@@ -151,10 +154,42 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
151
154
|
setDefaultUnitType(effectiveLayout === "rooms" ? "room" : "person");
|
|
152
155
|
setUnitDialogOpen(true);
|
|
153
156
|
};
|
|
157
|
+
const editTravelerType = (category) => {
|
|
158
|
+
setEditingCategory(category);
|
|
159
|
+
setCategoryDialogOpen(true);
|
|
160
|
+
};
|
|
161
|
+
async function removeTravelerType(category) {
|
|
162
|
+
if (!confirm(formatMessage(messages.products.operations.priceRules.travelerCategoryDeleteConfirm, {
|
|
163
|
+
name: category.name,
|
|
164
|
+
}))) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Categories this product/option owns are deleted outright. A global
|
|
168
|
+
// category only shows here because some cell references it, so removing
|
|
169
|
+
// its prices drops the column from this option without touching the
|
|
170
|
+
// shared category.
|
|
171
|
+
if (category.productId === productId || category.optionId === optionId) {
|
|
172
|
+
await removeCategory.mutateAsync(category.id);
|
|
173
|
+
void refetchCategories();
|
|
174
|
+
void refetchCells();
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
for (const cell of cells.filter((entry) => entry.pricingCategoryId === category.id)) {
|
|
178
|
+
await removeCell.mutateAsync(cell.id);
|
|
179
|
+
}
|
|
180
|
+
void refetchCells();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
154
183
|
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: () =>
|
|
184
|
+
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
|
+
setEditingCategory(undefined);
|
|
186
|
+
setCategoryDialogOpen(true);
|
|
187
|
+
}, 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
188
|
const condition = getCategoryCondition(column.metadata);
|
|
157
|
-
|
|
189
|
+
const category = column.id
|
|
190
|
+
? categories.find((entry) => entry.id === column.id)
|
|
191
|
+
: undefined;
|
|
192
|
+
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
193
|
}), _jsx("th", { className: "w-[72px] p-2.5 text-right font-medium" })] }) }), _jsx("tbody", { children: units.map((unit) => {
|
|
159
194
|
const subtitle = unitSubtitle(unit, effectiveLayout, t);
|
|
160
195
|
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 +211,17 @@ export function OptionPricingGrid({ productId, optionId, optionName, productCurr
|
|
|
176
211
|
deleteUnitMutation.mutate(unit.id);
|
|
177
212
|
}
|
|
178
213
|
}, 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: () => {
|
|
214
|
+
}) })] }) })), _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
215
|
setUnitDialogOpen(false);
|
|
181
216
|
setEditingUnit(undefined);
|
|
182
217
|
void refetchUnits();
|
|
183
|
-
} }), _jsx(TravelerCategoryDialog, { open: categoryDialogOpen, onOpenChange:
|
|
218
|
+
} }), _jsx(TravelerCategoryDialog, { open: categoryDialogOpen, onOpenChange: (open) => {
|
|
219
|
+
setCategoryDialogOpen(open);
|
|
220
|
+
if (!open)
|
|
221
|
+
setEditingCategory(undefined);
|
|
222
|
+
}, productId: productId, units: units, category: editingCategory, nextSortOrder: categories.length > 0 ? Math.max(...categories.map((c) => c.sortOrder)) + 1 : 0, onSuccess: () => {
|
|
184
223
|
setCategoryDialogOpen(false);
|
|
224
|
+
setEditingCategory(undefined);
|
|
185
225
|
void refetchCategories();
|
|
186
226
|
} }), _jsx(UnitPriceRuleDialog, { open: cellDialogOpen, onOpenChange: setCellDialogOpen, optionPriceRuleId: cellRuleId ?? defaultRule?.id ?? "", optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
|
|
187
227
|
setCellDialogOpen(false);
|
|
@@ -1,3 +1,4 @@
|
|
|
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";
|
|
@@ -22,13 +23,21 @@ export declare function PricingPanel({ productId, optionId, optionName, productC
|
|
|
22
23
|
layout: OptionPricingLayout;
|
|
23
24
|
extras?: React.ReactNode;
|
|
24
25
|
}): import("react/jsx-runtime").JSX.Element;
|
|
25
|
-
export declare function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }: {
|
|
26
|
+
export declare function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, category, onSuccess, }: {
|
|
26
27
|
open: boolean;
|
|
27
28
|
onOpenChange: (open: boolean) => void;
|
|
28
29
|
productId: string;
|
|
29
30
|
units: OptionUnitData[];
|
|
30
31
|
nextSortOrder: number;
|
|
32
|
+
category?: PricingCategoryRecord;
|
|
31
33
|
onSuccess: () => void;
|
|
32
34
|
}): import("react/jsx-runtime").JSX.Element;
|
|
35
|
+
export declare function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, ensureOptionPriceRuleId, productCurrency, }: {
|
|
36
|
+
productId: string;
|
|
37
|
+
optionId: string;
|
|
38
|
+
optionPriceRuleId?: string;
|
|
39
|
+
ensureOptionPriceRuleId?: () => Promise<string>;
|
|
40
|
+
productCurrency: string;
|
|
41
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
33
42
|
export declare function formatProductMoney(amountCents: number | null | undefined, currency: string): string;
|
|
34
43
|
//# 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;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;AAwdD,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";
|
|
@@ -217,6 +218,21 @@ function initialTravelerCategoryState() {
|
|
|
217
218
|
allowedUnitIds: [],
|
|
218
219
|
};
|
|
219
220
|
}
|
|
221
|
+
function stateFromCategory(category) {
|
|
222
|
+
const metadata = category.metadata ?? {};
|
|
223
|
+
const allowedUnitIds = Array.isArray(metadata.allowedUnitIds)
|
|
224
|
+
? metadata.allowedUnitIds.filter((id) => typeof id === "string")
|
|
225
|
+
: [];
|
|
226
|
+
return {
|
|
227
|
+
name: category.name,
|
|
228
|
+
code: category.code ?? "",
|
|
229
|
+
categoryType: category.categoryType,
|
|
230
|
+
minAge: category.minAge != null ? String(category.minAge) : "",
|
|
231
|
+
maxAge: category.maxAge != null ? String(category.maxAge) : "",
|
|
232
|
+
condition: typeof metadata.condition === "string" ? metadata.condition : "",
|
|
233
|
+
allowedUnitIds,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
220
236
|
function parseOptionalInteger(value) {
|
|
221
237
|
const trimmed = value.trim();
|
|
222
238
|
if (!trimmed)
|
|
@@ -224,11 +240,12 @@ function parseOptionalInteger(value) {
|
|
|
224
240
|
const parsed = Number(trimmed);
|
|
225
241
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
|
226
242
|
}
|
|
227
|
-
export function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }) {
|
|
243
|
+
export function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, category, onSuccess, }) {
|
|
228
244
|
const messages = useProductDetailMessages();
|
|
229
245
|
const priceRuleMessages = messages.products.operations.priceRules;
|
|
230
246
|
const pricingCategoryMessages = messages.pricing.categories;
|
|
231
|
-
const { create } = usePricingCategoryMutation();
|
|
247
|
+
const { create, update } = usePricingCategoryMutation();
|
|
248
|
+
const isEditing = !!category;
|
|
232
249
|
const [state, setState] = useState(() => initialTravelerCategoryState());
|
|
233
250
|
const [error, setError] = useState(null);
|
|
234
251
|
const travelerCategoryTypes = [
|
|
@@ -241,10 +258,10 @@ export function TravelerCategoryDialog({ open, onOpenChange, productId, units, n
|
|
|
241
258
|
];
|
|
242
259
|
useEffect(() => {
|
|
243
260
|
if (open) {
|
|
244
|
-
setState(initialTravelerCategoryState());
|
|
261
|
+
setState(category ? stateFromCategory(category) : initialTravelerCategoryState());
|
|
245
262
|
setError(null);
|
|
246
263
|
}
|
|
247
|
-
}, [open]);
|
|
264
|
+
}, [open, category]);
|
|
248
265
|
const toggleUnit = (unitId, checked) => {
|
|
249
266
|
setState((prev) => ({
|
|
250
267
|
...prev,
|
|
@@ -271,59 +288,110 @@ export function TravelerCategoryDialog({ open, onOpenChange, productId, units, n
|
|
|
271
288
|
metadata.allowedUnitCodes = selectedUnits.map((unit) => unit.code).filter(Boolean);
|
|
272
289
|
metadata.allowedUnitNames = selectedUnits.map((unit) => unit.name);
|
|
273
290
|
}
|
|
291
|
+
const payload = {
|
|
292
|
+
// On edit, preserve the category's existing scope — re-stamping a shared
|
|
293
|
+
// (global) category with this product's id would silently steal it from
|
|
294
|
+
// every other product that relies on it. Only a freshly created category
|
|
295
|
+
// is scoped to the current product.
|
|
296
|
+
productId: category ? (category.productId ?? null) : productId,
|
|
297
|
+
optionId: category ? (category.optionId ?? null) : null,
|
|
298
|
+
unitId: null,
|
|
299
|
+
name,
|
|
300
|
+
code: state.code.trim() || null,
|
|
301
|
+
categoryType: state.categoryType,
|
|
302
|
+
seatOccupancy: 1,
|
|
303
|
+
isAgeQualified: minAge != null || maxAge != null,
|
|
304
|
+
minAge,
|
|
305
|
+
maxAge,
|
|
306
|
+
internalUseOnly: false,
|
|
307
|
+
active: true,
|
|
308
|
+
sortOrder: category?.sortOrder ?? nextSortOrder,
|
|
309
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
|
310
|
+
};
|
|
274
311
|
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
|
-
});
|
|
312
|
+
if (category) {
|
|
313
|
+
await update.mutateAsync({ id: category.id, input: payload });
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
await create.mutateAsync(payload);
|
|
317
|
+
}
|
|
291
318
|
onSuccess();
|
|
292
319
|
}
|
|
293
320
|
catch (err) {
|
|
294
321
|
setError(err instanceof Error ? err.message : priceRuleMessages.travelerCategorySaveFailed);
|
|
295
322
|
}
|
|
296
323
|
};
|
|
297
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children:
|
|
324
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEditing
|
|
325
|
+
? priceRuleMessages.travelerCategoryEditTitle
|
|
326
|
+
: 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
327
|
...prev,
|
|
299
328
|
categoryType: (value ?? "child"),
|
|
300
329
|
})), 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
330
|
const checkboxId = `traveler-category-unit-${unit.id}`;
|
|
302
331
|
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:
|
|
332
|
+
}) }), _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
|
|
333
|
+
? priceRuleMessages.updateTravelerCategory
|
|
334
|
+
: priceRuleMessages.createTravelerCategory })] })] }) }));
|
|
304
335
|
}
|
|
305
|
-
function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, productCurrency, }) {
|
|
336
|
+
export function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, ensureOptionPriceRuleId, productCurrency, }) {
|
|
306
337
|
const messages = useProductDetailMessages();
|
|
307
338
|
const extraPriceMessages = messages.products.operations.extraPrices;
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
339
|
+
const extraMessages = messages.products.operations.extras;
|
|
340
|
+
const extrasQuery = useProductExtras({ productId, limit: 100 });
|
|
341
|
+
const rulesQuery = useExtraPriceRules({
|
|
342
|
+
optionPriceRuleId: optionPriceRuleId ?? "__none__",
|
|
343
|
+
optionId,
|
|
344
|
+
active: true,
|
|
345
|
+
limit: 100,
|
|
346
|
+
enabled: !!optionPriceRuleId,
|
|
347
|
+
});
|
|
348
|
+
const { remove: removeExtra } = useProductExtraMutation();
|
|
311
349
|
const [pricingExtraId, setPricingExtraId] = useState(null);
|
|
312
|
-
const
|
|
350
|
+
const [pricingRuleId, setPricingRuleId] = useState(optionPriceRuleId);
|
|
351
|
+
const [definitionDialogOpen, setDefinitionDialogOpen] = useState(false);
|
|
352
|
+
const [editingExtra, setEditingExtra] = useState();
|
|
353
|
+
const extras = (extrasQuery.data?.data ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
|
|
313
354
|
const rules = rulesQuery.data?.data ?? [];
|
|
314
355
|
const ruleByExtraId = new Map(rules.flatMap((rule) => (rule.productExtraId ? [[rule.productExtraId, rule]] : [])));
|
|
315
|
-
if (extras.length === 0)
|
|
316
|
-
return null;
|
|
317
356
|
const pricingExtra = extras.find((extra) => extra.id === pricingExtraId) ?? null;
|
|
318
|
-
return (_jsxs("div", { className: "mt-4 border-t pt-3", children: [
|
|
357
|
+
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: () => {
|
|
358
|
+
setEditingExtra(undefined);
|
|
359
|
+
setDefinitionDialogOpen(true);
|
|
360
|
+
}, 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
361
|
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: "
|
|
362
|
+
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
363
|
? formatProductMoney(rule.sellAmountCents, productCurrency)
|
|
322
|
-
: extraPriceMessages.noAmount }), _jsx(Button, { variant: "outline", size: "sm", onClick: () =>
|
|
323
|
-
|
|
364
|
+
: extraPriceMessages.noAmount }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => {
|
|
365
|
+
void (async () => {
|
|
366
|
+
const ruleId = optionPriceRuleId ??
|
|
367
|
+
(ensureOptionPriceRuleId ? await ensureOptionPriceRuleId() : undefined);
|
|
368
|
+
if (!ruleId)
|
|
369
|
+
return;
|
|
370
|
+
setPricingRuleId(ruleId);
|
|
371
|
+
setPricingExtraId(extra.id);
|
|
372
|
+
})();
|
|
373
|
+
}, children: extraPriceMessages.setPrice }), _jsx("button", { type: "button", "aria-label": extraMessages.editAction, onClick: () => {
|
|
374
|
+
setEditingExtra(extra);
|
|
375
|
+
setDefinitionDialogOpen(true);
|
|
376
|
+
}, className: "text-muted-foreground hover:text-foreground", children: _jsx(Pencil, { className: "h-3 w-3" }) }), _jsx("button", { type: "button", "aria-label": extraMessages.deleteAction, onClick: () => {
|
|
377
|
+
if (confirm(formatMessage(extraMessages.deleteConfirm, { name: extra.name }))) {
|
|
378
|
+
removeExtra.mutate(extra.id, {
|
|
379
|
+
onSuccess: () => void extrasQuery.refetch(),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}, className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3 w-3" }) })] })] }, extra.id));
|
|
383
|
+
}) })), _jsx(ProductExtraDialog, { open: definitionDialogOpen, onOpenChange: (open) => {
|
|
384
|
+
setDefinitionDialogOpen(open);
|
|
385
|
+
if (!open)
|
|
386
|
+
setEditingExtra(undefined);
|
|
387
|
+
}, productId: productId, extra: editingExtra, nextSortOrder: extras.length, onSuccess: () => {
|
|
388
|
+
setDefinitionDialogOpen(false);
|
|
389
|
+
setEditingExtra(undefined);
|
|
390
|
+
void extrasQuery.refetch();
|
|
391
|
+
} }), pricingExtra && (pricingRuleId ?? optionPriceRuleId) ? (_jsx(ExtraPriceRuleDialog, { open: !!pricingExtra, onOpenChange: (open) => {
|
|
324
392
|
if (!open)
|
|
325
393
|
setPricingExtraId(null);
|
|
326
|
-
}, optionPriceRuleId: optionPriceRuleId, optionId: optionId, extra: pricingExtra, existingRule: ruleByExtraId.get(pricingExtra.id), nextSortOrder: rules.length, productCurrency: productCurrency, onSuccess: () => {
|
|
394
|
+
}, optionPriceRuleId: (pricingRuleId ?? optionPriceRuleId), optionId: optionId, extra: pricingExtra, existingRule: ruleByExtraId.get(pricingExtra.id), nextSortOrder: rules.length, productCurrency: productCurrency, onSuccess: () => {
|
|
327
395
|
setPricingExtraId(null);
|
|
328
396
|
void rulesQuery.refetch();
|
|
329
397
|
} })) : 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.103.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.103.0",
|
|
54
|
+
"@voyantjs/availability-react": "0.103.0",
|
|
55
|
+
"@voyantjs/catalog-react": "0.103.0",
|
|
56
|
+
"@voyantjs/extras-react": "0.103.0",
|
|
57
|
+
"@voyantjs/finance": "0.103.0",
|
|
58
|
+
"@voyantjs/finance-ui": "0.103.0",
|
|
59
|
+
"@voyantjs/markets-react": "0.103.0",
|
|
60
|
+
"@voyantjs/pricing-react": "0.103.0",
|
|
61
|
+
"@voyantjs/pricing-ui": "0.103.0",
|
|
62
|
+
"@voyantjs/products-react": "0.103.0",
|
|
63
|
+
"@voyantjs/suppliers-react": "0.103.0",
|
|
64
|
+
"@voyantjs/ui": "0.103.0",
|
|
65
|
+
"@voyantjs/utils": "0.103.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.103.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.103.0",
|
|
86
|
+
"@voyantjs/availability-react": "0.103.0",
|
|
87
|
+
"@voyantjs/catalog-react": "0.103.0",
|
|
88
|
+
"@voyantjs/extras-react": "0.103.0",
|
|
89
|
+
"@voyantjs/finance": "0.103.0",
|
|
90
|
+
"@voyantjs/finance-ui": "0.103.0",
|
|
91
|
+
"@voyantjs/i18n": "0.103.0",
|
|
92
|
+
"@voyantjs/markets-react": "0.103.0",
|
|
93
|
+
"@voyantjs/pricing-react": "0.103.0",
|
|
94
|
+
"@voyantjs/pricing-ui": "0.103.0",
|
|
95
|
+
"@voyantjs/products-react": "0.103.0",
|
|
96
|
+
"@voyantjs/suppliers-react": "0.103.0",
|
|
97
|
+
"@voyantjs/ui": "0.103.0",
|
|
98
|
+
"@voyantjs/utils": "0.103.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
|
-
}
|