@voyantjs/promotions-ui 0.33.0 → 0.34.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/promotion-dialog.d.ts.map +1 -1
- package/dist/promotion-dialog.js +12 -11
- package/dist/promotions-page.d.ts +18 -9
- package/dist/promotions-page.d.ts.map +1 -1
- package/dist/promotions-page.js +148 -17
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { PromotionDialog, type PromotionDialogProps } from "./promotion-dialog.js";
|
|
2
|
-
export { loadPromotionsPage, PromotionsPage } from "./promotions-page.js";
|
|
2
|
+
export { loadPromotionsPage, type PromotionDialogRenderProps, PromotionsPage, type PromotionsPageProps, } from "./promotions-page.js";
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +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,
|
|
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,EACL,kBAAkB,EAClB,KAAK,0BAA0B,EAC/B,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,sBAAsB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { PromotionDialog } from "./promotion-dialog.js";
|
|
2
|
-
export { loadPromotionsPage, PromotionsPage } from "./promotions-page.js";
|
|
2
|
+
export { loadPromotionsPage, PromotionsPage, } from "./promotions-page.js";
|
|
@@ -1 +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;
|
|
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;AAuBnC,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;AAqKD,wBAAgB,eAAe,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,EAAE,oBAAoB,2CAgSlF"}
|
package/dist/promotion-dialog.js
CHANGED
|
@@ -11,6 +11,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
11
11
|
*/
|
|
12
12
|
import { promotionalOfferScopeSchema, useCreatePromotion, useUpdatePromotion, } from "@voyantjs/promotions-react";
|
|
13
13
|
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
|
|
14
|
+
import { CurrencyInput } from "@voyantjs/ui/components/currency-input";
|
|
15
|
+
import { DateTimePicker } from "@voyantjs/ui/components/date-time-picker";
|
|
14
16
|
import { useEffect, useState } from "react";
|
|
15
17
|
const SCOPE_KINDS = [
|
|
16
18
|
"global",
|
|
@@ -33,7 +35,7 @@ function emptyForm() {
|
|
|
33
35
|
description: "",
|
|
34
36
|
discountType: "percentage",
|
|
35
37
|
discountPercent: "",
|
|
36
|
-
discountAmountCents:
|
|
38
|
+
discountAmountCents: null,
|
|
37
39
|
currency: "USD",
|
|
38
40
|
scopeKind: "global",
|
|
39
41
|
scopeIds: "",
|
|
@@ -53,15 +55,14 @@ function offerToForm(offer) {
|
|
|
53
55
|
base.description = offer.description ?? "";
|
|
54
56
|
base.discountType = offer.discountType;
|
|
55
57
|
base.discountPercent = offer.discountPercent ?? "";
|
|
56
|
-
base.discountAmountCents =
|
|
57
|
-
offer.discountAmountCents != null ? String(offer.discountAmountCents) : "";
|
|
58
|
+
base.discountAmountCents = offer.discountAmountCents ?? null;
|
|
58
59
|
base.currency = offer.currency ?? "USD";
|
|
59
60
|
base.scopeKind = offer.scope.kind;
|
|
60
61
|
base.scopeIds = scopeIdsToString(offer.scope);
|
|
61
62
|
base.scopeAudiences = offer.scope.kind === "audiences" ? [...offer.scope.audiences] : ["customer"];
|
|
62
63
|
base.minPax = offer.conditions.minPax != null ? String(offer.conditions.minPax) : "";
|
|
63
|
-
base.validFrom = offer.validFrom ?
|
|
64
|
-
base.validUntil = offer.validUntil ?
|
|
64
|
+
base.validFrom = offer.validFrom ? toDateTimePickerValue(offer.validFrom) : "";
|
|
65
|
+
base.validUntil = offer.validUntil ? toDateTimePickerValue(offer.validUntil) : "";
|
|
65
66
|
base.code = offer.code ?? "";
|
|
66
67
|
base.stackable = offer.stackable;
|
|
67
68
|
base.active = offer.active;
|
|
@@ -81,8 +82,8 @@ function scopeIdsToString(scope) {
|
|
|
81
82
|
return "";
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
|
-
function
|
|
85
|
-
//
|
|
85
|
+
function toDateTimePickerValue(iso) {
|
|
86
|
+
// DateTimePicker values use `YYYY-MM-DDTHH:mm` with no timezone.
|
|
86
87
|
return iso.slice(0, 16);
|
|
87
88
|
}
|
|
88
89
|
function buildScope(state) {
|
|
@@ -116,7 +117,7 @@ function buildPayload(state) {
|
|
|
116
117
|
return { error: "Discount percent is required for percentage offers" };
|
|
117
118
|
}
|
|
118
119
|
if (state.discountType === "fixed_amount") {
|
|
119
|
-
if (
|
|
120
|
+
if (state.discountAmountCents == null || state.discountAmountCents <= 0) {
|
|
120
121
|
return { error: "Discount amount is required for fixed-amount offers" };
|
|
121
122
|
}
|
|
122
123
|
if (!state.currency.trim())
|
|
@@ -135,7 +136,7 @@ function buildPayload(state) {
|
|
|
135
136
|
description: state.description.trim() || null,
|
|
136
137
|
discountType: state.discountType,
|
|
137
138
|
discountPercent: state.discountType === "percentage" ? Number(state.discountPercent) : null,
|
|
138
|
-
discountAmountCents: state.discountType === "fixed_amount" ?
|
|
139
|
+
discountAmountCents: state.discountType === "fixed_amount" ? state.discountAmountCents : null,
|
|
139
140
|
currency: state.discountType === "fixed_amount" ? state.currency.trim().toUpperCase() : null,
|
|
140
141
|
scope,
|
|
141
142
|
conditions: state.minPax ? { minPax: Number(state.minPax) } : {},
|
|
@@ -187,7 +188,7 @@ export function PromotionDialog({ open, onOpenChange, offer }) {
|
|
|
187
188
|
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
189
|
if (v === "percentage" || v === "fixed_amount")
|
|
189
190
|
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-
|
|
191
|
+
}, 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-amount", children: "Amount" }), _jsx(CurrencyInput, { id: "promotion-amount", value: state.discountAmountCents, onChange: (value) => setField("discountAmountCents", value), currency: state.currency, placeholder: "5.00" })] }), _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
192
|
if (v != null && SCOPE_KINDS.includes(v)) {
|
|
192
193
|
setField("scopeKind", v);
|
|
193
194
|
}
|
|
@@ -202,7 +203,7 @@ export function PromotionDialog({ open, onOpenChange, offer }) {
|
|
|
202
203
|
: state.scopeAudiences.filter((a) => a !== audience);
|
|
203
204
|
setField("scopeAudiences", next);
|
|
204
205
|
} }), 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(
|
|
206
|
+
}) })] }))] }), _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(DateTimePicker, { value: state.validFrom, onChange: (nextValue) => setField("validFrom", nextValue ?? "") })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: "promotion-valid-until", children: "Valid until" }), _jsx(DateTimePicker, { value: state.validUntil, onChange: (nextValue) => setField("validUntil", nextValue ?? "") })] })] }), _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
|
}
|
|
207
208
|
function scopeIdsLabel(kind) {
|
|
208
209
|
switch (kind) {
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Operator-facing promotions list page.
|
|
3
3
|
*
|
|
4
|
-
* Lists every promotional offer with
|
|
5
|
-
*
|
|
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.
|
|
4
|
+
* Lists every promotional offer with server-backed search, filters, pagination,
|
|
5
|
+
* and optional row navigation for apps that expose a dedicated detail route.
|
|
10
6
|
*/
|
|
11
7
|
import type { QueryClient } from "@tanstack/react-query";
|
|
12
|
-
import { type PromotionsClientOptions } from "@voyantjs/promotions-react";
|
|
13
|
-
|
|
8
|
+
import { type PromotionalOfferRecord, type PromotionsClientOptions, type PromotionsListQuery } from "@voyantjs/promotions-react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
export interface PromotionDialogRenderProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
offer?: PromotionalOfferRecord;
|
|
14
|
+
onSuccess: () => void;
|
|
15
|
+
}
|
|
16
|
+
export interface PromotionsPageProps {
|
|
17
|
+
className?: string;
|
|
18
|
+
pageSize?: number;
|
|
19
|
+
onOpenPromotion?: (promotionId: string, promotion: PromotionalOfferRecord) => void;
|
|
20
|
+
renderPromotionDialog?: (props: PromotionDialogRenderProps) => ReactNode;
|
|
21
|
+
}
|
|
22
|
+
export declare function loadPromotionsPage(queryClient: QueryClient, client?: Partial<PromotionsClientOptions>, query?: PromotionsListQuery): Promise<{
|
|
14
23
|
data: {
|
|
15
24
|
id: string;
|
|
16
25
|
name: string;
|
|
@@ -55,5 +64,5 @@ export declare function loadPromotionsPage(queryClient: QueryClient, client?: Pa
|
|
|
55
64
|
limit: number;
|
|
56
65
|
offset: number;
|
|
57
66
|
}>;
|
|
58
|
-
export declare function PromotionsPage(): import("react/jsx-runtime").JSX.Element;
|
|
67
|
+
export declare function PromotionsPage({ className, pageSize, onOpenPromotion, renderPromotionDialog, }?: PromotionsPageProps): import("react/jsx-runtime").JSX.Element;
|
|
59
68
|
//# sourceMappingURL=promotions-page.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"promotions-page.d.ts","sourceRoot":"","sources":["../src/promotions-page.tsx"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"promotions-page.d.ts","sourceRoot":"","sources":["../src/promotions-page.tsx"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAKL,KAAK,sBAAsB,EAG3B,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EAEzB,MAAM,4BAA4B,CAAA;AAsBnC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AA0BtC,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,KAAK,CAAC,EAAE,sBAAsB,CAAA;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,eAAe,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,sBAAsB,KAAK,IAAI,CAAA;IAClF,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,SAAS,CAAA;CACzE;AAED,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,WAAW,EACxB,MAAM,CAAC,EAAE,OAAO,CAAC,uBAAuB,CAAC,EACzC,KAAK,GAAE,mBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAQhC;AAED,wBAAgB,cAAc,CAAC,EAC7B,SAAS,EACT,QAA4B,EAC5B,eAAe,EACf,qBAAqB,GACtB,GAAE,mBAAwB,2CAyR1B"}
|
package/dist/promotions-page.js
CHANGED
|
@@ -1,23 +1,58 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { createPromotionsClientOptions, getPromotionsListQueryOptions, usePromotionsList, } from "@voyantjs/promotions-react";
|
|
4
|
-
import { Badge, Button,
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { Badge, Button, DateRangePicker, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@voyantjs/ui/components";
|
|
5
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
6
|
+
import { Plus, Search, X } from "lucide-react";
|
|
7
|
+
import { useState } from "react";
|
|
7
8
|
import { PromotionDialog } from "./promotion-dialog.js";
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
10
|
+
const ALL = "__all__";
|
|
11
|
+
const TABLE_COLUMN_COUNT = 7;
|
|
12
|
+
const scopeKinds = [
|
|
13
|
+
"global",
|
|
14
|
+
"products",
|
|
15
|
+
"categories",
|
|
16
|
+
"destinations",
|
|
17
|
+
"markets",
|
|
18
|
+
"audiences",
|
|
19
|
+
];
|
|
20
|
+
const applicationModes = ["auto", "code"];
|
|
21
|
+
const statusFilters = ["active", "scheduled", "expired", "archived"];
|
|
22
|
+
export function loadPromotionsPage(queryClient, client, query = {}) {
|
|
23
|
+
return queryClient.ensureQueryData(getPromotionsListQueryOptions({ limit: DEFAULT_PAGE_SIZE, offset: 0, ...query }, createPromotionsClientOptions(client)));
|
|
10
24
|
}
|
|
11
|
-
export function PromotionsPage() {
|
|
12
|
-
const
|
|
25
|
+
export function PromotionsPage({ className, pageSize = DEFAULT_PAGE_SIZE, onOpenPromotion, renderPromotionDialog, } = {}) {
|
|
26
|
+
const [search, setSearch] = useState("");
|
|
27
|
+
const [applicationMode, setApplicationMode] = useState(ALL);
|
|
28
|
+
const [status, setStatus] = useState(ALL);
|
|
29
|
+
const [scopeKind, setScopeKind] = useState(ALL);
|
|
30
|
+
const [validityRange, setValidityRange] = useState(null);
|
|
31
|
+
const [pageIndex, setPageIndex] = useState(0);
|
|
13
32
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
14
33
|
const [editingOffer, setEditingOffer] = useState();
|
|
34
|
+
const query = {
|
|
35
|
+
search: search || undefined,
|
|
36
|
+
applicationMode: applicationMode === ALL ? undefined : applicationMode,
|
|
37
|
+
status: status === ALL ? undefined : status,
|
|
38
|
+
scopeKind: scopeKind === ALL ? undefined : scopeKind,
|
|
39
|
+
validFrom: validityRange?.from ?? undefined,
|
|
40
|
+
validUntil: validityRange?.to ?? undefined,
|
|
41
|
+
limit: pageSize,
|
|
42
|
+
offset: pageIndex * pageSize,
|
|
43
|
+
};
|
|
44
|
+
const { data, isPending, isFetching, isError, error, refetch } = usePromotionsList(query);
|
|
15
45
|
const offers = data?.data ?? [];
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
const total = data?.total ?? 0;
|
|
47
|
+
const page = pageIndex + 1;
|
|
48
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
49
|
+
const showSkeleton = isPending || (isFetching && offers.length === 0);
|
|
50
|
+
const hasActiveFilters = search !== "" ||
|
|
51
|
+
applicationMode !== ALL ||
|
|
52
|
+
status !== ALL ||
|
|
53
|
+
scopeKind !== ALL ||
|
|
54
|
+
Boolean(validityRange?.from || validityRange?.to);
|
|
55
|
+
const resetPage = () => setPageIndex(0);
|
|
21
56
|
function openCreate() {
|
|
22
57
|
setEditingOffer(undefined);
|
|
23
58
|
setDialogOpen(true);
|
|
@@ -26,10 +61,53 @@ export function PromotionsPage() {
|
|
|
26
61
|
setEditingOffer(offer);
|
|
27
62
|
setDialogOpen(true);
|
|
28
63
|
}
|
|
29
|
-
|
|
64
|
+
function openOffer(offer) {
|
|
65
|
+
if (onOpenPromotion) {
|
|
66
|
+
onOpenPromotion(offer.id, offer);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
openEdit(offer);
|
|
70
|
+
}
|
|
71
|
+
function clearFilters() {
|
|
72
|
+
setSearch("");
|
|
73
|
+
setApplicationMode(ALL);
|
|
74
|
+
setStatus(ALL);
|
|
75
|
+
setScopeKind(ALL);
|
|
76
|
+
setValidityRange(null);
|
|
77
|
+
resetPage();
|
|
78
|
+
}
|
|
79
|
+
const dialog = renderPromotionDialog ? (renderPromotionDialog({
|
|
80
|
+
open: dialogOpen,
|
|
81
|
+
onOpenChange: setDialogOpen,
|
|
82
|
+
offer: editingOffer,
|
|
83
|
+
onSuccess: () => {
|
|
84
|
+
setDialogOpen(false);
|
|
85
|
+
resetPage();
|
|
86
|
+
void refetch();
|
|
87
|
+
},
|
|
88
|
+
})) : (_jsx(PromotionDialog, { open: dialogOpen, onOpenChange: setDialogOpen, offer: editingOffer }));
|
|
89
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-6 p-6", className), children: [_jsxs("div", { className: "flex items-center justify-between gap-4", 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", "aria-hidden": "true" }), "New promotion"] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsxs("div", { className: "relative min-w-[14rem] max-w-sm flex-1", children: [_jsx(Label, { htmlFor: "promotions-search", className: "sr-only", children: "Search promotions" }), _jsx(Search, { className: "absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": "true" }), _jsx(Input, { id: "promotions-search", placeholder: "Search name, slug, description, or code", value: search, onChange: (event) => {
|
|
90
|
+
setSearch(event.target.value);
|
|
91
|
+
resetPage();
|
|
92
|
+
}, className: "pl-9" })] }), _jsxs(Select, { value: applicationMode, onValueChange: (value) => {
|
|
93
|
+
setApplicationMode(value ?? ALL);
|
|
94
|
+
resetPage();
|
|
95
|
+
}, children: [_jsx(SelectTrigger, { className: "w-[10.5rem]", children: _jsx(SelectValue, { placeholder: "Mode" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: ALL, children: "All modes" }), applicationModes.map((mode) => (_jsx(SelectItem, { value: mode, children: applicationModeLabel(mode) }, mode)))] })] }), _jsxs(Select, { value: status, onValueChange: (value) => {
|
|
96
|
+
setStatus(value ?? ALL);
|
|
97
|
+
resetPage();
|
|
98
|
+
}, children: [_jsx(SelectTrigger, { className: "w-[10.5rem]", children: _jsx(SelectValue, { placeholder: "Status" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: ALL, children: "All statuses" }), statusFilters.map((value) => (_jsx(SelectItem, { value: value, children: statusLabel(value) }, value)))] })] }), _jsxs(Select, { value: scopeKind, onValueChange: (value) => {
|
|
99
|
+
setScopeKind(value ?? ALL);
|
|
100
|
+
resetPage();
|
|
101
|
+
}, children: [_jsx(SelectTrigger, { className: "w-[11rem]", children: _jsx(SelectValue, { placeholder: "Scope" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: ALL, children: "All scopes" }), scopeKinds.map((value) => (_jsx(SelectItem, { value: value, children: scopeKindLabel(value) }, value)))] })] }), _jsx(DateRangePicker, { value: validityRange, onChange: (value) => {
|
|
102
|
+
setValidityRange(value);
|
|
103
|
+
resetPage();
|
|
104
|
+
}, placeholder: "Validity range", className: "w-[15rem]" }), hasActiveFilters ? (_jsxs(Button, { variant: "ghost", onClick: clearFilters, children: [_jsx(X, { className: "mr-2 size-4", "aria-hidden": "true" }), "Clear"] })) : null] }), _jsx("div", { className: "rounded-md border", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Name" }), _jsx(TableHead, { children: "Mode" }), _jsx(TableHead, { children: "Scope" }), _jsx(TableHead, { children: "Discount" }), _jsx(TableHead, { children: "Validity" }), _jsx(TableHead, { children: "Code" }), _jsx(TableHead, { children: "Status" })] }) }), _jsx(TableBody, { children: showSkeleton ? (_jsx(PromotionRowSkeleton, { rows: 6 })) : isError ? (_jsx(TableRow, { children: _jsxs(TableCell, { colSpan: TABLE_COLUMN_COUNT, className: "h-24 text-center text-sm text-destructive", children: ["Failed to load: ", error instanceof Error ? error.message : String(error)] }) })) : offers.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: TABLE_COLUMN_COUNT, className: "h-24 text-center text-sm text-muted-foreground", children: "No promotions match the current filters." }) })) : (offers.map((offer) => (_jsxs(TableRow, { className: "cursor-pointer", onClick: () => openOffer(offer), children: [_jsxs(TableCell, { children: [_jsx("div", { className: "font-medium", children: offer.name }), _jsx("div", { className: "font-mono text-xs text-muted-foreground", children: offer.slug })] }), _jsx(TableCell, { children: _jsx(Badge, { variant: offer.code == null ? "secondary" : "outline", children: offer.code == null ? "Auto" : "Code" }) }), _jsx(TableCell, { className: "text-muted-foreground", children: summarizeScope(offer.scope) }), _jsx(TableCell, { children: summarizeDiscount(offer) }), _jsx(TableCell, { className: "text-muted-foreground", children: summarizeValidity(offer.validFrom, offer.validUntil) }), _jsx(TableCell, { className: "font-mono text-xs", children: offer.code ?? "-" }), _jsx(TableCell, { children: _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Badge, { variant: statusBadgeVariant(getOfferStatus(offer)), children: statusLabel(getOfferStatus(offer)) }), offer.stackable ? _jsx(Badge, { variant: "secondary", children: "Stackable" }) : null] }) })] }, offer.id)))) })] }) }), _jsx(PaginationBar, { shown: offers.length, total: total, page: page, pageCount: pageCount, onPrevious: () => setPageIndex((prev) => Math.max(0, prev - 1)), onNext: () => setPageIndex((prev) => prev + 1), canGoBack: pageIndex > 0, canGoForward: (pageIndex + 1) * pageSize < total }), dialog] }));
|
|
105
|
+
}
|
|
106
|
+
function PaginationBar({ shown, total, page, pageCount, onPrevious, onNext, canGoBack, canGoForward, }) {
|
|
107
|
+
return (_jsxs("div", { className: "flex items-center justify-between text-sm text-muted-foreground", children: [_jsxs("span", { children: ["Showing ", shown, " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", disabled: !canGoBack, onClick: onPrevious, children: "Previous" }), _jsxs("span", { children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "sm", disabled: !canGoForward, onClick: onNext, children: "Next" })] })] }));
|
|
30
108
|
}
|
|
31
|
-
function
|
|
32
|
-
return (_jsx(
|
|
109
|
+
function PromotionRowSkeleton({ rows }) {
|
|
110
|
+
return (_jsx(_Fragment, { children: Array.from({ length: rows }).map((_, index) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-40" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-5 w-16 rounded-full" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-28" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-20" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-32" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-4 w-20" }) }), _jsx(TableCell, { children: _jsx(Skeleton, { className: "h-5 w-20 rounded-full" }) })] }, `promotion-skeleton-${index}`))) }));
|
|
33
111
|
}
|
|
34
112
|
function summarizeScope(scope) {
|
|
35
113
|
switch (scope.kind) {
|
|
@@ -63,5 +141,58 @@ function summarizeValidity(from, until) {
|
|
|
63
141
|
return `Until ${fmt(until ?? "")}`;
|
|
64
142
|
if (until == null)
|
|
65
143
|
return `From ${fmt(from)}`;
|
|
66
|
-
return `${fmt(from)}
|
|
144
|
+
return `${fmt(from)} - ${fmt(until)}`;
|
|
145
|
+
}
|
|
146
|
+
function getOfferStatus(offer) {
|
|
147
|
+
if (!offer.active)
|
|
148
|
+
return "archived";
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
if (offer.validFrom != null && new Date(offer.validFrom).getTime() > now)
|
|
151
|
+
return "scheduled";
|
|
152
|
+
if (offer.validUntil != null && new Date(offer.validUntil).getTime() < now)
|
|
153
|
+
return "expired";
|
|
154
|
+
return "active";
|
|
155
|
+
}
|
|
156
|
+
function statusBadgeVariant(status) {
|
|
157
|
+
switch (status) {
|
|
158
|
+
case "active":
|
|
159
|
+
return "default";
|
|
160
|
+
case "scheduled":
|
|
161
|
+
return "secondary";
|
|
162
|
+
case "expired":
|
|
163
|
+
return "destructive";
|
|
164
|
+
case "archived":
|
|
165
|
+
return "outline";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function applicationModeLabel(mode) {
|
|
169
|
+
return mode === "auto" ? "Auto-applied" : "Code-redeemed";
|
|
170
|
+
}
|
|
171
|
+
function statusLabel(status) {
|
|
172
|
+
switch (status) {
|
|
173
|
+
case "active":
|
|
174
|
+
return "Active";
|
|
175
|
+
case "scheduled":
|
|
176
|
+
return "Scheduled";
|
|
177
|
+
case "expired":
|
|
178
|
+
return "Expired";
|
|
179
|
+
case "archived":
|
|
180
|
+
return "Archived";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function scopeKindLabel(scopeKind) {
|
|
184
|
+
switch (scopeKind) {
|
|
185
|
+
case "global":
|
|
186
|
+
return "Global";
|
|
187
|
+
case "products":
|
|
188
|
+
return "Products";
|
|
189
|
+
case "categories":
|
|
190
|
+
return "Categories";
|
|
191
|
+
case "destinations":
|
|
192
|
+
return "Destinations";
|
|
193
|
+
case "markets":
|
|
194
|
+
return "Markets";
|
|
195
|
+
case "audiences":
|
|
196
|
+
return "Audiences";
|
|
197
|
+
}
|
|
67
198
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/promotions-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"react": "^19.0.0",
|
|
27
27
|
"react-dom": "^19.0.0",
|
|
28
28
|
"zod": "^4.3.6",
|
|
29
|
-
"@voyantjs/promotions-react": "0.
|
|
30
|
-
"@voyantjs/ui": "0.
|
|
29
|
+
"@voyantjs/promotions-react": "0.34.0",
|
|
30
|
+
"@voyantjs/ui": "0.34.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@tanstack/react-query": "^5.96.2",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"typescript": "^6.0.2",
|
|
40
40
|
"vitest": "^4.1.2",
|
|
41
41
|
"zod": "^4.3.6",
|
|
42
|
-
"@voyantjs/promotions-react": "0.
|
|
43
|
-
"@voyantjs/ui": "0.
|
|
42
|
+
"@voyantjs/promotions-react": "0.34.0",
|
|
43
|
+
"@voyantjs/ui": "0.34.0",
|
|
44
44
|
"@voyantjs/voyant-typescript-config": "0.1.0"
|
|
45
45
|
},
|
|
46
46
|
"files": [
|