@voyantjs/products-ui 0.101.2 → 0.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/product-detail/product-departure-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-departure-form.js +22 -2
- package/dist/components/product-detail/product-detail-form.d.ts +3 -0
- package/dist/components/product-detail/product-detail-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-detail-form.js +31 -4
- package/dist/components/product-detail/product-detail-page.d.ts.map +1 -1
- package/dist/components/product-detail/product-detail-page.js +2 -3
- package/dist/components/product-detail/product-extra-dialog.d.ts +21 -0
- package/dist/components/product-detail/product-extra-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-extra-dialog.js +131 -0
- package/dist/components/product-detail/product-option-pricing-grid.d.ts +16 -0
- package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
- package/dist/components/product-detail/product-option-pricing-grid.js +233 -0
- package/dist/components/product-detail/product-options-pricing.d.ts +38 -1
- package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-pricing.js +136 -46
- package/dist/components/product-detail/product-options-shared.d.ts +14 -0
- package/dist/components/product-detail/product-options-shared.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-shared.js +20 -0
- package/dist/components/product-detail/product-translation-popover.d.ts +4 -1
- package/dist/components/product-detail/product-translation-popover.d.ts.map +1 -1
- package/dist/components/product-detail/product-translation-popover.js +28 -8
- package/dist/components/product-detail/product-unit-dialog.d.ts +3 -1
- package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-dialog.js +2 -2
- package/dist/components/product-detail/product-unit-form.d.ts +9 -1
- package/dist/components/product-detail/product-unit-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-form.js +37 -7
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +2 -1
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-price-rule-dialog.js +2 -2
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts +2 -1
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-price-rule-form.js +28 -9
- package/dist/components/product-options-section.d.ts.map +1 -1
- package/dist/components/product-options-section.js +31 -20
- package/package.json +29 -29
- package/dist/components/product-detail/product-extras-section.d.ts +0 -4
- package/dist/components/product-detail/product-extras-section.d.ts.map +0 -1
- package/dist/components/product-detail/product-extras-section.js +0 -141
|
@@ -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
|
-
|
|
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":"
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
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:
|
|
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
|
|
287
|
-
const
|
|
288
|
-
const
|
|
339
|
+
const extraMessages = messages.products.operations.extras;
|
|
340
|
+
const extrasQuery = useProductExtras({ productId, limit: 100 });
|
|
341
|
+
const rulesQuery = useExtraPriceRules({
|
|
342
|
+
optionPriceRuleId: optionPriceRuleId ?? "__none__",
|
|
343
|
+
optionId,
|
|
344
|
+
active: true,
|
|
345
|
+
limit: 100,
|
|
346
|
+
enabled: !!optionPriceRuleId,
|
|
347
|
+
});
|
|
348
|
+
const { remove: removeExtra } = useProductExtraMutation();
|
|
289
349
|
const [pricingExtraId, setPricingExtraId] = useState(null);
|
|
290
|
-
const
|
|
350
|
+
const [pricingRuleId, setPricingRuleId] = useState(optionPriceRuleId);
|
|
351
|
+
const [definitionDialogOpen, setDefinitionDialogOpen] = useState(false);
|
|
352
|
+
const [editingExtra, setEditingExtra] = useState();
|
|
353
|
+
const extras = (extrasQuery.data?.data ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
|
|
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: [
|
|
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: "
|
|
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: () =>
|
|
301
|
-
|
|
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;
|