@voyantjs/products-ui 0.101.2 → 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 (30) hide show
  1. package/dist/components/product-detail/product-departure-form.d.ts.map +1 -1
  2. package/dist/components/product-detail/product-departure-form.js +22 -2
  3. package/dist/components/product-detail/product-detail-form.d.ts.map +1 -1
  4. package/dist/components/product-detail/product-detail-form.js +6 -3
  5. package/dist/components/product-detail/product-detail-page.d.ts.map +1 -1
  6. package/dist/components/product-detail/product-detail-page.js +2 -2
  7. package/dist/components/product-detail/product-option-pricing-grid.d.ts +16 -0
  8. package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
  9. package/dist/components/product-detail/product-option-pricing-grid.js +193 -0
  10. package/dist/components/product-detail/product-options-pricing.d.ts +29 -1
  11. package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -1
  12. package/dist/components/product-detail/product-options-pricing.js +34 -12
  13. package/dist/components/product-detail/product-options-shared.d.ts +14 -0
  14. package/dist/components/product-detail/product-options-shared.d.ts.map +1 -1
  15. package/dist/components/product-detail/product-options-shared.js +20 -0
  16. package/dist/components/product-detail/product-unit-dialog.d.ts +3 -1
  17. package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -1
  18. package/dist/components/product-detail/product-unit-dialog.js +2 -2
  19. package/dist/components/product-detail/product-unit-form.d.ts +9 -1
  20. package/dist/components/product-detail/product-unit-form.d.ts.map +1 -1
  21. package/dist/components/product-detail/product-unit-form.js +37 -7
  22. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +2 -1
  23. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -1
  24. package/dist/components/product-detail/product-unit-price-rule-dialog.js +2 -2
  25. package/dist/components/product-detail/product-unit-price-rule-form.d.ts +2 -1
  26. package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -1
  27. package/dist/components/product-detail/product-unit-price-rule-form.js +28 -9
  28. package/dist/components/product-options-section.d.ts.map +1 -1
  29. package/dist/components/product-options-section.js +31 -20
  30. package/package.json +29 -29
@@ -1 +1 @@
1
- {"version":3,"file":"product-departure-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-departure-form.tsx"],"names":[],"mappings":"AA8EA,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAA;IACpD,SAAS,EAAE,OAAO,CAAA;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,aAAa,CAAA;IACpB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAuDD,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,kBAAkB,2CAwWzF"}
1
+ {"version":3,"file":"product-departure-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-departure-form.tsx"],"names":[],"mappings":"AA+EA,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAA;IACpD,SAAS,EAAE,OAAO,CAAA;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,aAAa,CAAA;IACpB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAuDD,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,kBAAkB,2CAgZzF"}
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useProductResourceTemplates } from "@voyantjs/availability-react";
2
3
  import { formatMessage } from "@voyantjs/i18n";
3
4
  import { useProductItineraries } from "@voyantjs/products-react";
4
5
  import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
5
6
  import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, } from "@voyantjs/ui/components/combobox";
6
7
  import { Loader2 } from "lucide-react";
7
- import { useEffect } from "react";
8
+ import { useEffect, useMemo, useRef } from "react";
8
9
  import { useForm } from "react-hook-form";
9
10
  import { z } from "zod/v4";
10
11
  import { DatePicker } from "./date-picker.js";
@@ -119,6 +120,25 @@ export function DepartureForm({ productId, slot, onSuccess, onCancel }) {
119
120
  const { data: itineraryData } = useProductItineraries(productId);
120
121
  const itineraries = itineraryData?.data ?? [];
121
122
  const defaultItinerary = itineraries.find((itinerary) => itinerary.isDefault) ?? itineraries[0];
123
+ // Suggested pax = total physical capacity of the configured departure
124
+ // inventory (each room/seat type's count × its capacity, e.g. 20 doubles
125
+ // sleeping 2 = 40). Lets a new departure inherit capacity from the rooms the
126
+ // operator already set up, while staying editable for an override.
127
+ const { data: resourceTemplateData } = useProductResourceTemplates({ productId });
128
+ const suggestedPax = useMemo(() => (resourceTemplateData?.data ?? []).reduce((optionTotal, option) => optionTotal +
129
+ option.templates.reduce((sum, template) => sum + (template.defaultCount ?? 0) * template.capacity, 0), 0), [resourceTemplateData?.data]);
130
+ // Pre-fill capacity once for a brand-new departure, only while the field is
131
+ // still untouched — never clobber an edit or an existing slot's value.
132
+ const prefilledPaxRef = useRef(false);
133
+ useEffect(() => {
134
+ if (isEditing || prefilledPaxRef.current || suggestedPax <= 0)
135
+ return;
136
+ const current = form.getValues("initialPax");
137
+ if (current === "" || current == null) {
138
+ form.setValue("initialPax", suggestedPax);
139
+ prefilledPaxRef.current = true;
140
+ }
141
+ }, [isEditing, suggestedPax, form]);
122
142
  const nights = (() => {
123
143
  if (!startDate || !endDate || typeof endDate !== "string" || endDate.length === 0)
124
144
  return 0;
@@ -213,5 +233,5 @@ export function DepartureForm({ productId, slot, onSuccess, onCancel }) {
213
233
  }, children: [_jsx(ComboboxInput, { placeholder: departureMessages.timezoneSearchPlaceholder, className: "w-full" }), _jsxs(ComboboxContent, { children: [_jsx(ComboboxEmpty, { children: departureMessages.timezoneEmpty }), _jsx(ComboboxList, { children: _jsx(ComboboxCollection, { children: (id) => {
214
234
  const tz = TIMEZONE_OPTIONS.find((t) => t.id === id);
215
235
  return (_jsxs(ComboboxItem, { value: id, children: [_jsx("span", { className: "font-mono text-xs", children: id }), tz ? (_jsx("span", { className: "ml-2 text-xs text-muted-foreground", children: tz.label })) : null] }, id));
216
- } }) })] })] }), form.formState.errors.timezone && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.timezone.message }))] })] }), _jsxs("fieldset", { className: "grid gap-3", children: [_jsx("legend", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: departureMessages.availabilityLegend }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.statusLabel }), _jsxs(Select, { value: form.watch("status"), onValueChange: (v) => form.setValue("status", v), items: slotStatuses, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: slotStatuses.map((s) => (_jsx(SelectItem, { value: s.value, children: s.label }, s.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.capacityLabel }), _jsx(Input, { ...form.register("initialPax"), type: "number", min: "0", step: "1", placeholder: "0", disabled: unlimited })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "unlimited", checked: unlimited, onCheckedChange: (c) => form.setValue("unlimited", c) }), _jsx(Label, { htmlFor: "unlimited", className: "font-normal cursor-pointer", children: departureMessages.unlimitedLabel })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.notesLabel }), _jsx(Textarea, { ...form.register("notes"), placeholder: departureMessages.notesPlaceholder })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting, children: [form.formState.isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), isEditing ? productMessages.saveChanges : departureMessages.create] })] })] }));
236
+ } }) })] })] }), form.formState.errors.timezone && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.timezone.message }))] })] }), _jsxs("fieldset", { className: "grid gap-3", children: [_jsx("legend", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: departureMessages.availabilityLegend }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.statusLabel }), _jsxs(Select, { value: form.watch("status"), onValueChange: (v) => form.setValue("status", v), items: slotStatuses, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: slotStatuses.map((s) => (_jsx(SelectItem, { value: s.value, children: s.label }, s.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.capacityLabel }), _jsx(Input, { ...form.register("initialPax"), type: "number", min: "0", step: "1", placeholder: "0", disabled: unlimited }), !unlimited && suggestedPax > 0 ? (_jsx("button", { type: "button", onClick: () => form.setValue("initialPax", suggestedPax), className: "text-left text-xs text-muted-foreground hover:text-foreground", children: formatMessage(departureMessages.capacityAutoHint, { count: suggestedPax }) })) : null] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "unlimited", checked: unlimited, onCheckedChange: (c) => form.setValue("unlimited", c) }), _jsx(Label, { htmlFor: "unlimited", className: "font-normal cursor-pointer", children: departureMessages.unlimitedLabel })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: departureMessages.notesLabel }), _jsx(Textarea, { ...form.register("notes"), placeholder: departureMessages.notesPlaceholder })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting, children: [form.formState.isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), isEditing ? productMessages.saveChanges : departureMessages.create] })] })] }));
217
237
  }
@@ -1 +1 @@
1
- {"version":3,"file":"product-detail-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-form.tsx"],"names":[],"mappings":"AAuCA,MAAM,MAAM,WAAW,GAAG;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAA;IACvC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,CAAA;IACxF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC,CAAA;AAgBD,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,SAAS,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AA6BD,wBAAgB,iBAAiB,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CAqWzF"}
1
+ {"version":3,"file":"product-detail-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-form.tsx"],"names":[],"mappings":"AAuCA,MAAM,MAAM,WAAW,GAAG;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAA;IACvC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,CAAA;IACxF,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC,CAAA;AAgBD,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,SAAS,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AA6BD,wBAAgB,iBAAiB,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CAwWzF"}
@@ -64,13 +64,16 @@ export function ProductDetailForm({ product, onSuccess, onCancel }) {
64
64
  { value: "active", label: productMessages.statusActive },
65
65
  { value: "archived", label: productMessages.statusArchived },
66
66
  ];
