@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.
- package/dist/components/product-detail/product-departure-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-departure-form.js +22 -2
- package/dist/components/product-detail/product-detail-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-detail-form.js +6 -3
- package/dist/components/product-detail/product-detail-page.d.ts.map +1 -1
- package/dist/components/product-detail/product-detail-page.js +2 -2
- package/dist/components/product-detail/product-option-pricing-grid.d.ts +16 -0
- package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
- package/dist/components/product-detail/product-option-pricing-grid.js +193 -0
- package/dist/components/product-detail/product-options-pricing.d.ts +29 -1
- package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-pricing.js +34 -12
- package/dist/components/product-detail/product-options-shared.d.ts +14 -0
- package/dist/components/product-detail/product-options-shared.d.ts.map +1 -1
- package/dist/components/product-detail/product-options-shared.js +20 -0
- package/dist/components/product-detail/product-unit-dialog.d.ts +3 -1
- package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-dialog.js +2 -2
- package/dist/components/product-detail/product-unit-form.d.ts +9 -1
- package/dist/components/product-detail/product-unit-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-form.js +37 -7
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +2 -1
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-price-rule-dialog.js +2 -2
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts +2 -1
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -1
- package/dist/components/product-detail/product-unit-price-rule-form.js +28 -9
- package/dist/components/product-options-section.d.ts.map +1 -1
- package/dist/components/product-options-section.js +31 -20
- package/package.json +29 -29
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-departure-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-departure-form.tsx"],"names":[],"mappings":"
|
|
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,
|
|
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: "
|
|
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":"
|
|
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) => (
|
|
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
|
-
|
|
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":"
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
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":"
|
|
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
|
-
|
|
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,
|
|
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":"
|
|
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
|
-
|
|
41
|
-
|
|
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:
|
|
55
|
-
cost:
|
|
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
|
|
107
|
-
costAmountCents: Math.round(values.cost
|
|
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(
|
|
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,
|
|
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 })) :
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 ? (
|
|
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.
|
|
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.
|
|
54
|
-
"@voyantjs/availability-react": "0.
|
|
55
|
-
"@voyantjs/catalog-react": "0.
|
|
56
|
-
"@voyantjs/extras-react": "0.
|
|
57
|
-
"@voyantjs/finance": "0.
|
|
58
|
-
"@voyantjs/finance-ui": "0.
|
|
59
|
-
"@voyantjs/markets-react": "0.
|
|
60
|
-
"@voyantjs/pricing-react": "0.
|
|
61
|
-
"@voyantjs/pricing-ui": "0.
|
|
62
|
-
"@voyantjs/products-react": "0.
|
|
63
|
-
"@voyantjs/suppliers-react": "0.
|
|
64
|
-
"@voyantjs/ui": "0.
|
|
65
|
-
"@voyantjs/utils": "0.
|
|
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.
|
|
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.
|
|
86
|
-
"@voyantjs/availability-react": "0.
|
|
87
|
-
"@voyantjs/catalog-react": "0.
|
|
88
|
-
"@voyantjs/extras-react": "0.
|
|
89
|
-
"@voyantjs/finance": "0.
|
|
90
|
-
"@voyantjs/finance-ui": "0.
|
|
91
|
-
"@voyantjs/i18n": "0.
|
|
92
|
-
"@voyantjs/markets-react": "0.
|
|
93
|
-
"@voyantjs/pricing-react": "0.
|
|
94
|
-
"@voyantjs/pricing-ui": "0.
|
|
95
|
-
"@voyantjs/products-react": "0.
|
|
96
|
-
"@voyantjs/suppliers-react": "0.
|
|
97
|
-
"@voyantjs/ui": "0.
|
|
98
|
-
"@voyantjs/utils": "0.
|
|
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": [
|