@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
@@ -0,0 +1,233 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useMutation, useQuery } from "@tanstack/react-query";
4
+ import { formatMessage } from "@voyantjs/i18n";
5
+ import { useOptionPriceRuleMutation, useOptionUnitPriceRuleMutation, usePriceCatalogMutation, usePricingCategoryMutation, } from "@voyantjs/pricing-react";
6
+ import { useOptionUnitMutation, useVoyantProductsContext } from "@voyantjs/products-react";
7
+ import { Button } from "@voyantjs/ui/components/button";
8
+ import { Pencil, Plus, Trash2 } from "lucide-react";
9
+ import { useState } from "react";
10
+ import { useProductDetailMessages } from "./host.js";
11
+ import { categoryAppliesToUnit, ExtraPriceRulesPanel, formatProductMoney, getCategoryCondition, TravelerCategoryDialog, } from "./product-options-pricing.js";
12
+ import { getOptionPriceRulesQueryOptions, getOptionUnitPriceRulesQueryOptions, getOptionUnitsQueryOptions, getPriceCatalogsQueryOptions, getPricingCategoriesQueryOptions, } from "./product-options-shared.js";
13
+ import { UnitDialog } from "./product-unit-dialog.js";
14
+ import { UnitPriceRuleDialog, } from "./product-unit-price-rule-dialog.js";
15
+ function formatAvailability(unit, messages) {
16
+ if (unit.maxQuantity != null && unit.maxQuantity > 0) {
17
+ return formatMessage(messages.perDeparture, { count: unit.maxQuantity });
18
+ }
19
+ return "—";
20
+ }
21
+ function unitSubtitle(unit, layout, messages) {
22
+ if (layout === "rooms") {
23
+ const sleeps = unit.occupancyMax ?? unit.occupancyMin;
24
+ return sleeps != null ? formatMessage(messages.sleeps, { count: sleeps }) : null;
25
+ }
26
+ if (unit.minAge != null || unit.maxAge != null) {
27
+ return `${unit.minAge ?? 0}–${unit.maxAge ?? "∞"}`;
28
+ }
29
+ return null;
30
+ }
31
+ /**
32
+ * The everyday pricing surface for a booking option: one table that merges
33
+ * inventory (rooms / traveler types) with what each traveler pays. The single
34
+ * default rate plan is auto-managed and hidden — agents never see catalogs or
35
+ * rate-plan chrome here (that lives under Advanced).
36
+ */
37
+ export function OptionPricingGrid({ productId, optionId, optionName, productCurrency, layout, }) {
38
+ const client = useVoyantProductsContext();
39
+ const messages = useProductDetailMessages();
40
+ const t = messages.products.operations.pricingGrid;
41
+ const priceRuleMessages = messages.products.operations.priceRules;
42
+ const { data: unitsData, refetch: refetchUnits } = useQuery(getOptionUnitsQueryOptions(client, optionId));
43
+ const { data: rulesData, refetch: refetchRules } = useQuery(getOptionPriceRulesQueryOptions(client, optionId));
44
+ const { data: categoriesData, refetch: refetchCategories } = useQuery(getPricingCategoriesQueryOptions(client));
45
+ const { data: catalogsData } = useQuery(getPriceCatalogsQueryOptions(client));
46
+ const rules = rulesData?.data ?? [];
47
+ const defaultRule = rules.find((rule) => rule.isDefault) ?? rules[0];
48
+ const { data: cellsData, refetch: refetchCells } = useQuery({
49
+ ...getOptionUnitPriceRulesQueryOptions(client, defaultRule?.id ?? "__none__"),
50
+ enabled: Boolean(defaultRule?.id),
51
+ });
52
+ const { remove: removeUnit } = useOptionUnitMutation();
53
+ const { remove: removeCell } = useOptionUnitPriceRuleMutation();
54
+ const { create: createRule } = useOptionPriceRuleMutation();
55
+ const { create: createCatalog } = usePriceCatalogMutation();
56
+ const { remove: removeCategory } = usePricingCategoryMutation();
57
+ const deleteUnitMutation = useMutation({
58
+ mutationFn: (id) => removeUnit.mutateAsync(id),
59
+ onSuccess: () => {
60
+ void refetchUnits();
61
+ void refetchCells();
62
+ },
63
+ });
64
+ const deleteCellMutation = useMutation({
65
+ mutationFn: (id) => removeCell.mutateAsync(id),
66
+ onSuccess: () => void refetchCells(),
67
+ });
68
+ const [unitDialogOpen, setUnitDialogOpen] = useState(false);
69
+ const [editingUnit, setEditingUnit] = useState();
70
+ const [defaultUnitType, setDefaultUnitType] = useState("room");
71
+ const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
72
+ const [editingCategory, setEditingCategory] = useState();
73
+ const [cellDialogOpen, setCellDialogOpen] = useState(false);
74
+ const [cellRuleId, setCellRuleId] = useState();
75
+ const [editingCell, setEditingCell] = useState();
76
+ const [preselectedUnitId, setPreselectedUnitId] = useState();
77
+ const [preselectedCategoryId, setPreselectedCategoryId] = useState();
78
+ const units = (unitsData?.data ?? [])
79
+ .filter((unit) => !unit.isHidden)
80
+ .slice()
81
+ .sort((a, b) => a.sortOrder - b.sortOrder);
82
+ // Inventory wins over the booking-mode hint: an option that actually holds
83
+ // rooms (or vehicles/groups) is always priced as a rooms grid, even if the
84
+ // product's booking mode was set to a per-person type. The `layout` prop only
85
+ // decides the shape for a brand-new option that has no inventory yet.
86
+ const hasRoomLikeUnits = units.some((unit) => unit.unitType === "room" || unit.unitType === "vehicle" || unit.unitType === "group");
87
+ const hasPersonUnits = units.some((unit) => unit.unitType === "person");
88
+ const effectiveLayout = hasRoomLikeUnits
89
+ ? "rooms"
90
+ : hasPersonUnits
91
+ ? "seats"
92
+ : layout;
93
+ const cells = cellsData?.data ?? [];
94
+ const referencedCategoryIds = new Set(cells.flatMap((cell) => (cell.pricingCategoryId ? [cell.pricingCategoryId] : [])));
95
+ const categories = (categoriesData?.data ?? [])
96
+ .filter((category) => category.active &&
97
+ (((category.productId == null || category.productId === productId) &&
98
+ (category.optionId == null || category.optionId === optionId)) ||
99
+ referencedCategoryIds.has(category.id)))
100
+ .slice()
101
+ .sort((a, b) => a.sortOrder - b.sortOrder);
102
+ // Traveler-type columns. Seats layout prices each traveler-type row once
103
+ // (single price column). Rooms layout splits price by traveler category once
104
+ // any exist, else shows a single base-price column per room.
105
+ const columns = effectiveLayout === "rooms" && categories.length > 0
106
+ ? categories.map((category) => ({
107
+ id: category.id,
108
+ name: category.name,
109
+ metadata: category.metadata,
110
+ }))
111
+ : [{ id: null, name: t.priceColumn }];
112
+ const nextUnitSortOrder = units.length > 0 ? Math.max(...units.map((u) => u.sortOrder)) + 1 : 0;
113
+ const findCell = (unitId, categoryId) => cells.find((cell) => cell.unitId === unitId && (cell.pricingCategoryId ?? null) === categoryId) ?? null;
114
+ // Lazily materialize the hidden default rate plan (and a default catalog if
115
+ // the tenant has none) the first time the agent enters a price. Keeps the
116
+ // common path free of any rate-plan/catalog ceremony.
117
+ async function ensureRatePlanId() {
118
+ if (defaultRule?.id)
119
+ return defaultRule.id;
120
+ const catalogs = catalogsData?.data ?? [];
121
+ const existingCatalog = catalogs.find((catalog) => catalog.isDefault) ?? catalogs[0];
122
+ const catalogId = existingCatalog?.id ??
123
+ (await createCatalog.mutateAsync({
124
+ code: "default",
125
+ name: t.priceColumn,
126
+ catalogType: "public",
127
+ isDefault: true,
128
+ })).id;
129
+ const created = await createRule.mutateAsync({
130
+ productId,
131
+ optionId,
132
+ priceCatalogId: catalogId,
133
+ name: optionName,
134
+ pricingMode: "per_person",
135
+ baseSellAmountCents: 0,
136
+ baseCostAmountCents: 0,
137
+ allPricingCategories: effectiveLayout === "seats",
138
+ isDefault: true,
139
+ active: true,
140
+ });
141
+ await refetchRules();
142
+ return created.id;
143
+ }
144
+ async function openCellDialog(unit, categoryId) {
145
+ const ruleId = await ensureRatePlanId();
146
+ setCellRuleId(ruleId);
147
+ setEditingCell(undefined);
148
+ setPreselectedUnitId(unit.id);
149
+ setPreselectedCategoryId(categoryId);
150
+ setCellDialogOpen(true);
151
+ }
152
+ const addRoomOrTraveler = () => {
153
+ setEditingUnit(undefined);
154
+ setDefaultUnitType(effectiveLayout === "rooms" ? "room" : "person");
155
+ setUnitDialogOpen(true);
156
+ };
157
+ const editTravelerType = (category) => {
158
+ setEditingCategory(category);
159
+ setCategoryDialogOpen(true);
160
+ };
161
+ async function removeTravelerType(category) {
162
+ if (!confirm(formatMessage(messages.products.operations.priceRules.travelerCategoryDeleteConfirm, {
163
+ name: category.name,
164
+ }))) {
165
+ return;
166
+ }
167
+ // Categories this product/option owns are deleted outright. A global
168
+ // category only shows here because some cell references it, so removing
169
+ // its prices drops the column from this option without touching the
170
+ // shared category.
171
+ if (category.productId === productId || category.optionId === optionId) {
172
+ await removeCategory.mutateAsync(category.id);
173
+ void refetchCategories();
174
+ void refetchCells();
175
+ }
176
+ else {
177
+ for (const cell of cells.filter((entry) => entry.pricingCategoryId === category.id)) {
178
+ await removeCell.mutateAsync(cell.id);
179
+ }
180
+ void refetchCells();
181
+ }
182
+ }
183
+ const unitColumnLabel = effectiveLayout === "rooms" ? t.roomColumn : t.travelerColumn;
184
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: effectiveLayout === "rooms" ? t.roomsTitle : t.seatsTitle }), _jsx("p", { className: "text-xs text-muted-foreground", children: effectiveLayout === "rooms" ? t.roomsDescription : t.seatsDescription })] }), _jsxs("div", { className: "flex items-center gap-2", children: [effectiveLayout === "rooms" ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
185
+ setEditingCategory(undefined);
186
+ setCategoryDialogOpen(true);
187
+ }, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), t.addTravelerType] })) : null, _jsxs(Button, { variant: "outline", size: "sm", onClick: addRoomOrTraveler, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), effectiveLayout === "rooms" ? t.addRoom : t.addTravelerType] })] })] }), units.length === 0 ? (_jsx("p", { className: "rounded-md border bg-background px-3 py-6 text-center text-sm text-muted-foreground", children: effectiveLayout === "rooms" ? t.emptyRooms : t.emptySeats })) : (_jsx("div", { className: "overflow-x-auto rounded-md border bg-background", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b bg-muted/40 text-muted-foreground", children: [_jsx("th", { className: "p-2.5 text-left font-medium", children: unitColumnLabel }), _jsx("th", { className: "p-2.5 text-left font-medium", children: t.availableColumn }), columns.map((column) => {
188
+ const condition = getCategoryCondition(column.metadata);
189
+ const category = column.id
190
+ ? categories.find((entry) => entry.id === column.id)
191
+ : undefined;
192
+ return (_jsx("th", { className: "group p-2.5 text-left font-medium", children: _jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsxs("div", { children: [_jsx("div", { children: column.name }), condition ? (_jsx("div", { className: "mt-0.5 max-w-[220px] text-[10px] font-normal normal-case leading-snug text-muted-foreground", children: condition })) : null] }), category ? (_jsxs("div", { className: "flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100", children: [_jsx("button", { type: "button", "aria-label": priceRuleMessages.travelerCategoryEdit, onClick: () => editTravelerType(category), className: "text-muted-foreground hover:text-foreground", children: _jsx(Pencil, { className: "h-3 w-3" }) }), _jsx("button", { type: "button", "aria-label": priceRuleMessages.travelerCategoryDelete, onClick: () => void removeTravelerType(category), className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3 w-3" }) })] })) : null] }) }, column.id ?? "__base__"));
193
+ }), _jsx("th", { className: "w-[72px] p-2.5 text-right font-medium" })] }) }), _jsx("tbody", { children: units.map((unit) => {
194
+ const subtitle = unitSubtitle(unit, effectiveLayout, t);
195
+ return (_jsxs("tr", { className: "border-b last:border-b-0", children: [_jsxs("td", { className: "p-2.5", children: [_jsx("div", { className: "font-medium", children: unit.name }), subtitle ? (_jsx("div", { className: "text-[11px] text-muted-foreground", children: subtitle })) : null] }), _jsx("td", { className: "p-2.5 text-muted-foreground", children: formatAvailability(unit, t) }), columns.map((column) => {
196
+ const cell = findCell(unit.id, column.id);
197
+ const canPrice = categoryAppliesToUnit(column, unit);
198
+ return (_jsx("td", { className: "p-2.5", children: cell ? (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("button", { type: "button", onClick: () => {
199
+ setCellRuleId(defaultRule?.id);
200
+ setEditingCell(cell);
201
+ setPreselectedUnitId(undefined);
202
+ setPreselectedCategoryId(undefined);
203
+ setCellDialogOpen(true);
204
+ }, className: "font-mono text-foreground hover:underline", children: formatProductMoney(cell.sellAmountCents, productCurrency) }), _jsx("button", { type: "button", "aria-label": t.deleteRoom, onClick: () => deleteCellMutation.mutate(cell.id), className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3 w-3" }) })] })) : canPrice ? (_jsxs("button", { type: "button", onClick: () => void openCellDialog(unit, column.id), className: "inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground", children: [_jsx(Plus, { className: "h-3 w-3" }), t.setPrice] })) : (_jsx("span", { className: "text-muted-foreground", children: "\u2014" })) }, column.id ?? "__base__"));
205
+ }), _jsx("td", { className: "p-2.5", children: _jsxs("div", { className: "flex items-center justify-end gap-1", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": t.editRoom, onClick: () => {
206
+ setEditingUnit(unit);
207
+ setDefaultUnitType(unit.unitType);
208
+ setUnitDialogOpen(true);
209
+ }, children: _jsx(Pencil, { className: "h-4 w-4" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": t.deleteRoom, onClick: () => {
210
+ if (confirm(formatMessage(t.deleteRoomConfirm, { name: unit.name }))) {
211
+ deleteUnitMutation.mutate(unit.id);
212
+ }
213
+ }, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }) })] }, unit.id));
214
+ }) })] }) })), _jsx(ExtraPriceRulesPanel, { productId: productId, optionId: optionId, optionPriceRuleId: defaultRule?.id, ensureOptionPriceRuleId: ensureRatePlanId, productCurrency: productCurrency }), _jsx(UnitDialog, { open: unitDialogOpen, onOpenChange: setUnitDialogOpen, optionId: optionId, unit: editingUnit, defaultUnitType: editingUnit ? undefined : defaultUnitType, lockUnitType: true, nextSortOrder: nextUnitSortOrder, onSuccess: () => {
215
+ setUnitDialogOpen(false);
216
+ setEditingUnit(undefined);
217
+ void refetchUnits();
218
+ } }), _jsx(TravelerCategoryDialog, { open: categoryDialogOpen, onOpenChange: (open) => {
219
+ setCategoryDialogOpen(open);
220
+ if (!open)
221
+ setEditingCategory(undefined);
222
+ }, productId: productId, units: units, category: editingCategory, nextSortOrder: categories.length > 0 ? Math.max(...categories.map((c) => c.sortOrder)) + 1 : 0, onSuccess: () => {
223
+ setCategoryDialogOpen(false);
224
+ setEditingCategory(undefined);
225
+ void refetchCategories();
226
+ } }), _jsx(UnitPriceRuleDialog, { open: cellDialogOpen, onOpenChange: setCellDialogOpen, optionPriceRuleId: cellRuleId ?? defaultRule?.id ?? "", optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
227
+ setCellDialogOpen(false);
228
+ setEditingCell(undefined);
229
+ setPreselectedUnitId(undefined);
230
+ setPreselectedCategoryId(undefined);
231
+ void refetchCells();
232
+ } })] }));
233
+ }
@@ -1,6 +1,43 @@
1
- export declare function PricingPanel({ productId, optionId, productCurrency, }: {
1
+ import { type PricingCategoryRecord } from "@voyantjs/pricing-react";
2
+ import type * as React from "react";
3
+ import { useProductDetailMessages } from "./host.js";
4
+ import { type OptionPricingLayout } from "./product-options-shared.js";
5
+ import type { OptionUnitData } from "./product-unit-dialog.js";
6
+ export declare function getUnitTypeLabel(type: OptionUnitData["unitType"], messages: ReturnType<typeof useProductDetailMessages>["products"]["operations"]["units"]): string;
7
+ export declare function getCategoryCondition(metadata: Record<string, unknown> | null | undefined): string | null;
8
+ export declare function categoryAppliesToUnit(category: {
9
+ id: string | null;
10
+ metadata?: Record<string, unknown> | null;
11
+ }, unit: OptionUnitData): boolean;
12
+ /**
13
+ * Per-option pricing surface. The everyday view is the merged rooms/seats
14
+ * grid; the full rate-plan machinery (multiple plans, catalogs, cost prices,
15
+ * cancellation) plus any injected per-departure inventory live behind an
16
+ * Advanced disclosure so low-tech agents never have to see them.
17
+ */
18
+ export declare function PricingPanel({ productId, optionId, optionName, productCurrency, layout, extras, }: {
2
19
  productId: string;
3
20
  optionId: string;
21
+ optionName: string;
4
22
  productCurrency: string;
23
+ layout: OptionPricingLayout;
24
+ extras?: React.ReactNode;
5
25
  }): import("react/jsx-runtime").JSX.Element;
26
+ export declare function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, category, onSuccess, }: {
27
+ open: boolean;
28
+ onOpenChange: (open: boolean) => void;
29
+ productId: string;
30
+ units: OptionUnitData[];
31
+ nextSortOrder: number;
32
+ category?: PricingCategoryRecord;
33
+ onSuccess: () => void;
34
+ }): import("react/jsx-runtime").JSX.Element;
35
+ export declare function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, ensureOptionPriceRuleId, productCurrency, }: {
36
+ productId: string;
37
+ optionId: string;
38
+ optionPriceRuleId?: string;
39
+ ensureOptionPriceRuleId?: () => Promise<string>;
40
+ productCurrency: string;
41
+ }): import("react/jsx-runtime").JSX.Element;
42
+ export declare function formatProductMoney(amountCents: number | null | undefined, currency: string): string;
6
43
  //# sourceMappingURL=product-options-pricing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"product-options-pricing.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-pricing.tsx"],"names":[],"mappings":"AA+HA,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,QAAQ,EACR,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,2CA6EA"}