67
+ // Ordered most-common-first for this operator (multi-day tours, then day
68
+ // trips). The chosen mode also drives the option pricing layout
69
+ // (rooms vs per-person seats) — see deriveOptionPricingLayout.
67
70
  const bookingModes = [
71
+ { value: "itinerary", label: productMessages.bookingModeItinerary },
72
+ { value: "stay", label: productMessages.bookingModeStay },
68
73
  { value: "date", label: productMessages.bookingModeDate },
69
74
  { value: "date_time", label: productMessages.bookingModeDateTime },
70
- { value: "open", label: productMessages.bookingModeOpen },
71
- { value: "stay", label: productMessages.bookingModeStay },
72
75
  { value: "transfer", label: productMessages.bookingModeTransfer },
73
- { value: "itinerary", label: productMessages.bookingModeItinerary },
76
+ { value: "open", label: productMessages.bookingModeOpen },
74
77
  { value: "other", label: productMessages.bookingModeOther },
75
78
  ];
76
79
  const form = useForm({
@@ -1 +1 @@
1
- {"version":3,"file":"product-detail-page.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-page.tsx"],"names":[],"mappings":"AAgCA,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,2CAgNvD"}
1
+ {"version":3,"file":"product-detail-page.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-detail-page.tsx"],"names":[],"mappings":"AAmCA,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,2CAgNvD"}
@@ -17,7 +17,7 @@ import { ProductDetailSkeleton } from "./product-detail-skeleton.js";
17
17
  import { ProductExtrasSection } from "./product-extras-section.js";
18
18
  import { ProductMarketRulesSection } from "./product-market-rules-section.js";
19
19
  import { PricingPanel } from "./product-options-pricing.js";
20
- import { getDeparturePriceOverridesQueryOptions } from "./product-options-shared.js";
20
+ import { deriveOptionPricingLayout, getDeparturePriceOverridesQueryOptions, } from "./product-options-shared.js";
21
21
  import { ProductPaymentPolicySection } from "./product-payment-policy-section.js";
22
22
  import { ScheduleDialog } from "./product-schedule-dialog.js";
23
23
  import { useProductDetailData } from "./use-product-detail-data.js";
@@ -82,7 +82,7 @@ export function ProductDetailPage({ id }) {
82
82
  if (confirm(productMessages.deleteScheduleConfirm)) {
83
83
  mutations.deleteRule.mutate(ruleId);
84
84
  }
85
- } }), _jsx(ProductDetailItinerarySection, { productId: id }), _jsx(ProductsUiMessagesProvider, { locale: resolvedLocale, children: _jsx(ProductOptionsSection, { productId: id, renderOptionDetails: (option) => (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(PricingPanel, { productId: id, optionId: option.id, productCurrency: product.sellCurrency }), renderOptionExtras?.(id, option.id)] })) }) }), _jsx(ProductExtrasSection, { productId: id }), _jsx(ProductPaymentPolicySection, { product: product, onSuccess: invalidateProduct }), _jsx(ProductMarketRulesSection, { productId: id })] }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsx(ProductChannelsSection, { allChannels: channels, mappings: mappings, onAddChannel: (channelId) => mutations.addChannelMapping.mutate(channelId), onRemoveChannel: (mappingId) => mutations.removeChannelMapping.mutate(mappingId) }), _jsx(ProductOrganizeSection, { product: product, onEdit: dialogs.edit.openNow }), _jsx(ProductBrochureSection, { brochure: brochure, isGenerating: mutations.generateBrochure.isPending, onGenerate: () => mutations.generateBrochure.mutate() }), _jsx(ProductActivitySection, { productId: id })] })] }), _jsx(ProductDialog, { open: dialogs.edit.open, onOpenChange: dialogs.edit.setOpen, product: product, onSuccess: () => {
85
+ } }), _jsx(ProductDetailItinerarySection, { productId: id }), _jsx(ProductsUiMessagesProvider, { locale: resolvedLocale, children: _jsx(ProductOptionsSection, { productId: id, renderOptionDetails: (option) => (_jsx(PricingPanel, { productId: id, optionId: option.id, optionName: option.name, productCurrency: product.sellCurrency, layout: deriveOptionPricingLayout(product.bookingMode), extras: renderOptionExtras?.(id, option.id) })) }) }), _jsx(ProductExtrasSection, { productId: id }), _jsx(ProductPaymentPolicySection, { product: product, onSuccess: invalidateProduct }), _jsx(ProductMarketRulesSection, { productId: id })] }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsx(ProductChannelsSection, { allChannels: channels, mappings: mappings, onAddChannel: (channelId) => mutations.addChannelMapping.mutate(channelId), onRemoveChannel: (mappingId) => mutations.removeChannelMapping.mutate(mappingId) }), _jsx(ProductOrganizeSection, { product: product, onEdit: dialogs.edit.openNow }), _jsx(ProductBrochureSection, { brochure: brochure, isGenerating: mutations.generateBrochure.isPending, onGenerate: () => mutations.generateBrochure.mutate() }), _jsx(ProductActivitySection, { productId: id })] })] }), _jsx(ProductDialog, { open: dialogs.edit.open, onOpenChange: dialogs.edit.setOpen, product: product, onSuccess: () => {
86
86
  dialogs.edit.close();
87
87
  invalidateProduct();
88
88
  } }), _jsx(DepartureDialog, { open: dialogs.departure.open, onOpenChange: dialogs.departure.setOpen, productId: id, slot: dialogs.departure.editing, onSuccess: () => {
@@ -0,0 +1,16 @@
1
+ import { type OptionPricingLayout } from "./product-options-shared.js";
2
+ export interface OptionPricingGridProps {
3
+ productId: string;
4
+ optionId: string;
5
+ optionName: string;
6
+ productCurrency: string;
7
+ layout: OptionPricingLayout;
8
+ }
9
+ /**
10
+ * The everyday pricing surface for a booking option: one table that merges
11
+ * inventory (rooms / traveler types) with what each traveler pays. The single
12
+ * default rate plan is auto-managed and hidden — agents never see catalogs or
13
+ * rate-plan chrome here (that lives under Advanced).
14
+ */
15
+ export declare function OptionPricingGrid({ productId, optionId, optionName, productCurrency, layout, }: OptionPricingGridProps): import("react/jsx-runtime").JSX.Element;
16
+ //# sourceMappingURL=product-option-pricing-grid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-option-pricing-grid.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-option-pricing-grid.tsx"],"names":[],"mappings":"AAqBA,OAAO,EAML,KAAK,mBAAmB,EACzB,MAAM,6BAA6B,CAAA;AA8BpC,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,MAAM,EAAE,mBAAmB,CAAA;CAC5B;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,QAAQ,EACR,UAAU,EACV,eAAe,EACf,MAAM,GACP,EAAE,sBAAsB,2CAoWxB"}
@@ -0,0 +1,193 @@
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, } 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, 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 { data: unitsData, refetch: refetchUnits } = useQuery(getOptionUnitsQueryOptions(client, optionId));
42
+ const { data: rulesData, refetch: refetchRules } = useQuery(getOptionPriceRulesQueryOptions(client, optionId));
43
+ const { data: categoriesData, refetch: refetchCategories } = useQuery(getPricingCategoriesQueryOptions(client));
44
+ const { data: catalogsData } = useQuery(getPriceCatalogsQueryOptions(client));
45
+ const rules = rulesData?.data ?? [];
46
+ const defaultRule = rules.find((rule) => rule.isDefault) ?? rules[0];
47
+ const { data: cellsData, refetch: refetchCells } = useQuery({
48
+ ...getOptionUnitPriceRulesQueryOptions(client, defaultRule?.id ?? "__none__"),
49
+ enabled: Boolean(defaultRule?.id),
50
+ });
51
+ const { remove: removeUnit } = useOptionUnitMutation();
52
+ const { remove: removeCell } = useOptionUnitPriceRuleMutation();
53
+ const { create: createRule } = useOptionPriceRuleMutation();
54
+ const { create: createCatalog } = usePriceCatalogMutation();
55
+ const deleteUnitMutation = useMutation({
56
+ mutationFn: (id) => removeUnit.mutateAsync(id),
57
+ onSuccess: () => {
58
+ void refetchUnits();
59
+ void refetchCells();
60
+ },
61
+ });
62
+ const deleteCellMutation = useMutation({
63
+ mutationFn: (id) => removeCell.mutateAsync(id),
64
+ onSuccess: () => void refetchCells(),
65
+ });
66
+ const [unitDialogOpen, setUnitDialogOpen] = useState(false);
67
+ const [editingUnit, setEditingUnit] = useState();
68
+ const [defaultUnitType, setDefaultUnitType] = useState("room");
69
+ const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
70
+ const [cellDialogOpen, setCellDialogOpen] = useState(false);
71
+ const [cellRuleId, setCellRuleId] = useState();
72
+ const [editingCell, setEditingCell] = useState();
73
+ const [preselectedUnitId, setPreselectedUnitId] = useState();
74
+ const [preselectedCategoryId, setPreselectedCategoryId] = useState();
75
+ const units = (unitsData?.data ?? [])
76
+ .filter((unit) => !unit.isHidden)
77
+ .slice()
78
+ .sort((a, b) => a.sortOrder - b.sortOrder);
79
+ // Inventory wins over the booking-mode hint: an option that actually holds
80
+ // rooms (or vehicles/groups) is always priced as a rooms grid, even if the
81
+ // product's booking mode was set to a per-person type. The `layout` prop only
82
+ // decides the shape for a brand-new option that has no inventory yet.
83
+ const hasRoomLikeUnits = units.some((unit) => unit.unitType === "room" || unit.unitType === "vehicle" || unit.unitType === "group");
84
+ const hasPersonUnits = units.some((unit) => unit.unitType === "person");
85
+ const effectiveLayout = hasRoomLikeUnits
86
+ ? "rooms"
87
+ : hasPersonUnits
88
+ ? "seats"
89
+ : layout;
90
+ const cells = cellsData?.data ?? [];
91
+ const referencedCategoryIds = new Set(cells.flatMap((cell) => (cell.pricingCategoryId ? [cell.pricingCategoryId] : [])));
92
+ const categories = (categoriesData?.data ?? [])
93
+ .filter((category) => category.active &&
94
+ (((category.productId == null || category.productId === productId) &&
95
+ (category.optionId == null || category.optionId === optionId)) ||
96
+ referencedCategoryIds.has(category.id)))
97
+ .slice()
98
+ .sort((a, b) => a.sortOrder - b.sortOrder);
99
+ // Traveler-type columns. Seats layout prices each traveler-type row once
100
+ // (single price column). Rooms layout splits price by traveler category once
101
+ // any exist, else shows a single base-price column per room.
102
+ const columns = effectiveLayout === "rooms" && categories.length > 0
103
+ ? categories.map((category) => ({
104
+ id: category.id,
105
+ name: category.name,
106
+ metadata: category.metadata,
107
+ }))
108
+ : [{ id: null, name: t.priceColumn }];
109
+ const nextUnitSortOrder = units.length > 0 ? Math.max(...units.map((u) => u.sortOrder)) + 1 : 0;
110
+ const findCell = (unitId, categoryId) => cells.find((cell) => cell.unitId === unitId && (cell.pricingCategoryId ?? null) === categoryId) ?? null;
111
+ // Lazily materialize the hidden default rate plan (and a default catalog if
112
+ // the tenant has none) the first time the agent enters a price. Keeps the
113
+ // common path free of any rate-plan/catalog ceremony.
114
+ async function ensureRatePlanId() {
115
+ if (defaultRule?.id)
116
+ return defaultRule.id;
117
+ const catalogs = catalogsData?.data ?? [];
118
+ const existingCatalog = catalogs.find((catalog) => catalog.isDefault) ?? catalogs[0];
119
+ const catalogId = existingCatalog?.id ??
120
+ (await createCatalog.mutateAsync({
121
+ code: "default",
122
+ name: t.priceColumn,
123
+ catalogType: "public",
124
+ isDefault: true,
125
+ })).id;
126
+ const created = await createRule.mutateAsync({
127
+ productId,
128
+ optionId,
129
+ priceCatalogId: catalogId,
130
+ name: optionName,
131
+ pricingMode: "per_person",
132
+ baseSellAmountCents: 0,
133
+ baseCostAmountCents: 0,
134
+ allPricingCategories: effectiveLayout === "seats",
135
+ isDefault: true,
136
+ active: true,
137
+ });
138
+ await refetchRules();
139
+ return created.id;
140
+ }
141
+ async function openCellDialog(unit, categoryId) {
142
+ const ruleId = await ensureRatePlanId();
143
+ setCellRuleId(ruleId);
144
+ setEditingCell(undefined);
145
+ setPreselectedUnitId(unit.id);
146
+ setPreselectedCategoryId(categoryId);
147
+ setCellDialogOpen(true);
148
+ }
149
+ const addRoomOrTraveler = () => {
150
+ setEditingUnit(undefined);
151
+ setDefaultUnitType(effectiveLayout === "rooms" ? "room" : "person");
152
+ setUnitDialogOpen(true);
153
+ };
154
+ const unitColumnLabel = effectiveLayout === "rooms" ? t.roomColumn : t.travelerColumn;
155
+ 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: () => setCategoryDialogOpen(true), 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) => {
156
+ const condition = getCategoryCondition(column.metadata);
157
+ return (_jsxs("th", { className: "p-2.5 text-left font-medium", 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] }, column.id ?? "__base__"));
158
+ }), _jsx("th", { className: "w-[72px] p-2.5 text-right font-medium" })] }) }), _jsx("tbody", { children: units.map((unit) => {
159
+ const subtitle = unitSubtitle(unit, effectiveLayout, t);
160
+ 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) => {
161
+ const cell = findCell(unit.id, column.id);
162
+ const canPrice = categoryAppliesToUnit(column, unit);
163
+ return (_jsx("td", { className: "p-2.5", children: cell ? (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("button", { type: "button", onClick: () => {
164
+ setCellRuleId(defaultRule?.id);
165
+ setEditingCell(cell);
166
+ setPreselectedUnitId(undefined);
167
+ setPreselectedCategoryId(undefined);
168
+ setCellDialogOpen(true);
169
+ }, 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__"));
170
+ }), _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: () => {
171
+ setEditingUnit(unit);
172
+ setDefaultUnitType(unit.unitType);
173
+ setUnitDialogOpen(true);
174
+ }, children: _jsx(Pencil, { className: "h-4 w-4" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": t.deleteRoom, onClick: () => {
175
+ if (confirm(formatMessage(t.deleteRoomConfirm, { name: unit.name }))) {
176
+ deleteUnitMutation.mutate(unit.id);
177
+ }
178
+ }, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }) })] }, unit.id));
179
+ }) })] }) })), _jsx(UnitDialog, { open: unitDialogOpen, onOpenChange: setUnitDialogOpen, optionId: optionId, unit: editingUnit, defaultUnitType: editingUnit ? undefined : defaultUnitType, lockUnitType: true, nextSortOrder: nextUnitSortOrder, onSuccess: () => {
180
+ setUnitDialogOpen(false);
181
+ setEditingUnit(undefined);
182
+ void refetchUnits();
183
+ } }), _jsx(TravelerCategoryDialog, { open: categoryDialogOpen, onOpenChange: setCategoryDialogOpen, productId: productId, units: units, nextSortOrder: categories.length > 0 ? Math.max(...categories.map((c) => c.sortOrder)) + 1 : 0, onSuccess: () => {
184
+ setCategoryDialogOpen(false);
185
+ void refetchCategories();
186
+ } }), _jsx(UnitPriceRuleDialog, { open: cellDialogOpen, onOpenChange: setCellDialogOpen, optionPriceRuleId: cellRuleId ?? defaultRule?.id ?? "", optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
187
+ setCellDialogOpen(false);
188
+ setEditingCell(undefined);
189
+ setPreselectedUnitId(undefined);
190
+ setPreselectedCategoryId(undefined);
191
+ void refetchCells();
192
+ } })] }));
193
+ }
@@ -1,6 +1,34 @@
1
- export declare function PricingPanel({ productId, optionId, productCurrency, }: {
1
+ import type * as React from "react";
2
+ import { useProductDetailMessages } from "./host.js";
3
+ import { type OptionPricingLayout } from "./product-options-shared.js";
4
+ import type { OptionUnitData } from "./product-unit-dialog.js";
5
+ export declare function getUnitTypeLabel(type: OptionUnitData["unitType"], messages: ReturnType<typeof useProductDetailMessages>["products"]["operations"]["units"]): string;
6
+ export declare function getCategoryCondition(metadata: Record<string, unknown> | null | undefined): string | null;
7
+ export declare function categoryAppliesToUnit(category: {
8
+ id: string | null;
9
+ metadata?: Record<string, unknown> | null;
10
+ }, unit: OptionUnitData): boolean;
11
+ /**
12
+ * Per-option pricing surface. The everyday view is the merged rooms/seats
13
+ * grid; the full rate-plan machinery (multiple plans, catalogs, cost prices,
14
+ * cancellation) plus any injected per-departure inventory live behind an
15
+ * Advanced disclosure so low-tech agents never have to see them.
16
+ */
17
+ export declare function PricingPanel({ productId, optionId, optionName, productCurrency, layout, extras, }: {
2
18
  productId: string;
3
19
  optionId: string;
20
+ optionName: string;
4
21
  productCurrency: string;
22
+ layout: OptionPricingLayout;
23
+ extras?: React.ReactNode;
5
24
  }): import("react/jsx-runtime").JSX.Element;
25
+ export declare function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }: {
26
+ open: boolean;
27
+ onOpenChange: (open: boolean) => void;
28
+ productId: string;
29
+ units: OptionUnitData[];
30
+ nextSortOrder: number;
31
+ onSuccess: () => void;
32
+ }): import("react/jsx-runtime").JSX.Element;
33
+ export declare function formatProductMoney(amountCents: number | null | undefined, currency: string): string;
6
34
  //# sourceMappingURL=product-options-pricing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"product-options-pricing.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-pricing.tsx"],"names":[],"mappings":"AA+HA,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,QAAQ,EACR,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,2CA6EA"}
