@voyantjs/products-ui 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +13 -0
  2. package/dist/components/option-unit-dialog.d.ts +11 -0
  3. package/dist/components/option-unit-dialog.d.ts.map +1 -0
  4. package/dist/components/option-unit-dialog.js +13 -0
  5. package/dist/components/option-unit-form.d.ts +17 -0
  6. package/dist/components/option-unit-form.d.ts.map +1 -0
  7. package/dist/components/option-unit-form.js +114 -0
  8. package/dist/components/product-category-combobox.d.ts +10 -0
  9. package/dist/components/product-category-combobox.d.ts.map +1 -0
  10. package/dist/components/product-category-combobox.js +45 -0
  11. package/dist/components/product-category-dialog.d.ts +9 -0
  12. package/dist/components/product-category-dialog.d.ts.map +1 -0
  13. package/dist/components/product-category-dialog.js +13 -0
  14. package/dist/components/product-category-form.d.ts +15 -0
  15. package/dist/components/product-category-form.d.ts.map +1 -0
  16. package/dist/components/product-category-form.js +74 -0
  17. package/dist/components/product-category-list.d.ts +5 -0
  18. package/dist/components/product-category-list.d.ts.map +1 -0
  19. package/dist/components/product-category-list.js +42 -0
  20. package/dist/components/product-day-dialog.d.ts +11 -0
  21. package/dist/components/product-day-dialog.d.ts.map +1 -0
  22. package/dist/components/product-day-dialog.js +13 -0
  23. package/dist/components/product-day-form.d.ts +18 -0
  24. package/dist/components/product-day-form.d.ts.map +1 -0
  25. package/dist/components/product-day-form.js +67 -0
  26. package/dist/components/product-itinerary-dialog.d.ts +16 -0
  27. package/dist/components/product-itinerary-dialog.d.ts.map +1 -0
  28. package/dist/components/product-itinerary-dialog.js +77 -0
  29. package/dist/components/product-media-dialog.d.ts +11 -0
  30. package/dist/components/product-media-dialog.d.ts.map +1 -0
  31. package/dist/components/product-media-dialog.js +13 -0
  32. package/dist/components/product-media-form.d.ts +17 -0
  33. package/dist/components/product-media-form.d.ts.map +1 -0
  34. package/dist/components/product-media-form.js +92 -0
  35. package/dist/components/product-media-section.d.ts +27 -0
  36. package/dist/components/product-media-section.d.ts.map +1 -0
  37. package/dist/components/product-media-section.js +79 -0
  38. package/dist/components/product-option-dialog.d.ts +11 -0
  39. package/dist/components/product-option-dialog.d.ts.map +1 -0
  40. package/dist/components/product-option-dialog.js +13 -0
  41. package/dist/components/product-option-form.d.ts +17 -0
  42. package/dist/components/product-option-form.d.ts.map +1 -0
  43. package/dist/components/product-option-form.js +88 -0
  44. package/dist/components/product-options-section.d.ts +11 -0
  45. package/dist/components/product-options-section.d.ts.map +1 -0
  46. package/dist/components/product-options-section.js +87 -0
  47. package/dist/components/product-tag-dialog.d.ts +9 -0
  48. package/dist/components/product-tag-dialog.d.ts.map +1 -0
  49. package/dist/components/product-tag-dialog.js +13 -0
  50. package/dist/components/product-tag-form.d.ts +15 -0
  51. package/dist/components/product-tag-form.d.ts.map +1 -0
  52. package/dist/components/product-tag-form.js +44 -0
  53. package/dist/components/product-tag-list.d.ts +5 -0
  54. package/dist/components/product-tag-list.d.ts.map +1 -0
  55. package/dist/components/product-tag-list.js +40 -0
  56. package/dist/components/product-type-combobox.d.ts +9 -0
  57. package/dist/components/product-type-combobox.d.ts.map +1 -0
  58. package/dist/components/product-type-combobox.js +44 -0
  59. package/dist/components/product-version-dialog.d.ts +8 -0
  60. package/dist/components/product-version-dialog.d.ts.map +1 -0
  61. package/dist/components/product-version-dialog.js +37 -0
  62. package/dist/components/product-versions-section.d.ts +7 -0
  63. package/dist/components/product-versions-section.d.ts.map +1 -0
  64. package/dist/components/product-versions-section.js +14 -0
  65. package/dist/index.d.ts +22 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +21 -0
  68. package/package.json +68 -0
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useProductItineraryMutation } from "@voyantjs/products-react";
4
+ import { Button } from "@voyantjs/voyant-ui/components/button";
5
+ import { Checkbox } from "@voyantjs/voyant-ui/components/checkbox";
6
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@voyantjs/voyant-ui/components/dialog";
7
+ import { Input } from "@voyantjs/voyant-ui/components/input";
8
+ import { Label } from "@voyantjs/voyant-ui/components/label";
9
+ import { Loader2 } from "lucide-react";
10
+ import * as React from "react";
11
+ export function ProductItineraryDialog({ open, onOpenChange, productId, itinerary, itineraryCount, onSuccess, }) {
12
+ const isEditing = !!itinerary;
13
+ const isFirstItinerary = !isEditing && itineraryCount === 0;
14
+ const defaultLocked = isEditing && itinerary?.isDefault === true;
15
+ const [name, setName] = React.useState("");
16
+ const [isDefault, setIsDefault] = React.useState(false);
17
+ const [error, setError] = React.useState(null);
18
+ const { create, update } = useProductItineraryMutation();
19
+ const pending = create.isPending || update.isPending;
20
+ React.useEffect(() => {
21
+ if (!open)
22
+ return;
23
+ setError(null);
24
+ if (itinerary) {
25
+ setName(itinerary.name);
26
+ setIsDefault(itinerary.isDefault);
27
+ }
28
+ else {
29
+ setName("");
30
+ setIsDefault(isFirstItinerary);
31
+ }
32
+ }, [open, itinerary, isFirstItinerary]);
33
+ const handleSubmit = async (event) => {
34
+ event.preventDefault();
35
+ setError(null);
36
+ const trimmed = name.trim();
37
+ if (!trimmed) {
38
+ setError("Name is required");
39
+ return;
40
+ }
41
+ try {
42
+ if (itinerary) {
43
+ const patch = {};
44
+ if (trimmed !== itinerary.name)
45
+ patch.name = trimmed;
46
+ if (isDefault && !itinerary.isDefault)
47
+ patch.isDefault = true;
48
+ if (Object.keys(patch).length > 0) {
49
+ await update.mutateAsync({
50
+ productId,
51
+ itineraryId: itinerary.id,
52
+ input: patch,
53
+ });
54
+ }
55
+ onSuccess?.(itinerary.id);
56
+ }
57
+ else {
58
+ const created = await create.mutateAsync({
59
+ productId,
60
+ input: {
61
+ name: trimmed,
62
+ sortOrder: itineraryCount,
63
+ isDefault: itineraryCount === 0 ? true : isDefault,
64
+ },
65
+ });
66
+ onSuccess?.(created.id);
67
+ }
68
+ onOpenChange(false);
69
+ }
70
+ catch (submissionError) {
71
+ setError(submissionError instanceof Error ? submissionError.message : "Failed to save itinerary.");
72
+ }
73
+ };
74
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { "data-slot": "product-itinerary-dialog", className: "sm:max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEditing ? "Rename itinerary" : "New itinerary" }), _jsx(DialogDescription, { children: isEditing
75
+ ? "Update the itinerary name and default state."
76
+ : "Add another itinerary variant for this product." })] }), _jsxs("form", { className: "flex flex-col gap-4", onSubmit: handleSubmit, children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-itinerary-name", children: "Name" }), _jsx(Input, { id: "product-itinerary-name", autoFocus: true, value: name, onChange: (event) => setName(event.target.value), placeholder: "e.g. Main itinerary, Family variant" })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx(Checkbox, { id: "product-itinerary-default", checked: isDefault, disabled: defaultLocked || isFirstItinerary, onCheckedChange: (checked) => setIsDefault(checked === true) }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: "product-itinerary-default", className: "text-sm font-normal", children: "Set as default itinerary" }), defaultLocked ? (_jsx("p", { className: "text-xs text-muted-foreground", children: "This is the default. Set another itinerary as default to change it." })) : isFirstItinerary ? (_jsx("p", { className: "text-xs text-muted-foreground", children: "The first itinerary is automatically the default." })) : null] })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null, _jsxs("div", { className: "flex items-center justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => onOpenChange(false), children: "Cancel" }), _jsxs(Button, { type: "submit", disabled: pending, children: [pending ? _jsx(Loader2, { className: "mr-2 size-4 animate-spin", "aria-hidden": "true" }) : null, isEditing ? "Save changes" : "Create itinerary"] })] })] })] }) }));
77
+ }
@@ -0,0 +1,11 @@
1
+ import type { ProductMediaRecord } from "@voyantjs/products-react";
2
+ export interface ProductMediaDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ productId: string;
6
+ dayId?: string;
7
+ media?: ProductMediaRecord;
8
+ onSuccess?: (media: ProductMediaRecord) => void;
9
+ }
10
+ export declare function ProductMediaDialog({ open, onOpenChange, productId, dayId, media, onSuccess, }: ProductMediaDialogProps): import("react/jsx-runtime").JSX.Element;
11
+ //# sourceMappingURL=product-media-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-media-dialog.d.ts","sourceRoot":"","sources":["../../src/components/product-media-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAYlE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,kBAAkB,CAAA;IAC1B,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAA;CAChD;AAED,wBAAgB,kBAAkB,CAAC,EACjC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,KAAK,EACL,KAAK,EACL,SAAS,GACV,EAAE,uBAAuB,2CAyBzB"}
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@voyantjs/voyant-ui/components/dialog";
4
+ import { ProductMediaForm } from "./product-media-form";
5
+ export function ProductMediaDialog({ open, onOpenChange, productId, dayId, media, onSuccess, }) {
6
+ const isEdit = Boolean(media);
7
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { "data-slot": "product-media-dialog", className: "sm:max-w-[720px]", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEdit ? "Edit media" : "Add media" }), _jsx(DialogDescription, { children: isEdit
8
+ ? "Update metadata, sorting, and cover behavior for this media item."
9
+ : "Register a product or day-level media item by URL." })] }), _jsx(ProductMediaForm, { mode: media ? { kind: "edit", media } : { kind: "create", productId, dayId }, onSuccess: (savedMedia) => {
10
+ onSuccess?.(savedMedia);
11
+ onOpenChange(false);
12
+ }, onCancel: () => onOpenChange(false) })] }) }));
13
+ }
@@ -0,0 +1,17 @@
1
+ import { type ProductMediaRecord } from "@voyantjs/products-react";
2
+ type Mode = {
3
+ kind: "create";
4
+ productId: string;
5
+ dayId?: string;
6
+ } | {
7
+ kind: "edit";
8
+ media: ProductMediaRecord;
9
+ };
10
+ export interface ProductMediaFormProps {
11
+ mode: Mode;
12
+ onSuccess?: (media: ProductMediaRecord) => void;
13
+ onCancel?: () => void;
14
+ }
15
+ export declare function ProductMediaForm({ mode, onSuccess, onCancel }: ProductMediaFormProps): import("react/jsx-runtime").JSX.Element;
16
+ export {};
17
+ //# sourceMappingURL=product-media-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-media-form.d.ts","sourceRoot":"","sources":["../../src/components/product-media-form.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,kBAAkB,EAA2B,MAAM,0BAA0B,CAAA;AAkB3F,KAAK,IAAI,GACL;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,kBAAkB,CAAA;CAAE,CAAA;AAE/C,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,IAAI,CAAA;IACV,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAA;IAC/C,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAgDD,wBAAgB,gBAAgB,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,qBAAqB,2CAqLpF"}
@@ -0,0 +1,92 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useProductMediaMutation } from "@voyantjs/products-react";
4
+ import { Button } from "@voyantjs/voyant-ui/components/button";
5
+ import { Input } from "@voyantjs/voyant-ui/components/input";
6
+ import { Label } from "@voyantjs/voyant-ui/components/label";
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyantjs/voyant-ui/components/select";
8
+ import { Switch } from "@voyantjs/voyant-ui/components/switch";
9
+ import { Textarea } from "@voyantjs/voyant-ui/components/textarea";
10
+ import { Loader2 } from "lucide-react";
11
+ import * as React from "react";
12
+ const MEDIA_TYPES = [
13
+ { value: "image", label: "Image" },
14
+ { value: "video", label: "Video" },
15
+ { value: "document", label: "Document" },
16
+ ];
17
+ function initialState(mode) {
18
+ if (mode.kind === "edit") {
19
+ return {
20
+ mediaType: mode.media.mediaType,
21
+ name: mode.media.name,
22
+ url: mode.media.url,
23
+ storageKey: mode.media.storageKey ?? "",
24
+ mimeType: mode.media.mimeType ?? "",
25
+ fileSize: mode.media.fileSize == null ? "" : String(mode.media.fileSize),
26
+ altText: mode.media.altText ?? "",
27
+ sortOrder: String(mode.media.sortOrder),
28
+ isCover: mode.media.isCover,
29
+ };
30
+ }
31
+ return {
32
+ mediaType: "image",
33
+ name: "",
34
+ url: "",
35
+ storageKey: "",
36
+ mimeType: "",
37
+ fileSize: "",
38
+ altText: "",
39
+ sortOrder: "0",
40
+ isCover: false,
41
+ };
42
+ }
43
+ export function ProductMediaForm({ mode, onSuccess, onCancel }) {
44
+ const [state, setState] = React.useState(() => initialState(mode));
45
+ const [error, setError] = React.useState(null);
46
+ const { create, update } = useProductMediaMutation();
47
+ React.useEffect(() => {
48
+ setState(initialState(mode));
49
+ setError(null);
50
+ }, [mode]);
51
+ const isSubmitting = create.isPending || update.isPending;
52
+ const field = (key) => (value) => {
53
+ setState((previous) => ({ ...previous, [key]: value }));
54
+ };
55
+ const handleSubmit = async (event) => {
56
+ event.preventDefault();
57
+ setError(null);
58
+ if (!state.name.trim()) {
59
+ setError("Media name is required.");
60
+ return;
61
+ }
62
+ if (!state.url.trim()) {
63
+ setError("Media URL is required.");
64
+ return;
65
+ }
66
+ const payload = {
67
+ mediaType: state.mediaType,
68
+ name: state.name.trim(),
69
+ url: state.url.trim(),
70
+ storageKey: state.storageKey.trim() ? state.storageKey.trim() : null,
71
+ mimeType: state.mimeType.trim() ? state.mimeType.trim() : null,
72
+ fileSize: state.fileSize.trim() ? Number.parseInt(state.fileSize, 10) || 0 : null,
73
+ altText: state.altText.trim() ? state.altText.trim() : null,
74
+ sortOrder: Number.parseInt(state.sortOrder || "0", 10) || 0,
75
+ isCover: state.isCover,
76
+ };
77
+ try {
78
+ const media = mode.kind === "create"
79
+ ? await create.mutateAsync({
80
+ productId: mode.productId,
81
+ dayId: mode.dayId,
82
+ ...payload,
83
+ })
84
+ : await update.mutateAsync({ mediaId: mode.media.id, input: payload });
85
+ onSuccess?.(media);
86
+ }
87
+ catch (submissionError) {
88
+ setError(submissionError instanceof Error ? submissionError.message : "Failed to save media.");
89
+ }
90
+ };
91
+ return (_jsxs("form", { "data-slot": "product-media-form", onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: "Media type" }), _jsxs(Select, { items: MEDIA_TYPES, value: state.mediaType, onValueChange: (value) => field("mediaType")(value), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: MEDIA_TYPES.map((type) => (_jsx(SelectItem, { value: type.value, children: type.label }, type.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-media-name", children: "Name" }), _jsx(Input, { id: "product-media-name", autoFocus: true, required: true, value: state.name, onChange: (event) => field("name")(event.target.value), placeholder: "Hero image" })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-media-url", children: "URL" }), _jsx(Input, { id: "product-media-url", type: "url", required: true, value: state.url, onChange: (event) => field("url")(event.target.value), placeholder: "https://example.com/media/hero.jpg" })] }), _jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-media-storage-key", children: "Storage key" }), _jsx(Input, { id: "product-media-storage-key", value: state.storageKey, onChange: (event) => field("storageKey")(event.target.value) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-media-mime-type", children: "MIME type" }), _jsx(Input, { id: "product-media-mime-type", value: state.mimeType, onChange: (event) => field("mimeType")(event.target.value), placeholder: "image/jpeg" })] })] }), _jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-media-file-size", children: "File size" }), _jsx(Input, { id: "product-media-file-size", type: "number", min: "0", value: state.fileSize, onChange: (event) => field("fileSize")(event.target.value) })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-media-sort-order", children: "Sort order" }), _jsx(Input, { id: "product-media-sort-order", type: "number", value: state.sortOrder, onChange: (event) => field("sortOrder")(event.target.value) })] }), _jsxs("div", { className: "flex items-end gap-2 pb-2", children: [_jsx(Switch, { checked: state.isCover, onCheckedChange: (checked) => field("isCover")(checked) }), _jsx(Label, { children: "Cover media" })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-media-alt-text", children: "Alt text" }), _jsx(Textarea, { id: "product-media-alt-text", value: state.altText, onChange: (event) => field("altText")(event.target.value), placeholder: "Short accessibility description" })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null, _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", onClick: onCancel, disabled: isSubmitting, children: "Cancel" })) : null, _jsxs(Button, { type: "submit", disabled: isSubmitting, children: [isSubmitting ? (_jsx(Loader2, { className: "mr-2 size-4 animate-spin", "aria-hidden": "true" })) : null, mode.kind === "create" ? "Add media" : "Save media"] })] })] }));
92
+ }
@@ -0,0 +1,27 @@
1
+ import { type ProductMediaRecord } from "@voyantjs/products-react";
2
+ export interface ProductMediaUploadResult {
3
+ url: string;
4
+ name?: string;
5
+ mediaType?: ProductMediaRecord["mediaType"];
6
+ storageKey?: string | null;
7
+ mimeType?: string | null;
8
+ fileSize?: number | null;
9
+ altText?: string | null;
10
+ sortOrder?: number;
11
+ isCover?: boolean;
12
+ }
13
+ export type ProductMediaUploadHandler = (file: File, context: {
14
+ productId: string;
15
+ dayId?: string;
16
+ }) => Promise<ProductMediaUploadResult>;
17
+ export interface ProductMediaSectionProps {
18
+ productId: string;
19
+ dayId?: string;
20
+ title?: string;
21
+ description?: string;
22
+ compact?: boolean;
23
+ uploadMedia?: ProductMediaUploadHandler;
24
+ uploadAccept?: string;
25
+ }
26
+ export declare function ProductMediaSection({ productId, dayId, title, description, compact, uploadMedia, uploadAccept, }: ProductMediaSectionProps): import("react/jsx-runtime").JSX.Element;
27
+ //# sourceMappingURL=product-media-section.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-media-section.d.ts","sourceRoot":"","sources":["../../src/components/product-media-section.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,kBAAkB,EAGxB,MAAM,0BAA0B,CAAA;AAuBjC,MAAM,WAAW,wBAAwB;IACvC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,kBAAkB,CAAC,WAAW,CAAC,CAAA;IAC3C,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,MAAM,yBAAyB,GAAG,CACtC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,KAC3C,OAAO,CAAC,wBAAwB,CAAC,CAAA;AAEtC,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,WAAW,CAAC,EAAE,yBAAyB,CAAA;IACvC,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,wBAAgB,mBAAmB,CAAC,EAClC,SAAS,EACT,KAAK,EACL,KAAqC,EACrC,WAE4D,EAC5D,OAAe,EACf,WAAW,EACX,YAAgD,GACjD,EAAE,wBAAwB,2CA6O1B"}
@@ -0,0 +1,79 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useProductMedia, useProductMediaMutation, } from "@voyantjs/products-react";
4
+ import { Badge } from "@voyantjs/voyant-ui/components/badge";
5
+ import { Button } from "@voyantjs/voyant-ui/components/button";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@voyantjs/voyant-ui/components/card";
7
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@voyantjs/voyant-ui/components/table";
8
+ import { ImageIcon, Loader2, Pencil, Plus, Star, Trash2, Upload } from "lucide-react";
9
+ import * as React from "react";
10
+ import { ProductMediaDialog } from "./product-media-dialog";
11
+ export function ProductMediaSection({ productId, dayId, title = dayId ? "Day media" : "Media", description = dayId
12
+ ? "Manage media attached to this itinerary day."
13
+ : "Manage product-level media assets and cover selection.", compact = false, uploadMedia, uploadAccept = "image/*,video/*,application/pdf", }) {
14
+ const [dialogOpen, setDialogOpen] = React.useState(false);
15
+ const [editingMedia, setEditingMedia] = React.useState();
16
+ const [isUploading, setIsUploading] = React.useState(false);
17
+ const [uploadError, setUploadError] = React.useState(null);
18
+ const fileInputRef = React.useRef(null);
19
+ const { data, isPending, isError } = useProductMedia(productId, { dayId, limit: 100 });
20
+ const { create, remove, setCover } = useProductMediaMutation();
21
+ const media = React.useMemo(() => (data?.data ?? [])
22
+ .slice()
23
+ .sort((left, right) => Number(right.isCover) - Number(left.isCover) || left.sortOrder - right.sortOrder), [data?.data]);
24
+ const header = (_jsxs("div", { className: "space-y-1", children: [_jsx(CardTitle, { className: compact ? "text-base" : undefined, children: title }), _jsx(CardDescription, { children: description })] }));
25
+ const handleUpload = async (file) => {
26
+ if (!uploadMedia)
27
+ return;
28
+ setUploadError(null);
29
+ setIsUploading(true);
30
+ try {
31
+ const uploaded = await uploadMedia(file, { productId, dayId });
32
+ const mimeType = uploaded.mimeType?.trim() || file.type || null;
33
+ const inferredMediaType = uploaded.mediaType ??
34
+ (mimeType?.startsWith("video/")
35
+ ? "video"
36
+ : mimeType?.startsWith("image/")
37
+ ? "image"
38
+ : "document");
39
+ await create.mutateAsync({
40
+ productId,
41
+ dayId,
42
+ mediaType: inferredMediaType,
43
+ name: uploaded.name?.trim() || file.name,
44
+ url: uploaded.url,
45
+ storageKey: uploaded.storageKey ?? null,
46
+ mimeType,
47
+ fileSize: uploaded.fileSize ?? (file.size || null),
48
+ altText: uploaded.altText ?? null,
49
+ sortOrder: uploaded.sortOrder ?? media.length,
50
+ isCover: uploaded.isCover ?? media.length === 0,
51
+ });
52
+ }
53
+ catch (error) {
54
+ setUploadError(error instanceof Error ? error.message : "Failed to upload media.");
55
+ }
56
+ finally {
57
+ setIsUploading(false);
58
+ }
59
+ };
60
+ const actions = (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [uploadMedia ? (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: compact ? "outline" : "secondary", disabled: isUploading, onClick: () => fileInputRef.current?.click(), children: [isUploading ? (_jsx(Loader2, { className: "mr-2 size-4 animate-spin", "aria-hidden": "true" })) : (_jsx(Upload, { className: "mr-2 size-4", "aria-hidden": "true" })), "Upload"] }), _jsx("input", { ref: fileInputRef, type: "file", accept: uploadAccept, className: "hidden", onChange: (event) => {
61
+ const file = event.target.files?.[0];
62
+ if (file) {
63
+ void handleUpload(file);
64
+ event.target.value = "";
65
+ }
66
+ } })] })) : null, _jsxs(Button, { onClick: () => {
67
+ setEditingMedia(undefined);
68
+ setDialogOpen(true);
69
+ }, children: [_jsx(Plus, { className: "mr-2 size-4", "aria-hidden": "true" }), "Add media"] })] }));
70
+ const body = (_jsxs(_Fragment, { children: [uploadError ? _jsx("p", { className: "text-sm text-destructive", children: uploadError }) : null, isPending ? (_jsx("div", { className: "flex min-h-24 items-center justify-center", children: _jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" }) })) : isError ? (_jsx("p", { className: "text-sm text-destructive", children: "Failed to load media." })) : media.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No media items configured yet." })) : (_jsx("div", { className: "rounded-md border", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Name" }), _jsx(TableHead, { children: "Type" }), _jsx(TableHead, { children: "URL" }), _jsx(TableHead, { children: "Sort" }), _jsx(TableHead, { className: "w-32" })] }) }), _jsx(TableBody, { children: media.map((item) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ImageIcon, { className: "size-4 text-muted-foreground", "aria-hidden": "true" }), _jsxs("div", { children: [_jsx("div", { className: "font-medium", children: item.name }), item.altText ? (_jsx("div", { className: "text-xs text-muted-foreground", children: item.altText })) : null] }), item.isCover ? _jsx(Badge, { children: "Cover" }) : null] }) }), _jsx(TableCell, { children: _jsx(Badge, { variant: "outline", className: "capitalize", children: item.mediaType }) }), _jsx(TableCell, { className: "max-w-[320px]", children: _jsx("a", { href: item.url, target: "_blank", rel: "noreferrer", className: "truncate text-sm text-primary underline-offset-4 hover:underline", children: item.url }) }), _jsx(TableCell, { children: item.sortOrder }), _jsx(TableCell, { children: _jsxs("div", { className: "flex items-center justify-end gap-1", children: [!item.isCover ? (_jsx(Button, { variant: "ghost", size: "icon-sm", onClick: () => setCover.mutate(item.id), children: _jsx(Star, { className: "size-4", "aria-hidden": "true" }) })) : null, _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: () => {
71
+ setEditingMedia(item);
72
+ setDialogOpen(true);
73
+ }, children: _jsx(Pencil, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: () => {
74
+ if (confirm("Delete this media item?")) {
75
+ remove.mutate(item.id);
76
+ }
77
+ }, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] }) })] }, item.id))) })] }) }))] }));
78
+ return (_jsxs(_Fragment, { children: [compact ? (_jsxs("div", { "data-slot": "product-media-section", className: "flex flex-col gap-3 rounded-md border bg-background p-3", children: [_jsxs("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [header, actions] }), body] })) : (_jsxs(Card, { "data-slot": "product-media-section", children: [_jsxs(CardHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [header, actions] }), _jsx(CardContent, { children: body })] })), _jsx(ProductMediaDialog, { open: dialogOpen, onOpenChange: setDialogOpen, productId: productId, dayId: dayId, media: editingMedia, onSuccess: () => setEditingMedia(undefined) })] }));
79
+ }
@@ -0,0 +1,11 @@
1
+ import type { ProductOptionRecord } from "@voyantjs/products-react";
2
+ export interface ProductOptionDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ productId: string;
6
+ option?: ProductOptionRecord;
7
+ sortOrder?: number;
8
+ onSuccess?: (option: ProductOptionRecord) => void;
9
+ }
10
+ export declare function ProductOptionDialog({ open, onOpenChange, productId, option, sortOrder, onSuccess, }: ProductOptionDialogProps): import("react/jsx-runtime").JSX.Element;
11
+ //# sourceMappingURL=product-option-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-option-dialog.d.ts","sourceRoot":"","sources":["../../src/components/product-option-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAYnE,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,mBAAmB,CAAA;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,CAAA;CAClD;AAED,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,MAAM,EACN,SAAS,EACT,SAAS,GACV,EAAE,wBAAwB,2CAyB1B"}
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@voyantjs/voyant-ui/components/dialog";
4
+ import { ProductOptionForm } from "./product-option-form";
5
+ export function ProductOptionDialog({ open, onOpenChange, productId, option, sortOrder, onSuccess, }) {
6
+ const isEdit = Boolean(option);
7
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { "data-slot": "product-option-dialog", className: "sm:max-w-[640px]", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEdit ? "Edit option" : "New option" }), _jsx(DialogDescription, { children: isEdit
8
+ ? "Update option availability, ordering, and default behavior."
9
+ : "Create a reusable option under this product." })] }), _jsx(ProductOptionForm, { mode: option ? { kind: "edit", option } : { kind: "create", productId, sortOrder }, onSuccess: (saved) => {
10
+ onSuccess?.(saved);
11
+ onOpenChange(false);
12
+ }, onCancel: () => onOpenChange(false) })] }) }));
13
+ }
@@ -0,0 +1,17 @@
1
+ import { type ProductOptionRecord } from "@voyantjs/products-react";
2
+ type Mode = {
3
+ kind: "create";
4
+ productId: string;
5
+ sortOrder?: number;
6
+ } | {
7
+ kind: "edit";
8
+ option: ProductOptionRecord;
9
+ };
10
+ export interface ProductOptionFormProps {
11
+ mode: Mode;
12
+ onSuccess?: (option: ProductOptionRecord) => void;
13
+ onCancel?: () => void;
14
+ }
15
+ export declare function ProductOptionForm({ mode, onSuccess, onCancel }: ProductOptionFormProps): import("react/jsx-runtime").JSX.Element;
16
+ export {};
17
+ //# sourceMappingURL=product-option-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-option-form.d.ts","sourceRoot":"","sources":["../../src/components/product-option-form.tsx"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,0BAA0B,CAAA;AAiBjC,KAAK,IAAI,GACL;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAA;CAAE,CAAA;AAEjD,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,IAAI,CAAA;IACV,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,CAAA;IACjD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AA+DD,wBAAgB,iBAAiB,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CAsJtF"}
@@ -0,0 +1,88 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useProductOptionMutation, } from "@voyantjs/products-react";
4
+ import { Button } from "@voyantjs/voyant-ui/components/button";
5
+ import { DatePicker } from "@voyantjs/voyant-ui/components/date-picker";
6
+ import { Input } from "@voyantjs/voyant-ui/components/input";
7
+ import { Label } from "@voyantjs/voyant-ui/components/label";
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyantjs/voyant-ui/components/select";
9
+ import { Switch } from "@voyantjs/voyant-ui/components/switch";
10
+ import { Textarea } from "@voyantjs/voyant-ui/components/textarea";
11
+ import { Loader2 } from "lucide-react";
12
+ import * as React from "react";
13
+ const OPTION_STATUSES = [
14
+ { value: "draft", label: "Draft" },
15
+ { value: "active", label: "Active" },
16
+ { value: "archived", label: "Archived" },
17
+ ];
18
+ function initialState(mode) {
19
+ if (mode.kind === "edit") {
20
+ return {
21
+ name: mode.option.name,
22
+ code: mode.option.code ?? "",
23
+ description: mode.option.description ?? "",
24
+ status: mode.option.status,
25
+ isDefault: mode.option.isDefault,
26
+ sortOrder: String(mode.option.sortOrder),
27
+ availableFrom: mode.option.availableFrom ?? "",
28
+ availableTo: mode.option.availableTo ?? "",
29
+ };
30
+ }
31
+ return {
32
+ name: "",
33
+ code: "",
34
+ description: "",
35
+ status: "draft",
36
+ isDefault: false,
37
+ sortOrder: String(mode.sortOrder ?? 0),
38
+ availableFrom: "",
39
+ availableTo: "",
40
+ };
41
+ }
42
+ function toOptionalString(value) {
43
+ const trimmed = value.trim();
44
+ return trimmed ? trimmed : null;
45
+ }
46
+ function toPayload(state) {
47
+ return {
48
+ name: state.name.trim(),
49
+ code: toOptionalString(state.code),
50
+ description: toOptionalString(state.description),
51
+ status: state.status,
52
+ isDefault: state.isDefault,
53
+ sortOrder: Number.parseInt(state.sortOrder || "0", 10) || 0,
54
+ availableFrom: toOptionalString(state.availableFrom),
55
+ availableTo: toOptionalString(state.availableTo),
56
+ };
57
+ }
58
+ export function ProductOptionForm({ mode, onSuccess, onCancel }) {
59
+ const [state, setState] = React.useState(() => initialState(mode));
60
+ const [error, setError] = React.useState(null);
61
+ const { create, update } = useProductOptionMutation();
62
+ React.useEffect(() => {
63
+ setState(initialState(mode));
64
+ setError(null);
65
+ }, [mode]);
66
+ const isSubmitting = create.isPending || update.isPending;
67
+ const field = (key) => (value) => {
68
+ setState((prev) => ({ ...prev, [key]: value }));
69
+ };
70
+ const handleSubmit = async (event) => {
71
+ event.preventDefault();
72
+ setError(null);
73
+ if (!state.name.trim()) {
74
+ setError("Option name is required.");
75
+ return;
76
+ }
77
+ try {
78
+ const option = mode.kind === "create"
79
+ ? await create.mutateAsync({ productId: mode.productId, ...toPayload(state) })
80
+ : await update.mutateAsync({ id: mode.option.id, input: toPayload(state) });
81
+ onSuccess?.(option);
82
+ }
83
+ catch (err) {
84
+ setError(err instanceof Error ? err.message : "Failed to save product option.");
85
+ }
86
+ };
87
+ return (_jsxs("form", { "data-slot": "product-option-form", onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-option-name", children: "Name" }), _jsx(Input, { id: "product-option-name", required: true, autoFocus: true, value: state.name, onChange: (event) => field("name")(event.target.value), placeholder: "Single room" })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-option-code", children: "Code" }), _jsx(Input, { id: "product-option-code", value: state.code, onChange: (event) => field("code")(event.target.value), placeholder: "single-room" })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-option-description", children: "Description" }), _jsx(Textarea, { id: "product-option-description", value: state.description, onChange: (event) => field("description")(event.target.value), placeholder: "Optional option description" })] }), _jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { children: "Status" }), _jsxs(Select, { value: state.status, onValueChange: (value) => value && field("status")(value), items: OPTION_STATUSES, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: OPTION_STATUSES.map((status) => (_jsx(SelectItem, { value: status.value, children: status.label }, status.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-option-sort-order", children: "Sort order" }), _jsx(Input, { id: "product-option-sort-order", type: "number", value: state.sortOrder, onChange: (event) => field("sortOrder")(event.target.value) })] })] }), _jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-option-available-from", children: "Available from" }), _jsx(DatePicker, { value: state.availableFrom || null, onChange: (next) => field("availableFrom")(next ?? ""), placeholder: "Select start date", className: "w-full" })] }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "product-option-available-to", children: "Available to" }), _jsx(DatePicker, { value: state.availableTo || null, onChange: (next) => field("availableTo")(next ?? ""), placeholder: "Select end date", className: "w-full" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: state.isDefault, onCheckedChange: (checked) => field("isDefault")(checked) }), _jsx(Label, { htmlFor: "product-option-default", children: "Default option" })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null, _jsxs("div", { className: "flex items-center justify-end gap-2", children: [onCancel ? (_jsx(Button, { type: "button", variant: "ghost", onClick: onCancel, disabled: isSubmitting, children: "Cancel" })) : null, _jsxs(Button, { type: "submit", disabled: isSubmitting, children: [isSubmitting ? (_jsx(Loader2, { className: "mr-2 size-4 animate-spin", "aria-hidden": "true" })) : null, mode.kind === "create" ? "Create option" : "Save changes"] })] })] }));
88
+ }
@@ -0,0 +1,11 @@
1
+ import { type ProductOptionRecord } from "@voyantjs/products-react";
2
+ import * as React from "react";
3
+ export interface ProductOptionsSectionProps {
4
+ productId: string;
5
+ pageSize?: number;
6
+ title?: string;
7
+ description?: string;
8
+ renderOptionDetails?: (option: ProductOptionRecord) => React.ReactNode;
9
+ }
10
+ export declare function ProductOptionsSection({ productId, pageSize, title, description, renderOptionDetails, }: ProductOptionsSectionProps): import("react/jsx-runtime").JSX.Element;
11
+ //# sourceMappingURL=product-options-section.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-options-section.d.ts","sourceRoot":"","sources":["../../src/components/product-options-section.tsx"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,mBAAmB,EAMzB,MAAM,0BAA0B,CAAA;AAmBjC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAmB9B,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,KAAK,CAAC,SAAS,CAAA;CACvE;AAED,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,QAAc,EACd,KAA2B,EAC3B,WAAiF,EACjF,mBAAmB,GACpB,EAAE,0BAA0B,2CAqG5B"}
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useDuplicateOptionPricingMutation } from "@voyantjs/pricing-react";
4
+ import { useDuplicateProductOptionMutation, useOptionUnitMutation, useOptionUnits, useProductOptionMutation, useProductOptions, } from "@voyantjs/products-react";
5
+ import { Badge } from "@voyantjs/voyant-ui/components/badge";
6
+ import { Button } from "@voyantjs/voyant-ui/components/button";
7
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@voyantjs/voyant-ui/components/card";
8
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@voyantjs/voyant-ui/components/table";
9
+ import { ChevronDown, ChevronRight, Copy, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
10
+ import * as React from "react";
11
+ import { OptionUnitDialog } from "./option-unit-dialog";
12
+ import { ProductOptionDialog } from "./product-option-dialog";
13
+ const optionStatusVariant = {
14
+ draft: "outline",
15
+ active: "default",
16
+ archived: "secondary",
17
+ };
18
+ function formatRange(min, max) {
19
+ if (min == null && max == null) {
20
+ return "—";
21
+ }
22
+ return `${min ?? 0}–${max ?? "∞"}`;
23
+ }
24
+ export function ProductOptionsSection({ productId, pageSize = 100, title = "Options and units", description = "Manage option variants and the units available under each option.", renderOptionDetails, }) {
25
+ const [expandedOptionId, setExpandedOptionId] = React.useState(null);
26
+ const [dialogOpen, setDialogOpen] = React.useState(false);
27
+ const [editingOption, setEditingOption] = React.useState(undefined);
28
+ const { data, isPending, isError } = useProductOptions({
29
+ productId,
30
+ limit: pageSize,
31
+ });
32
+ const { remove } = useProductOptionMutation();
33
+ const duplicateOption = useDuplicateProductOptionMutation();
34
+ const duplicatePricing = useDuplicateOptionPricingMutation();
35
+ const options = React.useMemo(() => (data?.data ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder), [data?.data]);
36
+ const nextSortOrder = options.length > 0 ? Math.max(...options.map((option) => option.sortOrder)) + 1 : 0;
37
+ return (_jsxs(Card, { "data-slot": "product-options-section", children: [_jsxs(CardHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(CardTitle, { children: title }), _jsx(CardDescription, { children: description })] }), _jsxs(Button, { onClick: () => {
38
+ setEditingOption(undefined);
39
+ setDialogOpen(true);
40
+ }, children: [_jsx(Plus, { className: "mr-2 size-4", "aria-hidden": "true" }), "Add option"] })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [isPending ? (_jsx("div", { className: "flex min-h-24 items-center justify-center", children: _jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" }) })) : isError ? (_jsx("p", { className: "text-sm text-destructive", children: "Failed to load product options." })) : options.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No options configured for this product." })) : (options.map((option) => (_jsx(OptionRow, { option: option, expanded: expandedOptionId === option.id, onToggle: () => setExpandedOptionId((current) => (current === option.id ? null : option.id)), onEdit: () => {
41
+ setEditingOption(option);
42
+ setDialogOpen(true);
43
+ }, onDuplicate: () => {
44
+ duplicateOption.mutate({ sourceOptionId: option.id, productId }, {
45
+ onSuccess: async ({ option: duplicatedOption, unitIdMap }) => {
46
+ await duplicatePricing.mutateAsync({
47
+ sourceOptionId: option.id,
48
+ targetOptionId: duplicatedOption.id,
49
+ productId,
50
+ unitIdMap,
51
+ });
52
+ },
53
+ });
54
+ }, onDelete: () => {
55
+ if (confirm(`Delete option "${option.name}" and all its units?`)) {
56
+ remove.mutate(option.id);
57
+ }
58
+ }, children: renderOptionDetails?.(option) }, option.id)))), _jsx(ProductOptionDialog, { open: dialogOpen, onOpenChange: setDialogOpen, productId: productId, option: editingOption, sortOrder: nextSortOrder, onSuccess: () => {
59
+ setDialogOpen(false);
60
+ setEditingOption(undefined);
61
+ } })] })] }));
62
+ }
63
+ function OptionRow({ option, expanded, onToggle, onEdit, onDuplicate, onDelete, children, }) {
64
+ return (_jsxs("div", { className: "rounded-md border", children: [_jsxs("div", { className: "flex items-center gap-3 p-3", children: [_jsx("button", { type: "button", onClick: onToggle, className: "text-muted-foreground transition-colors hover:text-foreground", children: expanded ? _jsx(ChevronDown, { className: "size-4" }) : _jsx(ChevronRight, { className: "size-4" }) }), _jsxs("div", { className: "flex flex-1 flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: option.name }), option.code ? (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: option.code })) : null, _jsx(Badge, { variant: optionStatusVariant[option.status] ?? "outline", className: "capitalize", children: option.status }), option.isDefault ? _jsx(Badge, { variant: "secondary", children: "Default" }) : null] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDuplicate, children: _jsx(Copy, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onEdit, children: _jsx(Pencil, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onDelete, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] })] }), expanded ? (_jsxs("div", { className: "flex flex-col gap-4 border-t bg-muted/30 p-3", children: [_jsx(UnitsPanel, { optionId: option.id }), children] })) : null] }));
65
+ }
66
+ function UnitsPanel({ optionId }) {
67
+ const [dialogOpen, setDialogOpen] = React.useState(false);
68
+ const [editingUnit, setEditingUnit] = React.useState(undefined);
69
+ const { data, isPending, isError } = useOptionUnits({ optionId, limit: 100 });
70
+ const { remove } = useOptionUnitMutation();
71
+ const units = React.useMemo(() => (data?.data ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder), [data?.data]);
72
+ const nextSortOrder = units.length > 0 ? Math.max(...units.map((unit) => unit.sortOrder)) + 1 : 0;
73
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("p", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: "Units" }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Configure the selectable units that belong to this option." })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => {
74
+ setEditingUnit(undefined);
75
+ setDialogOpen(true);
76
+ }, children: [_jsx(Plus, { className: "mr-2 size-3.5", "aria-hidden": "true" }), "Add unit"] })] }), isPending ? (_jsx("div", { className: "flex min-h-20 items-center justify-center rounded-md border bg-background", children: _jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" }) })) : isError ? (_jsx("p", { className: "text-sm text-destructive", children: "Failed to load option units." })) : units.length === 0 ? (_jsx("p", { className: "rounded-md border bg-background px-3 py-4 text-sm text-muted-foreground", children: "No units configured for this option." })) : (_jsx("div", { className: "rounded-md border bg-background", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Type" }), _jsx(TableHead, { children: "Name" }), _jsx(TableHead, { children: "Quantity" }), _jsx(TableHead, { children: "Age" }), _jsx(TableHead, { children: "Occupancy" }), _jsx(TableHead, { className: "w-[88px] text-right", children: "Actions" })] }) }), _jsx(TableBody, { children: units.map((unit) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Badge, { variant: "outline", className: "capitalize", children: unit.unitType }) }), _jsxs(TableCell, { children: [_jsx("div", { className: "font-medium", children: unit.name }), unit.code ? (_jsx("div", { className: "font-mono text-xs text-muted-foreground", children: unit.code })) : null] }), _jsx(TableCell, { className: "font-mono text-xs", children: formatRange(unit.minQuantity, unit.maxQuantity) }), _jsx(TableCell, { className: "font-mono text-xs", children: formatRange(unit.minAge, unit.maxAge) }), _jsx(TableCell, { className: "font-mono text-xs", children: formatRange(unit.occupancyMin, unit.occupancyMax) }), _jsx(TableCell, { className: "text-right", children: _jsxs("div", { className: "flex items-center justify-end gap-1", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onClick: () => {
77
+ setEditingUnit(unit);
78
+ setDialogOpen(true);
79
+ }, children: _jsx(Pencil, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: () => {
80
+ if (confirm(`Delete unit "${unit.name}"?`)) {
81
+ remove.mutate(unit.id);
82
+ }
83
+ }, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] }) })] }, unit.id))) })] }) })), _jsx(OptionUnitDialog, { open: dialogOpen, onOpenChange: setDialogOpen, optionId: optionId, unit: editingUnit, sortOrder: nextSortOrder, onSuccess: () => {
84
+ setDialogOpen(false);
85
+ setEditingUnit(undefined);
86
+ } })] }));
87
+ }
@@ -0,0 +1,9 @@
1
+ import type { ProductTagRecord } from "@voyantjs/products-react";
2
+ export interface ProductTagDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ tag?: ProductTagRecord;
6
+ onSuccess?: (tag: ProductTagRecord) => void;
7
+ }
8
+ export declare function ProductTagDialog({ open, onOpenChange, tag, onSuccess }: ProductTagDialogProps): import("react/jsx-runtime").JSX.Element;
9
+ //# sourceMappingURL=product-tag-dialog.d.ts.map