chore: add client/src/pages/admin/discounts.tsx
This commit is contained in:
parent
771d2001db
commit
a248dc559b
1 changed files with 184 additions and 0 deletions
184
client/src/pages/admin/discounts.tsx
Normal file
184
client/src/pages/admin/discounts.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { queryClient, apiRequest } from "../../lib/queryClient";
|
||||||
|
import { AdminNav } from "./dashboard";
|
||||||
|
import { toast } from "../../hooks/use-toast";
|
||||||
|
import { Plus, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface DiscountCode {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
minOrderAmount?: string;
|
||||||
|
maxUses?: number;
|
||||||
|
usedCount: number;
|
||||||
|
active: boolean;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscountModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [form, setForm] = useState({ code: "", type: "percentage", value: "", minOrderAmount: "", maxUses: "", expiresAt: "" });
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: object) => apiRequest("POST", "/api/admin/discounts", body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/admin/discounts"] });
|
||||||
|
toast({ title: "Discount code created" });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate({
|
||||||
|
...form,
|
||||||
|
maxUses: form.maxUses ? parseInt(form.maxUses) : null,
|
||||||
|
expiresAt: form.expiresAt || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl w-full max-w-md shadow-xl">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||||
|
<h2 className="font-semibold">New discount code</h2>
|
||||||
|
<button onClick={onClose}><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Code</label>
|
||||||
|
<input value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="SUMMER20" required
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background font-mono uppercase focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid="input-discount-code" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Type</label>
|
||||||
|
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none">
|
||||||
|
<option value="percentage">Percentage (%)</option>
|
||||||
|
<option value="fixed">Fixed amount ($)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Value</label>
|
||||||
|
<input value={form.value} onChange={(e) => setForm({ ...form, value: e.target.value })} required placeholder={form.type === "percentage" ? "20" : "10.00"}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Min order ($)</label>
|
||||||
|
<input value={form.minOrderAmount} onChange={(e) => setForm({ ...form, minOrderAmount: e.target.value })} placeholder="0.00"
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Max uses (blank = unlimited)</label>
|
||||||
|
<input value={form.maxUses} onChange={(e) => setForm({ ...form, maxUses: e.target.value })} placeholder="unlimited"
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Expires at (optional)</label>
|
||||||
|
<input type="date" value={form.expiresAt} onChange={(e) => setForm({ ...form, expiresAt: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 border border-border rounded-md text-sm hover:bg-muted transition-colors">Cancel</button>
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="flex-1 bg-primary text-primary-foreground py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-60">
|
||||||
|
{mutation.isPending ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDiscounts() {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: codes, isLoading } = useQuery<DiscountCode[]>({
|
||||||
|
queryKey: ["/api/admin/discounts"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await fetch("/api/admin/discounts", { credentials: "include" });
|
||||||
|
if (r.status === 401) { window.location.href = "/admin/login"; return []; }
|
||||||
|
return r.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiRequest("DELETE", `/api/admin/discounts/${id}`),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/admin/discounts"] }); toast({ title: "Discount deleted" }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, active }: { id: number; active: boolean }) => apiRequest("PATCH", `/api/admin/discounts/${id}`, { active }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["/api/admin/discounts"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden" data-testid="admin-discounts">
|
||||||
|
<AdminNav active="/admin/discounts" />
|
||||||
|
<main className="flex-1 overflow-y-auto p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Discount codes</h1>
|
||||||
|
<button onClick={() => setShowModal(true)}
|
||||||
|
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
data-testid="button-new-discount">
|
||||||
|
<Plus size={14} /> New code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Code</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Discount</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">Uses</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">Expires</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<tr key={i}><td colSpan={6} className="px-4 py-3"><div className="h-4 bg-muted rounded animate-pulse" /></td></tr>
|
||||||
|
))
|
||||||
|
) : codes?.map((c) => (
|
||||||
|
<tr key={c.id} className="hover:bg-muted/20 transition-colors" data-testid={`discount-row-${c.id}`}>
|
||||||
|
<td className="px-4 py-3 font-mono font-semibold text-sm">{c.code}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">{c.type === "percentage" ? `${c.value}% off` : `$${c.value} off`}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center">{c.usedCount}{c.maxUses ? ` / ${c.maxUses}` : ""}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button onClick={() => toggleMutation.mutate({ id: c.id, active: !c.active })}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-colors ${c.active ? "bg-green-100 text-green-700 hover:bg-green-200" : "bg-muted text-muted-foreground hover:bg-muted/80"}`}>
|
||||||
|
{c.active ? "Active" : "Inactive"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center text-muted-foreground">{c.expiresAt ? new Date(c.expiresAt).toLocaleDateString() : "Never"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => { if (confirm("Delete this code?")) deleteMutation.mutate(c.id); }}
|
||||||
|
className="p-1.5 text-muted-foreground hover:text-destructive transition-colors">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && !codes?.length && (
|
||||||
|
<tr><td colSpan={6} className="text-center py-12 text-muted-foreground">No discount codes yet</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{showModal && <DiscountModal onClose={() => setShowModal(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue