Reverted to commit a06d538170
This commit is contained in:
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -62,12 +62,7 @@ const Breakout = () => {
|
|||||||
if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; }
|
if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; }
|
||||||
return { width: Math.floor(width), height: Math.floor(height) };
|
return { width: Math.floor(width), height: Math.floor(height) };
|
||||||
}
|
}
|
||||||
// Desktop: maximize based on available height
|
return isFullscreen ? { width: 600, height: 720 } : { width: 480, height: 580 };
|
||||||
const availableHeight = window.innerHeight - 100;
|
|
||||||
const aspectRatio = 480 / 580;
|
|
||||||
const height = Math.min(availableHeight, 700);
|
|
||||||
const width = Math.floor(height * aspectRatio);
|
|
||||||
return { width, height };
|
|
||||||
}, [isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
const [canvasSize, setCanvasSize] = useState(getCanvasSize);
|
const [canvasSize, setCanvasSize] = useState(getCanvasSize);
|
||||||
@@ -266,7 +261,7 @@ const Breakout = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4 items-start justify-center'} flex-1`}>
|
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : ''}`}>
|
||||||
<div className="border-2 border-primary box-glow bg-background/80">
|
<div className="border-2 border-primary box-glow bg-background/80">
|
||||||
<canvas ref={canvasRef} width={canvasSize.width} height={canvasSize.height} onTouchMove={handleTouchMove} className="block" />
|
<canvas ref={canvasRef} width={canvasSize.width} height={canvasSize.height} onTouchMove={handleTouchMove} className="block" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+121
-82
@@ -67,8 +67,6 @@ const Pacman = () => {
|
|||||||
const [player1Eliminated, setPlayer1Eliminated] = useState(false);
|
const [player1Eliminated, setPlayer1Eliminated] = useState(false);
|
||||||
const [player2Eliminated, setPlayer2Eliminated] = useState(false);
|
const [player2Eliminated, setPlayer2Eliminated] = useState(false);
|
||||||
const [showEliminationPrompt, setShowEliminationPrompt] = useState(false);
|
const [showEliminationPrompt, setShowEliminationPrompt] = useState(false);
|
||||||
const [player1Invincible, setPlayer1Invincible] = useState(false);
|
|
||||||
const [player2Invincible, setPlayer2Invincible] = useState(false);
|
|
||||||
const [ghosts, setGhosts] = useState<{ pos: Position; dir: Direction; eaten: boolean }[]>([
|
const [ghosts, setGhosts] = useState<{ pos: Position; dir: Direction; eaten: boolean }[]>([
|
||||||
{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false },
|
{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false },
|
||||||
{ pos: { x: 10, y: 9 }, dir: 'up', eaten: false },
|
{ pos: { x: 10, y: 9 }, dir: 'up', eaten: false },
|
||||||
@@ -101,11 +99,7 @@ const Pacman = () => {
|
|||||||
const maxHeight = window.innerHeight - 420;
|
const maxHeight = window.innerHeight - 420;
|
||||||
return Math.min(Math.floor(maxWidth / GRID_WIDTH), Math.floor(maxHeight / GRID_HEIGHT), 16);
|
return Math.min(Math.floor(maxWidth / GRID_WIDTH), Math.floor(maxHeight / GRID_HEIGHT), 16);
|
||||||
}
|
}
|
||||||
// Desktop: calculate based on available height (subtract header ~60px, margins ~40px)
|
return isFullscreen ? 26 : 20;
|
||||||
const availableHeight = window.innerHeight - 100;
|
|
||||||
const maxCellFromHeight = Math.floor(availableHeight / GRID_HEIGHT);
|
|
||||||
// Clamp to reasonable max
|
|
||||||
return Math.min(maxCellFromHeight, 28);
|
|
||||||
}, [isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
const [cellSize, setCellSize] = useState(getCellSize);
|
const [cellSize, setCellSize] = useState(getCellSize);
|
||||||
@@ -194,44 +188,10 @@ const Pacman = () => {
|
|||||||
setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setScore2(0); setLevel(1);
|
setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setScore2(0); setLevel(1);
|
||||||
setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false);
|
setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false);
|
||||||
setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false);
|
setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false);
|
||||||
setPlayer1Invincible(false); setPlayer2Invincible(false);
|
|
||||||
playSound('success'); gameRef.current?.focus();
|
playSound('success'); gameRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const continueIn1PMode = () => {
|
const continueIn1PMode = () => {
|
||||||
// Transfer the surviving player's state to Player 1
|
|
||||||
if (player1Eliminated && !player2Eliminated) {
|
|
||||||
// Player 1 died, Player 2 survives - transfer P2 to P1
|
|
||||||
setPacman(pacman2);
|
|
||||||
setDirection(direction2);
|
|
||||||
setNextDirection(nextDirection2);
|
|
||||||
setMouthOpen(mouthOpen2);
|
|
||||||
setLives(lives2);
|
|
||||||
setScore(score2);
|
|
||||||
setPlayer1Invincible(player2Invincible);
|
|
||||||
|
|
||||||
// Reset Player 2
|
|
||||||
setPacman2({ x: -1, y: -1 });
|
|
||||||
setDirection2('right');
|
|
||||||
setNextDirection2('right');
|
|
||||||
setMouthOpen2(true);
|
|
||||||
setLives2(3);
|
|
||||||
setScore2(0);
|
|
||||||
setPlayer2Invincible(false);
|
|
||||||
} else if (player2Eliminated && !player1Eliminated) {
|
|
||||||
// Player 2 died, Player 1 survives - P1 already active, just reset P2
|
|
||||||
setPacman2({ x: -1, y: -1 });
|
|
||||||
setDirection2('right');
|
|
||||||
setNextDirection2('right');
|
|
||||||
setMouthOpen2(true);
|
|
||||||
setLives2(3);
|
|
||||||
setScore2(0);
|
|
||||||
setPlayer2Invincible(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset eliminated states and continue
|
|
||||||
setPlayer1Eliminated(false);
|
|
||||||
setPlayer2Eliminated(false);
|
|
||||||
setGameMode('1p');
|
setGameMode('1p');
|
||||||
setShowEliminationPrompt(false);
|
setShowEliminationPrompt(false);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
@@ -361,16 +321,14 @@ const Pacman = () => {
|
|||||||
return ns;
|
return ns;
|
||||||
});
|
});
|
||||||
playSound('success');
|
playSound('success');
|
||||||
} else if (!ghost.eaten && !player1Invincible) {
|
} else if (!ghost.eaten) {
|
||||||
// Player 1 dies (only if not invincible)
|
// Player 1 dies
|
||||||
const newLives = lives - 1;
|
const newLives = lives - 1;
|
||||||
setLives(newLives);
|
setLives(newLives);
|
||||||
if (newLives > 0) {
|
if (newLives > 0) {
|
||||||
setPacman({ x: 10, y: 15 });
|
setPacman({ x: 10, y: 15 });
|
||||||
setPlayer1Invincible(true);
|
playSound('error');
|
||||||
setTimeout(() => setPlayer1Invincible(false), 3000); // 3 seconds of invincibility
|
} else {
|
||||||
playSound('error');
|
|
||||||
} else {
|
|
||||||
setPlayer1Eliminated(true);
|
setPlayer1Eliminated(true);
|
||||||
setPacman({ x: -1, y: -1 });
|
setPacman({ x: -1, y: -1 });
|
||||||
playSound('error');
|
playSound('error');
|
||||||
@@ -411,54 +369,135 @@ const Pacman = () => {
|
|||||||
return { ...ghost, pos: moveEntity(ghost.pos, dir), dir };
|
return { ...ghost, pos: moveEntity(ghost.pos, dir), dir };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Now check for collisions after all movement is complete
|
||||||
|
// Check Player 1 collisions (from both player movement and ghost movement)
|
||||||
|
if (!player1Eliminated) {
|
||||||
|
setGhosts(currentGhosts => {
|
||||||
|
for (let i = 0; i < currentGhosts.length; i++) {
|
||||||
|
const ghost = currentGhosts[i];
|
||||||
|
if (ghost.pos.x === pacman.x && ghost.pos.y === pacman.y) {
|
||||||
|
if (isPowered && !ghost.eaten) {
|
||||||
|
// Player eats ghost
|
||||||
|
setScore(prev => {
|
||||||
|
const ns = Math.min(prev + 200, MAX_SCORE);
|
||||||
|
if (ns >= MAX_SCORE) setShowGlitchCrash(true);
|
||||||
|
if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); }
|
||||||
|
return ns;
|
||||||
|
});
|
||||||
|
playSound('success');
|
||||||
|
return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g);
|
||||||
|
} else if (!ghost.eaten) {
|
||||||
|
// Ghost kills player
|
||||||
|
const newLives = lives - 1;
|
||||||
|
setLives(newLives);
|
||||||
|
if (newLives > 0) {
|
||||||
|
setPacman({ x: 10, y: 15 });
|
||||||
|
playSound('error');
|
||||||
|
} else {
|
||||||
|
setPlayer1Eliminated(true);
|
||||||
|
setPacman({ x: -1, y: -1 });
|
||||||
|
playSound('error');
|
||||||
|
if (gameMode === '2p' && !player2Eliminated) {
|
||||||
|
setShowEliminationPrompt(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
} else {
|
||||||
|
setGameOver(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentGhosts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Player 2 collisions
|
||||||
|
if (gameMode === '2p' && !player2Eliminated) {
|
||||||
|
setGhosts(currentGhosts => {
|
||||||
|
for (let i = 0; i < currentGhosts.length; i++) {
|
||||||
|
const ghost = currentGhosts[i];
|
||||||
|
if (ghost.pos.x === pacman2.x && ghost.pos.y === pacman2.y) {
|
||||||
|
if (isPowered && !ghost.eaten) {
|
||||||
|
// Player eats ghost
|
||||||
|
setScore2(prev => {
|
||||||
|
const ns = Math.min(prev + 200, MAX_SCORE);
|
||||||
|
if (ns >= MAX_SCORE) setShowGlitchCrash(true);
|
||||||
|
if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); }
|
||||||
|
return ns;
|
||||||
|
});
|
||||||
|
playSound('success');
|
||||||
|
return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g);
|
||||||
|
} else if (!ghost.eaten) {
|
||||||
|
// Ghost kills player
|
||||||
|
const newLives = lives2 - 1;
|
||||||
|
setLives2(newLives);
|
||||||
|
if (newLives > 0) {
|
||||||
|
setPacman2({ x: 10, y: 15 });
|
||||||
|
playSound('error');
|
||||||
|
} else {
|
||||||
|
setPlayer2Eliminated(true);
|
||||||
|
setPacman2({ x: -1, y: -1 });
|
||||||
|
playSound('error');
|
||||||
|
if (!player1Eliminated) {
|
||||||
|
setShowEliminationPrompt(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
} else {
|
||||||
|
setGameOver(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentGhosts;
|
||||||
|
});
|
||||||
|
}
|
||||||
}, TICK_SPEED);
|
}, TICK_SPEED);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated]);
|
}, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// More responsive input - update direction immediately when pressed
|
// More responsive input - update direction immediately when pressed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!gameStarted || gameOver || gameComplete || isPaused) return;
|
if (!gameStarted || gameOver || gameComplete || isPaused) return;
|
||||||
|
|
||||||
// Player 1 controls - WASD always, arrows only in 1P mode
|
// Player 1 controls (WASD and Arrow keys) - immediate response
|
||||||
if (e.key === 'w' || e.key === 'W' || e.key === 's' || e.key === 'S' || e.key === 'a' || e.key === 'A' || e.key === 'd' || e.key === 'D' ||
|
switch (e.key) {
|
||||||
(gameMode === '1p' && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight'))) {
|
case 'ArrowUp': case 'w': case 'W':
|
||||||
switch (e.key) {
|
e.preventDefault();
|
||||||
case 'ArrowUp': case 'w': case 'W':
|
if (!player1Eliminated) setNextDirection('up');
|
||||||
e.preventDefault();
|
break;
|
||||||
if (!player1Eliminated) setNextDirection('up');
|
case 'ArrowDown': case 's': case 'S':
|
||||||
break;
|
e.preventDefault();
|
||||||
case 'ArrowDown': case 's': case 'S':
|
if (!player1Eliminated) setNextDirection('down');
|
||||||
e.preventDefault();
|
break;
|
||||||
if (!player1Eliminated) setNextDirection('down');
|
case 'ArrowLeft': case 'a': case 'A':
|
||||||
break;
|
e.preventDefault();
|
||||||
case 'ArrowLeft': case 'a': case 'A':
|
if (!player1Eliminated) setNextDirection('left');
|
||||||
e.preventDefault();
|
break;
|
||||||
if (!player1Eliminated) setNextDirection('left');
|
case 'ArrowRight': case 'd': case 'D':
|
||||||
break;
|
e.preventDefault();
|
||||||
case 'ArrowRight': case 'd': case 'D':
|
if (!player1Eliminated) setNextDirection('right');
|
||||||
e.preventDefault();
|
break;
|
||||||
if (!player1Eliminated) setNextDirection('right');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player 2 controls (Arrow keys) - only in 2P mode
|
// Player 2 controls (IJKL keys) - only in 2P mode
|
||||||
if (gameMode === '2p') {
|
if (gameMode === '2p') {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'i': case 'I':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('up');
|
if (!player2Eliminated) setNextDirection2('up');
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'k': case 'K':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('down');
|
if (!player2Eliminated) setNextDirection2('down');
|
||||||
break;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'j': case 'J':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('left');
|
if (!player2Eliminated) setNextDirection2('left');
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'l': case 'L':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!player2Eliminated) setNextDirection2('right');
|
if (!player2Eliminated) setNextDirection2('right');
|
||||||
break;
|
break;
|
||||||
@@ -531,8 +570,8 @@ const Pacman = () => {
|
|||||||
const isPowerPellet = powerPellets.has(`${x},${y}`);
|
const isPowerPellet = powerPellets.has(`${x},${y}`);
|
||||||
cells.push(
|
cells.push(
|
||||||
<div key={`${x}-${y}`} className={`flex items-center justify-center transition-all duration-200 ease-out ${isWall ? 'bg-primary/20 border border-primary/40' : isTunnel ? 'bg-background/30' : 'bg-background/50 border border-primary/5'}`} style={{ width: cellSize, height: cellSize }}>
|
<div key={`${x}-${y}`} className={`flex items-center justify-center transition-all duration-200 ease-out ${isWall ? 'bg-primary/20 border border-primary/40' : isTunnel ? 'bg-background/30' : 'bg-background/50 border border-primary/5'}`} style={{ width: cellSize, height: cellSize }}>
|
||||||
{isPacmanHere && <div className={`drop-shadow-lg ${player1Invincible ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: player1Invincible ? 'drop-shadow(0 0 8px hsl(var(--primary))) brightness(1.5)' : 'drop-shadow(0 0 4px hsl(var(--primary)))', opacity: player1Invincible ? 0.8 : 1 }}>{renderPacman()}</div>}
|
{isPacmanHere && <div className="drop-shadow-lg" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(var(--primary)))' }}>{renderPacman()}</div>}
|
||||||
{isPacman2Here && <div className={`drop-shadow-lg ${player2Invincible ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: player2Invincible ? 'drop-shadow(0 0 8px hsl(120 70% 50%)) brightness(1.5)' : 'drop-shadow(0 0 4px hsl(120 70% 50%))', opacity: player2Invincible ? 0.8 : 1 }}>{renderPacman2()}</div>}
|
{isPacman2Here && <div className="drop-shadow-lg" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(120 70% 50%))' }}>{renderPacman2()}</div>}
|
||||||
{ghostIndex !== -1 && !isPacmanHere && !isPacman2Here && <div style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out' }}>{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}</div>}
|
{ghostIndex !== -1 && !isPacmanHere && !isPacman2Here && <div style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out' }}>{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}</div>}
|
||||||
{isDot && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-1.5 h-1.5 bg-primary/80 rounded-full transition-all duration-200 ease-out animate-pulse" style={{ animationDuration: '2s' }} />}
|
{isDot && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-1.5 h-1.5 bg-primary/80 rounded-full transition-all duration-200 ease-out animate-pulse" style={{ animationDuration: '2s' }} />}
|
||||||
{isPowerPellet && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-3 h-3 bg-primary rounded-full animate-pulse box-glow transition-all duration-200 ease-out" />}
|
{isPowerPellet && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-3 h-3 bg-primary rounded-full animate-pulse box-glow transition-all duration-200 ease-out" />}
|
||||||
@@ -558,7 +597,7 @@ const Pacman = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4 items-start justify-center'} flex-1`}>
|
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : ''}`}>
|
||||||
<div className="border-2 border-primary box-glow p-1 bg-background/80 relative overflow-hidden">
|
<div className="border-2 border-primary box-glow p-1 bg-background/80 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 animate-pulse" style={{ animationDuration: '4s' }}></div>
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 animate-pulse" style={{ animationDuration: '4s' }}></div>
|
||||||
<div className="grid relative z-10" style={{ gridTemplateColumns: `repeat(${GRID_WIDTH}, ${cellSize}px)` }}>{renderGrid()}</div>
|
<div className="grid relative z-10" style={{ gridTemplateColumns: `repeat(${GRID_WIDTH}, ${cellSize}px)` }}>{renderGrid()}</div>
|
||||||
@@ -595,8 +634,8 @@ const Pacman = () => {
|
|||||||
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
|
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
|
||||||
{gameMode === '2p' ? (
|
{gameMode === '2p' ? (
|
||||||
<>
|
<>
|
||||||
<p className="font-pixel text-[8px] text-foreground/80">P1: WASD</p>
|
<p className="font-pixel text-[8px] text-foreground/80">P1: WASD / ←→↑↓</p>
|
||||||
<p className="font-pixel text-[8px] text-foreground/80">P2: ← → ↑ ↓</p>
|
<p className="font-pixel text-[8px] text-foreground/80">P2: I J K L</p>
|
||||||
<p className="font-pixel text-[8px] text-foreground/80">P: Pause</p>
|
<p className="font-pixel text-[8px] text-foreground/80">P: Pause</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+5
-6
@@ -75,11 +75,10 @@ const Snake = () => {
|
|||||||
const maxHeight = window.innerHeight - 420;
|
const maxHeight = window.innerHeight - 420;
|
||||||
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 18);
|
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 18);
|
||||||
}
|
}
|
||||||
// Desktop: calculate based on available height (subtract header ~60px, margins ~40px)
|
if (gameMode === '2p') {
|
||||||
const availableHeight = window.innerHeight - 100;
|
return isFullscreen ? 20 : 16;
|
||||||
const maxCellFromHeight = Math.floor(availableHeight / GRID_SIZE);
|
}
|
||||||
// Clamp to reasonable max
|
return isFullscreen ? 28 : 24;
|
||||||
return Math.min(maxCellFromHeight, 30);
|
|
||||||
}, [isFullscreen, gameMode]);
|
}, [isFullscreen, gameMode]);
|
||||||
|
|
||||||
const [cellSize, setCellSize] = useState(getCellSize);
|
const [cellSize, setCellSize] = useState(getCellSize);
|
||||||
@@ -539,7 +538,7 @@ const Snake = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4 items-start justify-center'} flex-1`}>
|
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : ''}`}>
|
||||||
<div className="border-2 border-primary box-glow p-1 bg-background/80">
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">
|
||||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, ${cellSize}px)` }}>
|
<div className="grid" style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, ${cellSize}px)` }}>
|
||||||
{gameMode === '1p' ? renderGrid1P() : renderGrid2P()}
|
{gameMode === '1p' ? renderGrid1P() : renderGrid2P()}
|
||||||
|
|||||||
+55
-65
@@ -148,17 +148,10 @@ const Tetris = () => {
|
|||||||
const maxHeight = window.innerHeight - 440;
|
const maxHeight = window.innerHeight - 440;
|
||||||
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 22);
|
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 22);
|
||||||
}
|
}
|
||||||
// Desktop: calculate based on available height (subtract header ~60px, margins ~40px)
|
|
||||||
const availableHeight = window.innerHeight - 100;
|
|
||||||
const maxCellFromHeight = Math.floor(availableHeight / BOARD_HEIGHT);
|
|
||||||
if (gameMode === '2p') {
|
if (gameMode === '2p') {
|
||||||
// 2P needs two boards side by side plus controls (~500px for both boards + gaps)
|
return isFullscreen ? 22 : 18;
|
||||||
const availableWidth = window.innerWidth - 300; // space for controls
|
|
||||||
const maxCellFromWidth = Math.floor(availableWidth / (BOARD_WIDTH * 2 + 4));
|
|
||||||
return Math.min(maxCellFromHeight, maxCellFromWidth, 28);
|
|
||||||
}
|
}
|
||||||
// 1P mode - maximize based on height
|
return isFullscreen ? 30 : 24;
|
||||||
return Math.min(maxCellFromHeight, 32);
|
|
||||||
}, [isFullscreen, gameMode]);
|
}, [isFullscreen, gameMode]);
|
||||||
|
|
||||||
const [cellSize, setCellSize] = useState(getCellSize);
|
const [cellSize, setCellSize] = useState(getCellSize);
|
||||||
@@ -605,7 +598,7 @@ const Tetris = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-3 items-start justify-center'} flex-1`}>
|
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : ''}`}>
|
||||||
{gameMode === '1p' ? (
|
{gameMode === '1p' ? (
|
||||||
<>
|
<>
|
||||||
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard1P()}</div>
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard1P()}</div>
|
||||||
@@ -636,44 +629,42 @@ const Tetris = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* 2P Layout: P1 Board with Next | Controls | P2 Board with Next */
|
|
||||||
<>
|
<>
|
||||||
{/* P1 Section */}
|
{/* P1 Board */}
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="w-3 h-3 rounded-sm bg-primary" />
|
||||||
<div className="w-3 h-3 rounded-sm bg-primary" />
|
<span className="font-pixel text-xs text-primary">P1 (WASD/Q)</span>
|
||||||
<span className="font-pixel text-xs text-primary">P1 (WASD/Q)</span>
|
|
||||||
</div>
|
|
||||||
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard2P(player1, 'border-primary/20')}</div>
|
|
||||||
<div className="flex gap-2 text-center">
|
|
||||||
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{player1.score}</p></div>
|
|
||||||
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-primary">{player1.lines}</p></div>
|
|
||||||
</div>
|
|
||||||
{player1.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{/* P1 Next Piece */}
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard2P(player1, 'border-primary/20')}</div>
|
||||||
<div className="border border-primary/50 p-2 bg-background/50 flex flex-col items-center justify-center">
|
<div className="flex gap-2 text-center">
|
||||||
<p className="font-pixel text-[8px] text-foreground/60 mb-1">NEXT</p>
|
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{player1.score}</p></div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-primary">{player1.lines}</p></div>
|
||||||
{player1.nextPiece.shape.map((row, y) => (
|
</div>
|
||||||
<div key={y} className="flex gap-0.5">
|
{player1.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
||||||
{row.map((val, x) => (
|
</div>
|
||||||
<div key={`${y}-${x}`} className={`w-2 h-2 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
|
|
||||||
))}
|
{/* P1 Next Piece */}
|
||||||
</div>
|
<div className="border-2 border-primary/50 p-2 bg-background/50 flex flex-col items-center justify-center min-h-[60px]">
|
||||||
))}
|
<p className="font-pixel text-[10px] text-foreground/60">P1 NEXT</p>
|
||||||
</div>
|
<div className="flex flex-col gap-1">
|
||||||
|
{player1.nextPiece.shape.map((row, y) => (
|
||||||
|
<div key={y} className="flex gap-1">
|
||||||
|
{row.map((val, x) => (
|
||||||
|
<div key={`${y}-${x}`} className={`w-2 h-2 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center controls */}
|
{/* Center controls */}
|
||||||
<div className="flex flex-col gap-2 min-w-[90px] items-center justify-center">
|
<div className="flex flex-col gap-2 min-w-[100px] items-center">
|
||||||
<div className="border border-primary/50 p-2 bg-background/50 text-center">
|
<div className="border border-primary/50 p-2 bg-background/50 text-center">
|
||||||
<p className="font-pixel text-[8px] text-foreground/60 mb-1">CONTROLS</p>
|
<p className="font-pixel text-[8px] text-foreground/60 mb-1">CONTROLS</p>
|
||||||
<p className="font-pixel text-[7px] text-primary">P1: WASD Q</p>
|
<p className="font-pixel text-[8px] text-primary">P1: WASD Q</p>
|
||||||
<p className="font-pixel text-[7px] text-purple-400">P2: ↑↓←→ /</p>
|
<p className="font-pixel text-[8px] text-purple-400">P2: ↑↓←→ /</p>
|
||||||
<p className="font-pixel text-[7px] text-foreground/60 mt-1">P: Pause</p>
|
<p className="font-pixel text-[8px] text-foreground/60 mt-1">P: Pause</p>
|
||||||
</div>
|
</div>
|
||||||
{!gameStarted || gameOver ? (
|
{!gameStarted || gameOver ? (
|
||||||
<button onClick={() => startGame('2p')} className="font-minecraft text-xs py-2 px-3 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
<button onClick={() => startGame('2p')} className="font-minecraft text-xs py-2 px-3 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
||||||
@@ -686,32 +677,31 @@ const Tetris = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* P2 Section */}
|
{/* P2 Board */}
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{/* P2 Next Piece */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="border border-purple-500/50 p-2 bg-background/50 flex flex-col items-center justify-center">
|
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: 'hsl(280 70% 50%)' }} />
|
||||||
<p className="font-pixel text-[8px] text-purple-400 mb-1">NEXT</p>
|
<span className="font-pixel text-xs text-purple-400">P2 (↑↓←→/)</span>
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{player2.nextPiece.shape.map((row, y) => (
|
|
||||||
<div key={y} className="flex gap-0.5">
|
|
||||||
{row.map((val, x) => (
|
|
||||||
<div key={`${y}-${x}`} className={`w-2 h-2 ${val ? 'bg-purple-400 box-glow' : 'bg-transparent'}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="border-2 border-purple-500 p-1 bg-background/80" style={{ boxShadow: '0 0 10px hsl(280 70% 50% / 0.5)' }}>{renderBoard2P(player2, 'border-purple-500/20')}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-2 text-center">
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: 'hsl(280 70% 50%)' }} />
|
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-purple-400">{player2.score}</p></div>
|
||||||
<span className="font-pixel text-xs text-purple-400">P2 (↑↓←→/)</span>
|
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-purple-400">{player2.lines}</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-2 border-purple-500 p-1 bg-background/80" style={{ boxShadow: '0 0 10px hsl(280 70% 50% / 0.5)' }}>{renderBoard2P(player2, 'border-purple-500/20')}</div>
|
{player2.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
||||||
<div className="flex gap-2 text-center">
|
</div>
|
||||||
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-purple-400">{player2.score}</p></div>
|
|
||||||
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-purple-400">{player2.lines}</p></div>
|
{/* P2 Next Piece */}
|
||||||
</div>
|
<div className="border-2 border-purple-500/50 p-2 bg-background/50 flex flex-col items-center justify-center min-h-[60px]">
|
||||||
{player2.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
<p className="font-pixel text-[10px] text-purple-400">P2 NEXT</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{player2.nextPiece.shape.map((row, y) => (
|
||||||
|
<div key={y} className="flex gap-1">
|
||||||
|
{row.map((val, x) => (
|
||||||
|
<div key={`${y}-${x}`} className={`w-2 h-2 ${val ? 'bg-purple-400 box-glow' : 'bg-transparent'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{/* P2 Next Piece */}
|
||||||
|
<div className="border-2 border-purple-500/50 p-3 bg-background/50 flex flex-col items-center justify-center min-h-[60px]">
|
||||||
|
<p className="font-pixel text-[10px] text-purple-400">P2 NEXT</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{player2.nextPiece.shape.map((row, y) => (
|
||||||
|
<div key={y} className="flex gap-1">
|
||||||
|
{row.map((val, x) => (
|
||||||
|
<div key={`${y}-${x}`} className={`w-2 h-2 ${val ? 'bg-purple-400 box-glow' : 'bg-transparent'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center controls */}
|
||||||
Reference in New Issue
Block a user