@voyantjs/promotions-ui 0.29.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.
@@ -0,0 +1,3 @@
1
+ export { PromotionDialog, type PromotionDialogProps } from "./promotion-dialog.js";
2
+ export { loadPromotionsPage, PromotionsPage } from "./promotions-page.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AAClF,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { PromotionDialog } from "./promotion-dialog.js";
2
+ export { loadPromotionsPage, PromotionsPage } from "./promotions-page.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Create / edit dialog for a promotional offer.
3
+ *
4
+ * The discriminated-union scope picker is the trickiest piece here:
5
+ * a `kind` dropdown plus per-kind sub-fields rendered conditionally.
6
+ * Comma-separated text input for ID lists (productIds, marketIds, etc.)
7
+ * keeps the v1 UX simple — a future PR can swap in proper combobox-with-
8
+ * search components per scope kind.
9
+ */
10
+ import { type PromotionalOfferRecord } from "@voyantjs/promotions-react";
11
+ export interface PromotionDialogProps {
12
+ open: boolean;
13
+ onOpenChange: (open: boolean) => void;
14
+ /** When provided, the dialog opens in edit mode. */
15
+ offer?: PromotionalOfferRecord;
16
+ }
17
+ export declare function PromotionDialog({ open, onOpenChange, offer }: PromotionDialogProps): import("react/jsx-runtime").JSX.Element;
18
+ //# sourceMappingURL=promotion-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promotion-dialog.d.ts","sourceRoot":"","sources":["../src/promotion-dialog.tsx"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,OAAO,EACL,KAAK,sBAAsB,EAM5B,MAAM,4BAA4B,CAAA;AAqBnC,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,oDAAoD;IACpD,KAAK,CAAC,EAAE,sBAAsB,CAAA;CAC/B;AAuKD,wBAAgB,eAAe,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,EAAE,oBAAoB,2CAsSlF"}
@@ -0,0 +1,230 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * Create / edit dialog for a promotional offer.
5
+ *
6
+ * The discriminated-union scope picker is the trickiest piece here:
7
+ * a `kind` dropdown plus per-kind sub-fields rendered conditionally.
8
+ * Comma-separated text input for ID lists (productIds, marketIds, etc.)
9
+ * keeps the v1 UX simple — a future PR can swap in proper combobox-with-
10
+ * search components per scope kind.
11
+ */
12
+ import { promotionalOfferScopeSchema, useCreatePromotion, useUpdatePromotion, } from "@voyantjs/promotions-react";
13
+ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
14
+ import { useEffect, useState } from "react";
15
+ const SCOPE_KINDS = [
16
+ "global",
17
+ "products",
18
+ "categories",
19
+ "destinations",
20
+ "markets",
21
+ "audiences",
22
+ ];
23
+ const AUDIENCE_OPTIONS = [
24
+ "staff",
25
+ "customer",
26
+ "partner",
27
+ "supplier",
28
+ ];
29
+ function emptyForm() {
30
+ return {
31
+ name: "",
32
+ slug: "",
33
+ description: "",
34
+ discountType: "percentage",
35
+ discountPercent: "",
36
+ discountAmountCents: "",
37
+ currency: "USD",
38
+ scopeKind: "global",
39
+ scopeIds: "",
40
+ scopeAudiences: ["customer"],
41
+ minPax: "",
42
+ validFrom: "",
43
+ validUntil: "",
44
+ code: "",
45
+ stackable: false,
46
+ active: true,
47
+ };
48
+ }
49
+ function offerToForm(offer) {
50
+ const base = emptyForm();
51
+ base.name = offer.name;
52
+ base.slug = offer.slug;
53
+ base.description = offer.description ?? "";
54
+ base.discountType = offer.discountType;
55
+ base.discountPercent = offer.discountPercent ?? "";
56
+ base.discountAmountCents =
57
+ offer.discountAmountCents != null ? String(offer.discountAmountCents) : "";
58
+ base.currency = offer.currency ?? "USD";
59
+ base.scopeKind = offer.scope.kind;
60
+ base.scopeIds = scopeIdsToString(offer.scope);
61
+ base.scopeAudiences = offer.scope.kind === "audiences" ? [...offer.scope.audiences] : ["customer"];
62
+ base.minPax = offer.conditions.minPax != null ? String(offer.conditions.minPax) : "";
63
+ base.validFrom = offer.validFrom ? toDateInputValue(offer.validFrom) : "";
64
+ base.validUntil = offer.validUntil ? toDateInputValue(offer.validUntil) : "";
65
+ base.code = offer.code ?? "";
66
+ base.stackable = offer.stackable;
67
+ base.active = offer.active;
68
+ return base;
69
+ }
70
+ function scopeIdsToString(scope) {
71
+ switch (scope.kind) {
72
+ case "products":
73
+ return scope.productIds.join(", ");
74
+ case "categories":
75
+ return scope.categoryIds.join(", ");
76
+ case "destinations":
77
+ return scope.destinationIds.join(", ");
78
+ case "markets":
79
+ return scope.marketIds.join(", ");
80
+ default:
81
+ return "";
82
+ }
83
+ }
84
+ function toDateInputValue(iso) {
85
+ // <input type="datetime-local"> wants `YYYY-MM-DDTHH:mm` (no timezone).
86
+ return iso.slice(0, 16);
87
+ }
88
+ function buildScope(state) {
89
+ switch (state.scopeKind) {
90
+ case "global":
91
+ return { kind: "global" };
92
+ case "products":
93
+ return { kind: "products", productIds: parseIds(state.scopeIds) };
94
+ case "categories":
95
+ return { kind: "categories", categoryIds: parseIds(state.scopeIds) };
96
+ case "destinations":
97
+ return { kind: "destinations", destinationIds: parseIds(state.scopeIds) };
98
+ case "markets":
99
+ return { kind: "markets", marketIds: parseIds(state.scopeIds) };
100
+ case "audiences":
101
+ return { kind: "audiences", audiences: state.scopeAudiences };
102
+ }
103
+ }
104
+ function parseIds(raw) {
105
+ return raw
106
+ .split(/[,\s]+/)
107
+ .map((s) => s.trim())
108
+ .filter((s) => s.length > 0);
109
+ }
110
+ function buildPayload(state) {
111
+ if (!state.name.trim())
112
+ return { error: "Name is required" };
113
+ if (!state.slug.trim())
114
+ return { error: "Slug is required" };
115
+ if (state.discountType === "percentage" && !state.discountPercent) {
116
+ return { error: "Discount percent is required for percentage offers" };
117
+ }
118
+ if (state.discountType === "fixed_amount") {
119
+ if (!state.discountAmountCents) {
120
+ return { error: "Discount amount is required for fixed-amount offers" };
121
+ }
122
+ if (!state.currency.trim())
123
+ return { error: "Currency is required for fixed-amount offers" };
124
+ }
125
+ // Validate scope shape via Zod so the user gets clear errors when (e.g.)
126
+ // a products scope has an empty ID list.
127
+ const scope = buildScope(state);
128
+ const scopeResult = promotionalOfferScopeSchema.safeParse(scope);
129
+ if (!scopeResult.success) {
130
+ return { error: `Scope: ${scopeResult.error.issues[0]?.message ?? "invalid"}` };
131
+ }
132
+ const payload = {
133
+ name: state.name.trim(),
134
+ slug: state.slug.trim(),
135
+ description: state.description.trim() || null,
136
+ discountType: state.discountType,
137
+ discountPercent: state.discountType === "percentage" ? Number(state.discountPercent) : null,
138
+ discountAmountCents: state.discountType === "fixed_amount" ? Number(state.discountAmountCents) : null,
139
+ currency: state.discountType === "fixed_amount" ? state.currency.trim().toUpperCase() : null,
140
+ scope,
141
+ conditions: state.minPax ? { minPax: Number(state.minPax) } : {},
142
+ validFrom: state.validFrom ? new Date(state.validFrom).toISOString() : null,
143
+ validUntil: state.validUntil ? new Date(state.validUntil).toISOString() : null,
144
+ code: state.code.trim() || null,
145
+ stackable: state.stackable,
146
+ active: state.active,
147
+ };
148
+ return payload;
149
+ }
150
+ export function PromotionDialog({ open, onOpenChange, offer }) {
151
+ const [state, setState] = useState(emptyForm());
152
+ const [error, setError] = useState(null);
153
+ const createMutation = useCreatePromotion();
154
+ const updateMutation = useUpdatePromotion();
155
+ const isEdit = offer != null;
156
+ const isPending = createMutation.isPending || updateMutation.isPending;
157
+ // Re-seed form whenever the dialog opens with a different offer.
158
+ useEffect(() => {
159
+ if (!open)
160
+ return;
161
+ setError(null);
162
+ setState(offer ? offerToForm(offer) : emptyForm());
163
+ }, [open, offer]);
164
+ function setField(key, value) {
165
+ setState((prev) => ({ ...prev, [key]: value }));
166
+ }
167
+ async function handleSave() {
168
+ setError(null);
169
+ const result = buildPayload(state);
170
+ if ("error" in result) {
171
+ setError(result.error);
172
+ return;
173
+ }
174
+ try {
175
+ if (isEdit && offer) {
176
+ await updateMutation.mutateAsync({ id: offer.id, patch: result });
177
+ }
178
+ else {
179
+ await createMutation.mutateAsync(result);
180
+ }
181
+ onOpenChange(false);
182
+ }
183
+ catch (err) {
184
+ setError(err instanceof Error ? err.message : String(err));
185
+ }
186
+ }
187
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[90vh] max-w-2xl overflow-y-auto", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: isEdit ? "Edit promotion" : "New promotion" }), _jsx(DialogDescription, { children: "Set discount, scope, and validity. Code-gated offers require a non-empty code; leave it blank for auto-applied offers." })] }), _jsxs("div", { className: "grid gap-4 py-2", children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-name", children: "Name" }), _jsx(Input, { id: "promotion-name", value: state.name, onChange: (e) => setField("name", e.target.value), placeholder: "Spring Sale 2026" })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-slug", children: "Slug" }), _jsx(Input, { id: "promotion-slug", value: state.slug, onChange: (e) => setField("slug", e.target.value), placeholder: "spring-sale-2026" })] })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-description", children: "Description" }), _jsx(Textarea, { id: "promotion-description", value: state.description, onChange: (e) => setField("description", e.target.value), rows: 2, placeholder: "Internal note \u2014 what this offer is for" })] }), _jsxs("div", { className: "grid grid-cols-3 gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { children: "Type" }), _jsxs(Select, { value: state.discountType, onValueChange: (v) => {
188
+ if (v === "percentage" || v === "fixed_amount")
189
+ setField("discountType", v);
190
+ }, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "percentage", children: "Percentage" }), _jsx(SelectItem, { value: "fixed_amount", children: "Fixed amount" })] })] })] }), state.discountType === "percentage" ? (_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-percent", children: "Percent" }), _jsx(Input, { id: "promotion-percent", type: "number", step: "0.01", min: "0", max: "100", value: state.discountPercent, onChange: (e) => setField("discountPercent", e.target.value), placeholder: "20" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-cents", children: "Amount (cents)" }), _jsx(Input, { id: "promotion-cents", type: "number", step: "1", min: "1", value: state.discountAmountCents, onChange: (e) => setField("discountAmountCents", e.target.value), placeholder: "500" })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-currency", children: "Currency" }), _jsx(Input, { id: "promotion-currency", value: state.currency, onChange: (e) => setField("currency", e.target.value), placeholder: "USD", maxLength: 3 })] })] }))] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { children: "Scope" }), _jsxs(Select, { value: state.scopeKind, onValueChange: (v) => {
191
+ if (v != null && SCOPE_KINDS.includes(v)) {
192
+ setField("scopeKind", v);
193
+ }
194
+ }, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: SCOPE_KINDS.map((kind) => (_jsx(SelectItem, { value: kind, children: kind }, kind))) })] }), state.scopeKind === "global" ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Applies to every product." })) : null, (state.scopeKind === "products" ||
195
+ state.scopeKind === "categories" ||
196
+ state.scopeKind === "destinations" ||
197
+ state.scopeKind === "markets") && (_jsxs("div", { className: "grid gap-1.5", children: [_jsxs(Label, { htmlFor: "promotion-scope-ids", children: [scopeIdsLabel(state.scopeKind), " (comma-separated IDs)"] }), _jsx(Input, { id: "promotion-scope-ids", value: state.scopeIds, onChange: (e) => setField("scopeIds", e.target.value), placeholder: scopeIdsPlaceholder(state.scopeKind) })] })), state.scopeKind === "audiences" && (_jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { children: "Audiences" }), _jsx("div", { className: "flex flex-wrap gap-3", children: AUDIENCE_OPTIONS.map((audience) => {
198
+ const selected = state.scopeAudiences.includes(audience);
199
+ return (_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: selected, onChange: (e) => {
200
+ const next = e.target.checked
201
+ ? [...state.scopeAudiences, audience]
202
+ : state.scopeAudiences.filter((a) => a !== audience);
203
+ setField("scopeAudiences", next);
204
+ } }), audience] }, audience));
205
+ }) })] }))] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-valid-from", children: "Valid from" }), _jsx(Input, { id: "promotion-valid-from", type: "datetime-local", value: state.validFrom, onChange: (e) => setField("validFrom", e.target.value) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-valid-until", children: "Valid until" }), _jsx(Input, { id: "promotion-valid-until", type: "datetime-local", value: state.validUntil, onChange: (e) => setField("validUntil", e.target.value) })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-code", children: "Code (optional)" }), _jsx(Input, { id: "promotion-code", value: state.code, onChange: (e) => setField("code", e.target.value), placeholder: "EARLYBIRD2026" })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-min-pax", children: "Min pax (optional)" }), _jsx(Input, { id: "promotion-min-pax", type: "number", min: "1", step: "1", value: state.minPax, onChange: (e) => setField("minPax", e.target.value), placeholder: "4" })] })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "promotion-stackable", checked: state.stackable, onCheckedChange: (v) => setField("stackable", Boolean(v)) }), _jsx(Label, { htmlFor: "promotion-stackable", children: "Stackable with other offers" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "promotion-active", checked: state.active, onCheckedChange: (v) => setField("active", Boolean(v)) }), _jsx(Label, { htmlFor: "promotion-active", children: "Active" })] })] }), error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: isPending, children: "Cancel" }), _jsx(Button, { onClick: handleSave, disabled: isPending, children: isPending ? "Saving…" : isEdit ? "Save changes" : "Create" })] })] }) }));
206
+ }
207
+ function scopeIdsLabel(kind) {
208
+ switch (kind) {
209
+ case "products":
210
+ return "Product IDs";
211
+ case "categories":
212
+ return "Category IDs";
213
+ case "destinations":
214
+ return "Destination IDs";
215
+ case "markets":
216
+ return "Market IDs";
217
+ }
218
+ }
219
+ function scopeIdsPlaceholder(kind) {
220
+ switch (kind) {
221
+ case "products":
222
+ return "prod_xxx, prod_yyy";
223
+ case "categories":
224
+ return "cat_xxx";
225
+ case "destinations":
226
+ return "dest_xxx";
227
+ case "markets":
228
+ return "mkt_xxx";
229
+ }
230
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Operator-facing promotions list page.
3
+ *
4
+ * Lists every promotional offer with its scope, discount, validity, and
5
+ * status. Edit-on-click opens the create/edit dialog (PromotionDialog).
6
+ *
7
+ * v1 limitations: no separate detail page, no redemption-history view —
8
+ * the list + form covers the operator-essential capability. Detail +
9
+ * redemption views can ship as follow-up commits.
10
+ */
11
+ import type { QueryClient } from "@tanstack/react-query";
12
+ import { type PromotionsClientOptions } from "@voyantjs/promotions-react";
13
+ export declare function loadPromotionsPage(queryClient: QueryClient, client?: Partial<PromotionsClientOptions>): Promise<{
14
+ data: {
15
+ id: string;
16
+ name: string;
17
+ slug: string;
18
+ description: string | null;
19
+ discountType: "percentage" | "fixed_amount";
20
+ discountPercent: string | null;
21
+ discountAmountCents: number | null;
22
+ currency: string | null;
23
+ scope: {
24
+ kind: "global";
25
+ } | {
26
+ kind: "products";
27
+ productIds: string[];
28
+ } | {
29
+ kind: "categories";
30
+ categoryIds: string[];
31
+ } | {
32
+ kind: "destinations";
33
+ destinationIds: string[];
34
+ } | {
35
+ kind: "markets";
36
+ marketIds: string[];
37
+ } | {
38
+ kind: "audiences";
39
+ audiences: ("staff" | "customer" | "partner" | "supplier")[];
40
+ };
41
+ conditions: {
42
+ [x: string]: unknown;
43
+ minPax?: number | undefined;
44
+ };
45
+ validFrom: string | null;
46
+ validUntil: string | null;
47
+ code: string | null;
48
+ stackable: boolean;
49
+ active: boolean;
50
+ metadata: Record<string, unknown> | null;
51
+ createdAt: string;
52
+ updatedAt: string;
53
+ }[];
54
+ total: number;
55
+ limit: number;
56
+ offset: number;
57
+ }>;
58
+ export declare function PromotionsPage(): import("react/jsx-runtime").JSX.Element;
59
+ //# sourceMappingURL=promotions-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promotions-page.d.ts","sourceRoot":"","sources":["../src/promotions-page.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAKL,KAAK,uBAAuB,EAE7B,MAAM,4BAA4B,CAAA;AAOnC,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,WAAW,EACxB,MAAM,CAAC,EAAE,OAAO,CAAC,uBAAuB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAK1C;AAED,wBAAgB,cAAc,4CA4G7B"}
@@ -0,0 +1,67 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createPromotionsClientOptions, getPromotionsListQueryOptions, usePromotionsList, } from "@voyantjs/promotions-react";
4
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components";
5
+ import { Plus } from "lucide-react";
6
+ import { useMemo, useState } from "react";
7
+ import { PromotionDialog } from "./promotion-dialog.js";
8
+ export function loadPromotionsPage(queryClient, client) {
9
+ return queryClient.ensureQueryData(getPromotionsListQueryOptions({ limit: 50, offset: 0 }, createPromotionsClientOptions(client)));
10
+ }
11
+ export function PromotionsPage() {
12
+ const { data, isPending, error } = usePromotionsList({ limit: 50, offset: 0 });
13
+ const [dialogOpen, setDialogOpen] = useState(false);
14
+ const [editingOffer, setEditingOffer] = useState();
15
+ const offers = data?.data ?? [];
16
+ const summary = useMemo(() => {
17
+ const active = offers.filter((o) => o.active).length;
18
+ const codeGated = offers.filter((o) => o.code != null).length;
19
+ return { total: offers.length, active, codeGated };
20
+ }, [offers]);
21
+ function openCreate() {
22
+ setEditingOffer(undefined);
23
+ setDialogOpen(true);
24
+ }
25
+ function openEdit(offer) {
26
+ setEditingOffer(offer);
27
+ setDialogOpen(true);
28
+ }
29
+ return (_jsxs("div", { className: "space-y-4 p-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: "Promotions" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Auto-applied catalog discounts and code-redeemed offers." })] }), _jsxs(Button, { onClick: openCreate, children: [_jsx(Plus, { className: "mr-2 size-4" }), "New promotion"] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-3", children: [_jsx(SummaryCard, { label: "Total", value: summary.total }), _jsx(SummaryCard, { label: "Active", value: summary.active }), _jsx(SummaryCard, { label: "Code-gated", value: summary.codeGated })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "All offers" }) }), _jsx(CardContent, { children: error ? (_jsxs("p", { className: "text-sm text-destructive", children: ["Failed to load: ", error instanceof Error ? error.message : String(error)] })) : isPending ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" })) : offers.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No promotions yet. Create your first offer to get started." })) : (_jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "text-left text-xs uppercase text-muted-foreground", children: _jsxs("tr", { children: [_jsx("th", { className: "py-2 pr-4", children: "Name" }), _jsx("th", { className: "py-2 pr-4", children: "Scope" }), _jsx("th", { className: "py-2 pr-4", children: "Discount" }), _jsx("th", { className: "py-2 pr-4", children: "Validity" }), _jsx("th", { className: "py-2 pr-4", children: "Code" }), _jsx("th", { className: "py-2 pr-4", children: "Status" })] }) }), _jsx("tbody", { children: offers.map((offer) => (_jsxs("tr", { className: "cursor-pointer border-t hover:bg-muted/40", onClick: () => openEdit(offer), children: [_jsx("td", { className: "py-2 pr-4 font-medium", children: offer.name }), _jsx("td", { className: "py-2 pr-4 text-muted-foreground", children: summarizeScope(offer.scope) }), _jsx("td", { className: "py-2 pr-4", children: summarizeDiscount(offer) }), _jsx("td", { className: "py-2 pr-4 text-muted-foreground", children: summarizeValidity(offer.validFrom, offer.validUntil) }), _jsx("td", { className: "py-2 pr-4 font-mono text-xs", children: offer.code ?? "—" }), _jsxs("td", { className: "py-2 pr-4", children: [_jsx(Badge, { variant: offer.active ? "default" : "outline", children: offer.active ? "active" : "archived" }), offer.stackable ? (_jsx(Badge, { variant: "secondary", className: "ml-2", children: "stackable" })) : null] })] }, offer.id))) })] })) })] }), _jsx(PromotionDialog, { open: dialogOpen, onOpenChange: setDialogOpen, offer: editingOffer })] }));
30
+ }
31
+ function SummaryCard({ label, value }) {
32
+ return (_jsx(Card, { children: _jsxs(CardContent, { className: "flex flex-col gap-1 py-4", children: [_jsx("span", { className: "text-xs uppercase text-muted-foreground", children: label }), _jsx("span", { className: "text-2xl font-semibold", children: value })] }) }));
33
+ }
34
+ function summarizeScope(scope) {
35
+ switch (scope.kind) {
36
+ case "global":
37
+ return "Global";
38
+ case "products":
39
+ return `${scope.productIds.length} product${scope.productIds.length === 1 ? "" : "s"}`;
40
+ case "categories":
41
+ return `${scope.categoryIds.length} categor${scope.categoryIds.length === 1 ? "y" : "ies"}`;
42
+ case "destinations":
43
+ return `${scope.destinationIds.length} destination${scope.destinationIds.length === 1 ? "" : "s"}`;
44
+ case "markets":
45
+ return `Markets: ${scope.marketIds.join(", ")}`;
46
+ case "audiences":
47
+ return `Audiences: ${scope.audiences.join(", ")}`;
48
+ }
49
+ }
50
+ function summarizeDiscount(offer) {
51
+ if (offer.discountType === "percentage") {
52
+ return `${offer.discountPercent ?? "?"}%`;
53
+ }
54
+ const cents = offer.discountAmountCents ?? 0;
55
+ const currency = offer.currency ?? "";
56
+ return `${(cents / 100).toFixed(2)} ${currency}`.trim();
57
+ }
58
+ function summarizeValidity(from, until) {
59
+ if (from == null && until == null)
60
+ return "Anytime";
61
+ const fmt = (iso) => iso.slice(0, 10);
62
+ if (from == null)
63
+ return `Until ${fmt(until ?? "")}`;
64
+ if (until == null)
65
+ return `From ${fmt(from)}`;
66
+ return `${fmt(from)} → ${fmt(until)}`;
67
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@voyantjs/promotions-ui",
3
+ "version": "0.29.0",
4
+ "license": "Apache-2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/voyantjs/voyant.git",
8
+ "directory": "packages/promotions-ui"
9
+ },
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./components/*": "./src/*.tsx"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json",
18
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
19
+ "prepack": "pnpm run build",
20
+ "typecheck": "tsc --noEmit",
21
+ "lint": "biome check src/",
22
+ "test": "vitest run --passWithNoTests"
23
+ },
24
+ "peerDependencies": {
25
+ "@tanstack/react-query": "^5.0.0",
26
+ "@voyantjs/promotions-react": "workspace:*",
27
+ "@voyantjs/ui": "workspace:*",
28
+ "react": "^19.0.0",
29
+ "react-dom": "^19.0.0",
30
+ "zod": "^4.3.6"
31
+ },
32
+ "devDependencies": {
33
+ "@tanstack/react-query": "^5.96.2",
34
+ "@types/react": "^19.2.14",
35
+ "@types/react-dom": "^19.2.3",
36
+ "@voyantjs/promotions-react": "workspace:*",
37
+ "@voyantjs/ui": "workspace:*",
38
+ "@voyantjs/voyant-typescript-config": "workspace:*",
39
+ "lucide-react": "^0.475.0",
40
+ "react": "^19.2.4",
41
+ "react-dom": "^19.2.4",
42
+ "typescript": "^6.0.2",
43
+ "vitest": "^4.1.2",
44
+ "zod": "^4.3.6"
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "exports": {
52
+ ".": {
53
+ "types": "./dist/index.d.ts",
54
+ "import": "./dist/index.js",
55
+ "default": "./dist/index.js"
56
+ },
57
+ "./components/*": {
58
+ "types": "./dist/*.d.ts",
59
+ "import": "./dist/*.js",
60
+ "default": "./dist/*.js"
61
+ }
62
+ },
63
+ "main": "./dist/index.js",
64
+ "types": "./dist/index.d.ts"
65
+ }
66
+ }