@voyantjs/products-ui 0.101.1 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/components/product-detail/date-picker.d.ts +44 -0
  2. package/dist/components/product-detail/date-picker.d.ts.map +1 -0
  3. package/dist/components/product-detail/date-picker.js +125 -0
  4. package/dist/components/product-detail/host.d.ts +53 -0
  5. package/dist/components/product-detail/host.d.ts.map +1 -0
  6. package/dist/components/product-detail/host.js +24 -0
  7. package/dist/components/product-detail/index.d.ts +6 -0
  8. package/dist/components/product-detail/index.d.ts.map +1 -0
  9. package/dist/components/product-detail/index.js +5 -0
  10. package/dist/components/product-detail/product-activity-section.d.ts +4 -0
  11. package/dist/components/product-detail/product-activity-section.d.ts.map +1 -0
  12. package/dist/components/product-detail/product-activity-section.js +37 -0
  13. package/dist/components/product-detail/product-day-sheet.d.ts +14 -0
  14. package/dist/components/product-detail/product-day-sheet.d.ts.map +1 -0
  15. package/dist/components/product-detail/product-day-sheet.js +75 -0
  16. package/dist/components/product-detail/product-day-translation.d.ts +41 -0
  17. package/dist/components/product-detail/product-day-translation.d.ts.map +1 -0
  18. package/dist/components/product-detail/product-day-translation.js +111 -0
  19. package/dist/components/product-detail/product-departure-dialog.d.ts +11 -0
  20. package/dist/components/product-detail/product-departure-dialog.d.ts.map +1 -0
  21. package/dist/components/product-detail/product-departure-dialog.js +10 -0
  22. package/dist/components/product-detail/product-departure-form.d.ts +25 -0
  23. package/dist/components/product-detail/product-departure-form.d.ts.map +1 -0
  24. package/dist/components/product-detail/product-departure-form.js +237 -0
  25. package/dist/components/product-detail/product-departure-pricing-override-dialog.d.ts +8 -0
  26. package/dist/components/product-detail/product-departure-pricing-override-dialog.d.ts.map +1 -0
  27. package/dist/components/product-detail/product-departure-pricing-override-dialog.js +125 -0
  28. package/dist/components/product-detail/product-detail-day-row.d.ts +14 -0
  29. package/dist/components/product-detail/product-detail-day-row.d.ts.map +1 -0
  30. package/dist/components/product-detail/product-detail-day-row.js +43 -0
  31. package/dist/components/product-detail/product-detail-dialog.d.ts +10 -0
  32. package/dist/components/product-detail/product-detail-dialog.d.ts.map +1 -0
  33. package/dist/components/product-detail/product-detail-dialog.js +10 -0
  34. package/dist/components/product-detail/product-detail-form.d.ts +19 -0
  35. package/dist/components/product-detail/product-detail-form.d.ts.map +1 -0
  36. package/dist/components/product-detail/product-detail-form.js +180 -0
  37. package/dist/components/product-detail/product-detail-header.d.ts +12 -0
  38. package/dist/components/product-detail/product-detail-header.d.ts.map +1 -0
  39. package/dist/components/product-detail/product-detail-header.js +19 -0
  40. package/dist/components/product-detail/product-detail-itinerary-section.d.ts +4 -0
  41. package/dist/components/product-detail/product-detail-itinerary-section.d.ts.map +1 -0
  42. package/dist/components/product-detail/product-detail-itinerary-section.js +201 -0
  43. package/dist/components/product-detail/product-detail-page.d.ts +4 -0
  44. package/dist/components/product-detail/product-detail-page.d.ts.map +1 -0
  45. package/dist/components/product-detail/product-detail-page.js +97 -0
  46. package/dist/components/product-detail/product-detail-sections.d.ts +63 -0
  47. package/dist/components/product-detail/product-detail-sections.d.ts.map +1 -0
  48. package/dist/components/product-detail/product-detail-sections.js +143 -0
  49. package/dist/components/product-detail/product-detail-shared.d.ts +264 -0
  50. package/dist/components/product-detail/product-detail-shared.d.ts.map +1 -0
  51. package/dist/components/product-detail/product-detail-shared.js +157 -0
  52. package/dist/components/product-detail/product-detail-skeleton.d.ts +9 -0
  53. package/dist/components/product-detail/product-detail-skeleton.d.ts.map +1 -0
  54. package/dist/components/product-detail/product-detail-skeleton.js +53 -0
  55. package/dist/components/product-detail/product-extras-section.d.ts +4 -0
  56. package/dist/components/product-detail/product-extras-section.d.ts.map +1 -0
  57. package/dist/components/product-detail/product-extras-section.js +141 -0
  58. package/dist/components/product-detail/product-itinerary-form.d.ts +16 -0
  59. package/dist/components/product-detail/product-itinerary-form.d.ts.map +1 -0
  60. package/dist/components/product-detail/product-itinerary-form.js +38 -0
  61. package/dist/components/product-detail/product-market-rules-section.d.ts +6 -0
  62. package/dist/components/product-detail/product-market-rules-section.d.ts.map +1 -0
  63. package/dist/components/product-detail/product-market-rules-section.js +81 -0
  64. package/dist/components/product-detail/product-media-gallery.d.ts +19 -0
  65. package/dist/components/product-detail/product-media-gallery.d.ts.map +1 -0
  66. package/dist/components/product-detail/product-media-gallery.js +114 -0
  67. package/dist/components/product-detail/product-option-price-rule-dialog.d.ts +12 -0
  68. package/dist/components/product-detail/product-option-price-rule-dialog.d.ts.map +1 -0
  69. package/dist/components/product-detail/product-option-price-rule-dialog.js +10 -0
  70. package/dist/components/product-detail/product-option-price-rule-form.d.ts +29 -0
  71. package/dist/components/product-detail/product-option-price-rule-form.d.ts.map +1 -0
  72. package/dist/components/product-detail/product-option-price-rule-form.js +125 -0
  73. package/dist/components/product-detail/product-option-pricing-grid.d.ts +16 -0
  74. package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
  75. package/dist/components/product-detail/product-option-pricing-grid.js +193 -0
  76. package/dist/components/product-detail/product-options-pricing.d.ts +34 -0
  77. package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -0
  78. package/dist/components/product-detail/product-options-pricing.js +385 -0
  79. package/dist/components/product-detail/product-options-shared.d.ts +623 -0
  80. package/dist/components/product-detail/product-options-shared.d.ts.map +1 -0
  81. package/dist/components/product-detail/product-options-shared.js +54 -0
  82. package/dist/components/product-detail/product-payment-policy-section.d.ts +17 -0
  83. package/dist/components/product-detail/product-payment-policy-section.d.ts.map +1 -0
  84. package/dist/components/product-detail/product-payment-policy-section.js +58 -0
  85. package/dist/components/product-detail/product-schedule-dialog.d.ts +11 -0
  86. package/dist/components/product-detail/product-schedule-dialog.d.ts.map +1 -0
  87. package/dist/components/product-detail/product-schedule-dialog.js +10 -0
  88. package/dist/components/product-detail/product-schedule-form.d.ts +17 -0
  89. package/dist/components/product-detail/product-schedule-form.d.ts.map +1 -0
  90. package/dist/components/product-detail/product-schedule-form.js +222 -0
  91. package/dist/components/product-detail/product-service-dialog.d.ts +12 -0
  92. package/dist/components/product-detail/product-service-dialog.d.ts.map +1 -0
  93. package/dist/components/product-detail/product-service-dialog.js +10 -0
  94. package/dist/components/product-detail/product-service-form.d.ts +22 -0
  95. package/dist/components/product-detail/product-service-form.d.ts.map +1 -0
  96. package/dist/components/product-detail/product-service-form.js +154 -0
  97. package/dist/components/product-detail/product-translation-popover.d.ts +91 -0
  98. package/dist/components/product-detail/product-translation-popover.d.ts.map +1 -0
  99. package/dist/components/product-detail/product-translation-popover.js +217 -0
  100. package/dist/components/product-detail/product-unit-dialog.d.ts +14 -0
  101. package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -0
  102. package/dist/components/product-detail/product-unit-dialog.js +10 -0
  103. package/dist/components/product-detail/product-unit-form.d.ts +34 -0
  104. package/dist/components/product-detail/product-unit-form.d.ts.map +1 -0
  105. package/dist/components/product-detail/product-unit-form.js +139 -0
  106. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +17 -0
  107. package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -0
  108. package/dist/components/product-detail/product-unit-price-rule-dialog.js +10 -0
  109. package/dist/components/product-detail/product-unit-price-rule-form.d.ts +29 -0
  110. package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -0
  111. package/dist/components/product-detail/product-unit-price-rule-form.js +145 -0
  112. package/dist/components/product-detail/timezone-options.d.ts +9 -0
  113. package/dist/components/product-detail/timezone-options.d.ts.map +1 -0
  114. package/dist/components/product-detail/timezone-options.js +28 -0
  115. package/dist/components/product-detail/use-product-detail-data.d.ts +41 -0
  116. package/dist/components/product-detail/use-product-detail-data.d.ts.map +1 -0
  117. package/dist/components/product-detail/use-product-detail-data.js +143 -0
  118. package/dist/components/product-detail/use-product-detail-dialogs.d.ts +24 -0
  119. package/dist/components/product-detail/use-product-detail-dialogs.d.ts.map +1 -0
  120. package/dist/components/product-detail/use-product-detail-dialogs.js +40 -0
  121. package/dist/components/product-detail/zod-resolver.d.ts +4 -0
  122. package/dist/components/product-detail/zod-resolver.d.ts.map +1 -0
  123. package/dist/components/product-detail/zod-resolver.js +39 -0
  124. package/dist/components/product-options-section.d.ts.map +1 -1
  125. package/dist/components/product-options-section.js +31 -20
  126. package/package.json +38 -19
@@ -0,0 +1,145 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useOptionUnitPriceRuleMutation } from "@voyantjs/pricing-react";
3
+ import { PricingCategoryCombobox } from "@voyantjs/pricing-ui/components/pricing-category-combobox";
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";
6
+ import { Loader2 } from "lucide-react";
7
+ import { useEffect } from "react";
8
+ import { useForm } from "react-hook-form";
9
+ import { z } from "zod/v4";
10
+ import { useProductDetailMessages, useProductLocale } from "./host.js";
11
+ import { zodResolver } from "./zod-resolver.js";
12
+ function getUnitTypeLabel(type, messages) {
13
+ switch (type) {
14
+ case "person":
15
+ return messages.typePerson;
16
+ case "group":
17
+ return messages.typeGroup;
18
+ case "room":
19
+ return messages.typeRoom;
20
+ case "vehicle":
21
+ return messages.typeVehicle;
22
+ case "service":
23
+ return messages.typeService;
24
+ case "other":
25
+ return messages.typeOther;
26
+ default:
27
+ return type;
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
+ }
45
+ const buildCellFormSchema = (messages) => z.object({
46
+ unitId: z.string().min(1, messages.validationUnitRequired),
47
+ pricingCategoryId: z.string().optional().nullable(),
48
+ pricingMode: z.enum([
49
+ "per_unit",
50
+ "per_person",
51
+ "per_booking",
52
+ "included",
53
+ "free",
54
+ "on_request",
55
+ ]),
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),
60
+ minQuantity: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
61
+ maxQuantity: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
62
+ sortOrder: z.coerce.number().int(),
63
+ active: z.boolean(),
64
+ notes: z.string().optional().nullable(),
65
+ });
66
+ function initialValues(cell, preselectedUnitId, preselectedCategoryId) {
67
+ if (cell) {
68
+ return {
69
+ unitId: cell.unitId,
70
+ pricingCategoryId: cell.pricingCategoryId ?? "",
71
+ pricingMode: cell.pricingMode,
72
+ sell: cell.sellAmountCents ?? 0,
73
+ cost: cell.costAmountCents ?? 0,
74
+ minQuantity: cell.minQuantity ?? "",
75
+ maxQuantity: cell.maxQuantity ?? "",
76
+ sortOrder: cell.sortOrder,
77
+ active: cell.active,
78
+ notes: cell.notes ?? "",
79
+ };
80
+ }
81
+ return {
82
+ unitId: preselectedUnitId ?? "",
83
+ pricingCategoryId: preselectedCategoryId ?? "",
84
+ pricingMode: "per_person",
85
+ sell: 0,
86
+ cost: 0,
87
+ minQuantity: "",
88
+ maxQuantity: "",
89
+ sortOrder: 0,
90
+ active: true,
91
+ notes: "",
92
+ };
93
+ }
94
+ export function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, onCancel, }) {
95
+ const messages = useProductDetailMessages();
96
+ const productMessages = messages.products.core;
97
+ const unitPriceMessages = messages.products.operations.unitPrices;
98
+ const unitMessages = messages.products.operations.units;
99
+ const locale = useProductLocale();
100
+ const isEditing = !!cell;
101
+ const { create, update } = useOptionUnitPriceRuleMutation();
102
+ const cellFormSchema = buildCellFormSchema(unitPriceMessages);
103
+ const pricingModes = [
104
+ { value: "per_unit", label: unitPriceMessages.pricingModePerUnit },
105
+ { value: "per_person", label: unitPriceMessages.pricingModePerPerson },
106
+ { value: "per_booking", label: unitPriceMessages.pricingModePerBooking },
107
+ { value: "included", label: unitPriceMessages.pricingModeIncluded },
108
+ { value: "free", label: unitPriceMessages.pricingModeFree },
109
+ { value: "on_request", label: unitPriceMessages.pricingModeOnRequest },
110
+ ];
111
+ const form = useForm({
112
+ resolver: zodResolver(cellFormSchema),
113
+ defaultValues: initialValues(cell, preselectedUnitId, preselectedCategoryId),
114
+ });
115
+ useEffect(() => {
116
+ form.reset(initialValues(cell, preselectedUnitId, preselectedCategoryId));
117
+ }, [cell, preselectedUnitId, preselectedCategoryId, form]);
118
+ const onSubmit = async (values) => {
119
+ const payload = {
120
+ optionPriceRuleId,
121
+ optionId,
122
+ unitId: values.unitId,
123
+ pricingCategoryId: values.pricingCategoryId || null,
124
+ pricingMode: values.pricingMode,
125
+ sellAmountCents: Math.round(values.sell),
126
+ costAmountCents: Math.round(values.cost),
127
+ minQuantity: typeof values.minQuantity === "number" ? values.minQuantity : null,
128
+ maxQuantity: typeof values.maxQuantity === "number" ? values.maxQuantity : null,
129
+ sortOrder: values.sortOrder,
130
+ active: values.active,
131
+ notes: values.notes || null,
132
+ };
133
+ if (isEditing) {
134
+ await update.mutateAsync({ id: cell.id, input: payload });
135
+ }
136
+ else {
137
+ await create.mutateAsync(payload);
138
+ }
139
+ onSuccess();
140
+ };
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) => ({
142
+ value: u.id,
143
+ label: `${u.name} (${getUnitTypeLabel(u.unitType, unitMessages)})`,
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] })] })] }));
145
+ }
@@ -0,0 +1,9 @@
1
+ export type TimezoneOption = {
2
+ id: string;
3
+ label: string;
4
+ offset: number;
5
+ };
6
+ export declare const TIMEZONE_OPTIONS: TimezoneOption[];
7
+ export declare const TIMEZONE_IDS: string[];
8
+ export declare function getTimezoneLabel(id: string): string;
9
+ //# sourceMappingURL=timezone-options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timezone-options.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/timezone-options.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAsBD,eAAO,MAAM,gBAAgB,kBAAyB,CAAA;AACtD,eAAO,MAAM,YAAY,UAAoC,CAAA;AAI7D,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAGnD"}
@@ -0,0 +1,28 @@
1
+ import { timezones } from "@voyantjs/utils/timezones";
2
+ function buildTimezoneOptions() {
3
+ const seen = new Map();
4
+ for (const tz of timezones) {
5
+ for (const id of tz.utc) {
6
+ if (seen.has(id))
7
+ continue;
8
+ seen.set(id, { id, label: tz.text, offset: tz.offset });
9
+ }
10
+ }
11
+ // Include the browser-resolved zone if not already present
12
+ const browserZone = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : null;
13
+ if (browserZone && !seen.has(browserZone)) {
14
+ seen.set(browserZone, { id: browserZone, label: browserZone, offset: 0 });
15
+ }
16
+ return Array.from(seen.values()).sort((a, b) => {
17
+ if (a.offset !== b.offset)
18
+ return a.offset - b.offset;
19
+ return a.id.localeCompare(b.id);
20
+ });
21
+ }
22
+ export const TIMEZONE_OPTIONS = buildTimezoneOptions();
23
+ export const TIMEZONE_IDS = TIMEZONE_OPTIONS.map((t) => t.id);
24
+ const TIMEZONE_BY_ID = new Map(TIMEZONE_OPTIONS.map((t) => [t.id, t]));
25
+ export function getTimezoneLabel(id) {
26
+ const tz = TIMEZONE_BY_ID.get(id);
27
+ return tz ? `${id} — ${tz.label}` : id;
28
+ }
@@ -0,0 +1,41 @@
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import { useProduct } from "@voyantjs/products-react";
3
+ import { type AvailabilityRule, type ChannelInfo, type ChannelProductMapping, type DepartureSlot, type ProductMediaItem } from "./product-detail-shared.js";
4
+ export interface UseProductDetailDataResult {
5
+ product: ReturnType<typeof useProduct>["data"];
6
+ isPending: boolean;
7
+ slots: DepartureSlot[];
8
+ rules: AvailabilityRule[];
9
+ channels: ChannelInfo[];
10
+ mappings: ChannelProductMapping[];
11
+ media: ProductMediaItem[];
12
+ itineraryNameById: Map<string, string>;
13
+ refetch: {
14
+ slots: () => void;
15
+ rules: () => void;
16
+ mappings: () => void;
17
+ media: () => void;
18
+ };
19
+ mutations: {
20
+ addChannelMapping: ReturnType<typeof useMutation<unknown, Error, string>>;
21
+ removeChannelMapping: ReturnType<typeof useMutation<unknown, Error, string>>;
22
+ duplicateProduct: ReturnType<typeof useMutation<{
23
+ data: {
24
+ id: string;
25
+ };
26
+ }, Error, void>>;
27
+ deleteProduct: ReturnType<typeof useMutation<unknown, Error, void>>;
28
+ deleteSlot: ReturnType<typeof useMutation<unknown, Error, string>>;
29
+ deleteRule: ReturnType<typeof useMutation<unknown, Error, string>>;
30
+ uploadMedia: ReturnType<typeof useMutation<unknown, Error, {
31
+ file: File;
32
+ dayId?: string;
33
+ }>>;
34
+ deleteMedia: ReturnType<typeof useMutation<unknown, Error, string>>;
35
+ setCover: ReturnType<typeof useMutation<unknown, Error, string>>;
36
+ generateBrochure: ReturnType<typeof useMutation<unknown, Error, void>>;
37
+ };
38
+ invalidateProduct: () => void;
39
+ }
40
+ export declare function useProductDetailData(productId: string): UseProductDetailDataResult;
41
+ //# sourceMappingURL=use-product-detail-data.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-product-detail-data.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/use-product-detail-data.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAA4B,MAAM,uBAAuB,CAAA;AAC7E,OAAO,EAAqB,UAAU,EAAyB,MAAM,0BAA0B,CAAA;AAK/F,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC1B,KAAK,aAAa,EAMlB,KAAK,gBAAgB,EACtB,MAAM,4BAA4B,CAAA;AAEnC,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAA;IAC9C,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,KAAK,EAAE,gBAAgB,EAAE,CAAA;IACzB,QAAQ,EAAE,WAAW,EAAE,CAAA;IACvB,QAAQ,EAAE,qBAAqB,EAAE,CAAA;IACjC,KAAK,EAAE,gBAAgB,EAAE,CAAA;IACzB,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACtC,OAAO,EAAE;QACP,KAAK,EAAE,MAAM,IAAI,CAAA;QACjB,KAAK,EAAE,MAAM,IAAI,CAAA;QACjB,QAAQ,EAAE,MAAM,IAAI,CAAA;QACpB,KAAK,EAAE,MAAM,IAAI,CAAA;KAClB,CAAA;IACD,SAAS,EAAE;QACT,iBAAiB,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;QACzE,oBAAoB,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;QAC5E,gBAAgB,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC;YAAE,IAAI,EAAE;gBAAE,EAAE,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAA;QACvF,aAAa,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAA;QACnE,UAAU,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;QAClE,UAAU,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;QAClE,WAAW,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE;YAAE,IAAI,EAAE,IAAI,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC,CAAA;QAC3F,WAAW,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;QACnE,QAAQ,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;QAChE,gBAAgB,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAA;KACvE,CAAA;IACD,iBAAiB,EAAE,MAAM,IAAI,CAAA;CAC9B;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,0BAA0B,CAiKlF"}
@@ -0,0 +1,143 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { productsQueryKeys, useProduct, useProductItineraries } from "@voyantjs/products-react";
3
+ import { useMemo } from "react";
4
+ import { useProductDetailHost, useProductDetailMessages } from "./host.js";
5
+ import { getChannelsQueryOptions, getProductChannelMappingsQueryOptions, getProductMediaQueryOptions, getProductRulesQueryOptions, getProductSlotsQueryOptions, } from "./product-detail-shared.js";
6
+ export function useProductDetailData(productId) {
7
+ const queryClient = useQueryClient();
8
+ const host = useProductDetailHost();
9
+ const api = host.api;
10
+ const messages = useProductDetailMessages();
11
+ const productMessages = messages.products.core;
12
+ const productQuery = useProduct(productId);
13
+ const itinerariesQuery = useProductItineraries(productId);
14
+ const productActionLedgerQueryKey = [...productsQueryKeys.product(productId), "action-ledger"];
15
+ const slotsQuery = useQuery(getProductSlotsQueryOptions(api, productId));
16
+ const rulesQuery = useQuery(getProductRulesQueryOptions(api, productId));
17
+ const channelsQuery = useQuery(getChannelsQueryOptions(api));
18
+ const mappingsQuery = useQuery(getProductChannelMappingsQueryOptions(api, productId));
19
+ const mediaQuery = useQuery(getProductMediaQueryOptions(api, productId));
20
+ const addChannelMapping = useMutation({
21
+ mutationFn: (channelId) => api.post("/v1/distribution/product-mappings", {
22
+ channelId,
23
+ productId,
24
+ active: true,
25
+ }),
26
+ onSuccess: () => {
27
+ void mappingsQuery.refetch();
28
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
29
+ },
30
+ });
31
+ const removeChannelMapping = useMutation({
32
+ mutationFn: (mappingId) => api.delete(`/v1/distribution/product-mappings/${mappingId}`),
33
+ onSuccess: () => {
34
+ void mappingsQuery.refetch();
35
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
36
+ },
37
+ });
38
+ const deleteProduct = useMutation({
39
+ mutationFn: () => api.delete(`/v1/products/${productId}`),
40
+ onSuccess: () => {
41
+ void queryClient.invalidateQueries({ queryKey: ["products"] });
42
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
43
+ },
44
+ });
45
+ const duplicateProduct = useMutation({
46
+ mutationFn: () => api.post(`/v1/admin/products/${productId}/duplicate`),
47
+ onSuccess: () => {
48
+ void queryClient.invalidateQueries({ queryKey: productsQueryKeys.products() });
49
+ },
50
+ });
51
+ const deleteSlot = useMutation({
52
+ mutationFn: (slotId) => api.delete(`/v1/availability/slots/${slotId}`),
53
+ onSuccess: () => {
54
+ void slotsQuery.refetch();
55
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
56
+ },
57
+ });
58
+ const deleteRule = useMutation({
59
+ mutationFn: (ruleId) => api.delete(`/v1/availability/rules/${ruleId}`),
60
+ onSuccess: () => {
61
+ void rulesQuery.refetch();
62
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
63
+ },
64
+ });
65
+ const uploadMedia = useMutation({
66
+ mutationFn: async ({ file, dayId }) => {
67
+ if (!host.uploadMedia)
68
+ throw new Error(productMessages.uploadFailed);
69
+ const result = await host.uploadMedia(file, { productId, dayId });
70
+ const endpoint = dayId
71
+ ? `/v1/products/${productId}/days/${dayId}/media`
72
+ : `/v1/products/${productId}/media`;
73
+ return api.post(endpoint, {
74
+ mediaType: result.mediaType,
75
+ name: result.name,
76
+ url: result.url,
77
+ storageKey: result.storageKey,
78
+ mimeType: result.mimeType,
79
+ fileSize: result.fileSize,
80
+ });
81
+ },
82
+ onSuccess: () => {
83
+ void mediaQuery.refetch();
84
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
85
+ },
86
+ });
87
+ const deleteMedia = useMutation({
88
+ mutationFn: (mediaId) => api.delete(`/v1/products/media/${mediaId}`),
89
+ onSuccess: () => {
90
+ void mediaQuery.refetch();
91
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
92
+ },
93
+ });
94
+ const setCover = useMutation({
95
+ mutationFn: (mediaId) => api.patch(`/v1/products/media/${mediaId}/set-cover`, {}),
96
+ onSuccess: () => {
97
+ void mediaQuery.refetch();
98
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
99
+ },
100
+ });
101
+ const generateBrochure = useMutation({
102
+ mutationFn: () => api.post(`/v1/admin/products/${productId}/brochure/generate`, {}),
103
+ onSuccess: () => {
104
+ void mediaQuery.refetch();
105
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
106
+ },
107
+ });
108
+ const itineraryNameById = useMemo(() => new Map((itinerariesQuery.data?.data ?? []).map((itinerary) => [itinerary.id, itinerary.name])), [itinerariesQuery.data]);
109
+ const invalidateProduct = () => {
110
+ void queryClient.invalidateQueries({ queryKey: productsQueryKeys.product(productId) });
111
+ void queryClient.invalidateQueries({ queryKey: productsQueryKeys.products() });
112
+ void queryClient.invalidateQueries({ queryKey: productActionLedgerQueryKey });
113
+ };
114
+ return {
115
+ product: productQuery.data,
116
+ isPending: productQuery.isPending,
117
+ slots: slotsQuery.data?.data ?? [],
118
+ rules: rulesQuery.data?.data ?? [],
119
+ channels: channelsQuery.data?.data ?? [],
120
+ mappings: mappingsQuery.data?.data ?? [],
121
+ media: mediaQuery.data?.data ?? [],
122
+ itineraryNameById,
123
+ refetch: {
124
+ slots: () => void slotsQuery.refetch(),
125
+ rules: () => void rulesQuery.refetch(),
126
+ mappings: () => void mappingsQuery.refetch(),
127
+ media: () => void mediaQuery.refetch(),
128
+ },
129
+ mutations: {
130
+ addChannelMapping,
131
+ removeChannelMapping,
132
+ duplicateProduct,
133
+ deleteProduct,
134
+ deleteSlot,
135
+ deleteRule,
136
+ uploadMedia,
137
+ deleteMedia,
138
+ setCover,
139
+ generateBrochure,
140
+ },
141
+ invalidateProduct,
142
+ };
143
+ }
@@ -0,0 +1,24 @@
1
+ import type { AvailabilityRule, DepartureSlot } from "./product-detail-shared.js";
2
+ export interface Toggle {
3
+ open: boolean;
4
+ setOpen: (open: boolean) => void;
5
+ openNow: () => void;
6
+ close: () => void;
7
+ }
8
+ export interface EditingToggle<T> {
9
+ open: boolean;
10
+ setOpen: (open: boolean) => void;
11
+ editing: T | undefined;
12
+ openNew: () => void;
13
+ openEdit: (item: T) => void;
14
+ close: () => void;
15
+ }
16
+ export interface UseProductDetailDialogsResult {
17
+ edit: Toggle;
18
+ bookingCreate: Toggle;
19
+ departure: EditingToggle<DepartureSlot>;
20
+ departureOverride: EditingToggle<DepartureSlot>;
21
+ schedule: EditingToggle<AvailabilityRule>;
22
+ }
23
+ export declare function useProductDetailDialogs(): UseProductDetailDialogsResult;
24
+ //# sourceMappingURL=use-product-detail-dialogs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-product-detail-dialogs.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/use-product-detail-dialogs.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAEjF,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IAChC,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IAChC,OAAO,EAAE,CAAC,GAAG,SAAS,CAAA;IACtB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;IAC3B,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,6BAA6B;IAC5C,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,aAAa,CAAC,aAAa,CAAC,CAAA;IACvC,iBAAiB,EAAE,aAAa,CAAC,aAAa,CAAC,CAAA;IAC/C,QAAQ,EAAE,aAAa,CAAC,gBAAgB,CAAC,CAAA;CAC1C;AAkCD,wBAAgB,uBAAuB,IAAI,6BAA6B,CAQvE"}
@@ -0,0 +1,40 @@
1
+ import { useState } from "react";
2
+ function useToggle() {
3
+ const [open, setOpen] = useState(false);
4
+ return {
5
+ open,
6
+ setOpen,
7
+ openNow: () => setOpen(true),
8
+ close: () => setOpen(false),
9
+ };
10
+ }
11
+ function useEditingToggle() {
12
+ const [open, setOpen] = useState(false);
13
+ const [editing, setEditing] = useState();
14
+ return {
15
+ open,
16
+ setOpen,
17
+ editing,
18
+ openNew: () => {
19
+ setEditing(undefined);
20
+ setOpen(true);
21
+ },
22
+ openEdit: (item) => {
23
+ setEditing(item);
24
+ setOpen(true);
25
+ },
26
+ close: () => {
27
+ setOpen(false);
28
+ setEditing(undefined);
29
+ },
30
+ };
31
+ }
32
+ export function useProductDetailDialogs() {
33
+ return {
34
+ edit: useToggle(),
35
+ bookingCreate: useToggle(),
36
+ departure: useEditingToggle(),
37
+ departureOverride: useEditingToggle(),
38
+ schedule: useEditingToggle(),
39
+ };
40
+ }
@@ -0,0 +1,4 @@
1
+ import type { FieldValues, Resolver } from "react-hook-form";
2
+ import type { z } from "zod/v4";
3
+ export declare function zodResolver<TSchema extends z.ZodType<FieldValues, FieldValues>>(schema: TSchema): Resolver<z.input<TSchema>, unknown, z.output<TSchema>>;
4
+ //# sourceMappingURL=zod-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zod-resolver.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/zod-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,WAAW,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACzE,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAA;AA+B/B,wBAAgB,WAAW,CAAC,OAAO,SAAS,CAAC,CAAC,OAAO,CAAC,WAAW,EAAE,WAAW,CAAC,EAC7E,MAAM,EAAE,OAAO,GACd,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CA6BxD"}
@@ -0,0 +1,39 @@
1
+ function setFieldError(target, path, error) {
2
+ let current = target;
3
+ for (let index = 0; index < path.length; index += 1) {
4
+ const key = String(path[index] ?? "root");
5
+ if (index === path.length - 1) {
6
+ current[key] = error;
7
+ return;
8
+ }
9
+ const next = current[key];
10
+ if (typeof next !== "object" || next === null) {
11
+ current[key] = {};
12
+ }
13
+ current = current[key];
14
+ }
15
+ }
16
+ export function zodResolver(schema) {
17
+ return async (values) => {
18
+ const result = await schema.safeParseAsync(values);
19
+ if (result.success) {
20
+ return {
21
+ values: result.data,
22
+ errors: {},
23
+ };
24
+ }
25
+ const errors = {};
26
+ for (const issue of result.error.issues) {
27
+ const path = issue.path.filter((segment) => typeof segment !== "symbol");
28
+ const normalizedPath = path.length > 0 ? path : ["root"];
29
+ setFieldError(errors, normalizedPath, {
30
+ type: issue.code,
31
+ message: issue.message,
32
+ });
33
+ }
34
+ return {
35
+ values: {},
36
+ errors: errors,
37
+ };
38
+ };
39
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"product-options-section.d.ts","sourceRoot":"","sources":["../../src/components/product-options-section.tsx"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EAOzB,MAAM,0BAA0B,CAAA;AA6BjC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqC9B,wBAAgB,mCAAmC,CACjD,MAAM,EAAE,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,MAAM,CAAC,GACjD,OAAO,CAIT;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,aAAa,CAAC,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,QAAQ,CAAC,CAAC,EACpF,eAAe,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAC,EAAE,CAAC,GAClF,MAAM,EAAE,CASV;AA0CD,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,KAAK,CAAC,SAAS,CAAA;CACvE;AAED,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,QAAc,EACd,KAAK,EACL,WAAW,EACX,mBAAmB,GACpB,EAAE,0BAA0B,2CAsJ5B"}
1
+ {"version":3,"file":"product-options-section.d.ts","sourceRoot":"","sources":["../../src/components/product-options-section.tsx"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EAOzB,MAAM,0BAA0B,CAAA;AA6BjC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqC9B,wBAAgB,mCAAmC,CACjD,MAAM,EAAE,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,MAAM,CAAC,GACjD,OAAO,CAIT;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,aAAa,CAAC,IAAI,CAAC,mBAAmB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,QAAQ,CAAC,CAAC,EACpF,eAAe,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAC,EAAE,CAAC,GAClF,MAAM,EAAE,CASV;AA0CD,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,KAAK,CAAC,SAAS,CAAA;CACvE;AAED,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,QAAc,EACd,KAAK,EACL,WAAW,EACX,mBAAmB,GACpB,EAAE,0BAA0B,2CA+J5B"}
@@ -111,34 +111,45 @@ export function ProductOptionsSection({ productId, pageSize = 100, title, descri
111
111
  const nextSortOrder = options.length > 0 ? Math.max(...options.map((option) => option.sortOrder)) + 1 : 0;
112
112
  const resolvedTitle = title ?? messages.productOptionsSection.titles.default;
113
113
  const resolvedDescription = description ?? messages.productOptionsSection.descriptions.default;
114
+ // A product with a single option needs no option chrome — show its pricing
115
+ // table directly. Only flatten when a host injects the details (the grid);
116
+ // bare mounts (apps/dev) keep the expandable units table.
117
+ const flattenedOption = renderOptionDetails && options.length === 1 ? options[0] : undefined;
118
+ const editOption = (option) => {
119
+ setEditingOption(option);
120
+ setDialogOpen(true);
121
+ };
122
+ const duplicateOptionFlow = (option) => {
123
+ duplicateOption.mutate({ sourceOptionId: option.id, productId }, {
124
+ onSuccess: async ({ option: duplicatedOption, unitIdMap }) => {
125
+ await duplicatePricing.mutateAsync({
126
+ sourceOptionId: option.id,
127
+ targetOptionId: duplicatedOption.id,
128
+ productId,
129
+ unitIdMap,
130
+ });
131
+ },
132
+ });
133
+ };
134
+ const deleteOption = (option) => {
135
+ if (confirm(messages.productOptionsSection.deleteConfirm.option.replace("{name}", option.name))) {
136
+ remove.mutate(option.id);
137
+ }
138
+ };
114
139
  return (_jsxs(Card, { "data-slot": "product-options-section", children: [_jsxs(CardHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(CardTitle, { children: resolvedTitle }), _jsx(CardDescription, { children: resolvedDescription })] }), _jsxs(Button, { onClick: () => {
115
140
  setEditingOption(undefined);
116
141
  setDialogOpen(true);
117
- }, children: [_jsx(Plus, { className: "mr-2 size-4", "aria-hidden": "true" }), messages.productOptionsSection.actions.addOption] })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [showRoomArrangementWarning ? (_jsxs(Alert, { className: "border-amber-500/40 bg-amber-500/10", children: [_jsx(TriangleAlert, { className: "size-4 text-amber-600", "aria-hidden": "true" }), _jsx(AlertTitle, { children: messages.productOptionsSection.configurationWarnings.roomOptionsTitle }), _jsx(AlertDescription, { children: formatMessage(messages.productOptionsSection.configurationWarnings.roomOptionsDescription, { options: roomArrangementOptionNames.join(", ") }) })] })) : null, isPending ? (_jsx("div", { className: "flex min-h-24 items-center justify-center", children: _jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" }) })) : isError ? (_jsx("p", { className: "text-sm text-destructive", children: messages.productOptionsSection.loadingError.options })) : options.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.productOptionsSection.empty.options })) : (options.map((option) => (_jsx(OptionRow, { option: option, expanded: expandedOptionId === option.id, onToggle: () => setExpandedOptionId((current) => (current === option.id ? null : option.id)), onEdit: () => {
118
- setEditingOption(option);
119
- setDialogOpen(true);
120
- }, onDuplicate: () => {
121
- duplicateOption.mutate({ sourceOptionId: option.id, productId }, {
122
- onSuccess: async ({ option: duplicatedOption, unitIdMap }) => {
123
- await duplicatePricing.mutateAsync({
124
- sourceOptionId: option.id,
125
- targetOptionId: duplicatedOption.id,
126
- productId,
127
- unitIdMap,
128
- });
129
- },
130
- });
131
- }, onDelete: () => {
132
- if (confirm(messages.productOptionsSection.deleteConfirm.option.replace("{name}", option.name))) {
133
- remove.mutate(option.id);
134
- }
135
- }, messages: messages, children: renderOptionDetails?.(option) }, option.id)))), _jsx(ProductOptionDialog, { open: dialogOpen, onOpenChange: setDialogOpen, productId: productId, option: editingOption, sortOrder: nextSortOrder, onSuccess: () => {
142
+ }, children: [_jsx(Plus, { className: "mr-2 size-4", "aria-hidden": "true" }), messages.productOptionsSection.actions.addOption] })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [showRoomArrangementWarning ? (_jsxs(Alert, { className: "border-amber-500/40 bg-amber-500/10", children: [_jsx(TriangleAlert, { className: "size-4 text-amber-600", "aria-hidden": "true" }), _jsx(AlertTitle, { children: messages.productOptionsSection.configurationWarnings.roomOptionsTitle }), _jsx(AlertDescription, { children: formatMessage(messages.productOptionsSection.configurationWarnings.roomOptionsDescription, { options: roomArrangementOptionNames.join(", ") }) })] })) : null, isPending ? (_jsx("div", { className: "flex min-h-24 items-center justify-center", children: _jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" }) })) : isError ? (_jsx("p", { className: "text-sm text-destructive", children: messages.productOptionsSection.loadingError.options })) : options.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: messages.productOptionsSection.empty.options })) : flattenedOption ? (
143
+ // A single option needs no chrome at all — show its pricing table
144
+ // directly. Per-option actions (duplicate/edit/delete) only appear
145
+ // once there are 2+ options to disambiguate.
146
+ renderOptionDetails?.(flattenedOption)) : (options.map((option) => (_jsx(OptionRow, { option: option, expanded: expandedOptionId === option.id, onToggle: () => setExpandedOptionId((current) => (current === option.id ? null : option.id)), onEdit: () => editOption(option), onDuplicate: () => duplicateOptionFlow(option), onDelete: () => deleteOption(option), messages: messages, children: renderOptionDetails?.(option) }, option.id)))), _jsx(ProductOptionDialog, { open: dialogOpen, onOpenChange: setDialogOpen, productId: productId, option: editingOption, sortOrder: nextSortOrder, onSuccess: () => {
136
147
  setDialogOpen(false);
137
148
  setEditingOption(undefined);
138
149
  } })] })] }));
139
150
  }
140
151
  function OptionRow({ option, expanded, onToggle, onEdit, onDuplicate, onDelete, messages, children, }) {
141
- return (_jsxs("div", { className: "rounded-md border", children: [_jsxs("div", { className: "flex items-center gap-3 p-3", children: [_jsx("button", { type: "button", onClick: onToggle, className: "text-muted-foreground transition-colors hover:text-foreground", children: expanded ? _jsx(ChevronDown, { className: "size-4" }) : _jsx(ChevronRight, { className: "size-4" }) }), _jsxs("div", { className: "flex flex-1 flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: option.name }), option.code ? (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: option.code })) : null, _jsx(Badge, { variant: optionStatusVariant[option.status] ?? "outline", children: messages.common.optionStatusLabels[option.status] }), option.isDefault ? (_jsx(Badge, { variant: "secondary", children: messages.productOptionsSection.badges.defaultOption })) : null] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDuplicate, "aria-label": messages.productOptionsSection.actions.duplicate, children: _jsx(Copy, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onEdit, "aria-label": messages.productOptionsSection.actions.edit, children: _jsx(Pencil, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDelete, "aria-label": messages.productOptionsSection.actions.delete, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] })] }), expanded ? (_jsxs("div", { className: "flex flex-col gap-4 border-t bg-muted/30 p-3", children: [_jsx(UnitsPanel, { optionId: option.id, messages: messages }), children] })) : null] }));
152
+ return (_jsxs("div", { className: "rounded-md border", children: [_jsxs("div", { className: "flex items-center gap-3 p-3", children: [_jsx("button", { type: "button", onClick: onToggle, className: "text-muted-foreground transition-colors hover:text-foreground", children: expanded ? _jsx(ChevronDown, { className: "size-4" }) : _jsx(ChevronRight, { className: "size-4" }) }), _jsxs("div", { className: "flex flex-1 flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: option.name }), option.code ? (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: option.code })) : null, _jsx(Badge, { variant: optionStatusVariant[option.status] ?? "outline", children: messages.common.optionStatusLabels[option.status] }), option.isDefault ? (_jsx(Badge, { variant: "secondary", children: messages.productOptionsSection.badges.defaultOption })) : null] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDuplicate, "aria-label": messages.productOptionsSection.actions.duplicate, children: _jsx(Copy, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onEdit, "aria-label": messages.productOptionsSection.actions.edit, children: _jsx(Pencil, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDelete, "aria-label": messages.productOptionsSection.actions.delete, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] })] }), expanded ? (_jsx("div", { className: "flex flex-col gap-4 border-t bg-muted/30 p-3", children: children ?? _jsx(UnitsPanel, { optionId: option.id, messages: messages }) })) : null] }));
142
153
  }
143
154
  function UnitsPanel({ optionId, messages, }) {
144
155
  const [dialogOpen, setDialogOpen] = React.useState(false);