1
+ {"version":3,"file":"product-options-pricing.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-pricing.tsx"],"names":[],"mappings":"AAuCA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAEnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAMpD,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;AAwcD,wBAAgB,sBAAsB,CAAC,EACrC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,KAAK,EACL,aAAa,EACb,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,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,2CAgNA;AAgOD,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,UAG1F"}
@@ -6,10 +6,11 @@ import { useExtraPriceRuleMutation, useExtraPriceRules, useOptionPriceRuleMutati
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
12
  import { OptionPriceRuleDialog, } from "./product-option-price-rule-dialog.js";
13
+ import { OptionPricingGrid } from "./product-option-pricing-grid.js";
13
14
  import { getOptionPriceRulesQueryOptions, getOptionUnitPriceRulesQueryOptions, getOptionUnitsQueryOptions, getPricingCategoriesQueryOptions, } from "./product-options-shared.js";
14
15
  import { UnitPriceRuleDialog, } from "./product-unit-price-rule-dialog.js";
15
16
  function getRulePricingModeLabel(value, messages) {
@@ -28,7 +29,7 @@ function getRulePricingModeLabel(value, messages) {
28
29
  return value;
29
30
  }
30
31
  }
31
- function getUnitTypeLabel(type, messages) {
32
+ export function getUnitTypeLabel(type, messages) {
32
33
  switch (type) {
33
34
  case "person":
34
35
  return messages.typePerson;
@@ -46,11 +47,11 @@ function getUnitTypeLabel(type, messages) {
46
47
  return type;
47
48
  }
48
49
  }
49
- function getCategoryCondition(metadata) {
50
+ export function getCategoryCondition(metadata) {
50
51
  const condition = metadata?.condition;
51
52
  return typeof condition === "string" && condition.trim().length > 0 ? condition : null;
52
53
  }
53
- function categoryAppliesToUnit(category, unit) {
54
+ export function categoryAppliesToUnit(category, unit) {
54
55
  if (!category.id)
55
56
  return true;
56
57
  const allowedUnitIds = category.metadata?.allowedUnitIds;
@@ -61,7 +62,19 @@ function categoryAppliesToUnit(category, unit) {
61
62
  function ActionMenu({ children }) {
62
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 })] }));
63
64
  }
64
- export function PricingPanel({ productId, optionId, productCurrency, }) {
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, }) {
65
78
  const messages = useProductDetailMessages();
66
79
  const client = useVoyantProductsContext();
67
80
  const priceRuleMessages = messages.products.operations.priceRules;
@@ -74,10 +87,19 @@ export function PricingPanel({ productId, optionId, productCurrency, }) {
74
87
  onSuccess: () => void refetch(),
75
88
  });