1
+ {"version":3,"file":"product-options-pricing.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-pricing.tsx"],"names":[],"mappings":"AAOA,OAAO,EAEL,KAAK,qBAAqB,EAM3B,MAAM,yBAAyB,CAAA;AA4BhC,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAEnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAOpD,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,6BAA6B,CAAA;AACpC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AA0B9D,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,QAAQ,EAAE,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC,UAAU,CAAC,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,UAkBzF;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,iBAGxF;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE;IAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CAAE,EAC1E,IAAI,EAAE,cAAc,WAMrB;AAeD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,QAAQ,EACR,UAAU,EACV,eAAe,EACf,MAAM,EACN,MAAM,GACP,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,MAAM,EAAE,mBAAmB,CAAA;IAC3B,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CACzB,2CA+CA;AAwdD,wBAAgB,sBAAsB,CAAC,EACrC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,KAAK,EACL,aAAa,EACb,QAAQ,EACR,SAAS,GACV,EAAE;IACD,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,qBAAqB,CAAA;IAChC,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,2CAiOA;AAED,wBAAgB,oBAAoB,CAAC,EACnC,SAAS,EACT,QAAQ,EACR,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAIhB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,uBAAuB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IAC/C,eAAe,EAAE,MAAM,CAAA;CACxB,2CAwJA;AAkID,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,UAG1F"}
@@ -1,15 +1,17 @@
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";
7
7
  import { Badge, Button, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@voyantjs/ui/components";
8
8
  import { Checkbox } from "@voyantjs/ui/components/checkbox";
9
- import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
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";
14
+ import { OptionPricingGrid } from "./product-option-pricing-grid.js";
13
15
  import { getOptionPriceRulesQueryOptions, getOptionUnitPriceRulesQueryOptions, getOptionUnitsQueryOptions, getPricingCategoriesQueryOptions, } from "./product-options-shared.js";
14
16
  import { UnitPriceRuleDialog, } from "./product-unit-price-rule-dialog.js";
15
17
  function getRulePricingModeLabel(value, messages) {
@@ -28,7 +30,7 @@ function getRulePricingModeLabel(value, messages) {
28
30
  return value;
29
31
  }
30
32
  }
31
- function getUnitTypeLabel(type, messages) {
33
+ export function getUnitTypeLabel(type, messages) {
32
34
  switch (type) {
33
35
  case "person":
34
36
  return messages.typePerson;
@@ -46,11 +48,11 @@ function getUnitTypeLabel(type, messages) {
46
48
  return type;
47
49
  }
48
50
  }
49
- function getCategoryCondition(metadata) {
51
+ export function getCategoryCondition(metadata) {
50
52
  const condition = metadata?.condition;
51
53
  return typeof condition === "string" && condition.trim().length > 0 ? condition : null;
52
54
  }
53
- function categoryAppliesToUnit(category, unit) {
55
+ export function categoryAppliesToUnit(category, unit) {
54
56
  if (!category.id)
55
57
  return true;
56
58
  const allowedUnitIds = category.metadata?.allowedUnitIds;
@@ -61,7 +63,19 @@ function categoryAppliesToUnit(category, unit) {
61
63
  function ActionMenu({ children }) {
62
64
  return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 text-muted-foreground", children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: children })] }));
63
65
  }
64
- export function PricingPanel({ productId, optionId, productCurrency, }) {
66
+ /**
67
+ * Per-option pricing surface. The everyday view is the merged rooms/seats
68
+ * grid; the full rate-plan machinery (multiple plans, catalogs, cost prices,
69
+ * cancellation) plus any injected per-departure inventory live behind an
70
+ * Advanced disclosure so low-tech agents never have to see them.
71
+ */
72
+ export function PricingPanel({ productId, optionId, optionName, productCurrency, layout, extras, }) {
73
+ const messages = useProductDetailMessages();
74
+ const gridMessages = messages.products.operations.pricingGrid;
75
+ const [advancedOpen, setAdvancedOpen] = useState(false);
76
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(OptionPricingGrid, { productId: productId, optionId: optionId, optionName: optionName, productCurrency: productCurrency, layout: layout }), _jsxs("div", { className: "rounded-md border bg-background/60", children: [_jsxs("button", { type: "button", onClick: () => setAdvancedOpen((open) => !open), className: "flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground", children: [advancedOpen ? (_jsx(ChevronDown, { className: "h-3.5 w-3.5" })) : (_jsx(ChevronRight, { className: "h-3.5 w-3.5" })), _jsx("span", { children: gridMessages.advancedToggle }), !advancedOpen ? (_jsxs("span", { className: "font-normal normal-case", children: ["\u2014 ", gridMessages.advancedHint] })) : null] }), advancedOpen ? (_jsx("div", { className: "flex flex-col gap-4 border-t p-3", children: _jsx(AdvancedRatePlans, { productId: productId, optionId: optionId, productCurrency: productCurrency }) })) : null] }), extras] }));
77
+ }
78
+ function AdvancedRatePlans({ productId, optionId, productCurrency, }) {
65
79
  const messages = useProductDetailMessages();
66
80
  const client = useVoyantProductsContext();
67
81
  const priceRuleMessages = messages.products.operations.priceRules;
@@ -74,10 +88,19 @@ export function PricingPanel({ productId, optionId, productCurrency, }) {
74
88
  onSuccess: () => void refetch(),
75
89
  });
76
90
  const rules = data?.data ?? [];
77
- return (_jsxs("div", { children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: priceRuleMessages.sectionTitle }), _jsx("p", { className: "text-xs text-muted-foreground", children: priceRuleMessages.sectionDescription })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
78
- setEditingRule(undefined);
79
- setRuleDialogOpen(true);
80
- }, children: [_jsx(Plus, { className: "mr-1 h-3 w-3" }), priceRuleMessages.addAction] })] }), rules.length === 0 ? (_jsx("p", { className: "py-2 text-center text-xs text-muted-foreground", children: priceRuleMessages.empty })) : (_jsx("div", { className: "flex flex-col gap-3", children: rules.map((rule) => (_jsx(PriceRuleCard, { rule: rule, productId: productId, optionId: optionId, productCurrency: productCurrency, onEdit: () => {
91
+ // The default rate plan IS the everyday grid above don't re-render its
92
+ // identical matrix here. Advanced only manages the *extra* plans (net,
93
+ // contract, promo) plus the default plan's hidden settings (cost,
94
+ // cancellation, catalog) via "Edit default pricing".
95
+ const defaultRule = rules.find((rule) => rule.isDefault) ?? rules[0];
96
+ const additionalRules = rules.filter((rule) => rule.id !== defaultRule?.id);
97
+ return (_jsxs("div", { children: [_jsxs("div", { className: "mb-2 flex items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: priceRuleMessages.additionalSectionTitle }), _jsx("p", { className: "text-xs text-muted-foreground", children: priceRuleMessages.additionalSectionDescription })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [defaultRule ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => {
98
+ setEditingRule(defaultRule);
99
+ setRuleDialogOpen(true);
100
+ }, children: priceRuleMessages.editDefaultAction })) : null, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
101
+ setEditingRule(undefined);
102
+ setRuleDialogOpen(true);
103
+ }, children: [_jsx(Plus, { className: "mr-1 h-3 w-3" }), priceRuleMessages.addAction] })] })] }), additionalRules.length === 0 ? (_jsx("p", { className: "py-2 text-center text-xs text-muted-foreground", children: priceRuleMessages.additionalEmpty })) : (_jsx("div", { className: "flex flex-col gap-3", children: additionalRules.map((rule) => (_jsx(PriceRuleCard, { rule: rule, productId: productId, optionId: optionId, productCurrency: productCurrency, onEdit: () => {
81
104
  setEditingRule(rule);
82
105
  setRuleDialogOpen(true);
83
106
  }, onDelete: () => {
@@ -176,7 +199,7 @@ function UnitPriceMatrix({ productId, optionPriceRuleId, optionId, pricingMode,
176
199
  })] }, unit.id))) })] }) }), _jsx(TravelerCategoryDialog, { open: categoryDialogOpen, onOpenChange: setCategoryDialogOpen, productId: productId, units: units, nextSortOrder: categories.length > 0 ? Math.max(...categories.map((c) => c.sortOrder)) + 1 : 0, onSuccess: () => {
177
200
  setCategoryDialogOpen(false);
178
201
  void refetchCategories();
179
- } }), _jsx(UnitPriceRuleDialog, { open: dialogOpen, onOpenChange: setDialogOpen, optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
202
+ } }), _jsx(UnitPriceRuleDialog, { open: dialogOpen, onOpenChange: setDialogOpen, optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
180
203
  setDialogOpen(false);
181
204
  setEditingCell(undefined);
182
205
  setPreselectedUnitId(undefined);
@@ -195,6 +218,21 @@ function initialTravelerCategoryState() {
195
218
  allowedUnitIds: [],
196
219
  };
197
220
  }
221
+ function stateFromCategory(category) {
222
+ const metadata = category.metadata ?? {};
223
+ const allowedUnitIds = Array.isArray(metadata.allowedUnitIds)
224
+ ? metadata.allowedUnitIds.filter((id) => typeof id === "string")
225
+ : [];
226
+ return {
227
+ name: category.name,
228
+ code: category.code ?? "",
229
+ categoryType: category.categoryType,
230
+ minAge: category.minAge != null ? String(category.minAge) : "",
231
+ maxAge: category.maxAge != null ? String(category.maxAge) : "",
232
+ condition: typeof metadata.condition === "string" ? metadata.condition : "",
233
+ allowedUnitIds,
234
+ };
235
+ }
198
236
  function parseOptionalInteger(value) {
199
237
  const trimmed = value.trim();
200
238
  if (!trimmed)
@@ -202,11 +240,12 @@ function parseOptionalInteger(value) {
202
240
  const parsed = Number(trimmed);
203
241
  return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
204
242
  }
205
- function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }) {
243
+ export function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, category, onSuccess, }) {
206
244
  const messages = useProductDetailMessages();
207
245
  const priceRuleMessages = messages.products.operations.priceRules;
208
246
  const pricingCategoryMessages = messages.pricing.categories;
209
- const { create } = usePricingCategoryMutation();
247
+ const { create, update } = usePricingCategoryMutation();
248
+ const isEditing = !!category;
210
249
  const [state, setState] = useState(() => initialTravelerCategoryState());
211
250
  const [error, setError] = useState(null);
212
251
  const travelerCategoryTypes = [
@@ -219,10 +258,10 @@ function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSort
219
258
  ];
220
259
  useEffect(() => {
221
260
  if (open) {
222
- setState(initialTravelerCategoryState());
261
+ setState(category ? stateFromCategory(category) : initialTravelerCategoryState());
223
262
  setError(null);
224
263
  }
225
- }, [open]);
264
+ }, [open, category]);
226
265
  const toggleUnit = (unitId, checked) => {
227
266
  setState((prev) => ({
228
267
  ...prev,
@@ -249,59 +288,110 @@ function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSort
249
288
  metadata.allowedUnitCodes = selectedUnits.map((unit) => unit.code).filter(Boolean);
250
289
  metadata.allowedUnitNames = selectedUnits.map((unit) => unit.name);
251
290
  }
291
+ const payload = {
292
+ // On edit, preserve the category's existing scope — re-stamping a shared
293
+ // (global) category with this product's id would silently steal it from
294
+ // every other product that relies on it. Only a freshly created category
295
+ // is scoped to the current product.
296
+ productId: category ? (category.productId ?? null) : productId,
297
+ optionId: category ? (category.optionId ?? null) : null,
298
+ unitId: null,
299
+ name,
300
+ code: state.code.trim() || null,
301
+ categoryType: state.categoryType,
302
+ seatOccupancy: 1,
303
+ isAgeQualified: minAge != null || maxAge != null,
304
+ minAge,
305
+ maxAge,
306
+ internalUseOnly: false,
307
+ active: true,
308
+ sortOrder: category?.sortOrder ?? nextSortOrder,
309
+ metadata: Object.keys(metadata).length > 0 ? metadata : null,
310
+ };
252
311
  try {
253
- await create.mutateAsync({
254
- productId,
255
- optionId: null,
256
- unitId: null,
257
- name,
258
- code: state.code.trim() || null,
259
- categoryType: state.categoryType,
260
- seatOccupancy: 1,
261
- isAgeQualified: minAge != null || maxAge != null,
262
- minAge,
263
- maxAge,
264
- internalUseOnly: false,
265
- active: true,
266
- sortOrder: nextSortOrder,
267
- metadata: Object.keys(metadata).length > 0 ? metadata : null,
268
- });
312
+ if (category) {
313
+ await update.mutateAsync({ id: category.id, input: payload });
314
+ }
315
+ else {
316
+ await create.mutateAsync(payload);
317
+ }
269
318
  onSuccess();
270
319
  }
271
320
  catch (err) {
272
321
  setError(err instanceof Error ? err.message : priceRuleMessages.travelerCategorySaveFailed);
273
322
  }
274
323
  };
275
- 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) => ({
324
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEditing
325
+ ? priceRuleMessages.travelerCategoryEditTitle
326
+ : priceRuleMessages.travelerCategoryDialogTitle }), _jsx(DialogDescription, { children: priceRuleMessages.travelerCategoryDialogDescription })] }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-name", children: pricingCategoryMessages.nameLabel }), _jsx(Input, { id: "traveler-category-name", autoFocus: true, value: state.name, placeholder: priceRuleMessages.travelerCategoryNamePlaceholder, onChange: (event) => setState((prev) => ({ ...prev, name: event.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-code", children: pricingCategoryMessages.codeLabel }), _jsx(Input, { id: "traveler-category-code", value: state.code, placeholder: priceRuleMessages.travelerCategoryCodePlaceholder, onChange: (event) => setState((prev) => ({ ...prev, code: event.target.value })) })] })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: pricingCategoryMessages.typeLabel }), _jsxs(Select, { value: state.categoryType, onValueChange: (value) => setState((prev) => ({
276
327
  ...prev,
277
328
  categoryType: (value ?? "child"),
278
329
  })), items: travelerCategoryTypes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: travelerCategoryTypes.map((type) => (_jsx(SelectItem, { value: type.value, children: type.label }, type.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-min-age", children: pricingCategoryMessages.minAgeLabel }), _jsx(Input, { id: "traveler-category-min-age", type: "number", min: "0", value: state.minAge, onChange: (event) => setState((prev) => ({ ...prev, minAge: event.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-max-age", children: pricingCategoryMessages.maxAgeLabel }), _jsx(Input, { id: "traveler-category-max-age", type: "number", min: "0", value: state.maxAge, onChange: (event) => setState((prev) => ({ ...prev, maxAge: event.target.value })) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: priceRuleMessages.travelerCategoryAppliesToLabel }), _jsx("div", { className: "grid gap-2 rounded border p-3 sm:grid-cols-3", children: units.map((unit) => {
279
330
  const checkboxId = `traveler-category-unit-${unit.id}`;
280
331
  return (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: checkboxId, checked: state.allowedUnitIds.includes(unit.id), onCheckedChange: (checked) => toggleUnit(unit.id, checked === true) }), _jsx(Label, { htmlFor: checkboxId, className: "font-normal", children: unit.name })] }, unit.id));
281
- }) }), _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 })] })] }) }));
332
+ }) }), _jsx("p", { className: "text-muted-foreground text-xs", children: priceRuleMessages.travelerCategoryAppliesToHint })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-condition", children: priceRuleMessages.travelerCategoryConditionLabel }), _jsx(Textarea, { id: "traveler-category-condition", value: state.condition, placeholder: priceRuleMessages.travelerCategoryConditionPlaceholder, onChange: (event) => setState((prev) => ({ ...prev, condition: event.target.value })) })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null] }), _jsxs(DialogFooter, { className: "-mx-6 -mb-6", children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: pricingCategoryMessages.cancel }), _jsx(Button, { onClick: () => void save(), disabled: create.isPending || update.isPending, children: isEditing
333
+ ? priceRuleMessages.updateTravelerCategory
334
+ : priceRuleMessages.createTravelerCategory })] })] }) }));
282
335
  }
