@voyantjs/promotions-ui 0.32.3 → 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 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
@@ -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,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA"}
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;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"}
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"}
@@ -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 ? toDateInputValue(offer.validFrom) : "";
64
- base.validUntil = offer.validUntil ? toDateInputValue(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 toDateInputValue(iso) {
85
- // <input type="datetime-local"> wants `YYYY-MM-DDTHH:mm` (no timezone).
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 (!state.discountAmountCents) {
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" ? Number(state.discountAmountCents) : null,
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-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
+ }, 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(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
+ }) })] }))] }), _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 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.
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
- export declare function loadPromotionsPage(queryClient: QueryClient, client?: Partial<PromotionsClientOptions>): Promise<{
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;;;;;;;;;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"}
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"}
@@ -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, Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components";
5
- import { Plus } from "lucide-react";
6
- import { useMemo, useState } from "react";
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
- export function loadPromotionsPage(queryClient, client) {
9
- return queryClient.ensureQueryData(getPromotionsListQueryOptions({ limit: 50, offset: 0 }, createPromotionsClientOptions(client)));
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 { data, isPending, error } = usePromotionsList({ limit: 50, offset: 0 });
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 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]);
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
- 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 })] }));
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 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 })] }) }));
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)} ${fmt(until)}`;
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.32.3",
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.32.3",
30
- "@voyantjs/ui": "0.32.3"
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.32.3",
43
- "@voyantjs/ui": "0.32.3",
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": [