@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.
- 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 +237 -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 +180 -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-option-pricing-grid.d.ts +16 -0
- package/dist/components/product-detail/product-option-pricing-grid.d.ts.map +1 -0
- package/dist/components/product-detail/product-option-pricing-grid.js +193 -0
- package/dist/components/product-detail/product-options-pricing.d.ts +34 -0
- package/dist/components/product-detail/product-options-pricing.d.ts.map +1 -0
- package/dist/components/product-detail/product-options-pricing.js +385 -0
- package/dist/components/product-detail/product-options-shared.d.ts +623 -0
- package/dist/components/product-detail/product-options-shared.d.ts.map +1 -0
- package/dist/components/product-detail/product-options-shared.js +54 -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 +14 -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 +34 -0
- package/dist/components/product-detail/product-unit-form.d.ts.map +1 -0
- package/dist/components/product-detail/product-unit-form.js +139 -0
- package/dist/components/product-detail/product-unit-price-rule-dialog.d.ts +17 -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 +29 -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 +145 -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/dist/components/product-options-section.d.ts.map +1 -1
- package/dist/components/product-options-section.js +31 -20
- package/package.json +38 -19
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-translation-popover.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-translation-popover.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useProductTranslationMutation, useProductTranslations, } from "@voyantjs/products-react";
|
|
3
|
+
import { Button, Input, Label } from "@voyantjs/ui/components";
|
|
4
|
+
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, } from "@voyantjs/ui/components/combobox";
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@voyantjs/ui/components/popover";
|
|
6
|
+
import { RichTextEditor } from "@voyantjs/ui/components/rich-text-editor";
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@voyantjs/ui/components/tooltip";
|
|
8
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
9
|
+
import { languages } from "@voyantjs/utils/languages";
|
|
10
|
+
import { Globe, Plus, X } from "lucide-react";
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
function recordToDraft(record) {
|
|
13
|
+
return {
|
|
14
|
+
id: record.id,
|
|
15
|
+
languageTag: record.languageTag,
|
|
16
|
+
name: record.name,
|
|
17
|
+
description: record.description ?? "",
|
|
18
|
+
slug: record.slug ?? "",
|
|
19
|
+
shortDescription: record.shortDescription,
|
|
20
|
+
inclusionsHtml: record.inclusionsHtml,
|
|
21
|
+
exclusionsHtml: record.exclusionsHtml,
|
|
22
|
+
termsHtml: record.termsHtml,
|
|
23
|
+
seoTitle: record.seoTitle,
|
|
24
|
+
seoDescription: record.seoDescription,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function emptyDraft(languageTag) {
|
|
28
|
+
return {
|
|
29
|
+
id: null,
|
|
30
|
+
languageTag,
|
|
31
|
+
name: "",
|
|
32
|
+
description: "",
|
|
33
|
+
slug: "",
|
|
34
|
+
shortDescription: null,
|
|
35
|
+
inclusionsHtml: null,
|
|
36
|
+
exclusionsHtml: null,
|
|
37
|
+
termsHtml: null,
|
|
38
|
+
seoTitle: null,
|
|
39
|
+
seoDescription: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// Rich text is "set" only when it has visible text, not just empty markup like <p></p>.
|
|
43
|
+
export function richTextHasContent(html) {
|
|
44
|
+
return (html
|
|
45
|
+
.replace(/<[^>]*>/g, "")
|
|
46
|
+
.replace(/ /g, " ")
|
|
47
|
+
.trim().length > 0);
|
|
48
|
+
}
|
|
49
|
+
function fieldHasContent(draft, field) {
|
|
50
|
+
if (field === "description")
|
|
51
|
+
return richTextHasContent(draft.description);
|
|
52
|
+
return draft[field].trim().length > 0;
|
|
53
|
+
}
|
|
54
|
+
export function languageLabel(tag) {
|
|
55
|
+
const base = tag.split("-")[0]?.toLowerCase() ?? tag;
|
|
56
|
+
return languages[base] ?? tag;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Manages an in-memory draft of a product's translations so Name/Description/
|
|
60
|
+
* Slug can be edited in context from the edit sheet. Seeds from the saved
|
|
61
|
+
* translation records and persists create/update/delete on save.
|
|
62
|
+
*
|
|
63
|
+
* The base product columns hold the default language's Name/Description, so the
|
|
64
|
+
* default-language translation row (if any) just mirrors them and carries the
|
|
65
|
+
* slug (base has no slug column). Fields we don't edit here (short description,
|
|
66
|
+
* inclusions, SEO, …) are preserved untouched.
|
|
67
|
+
*/
|
|
68
|
+
export function useProductTranslationDrafts(productId) {
|
|
69
|
+
const query = useProductTranslations(productId ?? undefined, {
|
|
70
|
+
limit: 100,
|
|
71
|
+
enabled: !!productId,
|
|
72
|
+
});
|
|
73
|
+
const mutations = useProductTranslationMutation();
|
|
74
|
+
const [drafts, setDrafts] = useState([]);
|
|
75
|
+
const seededKey = useRef(null);
|
|
76
|
+
const existingRef = useRef([]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const key = productId ?? "__new__";
|
|
79
|
+
if (productId && query.isPending)
|
|
80
|
+
return;
|
|
81
|
+
if (seededKey.current === key)
|
|
82
|
+
return;
|
|
83
|
+
const records = query.data?.data ?? [];
|
|
84
|
+
existingRef.current = records;
|
|
85
|
+
setDrafts(records.map(recordToDraft));
|
|
86
|
+
seededKey.current = key;
|
|
87
|
+
}, [productId, query.isPending, query.data]);
|
|
88
|
+
const setFieldValue = useCallback((languageTag, field, value) => {
|
|
89
|
+
setDrafts((prev) => {
|
|
90
|
+
if (prev.some((draft) => draft.languageTag === languageTag)) {
|
|
91
|
+
return prev.map((draft) => draft.languageTag === languageTag ? { ...draft, [field]: value } : draft);
|
|
92
|
+
}
|
|
93
|
+
return [...prev, { ...emptyDraft(languageTag), [field]: value }];
|
|
94
|
+
});
|
|
95
|
+
}, []);
|
|
96
|
+
const addLanguage = useCallback((languageTag) => {
|
|
97
|
+
setDrafts((prev) => prev.some((draft) => draft.languageTag === languageTag)
|
|
98
|
+
? prev
|
|
99
|
+
: [...prev, emptyDraft(languageTag)]);
|
|
100
|
+
}, []);
|
|
101
|
+
const removeLanguage = useCallback((languageTag) => {
|
|
102
|
+
setDrafts((prev) => prev.filter((draft) => draft.languageTag !== languageTag));
|
|
103
|
+
}, []);
|
|
104
|
+
const persist = useCallback(async (resolvedProductId, options) => {
|
|
105
|
+
const { defaultLanguageTag, baseName, baseDescription } = options;
|
|
106
|
+
const original = existingRef.current;
|
|
107
|
+
const currentLanguages = new Set(drafts.map((draft) => draft.languageTag));
|
|
108
|
+
const deletes = original
|
|
109
|
+
.filter((record) => !currentLanguages.has(record.languageTag))
|
|
110
|
+
.map((record) => mutations.remove.mutateAsync({
|
|
111
|
+
productId: resolvedProductId,
|
|
112
|
+
translationId: record.id,
|
|
113
|
+
}));
|
|
114
|
+
const upserts = drafts.map((draft) => {
|
|
115
|
+
const isDefault = draft.languageTag === defaultLanguageTag;
|
|
116
|
+
// The default-language row mirrors the base columns so public serving
|
|
117
|
+
// (which prefers translations) stays consistent with what's edited.
|
|
118
|
+
// When base is empty we keep the row's own value rather than wiping it —
|
|
119
|
+
// legacy products often have empty base columns with content only here.
|
|
120
|
+
const name = isDefault ? baseName : draft.name.trim() || baseName;
|
|
121
|
+
const description = isDefault
|
|
122
|
+
? richTextHasContent(baseDescription)
|
|
123
|
+
? baseDescription
|
|
124
|
+
: richTextHasContent(draft.description)
|
|
125
|
+
? draft.description
|
|
126
|
+
: null
|
|
127
|
+
: richTextHasContent(draft.description)
|
|
128
|
+
? draft.description
|
|
129
|
+
: null;
|
|
130
|
+
const slug = draft.slug.trim() ? draft.slug.trim() : null;
|
|
131
|
+
if (draft.id) {
|
|
132
|
+
return mutations.update.mutateAsync({
|
|
133
|
+
productId: resolvedProductId,
|
|
134
|
+
translationId: draft.id,
|
|
135
|
+
input: { name, description, slug },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// A brand-new row is only worth creating once it carries content.
|
|
139
|
+
const isEmpty = isDefault
|
|
140
|
+
? !slug
|
|
141
|
+
: !draft.name.trim() && !richTextHasContent(draft.description) && !slug;
|
|
142
|
+
if (isEmpty)
|
|
143
|
+
return Promise.resolve(null);
|
|
144
|
+
return mutations.create.mutateAsync({
|
|
145
|
+
productId: resolvedProductId,
|
|
146
|
+
input: { languageTag: draft.languageTag, name, description, slug },
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
await Promise.all([...deletes, ...upserts]);
|
|
150
|
+
// Force a reseed from the refreshed server state so a second save patches
|
|
151
|
+
// (with real ids) instead of re-creating.
|
|
152
|
+
seededKey.current = null;
|
|
153
|
+
}, [drafts, mutations]);
|
|
154
|
+
return {
|
|
155
|
+
drafts,
|
|
156
|
+
isLoading: !!productId && query.isPending,
|
|
157
|
+
setFieldValue,
|
|
158
|
+
addLanguage,
|
|
159
|
+
removeLanguage,
|
|
160
|
+
persist,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Top-of-sheet switcher: picks which language every translatable field edits. */
|
|
164
|
+
export function ContentLanguageSwitcher({ activeLanguage, defaultLanguageTag, languageTags, messages, onSelect, onAddLanguage, onRemoveLanguage, }) {
|
|
165
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
166
|
+
const otherLanguages = languageTags.filter((tag) => tag !== defaultLanguageTag);
|
|
167
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: messages.editingLanguageLabel }), _jsxs("div", { className: "flex flex-wrap items-center gap-1.5", children: [_jsx(LanguageChip, { active: activeLanguage === defaultLanguageTag, languageTag: defaultLanguageTag, badge: messages.defaultBadge, onSelect: () => onSelect(defaultLanguageTag) }), otherLanguages.map((tag) => (_jsx(LanguageChip, { active: activeLanguage === tag, languageTag: tag, onSelect: () => onSelect(tag), onRemove: () => onRemoveLanguage(tag), removeLabel: messages.translationRemoveLanguage }, tag))), _jsxs(Popover, { open: addOpen, onOpenChange: setAddOpen, children: [_jsx(PopoverTrigger, { render: _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "h-7 border-dashed", children: [_jsx(Plus, { className: "size-3.5" }), messages.addLanguage] }) }), _jsx(PopoverContent, { align: "start", className: "w-64", children: _jsx(LanguageCombobox, { value: "", exclude: [defaultLanguageTag, ...otherLanguages], placeholder: messages.translationLanguageSearch, emptyLabel: messages.translationLanguageEmpty, onValueChange: (code) => {
|
|
168
|
+
if (code) {
|
|
169
|
+
onAddLanguage(code);
|
|
170
|
+
setAddOpen(false);
|
|
171
|
+
}
|
|
172
|
+
} }) })] })] })] }));
|
|
173
|
+
}
|
|
174
|
+
function LanguageChip({ active, languageTag, badge, onSelect, onRemove, removeLabel, }) {
|
|
175
|
+
return (_jsxs("div", { className: cn("inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors", active
|
|
176
|
+
? "border-primary bg-primary/10 text-foreground"
|
|
177
|
+
: "border-input text-muted-foreground hover:bg-accent"), children: [_jsxs("button", { type: "button", onClick: onSelect, className: "inline-flex items-center gap-1.5", children: [_jsx("span", { className: "font-medium", children: languageLabel(languageTag) }), _jsx("span", { className: "font-mono uppercase opacity-70", children: languageTag }), badge ? (_jsx("span", { className: "rounded bg-muted px-1 text-[10px] font-medium uppercase tracking-wide", children: badge })) : null] }), onRemove ? (_jsx("button", { type: "button", onClick: onRemove, "aria-label": removeLabel, className: "text-muted-foreground hover:text-destructive", children: _jsx(X, { className: "size-3" }) })) : null] }));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A field bound to the sheet's active language. When that's the default
|
|
181
|
+
* language (and the field has a base column), it edits the base value;
|
|
182
|
+
* otherwise it edits the active language's translation draft. The globe is an
|
|
183
|
+
* informational indicator (green when the field has any non-default translation).
|
|
184
|
+
*/
|
|
185
|
+
export function TranslatableField({ label, type, field, activeLanguage, defaultLanguageTag, base, translations, messages, placeholder, autoFocus, error, }) {
|
|
186
|
+
const usesBase = !!base && activeLanguage === defaultLanguageTag;
|
|
187
|
+
const activeDraft = translations.drafts.find((draft) => draft.languageTag === activeLanguage);
|
|
188
|
+
const defaultDraft = translations.drafts.find((draft) => draft.languageTag === defaultLanguageTag);
|
|
189
|
+
// When editing the default language, show the base value — but fall back to
|
|
190
|
+
// the default-language translation (legacy products keep content only there).
|
|
191
|
+
// Editing writes to the base columns, promoting that content forward.
|
|
192
|
+
const value = usesBase
|
|
193
|
+
? (base?.value ?? "") || (defaultDraft?.[field] ?? "")
|
|
194
|
+
: (activeDraft?.[field] ?? "");
|
|
195
|
+
const handleChange = usesBase
|
|
196
|
+
? (base?.onChange ?? (() => { }))
|
|
197
|
+
: (next) => translations.setFieldValue(activeLanguage, field, next);
|
|
198
|
+
const translatedLanguages = translations.drafts
|
|
199
|
+
.filter((draft) => draft.languageTag !== defaultLanguageTag && fieldHasContent(draft, field))
|
|
200
|
+
.map((draft) => draft.languageTag);
|
|
201
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Label, { children: label }), _jsx(TranslationIndicator, { languages: translatedLanguages, messages: messages })] }), type === "richtext" ? (_jsx(RichTextEditor, { value: value, onChange: handleChange, placeholder: placeholder, editorClassName: "max-h-[280px] overflow-y-auto" })) : (_jsx(Input, { value: value, onChange: (event) => handleChange(event.target.value), placeholder: placeholder, autoFocus: autoFocus })), error ? _jsx("p", { className: "text-xs text-destructive", children: error }) : null] }));
|
|
202
|
+
}
|
|
203
|
+
export function TranslationIndicator({ languages: translatedLanguages, messages, }) {
|
|
204
|
+
const isTranslated = translatedLanguages.length > 0;
|
|
205
|
+
return (_jsx(TooltipProvider, { delay: 150, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button", className: "inline-flex cursor-help items-center", children: _jsx(Globe, { className: cn("size-3.5", isTranslated ? "text-emerald-500" : "text-muted-foreground/50") }) }) }), _jsx(TooltipContent, { children: isTranslated
|
|
206
|
+
? `${messages.fieldTranslated}: ${translatedLanguages
|
|
207
|
+
.map((tag) => tag.toUpperCase())
|
|
208
|
+
.join(", ")}`
|
|
209
|
+
: messages.fieldNotTranslated })] }) }));
|
|
210
|
+
}
|
|
211
|
+
export function LanguageCombobox({ value, onValueChange, exclude = [], placeholder, emptyLabel, }) {
|
|
212
|
+
const excludeKey = exclude.join("|");
|
|
213
|
+
const options = useMemo(() => Object.entries(languages)
|
|
214
|
+
.filter(([code]) => !excludeKey.split("|").includes(code))
|
|
215
|
+
.map(([code, name]) => ({ value: code, label: name })), [excludeKey]);
|
|
216
|
+
return (_jsxs(Combobox, { value: value, onValueChange: (next) => onValueChange(next ?? ""), children: [_jsx(ComboboxInput, { placeholder: placeholder, className: "w-full" }), _jsx(ComboboxContent, { children: _jsxs(ComboboxList, { children: [options.map((option) => (_jsxs(ComboboxItem, { value: option.value, children: [_jsx("span", { className: "truncate", children: option.label }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: option.value })] }, option.value))), _jsx(ComboboxEmpty, { children: emptyLabel })] }) })] }));
|
|
217
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type OptionUnitData } from "./product-unit-form.js";
|
|
2
|
+
export type { OptionUnitData };
|
|
3
|
+
type UnitDialogProps = {
|
|
4
|
+
open: boolean;
|
|
5
|
+
onOpenChange: (open: boolean) => void;
|
|
6
|
+
optionId: string;
|
|
7
|
+
unit?: OptionUnitData;
|
|
8
|
+
defaultUnitType?: OptionUnitData["unitType"];
|
|
9
|
+
lockUnitType?: boolean;
|
|
10
|
+
nextSortOrder?: number;
|
|
11
|
+
onSuccess: () => void;
|
|
12
|
+
};
|
|
13
|
+
export declare function UnitDialog({ open, onOpenChange, optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, }: UnitDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
//# sourceMappingURL=product-unit-dialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-unit-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,cAAc,EAAY,MAAM,wBAAwB,CAAA;AAEtE,YAAY,EAAE,cAAc,EAAE,CAAA;AAE9B,KAAK,eAAe,GAAG;IACrB,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,eAAe,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAA;IAC5C,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,IAAI,EACJ,eAAe,EACf,YAAY,EACZ,aAAa,EACb,SAAS,GACV,EAAE,eAAe,2CAyBjB"}
|
|
@@ -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 { UnitForm } from "./product-unit-form.js";
|
|
5
|
+
export function UnitDialog({ open, onOpenChange, optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, }) {
|
|
6
|
+
const messages = useProductDetailMessages();
|
|
7
|
+
const unitMessages = messages.products.operations.units;
|
|
8
|
+
const isEditing = !!unit;
|
|
9
|
+
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? unitMessages.editTitle : unitMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(UnitForm, { optionId: optionId, unit: unit, defaultUnitType: defaultUnitType, lockUnitType: lockUnitType, nextSortOrder: nextSortOrder, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type OptionUnitData = {
|
|
2
|
+
id: string;
|
|
3
|
+
optionId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
code: string | null;
|
|
6
|
+
description: string | null;
|
|
7
|
+
unitType: "person" | "group" | "room" | "vehicle" | "service" | "other";
|
|
8
|
+
minQuantity: number | null;
|
|
9
|
+
maxQuantity: number | null;
|
|
10
|
+
minAge: number | null;
|
|
11
|
+
maxAge: number | null;
|
|
12
|
+
occupancyMin: number | null;
|
|
13
|
+
occupancyMax: number | null;
|
|
14
|
+
isRequired: boolean;
|
|
15
|
+
isHidden: boolean;
|
|
16
|
+
sortOrder: number;
|
|
17
|
+
};
|
|
18
|
+
export interface UnitFormProps {
|
|
19
|
+
optionId: string;
|
|
20
|
+
unit?: OptionUnitData;
|
|
21
|
+
/** Pre-selected unit type for the "add" path (e.g. Room vs Traveler type). */
|
|
22
|
+
defaultUnitType?: OptionUnitData["unitType"];
|
|
23
|
+
/**
|
|
24
|
+
* Hide the unit-type picker entirely. Used when the form is opened from a
|
|
25
|
+
* type-specific context (e.g. "Add room"), so the agent can't turn a room
|
|
26
|
+
* into a vehicle and create a nonsensical mix in the pricing grid.
|
|
27
|
+
*/
|
|
28
|
+
lockUnitType?: boolean;
|
|
29
|
+
nextSortOrder?: number;
|
|
30
|
+
onSuccess: () => void;
|
|
31
|
+
onCancel?: () => void;
|
|
32
|
+
}
|
|
33
|
+
export declare function UnitForm({ optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, onCancel, }: UnitFormProps): import("react/jsx-runtime").JSX.Element;
|
|
34
|
+
//# sourceMappingURL=product-unit-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-unit-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-form.tsx"],"names":[],"mappings":"AA2EA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,QAAQ,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;IACvE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,8EAA8E;IAC9E,eAAe,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAA;IAC5C;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAyCD,wBAAgB,QAAQ,CAAC,EACvB,QAAQ,EACR,IAAI,EACJ,eAAe,EACf,YAAY,EACZ,aAAa,EACb,SAAS,EACT,QAAQ,GACT,EAAE,aAAa,2CA2Lf"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useOptionUnitMutation } from "@voyantjs/products-react";
|
|
3
|
+
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { useForm } from "react-hook-form";
|
|
7
|
+
import { z } from "zod/v4";
|
|
8
|
+
import { useProductDetailMessages } from "./host.js";
|
|
9
|
+
import { zodResolver } from "./zod-resolver.js";
|
|
10
|
+
// "Min/Max quantity" is meaningless to an agent — phrase it in terms of the
|
|
11
|
+
// thing being counted (rooms / vehicles / travelers) for the selected type.
|
|
12
|
+
function quantityLabels(unitType, m) {
|
|
13
|
+
switch (unitType) {
|
|
14
|
+
case "room":
|
|
15
|
+
return { min: m.quantityRoomMin, max: m.quantityRoomMax };
|
|
16
|
+
case "vehicle":
|
|
17
|
+
return { min: m.quantityVehicleMin, max: m.quantityVehicleMax };
|
|
18
|
+
case "person":
|
|
19
|
+
return { min: m.quantityPersonMin, max: m.quantityPersonMax };
|
|
20
|
+
default:
|
|
21
|
+
return { min: m.minQuantityLabel, max: m.maxQuantityLabel };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Occupancy = how many people fit in one unit (guests per room, seats per
|
|
25
|
+
// vehicle, group size). Label it for the selected type.
|
|
26
|
+
function occupancyLabels(unitType, m) {
|
|
27
|
+
switch (unitType) {
|
|
28
|
+
case "room":
|
|
29
|
+
return { min: m.occupancyRoomMin, max: m.occupancyRoomMax };
|
|
30
|
+
case "vehicle":
|
|
31
|
+
return { min: m.occupancyVehicleMin, max: m.occupancyVehicleMax };
|
|
32
|
+
case "group":
|
|
33
|
+
return { min: m.occupancyGroupMin, max: m.occupancyGroupMax };
|
|
34
|
+
default:
|
|
35
|
+
return { min: m.occupancyMinLabel, max: m.occupancyMaxLabel };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const buildUnitFormSchema = (messages) => z.object({
|
|
39
|
+
name: z.string().min(1, messages.validationNameRequired).max(255),
|
|
40
|
+
code: z.string().max(100).optional().nullable(),
|
|
41
|
+
description: z.string().optional().nullable(),
|
|
42
|
+
unitType: z.enum(["person", "group", "room", "vehicle", "service", "other"]),
|
|
43
|
+
minQuantity: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
44
|
+
maxQuantity: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
45
|
+
minAge: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
46
|
+
maxAge: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
47
|
+
occupancyMin: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
48
|
+
occupancyMax: z.coerce.number().int().min(0).optional().or(z.literal("")).nullable(),
|
|
49
|
+
isRequired: z.boolean(),
|
|
50
|
+
isHidden: z.boolean(),
|
|
51
|
+
sortOrder: z.coerce.number().int(),
|
|
52
|
+
});
|
|
53
|
+
function initialValues(unit, nextSortOrder, defaultUnitType) {
|
|
54
|
+
if (unit) {
|
|
55
|
+
return {
|
|
56
|
+
name: unit.name,
|
|
57
|
+
code: unit.code ?? "",
|
|
58
|
+
description: unit.description ?? "",
|
|
59
|
+
unitType: unit.unitType,
|
|
60
|
+
minQuantity: unit.minQuantity ?? "",
|
|
61
|
+
maxQuantity: unit.maxQuantity ?? "",
|
|
62
|
+
minAge: unit.minAge ?? "",
|
|
63
|
+
maxAge: unit.maxAge ?? "",
|
|
64
|
+
occupancyMin: unit.occupancyMin ?? "",
|
|
65
|
+
occupancyMax: unit.occupancyMax ?? "",
|
|
66
|
+
isRequired: unit.isRequired,
|
|
67
|
+
isHidden: unit.isHidden,
|
|
68
|
+
sortOrder: unit.sortOrder,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name: "",
|
|
73
|
+
code: "",
|
|
74
|
+
description: "",
|
|
75
|
+
unitType: defaultUnitType ?? "person",
|
|
76
|
+
minQuantity: "",
|
|
77
|
+
maxQuantity: "",
|
|
78
|
+
minAge: "",
|
|
79
|
+
maxAge: "",
|
|
80
|
+
occupancyMin: "",
|
|
81
|
+
occupancyMax: "",
|
|
82
|
+
isRequired: false,
|
|
83
|
+
isHidden: false,
|
|
84
|
+
sortOrder: nextSortOrder ?? 0,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function UnitForm({ optionId, unit, defaultUnitType, lockUnitType, nextSortOrder, onSuccess, onCancel, }) {
|
|
88
|
+
const messages = useProductDetailMessages();
|
|
89
|
+
const productMessages = messages.products.core;
|
|
90
|
+
const unitMessages = messages.products.operations.units;
|
|
91
|
+
const isEditing = !!unit;
|
|
92
|
+
const { create, update } = useOptionUnitMutation();
|
|
93
|
+
const unitFormSchema = buildUnitFormSchema(unitMessages);
|
|
94
|
+
const unitTypes = [
|
|
95
|
+
{ value: "person", label: unitMessages.typePerson },
|
|
96
|
+
{ value: "group", label: unitMessages.typeGroup },
|
|
97
|
+
{ value: "room", label: unitMessages.typeRoom },
|
|
98
|
+
{ value: "vehicle", label: unitMessages.typeVehicle },
|
|
99
|
+
{ value: "service", label: unitMessages.typeService },
|
|
100
|
+
{ value: "other", label: unitMessages.typeOther },
|
|
101
|
+
];
|
|
102
|
+
const form = useForm({
|
|
103
|
+
resolver: zodResolver(unitFormSchema),
|
|
104
|
+
defaultValues: initialValues(unit, nextSortOrder, defaultUnitType),
|
|
105
|
+
});
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
form.reset(initialValues(unit, nextSortOrder, defaultUnitType));
|
|
108
|
+
}, [unit, nextSortOrder, defaultUnitType, form]);
|
|
109
|
+
const onSubmit = async (values) => {
|
|
110
|
+
const canHaveAge = values.unitType === "person";
|
|
111
|
+
const canHaveOccupancy = values.unitType === "group" || values.unitType === "room" || values.unitType === "vehicle";
|
|
112
|
+
const payload = {
|
|
113
|
+
name: values.name,
|
|
114
|
+
code: values.code || null,
|
|
115
|
+
description: values.description || null,
|
|
116
|
+
unitType: values.unitType,
|
|
117
|
+
minQuantity: typeof values.minQuantity === "number" ? values.minQuantity : null,
|
|
118
|
+
maxQuantity: typeof values.maxQuantity === "number" ? values.maxQuantity : null,
|
|
119
|
+
minAge: canHaveAge && typeof values.minAge === "number" ? values.minAge : null,
|
|
120
|
+
maxAge: canHaveAge && typeof values.maxAge === "number" ? values.maxAge : null,
|
|
121
|
+
occupancyMin: canHaveOccupancy && typeof values.occupancyMin === "number" ? values.occupancyMin : null,
|
|
122
|
+
occupancyMax: canHaveOccupancy && typeof values.occupancyMax === "number" ? values.occupancyMax : null,
|
|
123
|
+
isRequired: values.isRequired,
|
|
124
|
+
isHidden: values.isHidden,
|
|
125
|
+
sortOrder: values.sortOrder,
|
|
126
|
+
};
|
|
127
|
+
if (isEditing) {
|
|
128
|
+
await update.mutateAsync({ id: unit.id, input: payload });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
await create.mutateAsync({ optionId, ...payload });
|
|
132
|
+
}
|
|
133
|
+
onSuccess();
|
|
134
|
+
};
|
|
135
|
+
const unitType = form.watch("unitType");
|
|
136
|
+
const qtyLabels = quantityLabels(unitType, unitMessages);
|
|
137
|
+
const occLabels = occupancyLabels(unitType, unitMessages);
|
|
138
|
+
return (_jsxs("form", { onSubmit: form.handleSubmit(onSubmit), className: "flex flex-1 flex-col gap-4 overflow-hidden", children: [_jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.nameLabel }), _jsx(Input, { ...form.register("name"), placeholder: unitMessages.namePlaceholder }), form.formState.errors.name && (_jsx("p", { className: "text-xs text-destructive", children: form.formState.errors.name.message }))] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.codeLabel }), _jsx(Input, { ...form.register("code"), placeholder: unitMessages.codePlaceholder })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [lockUnitType ? null : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.typeLabel }), _jsxs(Select, { value: unitType, onValueChange: (v) => form.setValue("unitType", v), items: unitTypes, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: unitTypes.map((t) => (_jsx(SelectItem, { value: t.value, children: t.label }, t.value))) })] })] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.sortOrderLabel }), _jsx(Input, { ...form.register("sortOrder"), type: "number" })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: qtyLabels.min }), _jsx(Input, { ...form.register("minQuantity"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: qtyLabels.max }), _jsx(Input, { ...form.register("maxQuantity"), type: "number", min: "0" })] })] }), unitType === "person" && (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.minAgeLabel }), _jsx(Input, { ...form.register("minAge"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.maxAgeLabel }), _jsx(Input, { ...form.register("maxAge"), type: "number", min: "0" })] })] })), (unitType === "room" || unitType === "vehicle" || unitType === "group") && (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: occLabels.min }), _jsx(Input, { ...form.register("occupancyMin"), type: "number", min: "0" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: occLabels.max }), _jsx(Input, { ...form.register("occupancyMax"), type: "number", min: "0" })] })] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: unitMessages.descriptionLabel }), _jsx(Textarea, { ...form.register("description") })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("isRequired"), onCheckedChange: (v) => form.setValue("isRequired", v) }), _jsx(Label, { children: unitMessages.requiredLabel })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: form.watch("isHidden"), onCheckedChange: (v) => form.setValue("isHidden", v) }), _jsx(Label, { children: unitMessages.hiddenLabel })] })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: onCancel, children: productMessages.cancel })) : null, _jsxs(Button, { type: "submit", size: "sm", disabled: form.formState.isSubmitting || create.isPending || update.isPending, children: [(form.formState.isSubmitting || create.isPending || update.isPending) && (_jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" })), isEditing ? productMessages.saveChanges : unitMessages.create] })] })] }));
|
|
139
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OptionUnitData } from "./product-unit-form.js";
|
|
2
|
+
import { type OptionUnitPriceRuleData } from "./product-unit-price-rule-form.js";
|
|
3
|
+
export type { OptionUnitPriceRuleData };
|
|
4
|
+
type UnitPriceRuleDialogProps = {
|
|
5
|
+
open: boolean;
|
|
6
|
+
onOpenChange: (open: boolean) => void;
|
|
7
|
+
optionPriceRuleId: string;
|
|
8
|
+
optionId: string;
|
|
9
|
+
units: OptionUnitData[];
|
|
10
|
+
productCurrency?: string;
|
|
11
|
+
preselectedUnitId?: string;
|
|
12
|
+
preselectedCategoryId?: string | null;
|
|
13
|
+
cell?: OptionUnitPriceRuleData;
|
|
14
|
+
onSuccess: () => void;
|
|
15
|
+
};
|
|
16
|
+
export declare function UnitPriceRuleDialog({ open, onOpenChange, optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, }: UnitPriceRuleDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
//# sourceMappingURL=product-unit-price-rule-dialog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-unit-price-rule-dialog.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-price-rule-dialog.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAC5D,OAAO,EAAE,KAAK,uBAAuB,EAAqB,MAAM,mCAAmC,CAAA;AAEnG,YAAY,EAAE,uBAAuB,EAAE,CAAA;AAEvC,KAAK,wBAAwB,GAAG;IAC9B,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,IAAI,CAAC,EAAE,uBAAuB,CAAA;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB,CAAA;AAED,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,iBAAiB,EACjB,QAAQ,EACR,KAAK,EACL,eAAe,EACf,iBAAiB,EACjB,qBAAqB,EACrB,IAAI,EACJ,SAAS,GACV,EAAE,wBAAwB,2CA6B1B"}
|
|
@@ -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 { UnitPriceRuleForm } from "./product-unit-price-rule-form.js";
|
|
5
|
+
export function UnitPriceRuleDialog({ open, onOpenChange, optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, }) {
|
|
6
|
+
const messages = useProductDetailMessages();
|
|
7
|
+
const unitPriceMessages = messages.products.operations.unitPrices;
|
|
8
|
+
const isEditing = !!cell;
|
|
9
|
+
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? unitPriceMessages.editTitle : unitPriceMessages.newTitle }) }), _jsx(SheetBody, { children: _jsx(UnitPriceRuleForm, { optionPriceRuleId: optionPriceRuleId, optionId: optionId, units: units, productCurrency: productCurrency, preselectedUnitId: preselectedUnitId, preselectedCategoryId: preselectedCategoryId, cell: cell, onSuccess: onSuccess, onCancel: () => onOpenChange(false) }) })] }) }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OptionUnitData } from "./product-unit-form.js";
|
|
2
|
+
export type OptionUnitPriceRuleData = {
|
|
3
|
+
id: string;
|
|
4
|
+
optionPriceRuleId: string;
|
|
5
|
+
optionId: string;
|
|
6
|
+
unitId: string;
|
|
7
|
+
pricingCategoryId: string | null;
|
|
8
|
+
pricingMode: "per_unit" | "per_person" | "per_booking" | "included" | "free" | "on_request";
|
|
9
|
+
sellAmountCents: number | null;
|
|
10
|
+
costAmountCents: number | null;
|
|
11
|
+
minQuantity: number | null;
|
|
12
|
+
maxQuantity: number | null;
|
|
13
|
+
sortOrder: number;
|
|
14
|
+
active: boolean;
|
|
15
|
+
notes: string | null;
|
|
16
|
+
};
|
|
17
|
+
export interface UnitPriceRuleFormProps {
|
|
18
|
+
optionPriceRuleId: string;
|
|
19
|
+
optionId: string;
|
|
20
|
+
units: OptionUnitData[];
|
|
21
|
+
productCurrency?: string;
|
|
22
|
+
preselectedUnitId?: string;
|
|
23
|
+
preselectedCategoryId?: string | null;
|
|
24
|
+
cell?: OptionUnitPriceRuleData;
|
|
25
|
+
onSuccess: () => void;
|
|
26
|
+
onCancel?: () => void;
|
|
27
|
+
}
|
|
28
|
+
export declare function UnitPriceRuleForm({ optionPriceRuleId, optionId, units, productCurrency, preselectedUnitId, preselectedCategoryId, cell, onSuccess, onCancel, }: UnitPriceRuleFormProps): import("react/jsx-runtime").JSX.Element;
|
|
29
|
+
//# sourceMappingURL=product-unit-price-rule-form.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-unit-price-rule-form.d.ts","sourceRoot":"","sources":["../../../src/components/product-detail/product-unit-price-rule-form.tsx"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AA2E5D,MAAM,MAAM,uBAAuB,GAAG;IACpC,EAAE,EAAE,MAAM,CAAA;IACV,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,WAAW,EAAE,UAAU,GAAG,YAAY,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,GAAG,YAAY,CAAA;IAC3F,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,OAAO,CAAA;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB,CAAA;AAED,MAAM,WAAW,sBAAsB;IACrC,iBAAiB,EAAE,MAAM,CAAA;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,IAAI,CAAC,EAAE,uBAAuB,CAAA;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAmCD,wBAAgB,iBAAiB,CAAC,EAChC,iBAAiB,EACjB,QAAQ,EACR,KAAK,EACL,eAAe,EACf,iBAAiB,EACjB,qBAAqB,EACrB,IAAI,EACJ,SAAS,EACT,QAAQ,GACT,EAAE,sBAAsB,2CAoLxB"}
|