283
- function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, productCurrency, }) {
336
+ export function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, ensureOptionPriceRuleId, productCurrency, }) {
284
337
  const messages = useProductDetailMessages();
285
338
  const extraPriceMessages = messages.products.operations.extraPrices;
286
- const extrasQuery = useProductExtras({ productId, active: true, limit: 100 });
287
- const rulesQuery = useExtraPriceRules({ optionPriceRuleId, optionId, active: true, limit: 100 });
288
- const { remove } = useExtraPriceRuleMutation();
339
+ const extraMessages = messages.products.operations.extras;
340
+ const extrasQuery = useProductExtras({ productId, limit: 100 });
341
+ const rulesQuery = useExtraPriceRules({
342
+ optionPriceRuleId: optionPriceRuleId ?? "__none__",
343
+ optionId,
344
+ active: true,
345
+ limit: 100,
346
+ enabled: !!optionPriceRuleId,
347
+ });
348
+ const { remove: removeExtra } = useProductExtraMutation();
289
349
  const [pricingExtraId, setPricingExtraId] = useState(null);
290
- const extras = extrasQuery.data?.data ?? [];
350
+ const [pricingRuleId, setPricingRuleId] = useState(optionPriceRuleId);
351
+ const [definitionDialogOpen, setDefinitionDialogOpen] = useState(false);
352
+ const [editingExtra, setEditingExtra] = useState();
353
+ const extras = (extrasQuery.data?.data ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
291
354
  const rules = rulesQuery.data?.data ?? [];
292
355
  const ruleByExtraId = new Map(rules.flatMap((rule) => (rule.productExtraId ? [[rule.productExtraId, rule]] : [])));
293
- if (extras.length === 0)
294
- return null;
295
356
  const pricingExtra = extras.find((extra) => extra.id === pricingExtraId) ?? null;
296
- 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) => {
357
+ return (_jsxs("div", { className: "mt-4 border-t pt-3", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between gap-2", children: [_jsx("div", { className: "text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: extraMessages.sectionTitle }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
358
+ setEditingExtra(undefined);
359
+ setDefinitionDialogOpen(true);
360
+ }, children: [_jsx(Plus, { className: "mr-1 h-3 w-3" }), extraMessages.addAction] })] }), extras.length === 0 ? (_jsx("p", { className: "py-2 text-center text-xs text-muted-foreground", children: extraMessages.empty })) : (_jsx("div", { className: "flex flex-col gap-2", children: extras.map((extra) => {
297
361
  const rule = ruleByExtraId.get(extra.id);
298
- 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
362
+ return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded border px-2 py-1.5 text-xs", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: "font-medium", children: extra.name }), _jsx(Badge, { variant: "secondary", className: "text-[10px]", children: getExtraPricingModeLabel(extra.pricingMode, extraMessages) }), extra.pricedPerPerson ? (_jsx("span", { className: "text-muted-foreground", children: extraPriceMessages.perTraveler })) : null] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-mono", children: rule?.sellAmountCents != null
299
363
  ? formatProductMoney(rule.sellAmountCents, productCurrency)
300
- : 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));
301
- }) }), pricingExtra ? (_jsx(ExtraPriceRuleDialog, { open: !!pricingExtra, onOpenChange: (open) => {
364
+ : extraPriceMessages.noAmount }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => {
365
+ void (async () => {
366
+ const ruleId = optionPriceRuleId ??
367
+ (ensureOptionPriceRuleId ? await ensureOptionPriceRuleId() : undefined);
368
+ if (!ruleId)
369
+ return;
370
+ setPricingRuleId(ruleId);
371
+ setPricingExtraId(extra.id);
372
+ })();
373
+ }, children: extraPriceMessages.setPrice }), _jsx("button", { type: "button", "aria-label": extraMessages.editAction, onClick: () => {
374
+ setEditingExtra(extra);
375
+ setDefinitionDialogOpen(true);
376
+ }, className: "text-muted-foreground hover:text-foreground", children: _jsx(Pencil, { className: "h-3 w-3" }) }), _jsx("button", { type: "button", "aria-label": extraMessages.deleteAction, onClick: () => {
377
+ if (confirm(formatMessage(extraMessages.deleteConfirm, { name: extra.name }))) {
378
+ removeExtra.mutate(extra.id, {
379
+ onSuccess: () => void extrasQuery.refetch(),
380
+ });
381
+ }
382
+ }, className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3 w-3" }) })] })] }, extra.id));
383
+ }) })), _jsx(ProductExtraDialog, { open: definitionDialogOpen, onOpenChange: (open) => {
384
+ setDefinitionDialogOpen(open);
385
+ if (!open)
386
+ setEditingExtra(undefined);
387
+ }, productId: productId, extra: editingExtra, nextSortOrder: extras.length, onSuccess: () => {
388
+ setDefinitionDialogOpen(false);
389
+ setEditingExtra(undefined);
390
+ void extrasQuery.refetch();
391
+ } }), pricingExtra && (pricingRuleId ?? optionPriceRuleId) ? (_jsx(ExtraPriceRuleDialog, { open: !!pricingExtra, onOpenChange: (open) => {
302
392
  if (!open)
303
393
  setPricingExtraId(null);
304
- }, optionPriceRuleId: optionPriceRuleId, optionId: optionId, extra: pricingExtra, existingRule: ruleByExtraId.get(pricingExtra.id), nextSortOrder: rules.length, productCurrency: productCurrency, onSuccess: () => {
394
+ }, optionPriceRuleId: (pricingRuleId ?? optionPriceRuleId), optionId: optionId, extra: pricingExtra, existingRule: ruleByExtraId.get(pricingExtra.id), nextSortOrder: rules.length, productCurrency: productCurrency, onSuccess: () => {
305
395
  setPricingExtraId(null);
306
396
  void rulesQuery.refetch();
307
397
  } })) : null] }));