76
89
  const rules = data?.data ?? [];
77
- return (_jsxs("div", { children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: priceRuleMessages.sectionTitle }), _jsx("p", { className: "text-xs text-muted-foreground", children: priceRuleMessages.sectionDescription })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
78
- setEditingRule(undefined);
79
- setRuleDialogOpen(true);
80
- }, children: [_jsx(Plus, { className: "mr-1 h-3 w-3" }), priceRuleMessages.addAction] })] }), rules.length === 0 ? (_jsx("p", { className: "py-2 text-center text-xs text-muted-foreground", children: priceRuleMessages.empty })) : (_jsx("div", { className: "flex flex-col gap-3", children: rules.map((rule) => (_jsx(PriceRuleCard, { rule: rule, productId: productId, optionId: optionId, productCurrency: productCurrency, onEdit: () => {
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: () => {
81
103
  setEditingRule(rule);
82
104
  setRuleDialogOpen(true);
83
105
  }, onDelete: () => {
@@ -176,7 +198,7 @@ function UnitPriceMatrix({ productId, optionPriceRuleId, optionId, pricingMode,
176
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: () => {
177
199
  setCategoryDialogOpen(false);
178
200
  void refetchCategories();
179
- } }), _jsx(UnitPriceRuleDialog, { open: dialogOpen, onOpenChange: setDialogOpen, optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
201
+ } }), _jsx(UnitPriceRuleDialog, { open: dialogOpen, onOpenChange: setDialogOpen, optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: editingCell, onSuccess: () => {
180
202
  setDialogOpen(false);
181
203
  setEditingCell(undefined);
182
204
  setPreselectedUnitId(undefined);
@@ -202,7 +224,7 @@ function parseOptionalInteger(value) {
202
224
  const parsed = Number(trimmed);
203
225
  return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
204
226
  }
205
- function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }) {
227
+ export function TravelerCategoryDialog({ open, onOpenChange, productId, units, nextSortOrder, onSuccess, }) {
206
228
  const messages = useProductDetailMessages();
207
229
  const priceRuleMessages = messages.products.operations.priceRules;
208
230
  const pricingCategoryMessages = messages.pricing.categories;
@@ -356,7 +378,7 @@ function defaultExtraPriceRuleMode(extra) {
356
378
  return "on_request";
357
379
  return "per_booking";
358
380
  }
359
- function formatProductMoney(amountCents, currency) {
381
+ export function formatProductMoney(amountCents, currency) {
360
382
  if (amountCents == null)
361
383
  return "-";
362
384
  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;
@@ -1 +1 @@
1
- {"version":3,"file":"product-options-shared.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-shared.ts"],"names":[],"mappings":"AAOA,OAAO,EAGL,KAAK,0BAA0B,EAChC,MAAM,0BAA0B,CAAA;AAEjC;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,0BAA0B,CAAA;AAEtD,eAAO,MAAM,mBAAmB,EAAE,MAAM,CACtC,MAAM,EACN,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,aAAa,CAKpD,CAAA;AAED,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAErF;AAED,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEjF;AAED,wBAAgB,+BAA+B,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEtF;AAED,wBAAgB,gCAAgC,CAAC,MAAM,EAAE,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAErE;AAED,wBAAgB,mCAAmC,CACjD,MAAM,EAAE,aAAa,EACrB,iBAAiB,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAM1B;AAED,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEjE;AAED,wBAAgB,sCAAsC,CAAC,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKhG"}
1
+ {"version":3,"file":"product-options-shared.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-options-shared.ts"],"names":[],"mappings":"AAOA,OAAO,EAGL,KAAK,0BAA0B,EAChC,MAAM,0BAA0B,CAAA;AAEjC;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,0BAA0B,CAAA;AAEtD,eAAO,MAAM,mBAAmB,EAAE,MAAM,CACtC,MAAM,EACN,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,aAAa,CAKpD,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,OAAO,CAAA;AAEnD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,QAAQ,CAAC,EAAE,MAAM,GAChB,mBAAmB,CAarB;AAED,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAErF;AAED,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEjF;AAED,wBAAgB,+BAA+B,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEtF;AAED,wBAAgB,gCAAgC,CAAC,MAAM,EAAE,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAErE;AAED,wBAAgB,mCAAmC,CACjD,MAAM,EAAE,aAAa,EACrB,iBAAiB,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAM1B;AAED,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEjE;AAED,wBAAgB,sCAAsC,CAAC,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKhG"}
@@ -5,6 +5,26 @@ export const optionStatusVariant = {
5
5
  active: "default",
6
6
  archived: "secondary",
7
7
  };
8
+ /**
9
+ * Derive the pricing layout from the product's booking mode. Multi-day /
10
+ * overnight modes imply rooms; single-day activity modes imply per-person
11
+ * seats. `dayCount` is a fallback for the ambiguous `other` mode (>1 day →
12
+ * rooms), matching the operator rule "more than one day means rooms".
13
+ */
14
+ export function deriveOptionPricingLayout(bookingMode, dayCount) {
15
+ switch (bookingMode) {
16
+ case "stay":
17
+ case "itinerary":
18
+ return "rooms";
19
+ case "date":
20
+ case "date_time":
21
+ case "open":
22
+ case "transfer":
23
+ return "seats";
24
+ default:
25
+ return dayCount != null && dayCount > 1 ? "rooms" : "seats";
26
+ }
27
+ }
8
28
  export function getProductOptionsQueryOptions(client, productId) {
9
29
  return getSharedProductOptionsQueryOptions(client, { productId, limit: 100 });
10
30
  }
@@ -5,8 +5,10 @@ type UnitDialogProps = {
5
5
  onOpenChange: (open: boolean) => void;
6
6
  optionId: string;
7
7
  unit?: OptionUnitData;
8
+ defaultUnitType?: OptionUnitData["unitType"];
9
+ lockUnitType?: boolean;
8
10
  nextSortOrder?: number;
9
11
  onSuccess: () => void;
10
12
  };
11
- export declare function UnitDialog({ open, onOpenChange, optionId, unit, nextSortOrder, onSuccess, }: UnitDialogProps): import("react/jsx-runtime").JSX.Element;
13
+ export declare function UnitDialog({ open, onOpenChange, optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, }: UnitDialogProps): import("react/jsx-runtime").JSX.Element;
12
14
  //# sourceMappingURL=product-unit-dialog.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"product-unit-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,cAAc,EAAY,MAAM,wBAAwB,CAAA;AAEtE,YAAY,EAAE,cAAc,EAAE,CAAA;AAE9B,KAAK,eAAe,GAAG;IACrB,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,IAAI,EACJ,aAAa,EACb,SAAS,GACV,EAAE,eAAe,2CAuBjB"}
1
+ {"version":3,"file":"product-unit-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,cAAc,EAAY,MAAM,wBAAwB,CAAA;AAEtE,YAAY,EAAE,cAAc,EAAE,CAAA;AAE9B,KAAK,eAAe,GAAG;IACrB,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,eAAe,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAA;IAC5C,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,IAAI,EACJ,eAAe,EACf,YAAY,EACZ,aAAa,EACb,SAAS,GACV,EAAE,eAAe,2CAyBjB"}
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from "@voyantjs/ui/components";
3
3
  import { useProductDetailMessages } from "./host.js";
4
4
  import { UnitForm } from "./product-unit-form.js";
5
- export function UnitDialog({ open, onOpenChange, optionId, unit, nextSortOrder, onSuccess, }) {
5
+ export function UnitDialog({ open, onOpenChange, optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, }) {
6
6
  const messages = useProductDetailMessages();
7
7
  const unitMessages = messages.products.operations.units;
8
8
  const isEditing = !!unit;
9
- return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? unitMessages.editTitle : unitMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(UnitForm, { optionId: optionId, unit: unit, nextSortOrder: nextSortOrder, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
9
+ return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? unitMessages.editTitle : unitMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(UnitForm, { optionId: optionId, unit: unit, defaultUnitType: defaultUnitType, lockUnitType: lockUnitType, nextSortOrder: nextSortOrder, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
10
10
  }
@@ -18,9 +18,17 @@ export type OptionUnitData = {
18
18
  export interface UnitFormProps {
19
19
  optionId: string;
20
20
  unit?: OptionUnitData;
21
+ /** Pre-selected unit type for the "add" path (e.g. Room vs Traveler type). */
22
+ defaultUnitType?: OptionUnitData["unitType"];
23
+ /**
24
+ * Hide the unit-type picker entirely. Used when the form is opened from a
25
+ * type-specific context (e.g. "Add room"), so the agent can't turn a room
26
+ * into a vehicle and create a nonsensical mix in the pricing grid.
27
+ */
28
+ lockUnitType?: boolean;
21
29
  nextSortOrder?: number;
22
30
  onSuccess: () => void;
23
31
  onCancel?: () => void;
24
32
  }
25
- export declare function UnitForm({ optionId, unit, nextSortOrder, onSuccess, onCancel }: UnitFormProps): import("react/jsx-runtime").JSX.Element;
33
+ export declare function UnitForm({ optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, onCancel, }: UnitFormProps): import("react/jsx-runtime").JSX.Element;
26
34
  //# sourceMappingURL=product-unit-form.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"product-unit-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-form.tsx"],"names":[],"mappings":"AA2CA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,QAAQ,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;IACvE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAqCD,wBAAgB,QAAQ,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,aAAa,2CAuL7F"}
1
+ {"version":3,"file":"product-unit-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-form.tsx"],"names":[],"mappings":"AA2EA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,QAAQ,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;IACvE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,8EAA8E;IAC9E,eAAe,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAA;IAC5C;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAyCD,wBAAgB,QAAQ,CAAC,EACvB,QAAQ,EACR,IAAI,EACJ,eAAe,EACf,YAAY,EACZ,aAAa,EACb,SAAS,EACT,QAAQ,GACT,EAAE,aAAa,2CA2Lf"}
@@ -7,6 +7,34 @@ import { useForm } from "react-hook-form";
7
7
  import { z } from "zod/v4";
8
8
  import { useProductDetailMessages } from "./host.js";
9
9
  import { zodResolver } from "./zod-resolver.js";
10
+ // "Min/Max quantity" is meaningless to an agent — phrase it in terms of the
11
+ // thing being counted (rooms / vehicles / travelers) for the selected type.
12
+ function quantityLabels(unitType, m) {
13
+ switch (unitType) {
14
+ case "room":
15
+ return { min: m.quantityRoomMin, max: m.quantityRoomMax };
16
+ case "vehicle":
17
+ return { min: m.quantityVehicleMin, max: m.quantityVehicleMax };
18
+ case "person":
19
+ return { min: m.quantityPersonMin, max: m.quantityPersonMax };
20
+ default:
21
+ return { min: m.minQuantityLabel, max: m.maxQuantityLabel };
22
+ }
23
+ }
24
+ // Occupancy = how many people fit in one unit (guests per room, seats per
25
+ // vehicle, group size). Label it for the selected type.
26
+ function occupancyLabels(unitType, m) {
27
+ switch (unitType) {
28
+ case "room":
29
+ return { min: m.occupancyRoomMin, max: m.occupancyRoomMax };
30
+ case "vehicle":
31
+ return { min: m.occupancyVehicleMin, max: m.occupancyVehicleMax };
32
+ case "group":
33
+ return { min: m.occupancyGroupMin, max: m.occupancyGroupMax };
34
+ default:
35
+ return { min: m.occupancyMinLabel, max: m.occupancyMaxLabel };
36
+ }
37
+ }
10
38
  const buildUnitFormSchema = (messages) => z.object({
11
39
  name: z.string().min(1, messages.validationNameRequired).max(255),
12
40
  code: z.string().max(100).optional().nullable(),
@@ -22,7 +50,7 @@ const buildUnitFormSchema = (messages) => z.object({
22
50
  isHidden: z.boolean(),
23
51
  sortOrder: z.coerce.number().int(),
24
52
  });
25
- function initialValues(unit, nextSortOrder) {
53
+ function initialValues(unit, nextSortOrder, defaultUnitType) {
26
54
  if (unit) {
27
55
  return {
28
56
  name: unit.name,
@@ -44,7 +72,7 @@ function initialValues(unit, nextSortOrder) {
44
72
  name: "",
45
73
  code: "",
46
74
  description: "",
47
- unitType: "person",
75
+ unitType: defaultUnitType ?? "person",
48
76
  minQuantity: "",
49
77
  maxQuantity: "",
50
78
  minAge: "",
@@ -56,7 +84,7 @@ function initialValues(unit, nextSortOrder) {
56
84
  sortOrder: nextSortOrder ?? 0,
57
85
  };
58
86
  }
59
- export function UnitForm({ optionId, unit, nextSortOrder, onSuccess, onCancel }) {
87
+ export function UnitForm({ optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, onCancel, }) {
60
88
  const messages = useProductDetailMessages();
61
89
  const productMessages = messages.products.core;
62
90
  const unitMessages = messages.products.operations.units;
@@ -73,11 +101,11 @@ export function UnitForm({ optionId, unit, nextSortOrder, onSuccess, onCancel })
73
101
  ];
74
102
  const form = useForm({
75
103
  resolver: zodResolver(unitFormSchema),
76
- defaultValues: initialValues(unit, nextSortOrder),
104
+ defaultValues: initialValues(unit, nextSortOrder, defaultUnitType),
77
105
  });
78
106
  useEffect(() => {
79
- form.reset(initialValues(unit, nextSortOrder));
80
- }, [unit, nextSortOrder, form]);
107
+ form.reset(initialValues(unit, nextSortOrder, defaultUnitType));
108
+ }, [unit, nextSortOrder, defaultUnitType, form]);
81
109
  const onSubmit = async (values) => {
82
110
  const canHaveAge = values.unitType === "person";
83
111
  const canHaveOccupancy = values.unitType === "group" || values.unitType === "room" || values.unitType === "vehicle";
@@ -105,5 +133,7 @@ export function UnitForm({ optionId, unit, nextSortOrder, onSuccess, onCancel })
105
133
  onSuccess();
106
134
  };
107
135
  const unitType = form.watch("unitType");
108
- return (_jsxs("form", { onSubmit: form.handleSubmit(onSubmit), className: "flex flex-1 flex-col gap-4 overflow-hidden", children: [_jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.nameLabel }), _jsx(Input, { ...form.register("name"), placeholder: unitMessages.namePlaceholder }), form.formState.errors.name && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.name.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.codeLabel }), _jsx(Input, { ...form.register("code"), placeholder: unitMessages.codePlaceholder })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.typeLabel }), _jsxs(Select, { value: unitType, onValueChange: (v) => form.setValue("unitType", v), items: unitTypes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: unitTypes.map((t) => (_jsx(SelectItem, { value: t.value, children: t.label }, t.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.sortOrderLabel }), _jsx(Input, { ...form.register("sortOrder"), type: "number" })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.minQuantityLabel }), _jsx(Input, { ...form.register("minQuantity"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.maxQuantityLabel }), _jsx(Input, { ...form.register("maxQuantity"), type: "number", min: "0" })] })] }), unitType === "person" && (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.minAgeLabel }), _jsx(Input, { ...form.register("minAge"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.maxAgeLabel }), _jsx(Input, { ...form.register("maxAge"), type: "number", min: "0" })] })] })), (unitType === "room" || unitType === "vehicle" || unitType === "group") && (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.occupancyMinLabel }), _jsx(Input, { ...form.register("occupancyMin"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.occupancyMaxLabel }), _jsx(Input, { ...form.register("occupancyMax"), type: "number", min: "0" })] })] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.descriptionLabel }), _jsx(Textarea, { ...form.register("description") })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("isRequired"), onCheckedChange: (v) => form.setValue("isRequired", v) }), _jsx(Label, { children: unitMessages.requiredLabel })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("isHidden"), onCheckedChange: (v) => form.setValue("isHidden", v) }), _jsx(Label, { children: unitMessages.hiddenLabel })] })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting || create.isPending || update.isPending, children: [(form.formState.isSubmitting || create.isPending || update.isPending) && (_jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" })), isEditing ? productMessages.saveChanges : unitMessages.create] })] })] }));
136
+ const qtyLabels = quantityLabels(unitType, unitMessages);
137
+ const occLabels = occupancyLabels(unitType, unitMessages);
138
+ return (_jsxs("form", { onSubmit: form.handleSubmit(onSubmit), className: "flex flex-1 flex-col gap-4 overflow-hidden", children: [_jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.nameLabel }), _jsx(Input, { ...form.register("name"), placeholder: unitMessages.namePlaceholder }), form.formState.errors.name && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.name.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.codeLabel }), _jsx(Input, { ...form.register("code"), placeholder: unitMessages.codePlaceholder })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [lockUnitType ? null : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.typeLabel }), _jsxs(Select, { value: unitType, onValueChange: (v) => form.setValue("unitType", v), items: unitTypes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: unitTypes.map((t) => (_jsx(SelectItem, { value: t.value, children: t.label }, t.value))) })] })] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.sortOrderLabel }), _jsx(Input, { ...form.register("sortOrder"), type: "number" })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: qtyLabels.min }), _jsx(Input, { ...form.register("minQuantity"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: qtyLabels.max }), _jsx(Input, { ...form.register("maxQuantity"), type: "number", min: "0" })] })] }), unitType === "person" && (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.minAgeLabel }), _jsx(Input, { ...form.register("minAge"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.maxAgeLabel }), _jsx(Input, { ...form.register("maxAge"), type: "number", min: "0" })] })] })), (unitType === "room" || unitType === "vehicle" || unitType === "group") && (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: occLabels.min }), _jsx(Input, { ...form.register("occupancyMin"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: occLabels.max }), _jsx(Input, { ...form.register("occupancyMax"), type: "number", min: "0" })] })] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.descriptionLabel }), _jsx(Textarea, { ...form.register("description") })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("isRequired"), onCheckedChange: (v) => form.setValue("isRequired", v) }), _jsx(Label, { children: unitMessages.requiredLabel })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("isHidden"), onCheckedChange: (v) => form.setValue("isHidden", v) }), _jsx(Label, { children: unitMessages.hiddenLabel })] })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting || create.isPending || update.isPending, children: [(form.formState.isSubmitting || create.isPending || update.isPending) && (_jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" })), isEditing ? productMessages.saveChanges : unitMessages.create] })] })] }));
109
139
  }
@@ -7,10 +7,11 @@ type UnitPriceRuleDialogProps = {
7
7
  optionPriceRuleId: string;
8
8
  optionId: string;
9
9
  units: OptionUnitData[];
10
+ productCurrency?: string;
10
11
  preselectedUnitId?: string;
11
12
  preselectedCategoryId?: string | null;
12
13
  cell?: OptionUnitPriceRuleData;
13
14
  onSuccess: () => void;
14
15
  };
15
- export declare function UnitPriceRuleDialog({ open, onOpenChange, optionPriceRuleId, optionId, units, preselectedUnitId, preselectedCategoryId, cell, onSuccess, }: UnitPriceRuleDialogProps): import("react/jsx-runtime").JSX.Element;
16
+ export declare function UnitPriceRuleDialog({ open, onOpenChange, optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, }: UnitPriceRuleDialogProps): import("react/jsx-runtime").JSX.Element;
16
17
  //# sourceMappingURL=product-unit-price-rule-dialog.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"product-unit-price-rule-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-price-rule-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAC5D,OAAO,EAAE,KAAK,uBAAuB,EAAqB,MAAM,mCAAmC,CAAA;AAEnG,YAAY,EAAE,uBAAuB,EAAE,CAAA;AAEvC,KAAK,wBAAwB,GAAG;IAC9B,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,IAAI,CAAC,EAAE,uBAAuB,CAAA;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,iBAAiB,EACjB,QAAQ,EACR,KAAK,EACL,iBAAiB,EACjB,qBAAqB,EACrB,IAAI,EACJ,SAAS,GACV,EAAE,wBAAwB,2CA4B1B"}
1
+ {"version":3,"file":"product-unit-price-rule-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-price-rule-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAC5D,OAAO,EAAE,KAAK,uBAAuB,EAAqB,MAAM,mCAAmC,CAAA;AAEnG,YAAY,EAAE,uBAAuB,EAAE,CAAA;AAEvC,KAAK,wBAAwB,GAAG;IAC9B,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,IAAI,CAAC,EAAE,uBAAuB,CAAA;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,iBAAiB,EACjB,QAAQ,EACR,KAAK,EACL,eAAe,EACf,iBAAiB,EACjB,qBAAqB,EACrB,IAAI,EACJ,SAAS,GACV,EAAE,wBAAwB,2CA6B1B"}
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from "@voyantjs/ui/components";
3
3
  import { useProductDetailMessages } from "./host.js";
4
4
  import { UnitPriceRuleForm } from "./product-unit-price-rule-form.js";
5
- export function UnitPriceRuleDialog({ open, onOpenChange, optionPriceRuleId, optionId, units, preselectedUnitId, preselectedCategoryId, cell, onSuccess, }) {
5
+ export function UnitPriceRuleDialog({ open, onOpenChange, optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, }) {
6
6
  const messages = useProductDetailMessages();
7
7
  const unitPriceMessages = messages.products.operations.unitPrices;
8
8
  const isEditing = !!cell;
9
- return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? unitPriceMessages.editTitle : unitPriceMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(UnitPriceRuleForm, { optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: cell, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
9
+ return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? unitPriceMessages.editTitle : unitPriceMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(UnitPriceRuleForm, { optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: cell, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
10
10
  }
@@ -18,11 +18,12 @@ export interface UnitPriceRuleFormProps {
18
18
  optionPriceRuleId: string;
19
19
  optionId: string;
20
20
  units: OptionUnitData[];
21
+ productCurrency?: string;
21
22
  preselectedUnitId?: string;
22
23
  preselectedCategoryId?: string | null;
23
24
  cell?: OptionUnitPriceRuleData;
24
25
  onSuccess: () => void;
25
26
  onCancel?: () => void;
26
27
  }
27
- export declare function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, preselectedUnitId, preselectedCategoryId, cell, onSuccess, onCancel, }: UnitPriceRuleFormProps): import("react/jsx-runtime").JSX.Element;
28
+ export declare function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, onCancel, }: UnitPriceRuleFormProps): import("react/jsx-runtime").JSX.Element;
28
29
  //# sourceMappingURL=product-unit-price-rule-form.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"product-unit-price-rule-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-price-rule-form.tsx"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAsD5D,MAAM,MAAM,uBAAuB,GAAG;IACpC,EAAE,EAAE,MAAM,CAAA;IACV,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,WAAW,EAAE,UAAU,GAAG,YAAY,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,GAAG,YAAY,CAAA;IAC3F,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,OAAO,CAAA;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,WAAW,sBAAsB;IACrC,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,IAAI,CAAC,EAAE,uBAAuB,CAAA;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAmCD,wBAAgB,iBAAiB,CAAC,EAChC,iBAAiB,EACjB,QAAQ,EACR,KAAK,EACL,iBAAiB,EACjB,qBAAqB,EACrB,IAAI,EACJ,SAAS,EACT,QAAQ,GACT,EAAE,sBAAsB,2CAyKxB"}
1
+ {"version":3,"file":"product-unit-price-rule-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-price-rule-form.tsx"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AA2E5D,MAAM,MAAM,uBAAuB,GAAG;IACpC,EAAE,EAAE,MAAM,CAAA;IACV,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,WAAW,EAAE,UAAU,GAAG,YAAY,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,GAAG,YAAY,CAAA;IAC3F,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,OAAO,CAAA;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,WAAW,sBAAsB;IACrC,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,IAAI,CAAC,EAAE,uBAAuB,CAAA;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAmCD,wBAAgB,iBAAiB,CAAC,EAChC,iBAAiB,EACjB,QAAQ,EACR,KAAK,EACL,eAAe,EACf,iBAAiB,EACjB,qBAAqB,EACrB,IAAI,EACJ,SAAS,EACT,QAAQ,GACT,EAAE,sBAAsB,2CAoLxB"}
@@ -2,11 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useOptionUnitPriceRuleMutation } from "@voyantjs/pricing-react";
3
3
  import { PricingCategoryCombobox } from "@voyantjs/pricing-ui/components/pricing-category-combobox";
4
4
  import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
5
+ import { CurrencyInput } from "@voyantjs/ui/components/currency-input";
5
6
  import { Loader2 } from "lucide-react";
6
7
  import { useEffect } from "react";
7
8
  import { useForm } from "react-hook-form";
8
9
  import { z } from "zod/v4";
9
- import { useProductDetailMessages } from "./host.js";
10
+ import { useProductDetailMessages, useProductLocale } from "./host.js";
10
11
  import { zodResolver } from "./zod-resolver.js";
11
12
  function getUnitTypeLabel(type, messages) {
12
13
  switch (type) {
@@ -26,6 +27,21 @@ function getUnitTypeLabel(type, messages) {
26
27
  return type;
27
28
  }
28
29
  }
30
+ // "Min/Max quantity" means different things per pricing mode — travelers for
31
+ // per-person, units for per-unit, the whole booking for per-booking. Label it
32
+ // for what's actually being counted.
33
+ function cellQuantityLabels(pricingMode, m) {
34
+ switch (pricingMode) {
35
+ case "per_person":
36
+ return { min: m.minQuantityPerson, max: m.maxQuantityPerson };
37
+ case "per_unit":
38
+ return { min: m.minQuantityUnit, max: m.maxQuantityUnit };
39
+ case "per_booking":
40
+ return { min: m.minQuantityBooking, max: m.maxQuantityBooking };
41
+ default:
42
+ return { min: m.minQuantityLabel, max: m.maxQuantityLabel };
43
+ }
44
+ }
29
45
  const buildCellFormSchema = (messages) => z.object({
30
46
  unitId: z.string().min(1, messages.validationUnitRequired),
31
47
  pricingCategoryId: z.string().optional().nullable(),
@@ -37,8 +53,10 @@ const buildCellFormSchema = (messages) => z.object({
37
53
  "free",
38
54
  "on_request",
39
55
  ]),
40
- sell: z.coerce.number().min(0),
41
- cost: z.coerce.number().min(0),
56
+ // Stored in minor units (cents) so CurrencyInput can render the currency
57
+ // prefix and parse locale-formatted amounts directly.
58
+ sell: z.number().int().min(0),
59
+ cost: z.number().int().min(0),
42
60
  minQuantity: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
43
61
  maxQuantity: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
44
62
  sortOrder: z.coerce.number().int(),
@@ -51,8 +69,8 @@ function initialValues(cell, preselectedUnitId, preselectedCategoryId) {
51
69
  unitId: cell.unitId,
52
70
  pricingCategoryId: cell.pricingCategoryId ?? "",
53
71
  pricingMode: cell.pricingMode,
54
- sell: (cell.sellAmountCents ?? 0) / 100,
55
- cost: (cell.costAmountCents ?? 0) / 100,
72
+ sell: cell.sellAmountCents ?? 0,
73
+ cost: cell.costAmountCents ?? 0,
56
74
  minQuantity: cell.minQuantity ?? "",
57
75
  maxQuantity: cell.maxQuantity ?? "",
58
76
  sortOrder: cell.sortOrder,
@@ -73,11 +91,12 @@ function initialValues(cell, preselectedUnitId, preselectedCategoryId) {
73
91
  notes: "",
74
92
  };
75
93
  }
76
- export function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, preselectedUnitId, preselectedCategoryId, cell, onSuccess, onCancel, }) {
94
+ export function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, onCancel, }) {
77
95
  const messages = useProductDetailMessages();
78
96
  const productMessages = messages.products.core;
79
97
  const unitPriceMessages = messages.products.operations.unitPrices;
80
98
  const unitMessages = messages.products.operations.units;
99
+ const locale = useProductLocale();
81
100
  const isEditing = !!cell;
82
101
  const { create, update } = useOptionUnitPriceRuleMutation();
83
102
  const cellFormSchema = buildCellFormSchema(unitPriceMessages);
@@ -103,8 +122,8 @@ export function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, preselec
103
122
  unitId: values.unitId,
104
123
  pricingCategoryId: values.pricingCategoryId || null,
105
124
  pricingMode: values.pricingMode,
106
- sellAmountCents: Math.round(values.sell * 100),
107
- costAmountCents: Math.round(values.cost * 100),
125
+ sellAmountCents: Math.round(values.sell),
126
+ costAmountCents: Math.round(values.cost),
108
127
  minQuantity: typeof values.minQuantity === "number" ? values.minQuantity : null,
109
128
  maxQuantity: typeof values.maxQuantity === "number" ? values.maxQuantity : null,
110
129
  sortOrder: values.sortOrder,
@@ -122,5 +141,5 @@ export function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, preselec
122
141
  return (_jsxs("form", { onSubmit: form.handleSubmit(onSubmit), className: "flex flex-1 flex-col gap-4 overflow-hidden", children: [_jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.unitLabel }), _jsxs(Select, { value: form.watch("unitId") || undefined, onValueChange: (v) => form.setValue("unitId", v ?? "", { shouldValidate: true }), items: units.map((u) => ({
123
142
  value: u.id,
124
143
  label: `${u.name} (${getUnitTypeLabel(u.unitType, unitMessages)})`,
125
- })), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: unitPriceMessages.unitPlaceholder }) }), _jsx(SelectContent, { children: units.map((u) => (_jsxs(SelectItem, { value: u.id, children: [u.name, " (", getUnitTypeLabel(u.unitType, unitMessages), ")"] }, u.id))) })] }), form.formState.errors.unitId && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.unitId.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.categoryLabel }), _jsx(PricingCategoryCombobox, { value: form.watch("pricingCategoryId"), onChange: (value) => form.setValue("pricingCategoryId", value ?? "", { shouldDirty: true }), placeholder: unitPriceMessages.categoryPlaceholder })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.pricingModeLabel }), _jsxs(Select, { value: form.watch("pricingMode"), onValueChange: (v) => form.setValue("pricingMode", v), items: pricingModes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: pricingModes.map((m) => (_jsx(SelectItem, { value: m.value, children: m.label }, m.value))) })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.sellLabel }), _jsx(Input, { ...form.register("sell"), type: "number", step: "0.01", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.costLabel }), _jsx(Input, { ...form.register("cost"), type: "number", step: "0.01", min: "0" })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.minQuantityLabel }), _jsx(Input, { ...form.register("minQuantity"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.maxQuantityLabel }), _jsx(Input, { ...form.register("maxQuantity"), type: "number", min: "0" })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.sortOrderLabel }), _jsx(Input, { ...form.register("sortOrder"), type: "number" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("active"), onCheckedChange: (v) => form.setValue("active", v) }), _jsx(Label, { children: unitPriceMessages.activeLabel })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.notesLabel }), _jsx(Textarea, { ...form.register("notes") })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting, children: [form.formState.isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), isEditing ? productMessages.saveChanges : unitPriceMessages.create] })] })] }));
144
+ })), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: unitPriceMessages.unitPlaceholder }) }), _jsx(SelectContent, { children: units.map((u) => (_jsxs(SelectItem, { value: u.id, children: [u.name, " (", getUnitTypeLabel(u.unitType, unitMessages), ")"] }, u.id))) })] }), form.formState.errors.unitId && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.unitId.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.categoryLabel }), _jsx(PricingCategoryCombobox, { value: form.watch("pricingCategoryId"), onChange: (value) => form.setValue("pricingCategoryId", value ?? "", { shouldDirty: true }), placeholder: unitPriceMessages.categoryPlaceholder })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.pricingModeLabel }), _jsxs(Select, { value: form.watch("pricingMode"), onValueChange: (v) => form.setValue("pricingMode", v), items: pricingModes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: pricingModes.map((m) => (_jsx(SelectItem, { value: m.value, children: m.label }, m.value))) })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.sellLabel }), _jsx(CurrencyInput, { value: form.watch("sell"), onChange: (value) => form.setValue("sell", value ?? 0, { shouldValidate: true }), currency: productCurrency, locale: locale })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.costLabel }), _jsx(CurrencyInput, { value: form.watch("cost"), onChange: (value) => form.setValue("cost", value ?? 0, { shouldValidate: true }), currency: productCurrency, locale: locale })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: cellQuantityLabels(form.watch("pricingMode"), unitPriceMessages).min }), _jsx(Input, { ...form.register("minQuantity"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: cellQuantityLabels(form.watch("pricingMode"), unitPriceMessages).max }), _jsx(Input, { ...form.register("maxQuantity"), type: "number", min: "0" })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.sortOrderLabel }), _jsx(Input, { ...form.register("sortOrder"), type: "number" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("active"), onCheckedChange: (v) => form.setValue("active", v) }), _jsx(Label, { children: unitPriceMessages.activeLabel })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitPriceMessages.notesLabel }), _jsx(Textarea, { ...form.register("notes") })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting, children: [form.formState.isSubmitting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), isEditing ? productMessages.saveChanges : unitPriceMessages.create] })] })] }));
126
145
  }
