@voyantjs/products-ui 0.101.2 → 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-departure-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-departure-form.js +22 -2
- 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 +31 -4
- package/dist/components/product-detail/product-detail-page.d.ts.map +1 -1
- package/dist/components/product-detail/product-detail-page.js +2 -3
- 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 +16 -0
- package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
- package/dist/components/product-detail/product-option-pricing-grid.js +233 -0
- package/dist/components/product-detail/product-options-pricing.d.ts +38 -1
- package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-pricing.js +136 -46
- package/dist/components/product-detail/product-options-shared.d.ts +14 -0
- package/dist/components/product-detail/product-options-shared.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-shared.js +20 -0
- 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/dist/components/product-detail/product-unit-dialog.d.ts +3 -1
- package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-dialog.js +2 -2
- package/dist/components/product-detail/product-unit-form.d.ts +9 -1
- package/dist/components/product-detail/product-unit-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-form.js +37 -7
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +2 -1
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-price-rule-dialog.js +2 -2
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts +2 -1
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-price-rule-form.js +28 -9
- package/dist/components/product-options-section.d.ts.map +1 -1
- package/dist/components/product-options-section.js +31 -20
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-departure-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-departure-form.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"product-departure-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-departure-form.tsx"],"names":[],"mappings":"AA+EA,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAA;IACpD,SAAS,EAAE,OAAO,CAAA;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,aAAa,CAAA;IACpB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAuDD,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,kBAAkB,2CAgZzF"}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useProductResourceTemplates } from "@voyantjs/availability-react";
|
|
2
3
|
import { formatMessage } from "@voyantjs/i18n";
|
|
3
4
|
import { useProductItineraries } from "@voyantjs/products-react";
|
|
4
5
|
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
|
|
5
6
|
import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, } from "@voyantjs/ui/components/combobox";
|
|
6
7
|
import { Loader2 } from "lucide-react";
|
|
7
|
-
import { useEffect } from "react";
|
|
8
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
8
9
|
import { useForm } from "react-hook-form";
|
|
9
10
|
import { z } from "zod/v4";
|
|
10
11
|
import { DatePicker } from "./date-picker.js";
|
|
@@ -119,6 +120,25 @@ export function DepartureForm({ productId, slot, onSuccess, onCancel }) {
|
|
|
119
120
|
const { data: itineraryData } = useProductItineraries(productId);
|
|
120
121
|
const itineraries = itineraryData?.data ?? [];
|
|
121
122
|
const defaultItinerary = itineraries.find((itinerary) => itinerary.isDefault) ?? itineraries[0];
|
|
123
|
+
// Suggested pax = total physical capacity of the configured departure
|
|
124
|
+
// inventory (each room/seat type's count × its capacity, e.g. 20 doubles
|
|
125
|
+
// sleeping 2 = 40). Lets a new departure inherit capacity from the rooms the
|
|
126
|
+
// operator already set up, while staying editable for an override.
|
|
127
|
+
const { data: resourceTemplateData } = useProductResourceTemplates({ productId });
|
|
128
|
+
const suggestedPax = useMemo(() => (resourceTemplateData?.data ?? []).reduce((optionTotal, option) => optionTotal +
|
|
129
|
+
option.templates.reduce((sum, template) => sum + (template.defaultCount ?? 0) * template.capacity, 0), 0), [resourceTemplateData?.data]);
|
|
130
|
+
// Pre-fill capacity once for a brand-new departure, only while the field is
|
|
131
|
+
// still untouched — never clobber an edit or an existing slot's value.
|
|
132
|
+
const prefilledPaxRef = useRef(false);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (isEditing || prefilledPaxRef.current || suggestedPax <= 0)
|
|
135
|
+
return;
|
|
136
|
+
const current = form.getValues("initialPax");
|
|
137
|
+
if (current === "" || current == null) {
|
|
138
|
+
form.setValue("initialPax", suggestedPax);
|
|
139
|
+
prefilledPaxRef.current = true;
|
|
140
|
+
}
|
|
141
|
+
}, [isEditing, suggestedPax, form]);
|
|
122
142
|
const nights = (() => {
|
|
123
143
|
if (!startDate || !endDate || typeof endDate !== "string" || endDate.length === 0)
|
|
124
144
|
return 0;
|
|
@@ -213,5 +233,5 @@ export function DepartureForm({ productId, slot, onSuccess, onCancel }) {
|
|
|
213
233
|
}, children: [_jsx(ComboboxInput, { placeholder: departureMessages.timezoneSearchPlaceholder, className: "w-full" }), _jsxs(ComboboxContent, { children: [_jsx(ComboboxEmpty, { children: departureMessages.timezoneEmpty }), _jsx(ComboboxList, { children: _jsx(ComboboxCollection, { children: (id) => {
|
|
214
234
|
const tz = TIMEZONE_OPTIONS.find((t) => t.id === id);
|
|
215
235
|
return (_jsxs(ComboboxItem, { value: id, children: [_jsx("span", { className: "font-mono text-xs", children: id }), tz ? (_jsx("span", { className: "ml-2 text-xs text-muted-foreground", children: tz.label })) : null] }, id));
|
|
216
|
-
} }) })] })] }), form.formState.errors.timezone && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.timezone.message }))] })] }), _jsxs("fieldset", { className: "grid gap-3", children: [_jsx("legend", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: departureMessages.availabilityLegend }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.statusLabel }), _jsxs(Select, { value: form.watch("status"), onValueChange: (v) => form.setValue("status", v), items: slotStatuses, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: slotStatuses.map((s) => (_jsx(SelectItem, { value: s.value, children: s.label }, s.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.capacityLabel }), _jsx(Input, { ...form.register("initialPax"), type: "number", min: "0", step: "1", placeholder: "0", disabled: unlimited })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "unlimited", checked: unlimited, onCheckedChange: (c) => form.setValue("unlimited", c) }), _jsx(Label, { htmlFor: "unlimited", className: "font-normal cursor-pointer", children: departureMessages.unlimitedLabel })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.notesLabel }), _jsx(Textarea, { ...form.register("notes"), placeholder: departureMessages.notesPlaceholder })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting, children: [form.formState.isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), isEditing ? productMessages.saveChanges : departureMessages.create] })] })] }));
|
|
236
|
+
} }) })] })] }), form.formState.errors.timezone && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.timezone.message }))] })] }), _jsxs("fieldset", { className: "grid gap-3", children: [_jsx("legend", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: departureMessages.availabilityLegend }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.statusLabel }), _jsxs(Select, { value: form.watch("status"), onValueChange: (v) => form.setValue("status", v), items: slotStatuses, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: slotStatuses.map((s) => (_jsx(SelectItem, { value: s.value, children: s.label }, s.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.capacityLabel }), _jsx(Input, { ...form.register("initialPax"), type: "number", min: "0", step: "1", placeholder: "0", disabled: unlimited }), !unlimited && suggestedPax > 0 ? (_jsx("button", { type: "button", onClick: () => form.setValue("initialPax", suggestedPax), className: "text-left text-xs text-muted-foreground hover:text-foreground", children: formatMessage(departureMessages.capacityAutoHint, { count: suggestedPax }) })) : null] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "unlimited", checked: unlimited, onCheckedChange: (c) => form.setValue("unlimited", c) }), _jsx(Label, { htmlFor: "unlimited", className: "font-normal cursor-pointer", children: departureMessages.unlimitedLabel })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.notesLabel }), _jsx(Textarea, { ...form.register("notes"), placeholder: departureMessages.notesPlaceholder })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting, children: [form.formState.isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), isEditing ? productMessages.saveChanges : departureMessages.create] })] })] }));
|
|
217
237
|
}
|
|
@@ -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(),
|
|
@@ -64,13 +73,16 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
64
73
|
{ value: "active", label: productMessages.statusActive },
|
|
65
74
|
{ value: "archived", label: productMessages.statusArchived },
|
|
66
75
|
];
|
|
76
|
+
// Ordered most-common-first for this operator (multi-day tours, then day
|
|
77
|
+
// trips). The chosen mode also drives the option pricing layout
|
|
78
|
+
// (rooms vs per-person seats) — see deriveOptionPricingLayout.
|
|
67
79
|
const bookingModes = [
|
|
80
|
+
{ value: "itinerary", label: productMessages.bookingModeItinerary },
|
|
81
|
+
{ value: "stay", label: productMessages.bookingModeStay },
|
|
68
82
|
{ value: "date", label: productMessages.bookingModeDate },
|
|
69
83
|
{ value: "date_time", label: productMessages.bookingModeDateTime },
|
|
70
|
-
{ value: "open", label: productMessages.bookingModeOpen },
|
|
71
|
-
{ value: "stay", label: productMessages.bookingModeStay },
|
|
72
84
|
{ value: "transfer", label: productMessages.bookingModeTransfer },
|
|
73
|
-
{ value: "
|
|
85
|
+
{ value: "open", label: productMessages.bookingModeOpen },
|
|
74
86
|
{ value: "other", label: productMessages.bookingModeOther },
|
|
75
87
|
];
|
|
76
88
|
const form = useForm({
|
|
@@ -108,6 +120,9 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
108
120
|
name: values.name,
|
|
109
121
|
status: values.status,
|
|
110
122
|
description: values.description || null,
|
|
123
|
+
inclusionsHtml: values.inclusionsHtml || null,
|
|
124
|
+
exclusionsHtml: values.exclusionsHtml || null,
|
|
125
|
+
termsHtml: values.termsHtml || null,
|
|
111
126
|
bookingMode: values.bookingMode,
|
|
112
127
|
productTypeId: values.productTypeId || null,
|
|
113
128
|
taxClassId: values.taxClassId || null,
|
|
@@ -119,6 +134,9 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
119
134
|
defaultLanguageTag: resolvedDefaultLanguage,
|
|
120
135
|
baseName: values.name,
|
|
121
136
|
baseDescription: values.description ?? "",
|
|
137
|
+
baseInclusionsHtml: values.inclusionsHtml ?? "",
|
|
138
|
+
baseExclusionsHtml: values.exclusionsHtml ?? "",
|
|
139
|
+
baseTermsHtml: values.termsHtml ?? "",
|
|
122
140
|
};
|
|
123
141
|
if (isEditing) {
|
|
124
142
|
await api.patch(`/v1/products/${product.id}`, payload);
|
|
@@ -144,7 +162,16 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
|
|
|
144
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: {
|
|
145
163
|
value: form.watch("description") ?? "",
|
|
146
164
|
onChange: (value) => form.setValue("description", value, { shouldDirty: true }),
|
|
147
|
-
}, 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: () => {
|
|
148
175
|
const current = form.getValues("tags") ?? [];
|
|
149
176
|
form.setValue("tags", current.filter((t) => t !== tag), { shouldDirty: true });
|
|
150
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,10 +14,9 @@ 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
|
-
import { getDeparturePriceOverridesQueryOptions } from "./product-options-shared.js";
|
|
19
|
+
import { deriveOptionPricingLayout, getDeparturePriceOverridesQueryOptions, } from "./product-options-shared.js";
|
|
21
20
|
import { ProductPaymentPolicySection } from "./product-payment-policy-section.js";
|
|
22
21
|
import { ScheduleDialog } from "./product-schedule-dialog.js";
|
|
23
22
|
import { useProductDetailData } from "./use-product-detail-data.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) => (
|
|
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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type OptionPricingLayout } from "./product-options-shared.js";
|
|
2
|
+
export interface OptionPricingGridProps {
|
|
3
|
+
productId: string;
|
|
4
|
+
optionId: string;
|
|
5
|
+
optionName: string;
|
|
6
|
+
productCurrency: string;
|
|
7
|
+
layout: OptionPricingLayout;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The everyday pricing surface for a booking option: one table that merges
|
|
11
|
+
* inventory (rooms / traveler types) with what each traveler pays. The single
|
|
12
|
+
* default rate plan is auto-managed and hidden — agents never see catalogs or
|
|
13
|
+
* rate-plan chrome here (that lives under Advanced).
|
|
14
|
+
*/
|
|
15
|
+
export declare function OptionPricingGrid({ productId, optionId, optionName, productCurrency, layout, }: OptionPricingGridProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
//# sourceMappingURL=product-option-pricing-grid.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|