Reverted to commit a06d538170

This commit is contained in:
gpt-engineer-app[bot]
2026-01-06 23:56:25 +00:00
parent 93e454a42e
commit fdf6b08c07
9 changed files with 1605 additions and 160 deletions
+367
View File
@@ -0,0 +1,367 @@
import { useState, useEffect, useMemo } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line,
Legend,
} from "recharts";
import {
TrendingUp,
TrendingDown,
DollarSign,
ShoppingCart,
Users,
CreditCard,
Calendar,
RefreshCw,
} from "lucide-react";
import { CONFIG } from "@/config/payments";
interface Order {
id: string;
amount: number;
status: string;
payment_method: string;
created_at: string;
customer_email: string;
}
const COLORS = ["hsl(142, 76%, 36%)", "hsl(38, 92%, 50%)", "hsl(0, 84%, 60%)", "hsl(240, 4%, 46%)"];
const AnalyticsTab = () => {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState("30");
useEffect(() => {
fetchOrders();
}, [timeRange]);
const fetchOrders = async () => {
setLoading(true);
try {
const startDate = new Date();
startDate.setDate(startDate.getDate() - parseInt(timeRange));
const { data, error } = await supabase
.from("orders")
.select("id, amount, status, payment_method, created_at, customer_email")
.gte("created_at", startDate.toISOString())
.order("created_at", { ascending: true });
if (error) throw error;
setOrders(data || []);
} catch (error) {
console.error("Failed to fetch analytics data:", error);
} finally {
setLoading(false);
}
};
const stats = useMemo(() => {
const verifiedOrders = orders.filter(o => o.status === "verified" || o.status === "completed");
const totalRevenue = verifiedOrders.reduce((sum, o) => sum + Number(o.amount), 0);
const avgOrderValue = verifiedOrders.length > 0 ? totalRevenue / verifiedOrders.length : 0;
const uniqueCustomers = new Set(orders.map(o => o.customer_email)).size;
const conversionRate = orders.length > 0 ? (verifiedOrders.length / orders.length) * 100 : 0;
return {
totalRevenue,
totalOrders: orders.length,
verifiedOrders: verifiedOrders.length,
avgOrderValue,
uniqueCustomers,
conversionRate,
};
}, [orders]);
const revenueByDay = useMemo(() => {
const grouped: Record<string, { date: string; revenue: number; orders: number }> = {};
orders.forEach(order => {
if (order.status === "verified" || order.status === "completed") {
const date = new Date(order.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
if (!grouped[date]) {
grouped[date] = { date, revenue: 0, orders: 0 };
}
grouped[date].revenue += Number(order.amount);
grouped[date].orders += 1;
}
});
return Object.values(grouped);
}, [orders]);
const paymentMethodData = useMemo(() => {
const grouped: Record<string, number> = {};
orders.forEach(order => {
const method = order.payment_method || "unknown";
grouped[method] = (grouped[method] || 0) + 1;
});
return Object.entries(grouped).map(([name, value]) => ({
name: name.charAt(0).toUpperCase() + name.slice(1),
value,
}));
}, [orders]);
const statusData = useMemo(() => {
const grouped: Record<string, number> = {};
orders.forEach(order => {
grouped[order.status] = (grouped[order.status] || 0) + 1;
});
return Object.entries(grouped).map(([name, value]) => ({
name: name.charAt(0).toUpperCase() + name.slice(1),
value,
}));
}, [orders]);
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
Loading analytics...
</div>
);
}
return (
<div className="space-y-6">
{/* Time Range Selector */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5" />
Analytics Overview
</h2>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
<SelectItem value="365">Last year</SelectItem>
</SelectContent>
</Select>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Revenue
</CardTitle>
<DollarSign className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-primary">
{CONFIG.store.currencySymbol}{stats.totalRevenue.toFixed(2)}
</div>
<p className="text-xs text-muted-foreground mt-1">
From {stats.verifiedOrders} verified orders
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Orders
</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalOrders}</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.verifiedOrders} completed
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg. Order Value
</CardTitle>
<TrendingUp className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{CONFIG.store.currencySymbol}{stats.avgOrderValue.toFixed(2)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Per verified order
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Conversion Rate
</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.conversionRate.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.uniqueCustomers} unique customers
</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid lg:grid-cols-2 gap-6">
{/* Revenue Over Time */}
<Card>
<CardHeader>
<CardTitle className="text-base">Revenue Over Time</CardTitle>
</CardHeader>
<CardContent>
{revenueByDay.length === 0 ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No revenue data for this period
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueByDay}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="date"
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
/>
<YAxis
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
labelStyle={{ color: "hsl(var(--foreground))" }}
formatter={(value: number) => [`$${value.toFixed(2)}`, "Revenue"]}
/>
<Line
type="monotone"
dataKey="revenue"
stroke="hsl(142, 76%, 36%)"
strokeWidth={2}
dot={{ fill: "hsl(142, 76%, 36%)" }}
/>
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Orders by Status */}
<Card>
<CardHeader>
<CardTitle className="text-base">Orders by Status</CardTitle>
</CardHeader>
<CardContent>
{statusData.length === 0 ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No order data for this period
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={statusData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
label={({ name, value }) => `${name}: ${value}`}
>
{statusData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Payment Methods */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Orders by Payment Method</CardTitle>
</CardHeader>
<CardContent>
{paymentMethodData.length === 0 ? (
<div className="h-[250px] flex items-center justify-center text-muted-foreground">
No payment data for this period
</div>
) : (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={paymentMethodData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis type="number" tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }} />
<YAxis
type="category"
dataKey="name"
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
formatter={(value: number) => [value, "Orders"]}
/>
<Bar dataKey="value" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
</div>
);
};
export default AnalyticsTab;
+456
View File
@@ -0,0 +1,456 @@
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;
+378
View File
@@ -0,0 +1,378 @@
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 { Textarea } from "@/components/ui/textarea";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Pencil, Trash2, Package, RefreshCw } from "lucide-react";
interface Product {
id: string;
sku: string;
name: string;
description: string | null;
price: number;
currency: string;
download_url: string | null;
is_active: boolean;
stock_quantity: number | null;
created_at: string;
}
const ProductsTab = () => {
const { toast } = useToast();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
sku: "",
name: "",
description: "",
price: "",
currency: "USD",
download_url: "",
is_active: true,
stock_quantity: "",
});
useEffect(() => {
fetchProducts();
}, []);
const fetchProducts = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from("products")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
setProducts(data || []);
} catch (error) {
console.error("Failed to fetch products:", error);
toast({
title: "Error",
description: "Failed to load products.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
sku: "",
name: "",
description: "",
price: "",
currency: "USD",
download_url: "",
is_active: true,
stock_quantity: "",
});
setEditingProduct(null);
};
const openEditDialog = (product: Product) => {
setEditingProduct(product);
setFormData({
sku: product.sku,
name: product.name,
description: product.description || "",
price: product.price.toString(),
currency: product.currency,
download_url: product.download_url || "",
is_active: product.is_active,
stock_quantity: product.stock_quantity?.toString() || "",
});
setDialogOpen(true);
};
const handleSubmit = async () => {
if (!formData.sku || !formData.name || !formData.price) {
toast({
title: "Validation Error",
description: "SKU, name, and price are required.",
variant: "destructive",
});
return;
}
setSaving(true);
try {
const productData = {
sku: formData.sku.trim(),
name: formData.name.trim(),
description: formData.description.trim() || null,
price: parseFloat(formData.price),
currency: formData.currency,
download_url: formData.download_url.trim() || null,
is_active: formData.is_active,
stock_quantity: formData.stock_quantity ? parseInt(formData.stock_quantity) : null,
};
if (editingProduct) {
const { error } = await supabase
.from("products")
.update(productData)
.eq("id", editingProduct.id);
if (error) throw error;
toast({ title: "Success", description: "Product updated." });
} else {
const { error } = await supabase.from("products").insert(productData);
if (error) throw error;
toast({ title: "Success", description: "Product created." });
}
setDialogOpen(false);
resetForm();
fetchProducts();
} catch (error: any) {
console.error("Failed to save product:", error);
toast({
title: "Error",
description: error.message || "Failed to save product.",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this product?")) return;
try {
const { error } = await supabase.from("products").delete().eq("id", id);
if (error) throw error;
toast({ title: "Success", description: "Product deleted." });
fetchProducts();
} catch (error) {
console.error("Failed to delete product:", error);
toast({
title: "Error",
description: "Failed to delete product.",
variant: "destructive",
});
}
};
const toggleActive = async (product: Product) => {
try {
const { error } = await supabase
.from("products")
.update({ is_active: !product.is_active })
.eq("id", product.id);
if (error) throw error;
fetchProducts();
} catch (error) {
console.error("Failed to toggle product:", error);
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Products
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={fetchProducts}>
<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 Product
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingProduct ? "Edit Product" : "Add Product"}</DialogTitle>
<DialogDescription>
{editingProduct ? "Update product details." : "Create a new product."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sku">SKU *</Label>
<Input
id="sku"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
placeholder="prod_001"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price">Price *</Label>
<Input
id="price"
type="number"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
placeholder="49.99"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Product Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Product description..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="download_url">Download URL</Label>
<Input
id="download_url"
value={formData.download_url}
onChange={(e) => setFormData({ ...formData, download_url: e.target.value })}
placeholder="https://..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="stock">Stock (optional)</Label>
<Input
id="stock"
type="number"
value={formData.stock_quantity}
onChange={(e) => setFormData({ ...formData, stock_quantity: e.target.value })}
placeholder="Unlimited"
/>
</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..." : editingProduct ? "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>
) : products.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No products yet</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Name</TableHead>
<TableHead>Price</TableHead>
<TableHead>Stock</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell className="font-mono text-sm">{product.sku}</TableCell>
<TableCell className="font-medium">{product.name}</TableCell>
<TableCell>${Number(product.price).toFixed(2)}</TableCell>
<TableCell>
{product.stock_quantity !== null ? product.stock_quantity : "∞"}
</TableCell>
<TableCell>
<Badge
variant={product.is_active ? "default" : "secondary"}
className="cursor-pointer"
onClick={() => toggleActive(product)}
>
{product.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(product)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(product.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};
export default ProductsTab;
+206
View File
@@ -0,0 +1,206 @@
import { useNavigate } from "react-router-dom";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useCart } from "@/contexts/CartContext";
import { CONFIG } from "@/config/payments";
import { Minus, Plus, Trash2, ShoppingBag, Package, Clock, Truck } from "lucide-react";
const CartDrawer = () => {
const navigate = useNavigate();
const { items, isOpen, setIsOpen, updateQuantity, removeItem, getSubtotal, clearCart } = useCart();
const subtotal = getSubtotal();
const hasPhysicalItems = items.some(item => item.product.type === 'physical');
const shippingCost = hasPhysicalItems
? (subtotal >= CONFIG.shipping.freeShippingThreshold ? 0 : CONFIG.shipping.flatRate)
: 0;
const total = subtotal + shippingCost;
const handleCheckout = () => {
setIsOpen(false);
// For multi-item cart, navigate to cart page
// For now, just go to checkout with first item
if (items.length === 1) {
navigate(`/checkout?product=${items[0].product.id}`);
} else {
navigate('/cart');
}
};
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent className="flex flex-col w-full sm:max-w-lg">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<ShoppingBag className="h-5 w-5" />
Shopping Cart ({items.length} {items.length === 1 ? 'item' : 'items'})
</SheetTitle>
</SheetHeader>
{items.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center py-12">
<ShoppingBag className="h-16 w-16 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">Your cart is empty</h3>
<p className="text-sm text-muted-foreground mb-6">
Add some products to get started
</p>
<Button onClick={() => setIsOpen(false)}>
Continue Shopping
</Button>
</div>
) : (
<>
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="space-y-4 py-4">
{items.map((item) => {
const isPhysical = item.product.type === 'physical';
return (
<div key={item.product.id} className="flex gap-4">
{/* Product Image */}
<div className="w-20 h-20 bg-muted rounded-lg flex items-center justify-center text-3xl shrink-0">
{item.product.image}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-foreground truncate">
{item.product.name}
</h4>
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
{isPhysical ? (
<>
<Package className="h-3 w-3" />
Physical
</>
) : (
<>
<Clock className="h-3 w-3" />
Digital
</>
)}
</div>
{/* Quantity Controls */}
<div className="flex items-center gap-2 mt-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-8 text-center text-sm font-medium">
{item.quantity}
</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive ml-auto"
onClick={() => removeItem(item.product.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Price */}
<div className="text-right shrink-0">
<p className="font-semibold text-foreground">
{CONFIG.store.currencySymbol}{(item.product.priceFiat * item.quantity).toFixed(2)}
</p>
{item.quantity > 1 && (
<p className="text-xs text-muted-foreground">
{CONFIG.store.currencySymbol}{item.product.priceFiat.toFixed(2)} each
</p>
)}
</div>
</div>
);
})}
</div>
</ScrollArea>
<div className="border-t pt-4 space-y-4">
{/* Order Summary */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="text-foreground">
{CONFIG.store.currencySymbol}{subtotal.toFixed(2)}
</span>
</div>
{hasPhysicalItems && (
<div className="flex justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Truck className="h-3 w-3" />
Shipping
</span>
<span className="text-foreground">
{shippingCost === 0 ? (
<span className="text-primary">FREE</span>
) : (
`${CONFIG.store.currencySymbol}${shippingCost.toFixed(2)}`
)}
</span>
</div>
)}
<Separator />
<div className="flex justify-between text-base font-semibold">
<span>Total</span>
<span className="text-primary">
{CONFIG.store.currencySymbol}{total.toFixed(2)}
</span>
</div>
</div>
{/* Free Shipping Notice */}
{hasPhysicalItems && shippingCost > 0 && (
<div className="text-xs text-muted-foreground text-center p-2 bg-muted/50 rounded">
Add {CONFIG.store.currencySymbol}{(CONFIG.shipping.freeShippingThreshold - subtotal).toFixed(2)} more for free shipping!
</div>
)}
<SheetFooter className="flex-col gap-2 sm:flex-col">
<Button className="w-full" size="lg" onClick={handleCheckout}>
Proceed to Checkout
</Button>
<div className="flex gap-2 w-full">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsOpen(false)}
>
Continue Shopping
</Button>
<Button
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={clearCart}
>
Clear Cart
</Button>
</div>
</SheetFooter>
</div>
</>
)}
</SheetContent>
</Sheet>
);
};
export default CartDrawer;