Fix Tetris 2P preview block synchronization issue
- Prevent duplicate piece shapes when generating next pieces - Enhanced randomTetromino() with excludeShape parameter - Update piece placement logic to avoid visual duplicates - Improve initial state generation for both players - Add P1 and P2 next piece previews in 2P mode
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Loader2, CreditCard, Check } from "lucide-react";
|
||||
|
||||
// PayPal SDK types
|
||||
declare global {
|
||||
interface Window {
|
||||
paypal?: any;
|
||||
}
|
||||
}
|
||||
|
||||
interface PayPalPaymentProps {
|
||||
amount: string;
|
||||
currency?: string;
|
||||
item: string;
|
||||
onPaymentComplete?: (orderId: string) => void;
|
||||
}
|
||||
|
||||
type PaymentMethod = 'paypal' | 'card' | 'ideal';
|
||||
|
||||
const PayPalPayment = ({
|
||||
amount,
|
||||
currency = "EUR",
|
||||
item,
|
||||
onPaymentComplete
|
||||
}: PayPalPaymentProps) => {
|
||||
const [sdkReady, setSdkReady] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>('paypal');
|
||||
const [paymentComplete, setPaymentComplete] = useState(false);
|
||||
const [orderId, setOrderId] = useState<string | null>(null);
|
||||
const paypalButtonRef = useRef<HTMLDivElement>(null);
|
||||
const cardButtonRef = useRef<HTMLDivElement>(null);
|
||||
const idealButtonRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Replace with your PayPal Client ID
|
||||
// Get one at: https://developer.paypal.com/dashboard/applications
|
||||
const PAYPAL_CLIENT_ID = "YOUR_PAYPAL_CLIENT_ID";
|
||||
|
||||
useEffect(() => {
|
||||
const loadPayPalSDK = () => {
|
||||
if (window.paypal) {
|
||||
setSdkReady(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = `https://www.paypal.com/sdk/js?client-id=${PAYPAL_CLIENT_ID}¤cy=${currency}&components=buttons,funding-eligibility&enable-funding=ideal,card`;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
setSdkReady(true);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
console.error("PayPal SDK failed to load");
|
||||
setLoading(false);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load payment system. Check your Client ID.",
|
||||
variant: "destructive",
|
||||
});
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
loadPayPalSDK();
|
||||
|
||||
return () => {
|
||||
// Cleanup if needed
|
||||
};
|
||||
}, [currency, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sdkReady || !window.paypal || paymentComplete) return;
|
||||
|
||||
const createOrder = (_data: any, actions: any) => {
|
||||
return actions.order.create({
|
||||
purchase_units: [{
|
||||
description: item,
|
||||
amount: {
|
||||
currency_code: currency,
|
||||
value: amount,
|
||||
},
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const onApprove = async (_data: any, actions: any) => {
|
||||
try {
|
||||
const order = await actions.order.capture();
|
||||
console.log("Payment successful:", order);
|
||||
|
||||
setPaymentComplete(true);
|
||||
setOrderId(order.id);
|
||||
|
||||
toast({
|
||||
title: "Payment Successful!",
|
||||
description: `Order ${order.id} has been confirmed.`,
|
||||
});
|
||||
|
||||
onPaymentComplete?.(order.id);
|
||||
} catch (error) {
|
||||
console.error("Payment capture error:", error);
|
||||
toast({
|
||||
title: "Payment Failed",
|
||||
description: "There was an error processing your payment.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: any) => {
|
||||
console.error("PayPal error:", err);
|
||||
toast({
|
||||
title: "Payment Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
};
|
||||
|
||||
// Render PayPal button
|
||||
if (paypalButtonRef.current && selectedMethod === 'paypal') {
|
||||
paypalButtonRef.current.innerHTML = '';
|
||||
window.paypal.Buttons({
|
||||
fundingSource: window.paypal.FUNDING.PAYPAL,
|
||||
style: {
|
||||
layout: 'vertical',
|
||||
color: 'black',
|
||||
shape: 'rect',
|
||||
label: 'paypal',
|
||||
height: 40,
|
||||
},
|
||||
createOrder,
|
||||
onApprove,
|
||||
onError,
|
||||
}).render(paypalButtonRef.current);
|
||||
}
|
||||
|
||||
// Render Card button
|
||||
if (cardButtonRef.current && selectedMethod === 'card') {
|
||||
cardButtonRef.current.innerHTML = '';
|
||||
window.paypal.Buttons({
|
||||
fundingSource: window.paypal.FUNDING.CARD,
|
||||
style: {
|
||||
layout: 'vertical',
|
||||
color: 'black',
|
||||
shape: 'rect',
|
||||
height: 40,
|
||||
},
|
||||
createOrder,
|
||||
onApprove,
|
||||
onError,
|
||||
}).render(cardButtonRef.current);
|
||||
}
|
||||
|
||||
// Render iDEAL button (available in Netherlands)
|
||||
if (idealButtonRef.current && selectedMethod === 'ideal') {
|
||||
idealButtonRef.current.innerHTML = '';
|
||||
if (window.paypal.FUNDING.IDEAL) {
|
||||
window.paypal.Buttons({
|
||||
fundingSource: window.paypal.FUNDING.IDEAL,
|
||||
style: {
|
||||
layout: 'vertical',
|
||||
shape: 'rect',
|
||||
height: 40,
|
||||
},
|
||||
createOrder,
|
||||
onApprove,
|
||||
onError,
|
||||
}).render(idealButtonRef.current);
|
||||
} else {
|
||||
idealButtonRef.current.innerHTML = '<p class="text-xs text-muted-foreground text-center py-4">iDEAL not available in your region</p>';
|
||||
}
|
||||
}
|
||||
}, [sdkReady, selectedMethod, amount, currency, item, toast, onPaymentComplete, paymentComplete]);
|
||||
|
||||
if (paymentComplete && orderId) {
|
||||
return (
|
||||
<div className="border border-primary bg-card p-4 w-full max-w-sm">
|
||||
<div className="border-b border-primary pb-2 mb-4">
|
||||
<span className="text-xs text-primary uppercase tracking-wider">
|
||||
[ PAYMENT CONFIRMED ]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Check className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-foreground mb-2">
|
||||
Payment Successful!
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{amount} {currency} paid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-border bg-secondary p-3 mb-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Order ID:</p>
|
||||
<code className="text-xs text-primary break-all font-mono">
|
||||
{orderId}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Your purchase is complete. Check your email for confirmation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-card p-4 w-full max-w-sm">
|
||||
<div className="border-b border-border pb-2 mb-4">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
[ CARD / PAYPAL PAYMENT ]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-foreground mb-1">{item}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pay securely with PayPal, card, or iDEAL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1 mb-4">
|
||||
<Button
|
||||
variant={selectedMethod === 'paypal' ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMethod('paypal')}
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
PayPal
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMethod === 'card' ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMethod('card')}
|
||||
className="text-xs px-2 py-1 h-auto flex items-center gap-1"
|
||||
>
|
||||
<CreditCard className="h-3 w-3" />
|
||||
Card
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMethod === 'ideal' ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMethod('ideal')}
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
iDEAL
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Amount:</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{amount} {currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !sdkReady ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-xs text-destructive mb-2">
|
||||
PayPal SDK not loaded
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Replace YOUR_PAYPAL_CLIENT_ID with your actual PayPal Client ID
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
ref={paypalButtonRef}
|
||||
className={selectedMethod === 'paypal' ? 'block' : 'hidden'}
|
||||
/>
|
||||
<div
|
||||
ref={cardButtonRef}
|
||||
className={selectedMethod === 'card' ? 'block' : 'hidden'}
|
||||
/>
|
||||
<div
|
||||
ref={idealButtonRef}
|
||||
className={selectedMethod === 'ideal' ? 'block' : 'hidden'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-4 leading-relaxed">
|
||||
{selectedMethod === 'paypal' && "Pay with your PayPal balance or linked cards."}
|
||||
{selectedMethod === 'card' && "Pay securely with Visa, Mastercard, or Amex."}
|
||||
{selectedMethod === 'ideal' && "Pay directly from your Dutch bank account."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayPalPayment;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Monitor } from 'lucide-react';
|
||||
|
||||
interface DesktopOnlyProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DesktopOnly = ({ children }: DesktopOnlyProps) => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-background p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="max-w-md w-full text-center"
|
||||
>
|
||||
<Monitor className="w-16 h-16 text-primary mb-4 mx-auto" />
|
||||
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow mb-4">
|
||||
Desktop Only
|
||||
</h1>
|
||||
<p className="font-pixel text-sm text-foreground/70 mb-4">
|
||||
This game requires a keyboard and is designed for desktop play.
|
||||
</p>
|
||||
<p className="font-pixel text-xs text-foreground/50">
|
||||
Please visit this page on a desktop or laptop computer.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default DesktopOnly;
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: React.ErrorInfo;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
retryCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return {
|
||||
hasError: true,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('🔥 ErrorBoundary caught an error:', error);
|
||||
console.error('📍 Error details:', errorInfo);
|
||||
|
||||
// Enhanced logging for debugging
|
||||
console.group('🔍 Error Diagnostics');
|
||||
console.error('Error:', error);
|
||||
console.error('Component Stack:', errorInfo.componentStack);
|
||||
|
||||
// Log performance/memory context
|
||||
if ((performance as any).memory) {
|
||||
const memory = (performance as any).memory;
|
||||
console.log('Memory Usage:', {
|
||||
used: `${Math.round(memory.usedJSHeapSize / (1024 * 1024))}MB`,
|
||||
total: `${Math.round(memory.totalJSHeapSize / (1024 * 1024))}MB`,
|
||||
limit: `${Math.round(memory.jsHeapSizeLimit / (1024 * 1024))}MB`
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Navigator Info:', {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
onLine: navigator.onLine
|
||||
});
|
||||
console.groupEnd();
|
||||
|
||||
// Store error info for display
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
console.log(`🔄 Retry attempt ${this.state.retryCount + 1}`);
|
||||
|
||||
// Clear any timeouts and intervals (safer approach)
|
||||
const maxTimeouts = 1000;
|
||||
for (let i = 1; i <= maxTimeouts; i++) {
|
||||
clearTimeout(i);
|
||||
clearInterval(i);
|
||||
}
|
||||
|
||||
// Reset error state and increment retry count
|
||||
this.setState(prevState => ({
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
errorInfo: undefined,
|
||||
retryCount: prevState.retryCount + 1
|
||||
}));
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
console.log('🔄 Reloading page due to error');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback UI if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI with detailed diagnostics
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-green-400 font-mono p-8 border border-green-500 rounded-lg">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center border-b border-green-500 pb-4">
|
||||
<h1 className="text-2xl text-green-400 mb-2">⚠️ SYSTEM ERROR</h1>
|
||||
<p className="text-green-300">The oscilloscope encountered a critical error</p>
|
||||
</div>
|
||||
|
||||
{/* Error Details */}
|
||||
<div className="bg-black/50 border border-green-500 p-4 rounded">
|
||||
<h2 className="text-xl text-green-400 mb-3">🔍 Error Analysis</h2>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-green-300">Error Message:</span>
|
||||
<div className="text-red-400 mt-1 p-2 bg-black rounded border border-red-500">
|
||||
{this.state.error?.message || 'Unknown error'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.state.error?.stack && (
|
||||
<div>
|
||||
<span className="text-green-300">Stack Trace:</span>
|
||||
<pre className="text-xs text-yellow-300 mt-1 p-2 bg-black rounded border border-yellow-500 overflow-auto max-h-40">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.errorInfo?.componentStack && (
|
||||
<div>
|
||||
<span className="text-green-300">Component Stack:</span>
|
||||
<pre className="text-xs text-blue-300 mt-1 p-2 bg-black rounded border border-blue-500 overflow-auto max-h-40">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-green-300">Retry Count:</span> {this.state.retryCount}
|
||||
</div>
|
||||
|
||||
{this.state.retryCount > 2 && (
|
||||
<div className="bg-red-900/30 border border-red-500 p-3 rounded mt-4">
|
||||
<p className="text-red-300 font-bold">⚠️ Multiple Retry Attempts</p>
|
||||
<p className="text-red-200 text-sm mt-1">
|
||||
This error may be persistent. Consider refreshing the page or using a smaller audio file.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<div className="bg-black/50 border border-green-500 p-4 rounded">
|
||||
<h2 className="text-xl text-green-400 mb-3">💻 System Diagnostics</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-green-300">Browser:</span>
|
||||
<div className="text-white">{navigator.userAgent.split(' ')[0]}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-green-300">Platform:</span>
|
||||
<div className="text-white">{(navigator as any).userAgentData?.platform || navigator.platform}</div>
|
||||
</div>
|
||||
|
||||
{(performance as any).memory && (
|
||||
<div>
|
||||
<span className="text-green-300">Memory Usage:</span>
|
||||
<div className="text-white">
|
||||
{Math.round((performance as any).memory.usedJSHeapSize / (1024 * 1024))}MB /
|
||||
{Math.round((performance as any).memory.jsHeapSizeLimit / (1024 * 1024))}MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-green-300">Online Status:</span>
|
||||
<div className={navigator.onLine ? 'text-green-400' : 'text-red-400'}>
|
||||
{navigator.onLine ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-4 justify-center border-t border-green-500 pt-4">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="px-6 py-2 bg-green-600 text-white font-mono rounded hover:bg-green-500 transition-colors"
|
||||
disabled={this.state.retryCount >= 5}
|
||||
>
|
||||
🔄 Try Again ({this.state.retryCount}/5)
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="px-6 py-2 bg-blue-600 text-white font-mono rounded hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
🔄 Reload Page
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.open('/', '_self')}
|
||||
className="px-6 py-2 bg-yellow-600 text-white font-mono rounded hover:bg-yellow-500 transition-colors"
|
||||
>
|
||||
🏠 Go Home
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting Tips */}
|
||||
<div className="bg-blue-900/20 border border-blue-500 p-4 rounded">
|
||||
<h3 className="text-lg text-blue-400 mb-2">💡 Troubleshooting Tips</h3>
|
||||
<ul className="text-sm text-blue-200 space-y-1">
|
||||
<li>• Try using a smaller audio file (<500MB for testing)</li>
|
||||
<li>• Convert large FLAC files to MP3/OGG format</li>
|
||||
<li>• Close other browser tabs to free up memory</li>
|
||||
<li>• Try Chrome browser with --max-old-space-size=8192 flag</li>
|
||||
<li>• Check browser console for additional error details</li>
|
||||
<li>• Restart browser if memory usage is high</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,322 @@
|
||||
// Streaming audio processor for large files using WebCodecs API
|
||||
// Handles files > 1GB without loading entire file into memory
|
||||
|
||||
export interface DecodedAudioFrame {
|
||||
leftChannel: Float32Array;
|
||||
rightChannel: Float32Array;
|
||||
sampleRate: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface AudioProcessorConfig {
|
||||
chunkSize: number;
|
||||
bufferSize: number; // seconds of audio to keep in memory
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
}
|
||||
|
||||
export class StreamingAudioProcessor {
|
||||
private audioContext: AudioContext;
|
||||
private decoder: AudioDecoder | null = null;
|
||||
private fileBufferQueue: Float32Array[] = [];
|
||||
private fileReader: FileReader | null = null;
|
||||
private totalSamplesProcessed = 0;
|
||||
private sampleRate = 48000;
|
||||
private channels = 2;
|
||||
private isProcessing = false;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
constructor(config: AudioProcessorConfig) {
|
||||
this.audioContext = new AudioContext();
|
||||
this.sampleRate = config.sampleRate;
|
||||
this.channels = config.channels;
|
||||
}
|
||||
|
||||
async initializeDecoder(file: File): Promise<void> {
|
||||
// Detect codec from file type
|
||||
const codec = this.getCodecFromFile(file);
|
||||
console.log(`Initializing decoder for codec: ${codec}`);
|
||||
|
||||
// Check browser support
|
||||
if (!this.isWebCodecsSupported()) {
|
||||
throw new Error(`WebCodecs AudioDecoder not supported in this browser. codec: ${codec}`);
|
||||
}
|
||||
|
||||
// FLAC is not widely supported by WebCodecs, use traditional decode
|
||||
if (codec === 'flac') {
|
||||
console.warn('FLAC codec not supported by WebCodecs, using traditional decode (may have memory limits)');
|
||||
throw new Error('FLAC codec requires traditional decode. For large FLAC files, consider converting to MP3/OGG for streaming support.');
|
||||
}
|
||||
|
||||
// Check codec support
|
||||
const codecSupport = await AudioDecoder.isConfigSupported({
|
||||
codec,
|
||||
sampleRate: this.sampleRate,
|
||||
numberOfChannels: this.channels
|
||||
});
|
||||
|
||||
console.log(`Codec ${codec} support:`, codecSupport);
|
||||
|
||||
if (!codecSupport.supported) {
|
||||
throw new Error(`Codec ${codec} not supported by this browser`);
|
||||
}
|
||||
|
||||
// Create decoder
|
||||
this.decoder = new AudioDecoder({
|
||||
output: (audioData) => {
|
||||
this.handleDecodedFrame(audioData);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('AudioDecoder error:', error);
|
||||
throw new Error(`Decoding failed: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await this.decoder.configure({
|
||||
codec,
|
||||
sampleRate: this.sampleRate,
|
||||
numberOfChannels: this.channels
|
||||
});
|
||||
|
||||
console.log(`AudioDecoder configured successfully for ${codec}`);
|
||||
}
|
||||
|
||||
private getCodecFromFile(file: File): string {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
const mimeType = file.type;
|
||||
|
||||
// Map file extensions to codec strings
|
||||
const codecMap: Record<string, string> = {
|
||||
'mp3': 'mp3',
|
||||
'flac': 'flac',
|
||||
'wav': 'pcm',
|
||||
'ogg': 'opus',
|
||||
'm4a': 'aac',
|
||||
'aac': 'aac'
|
||||
};
|
||||
|
||||
// Try extension first
|
||||
if (extension && codecMap[extension]) {
|
||||
return codecMap[extension];
|
||||
}
|
||||
|
||||
// Try MIME type
|
||||
if (mimeType) {
|
||||
if (mimeType.includes('mp3')) return 'mp3';
|
||||
if (mimeType.includes('flac')) return 'flac';
|
||||
if (mimeType.includes('wav')) return 'pcm';
|
||||
if (mimeType.includes('opus')) return 'opus';
|
||||
if (mimeType.includes('aac')) return 'aac';
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported file format: ${extension || 'unknown'}`);
|
||||
}
|
||||
|
||||
private isWebCodecsSupported(): boolean {
|
||||
return 'AudioDecoder' in window && 'EncodedAudioChunk' in window;
|
||||
}
|
||||
|
||||
private handleDecodedFrame(audioData: AudioData): void {
|
||||
// Convert AudioData to Float32Array channels
|
||||
const leftChannel = new Float32Array(audioData.numberOfFrames);
|
||||
const rightChannel = new Float32Array(audioData.numberOfFrames);
|
||||
|
||||
audioData.copyTo(leftChannel, { planeIndex: 0 });
|
||||
audioData.copyTo(rightChannel, { planeIndex: 1 });
|
||||
|
||||
// Add to buffer queue
|
||||
this.fileBufferQueue.push(leftChannel, rightChannel);
|
||||
this.totalSamplesProcessed += audioData.numberOfFrames;
|
||||
|
||||
// Limit buffer size to prevent memory growth
|
||||
const maxBufferFrames = this.sampleRate * 30; // Keep 30 seconds max
|
||||
const currentBufferFrames = this.fileBufferQueue.length / 2 * leftChannel.length;
|
||||
|
||||
if (currentBufferFrames > maxBufferFrames) {
|
||||
// Remove oldest data to maintain buffer limit
|
||||
const framesToRemove = currentBufferFrames - maxBufferFrames;
|
||||
const chunksToRemove = Math.ceil(framesToRemove / leftChannel.length) * 2;
|
||||
this.fileBufferQueue.splice(0, chunksToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
async processLargeFile(file: File): Promise<{
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
totalSamples: number;
|
||||
getBufferedSamples: (offset: number, count: number) => { left: Float32Array; right: Float32Array } | null;
|
||||
}> {
|
||||
console.log(`Starting streaming processing for ${file.name} (${(file.size / (1024 * 1024 * 1024)).toFixed(1)}GB)`);
|
||||
|
||||
this.isProcessing = true;
|
||||
this.abortController = new AbortController();
|
||||
this.fileBufferQueue = [];
|
||||
this.totalSamplesProcessed = 0;
|
||||
|
||||
try {
|
||||
await this.initializeDecoder(file);
|
||||
|
||||
// Get metadata first
|
||||
const metadata = await this.extractMetadata(file);
|
||||
console.log(`Audio metadata: ${metadata.duration.toFixed(1)}s, sampleRate: ${metadata.sampleRate}`);
|
||||
|
||||
// Process file in chunks
|
||||
await this.streamFileChunks(file);
|
||||
|
||||
// Flush remaining decoder data
|
||||
if (this.decoder) {
|
||||
await this.decoder.flush();
|
||||
}
|
||||
|
||||
const duration = this.totalSamplesProcessed / this.sampleRate;
|
||||
|
||||
console.log(`✅ Streaming processing complete: ${duration.toFixed(1)}s, ${this.totalSamplesProcessed.toLocaleString()} samples`);
|
||||
|
||||
return {
|
||||
sampleRate: this.sampleRate,
|
||||
duration,
|
||||
totalSamples: this.totalSamplesProcessed,
|
||||
getBufferedSamples: (offset: number, count: number) => {
|
||||
return this.getSamplesFromBuffer(offset, count);
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Streaming processing failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private async extractMetadata(file: File): Promise<{ duration: number; sampleRate: number }> {
|
||||
// Create temporary audio element to get basic metadata
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve({
|
||||
duration: audio.duration || 0,
|
||||
sampleRate: this.sampleRate
|
||||
});
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error('Failed to load audio metadata'));
|
||||
});
|
||||
|
||||
audio.src = objectUrl;
|
||||
audio.load();
|
||||
});
|
||||
}
|
||||
|
||||
private async streamFileChunks(file: File): Promise<void> {
|
||||
const chunkSize = 64 * 1024; // 64KB chunks
|
||||
let offset = 0;
|
||||
|
||||
console.log(`Starting chunked reading with ${chunkSize} byte chunks...`);
|
||||
|
||||
while (offset < file.size && !this.abortController?.signal.aborted) {
|
||||
const chunk = file.slice(offset, offset + chunkSize);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
|
||||
if (this.decoder && arrayBuffer.byteLength > 0) {
|
||||
// Create EncodedAudioChunk
|
||||
const encodedChunk = new EncodedAudioChunk({
|
||||
type: offset === 0 ? 'key' : 'delta', // First chunk is key frame
|
||||
timestamp: (offset / file.size) * 1000000, // microseconds
|
||||
data: arrayBuffer
|
||||
});
|
||||
|
||||
// Feed to decoder
|
||||
this.decoder.decode(encodedChunk);
|
||||
}
|
||||
|
||||
offset += arrayBuffer.byteLength;
|
||||
|
||||
// Progress logging for large files
|
||||
if (offset % (10 * 1024 * 1024) < chunkSize) { // Every 10MB
|
||||
const progress = (offset / file.size) * 100;
|
||||
console.log(`Processed ${(offset / (1024 * 1024)).toFixed(1)}MB (${progress.toFixed(1)}%)`);
|
||||
}
|
||||
|
||||
} catch (chunkError) {
|
||||
console.warn(`Failed to process chunk at offset ${offset}:`, chunkError);
|
||||
// Continue with next chunk rather than failing completely
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSamplesFromBuffer(offset: number, count: number): { left: Float32Array; right: Float32Array } | null {
|
||||
if (this.fileBufferQueue.length === 0) return null;
|
||||
|
||||
const samplesPerChannel = this.fileBufferQueue.length / 2;
|
||||
const samplesPerChunk = this.fileBufferQueue[0]?.length || 0;
|
||||
const samplesAvailable = Math.min(count, samplesPerChannel * samplesPerChunk);
|
||||
|
||||
if (samplesAvailable <= 0) return null;
|
||||
|
||||
const leftResult = new Float32Array(samplesAvailable);
|
||||
const rightResult = new Float32Array(samplesAvailable);
|
||||
|
||||
// Copy samples from buffer queue
|
||||
let samplesCopied = 0;
|
||||
const bufferIndex = Math.floor(offset / samplesPerChunk) * 2;
|
||||
|
||||
for (let i = bufferIndex; i < this.fileBufferQueue.length && samplesCopied < samplesAvailable; i += 2) {
|
||||
const leftChunk = this.fileBufferQueue[i];
|
||||
const rightChunk = this.fileBufferQueue[i + 1];
|
||||
|
||||
const copyLength = Math.min(leftChunk.length, samplesAvailable - samplesCopied);
|
||||
|
||||
leftResult.set(leftChunk.subarray(0, copyLength), samplesCopied);
|
||||
rightResult.set(rightChunk.subarray(0, copyLength), samplesCopied);
|
||||
|
||||
samplesCopied += copyLength;
|
||||
}
|
||||
|
||||
return {
|
||||
left: leftResult,
|
||||
right: rightResult
|
||||
};
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.decoder) {
|
||||
try {
|
||||
this.decoder.close();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
this.fileBufferQueue = [];
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return this.decoder?.state === 'configured';
|
||||
}
|
||||
|
||||
get progress(): number {
|
||||
if (this.fileBufferQueue.length === 0) return 0;
|
||||
return this.totalSamplesProcessed / this.sampleRate; // seconds processed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
interface PerformanceMetrics {
|
||||
frameRate: number;
|
||||
memoryUsage?: number;
|
||||
renderTime: number;
|
||||
dropCount: number;
|
||||
}
|
||||
|
||||
export function usePerformanceMonitor() {
|
||||
const frameCount = useRef(0);
|
||||
const lastTime = useRef(performance.now());
|
||||
const dropCount = useRef(0);
|
||||
const renderTimes = useRef<number[]>([]);
|
||||
const maxRenderTimeSamples = 60; // Keep last 60 frames
|
||||
|
||||
const recordFrame = useCallback(() => {
|
||||
const now = performance.now();
|
||||
const deltaTime = now - lastTime.current;
|
||||
|
||||
frameCount.current++;
|
||||
|
||||
// Track render time
|
||||
renderTimes.current.push(deltaTime);
|
||||
if (renderTimes.current.length > maxRenderTimeSamples) {
|
||||
renderTimes.current.shift();
|
||||
}
|
||||
|
||||
// Detect frame drops (should be ~16.67ms for 60fps)
|
||||
if (deltaTime > 20) { // Allow some tolerance
|
||||
dropCount.current++;
|
||||
}
|
||||
|
||||
lastTime.current = now;
|
||||
|
||||
// Calculate metrics every second
|
||||
if (frameCount.current % 60 === 0) {
|
||||
const avgRenderTime = renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length;
|
||||
const fps = 1000 / avgRenderTime;
|
||||
|
||||
const metrics: PerformanceMetrics = {
|
||||
frameRate: fps,
|
||||
memoryUsage: (performance as any).memory ?
|
||||
Math.round((performance as any).memory.usedJSHeapSize / (1024 * 1024)) : undefined,
|
||||
renderTime: avgRenderTime,
|
||||
dropCount: dropCount.current
|
||||
};
|
||||
|
||||
// Log performance warnings
|
||||
if (fps < 30) {
|
||||
console.warn(`⚠️ Low FPS: ${fps.toFixed(1)}, Drops: ${dropCount.current}, Memory: ${metrics.memoryUsage}MB`);
|
||||
}
|
||||
|
||||
if (metrics.memoryUsage && metrics.memoryUsage > 2000) { // > 2GB
|
||||
console.warn(`⚠️ High memory usage: ${metrics.memoryUsage}MB, FPS: ${fps.toFixed(1)}`);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
frameCount.current = 0;
|
||||
lastTime.current = performance.now();
|
||||
dropCount.current = 0;
|
||||
renderTimes.current = [];
|
||||
}, []);
|
||||
|
||||
const getCurrentMetrics = useCallback((): PerformanceMetrics => {
|
||||
const avgRenderTime = renderTimes.current.length > 0
|
||||
? renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length
|
||||
: 16.67; // Default for 60fps
|
||||
|
||||
return {
|
||||
frameRate: 1000 / avgRenderTime,
|
||||
memoryUsage: (performance as any).memory ?
|
||||
Math.round((performance as any).memory.usedJSHeapSize / (1024 * 1024)) : undefined,
|
||||
renderTime: avgRenderTime,
|
||||
dropCount: dropCount.current
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recordFrame,
|
||||
reset,
|
||||
getCurrentMetrics
|
||||
};
|
||||
}
|
||||
+65
-18
@@ -58,10 +58,21 @@ interface PlayerState {
|
||||
|
||||
const createBoard = (): Board => Array.from({ length: BOARD_HEIGHT }, () => Array(BOARD_WIDTH).fill(null));
|
||||
|
||||
const randomTetromino = (isP2 = false): Piece => {
|
||||
const randomTetromino = (isP2 = false, excludeShape?: number[][]): Piece => {
|
||||
const keys = Object.keys(TETROMINOS) as TetrominoKey[];
|
||||
const key = keys[Math.floor(Math.random() * keys.length)];
|
||||
const tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
||||
let key = keys[Math.floor(Math.random() * keys.length)];
|
||||
let tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
||||
|
||||
// Ensure we don't generate the same shape twice in a row
|
||||
if (excludeShape && JSON.stringify(tetromino.shape) === JSON.stringify(excludeShape)) {
|
||||
// Try up to 7 times to get a different shape
|
||||
for (let attempts = 0; attempts < 7; attempts++) {
|
||||
key = keys[Math.floor(Math.random() * keys.length)];
|
||||
tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
||||
if (JSON.stringify(tetromino.shape) !== JSON.stringify(excludeShape)) break;
|
||||
}
|
||||
}
|
||||
|
||||
return { shape: tetromino.shape, color: tetromino.color, x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2), y: 0 };
|
||||
};
|
||||
|
||||
@@ -93,21 +104,29 @@ const Tetris = () => {
|
||||
const [lines, setLines] = useState(0);
|
||||
|
||||
// 2P state
|
||||
const [player1, setPlayer1] = useState<PlayerState>({
|
||||
board: createBoard(),
|
||||
piece: randomTetromino(),
|
||||
nextPiece: randomTetromino(),
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
const [player1, setPlayer1] = useState<PlayerState>(() => {
|
||||
const p1Piece = randomTetromino();
|
||||
const p1Next = randomTetromino(false, p1Piece.shape);
|
||||
return {
|
||||
board: createBoard(),
|
||||
piece: p1Piece,
|
||||
nextPiece: p1Next,
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
};
|
||||
});
|
||||
const [player2, setPlayer2] = useState<PlayerState>({
|
||||
board: createBoard(),
|
||||
piece: randomTetromino(true),
|
||||
nextPiece: randomTetromino(true),
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
const [player2, setPlayer2] = useState<PlayerState>(() => {
|
||||
const p2Piece = randomTetromino(true);
|
||||
const p2Next = randomTetromino(true, p2Piece.shape);
|
||||
return {
|
||||
board: createBoard(),
|
||||
piece: p2Piece,
|
||||
nextPiece: p2Next,
|
||||
score: 0,
|
||||
lines: 0,
|
||||
gameOver: false,
|
||||
};
|
||||
});
|
||||
const [winner, setWinner] = useState<string | null>(null);
|
||||
|
||||
@@ -364,7 +383,7 @@ const Tetris = () => {
|
||||
else playSound('click');
|
||||
|
||||
const newTetromino = prev.nextPiece;
|
||||
const nextNext = randomTetromino(isP2);
|
||||
const nextNext = randomTetromino(isP2, prev.nextPiece.shape);
|
||||
|
||||
if (!isValidMove(newTetromino, clearedBoard)) {
|
||||
playSound('error');
|
||||
@@ -625,6 +644,20 @@ const Tetris = () => {
|
||||
{player1.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
||||
</div>
|
||||
|
||||
{/* P1 Next Piece */}
|
||||
<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 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>
|
||||
|
||||
{/* Center controls */}
|
||||
<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">
|
||||
@@ -657,6 +690,20 @@ const Tetris = () => {
|
||||
</div>
|
||||
{player2.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
||||
</div>
|
||||
|
||||
{/* P2 Next Piece */}
|
||||
<div className="border-2 border-purple-500/50 p-2 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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