Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c870d46d | ||
|
|
a0c5a6a98c | ||
|
|
e7969429b6 |
@@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.29.1] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
- The Coupon codes page matches the Bundle deals page and works on phones. Codes show as cards with the discount, events, uses, restrictions, dates and limits. On phones, the edit form and the list of uses open full-width from the bottom, and the discount type is picked from three buttons.
|
||||
- The coupons list hides codes that are off, expired or used up unless you tick "Show codes that are off, expired or used up". Search also matches event names.
|
||||
- When creating or editing a code, the Events list shows upcoming events only, soonest first, with a search box and a "Show past events" option. Past events already on the code stay listed so they can be unticked.
|
||||
|
||||
## [1.29.0] - 2026-10-21
|
||||
|
||||
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { TicketPercent, Plus, X, Search } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
@@ -17,6 +16,7 @@ type EventLite = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
|
||||
};
|
||||
|
||||
@@ -122,17 +122,19 @@ function bodyFromDraft(d: Draft) {
|
||||
};
|
||||
}
|
||||
|
||||
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
|
||||
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
|
||||
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
|
||||
|
||||
function couponStatus(c: Coupon): { label: string; cls: string } {
|
||||
type Status = { key: "active" | "scheduled" | "off" | "expired" | "used"; label: string; cls: string };
|
||||
function couponStatus(c: Coupon): Status {
|
||||
const now = Date.now();
|
||||
if (!c.active) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
|
||||
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { label: "Expired", cls: "bg-gray-100 text-gray-600" };
|
||||
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
|
||||
if (c.maxUses && c.uses >= c.maxUses) return { label: "Used up", cls: "bg-gray-100 text-gray-600" };
|
||||
return { label: "Active", cls: "bg-green-100 text-green-800" };
|
||||
if (!c.active) return { key: "off", label: "Off", cls: "bg-gray-100 text-gray-600" };
|
||||
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { key: "expired", label: "Expired", cls: "bg-gray-100 text-gray-600" };
|
||||
if (c.maxUses && c.uses >= c.maxUses) return { key: "used", label: "Used up", cls: "bg-gray-100 text-gray-600" };
|
||||
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { key: "scheduled", label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
|
||||
return { key: "active", label: "Active", cls: "bg-green-100 text-green-800" };
|
||||
}
|
||||
|
||||
export default function CouponsPage() {
|
||||
@@ -151,7 +153,7 @@ export default function CouponsPage() {
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showOff, setShowOff] = useState(false);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
|
||||
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
|
||||
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
|
||||
@@ -175,10 +177,17 @@ export default function CouponsPage() {
|
||||
|
||||
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
|
||||
|
||||
// Codes that can still be used first; off, expired and used-up ones only when asked for.
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return coupons.filter(c => (showOff || c.active) && (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q)));
|
||||
}, [coupons, query, showOff]);
|
||||
const order: Record<Status["key"], number> = { active: 0, scheduled: 1, used: 2, expired: 3, off: 4 };
|
||||
return coupons
|
||||
.map(c => ({ c, status: couponStatus(c) }))
|
||||
.filter(({ c, status }) => (showInactive || status.key === "active" || status.key === "scheduled")
|
||||
&& (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q) || c.events.some(e => e.event.title.toLowerCase().includes(q))))
|
||||
.sort((a, b) => order[a.status.key] - order[b.status.key]);
|
||||
}, [coupons, query, showInactive]);
|
||||
const hiddenCount = coupons.filter(c => !["active", "scheduled"].includes(couponStatus(c).key)).length;
|
||||
|
||||
const toggleActive = async (c: Coupon) => {
|
||||
try {
|
||||
@@ -199,9 +208,11 @@ export default function CouponsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const startNew = () => setEditing({ id: null, draft: blankDraft() });
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<TicketPercent className="w-5 h-5 text-brand-600" />
|
||||
@@ -214,8 +225,8 @@ export default function CouponsPage() {
|
||||
</div>
|
||||
{canView && (
|
||||
<button
|
||||
onClick={() => setEditing({ id: null, draft: blankDraft() })}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700"
|
||||
onClick={startNew}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New code
|
||||
</button>
|
||||
@@ -230,72 +241,46 @@ export default function CouponsPage() {
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
|
||||
<input className={`${inputCls} pl-8`} placeholder="Search codes" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<input className={`${inputCls} pl-8`} placeholder="Search codes or events" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={showOff} onChange={e => setShowOff(e.target.checked)} /> Show codes that are off
|
||||
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} />
|
||||
Show codes that are off, expired or used up{hiddenCount > 0 ? ` (${hiddenCount})` : ""}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Discount</TableHead>
|
||||
<TableHead>Events</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>Valid</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map(c => {
|
||||
const status = couponStatus(c);
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>
|
||||
<div className="font-mono font-semibold text-gray-900">{c.code}</div>
|
||||
{c.description && <div className="text-xs text-gray-500">{c.description}</div>}
|
||||
{(c.restrictRoles.length > 0 || c.allowedUsers.length > 0 || c.restrictDomain) && (
|
||||
<div className="text-xs text-amber-700 mt-0.5">
|
||||
Restricted: {c.allowedUsers.length ? `${c.allowedUsers.length} person${c.allowedUsers.length === 1 ? "" : "s"}` : c.restrictRoles.length ? c.restrictRoles.join(", ") : `@${c.restrictDomain}`}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{c.summary}</div>
|
||||
{c.options.length > 0 && <div className="text-xs text-gray-500">{c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{c.events.length ? c.events.map(e => e.event.title).join(", ") : <span className="text-gray-500">All events</span>}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}
|
||||
<div className="text-xs text-gray-500">{fmtRand(c.totalDiscount)} given</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">
|
||||
{c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end"}` : "Always"}
|
||||
</TableCell>
|
||||
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => openUses(c)}>Uses</button>
|
||||
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}>Edit</button>
|
||||
<button className={`px-2 py-1 text-xs rounded text-white ${c.active ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(c)}>
|
||||
{c.active ? "Turn off" : "Turn on"}
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!fetching && visible.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">No coupon codes yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{!fetching && visible.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center mx-auto mb-3">
|
||||
<TicketPercent className="w-6 h-6 text-brand-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">{coupons.length === 0 ? "No coupon codes yet" : "No codes match"}</p>
|
||||
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. "STAFF50": 50% off for staff, or "WELCOME20": R20 off for first-time visitors.</p>
|
||||
{coupons.length === 0 && (
|
||||
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
|
||||
<Plus className="w-4 h-4" /> Create a code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{visible.map(({ c, status }) => (
|
||||
<CouponCard
|
||||
key={c.id}
|
||||
coupon={c}
|
||||
status={status}
|
||||
onUses={() => openUses(c)}
|
||||
onEdit={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}
|
||||
onToggle={() => toggleActive(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -311,47 +296,119 @@ export default function CouponsPage() {
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setViewing(null)}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="font-semibold">Uses of <span className="font-mono">{viewing.coupon.code}</span></h2>
|
||||
<button onClick={() => setViewing(null)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto">
|
||||
{viewing.redemptions.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">This code hasn't been used yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{viewing.redemptions.map(r => (
|
||||
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
|
||||
<TableCell>
|
||||
<div className="text-sm">{r.user.name}</div>
|
||||
<div className="text-xs text-gray-500">{r.user.email}</div>
|
||||
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{fmtRand(r.discountAmount)}
|
||||
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Sheet title={<>Uses of <span className="font-mono">{viewing.coupon.code}</span></>} onClose={() => setViewing(null)}>
|
||||
{viewing.redemptions.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">This code hasn't been used yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{viewing.redemptions.map(r => (
|
||||
<li key={r.id} className={`py-2.5 flex items-start justify-between gap-3 ${r.releasedAt ? "opacity-60" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{r.user.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{r.user.email}</div>
|
||||
<div className="text-xs text-gray-500">{r.registration.event.title} · {fmtDate(r.createdAt)}</div>
|
||||
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-sm font-semibold">{fmtRand(r.discountAmount)}</div>
|
||||
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A dialog that slides up full-width on phones and sits centred on bigger screens. */
|
||||
function Sheet({ title, subtitle, onClose, children, footer, onSubmit }: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
onSubmit?: (e: React.FormEvent) => void;
|
||||
}) {
|
||||
const Box = onSubmit ? "form" : "div";
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
|
||||
<Box onSubmit={onSubmit} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between gap-3 p-4 border-b">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto overflow-x-hidden overscroll-contain">{children}</div>
|
||||
{footer && <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">{footer}</div>}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CouponCard({ coupon: c, status, onUses, onEdit, onToggle }: { coupon: Coupon; status: Status; onUses: () => void; onEdit: () => void; onToggle: () => void }) {
|
||||
const restricted = c.allowedUsers.length
|
||||
? `${c.allowedUsers.length} ${c.allowedUsers.length === 1 ? "person" : "people"}`
|
||||
: c.restrictRoles.length ? c.restrictRoles.map(r => (r === "user" ? "members" : r)).join(", ") : c.restrictDomain ? `@${c.restrictDomain}` : null;
|
||||
const dates = c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end date"}` : null;
|
||||
const limits = [
|
||||
c.maxUses ? `${c.maxUses} uses in total` : null,
|
||||
c.maxUsesPerUser ? `${c.maxUsesPerUser} per person` : null,
|
||||
c.minQuantity ? `min ${c.minQuantity} tickets` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
const live = status.key === "active" || status.key === "scheduled";
|
||||
|
||||
return (
|
||||
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${live ? "bg-white" : "bg-gray-50"}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono font-semibold text-gray-900 break-all">{c.code}</div>
|
||||
{c.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{c.description}</div>}
|
||||
</div>
|
||||
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span className="text-xl font-semibold text-gray-900">{c.summary}</span>
|
||||
{c.options.length > 0 && (
|
||||
<span className="text-xs text-gray-500">on {c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.events.length === 0
|
||||
? <span className="rounded-full bg-gray-100 text-gray-700 px-2.5 py-1 text-xs">All events</span>
|
||||
: c.events.map(e => (
|
||||
<span key={e.eventId} className="rounded-full bg-brand-50 text-brand-800 border border-brand-100 px-2.5 py-1 text-xs break-words">{e.event.title}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 space-y-0.5">
|
||||
<div>Used <strong>{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}×</strong> · {fmtRand(c.totalDiscount)} given</div>
|
||||
{restricted && <div className="flex items-center gap-1 text-amber-700"><Users className="w-3.5 h-3.5 shrink-0" /> Only for {restricted}</div>}
|
||||
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5 shrink-0" /> {dates}</div>}
|
||||
{limits && <div>{limits}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
|
||||
<button onClick={onUses} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
|
||||
<ListChecks className="w-3.5 h-3.5" /> Uses
|
||||
</button>
|
||||
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 sm:ml-auto ${c.active ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
|
||||
<Power className="w-3.5 h-3.5" /> {c.active ? "Turn off" : "Turn on"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
|
||||
token: string;
|
||||
events: EventLite[];
|
||||
@@ -365,8 +422,22 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
|
||||
const [eventQuery, setEventQuery] = useState("");
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
// Upcoming events soonest first. Past events only when asked for, but ones already
|
||||
// picked always stay in the list so they can be unticked.
|
||||
const eventChoices = useMemo(() => {
|
||||
const q = eventQuery.trim().toLowerCase();
|
||||
const upcoming = events.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
const past = events.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
return [...upcoming, ...past]
|
||||
.filter(e => showPast || !isPast(e) || d.eventIds.includes(e.id))
|
||||
.filter(e => !q || e.title.toLowerCase().includes(q));
|
||||
}, [events, eventQuery, showPast, d.eventIds]);
|
||||
const pastCount = events.filter(isPast).length;
|
||||
|
||||
// Ticket options only make sense within the chosen events.
|
||||
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
|
||||
|
||||
@@ -415,184 +486,200 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
|
||||
}
|
||||
};
|
||||
|
||||
const label = "block text-sm font-medium text-gray-700 mb-1";
|
||||
const optional = <span className="font-normal text-gray-400">(optional)</span>;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
|
||||
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
|
||||
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
|
||||
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
|
||||
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
|
||||
<option value="percent">Percentage</option>
|
||||
<option value="fixed">Fixed amount (R)</option>
|
||||
<option value="comp">Free (100%)</option>
|
||||
</select>
|
||||
</div>
|
||||
{d.type !== "comp" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
|
||||
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
|
||||
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
|
||||
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
|
||||
{events.map(ev => (
|
||||
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
|
||||
<span>{ev.title}</span>
|
||||
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
|
||||
</label>
|
||||
))}
|
||||
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scopedEvents.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
|
||||
<div className="space-y-2">
|
||||
{scopedEvents.map(ev => (
|
||||
<div key={ev.id} className="border rounded-lg p-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
|
||||
{ev.eventOptions.map(o => (
|
||||
<div key={o.id}>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
|
||||
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
|
||||
</label>
|
||||
{(o.variants || []).map(v => (
|
||||
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
|
||||
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
|
||||
{v.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
|
||||
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
|
||||
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
|
||||
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
|
||||
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
|
||||
<option value="anyone">Anyone with the code</option>
|
||||
<option value="roles">People with certain roles</option>
|
||||
<option value="users">Specific people</option>
|
||||
<option value="domain">An email domain</option>
|
||||
</select>
|
||||
{d.restrictTo !== "anyone" && (
|
||||
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can't be shared in group chats.</p>
|
||||
)}
|
||||
|
||||
{d.restrictTo === "roles" && (
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
|
||||
<label key={r} className="flex items-center gap-2 text-sm capitalize">
|
||||
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
|
||||
{r === "user" ? "Member" : r}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.restrictTo === "domain" && (
|
||||
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
|
||||
)}
|
||||
|
||||
{d.restrictTo === "users" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{d.users.map(u => (
|
||||
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
|
||||
{u.name}
|
||||
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
|
||||
{userResults.length > 0 && (
|
||||
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
|
||||
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
|
||||
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
|
||||
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
|
||||
</label>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t">
|
||||
<Sheet
|
||||
title={couponId ? "Edit code" : "New coupon code"}
|
||||
onClose={onClose}
|
||||
onSubmit={save}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={label}>Code</label>
|
||||
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
|
||||
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>Description {optional}</label>
|
||||
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
|
||||
<p className="text-xs text-gray-500 mt-1">Only staff see this.</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Discount</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([["percent", "Percentage", "e.g. 20% off"], ["fixed", "Amount off", "e.g. R50 off"], ["comp", "Free", "100% off"]] as [CouponType, string, string][]).map(([t, name, hint]) => (
|
||||
<button type="button" key={t} onClick={() => set("type", t)} aria-pressed={d.type === t}
|
||||
className={`text-left rounded-lg border p-2.5 transition-colors ${d.type === t ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
|
||||
<div className="text-sm font-medium text-gray-900">{name}</div>
|
||||
<div className="text-xs text-gray-500">{hint}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{d.type !== "comp" && (
|
||||
<div className="mt-3 sm:max-w-[12rem]">
|
||||
<label className={label}>{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
|
||||
<input type="number" min="0" inputMode="decimal" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<label className="mt-3 flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
|
||||
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={label}>Events</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{d.eventIds.length === 0 ? "None picked: the code works for every event." : `${d.eventIds.length} picked. The code only works for ${d.eventIds.length === 1 ? "this event" : "these events"}.`}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
|
||||
<input className={`${inputCls} pl-8`} placeholder="Find an event" value={eventQuery} onChange={e => setEventQuery(e.target.value)} />
|
||||
</div>
|
||||
{pastCount > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 shrink-0">
|
||||
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
|
||||
{eventChoices.map(ev => (
|
||||
<label key={ev.id} className={`flex items-center gap-2 px-3 py-2 text-sm ${d.eventIds.includes(ev.id) ? "bg-brand-50/60" : ""}`}>
|
||||
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
|
||||
<span className="min-w-0 flex-1 truncate">{ev.title}</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">{fmtDate(ev.startDate)}{isPast(ev) ? " · past" : ""}</span>
|
||||
</label>
|
||||
))}
|
||||
{eventChoices.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">{eventQuery ? "No events match." : "No upcoming events."}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scopedEvents.length > 0 && (
|
||||
<div>
|
||||
<label className={label}>Only these tickets / items {optional}</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
|
||||
<div className="space-y-2">
|
||||
{scopedEvents.map(ev => (
|
||||
<div key={ev.id} className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
|
||||
{ev.eventOptions.map(o => (
|
||||
<div key={o.id}>
|
||||
<label className="flex items-center gap-2 text-sm py-0.5">
|
||||
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
|
||||
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
|
||||
</label>
|
||||
{(o.variants || []).map(v => (
|
||||
<label key={v.id} className="flex items-center gap-2 text-sm ml-6 py-0.5">
|
||||
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
|
||||
{v.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={label}>Valid from {optional}</label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>Valid until {optional}</label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={label}>Max total uses</label>
|
||||
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>Max uses per person</label>
|
||||
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>Minimum tickets</label>
|
||||
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={label}>Who can use it</label>
|
||||
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
|
||||
<option value="anyone">Anyone with the code</option>
|
||||
<option value="roles">People with certain roles</option>
|
||||
<option value="users">Specific people</option>
|
||||
<option value="domain">An email domain</option>
|
||||
</select>
|
||||
{d.restrictTo !== "anyone" && (
|
||||
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can't be shared in group chats.</p>
|
||||
)}
|
||||
|
||||
{d.restrictTo === "roles" && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 mt-2">
|
||||
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
|
||||
<label key={r} className="flex items-center gap-2 text-sm capitalize">
|
||||
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
|
||||
{r === "user" ? "Member" : r}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.restrictTo === "domain" && (
|
||||
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
|
||||
)}
|
||||
|
||||
{d.restrictTo === "users" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{d.users.map(u => (
|
||||
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
|
||||
{u.name}
|
||||
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
|
||||
{userResults.length > 0 && (
|
||||
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
|
||||
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
|
||||
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
|
||||
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={d.active} onChange={e => set("active", e.target.checked)} />
|
||||
<span>Active <span className="block text-xs text-gray-500">Discounts already given keep their amount if you turn it off later.</span></span>
|
||||
</label>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user