@@ -356,7 +446,7 @@ function defaultExtraPriceRuleMode(extra) {
356
446
  return "on_request";
357
447
  return "per_booking";
358
448
  }
359
- function formatProductMoney(amountCents, currency) {
449
+ export function formatProductMoney(amountCents, currency) {
360
450
  if (amountCents == null)
361
451
  return "-";
362
452
  return `${(amountCents / 100).toFixed(2)} ${currency}`;
@@ -6,6 +6,20 @@ import { type VoyantProductsContextValue } from "@voyantjs/products-react";
6
6
  */
7
7
  export type OptionsClient = VoyantProductsContextValue;
8
8
  export declare const optionStatusVariant: Record<string, "default" | "secondary" | "outline" | "destructive">;
9
+ /**
10
+ * Which pricing layout an option shows. "rooms" = a room×traveler-type grid
11
+ * (accommodation / multi-day). "seats" = a flat traveler-type price list
12
+ * (single-day excursions, transfers). Derived from the product's bookingMode
13
+ * so the agent never picks a pricing model directly.
14
+ */
15
+ export type OptionPricingLayout = "rooms" | "seats";
16
+ /**
17
+ * Derive the pricing layout from the product's booking mode. Multi-day /
18
+ * overnight modes imply rooms; single-day activity modes imply per-person
19
+ * seats. `dayCount` is a fallback for the ambiguous `other` mode (>1 day →
20
+ * rooms), matching the operator rule "more than one day means rooms".
21
+ */
22
+ export declare function deriveOptionPricingLayout(bookingMode: string | null | undefined, dayCount?: number): OptionPricingLayout;
9
23
  export declare function getProductOptionsQueryOptions(client: OptionsClient, productId: string): import("@tanstack/react-query").OmitKeyof<import("@tanstack/react-query").UseQueryOptions<{
10
24
  data: {
11
25
  id: string;