@@ -1 +1 @@
1
- {"version":3,"file":"product-options-section.d.ts","sourceRoot":"","sources":["../../src/components/product-options-section.tsx"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EAOzB,MAAM,0BAA0B,CAAA;AA6BjC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqC9B,wBAAgB,mCAAmC,CACjD,MAAM,EAAE,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,MAAM,CAAC,GACjD,OAAO,CAIT;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,aAAa,CAAC,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,QAAQ,CAAC,CAAC,EACpF,eAAe,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAC,EAAE,CAAC,GAClF,MAAM,EAAE,CASV;AA0CD,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,KAAK,CAAC,SAAS,CAAA;CACvE;AAED,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,QAAc,EACd,KAAK,EACL,WAAW,EACX,mBAAmB,GACpB,EAAE,0BAA0B,2CAsJ5B"}
1
+ {"version":3,"file":"product-options-section.d.ts","sourceRoot":"","sources":["../../src/components/product-options-section.tsx"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EAOzB,MAAM,0BAA0B,CAAA;AA6BjC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqC9B,wBAAgB,mCAAmC,CACjD,MAAM,EAAE,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,MAAM,CAAC,GACjD,OAAO,CAIT;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,aAAa,CAAC,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,QAAQ,CAAC,CAAC,EACpF,eAAe,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAC,EAAE,CAAC,GAClF,MAAM,EAAE,CASV;AA0CD,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,KAAK,CAAC,SAAS,CAAA;CACvE;AAED,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,QAAc,EACd,KAAK,EACL,WAAW,EACX,mBAAmB,GACpB,EAAE,0BAA0B,2CA+J5B"}
@@ -111,34 +111,45 @@ export function ProductOptionsSection({ productId, pageSize = 100, title, descri
111
111
  const nextSortOrder = options.length > 0 ? Math.max(...options.map((option) => option.sortOrder)) + 1 : 0;
112
112
  const resolvedTitle = title ?? messages.productOptionsSection.titles.default;
113
113
  const resolvedDescription = description ?? messages.productOptionsSection.descriptions.default;
114
+ // A product with a single option needs no option chrome — show its pricing
115
+ // table directly. Only flatten when a host injects the details (the grid);
116
+ // bare mounts (apps/dev) keep the expandable units table.
117
+ const flattenedOption = renderOptionDetails && options.length === 1 ? options[0] : undefined;
118
+ const editOption = (option) => {
119
+ setEditingOption(option);
120
+ setDialogOpen(true);
121
+ };
122
+ const duplicateOptionFlow = (option) => {
123
+ duplicateOption.mutate({ sourceOptionId: option.id, productId }, {
124
+ onSuccess: async ({ option: duplicatedOption, unitIdMap }) => {
125
+ await duplicatePricing.mutateAsync({
126
+ sourceOptionId: option.id,
127
+ targetOptionId: duplicatedOption.id,
128
+ productId,
129
+ unitIdMap,
130
+ });
131
+ },
132
+ });
133
+ };
134
+ const deleteOption = (option) => {
135
+ if (confirm(messages.productOptionsSection.deleteConfirm.option.replace("{name}", option.name))) {
136
+ remove.mutate(option.id);
137
+ }
138
+ };
114
139
  return (_jsxs(Card, { "data-slot": "product-options-section", children: [_jsxs(CardHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(CardTitle, { children: resolvedTitle }), _jsx(CardDescription, { children: resolvedDescription })] }), _jsxs(Button, { onClick: () => {
115
140
  setEditingOption(undefined);
116
141
  setDialogOpen(true);
117
- }, children: [_jsx(Plus, { className: "mr-2 size-4", "aria-hidden": "true" }), messages.productOptionsSection.actions.addOption] })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [showRoomArrangementWarning ? (_jsxs(Alert, { className: "border-amber-500/40 bg-amber-500/10", children: [_jsx(TriangleAlert, { className: "size-4 text-amber-600", "aria-hidden": "true" }), _jsx(AlertTitle, { children: messages.productOptionsSection.configurationWarnings.roomOptionsTitle }), _jsx(AlertDescription, { children: formatMessage(messages.productOptionsSection.configurationWarnings.roomOptionsDescription, { options: roomArrangementOptionNames.join(", ") }) })] })) : null, isPending ? (_jsx("div", { className: "flex min-h-24 items-center justify-center", children: _jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" }) })) : isError ? (_jsx("p", { className: "text-sm text-destructive", children: messages.productOptionsSection.loadingError.options })) : options.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.productOptionsSection.empty.options })) : (options.map((option) => (_jsx(OptionRow, { option: option, expanded: expandedOptionId === option.id, onToggle: () => setExpandedOptionId((current) => (current === option.id ? null : option.id)), onEdit: () => {
118
- setEditingOption(option);
119
- setDialogOpen(true);
120
- }, onDuplicate: () => {
121
- duplicateOption.mutate({ sourceOptionId: option.id, productId }, {
122
- onSuccess: async ({ option: duplicatedOption, unitIdMap }) => {
123
- await duplicatePricing.mutateAsync({
124
- sourceOptionId: option.id,
125
- targetOptionId: duplicatedOption.id,
126
- productId,
127
- unitIdMap,
128
- });
129
- },
130
- });
131
- }, onDelete: () => {
132
- if (confirm(messages.productOptionsSection.deleteConfirm.option.replace("{name}", option.name))) {
133
- remove.mutate(option.id);
134
- }
135
- }, messages: messages, children: renderOptionDetails?.(option) }, option.id)))), _jsx(ProductOptionDialog, { open: dialogOpen, onOpenChange: setDialogOpen, productId: productId, option: editingOption, sortOrder: nextSortOrder, onSuccess: () => {
142
+ }, children: [_jsx(Plus, { className: "mr-2 size-4", "aria-hidden": "true" }), messages.productOptionsSection.actions.addOption] })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [showRoomArrangementWarning ? (_jsxs(Alert, { className: "border-amber-500/40 bg-amber-500/10", children: [_jsx(TriangleAlert, { className: "size-4 text-amber-600", "aria-hidden": "true" }), _jsx(AlertTitle, { children: messages.productOptionsSection.configurationWarnings.roomOptionsTitle }), _jsx(AlertDescription, { children: formatMessage(messages.productOptionsSection.configurationWarnings.roomOptionsDescription, { options: roomArrangementOptionNames.join(", ") }) })] })) : null, isPending ? (_jsx("div", { className: "flex min-h-24 items-center justify-center", children: _jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" }) })) : isError ? (_jsx("p", { className: "text-sm text-destructive", children: messages.productOptionsSection.loadingError.options })) : options.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.productOptionsSection.empty.options })) : flattenedOption ? (
143
+ // A single option needs no chrome at all — show its pricing table
144
+ // directly. Per-option actions (duplicate/edit/delete) only appear
145
+ // once there are 2+ options to disambiguate.
146
+ renderOptionDetails?.(flattenedOption)) : (options.map((option) => (_jsx(OptionRow, { option: option, expanded: expandedOptionId === option.id, onToggle: () => setExpandedOptionId((current) => (current === option.id ? null : option.id)), onEdit: () => editOption(option), onDuplicate: () => duplicateOptionFlow(option), onDelete: () => deleteOption(option), messages: messages, children: renderOptionDetails?.(option) }, option.id)))), _jsx(ProductOptionDialog, { open: dialogOpen, onOpenChange: setDialogOpen, productId: productId, option: editingOption, sortOrder: nextSortOrder, onSuccess: () => {
136
147
  setDialogOpen(false);
137
148
  setEditingOption(undefined);
138
149
  } })] })] }));
139
150
  }
140
151
  function OptionRow({ option, expanded, onToggle, onEdit, onDuplicate, onDelete, messages, children, }) {
141
- return (_jsxs("div", { className: "rounded-md border", children: [_jsxs("div", { className: "flex items-center gap-3 p-3", children: [_jsx("button", { type: "button", onClick: onToggle, className: "text-muted-foreground transition-colors hover:text-foreground", children: expanded ? _jsx(ChevronDown, { className: "size-4" }) : _jsx(ChevronRight, { className: "size-4" }) }), _jsxs("div", { className: "flex flex-1 flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: option.name }), option.code ? (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: option.code })) : null, _jsx(Badge, { variant: optionStatusVariant[option.status] ?? "outline", children: messages.common.optionStatusLabels[option.status] }), option.isDefault ? (_jsx(Badge, { variant: "secondary", children: messages.productOptionsSection.badges.defaultOption })) : null] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDuplicate, "aria-label": messages.productOptionsSection.actions.duplicate, children: _jsx(Copy, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onEdit, "aria-label": messages.productOptionsSection.actions.edit, children: _jsx(Pencil, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDelete, "aria-label": messages.productOptionsSection.actions.delete, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] })] }), expanded ? (_jsxs("div", { className: "flex flex-col gap-4 border-t bg-muted/30 p-3", children: [_jsx(UnitsPanel, { optionId: option.id, messages: messages }), children] })) : null] }));
152
+ return (_jsxs("div", { className: "rounded-md border", children: [_jsxs("div", { className: "flex items-center gap-3 p-3", children: [_jsx("button", { type: "button", onClick: onToggle, className: "text-muted-foreground transition-colors hover:text-foreground", children: expanded ? _jsx(ChevronDown, { className: "size-4" }) : _jsx(ChevronRight, { className: "size-4" }) }), _jsxs("div", { className: "flex flex-1 flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: option.name }), option.code ? (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: option.code })) : null, _jsx(Badge, { variant: optionStatusVariant[option.status] ?? "outline", children: messages.common.optionStatusLabels[option.status] }), option.isDefault ? (_jsx(Badge, { variant: "secondary", children: messages.productOptionsSection.badges.defaultOption })) : null] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDuplicate, "aria-label": messages.productOptionsSection.actions.duplicate, children: _jsx(Copy, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onEdit, "aria-label": messages.productOptionsSection.actions.edit, children: _jsx(Pencil, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDelete, "aria-label": messages.productOptionsSection.actions.delete, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] })] }), expanded ? (_jsx("div", { className: "flex flex-col gap-4 border-t bg-muted/30 p-3", children: children ?? _jsx(UnitsPanel, { optionId: option.id, messages: messages }) })) : null] }));
142
153
  }
143
154
  function UnitsPanel({ optionId, messages, }) {
144
155
  const [dialogOpen, setDialogOpen] = React.useState(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyantjs/products-ui",
3
- "version": "0.101.2",
3
+ "version": "0.102.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,26 +50,26 @@
50
50
  "react-dom": "^19.0.0",
51
51
  "react-hook-form": "^7.60.0",
52
52
  "zod": "^4.3.6",
53
- "@voyantjs/availability": "0.101.2",
54
- "@voyantjs/availability-react": "0.101.2",
55
- "@voyantjs/catalog-react": "0.101.2",
56
- "@voyantjs/extras-react": "0.101.2",
57
- "@voyantjs/finance": "0.101.2",
58
- "@voyantjs/finance-ui": "0.101.2",
59
- "@voyantjs/markets-react": "0.101.2",
60
- "@voyantjs/pricing-react": "0.101.2",
61
- "@voyantjs/pricing-ui": "0.101.2",
62
- "@voyantjs/products-react": "0.101.2",
63
- "@voyantjs/suppliers-react": "0.101.2",
64
- "@voyantjs/ui": "0.101.2",
65
- "@voyantjs/utils": "0.101.2"
53
+ "@voyantjs/availability": "0.102.0",
54
+ "@voyantjs/availability-react": "0.102.0",
55
+ "@voyantjs/catalog-react": "0.102.0",
56
+ "@voyantjs/extras-react": "0.102.0",
57
+ "@voyantjs/finance": "0.102.0",
58
+ "@voyantjs/finance-ui": "0.102.0",
59
+ "@voyantjs/markets-react": "0.102.0",
60
+ "@voyantjs/pricing-react": "0.102.0",
61
+ "@voyantjs/pricing-ui": "0.102.0",
62
+ "@voyantjs/products-react": "0.102.0",
63
+ "@voyantjs/suppliers-react": "0.102.0",
64
+ "@voyantjs/ui": "0.102.0",
65
+ "@voyantjs/utils": "0.102.0"
66
66
  },
67
67
  "dependencies": {
68
68
  "date-fns": "^4.1.0",
69
69
  "motion": "^12.38.0",
70
70
  "react-day-picker": "^9.8.0",
71
71
  "sonner": "^2.0.7",
72
- "@voyantjs/i18n": "0.101.2"
72
+ "@voyantjs/i18n": "0.102.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@tanstack/react-query": "^5.100.11",
@@ -82,20 +82,20 @@
82
82
  "typescript": "^6.0.2",
83
83
  "vitest": "^4.1.2",
84
84
  "zod": "^4.3.6",
85
- "@voyantjs/availability": "0.101.2",
86
- "@voyantjs/availability-react": "0.101.2",
87
- "@voyantjs/catalog-react": "0.101.2",
88
- "@voyantjs/extras-react": "0.101.2",
89
- "@voyantjs/finance": "0.101.2",
90
- "@voyantjs/finance-ui": "0.101.2",
91
- "@voyantjs/i18n": "0.101.2",
92
- "@voyantjs/markets-react": "0.101.2",
93
- "@voyantjs/pricing-react": "0.101.2",
94
- "@voyantjs/pricing-ui": "0.101.2",
95
- "@voyantjs/products-react": "0.101.2",
96
- "@voyantjs/suppliers-react": "0.101.2",
97
- "@voyantjs/ui": "0.101.2",
98
- "@voyantjs/utils": "0.101.2",
85
+ "@voyantjs/availability": "0.102.0",
86
+ "@voyantjs/availability-react": "0.102.0",
87
+ "@voyantjs/catalog-react": "0.102.0",
88
+ "@voyantjs/extras-react": "0.102.0",
89
+ "@voyantjs/finance": "0.102.0",
90
+ "@voyantjs/finance-ui": "0.102.0",
91
+ "@voyantjs/i18n": "0.102.0",
92
+ "@voyantjs/markets-react": "0.102.0",
93
+ "@voyantjs/pricing-react": "0.102.0",
94
+ "@voyantjs/pricing-ui": "0.102.0",
95
+ "@voyantjs/products-react": "0.102.0",
96
+ "@voyantjs/suppliers-react": "0.102.0",
97
+ "@voyantjs/ui": "0.102.0",
98
+ "@voyantjs/utils": "0.102.0",
99
99
  "@voyantjs/voyant-typescript-config": "0.1.0"
100
100
  },
101
101
  "files": [