@voyantjs/products-ui 0.101.1 → 0.102.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 (126) hide show
  1. package/dist/components/product-detail/date-picker.d.ts +44 -0
  2. package/dist/components/product-detail/date-picker.d.ts.map +1 -0
  3. package/dist/components/product-detail/date-picker.js +125 -0
  4. package/dist/components/product-detail/host.d.ts +53 -0
  5. package/dist/components/product-detail/host.d.ts.map +1 -0
  6. package/dist/components/product-detail/host.js +24 -0
  7. package/dist/components/product-detail/index.d.ts +6 -0
  8. package/dist/components/product-detail/index.d.ts.map +1 -0
  9. package/dist/components/product-detail/index.js +5 -0
  10. package/dist/components/product-detail/product-activity-section.d.ts +4 -0
  11. package/dist/components/product-detail/product-activity-section.d.ts.map +1 -0
  12. package/dist/components/product-detail/product-activity-section.js +37 -0
  13. package/dist/components/product-detail/product-day-sheet.d.ts +14 -0
  14. package/dist/components/product-detail/product-day-sheet.d.ts.map +1 -0
  15. package/dist/components/product-detail/product-day-sheet.js +75 -0
  16. package/dist/components/product-detail/product-day-translation.d.ts +41 -0
  17. package/dist/components/product-detail/product-day-translation.d.ts.map +1 -0
  18. package/dist/components/product-detail/product-day-translation.js +111 -0
  19. package/dist/components/product-detail/product-departure-dialog.d.ts +11 -0
  20. package/dist/components/product-detail/product-departure-dialog.d.ts.map +1 -0
  21. package/dist/components/product-detail/product-departure-dialog.js +10 -0
  22. package/dist/components/product-detail/product-departure-form.d.ts +25 -0
  23. package/dist/components/product-detail/product-departure-form.d.ts.map +1 -0
  24. package/dist/components/product-detail/product-departure-form.js +237 -0
  25. package/dist/components/product-detail/product-departure-pricing-override-dialog.d.ts +8 -0
  26. package/dist/components/product-detail/product-departure-pricing-override-dialog.d.ts.map +1 -0
  27. package/dist/components/product-detail/product-departure-pricing-override-dialog.js +125 -0
  28. package/dist/components/product-detail/product-detail-day-row.d.ts +14 -0
  29. package/dist/components/product-detail/product-detail-day-row.d.ts.map +1 -0
  30. package/dist/components/product-detail/product-detail-day-row.js +43 -0
  31. package/dist/components/product-detail/product-detail-dialog.d.ts +10 -0
  32. package/dist/components/product-detail/product-detail-dialog.d.ts.map +1 -0
  33. package/dist/components/product-detail/product-detail-dialog.js +10 -0
  34. package/dist/components/product-detail/product-detail-form.d.ts +19 -0
  35. package/dist/components/product-detail/product-detail-form.d.ts.map +1 -0
  36. package/dist/components/product-detail/product-detail-form.js +180 -0
  37. package/dist/components/product-detail/product-detail-header.d.ts +12 -0
  38. package/dist/components/product-detail/product-detail-header.d.ts.map +1 -0
  39. package/dist/components/product-detail/product-detail-header.js +19 -0
  40. package/dist/components/product-detail/product-detail-itinerary-section.d.ts +4 -0
  41. package/dist/components/product-detail/product-detail-itinerary-section.d.ts.map +1 -0
  42. package/dist/components/product-detail/product-detail-itinerary-section.js +201 -0
  43. package/dist/components/product-detail/product-detail-page.d.ts +4 -0
  44. package/dist/components/product-detail/product-detail-page.d.ts.map +1 -0
  45. package/dist/components/product-detail/product-detail-page.js +97 -0
  46. package/dist/components/product-detail/product-detail-sections.d.ts +63 -0
  47. package/dist/components/product-detail/product-detail-sections.d.ts.map +1 -0
  48. package/dist/components/product-detail/product-detail-sections.js +143 -0
  49. package/dist/components/product-detail/product-detail-shared.d.ts +264 -0
  50. package/dist/components/product-detail/product-detail-shared.d.ts.map +1 -0
  51. package/dist/components/product-detail/product-detail-shared.js +157 -0
  52. package/dist/components/product-detail/product-detail-skeleton.d.ts +9 -0
  53. package/dist/components/product-detail/product-detail-skeleton.d.ts.map +1 -0
  54. package/dist/components/product-detail/product-detail-skeleton.js +53 -0
  55. package/dist/components/product-detail/product-extras-section.d.ts +4 -0
  56. package/dist/components/product-detail/product-extras-section.d.ts.map +1 -0
  57. package/dist/components/product-detail/product-extras-section.js +141 -0
  58. package/dist/components/product-detail/product-itinerary-form.d.ts +16 -0
  59. package/dist/components/product-detail/product-itinerary-form.d.ts.map +1 -0
  60. package/dist/components/product-detail/product-itinerary-form.js +38 -0
  61. package/dist/components/product-detail/product-market-rules-section.d.ts +6 -0
  62. package/dist/components/product-detail/product-market-rules-section.d.ts.map +1 -0
  63. package/dist/components/product-detail/product-market-rules-section.js +81 -0
  64. package/dist/components/product-detail/product-media-gallery.d.ts +19 -0
  65. package/dist/components/product-detail/product-media-gallery.d.ts.map +1 -0
  66. package/dist/components/product-detail/product-media-gallery.js +114 -0
  67. package/dist/components/product-detail/product-option-price-rule-dialog.d.ts +12 -0
  68. package/dist/components/product-detail/product-option-price-rule-dialog.d.ts.map +1 -0
  69. package/dist/components/product-detail/product-option-price-rule-dialog.js +10 -0
  70. package/dist/components/product-detail/product-option-price-rule-form.d.ts +29 -0
  71. package/dist/components/product-detail/product-option-price-rule-form.d.ts.map +1 -0
  72. package/dist/components/product-detail/product-option-price-rule-form.js +125 -0
  73. package/dist/components/product-detail/product-option-pricing-grid.d.ts +16 -0
  74. package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
  75. package/dist/components/product-detail/product-option-pricing-grid.js +193 -0
  76. package/dist/components/product-detail/product-options-pricing.d.ts +34 -0
  77. package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -0
  78. package/dist/components/product-detail/product-options-pricing.js +385 -0
  79. package/dist/components/product-detail/product-options-shared.d.ts +623 -0
  80. package/dist/components/product-detail/product-options-shared.d.ts.map +1 -0
  81. package/dist/components/product-detail/product-options-shared.js +54 -0
  82. package/dist/components/product-detail/product-payment-policy-section.d.ts +17 -0
  83. package/dist/components/product-detail/product-payment-policy-section.d.ts.map +1 -0
  84. package/dist/components/product-detail/product-payment-policy-section.js +58 -0
  85. package/dist/components/product-detail/product-schedule-dialog.d.ts +11 -0
  86. package/dist/components/product-detail/product-schedule-dialog.d.ts.map +1 -0
  87. package/dist/components/product-detail/product-schedule-dialog.js +10 -0
  88. package/dist/components/product-detail/product-schedule-form.d.ts +17 -0
  89. package/dist/components/product-detail/product-schedule-form.d.ts.map +1 -0
  90. package/dist/components/product-detail/product-schedule-form.js +222 -0
  91. package/dist/components/product-detail/product-service-dialog.d.ts +12 -0
  92. package/dist/components/product-detail/product-service-dialog.d.ts.map +1 -0
  93. package/dist/components/product-detail/product-service-dialog.js +10 -0
  94. package/dist/components/product-detail/product-service-form.d.ts +22 -0
  95. package/dist/components/product-detail/product-service-form.d.ts.map +1 -0
  96. package/dist/components/product-detail/product-service-form.js +154 -0
  97. package/dist/components/product-detail/product-translation-popover.d.ts +91 -0
  98. package/dist/components/product-detail/product-translation-popover.d.ts.map +1 -0
  99. package/dist/components/product-detail/product-translation-popover.js +217 -0
  100. package/dist/components/product-detail/product-unit-dialog.d.ts +14 -0
  101. package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -0
  102. package/dist/components/product-detail/product-unit-dialog.js +10 -0
  103. package/dist/components/product-detail/product-unit-form.d.ts +34 -0
  104. package/dist/components/product-detail/product-unit-form.d.ts.map +1 -0
  105. package/dist/components/product-detail/product-unit-form.js +139 -0
  106. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +17 -0
  107. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -0
  108. package/dist/components/product-detail/product-unit-price-rule-dialog.js +10 -0
  109. package/dist/components/product-detail/product-unit-price-rule-form.d.ts +29 -0
  110. package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -0
  111. package/dist/components/product-detail/product-unit-price-rule-form.js +145 -0
  112. package/dist/components/product-detail/timezone-options.d.ts +9 -0
  113. package/dist/components/product-detail/timezone-options.d.ts.map +1 -0
  114. package/dist/components/product-detail/timezone-options.js +28 -0
  115. package/dist/components/product-detail/use-product-detail-data.d.ts +41 -0
  116. package/dist/components/product-detail/use-product-detail-data.d.ts.map +1 -0
  117. package/dist/components/product-detail/use-product-detail-data.js +143 -0
  118. package/dist/components/product-detail/use-product-detail-dialogs.d.ts +24 -0
  119. package/dist/components/product-detail/use-product-detail-dialogs.d.ts.map +1 -0
  120. package/dist/components/product-detail/use-product-detail-dialogs.js +40 -0
  121. package/dist/components/product-detail/zod-resolver.d.ts +4 -0
  122. package/dist/components/product-detail/zod-resolver.d.ts.map +1 -0
  123. package/dist/components/product-detail/zod-resolver.js +39 -0
  124. package/dist/components/product-options-section.d.ts.map +1 -1
  125. package/dist/components/product-options-section.js +31 -20
  126. package/package.json +38 -19
