@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.
@@ -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;AA6BD,wBAAgB,iBAAiB,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CAwWzF"}
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 }), _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: () => {
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":"AAmCA,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,2CAgNvD"}
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(ProductExtrasSection, { productId: 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: () => {
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":"AAqBA,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,2CAoWxB"}
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.productId == null || category.productId === productId) &&
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: () => setCategoryDialogOpen(true), 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) => {
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
- return (_jsxs("th", { className: "p-2.5 text-left font-medium", 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] }, column.id ?? "__base__"));
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: setCategoryDialogOpen, productId: productId, units: units, nextSortOrder: categories.length > 0 ? Math.max(...categories.map((c) => c.sortOrder)) + 1 : 0, onSuccess: () => {
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":"AAuCA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAEnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAMpD,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;AAwcD,wBAAgB,sBAAsB,CAAC,EACrC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,KAAK,EACL,aAAa,EACb,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,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,2CAgNA;AAgOD,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,UAG1F"}
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.productId == null || category.productId === productId) &&
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
- await create.mutateAsync({
276
- productId,
277
- optionId: null,
278
- unitId: null,
279
- name,
280
- code: state.code.trim() || null,
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: 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) => ({
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: priceRuleMessages.createTravelerCategory })] })] }) }));
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 extrasQuery = useProductExtras({ productId, active: true, limit: 100 });
309
- const rulesQuery = useExtraPriceRules({ optionPriceRuleId, optionId, active: true, limit: 100 });
310
- const { remove } = useExtraPriceRuleMutation();
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 extras = extrasQuery.data?.data ?? [];
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: [_jsx("div", { className: "mb-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: extraPriceMessages.sectionTitle }), _jsx("div", { className: "flex flex-col gap-2", children: extras.map((extra) => {
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: "ml-2 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
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: () => setPricingExtraId(extra.id), children: extraPriceMessages.setPrice }), rule ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => remove.mutate(rule.id, { onSuccess: () => void rulesQuery.refetch() }), children: extraPriceMessages.remove })) : null] })] }, extra.id));
323
- }) }), pricingExtra ? (_jsx(ExtraPriceRuleDialog, { open: !!pricingExtra, onOpenChange: (open) => {
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":"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"}
1
+ {"version":3,"file":"product-translation-popover.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-translation-popover.tsx"],"names":[],"mappings":"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"}
@@ -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
- if (field === "description")
51
- return richTextHasContent(draft.description);
52
- return draft[field].trim().length > 0;
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.102.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.102.0",
54
- "@voyantjs/availability-react": "0.102.0",
55
- "@voyantjs/catalog-react": "0.102.0",
56
- "@voyantjs/extras-react": "0.102.0",
57
- "@voyantjs/finance": "0.102.0",
58
- "@voyantjs/finance-ui": "0.102.0",
59
- "@voyantjs/markets-react": "0.102.0",
60
- "@voyantjs/pricing-react": "0.102.0",
61
- "@voyantjs/pricing-ui": "0.102.0",
62
- "@voyantjs/products-react": "0.102.0",
63
- "@voyantjs/suppliers-react": "0.102.0",
64
- "@voyantjs/ui": "0.102.0",
65
- "@voyantjs/utils": "0.102.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.102.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.102.0",
86
- "@voyantjs/availability-react": "0.102.0",
87
- "@voyantjs/catalog-react": "0.102.0",
88
- "@voyantjs/extras-react": "0.102.0",
89
- "@voyantjs/finance": "0.102.0",
90
- "@voyantjs/finance-ui": "0.102.0",
91
- "@voyantjs/i18n": "0.102.0",
92
- "@voyantjs/markets-react": "0.102.0",
93
- "@voyantjs/pricing-react": "0.102.0",
94
- "@voyantjs/pricing-ui": "0.102.0",
95
- "@voyantjs/products-react": "0.102.0",
96
- "@voyantjs/suppliers-react": "0.102.0",
97
- "@voyantjs/ui": "0.102.0",
98
- "@voyantjs/utils": "0.102.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,4 +0,0 @@
1
- export declare function ProductExtrasSection({ productId }: {
2
- productId: string;
3
- }): import("react/jsx-runtime").JSX.Element;
4
- //# sourceMappingURL=product-extras-section.d.ts.map
@@ -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
- }