@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.
Files changed (40) hide show
  1. package/dist/components/product-detail/product-departure-form.d.ts.map +1 -1
  2. package/dist/components/product-detail/product-departure-form.js +22 -2
  3. package/dist/components/product-detail/product-detail-form.d.ts +3 -0
  4. package/dist/components/product-detail/product-detail-form.d.ts.map +1 -1
  5. package/dist/components/product-detail/product-detail-form.js +31 -4
  6. package/dist/components/product-detail/product-detail-page.d.ts.map +1 -1
  7. package/dist/components/product-detail/product-detail-page.js +2 -3
  8. package/dist/components/product-detail/product-extra-dialog.d.ts +21 -0
  9. package/dist/components/product-detail/product-extra-dialog.d.ts.map +1 -0
  10. package/dist/components/product-detail/product-extra-dialog.js +131 -0
  11. package/dist/components/product-detail/product-option-pricing-grid.d.ts +16 -0
  12. package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
  13. package/dist/components/product-detail/product-option-pricing-grid.js +233 -0
  14. package/dist/components/product-detail/product-options-pricing.d.ts +38 -1
  15. package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -1
  16. package/dist/components/product-detail/product-options-pricing.js +136 -46
  17. package/dist/components/product-detail/product-options-shared.d.ts +14 -0
  18. package/dist/components/product-detail/product-options-shared.d.ts.map +1 -1
  19. package/dist/components/product-detail/product-options-shared.js +20 -0
  20. package/dist/components/product-detail/product-translation-popover.d.ts +4 -1
  21. package/dist/components/product-detail/product-translation-popover.d.ts.map +1 -1
  22. package/dist/components/product-detail/product-translation-popover.js +28 -8
  23. package/dist/components/product-detail/product-unit-dialog.d.ts +3 -1
  24. package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -1
  25. package/dist/components/product-detail/product-unit-dialog.js +2 -2
  26. package/dist/components/product-detail/product-unit-form.d.ts +9 -1
  27. package/dist/components/product-detail/product-unit-form.d.ts.map +1 -1
  28. package/dist/components/product-detail/product-unit-form.js +37 -7
  29. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +2 -1
  30. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -1
  31. package/dist/components/product-detail/product-unit-price-rule-dialog.js +2 -2
  32. package/dist/components/product-detail/product-unit-price-rule-form.d.ts +2 -1
  33. package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -1
  34. package/dist/components/product-detail/product-unit-price-rule-form.js +28 -9
  35. package/dist/components/product-options-section.d.ts.map +1 -1
  36. package/dist/components/product-options-section.js +31 -20
  37. package/package.json +29 -29
  38. package/dist/components/product-detail/product-extras-section.d.ts +0 -4
  39. package/dist/components/product-detail/product-extras-section.d.ts.map +0 -1
  40. 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":"AA8EA,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,2CAwWzF"}
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;AA6BD,wBAAgB,iBAAiB,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CAqWzF"}
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: "itinerary", label: productMessages.bookingModeItinerary },
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 }), _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: () => {
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":"AAgCA,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,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) => (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(PricingPanel, { productId: id, optionId: option.id, productCurrency: product.sellCurrency }), 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
+ }
@@ -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"}