@@ -0,0 +1,385 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import { useProductExtras } from "@voyantjs/extras-react";
4
+ import { formatMessage } from "@voyantjs/i18n";
5
+ import { useExtraPriceRuleMutation, useExtraPriceRules, useOptionPriceRuleMutation, useOptionUnitPriceRuleMutation, usePricingCategoryMutation, } from "@voyantjs/pricing-react";
6
+ import { useVoyantProductsContext } from "@voyantjs/products-react";
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
+ import { Checkbox } from "@voyantjs/ui/components/checkbox";
9
+ import { ChevronDown, ChevronRight, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
10
+ import { useEffect, useState } from "react";
11
+ import { useProductDetailMessages } from "./host.js";
12
+ import { OptionPriceRuleDialog, } from "./product-option-price-rule-dialog.js";
13
+ import { OptionPricingGrid } from "./product-option-pricing-grid.js";
14
+ import { getOptionPriceRulesQueryOptions, getOptionUnitPriceRulesQueryOptions, getOptionUnitsQueryOptions, getPricingCategoriesQueryOptions, } from "./product-options-shared.js";
15
+ import { UnitPriceRuleDialog, } from "./product-unit-price-rule-dialog.js";
16
+ function getRulePricingModeLabel(value, messages) {
17
+ switch (value) {
18
+ case "per_person":
19
+ return messages.pricingModePerPerson;
20
+ case "per_booking":
21
+ return messages.pricingModePerBooking;
22
+ case "starting_from":
23
+ return messages.pricingModeStartingFrom;
24
+ case "free":
25
+ return messages.pricingModeFree;
26
+ case "on_request":
27
+ return messages.pricingModeOnRequest;
28
+ default:
29
+ return value;
30
+ }
31
+ }
32
+ export function getUnitTypeLabel(type, messages) {
33
+ switch (type) {
34
+ case "person":
35
+ return messages.typePerson;
36
+ case "group":
37
+ return messages.typeGroup;
38
+ case "room":
39
+ return messages.typeRoom;
40
+ case "vehicle":
41
+ return messages.typeVehicle;
42
+ case "service":
43
+ return messages.typeService;
44
+ case "other":
45
+ return messages.typeOther;
46
+ default:
47
+ return type;
48
+ }
49
+ }
50
+ export function getCategoryCondition(metadata) {
51
+ const condition = metadata?.condition;
52
+ return typeof condition === "string" && condition.trim().length > 0 ? condition : null;
53
+ }
54
+ export function categoryAppliesToUnit(category, unit) {
55
+ if (!category.id)
56
+ return true;
57
+ const allowedUnitIds = category.metadata?.allowedUnitIds;
58
+ if (!Array.isArray(allowedUnitIds) || allowedUnitIds.length === 0)
59
+ return true;
60
+ return allowedUnitIds.includes(unit.id);
61
+ }
62
+ function ActionMenu({ children }) {
63
+ 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 })] }));
64
+ }
65
+ /**
66
+ * Per-option pricing surface. The everyday view is the merged rooms/seats
67
+ * grid; the full rate-plan machinery (multiple plans, catalogs, cost prices,
68
+ * cancellation) plus any injected per-departure inventory live behind an
69
+ * Advanced disclosure so low-tech agents never have to see them.
70
+ */
71
+ export function PricingPanel({ productId, optionId, optionName, productCurrency, layout, extras, }) {
72
+ const messages = useProductDetailMessages();
73
+ const gridMessages = messages.products.operations.pricingGrid;
74
+ const [advancedOpen, setAdvancedOpen] = useState(false);
75
+ 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] }));
76
+ }
77
+ function AdvancedRatePlans({ productId, optionId, productCurrency, }) {
78
+ const messages = useProductDetailMessages();
79
+ const client = useVoyantProductsContext();
80
+ const priceRuleMessages = messages.products.operations.priceRules;
81
+ const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
82
+ const [editingRule, setEditingRule] = useState();
83
+ const { data, refetch } = useQuery(getOptionPriceRulesQueryOptions(client, optionId));
84
+ const { remove: removeRule } = useOptionPriceRuleMutation();
85
+ const deleteMutation = useMutation({
86
+ mutationFn: (id) => removeRule.mutateAsync(id),
87
+ onSuccess: () => void refetch(),
88
+ });
89
+ const rules = data?.data ?? [];
90
+ // The default rate plan IS the everyday grid above — don't re-render its
91
+ // identical matrix here. Advanced only manages the *extra* plans (net,
92
+ // contract, promo) plus the default plan's hidden settings (cost,
93
+ // cancellation, catalog) via "Edit default pricing".
94
+ const defaultRule = rules.find((rule) => rule.isDefault) ?? rules[0];
95
+ const additionalRules = rules.filter((rule) => rule.id !== defaultRule?.id);
96
+ 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: () => {
97
+ setEditingRule(defaultRule);
98
+ setRuleDialogOpen(true);
99
+ }, children: priceRuleMessages.editDefaultAction })) : null, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
100
+ setEditingRule(undefined);
101
+ setRuleDialogOpen(true);
102
+ }, 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: () => {
103
+ setEditingRule(rule);
104
+ setRuleDialogOpen(true);
105
+ }, onDelete: () => {
106
+ if (confirm(formatMessage(priceRuleMessages.deleteRuleConfirm, { name: rule.name }))) {
107
+ deleteMutation.mutate(rule.id);
108
+ }
109
+ } }, rule.id))) })), _jsx(OptionPriceRuleDialog, { open: ruleDialogOpen, onOpenChange: setRuleDialogOpen, productId: productId, optionId: optionId, rule: editingRule, onSuccess: () => {
110
+ setRuleDialogOpen(false);
111
+ setEditingRule(undefined);
112
+ void refetch();
113
+ } })] }));
114
+ }
115
+ function PriceRuleCard({ rule, productId, optionId, productCurrency, onEdit, onDelete, }) {
116
+ const messages = useProductDetailMessages();
117
+ const priceRuleMessages = messages.products.operations.priceRules;
118
+ return (_jsxs("div", { className: "rounded-lg border bg-background p-4", children: [_jsxs("div", { className: "flex items-start justify-between", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: rule.name }), _jsx(Badge, { variant: "outline", className: "text-xs capitalize", children: getRulePricingModeLabel(rule.pricingMode, priceRuleMessages) }), rule.isDefault && _jsx(Badge, { variant: "secondary", children: priceRuleMessages.defaultBadge }), _jsx(Badge, { variant: rule.active ? "default" : "outline", children: rule.active ? priceRuleMessages.activeBadge : priceRuleMessages.inactiveBadge })] }), _jsxs("div", { className: "flex items-center gap-3 text-xs text-muted-foreground", children: [_jsxs("span", { children: [priceRuleMessages.baseSellLabel, ":", " ", _jsx("span", { className: "font-mono text-foreground", children: formatProductMoney(rule.baseSellAmountCents, productCurrency) })] }), _jsxs("span", { children: [priceRuleMessages.baseCostLabel, ":", " ", _jsx("span", { className: "font-mono text-foreground", children: formatProductMoney(rule.baseCostAmountCents, productCurrency) })] }), rule.allPricingCategories && _jsx("span", { children: priceRuleMessages.allCategoriesLabel })] })] }), _jsxs(ActionMenu, { children: [_jsxs(DropdownMenuItem, { onClick: onEdit, children: [_jsx(Pencil, { className: "h-4 w-4" }), priceRuleMessages.editAction] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { variant: "destructive", onClick: onDelete, children: [_jsx(Trash2, { className: "h-4 w-4" }), priceRuleMessages.deleteAction] })] })] }), _jsxs("div", { className: "mt-3", children: [_jsx(UnitPriceMatrix, { productId: productId, optionPriceRuleId: rule.id, optionId: optionId, pricingMode: rule.pricingMode, allPricingCategories: rule.allPricingCategories, productCurrency: productCurrency }), _jsx(ExtraPriceRulesPanel, { productId: productId, optionId: optionId, optionPriceRuleId: rule.id, productCurrency: productCurrency })] })] }));
119
+ }
120
+ function UnitPriceMatrix({ productId, optionPriceRuleId, optionId, pricingMode, allPricingCategories, productCurrency, }) {
121
+ const messages = useProductDetailMessages();
122
+ const client = useVoyantProductsContext();
123
+ const priceRuleMessages = messages.products.operations.priceRules;
124
+ const unitMessages = messages.products.operations.units;
125
+ const [dialogOpen, setDialogOpen] = useState(false);
126
+ const [editingCell, setEditingCell] = useState();
127
+ const [preselectedUnitId, setPreselectedUnitId] = useState();
128
+ const [preselectedCategoryId, setPreselectedCategoryId] = useState();
129
+ const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
130
+ const { data: unitsData } = useQuery(getOptionUnitsQueryOptions(client, optionId));
131
+ const { data: categoriesData, refetch: refetchCategories } = useQuery(getPricingCategoriesQueryOptions(client));
132
+ const { data: cellsData, refetch: refetchCells } = useQuery(getOptionUnitPriceRulesQueryOptions(client, optionPriceRuleId));
133
+ const { remove } = useOptionUnitPriceRuleMutation();
134
+ const deleteMutation = useMutation({
135
+ mutationFn: (id) => remove.mutateAsync(id),
136
+ onSuccess: () => void refetchCells(),
137
+ });
138
+ const units = (unitsData?.data ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
139
+ const cells = cellsData?.data ?? [];
140
+ const referencedCategoryIds = new Set(cells.flatMap((cell) => (cell.pricingCategoryId ? [cell.pricingCategoryId] : [])));
141
+ const categories = (categoriesData?.data ?? []).filter((category) => category.active &&
142
+ (((category.productId == null || category.productId === productId) &&
143
+ (category.optionId == null || category.optionId === optionId)) ||
144
+ referencedCategoryIds.has(category.id)));
145
+ const isPersonOnly = units.length > 0 && units.every((unit) => unit.unitType === "person");
146
+ const findCell = (unitId, categoryId) => cells.find((cell) => cell.unitId === unitId && (cell.pricingCategoryId ?? null) === categoryId) ?? null;
147
+ if (units.length === 0) {
148
+ return _jsx("p", { className: "text-xs italic text-muted-foreground", children: priceRuleMessages.addUnitsHint });
149
+ }
150
+ if (pricingMode === "per_booking") {
151
+ return (_jsx("p", { className: "text-xs italic text-muted-foreground", children: priceRuleMessages.perBookingFlatHint }));
152
+ }
153
+ // Per-pax tour with no category cross-cut: render a simple unit-only table
154
+ // (Sell / Cost) instead of the unit×category matrix. Operators on
155
+ // accommodation products (or rules with allPricingCategories=false) still
156
+ // get the full matrix.
157
+ const useSimpleTable = pricingMode === "per_person" && allPricingCategories;
158
+ const tableTitle = useSimpleTable
159
+ ? isPersonOnly
160
+ ? priceRuleMessages.personUnitPricingTitle
161
+ : priceRuleMessages.unitPricingTitle
162
+ : isPersonOnly
163
+ ? priceRuleMessages.personUnitCategoryTitle
164
+ : priceRuleMessages.unitCategoryTitle;
165
+ const unitColumnLabel = isPersonOnly
166
+ ? priceRuleMessages.tableTravelerUnit
167
+ : priceRuleMessages.tableUnit;
168
+ const columns = useSimpleTable
169
+ ? [{ id: null, name: priceRuleMessages.tableSell }]
170
+ : categories.length > 0
171
+ ? categories.map((category) => ({
172
+ id: category.id,
173
+ name: category.name,
174
+ metadata: category.metadata,
175
+ }))
176
+ : [{ id: null, name: priceRuleMessages.defaultBadge }];
177
+ return (_jsxs("div", { children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsx("p", { className: "text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: tableTitle }), !useSimpleTable ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => setCategoryDialogOpen(true), children: [_jsx(Plus, { className: "mr-1 h-3 w-3" }), priceRuleMessages.addTravelerCategory] })) : null] }), _jsx("div", { className: "overflow-x-auto rounded border", children: _jsxs("table", { className: "w-full text-xs", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b bg-muted/50 text-muted-foreground", children: [_jsx("th", { className: "p-2 text-left font-medium", children: unitColumnLabel }), columns.map((category) => {
178
+ const condition = getCategoryCondition(category.metadata);
179
+ return (_jsxs("th", { className: "p-2 text-left font-medium", children: [_jsx("div", { children: category.name }), condition ? (_jsx("div", { className: "mt-0.5 max-w-[220px] text-[10px] font-normal leading-snug text-muted-foreground normal-case", children: condition })) : null] }, category.id ?? "__default__"));
180
+ })] }) }), _jsx("tbody", { children: units.map((unit) => (_jsxs("tr", { className: "border-b last:border-b-0", children: [_jsxs("td", { className: "p-2 font-medium", children: [unit.name, _jsxs("span", { className: "ml-1 text-[10px] text-muted-foreground", children: ["(", getUnitTypeLabel(unit.unitType, unitMessages), ")"] })] }), columns.map((category) => {
181
+ const cell = findCell(unit.id, category.id);
182
+ const canPriceCategory = categoryAppliesToUnit(category, unit);
183
+ return (_jsx("td", { className: "p-2", children: cell ? (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => {
184
+ setEditingCell(cell);
185
+ setPreselectedUnitId(undefined);
186
+ setPreselectedCategoryId(undefined);
187
+ setDialogOpen(true);
188
+ }, className: "font-mono text-foreground hover:underline", children: formatProductMoney(cell.sellAmountCents, productCurrency) }), _jsx("button", { type: "button", onClick: () => {
189
+ if (confirm(priceRuleMessages.deleteCellConfirm)) {
190
+ deleteMutation.mutate(cell.id);
191
+ }
192
+ }, className: "text-muted-foreground hover:text-destructive", children: _jsx(Trash2, { className: "h-3 w-3" }) })] })) : canPriceCategory ? (_jsx("button", { type: "button", onClick: () => {
193
+ setEditingCell(undefined);
194
+ setPreselectedUnitId(unit.id);
195
+ setPreselectedCategoryId(category.id);
196
+ setDialogOpen(true);
197
+ }, className: "text-muted-foreground hover:text-foreground", children: _jsx(Plus, { className: "h-3 w-3" }) })) : (_jsx("span", { className: "text-muted-foreground", children: "-" })) }, category.id ?? "__default__"));
198
+ })] }, 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: () => {
199
+ setCategoryDialogOpen(false);
200
+ void refetchCategories();
201
+ } }), _jsx(UnitPriceRuleDialog, { open: dialogOpen, onOpenChange: setDialogOpen, optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
202
+ setDialogOpen(false);
203
+ setEditingCell(undefined);
204
+ setPreselectedUnitId(undefined);
205
+ setPreselectedCategoryId(undefined);
206
+ void refetchCells();
207
+ } })] }));
208
+ }
209
+ function initialTravelerCategoryState() {
210
+ return {
211
+ name: "",
212
+ code: "",
213
+ categoryType: "child",
214
+ minAge: "",
215
+ maxAge: "",
216
+ condition: "",
217
+ allowedUnitIds: [],
218
+ };
219
+ }
220
+ function parseOptionalInteger(value) {
221
+ const trimmed = value.trim();
222
+ if (!trimmed)
223
+ return null;
224
+ const parsed = Number(trimmed);
225
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
226
+ }
227
+ export function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }) {
228
+ const messages = useProductDetailMessages();
229
+ const priceRuleMessages = messages.products.operations.priceRules;
230
+ const pricingCategoryMessages = messages.pricing.categories;
231
+ const { create } = usePricingCategoryMutation();
232
+ const [state, setState] = useState(() => initialTravelerCategoryState());
233
+ const [error, setError] = useState(null);
234
+ const travelerCategoryTypes = [
235
+ { value: "adult", label: pricingCategoryMessages.typeAdult },
236
+ { value: "child", label: pricingCategoryMessages.typeChild },
237
+ { value: "infant", label: pricingCategoryMessages.typeInfant },
238
+ { value: "senior", label: pricingCategoryMessages.typeSenior },
239
+ { value: "group", label: pricingCategoryMessages.typeGroup },
240
+ { value: "other", label: pricingCategoryMessages.typeOther },
241
+ ];
242
+ useEffect(() => {
243
+ if (open) {
244
+ setState(initialTravelerCategoryState());
245
+ setError(null);
246
+ }
247
+ }, [open]);
248
+ const toggleUnit = (unitId, checked) => {
249
+ setState((prev) => ({
250
+ ...prev,
251
+ allowedUnitIds: checked
252
+ ? [...prev.allowedUnitIds, unitId]
253
+ : prev.allowedUnitIds.filter((id) => id !== unitId),
254
+ }));
255
+ };
256
+ const save = async () => {
257
+ const name = state.name.trim();
258
+ if (!name) {
259
+ setError(priceRuleMessages.travelerCategoryNameRequired);
260
+ return;
261
+ }
262
+ const selectedUnits = units.filter((unit) => state.allowedUnitIds.includes(unit.id));
263
+ const minAge = parseOptionalInteger(state.minAge);
264
+ const maxAge = parseOptionalInteger(state.maxAge);
265
+ const condition = state.condition.trim();
266
+ const metadata = {};
267
+ if (condition)
268
+ metadata.condition = condition;
269
+ if (selectedUnits.length > 0) {
270
+ metadata.allowedUnitIds = selectedUnits.map((unit) => unit.id);
271
+ metadata.allowedUnitCodes = selectedUnits.map((unit) => unit.code).filter(Boolean);
272
+ metadata.allowedUnitNames = selectedUnits.map((unit) => unit.name);
273
+ }
274
+ try {
275
+ await create.mutateAsync({
276
+ productId,
277
+ optionId: null,
278
+ unitId: null,
279
+ name,
280
+ code: state.code.trim() || null,
281
+ categoryType: state.categoryType,
282
+ seatOccupancy: 1,
283
+ isAgeQualified: minAge != null || maxAge != null,
284
+ minAge,
285
+ maxAge,
286
+ internalUseOnly: false,
287
+ active: true,
288
+ sortOrder: nextSortOrder,
289
+ metadata: Object.keys(metadata).length > 0 ? metadata : null,
290
+ });
291
+ onSuccess();
292
+ }
293
+ catch (err) {
294
+ setError(err instanceof Error ? err.message : priceRuleMessages.travelerCategorySaveFailed);
295
+ }
296
+ };
297
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: priceRuleMessages.travelerCategoryDialogTitle }), _jsx(DialogDescription, { children: priceRuleMessages.travelerCategoryDialogDescription })] }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-name", children: pricingCategoryMessages.nameLabel }), _jsx(Input, { id: "traveler-category-name", autoFocus: true, value: state.name, placeholder: priceRuleMessages.travelerCategoryNamePlaceholder, onChange: (event) => setState((prev) => ({ ...prev, name: event.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-code", children: pricingCategoryMessages.codeLabel }), _jsx(Input, { id: "traveler-category-code", value: state.code, placeholder: priceRuleMessages.travelerCategoryCodePlaceholder, onChange: (event) => setState((prev) => ({ ...prev, code: event.target.value })) })] })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: pricingCategoryMessages.typeLabel }), _jsxs(Select, { value: state.categoryType, onValueChange: (value) => setState((prev) => ({
298
+ ...prev,
299
+ categoryType: (value ?? "child"),
300
+ })), items: travelerCategoryTypes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: travelerCategoryTypes.map((type) => (_jsx(SelectItem, { value: type.value, children: type.label }, type.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-min-age", children: pricingCategoryMessages.minAgeLabel }), _jsx(Input, { id: "traveler-category-min-age", type: "number", min: "0", value: state.minAge, onChange: (event) => setState((prev) => ({ ...prev, minAge: event.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-max-age", children: pricingCategoryMessages.maxAgeLabel }), _jsx(Input, { id: "traveler-category-max-age", type: "number", min: "0", value: state.maxAge, onChange: (event) => setState((prev) => ({ ...prev, maxAge: event.target.value })) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: priceRuleMessages.travelerCategoryAppliesToLabel }), _jsx("div", { className: "grid gap-2 rounded border p-3 sm:grid-cols-3", children: units.map((unit) => {
301
+ const checkboxId = `traveler-category-unit-${unit.id}`;
302
+ return (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: checkboxId, checked: state.allowedUnitIds.includes(unit.id), onCheckedChange: (checked) => toggleUnit(unit.id, checked === true) }), _jsx(Label, { htmlFor: checkboxId, className: "font-normal", children: unit.name })] }, unit.id));
303
+ }) }), _jsx("p", { className: "text-muted-foreground text-xs", children: priceRuleMessages.travelerCategoryAppliesToHint })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "traveler-category-condition", children: priceRuleMessages.travelerCategoryConditionLabel }), _jsx(Textarea, { id: "traveler-category-condition", value: state.condition, placeholder: priceRuleMessages.travelerCategoryConditionPlaceholder, onChange: (event) => setState((prev) => ({ ...prev, condition: event.target.value })) })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null] }), _jsxs(DialogFooter, { className: "-mx-6 -mb-6", children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: pricingCategoryMessages.cancel }), _jsx(Button, { onClick: () => void save(), disabled: create.isPending, children: priceRuleMessages.createTravelerCategory })] })] }) }));
304
+ }
305
+ function ExtraPriceRulesPanel({ productId, optionId, optionPriceRuleId, productCurrency, }) {
306
+ const messages = useProductDetailMessages();
307
+ const extraPriceMessages = messages.products.operations.extraPrices;
308
+ const extrasQuery = useProductExtras({ productId, active: true, limit: 100 });
309
+ const rulesQuery = useExtraPriceRules({ optionPriceRuleId, optionId, active: true, limit: 100 });
310
+ const { remove } = useExtraPriceRuleMutation();
311
+ const [pricingExtraId, setPricingExtraId] = useState(null);
312
+ const extras = extrasQuery.data?.data ?? [];
313
+ const rules = rulesQuery.data?.data ?? [];
314
+ const ruleByExtraId = new Map(rules.flatMap((rule) => (rule.productExtraId ? [[rule.productExtraId, rule]] : [])));
315
+ if (extras.length === 0)
316
+ return null;
317
+ const pricingExtra = extras.find((extra) => extra.id === pricingExtraId) ?? null;
318
+ return (_jsxs("div", { className: "mt-4 border-t pt-3", children: [_jsx("div", { className: "mb-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: extraPriceMessages.sectionTitle }), _jsx("div", { className: "flex flex-col gap-2", children: extras.map((extra) => {
319
+ const rule = ruleByExtraId.get(extra.id);
320
+ return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded border px-2 py-1.5 text-xs", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("span", { className: "font-medium", children: extra.name }), extra.pricedPerPerson ? (_jsx("span", { className: "ml-2 text-muted-foreground", children: extraPriceMessages.perTraveler })) : null] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-mono", children: rule?.sellAmountCents != null
321
+ ? formatProductMoney(rule.sellAmountCents, productCurrency)
322
+ : extraPriceMessages.noAmount }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => setPricingExtraId(extra.id), children: extraPriceMessages.setPrice }), rule ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => remove.mutate(rule.id, { onSuccess: () => void rulesQuery.refetch() }), children: extraPriceMessages.remove })) : null] })] }, extra.id));
323
+ }) }), pricingExtra ? (_jsx(ExtraPriceRuleDialog, { open: !!pricingExtra, onOpenChange: (open) => {
324
+ if (!open)
325
+ setPricingExtraId(null);
326
+ }, optionPriceRuleId: optionPriceRuleId, optionId: optionId, extra: pricingExtra, existingRule: ruleByExtraId.get(pricingExtra.id), nextSortOrder: rules.length, productCurrency: productCurrency, onSuccess: () => {
327
+ setPricingExtraId(null);
328
+ void rulesQuery.refetch();
329
+ } })) : null] }));
330
+ }
331
+ function ExtraPriceRuleDialog({ open, onOpenChange, optionPriceRuleId, optionId, extra, existingRule, nextSortOrder, productCurrency, onSuccess, }) {
332
+ const messages = useProductDetailMessages();
333
+ const extraPriceMessages = messages.products.operations.extraPrices;
334
+ const { create, update } = useExtraPriceRuleMutation();
335
+ const [amount, setAmount] = useState("");
336
+ const [pricingMode, setPricingMode] = useState("per_booking");
337
+ const isEditing = !!existingRule;
338
+ const pricingModes = [
339
+ { value: "per_booking", label: extraPriceMessages.pricingPerBooking },
340
+ { value: "per_person", label: extraPriceMessages.pricingPerPerson },
341
+ { value: "included", label: extraPriceMessages.pricingIncluded },
342
+ { value: "on_request", label: extraPriceMessages.pricingOnRequest },
343
+ { value: "unavailable", label: extraPriceMessages.pricingUnavailable },
344
+ ];
345
+ useEffect(() => {
346
+ setAmount(existingRule?.sellAmountCents != null ? String(existingRule.sellAmountCents / 100) : "");
347
+ setPricingMode(existingRule?.pricingMode ?? defaultExtraPriceRuleMode(extra));
348
+ }, [existingRule, extra]);
349
+ const save = async () => {
350
+ const parsedAmount = amount.trim() === "" ? null : Math.round(Number(amount) * 100);
351
+ if (parsedAmount != null && (!Number.isFinite(parsedAmount) || parsedAmount < 0))
352
+ return;
353
+ const payload = {
354
+ optionPriceRuleId,
355
+ optionId,
356
+ productExtraId: extra.id,
357
+ optionExtraConfigId: null,
358
+ pricingMode,
359
+ sellAmountCents: parsedAmount,
360
+ costAmountCents: null,
361
+ active: true,
362
+ sortOrder: existingRule?.sortOrder ?? nextSortOrder,
363
+ };
364
+ if (existingRule)
365
+ await update.mutateAsync({ id: existingRule.id, input: payload });
366
+ else
367
+ await create.mutateAsync(payload);
368
+ onSuccess();
369
+ };
370
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEditing ? extraPriceMessages.editTitle : extraPriceMessages.newTitle }), _jsx(DialogDescription, { children: extra.name })] }), _jsxs(DialogBody, { className: "grid gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: extraPriceMessages.pricingModeLabel }), _jsxs(Select, { value: pricingMode, onValueChange: (value) => setPricingMode((value ?? "per_booking")), items: pricingModes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: pricingModes.map((mode) => (_jsx(SelectItem, { value: mode.value, children: mode.label }, mode.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: extraPriceMessages.sellAmountLabel }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: amount, type: "number", min: "0", step: "0.01", onChange: (event) => setAmount(event.target.value) }), _jsx("span", { className: "min-w-12 text-muted-foreground text-sm", children: productCurrency })] })] })] }), _jsxs(DialogFooter, { className: "-mx-6 -mb-6", children: [_jsx(Button, { variant: "ghost", onClick: () => onOpenChange(false), children: extraPriceMessages.cancel }), _jsx(Button, { onClick: () => void save(), children: extraPriceMessages.save })] })] }) }));
371
+ }
372
+ function defaultExtraPriceRuleMode(extra) {
373
+ if (extra.pricedPerPerson || extra.pricingMode === "per_person")
374
+ return "per_person";
375
+ if (extra.pricingMode === "included" || extra.pricingMode === "free")
376
+ return "included";
377
+ if (extra.pricingMode === "on_request")
378
+ return "on_request";
379
+ return "per_booking";
380
+ }
381
+ export function formatProductMoney(amountCents, currency) {
382
+ if (amountCents == null)
383
+ return "-";
384
+ return `${(amountCents / 100).toFixed(2)} ${currency}`;
385
+ }