Files
personal_website/src/components/admin/DiscountCodesTab.tsx
T
2026-01-06 23:56:25 +00:00

457 lines
16 KiB
TypeScript

import { useState, useEffect } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Pencil, Trash2, Ticket, RefreshCw, Copy } from "lucide-react";
interface DiscountCode {
id: string;
code: string;
description: string | null;
discount_type: string;
discount_value: number;
min_order_amount: number;
max_uses: number | null;
current_uses: number;
valid_from: string;
valid_until: string | null;
is_active: boolean;
created_at: string;
}
const DiscountCodesTab = () => {
const { toast } = useToast();
const [codes, setCodes] = useState<DiscountCode[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCode, setEditingCode] = useState<DiscountCode | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
code: "",
description: "",
discount_type: "percentage",
discount_value: "",
min_order_amount: "0",
max_uses: "",
valid_until: "",
is_active: true,
});
useEffect(() => {
fetchCodes();
}, []);
const fetchCodes = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from("discount_codes")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
setCodes(data || []);
} catch (error) {
console.error("Failed to fetch discount codes:", error);
toast({
title: "Error",
description: "Failed to load discount codes.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
code: "",
description: "",
discount_type: "percentage",
discount_value: "",
min_order_amount: "0",
max_uses: "",
valid_until: "",
is_active: true,
});
setEditingCode(null);
};
const generateCode = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let code = "";
for (let i = 0; i < 8; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
setFormData({ ...formData, code });
};
const openEditDialog = (code: DiscountCode) => {
setEditingCode(code);
setFormData({
code: code.code,
description: code.description || "",
discount_type: code.discount_type,
discount_value: code.discount_value.toString(),
min_order_amount: code.min_order_amount.toString(),
max_uses: code.max_uses?.toString() || "",
valid_until: code.valid_until ? code.valid_until.split("T")[0] : "",
is_active: code.is_active,
});
setDialogOpen(true);
};
const handleSubmit = async () => {
if (!formData.code || !formData.discount_value) {
toast({
title: "Validation Error",
description: "Code and discount value are required.",
variant: "destructive",
});
return;
}
setSaving(true);
try {
const codeData = {
code: formData.code.toUpperCase().trim(),
description: formData.description.trim() || null,
discount_type: formData.discount_type,
discount_value: parseFloat(formData.discount_value),
min_order_amount: parseFloat(formData.min_order_amount) || 0,
max_uses: formData.max_uses ? parseInt(formData.max_uses) : null,
valid_until: formData.valid_until ? new Date(formData.valid_until).toISOString() : null,
is_active: formData.is_active,
};
if (editingCode) {
const { error } = await supabase
.from("discount_codes")
.update(codeData)
.eq("id", editingCode.id);
if (error) throw error;
toast({ title: "Success", description: "Discount code updated." });
} else {
const { error } = await supabase.from("discount_codes").insert(codeData);
if (error) throw error;
toast({ title: "Success", description: "Discount code created." });
}
setDialogOpen(false);
resetForm();
fetchCodes();
} catch (error: any) {
console.error("Failed to save discount code:", error);
toast({
title: "Error",
description: error.message || "Failed to save discount code.",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this discount code?")) return;
try {
const { error } = await supabase.from("discount_codes").delete().eq("id", id);
if (error) throw error;
toast({ title: "Success", description: "Discount code deleted." });
fetchCodes();
} catch (error) {
console.error("Failed to delete discount code:", error);
toast({
title: "Error",
description: "Failed to delete discount code.",
variant: "destructive",
});
}
};
const copyCode = (code: string) => {
navigator.clipboard.writeText(code);
toast({ title: "Copied!", description: `Code "${code}" copied to clipboard.` });
};
const toggleActive = async (code: DiscountCode) => {
try {
const { error } = await supabase
.from("discount_codes")
.update({ is_active: !code.is_active })
.eq("id", code.id);
if (error) throw error;
fetchCodes();
} catch (error) {
console.error("Failed to toggle code:", error);
}
};
const isExpired = (validUntil: string | null) => {
if (!validUntil) return false;
return new Date(validUntil) < new Date();
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Ticket className="h-5 w-5" />
Discount Codes
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={fetchCodes}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Code
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingCode ? "Edit Discount Code" : "Create Discount Code"}</DialogTitle>
<DialogDescription>
{editingCode ? "Update discount code details." : "Create a new discount code for customers."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="code">Code *</Label>
<div className="flex gap-2">
<Input
id="code"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
placeholder="SAVE20"
className="font-mono"
/>
<Button type="button" variant="outline" onClick={generateCode}>
Generate
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Summer sale discount"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Discount Type</Label>
<Select
value={formData.discount_type}
onValueChange={(value) => setFormData({ ...formData, discount_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percentage">Percentage (%)</SelectItem>
<SelectItem value="fixed">Fixed Amount ($)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="discount_value">
Value * ({formData.discount_type === "percentage" ? "%" : "$"})
</Label>
<Input
id="discount_value"
type="number"
step="0.01"
value={formData.discount_value}
onChange={(e) => setFormData({ ...formData, discount_value: e.target.value })}
placeholder={formData.discount_type === "percentage" ? "20" : "10.00"}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="min_order">Min. Order ($)</Label>
<Input
id="min_order"
type="number"
step="0.01"
value={formData.min_order_amount}
onChange={(e) => setFormData({ ...formData, min_order_amount: e.target.value })}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="max_uses">Max Uses</Label>
<Input
id="max_uses"
type="number"
value={formData.max_uses}
onChange={(e) => setFormData({ ...formData, max_uses: e.target.value })}
placeholder="Unlimited"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="valid_until">Expires On</Label>
<Input
id="valid_until"
type="date"
value={formData.valid_until}
onChange={(e) => setFormData({ ...formData, valid_until: e.target.value })}
/>
</div>
<div className="flex items-center space-x-2 pt-6">
<Switch
id="active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="active">Active</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? "Saving..." : editingCode ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
Loading...
</div>
) : codes.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Ticket className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No discount codes yet</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{codes.map((code) => (
<TableRow key={code.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{code.code}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyCode(code.code)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>
{code.discount_type === "percentage"
? `${code.discount_value}%`
: `$${Number(code.discount_value).toFixed(2)}`}
</TableCell>
<TableCell>
{code.current_uses}{code.max_uses ? `/${code.max_uses}` : ""}
</TableCell>
<TableCell>
{code.valid_until
? new Date(code.valid_until).toLocaleDateString()
: "Never"}
</TableCell>
<TableCell>
{isExpired(code.valid_until) ? (
<Badge variant="destructive">Expired</Badge>
) : (
<Badge
variant={code.is_active ? "default" : "secondary"}
className="cursor-pointer"
onClick={() => toggleActive(code)}
>
{code.is_active ? "Active" : "Inactive"}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(code)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(code.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};
export default DiscountCodesTab;