@voyantjs/products-ui 0.101.1 → 0.101.2
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/date-picker.d.ts +44 -0
- package/dist/components/product-detail/date-picker.d.ts.map +1 -0
- package/dist/components/product-detail/date-picker.js +125 -0
- package/dist/components/product-detail/host.d.ts +53 -0
- package/dist/components/product-detail/host.d.ts.map +1 -0
- package/dist/components/product-detail/host.js +24 -0
- package/dist/components/product-detail/index.d.ts +6 -0
- package/dist/components/product-detail/index.d.ts.map +1 -0
- package/dist/components/product-detail/index.js +5 -0
- package/dist/components/product-detail/product-activity-section.d.ts +4 -0
- package/dist/components/product-detail/product-activity-section.d.ts.map +1 -0
- package/dist/components/product-detail/product-activity-section.js +37 -0
- package/dist/components/product-detail/product-day-sheet.d.ts +14 -0
- package/dist/components/product-detail/product-day-sheet.d.ts.map +1 -0
- package/dist/components/product-detail/product-day-sheet.js +75 -0
- package/dist/components/product-detail/product-day-translation.d.ts +41 -0
- package/dist/components/product-detail/product-day-translation.d.ts.map +1 -0
- package/dist/components/product-detail/product-day-translation.js +111 -0
- package/dist/components/product-detail/product-departure-dialog.d.ts +11 -0
- package/dist/components/product-detail/product-departure-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-departure-dialog.js +10 -0
- package/dist/components/product-detail/product-departure-form.d.ts +25 -0
- package/dist/components/product-detail/product-departure-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-departure-form.js +217 -0
- package/dist/components/product-detail/product-departure-pricing-override-dialog.d.ts +8 -0
- package/dist/components/product-detail/product-departure-pricing-override-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-departure-pricing-override-dialog.js +125 -0
- package/dist/components/product-detail/product-detail-day-row.d.ts +14 -0
- package/dist/components/product-detail/product-detail-day-row.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-day-row.js +43 -0
- package/dist/components/product-detail/product-detail-dialog.d.ts +10 -0
- package/dist/components/product-detail/product-detail-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-dialog.js +10 -0
- package/dist/components/product-detail/product-detail-form.d.ts +19 -0
- package/dist/components/product-detail/product-detail-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-form.js +177 -0
- package/dist/components/product-detail/product-detail-header.d.ts +12 -0
- package/dist/components/product-detail/product-detail-header.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-header.js +19 -0
- package/dist/components/product-detail/product-detail-itinerary-section.d.ts +4 -0
- package/dist/components/product-detail/product-detail-itinerary-section.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-itinerary-section.js +201 -0
- package/dist/components/product-detail/product-detail-page.d.ts +4 -0
- package/dist/components/product-detail/product-detail-page.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-page.js +97 -0
- package/dist/components/product-detail/product-detail-sections.d.ts +63 -0
- package/dist/components/product-detail/product-detail-sections.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-sections.js +143 -0
- package/dist/components/product-detail/product-detail-shared.d.ts +264 -0
- package/dist/components/product-detail/product-detail-shared.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-shared.js +157 -0
- package/dist/components/product-detail/product-detail-skeleton.d.ts +9 -0
- package/dist/components/product-detail/product-detail-skeleton.d.ts.map +1 -0
- package/dist/components/product-detail/product-detail-skeleton.js +53 -0
- package/dist/components/product-detail/product-extras-section.d.ts +4 -0
- package/dist/components/product-detail/product-extras-section.d.ts.map +1 -0
- package/dist/components/product-detail/product-extras-section.js +141 -0
- package/dist/components/product-detail/product-itinerary-form.d.ts +16 -0
- package/dist/components/product-detail/product-itinerary-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-itinerary-form.js +38 -0
- package/dist/components/product-detail/product-market-rules-section.d.ts +6 -0
- package/dist/components/product-detail/product-market-rules-section.d.ts.map +1 -0
- package/dist/components/product-detail/product-market-rules-section.js +81 -0
- package/dist/components/product-detail/product-media-gallery.d.ts +19 -0
- package/dist/components/product-detail/product-media-gallery.d.ts.map +1 -0
- package/dist/components/product-detail/product-media-gallery.js +114 -0
- package/dist/components/product-detail/product-option-price-rule-dialog.d.ts +12 -0
- package/dist/components/product-detail/product-option-price-rule-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-option-price-rule-dialog.js +10 -0
- package/dist/components/product-detail/product-option-price-rule-form.d.ts +29 -0
- package/dist/components/product-detail/product-option-price-rule-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-option-price-rule-form.js +125 -0
- package/dist/components/product-detail/product-options-pricing.d.ts +6 -0
- package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -0
- package/dist/components/product-detail/product-options-pricing.js +363 -0
- package/dist/components/product-detail/product-options-shared.d.ts +609 -0
- package/dist/components/product-detail/product-options-shared.d.ts.map +1 -0
- package/dist/components/product-detail/product-options-shared.js +34 -0
- package/dist/components/product-detail/product-payment-policy-section.d.ts +17 -0
- package/dist/components/product-detail/product-payment-policy-section.d.ts.map +1 -0
- package/dist/components/product-detail/product-payment-policy-section.js +58 -0
- package/dist/components/product-detail/product-schedule-dialog.d.ts +11 -0
- package/dist/components/product-detail/product-schedule-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-schedule-dialog.js +10 -0
- package/dist/components/product-detail/product-schedule-form.d.ts +17 -0
- package/dist/components/product-detail/product-schedule-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-schedule-form.js +222 -0
- package/dist/components/product-detail/product-service-dialog.d.ts +12 -0
- package/dist/components/product-detail/product-service-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-service-dialog.js +10 -0
- package/dist/components/product-detail/product-service-form.d.ts +22 -0
- package/dist/components/product-detail/product-service-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-service-form.js +154 -0
- package/dist/components/product-detail/product-translation-popover.d.ts +91 -0
- package/dist/components/product-detail/product-translation-popover.d.ts.map +1 -0
- package/dist/components/product-detail/product-translation-popover.js +217 -0
- package/dist/components/product-detail/product-unit-dialog.d.ts +12 -0
- package/dist/components/product-detail/product-unit-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-unit-dialog.js +10 -0
- package/dist/components/product-detail/product-unit-form.d.ts +26 -0
- package/dist/components/product-detail/product-unit-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-unit-form.js +109 -0
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +16 -0
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts.map +1 -0
- package/dist/components/product-detail/product-unit-price-rule-dialog.js +10 -0
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts +28 -0
- package/dist/components/product-detail/product-unit-price-rule-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-unit-price-rule-form.js +126 -0
- package/dist/components/product-detail/timezone-options.d.ts +9 -0
- package/dist/components/product-detail/timezone-options.d.ts.map +1 -0
- package/dist/components/product-detail/timezone-options.js +28 -0
- package/dist/components/product-detail/use-product-detail-data.d.ts +41 -0
- package/dist/components/product-detail/use-product-detail-data.d.ts.map +1 -0
- package/dist/components/product-detail/use-product-detail-data.js +143 -0
- package/dist/components/product-detail/use-product-detail-dialogs.d.ts +24 -0
- package/dist/components/product-detail/use-product-detail-dialogs.d.ts.map +1 -0
- package/dist/components/product-detail/use-product-detail-dialogs.js +40 -0
- package/dist/components/product-detail/zod-resolver.d.ts +4 -0
- package/dist/components/product-detail/zod-resolver.d.ts.map +1 -0
- package/dist/components/product-detail/zod-resolver.js +39 -0
- package/package.json +38 -19
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { PaymentPolicyForm, PaymentPolicyPreview } from "@voyantjs/finance-ui";
|
|
4
|
+
import { useProductMutation, } from "@voyantjs/products-react";
|
|
5
|
+
import { Badge, Button, Label, Switch } from "@voyantjs/ui/components";
|
|
6
|
+
import { Separator } from "@voyantjs/ui/components/separator";
|
|
7
|
+
import { Loader2 } from "lucide-react";
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { toast } from "sonner";
|
|
10
|
+
import { useProductDetailMessages } from "./host.js";
|
|
11
|
+
import { Section } from "./product-detail-sections.js";
|
|
12
|
+
const DEFAULT_POLICY = {
|
|
13
|
+
deposit: { kind: "percent", percent: 50 },
|
|
14
|
+
minDaysBeforeDepartureForDeposit: 30,
|
|
15
|
+
balanceDueDaysBeforeDeparture: 30,
|
|
16
|
+
balanceDueMinDaysFromNow: 7,
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Per-listing customer payment policy override for a product.
|
|
20
|
+
*
|
|
21
|
+
* Wins over the product's category and supplier policies in the
|
|
22
|
+
* cascade — use this when a single product has stricter / looser
|
|
23
|
+
* terms than the rest of its catalog group (a luxury-tier offering,
|
|
24
|
+
* a flash sale, etc.).
|
|
25
|
+
*
|
|
26
|
+
* Inherit by default; flipping the toggle off saves an explicit
|
|
27
|
+
* policy on the product row.
|
|
28
|
+
*/
|
|
29
|
+
export function ProductPaymentPolicySection({ product, onSuccess, }) {
|
|
30
|
+
const t = useProductDetailMessages().products.operations.paymentPolicy;
|
|
31
|
+
const persisted = product.customerPaymentPolicy ?? null;
|
|
32
|
+
const [draft, setDraft] = useState(persisted);
|
|
33
|
+
const { update } = useProductMutation();
|
|
34
|
+
// One-way sync: when the persisted policy reference changes (after
|
|
35
|
+
// a save → query invalidation, or external edit), refresh the
|
|
36
|
+
// draft. Mid-flight typing is preserved because we don't depend on
|
|
37
|
+
// setState callbacks running on every render.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setDraft(persisted);
|
|
40
|
+
}, [persisted]);
|
|
41
|
+
const isInheriting = draft === null;
|
|
42
|
+
const isDirty = JSON.stringify(draft) !== JSON.stringify(persisted);
|
|
43
|
+
const save = () => {
|
|
44
|
+
update.mutate({
|
|
45
|
+
id: product.id,
|
|
46
|
+
input: { customerPaymentPolicy: draft ?? null },
|
|
47
|
+
}, {
|
|
48
|
+
onSuccess: () => {
|
|
49
|
+
toast.success(t.savedToast);
|
|
50
|
+
onSuccess?.();
|
|
51
|
+
},
|
|
52
|
+
onError: (err) => toast.error(err instanceof Error ? err.message : t.saveFailed),
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
return (_jsx(Section, { title: t.title, actions: _jsxs(Button, { size: "sm", disabled: !isDirty || update.isPending, onClick: save, children: [update.isPending ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : null, t.save] }), children: _jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Label, { htmlFor: "payment-policy-inherit", className: "text-sm font-medium", children: t.inheritLabel }), _jsx(Badge, { variant: isInheriting ? "secondary" : "outline", className: "text-[10px]", children: isInheriting ? t.inheritingBadge : t.customBadge })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: t.inheritHint })] }), _jsx(Switch, { id: "payment-policy-inherit", checked: isInheriting, onCheckedChange: (checked) => {
|
|
56
|
+
setDraft(checked ? null : (draft ?? DEFAULT_POLICY));
|
|
57
|
+
}, disabled: update.isPending })] }), isInheriting ? null : (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsx(PaymentPolicyForm, { value: draft, onChange: setDraft, inheritable: false, currency: product.sellCurrency, disabled: update.isPending }), _jsx(Separator, {}), _jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: t.previewHeading }), _jsx(PaymentPolicyPreview, { policy: draft, currency: product.sellCurrency })] })] }))] }) }));
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type AvailabilityRule } from "./product-schedule-form.js";
|
|
2
|
+
export type { AvailabilityRule };
|
|
3
|
+
type ScheduleDialogProps = {
|
|
4
|
+
open: boolean;
|
|
5
|
+
onOpenChange: (open: boolean) => void;
|
|
6
|
+
productId: string;
|
|
7
|
+
rule?: AvailabilityRule;
|
|
8
|
+
onSuccess: () => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function ScheduleDialog({ open, onOpenChange, productId, rule, onSuccess, }: ScheduleDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
//# sourceMappingURL=product-schedule-dialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-schedule-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-schedule-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,gBAAgB,EAAgB,MAAM,4BAA4B,CAAA;AAEhF,YAAY,EAAE,gBAAgB,EAAE,CAAA;AAEhC,KAAK,mBAAmB,GAAG;IACzB,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,gBAAgB,CAAA;IACvB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,cAAc,CAAC,EAC7B,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,IAAI,EACJ,SAAS,GACV,EAAE,mBAAmB,2CAwBrB"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from "@voyantjs/ui/components";
|
|
3
|
+
import { useProductDetailMessages } from "./host.js";
|
|
4
|
+
import { ScheduleForm } from "./product-schedule-form.js";
|
|
5
|
+
export function ScheduleDialog({ open, onOpenChange, productId, rule, onSuccess, }) {
|
|
6
|
+
const messages = useProductDetailMessages();
|
|
7
|
+
const scheduleMessages = messages.products.operations.schedules;
|
|
8
|
+
const isEditing = !!rule;
|
|
9
|
+
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? scheduleMessages.editTitle : scheduleMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(ScheduleForm, { productId: productId, rule: rule, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type AvailabilityRule = {
|
|
2
|
+
id: string;
|
|
3
|
+
productId: string;
|
|
4
|
+
timezone: string;
|
|
5
|
+
recurrenceRule: string;
|
|
6
|
+
maxCapacity: number;
|
|
7
|
+
cutoffMinutes: number | null;
|
|
8
|
+
active: boolean;
|
|
9
|
+
};
|
|
10
|
+
export interface ScheduleFormProps {
|
|
11
|
+
productId: string;
|
|
12
|
+
rule?: AvailabilityRule;
|
|
13
|
+
onSuccess: () => void;
|
|
14
|
+
onCancel?: () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function ScheduleForm({ productId, rule, onSuccess, onCancel }: ScheduleFormProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
//# sourceMappingURL=product-schedule-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-schedule-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-schedule-form.tsx"],"names":[],"mappings":"AAuEA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,cAAc,EAAE,MAAM,CAAA;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,MAAM,EAAE,OAAO,CAAA;CAChB,CAAA;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,gBAAgB,CAAA;IACvB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAgID,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,iBAAiB,2CAgSvF"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatMessage } from "@voyantjs/i18n";
|
|
3
|
+
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, } from "@voyantjs/ui/components";
|
|
4
|
+
import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, } from "@voyantjs/ui/components/combobox";
|
|
5
|
+
import { ToggleGroup, ToggleGroupItem } from "@voyantjs/ui/components/toggle-group";
|
|
6
|
+
import { Loader2 } from "lucide-react";
|
|
7
|
+
import { useEffect, useMemo } from "react";
|
|
8
|
+
import { useForm } from "react-hook-form";
|
|
9
|
+
import { z } from "zod/v4";
|
|
10
|
+
import { useProductDetailApi, useProductDetailMessages } from "./host.js";
|
|
11
|
+
import { getTimezoneLabel, TIMEZONE_IDS, TIMEZONE_OPTIONS } from "./timezone-options.js";
|
|
12
|
+
import { zodResolver } from "./zod-resolver.js";
|
|
13
|
+
const WEEKDAY_VALUES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
|
|
14
|
+
const FREQUENCY_OPTIONS = ["DAILY", "WEEKLY", "MONTHLY"];
|
|
15
|
+
const buildScheduleFormSchema = (messages) => z
|
|
16
|
+
.object({
|
|
17
|
+
timezone: z.string().min(1, messages.validationTimezoneRequired),
|
|
18
|
+
frequency: z.enum(FREQUENCY_OPTIONS),
|
|
19
|
+
interval: z.coerce.number().int().min(1).max(365),
|
|
20
|
+
byWeekdays: z.array(z.enum(WEEKDAY_VALUES)),
|
|
21
|
+
byMonthDays: z.array(z.coerce.number().int().min(1).max(31)),
|
|
22
|
+
maxCapacity: z.coerce.number().int().min(0),
|
|
23
|
+
cutoffMinutes: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
24
|
+
active: z.boolean(),
|
|
25
|
+
})
|
|
26
|
+
.refine((v) => {
|
|
27
|
+
if (v.frequency !== "WEEKLY")
|
|
28
|
+
return true;
|
|
29
|
+
return v.byWeekdays.length > 0;
|
|
30
|
+
}, { message: messages.validationWeekdayRequired, path: ["byWeekdays"] })
|
|
31
|
+
.refine((v) => {
|
|
32
|
+
if (v.frequency !== "MONTHLY")
|
|
33
|
+
return true;
|
|
34
|
+
return v.byMonthDays.length > 0;
|
|
35
|
+
}, { message: messages.validationMonthdayRequired, path: ["byMonthDays"] });
|
|
36
|
+
function parseRRule(rrule) {
|
|
37
|
+
const parts = rrule
|
|
38
|
+
.split(";")
|
|
39
|
+
.map((p) => p.trim())
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
const map = new Map();
|
|
42
|
+
for (const part of parts) {
|
|
43
|
+
const [key, value] = part.split("=");
|
|
44
|
+
if (key && value !== undefined)
|
|
45
|
+
map.set(key.toUpperCase(), value);
|
|
46
|
+
}
|
|
47
|
+
const rawFreq = (map.get("FREQ") ?? "DAILY").toUpperCase();
|
|
48
|
+
const frequency = rawFreq === "WEEKLY" || rawFreq === "MONTHLY" ? rawFreq : "DAILY";
|
|
49
|
+
const interval = Number.parseInt(map.get("INTERVAL") ?? "1", 10) || 1;
|
|
50
|
+
const byday = map.get("BYDAY") ?? "";
|
|
51
|
+
const byWeekdays = byday
|
|
52
|
+
.split(",")
|
|
53
|
+
.map((d) => d.trim().toUpperCase())
|
|
54
|
+
.filter((d) => WEEKDAY_VALUES.includes(d));
|
|
55
|
+
const bymonthday = map.get("BYMONTHDAY") ?? "";
|
|
56
|
+
const byMonthDays = bymonthday
|
|
57
|
+
.split(",")
|
|
58
|
+
.map((d) => Number.parseInt(d.trim(), 10))
|
|
59
|
+
.filter((n) => Number.isFinite(n) && n >= 1 && n <= 31);
|
|
60
|
+
return { frequency, interval, byWeekdays, byMonthDays };
|
|
61
|
+
}
|
|
62
|
+
function buildRRule(values) {
|
|
63
|
+
const parts = [`FREQ=${values.frequency}`];
|
|
64
|
+
if (values.interval > 1)
|
|
65
|
+
parts.push(`INTERVAL=${values.interval}`);
|
|
66
|
+
if (values.frequency === "WEEKLY" && values.byWeekdays.length > 0) {
|
|
67
|
+
const ordered = WEEKDAY_VALUES.filter((d) => values.byWeekdays.includes(d));
|
|
68
|
+
parts.push(`BYDAY=${ordered.join(",")}`);
|
|
69
|
+
}
|
|
70
|
+
if (values.frequency === "MONTHLY" && values.byMonthDays.length > 0) {
|
|
71
|
+
const ordered = [...values.byMonthDays].sort((a, b) => a - b);
|
|
72
|
+
parts.push(`BYMONTHDAY=${ordered.join(",")}`);
|
|
73
|
+
}
|
|
74
|
+
return parts.join(";");
|
|
75
|
+
}
|
|
76
|
+
function describeRRule(values, messages, weekdayOptions) {
|
|
77
|
+
const { frequency, interval, byWeekdays, byMonthDays } = values;
|
|
78
|
+
const cadence = frequency === "DAILY"
|
|
79
|
+
? interval > 1
|
|
80
|
+
? formatMessage(messages.previewEveryDays, { interval })
|
|
81
|
+
: messages.previewEveryDay
|
|
82
|
+
: frequency === "WEEKLY"
|
|
83
|
+
? interval > 1
|
|
84
|
+
? formatMessage(messages.previewEveryWeeks, { interval })
|
|
85
|
+
: messages.previewEveryWeek
|
|
86
|
+
: interval > 1
|
|
87
|
+
? formatMessage(messages.previewEveryMonths, { interval })
|
|
88
|
+
: messages.previewEveryMonth;
|
|
89
|
+
if (frequency === "WEEKLY") {
|
|
90
|
+
if (byWeekdays.length === 0)
|
|
91
|
+
return `${cadence}${messages.previewPickWeekday}`;
|
|
92
|
+
const labels = weekdayOptions.filter((d) => byWeekdays.includes(d.value)).map((d) => d.label);
|
|
93
|
+
return formatMessage(messages.previewWeekly, { cadence, days: labels.join(", ") });
|
|
94
|
+
}
|
|
95
|
+
if (frequency === "MONTHLY") {
|
|
96
|
+
if (byMonthDays.length === 0)
|
|
97
|
+
return `${cadence}${messages.previewPickMonthday}`;
|
|
98
|
+
const ordered = [...byMonthDays].sort((a, b) => a - b);
|
|
99
|
+
return formatMessage(messages.previewMonthly, {
|
|
100
|
+
cadence,
|
|
101
|
+
suffix: ordered.length === 1 ? "" : messages.previewSuffixDays,
|
|
102
|
+
days: ordered.join(", "),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return cadence;
|
|
106
|
+
}
|
|
107
|
+
const MONTH_DAYS = Array.from({ length: 31 }, (_, i) => i + 1);
|
|
108
|
+
function initialValues(rule, defaultTz) {
|
|
109
|
+
if (rule) {
|
|
110
|
+
const parsed = parseRRule(rule.recurrenceRule);
|
|
111
|
+
return {
|
|
112
|
+
timezone: rule.timezone,
|
|
113
|
+
frequency: parsed.frequency,
|
|
114
|
+
interval: parsed.interval,
|
|
115
|
+
byWeekdays: parsed.byWeekdays,
|
|
116
|
+
byMonthDays: parsed.byMonthDays,
|
|
117
|
+
maxCapacity: rule.maxCapacity,
|
|
118
|
+
cutoffMinutes: rule.cutoffMinutes ?? "",
|
|
119
|
+
active: rule.active,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
timezone: defaultTz,
|
|
124
|
+
frequency: "WEEKLY",
|
|
125
|
+
interval: 1,
|
|
126
|
+
byWeekdays: ["MO"],
|
|
127
|
+
byMonthDays: [],
|
|
128
|
+
maxCapacity: 0,
|
|
129
|
+
cutoffMinutes: "",
|
|
130
|
+
active: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function ScheduleForm({ productId, rule, onSuccess, onCancel }) {
|
|
134
|
+
const messages = useProductDetailMessages();
|
|
135
|
+
const api = useProductDetailApi();
|
|
136
|
+
const productMessages = messages.products.core;
|
|
137
|
+
const scheduleMessages = messages.products.operations.schedules;
|
|
138
|
+
const isEditing = !!rule;
|
|
139
|
+
const scheduleFormSchema = buildScheduleFormSchema(scheduleMessages);
|
|
140
|
+
const weekdayOptions = [
|
|
141
|
+
{ value: "MO", label: scheduleMessages.weekdayMon },
|
|
142
|
+
{ value: "TU", label: scheduleMessages.weekdayTue },
|
|
143
|
+
{ value: "WE", label: scheduleMessages.weekdayWed },
|
|
144
|
+
{ value: "TH", label: scheduleMessages.weekdayThu },
|
|
145
|
+
{ value: "FR", label: scheduleMessages.weekdayFri },
|
|
146
|
+
{ value: "SA", label: scheduleMessages.weekdaySat },
|
|
147
|
+
{ value: "SU", label: scheduleMessages.weekdaySun },
|
|
148
|
+
];
|
|
149
|
+
const frequencyOptions = [
|
|
150
|
+
{ value: "DAILY", label: scheduleMessages.frequencyDaily },
|
|
151
|
+
{ value: "WEEKLY", label: scheduleMessages.frequencyWeekly },
|
|
152
|
+
{ value: "MONTHLY", label: scheduleMessages.frequencyMonthly },
|
|
153
|
+
];
|
|
154
|
+
const defaultTz = typeof Intl !== "undefined"
|
|
155
|
+
? (Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC")
|
|
156
|
+
: "UTC";
|
|
157
|
+
const form = useForm({
|
|
158
|
+
resolver: zodResolver(scheduleFormSchema),
|
|
159
|
+
defaultValues: initialValues(rule, defaultTz),
|
|
160
|
+
});
|
|
161
|
+
const active = form.watch("active");
|
|
162
|
+
const timezone = form.watch("timezone");
|
|
163
|
+
const frequency = form.watch("frequency");
|
|
164
|
+
const interval = form.watch("interval");
|
|
165
|
+
const byWeekdays = form.watch("byWeekdays");
|
|
166
|
+
const byMonthDays = form.watch("byMonthDays");
|
|
167
|
+
const preview = useMemo(() => describeRRule({
|
|
168
|
+
frequency,
|
|
169
|
+
interval: typeof interval === "number" ? interval : Number(interval) || 1,
|
|
170
|
+
byWeekdays,
|
|
171
|
+
byMonthDays,
|
|
172
|
+
}, scheduleMessages, weekdayOptions), [byMonthDays, byWeekdays, frequency, interval, scheduleMessages, weekdayOptions]);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
form.reset(initialValues(rule, defaultTz));
|
|
175
|
+
}, [rule, form, defaultTz]);
|
|
176
|
+
const onSubmit = async (values) => {
|
|
177
|
+
const cutoffMinutes = typeof values.cutoffMinutes === "number" ? values.cutoffMinutes : null;
|
|
178
|
+
const recurrenceRule = buildRRule({
|
|
179
|
+
frequency: values.frequency,
|
|
180
|
+
interval: values.interval,
|
|
181
|
+
byWeekdays: values.byWeekdays,
|
|
182
|
+
byMonthDays: values.byMonthDays,
|
|
183
|
+
});
|
|
184
|
+
const payload = {
|
|
185
|
+
productId,
|
|
186
|
+
timezone: values.timezone,
|
|
187
|
+
recurrenceRule,
|
|
188
|
+
maxCapacity: values.maxCapacity,
|
|
189
|
+
cutoffMinutes,
|
|
190
|
+
active: values.active,
|
|
191
|
+
};
|
|
192
|
+
if (isEditing) {
|
|
193
|
+
await api.patch(`/v1/availability/rules/${rule.id}`, payload);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
await api.post("/v1/availability/rules", payload);
|
|
197
|
+
}
|
|
198
|
+
onSuccess();
|
|
199
|
+
};
|
|
200
|
+
const unitLabel = frequency === "DAILY"
|
|
201
|
+
? scheduleMessages.unitDays
|
|
202
|
+
: frequency === "WEEKLY"
|
|
203
|
+
? scheduleMessages.unitWeeks
|
|
204
|
+
: scheduleMessages.unitMonths;
|
|
205
|
+
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-[160px_1fr] gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: scheduleMessages.repeatsLabel }), _jsxs(Select, { value: frequency, onValueChange: (v) => form.setValue("frequency", v, {
|
|
206
|
+
shouldValidate: true,
|
|
207
|
+
shouldDirty: true,
|
|
208
|
+
}), items: frequencyOptions, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: frequencyOptions.map((f) => (_jsx(SelectItem, { value: f.value, children: f.label }, f.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: scheduleMessages.everyLabel }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { ...form.register("interval"), type: "number", min: "1", max: "365", step: "1", className: "w-24" }), _jsx("span", { className: "text-sm text-muted-foreground", children: unitLabel })] }), form.formState.errors.interval && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.interval.message }))] })] }), frequency === "WEEKLY" && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: scheduleMessages.weeklyDaysLabel }), _jsx(ToggleGroup, { multiple: true, value: byWeekdays, onValueChange: (next) => form.setValue("byWeekdays", next, {
|
|
209
|
+
shouldValidate: true,
|
|
210
|
+
shouldDirty: true,
|
|
211
|
+
}), variant: "outline", spacing: 1, children: weekdayOptions.map((d) => (_jsx(ToggleGroupItem, { value: d.value, className: "w-14", children: d.label }, d.value))) }), form.formState.errors.byWeekdays && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.byWeekdays.message }))] })), frequency === "MONTHLY" && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: scheduleMessages.monthlyDaysLabel }), _jsx(ToggleGroup, { multiple: true, value: byMonthDays.map(String), onValueChange: (next) => form.setValue("byMonthDays", next.map((n) => Number.parseInt(n, 10)), { shouldValidate: true, shouldDirty: true }), variant: "outline", spacing: 1, className: "flex-wrap", children: MONTH_DAYS.map((d) => (_jsx(ToggleGroupItem, { value: String(d), className: "w-10", children: d }, d))) }), form.formState.errors.byMonthDays && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.byMonthDays.message }))] })), _jsx("p", { className: "text-xs text-muted-foreground", children: preview }), _jsxs("div", { className: "grid grid-cols-3 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: scheduleMessages.timezoneLabel }), _jsxs(Combobox, { items: TIMEZONE_IDS, value: timezone || null, autoHighlight: true, itemToStringValue: (id) => getTimezoneLabel(id), onValueChange: (next) => {
|
|
212
|
+
if (typeof next === "string") {
|
|
213
|
+
form.setValue("timezone", next, {
|
|
214
|
+
shouldValidate: true,
|
|
215
|
+
shouldDirty: true,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}, children: [_jsx(ComboboxInput, { placeholder: scheduleMessages.timezoneSearchPlaceholder }), _jsxs(ComboboxContent, { children: [_jsx(ComboboxEmpty, { children: scheduleMessages.timezoneEmpty }), _jsx(ComboboxList, { children: _jsx(ComboboxCollection, { children: (id) => {
|
|
219
|
+
const tz = TIMEZONE_OPTIONS.find((t) => t.id === id);
|
|
220
|
+
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));
|
|
221
|
+
} }) })] })] }), form.formState.errors.timezone && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.timezone.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: scheduleMessages.maxCapacityLabel }), _jsx(Input, { ...form.register("maxCapacity"), type: "number", min: "0", step: "1" }), form.formState.errors.maxCapacity && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.maxCapacity.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: scheduleMessages.cutoffLabel }), _jsx(Input, { ...form.register("cutoffMinutes"), type: "number", min: "0", step: "1", placeholder: "0" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "schedule-active", checked: active, onCheckedChange: (c) => form.setValue("active", c) }), _jsx(Label, { htmlFor: "schedule-active", className: "font-normal cursor-pointer", children: scheduleMessages.activeDescription })] })] }), _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 : scheduleMessages.create] })] })] }));
|
|
222
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type DayServiceData } from "./product-service-form.js";
|
|
2
|
+
export type { DayServiceData };
|
|
3
|
+
type ServiceDialogProps = {
|
|
4
|
+
open: boolean;
|
|
5
|
+
onOpenChange: (open: boolean) => void;
|
|
6
|
+
productId: string;
|
|
7
|
+
dayId: string;
|
|
8
|
+
service?: DayServiceData;
|
|
9
|
+
onSuccess: () => void;
|
|
10
|
+
};
|
|
11
|
+
export declare function ServiceDialog({ open, onOpenChange, productId, dayId, service, onSuccess, }: ServiceDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
//# sourceMappingURL=product-service-dialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-service-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-service-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,cAAc,EAAe,MAAM,2BAA2B,CAAA;AAE5E,YAAY,EAAE,cAAc,EAAE,CAAA;AAE9B,KAAK,kBAAkB,GAAG;IACxB,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,KAAK,EACL,OAAO,EACP,SAAS,GACV,EAAE,kBAAkB,2CAyBpB"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from "@voyantjs/ui/components";
|
|
3
|
+
import { useProductDetailMessages } from "./host.js";
|
|
4
|
+
import { ServiceForm } from "./product-service-form.js";
|
|
5
|
+
export function ServiceDialog({ open, onOpenChange, productId, dayId, service, onSuccess, }) {
|
|
6
|
+
const messages = useProductDetailMessages();
|
|
7
|
+
const serviceMessages = messages.products.operations.services;
|
|
8
|
+
const isEditing = !!service;
|
|
9
|
+
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? serviceMessages.editTitle : serviceMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(ServiceForm, { productId: productId, dayId: dayId, service: service, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type DayServiceData = {
|
|
2
|
+
id: string;
|
|
3
|
+
serviceType: "accommodation" | "transfer" | "experience" | "guide" | "meal" | "other";
|
|
4
|
+
name: string;
|
|
5
|
+
description: string | null;
|
|
6
|
+
countryCode: string | null;
|
|
7
|
+
supplierServiceId: string | null;
|
|
8
|
+
costCurrency: string;
|
|
9
|
+
costAmountCents: number;
|
|
10
|
+
quantity: number;
|
|
11
|
+
sortOrder: number | null;
|
|
12
|
+
notes: string | null;
|
|
13
|
+
};
|
|
14
|
+
export interface ServiceFormProps {
|
|
15
|
+
productId: string;
|
|
16
|
+
dayId: string;
|
|
17
|
+
service?: DayServiceData;
|
|
18
|
+
onSuccess: () => void;
|
|
19
|
+
onCancel?: () => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function ServiceForm({ productId, dayId, service, onSuccess, onCancel }: ServiceFormProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
//# sourceMappingURL=product-service-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-service-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-service-form.tsx"],"names":[],"mappings":"AAgDA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,eAAe,GAAG,UAAU,GAAG,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAA;IACrF,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,MAAM,CAAA;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAUD,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAkDD,wBAAgB,WAAW,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,gBAAgB,2CAwP/F"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@voyantjs/ui/components";
|
|
4
|
+
import { CurrencyCombobox } from "@voyantjs/ui/components/currency-combobox";
|
|
5
|
+
import { Loader2 } from "lucide-react";
|
|
6
|
+
import { useEffect } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import { z } from "zod/v4";
|
|
9
|
+
import { useProductDetailApi, useProductDetailMessages } from "./host.js";
|
|
10
|
+
import { zodResolver } from "./zod-resolver.js";
|
|
11
|
+
const buildServiceFormSchema = (messages) => z.object({
|
|
12
|
+
serviceType: z.enum(["accommodation", "transfer", "experience", "guide", "meal", "other"]),
|
|
13
|
+
name: z.string().min(1, messages.validationNameRequired),
|
|
14
|
+
description: z.string().optional().nullable(),
|
|
15
|
+
countryCode: z
|
|
16
|
+
.string()
|
|
17
|
+
.trim()
|
|
18
|
+
.max(2, messages.validationCountryCode)
|
|
19
|
+
.optional()
|
|
20
|
+
.or(z.literal(""))
|
|
21
|
+
.nullable(),
|
|
22
|
+
supplierServiceId: z.string().optional().nullable(),
|
|
23
|
+
costCurrency: z.string().min(3).max(3, messages.validationIsoCurrency),
|
|
24
|
+
costAmount: z.coerce.number().min(0, messages.validationCostNonNegative),
|
|
25
|
+
quantity: z.coerce.number().int().positive().default(1),
|
|
26
|
+
sortOrder: z.coerce.number().int().optional().or(z.literal("")).nullable(),
|
|
27
|
+
notes: z.string().optional().nullable(),
|
|
28
|
+
});
|
|
29
|
+
function initialValues(service) {
|
|
30
|
+
if (service) {
|
|
31
|
+
return {
|
|
32
|
+
serviceType: service.serviceType,
|
|
33
|
+
name: service.name,
|
|
34
|
+
description: service.description ?? "",
|
|
35
|
+
countryCode: service.countryCode ?? "",
|
|
36
|
+
supplierServiceId: service.supplierServiceId ?? "",
|
|
37
|
+
costCurrency: service.costCurrency,
|
|
38
|
+
costAmount: service.costAmountCents / 100,
|
|
39
|
+
quantity: service.quantity,
|
|
40
|
+
sortOrder: service.sortOrder ?? "",
|
|
41
|
+
notes: service.notes ?? "",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
serviceType: "accommodation",
|
|
46
|
+
name: "",
|
|
47
|
+
description: "",
|
|
48
|
+
countryCode: "",
|
|
49
|
+
supplierServiceId: "",
|
|
50
|
+
costCurrency: "EUR",
|
|
51
|
+
costAmount: 0,
|
|
52
|
+
quantity: 1,
|
|
53
|
+
sortOrder: "",
|
|
54
|
+
notes: "",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function getServiceTypeLabel(type, messages) {
|
|
58
|
+
switch (type) {
|
|
59
|
+
case "accommodation":
|
|
60
|
+
return messages.serviceTypeAccommodation;
|
|
61
|
+
case "transfer":
|
|
62
|
+
return messages.serviceTypeTransfer;
|
|
63
|
+
case "experience":
|
|
64
|
+
return messages.serviceTypeExperience;
|
|
65
|
+
case "guide":
|
|
66
|
+
return messages.serviceTypeGuide;
|
|
67
|
+
case "meal":
|
|
68
|
+
return messages.serviceTypeMeal;
|
|
69
|
+
case "other":
|
|
70
|
+
return messages.serviceTypeOther;
|
|
71
|
+
default:
|
|
72
|
+
return type;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function ServiceForm({ productId, dayId, service, onSuccess, onCancel }) {
|
|
76
|
+
const messages = useProductDetailMessages();
|
|
77
|
+
const api = useProductDetailApi();
|
|
78
|
+
const productMessages = messages.products.core;
|
|
79
|
+
const serviceMessages = messages.products.operations.services;
|
|
80
|
+
const isEditing = !!service;
|
|
81
|
+
const serviceFormSchema = buildServiceFormSchema(serviceMessages);
|
|
82
|
+
const serviceTypes = [
|
|
83
|
+
{ value: "accommodation", label: serviceMessages.serviceTypeAccommodation },
|
|
84
|
+
{ value: "transfer", label: serviceMessages.serviceTypeTransfer },
|
|
85
|
+
{ value: "experience", label: serviceMessages.serviceTypeExperience },
|
|
86
|
+
{ value: "guide", label: serviceMessages.serviceTypeGuide },
|
|
87
|
+
{ value: "meal", label: serviceMessages.serviceTypeMeal },
|
|
88
|
+
{ value: "other", label: serviceMessages.serviceTypeOther },
|
|
89
|
+
];
|
|
90
|
+
const { data: suppliersData } = useQuery({
|
|
91
|
+
queryKey: ["suppliers-for-picker"],
|
|
92
|
+
queryFn: async () => {
|
|
93
|
+
const res = await api.get("/v1/suppliers?limit=100");
|
|
94
|
+
const options = [];
|
|
95
|
+
for (const supplier of res.data) {
|
|
96
|
+
const servicesRes = await api.get(`/v1/suppliers/${supplier.id}/services`);
|
|
97
|
+
for (const svc of servicesRes.data) {
|
|
98
|
+
options.push({
|
|
99
|
+
id: svc.id,
|
|
100
|
+
supplierId: supplier.id,
|
|
101
|
+
supplierName: supplier.name,
|
|
102
|
+
serviceType: svc.serviceType,
|
|
103
|
+
name: svc.name,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return options;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const form = useForm({
|
|
111
|
+
resolver: zodResolver(serviceFormSchema),
|
|
112
|
+
defaultValues: initialValues(service),
|
|
113
|
+
});
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
form.reset(initialValues(service));
|
|
116
|
+
}, [service, form]);
|
|
117
|
+
const handleSupplierServiceSelect = (supplierServiceId) => {
|
|
118
|
+
const nextSupplierServiceId = supplierServiceId ?? "";
|
|
119
|
+
form.setValue("supplierServiceId", nextSupplierServiceId);
|
|
120
|
+
const option = suppliersData?.find((o) => o.id === nextSupplierServiceId);
|
|
121
|
+
if (option) {
|
|
122
|
+
form.setValue("name", option.name);
|
|
123
|
+
form.setValue("serviceType", option.serviceType);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const onSubmit = async (values) => {
|
|
127
|
+
const payload = {
|
|
128
|
+
serviceType: values.serviceType,
|
|
129
|
+
name: values.name,
|
|
130
|
+
description: values.description || null,
|
|
131
|
+
countryCode: values.countryCode?.trim().toUpperCase() || null,
|
|
132
|
+
supplierServiceId: values.supplierServiceId || null,
|
|
133
|
+
costCurrency: values.costCurrency,
|
|
134
|
+
costAmountCents: Math.round(values.costAmount * 100),
|
|
135
|
+
quantity: values.quantity,
|
|
136
|
+
sortOrder: values.sortOrder && typeof values.sortOrder === "number" ? values.sortOrder : null,
|
|
137
|
+
notes: values.notes || null,
|
|
138
|
+
};
|
|
139
|
+
if (isEditing) {
|
|
140
|
+
await api.patch(`/v1/products/${productId}/days/${dayId}/services/${service.id}`, payload);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await api.post(`/v1/products/${productId}/days/${dayId}/services`, payload);
|
|
144
|
+
}
|
|
145
|
+
onSuccess();
|
|
146
|
+
};
|
|
147
|
+
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: [suppliersData && suppliersData.length > 0 && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.supplierServiceLabel }), _jsxs(Select, { value: form.watch("supplierServiceId") ?? "", onValueChange: handleSupplierServiceSelect, items: suppliersData.map((opt) => ({
|
|
148
|
+
value: opt.id,
|
|
149
|
+
label: `${opt.supplierName} — ${opt.name} (${getServiceTypeLabel(opt.serviceType, serviceMessages)})`,
|
|
150
|
+
})), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: serviceMessages.supplierServicePlaceholder }) }), _jsx(SelectContent, { children: suppliersData.map((opt) => (_jsxs(SelectItem, { value: opt.id, children: [opt.supplierName, " \u2014 ", opt.name, " (", getServiceTypeLabel(opt.serviceType, serviceMessages), ")"] }, opt.id))) })] })] })), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.serviceTypeLabel }), _jsxs(Select, { value: form.watch("serviceType"), onValueChange: (v) => form.setValue("serviceType", v), items: serviceTypes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: serviceTypes.map((t) => (_jsx(SelectItem, { value: t.value, children: t.label }, t.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.nameLabel }), _jsx(Input, { ...form.register("name"), placeholder: serviceMessages.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: serviceMessages.descriptionLabel }), _jsx(Textarea, { ...form.register("description"), placeholder: serviceMessages.descriptionPlaceholder })] }), _jsx("div", { className: "grid grid-cols-2 gap-4", children: _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.countryCodeLabel }), _jsx(Input, { ...form.register("countryCode"), placeholder: serviceMessages.countryCodePlaceholder, maxLength: 2, className: "uppercase" }), form.formState.errors.countryCode && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.countryCode.message }))] }) }), _jsxs("div", { className: "grid grid-cols-3 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.costCurrencyLabel }), _jsx(CurrencyCombobox, { value: form.watch("costCurrency") || null, onChange: (next) => form.setValue("costCurrency", next ?? "", {
|
|
151
|
+
shouldValidate: true,
|
|
152
|
+
shouldDirty: true,
|
|
153
|
+
}), placeholder: serviceMessages.costCurrencyPlaceholder }), form.formState.errors.costCurrency && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.costCurrency.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.costAmountLabel }), _jsx(Input, { ...form.register("costAmount"), type: "number", step: "0.01", min: "0", placeholder: serviceMessages.costAmountPlaceholder }), form.formState.errors.costAmount && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.costAmount.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.quantityLabel }), _jsx(Input, { ...form.register("quantity"), type: "number", min: "1", placeholder: serviceMessages.quantityPlaceholder })] })] }), _jsx("div", { className: "grid grid-cols-2 gap-4", children: _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.sortOrderLabel }), _jsx(Input, { ...form.register("sortOrder"), type: "number", placeholder: serviceMessages.sortOrderPlaceholder })] }) }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: serviceMessages.notesLabel }), _jsx(Textarea, { ...form.register("notes"), placeholder: serviceMessages.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 : serviceMessages.create] })] })] }));
|
|
154
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { useProductDetailMessages } from "./host.js";
|
|
2
|
+
type ProductCoreMessages = ReturnType<typeof useProductDetailMessages>["products"]["core"];
|
|
3
|
+
export type TranslatableField = "name" | "description" | "slug";
|
|
4
|
+
export type TranslationDraft = {
|
|
5
|
+
id: string | null;
|
|
6
|
+
languageTag: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
shortDescription: string | null;
|
|
11
|
+
inclusionsHtml: string | null;
|
|
12
|
+
exclusionsHtml: string | null;
|
|
13
|
+
termsHtml: string | null;
|
|
14
|
+
seoTitle: string | null;
|
|
15
|
+
seoDescription: string | null;
|
|
16
|
+
};
|
|
17
|
+
export declare function richTextHasContent(html: string): boolean;
|
|
18
|
+
export declare function languageLabel(tag: string): string;
|
|
19
|
+
export interface PersistTranslationsOptions {
|
|
20
|
+
defaultLanguageTag: string;
|
|
21
|
+
baseName: string;
|
|
22
|
+
baseDescription: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ProductTranslationDrafts {
|
|
25
|
+
drafts: TranslationDraft[];
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
setFieldValue: (languageTag: string, field: TranslatableField, value: string) => void;
|
|
28
|
+
addLanguage: (languageTag: string) => void;
|
|
29
|
+
removeLanguage: (languageTag: string) => void;
|
|
30
|
+
persist: (productId: string, options: PersistTranslationsOptions) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Manages an in-memory draft of a product's translations so Name/Description/
|
|
34
|
+
* Slug can be edited in context from the edit sheet. Seeds from the saved
|
|
35
|
+
* translation records and persists create/update/delete on save.
|
|
36
|
+
*
|
|
37
|
+
* The base product columns hold the default language's Name/Description, so the
|
|
38
|
+
* default-language translation row (if any) just mirrors them and carries the
|
|
39
|
+
* slug (base has no slug column). Fields we don't edit here (short description,
|
|
40
|
+
* inclusions, SEO, …) are preserved untouched.
|
|
41
|
+
*/
|
|
42
|
+
export declare function useProductTranslationDrafts(productId: string | null): ProductTranslationDrafts;
|
|
43
|
+
export interface ContentLanguageSwitcherProps {
|
|
44
|
+
activeLanguage: string;
|
|
45
|
+
defaultLanguageTag: string;
|
|
46
|
+
/** The language tags that currently have a translation draft (excluding the default). */
|
|
47
|
+
languageTags: string[];
|
|
48
|
+
messages: ProductCoreMessages;
|
|
49
|
+
onSelect: (languageTag: string) => void;
|
|
50
|
+
onAddLanguage: (languageTag: string) => void;
|
|
51
|
+
onRemoveLanguage: (languageTag: string) => void;
|
|
52
|
+
}
|
|
53
|
+
/** Top-of-sheet switcher: picks which language every translatable field edits. */
|
|
54
|
+
export declare function ContentLanguageSwitcher({ activeLanguage, defaultLanguageTag, languageTags, messages, onSelect, onAddLanguage, onRemoveLanguage, }: ContentLanguageSwitcherProps): import("react/jsx-runtime").JSX.Element;
|
|
55
|
+
export interface TranslatableFieldProps {
|
|
56
|
+
label: string;
|
|
57
|
+
type: "text" | "richtext";
|
|
58
|
+
field: TranslatableField;
|
|
59
|
+
activeLanguage: string;
|
|
60
|
+
defaultLanguageTag: string;
|
|
61
|
+
/** The base product value (used when the active language is the default). Omit for slug. */
|
|
62
|
+
base?: {
|
|
63
|
+
value: string;
|
|
64
|
+
onChange: (value: string) => void;
|
|
65
|
+
};
|
|
66
|
+
translations: ProductTranslationDrafts;
|
|
67
|
+
messages: ProductCoreMessages;
|
|
68
|
+
placeholder?: string;
|
|
69
|
+
autoFocus?: boolean;
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A field bound to the sheet's active language. When that's the default
|
|
74
|
+
* language (and the field has a base column), it edits the base value;
|
|
75
|
+
* otherwise it edits the active language's translation draft. The globe is an
|
|
76
|
+
* informational indicator (green when the field has any non-default translation).
|
|
77
|
+
*/
|
|
78
|
+
export declare function TranslatableField({ label, type, field, activeLanguage, defaultLanguageTag, base, translations, messages, placeholder, autoFocus, error, }: TranslatableFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
79
|
+
export declare function TranslationIndicator({ languages: translatedLanguages, messages, }: {
|
|
80
|
+
languages: string[];
|
|
81
|
+
messages: ProductCoreMessages;
|
|
82
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
83
|
+
export declare function LanguageCombobox({ value, onValueChange, exclude, placeholder, emptyLabel, }: {
|
|
84
|
+
value: string;
|
|
85
|
+
onValueChange: (languageTag: string) => void;
|
|
86
|
+
exclude?: string[];
|
|
87
|
+
placeholder?: string;
|
|
88
|
+
emptyLabel?: string;
|
|
89
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
90
|
+
export {};
|
|
91
|
+
//# sourceMappingURL=product-translation-popover.d.ts.map
|