Add frontend pages scaffolding
Implement auth, product listing, checkout, dashboard, and admin product management UI wired to Supabase schema. X-Lovable-Edit-ID: edt-1b517476-db7d-4650-a35f-bdb52c3137ef
This commit is contained in:
19
src/App.tsx
19
src/App.tsx
@@ -3,7 +3,15 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "@/hooks/useAuth";
|
||||
import { CartProvider } from "@/contexts/CartContext";
|
||||
import Index from "./pages/Index";
|
||||
import Auth from "./pages/Auth";
|
||||
import Products from "./pages/Products";
|
||||
import ProductDetail from "./pages/ProductDetail";
|
||||
import Checkout from "./pages/Checkout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Admin from "./pages/Admin";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -11,15 +19,24 @@ const queryClient = new QueryClient();
|
||||
const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<AuthProvider>
|
||||
<CartProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/products/:slug" element={<ProductDetail />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</CartProvider>
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
72
src/components/Layout.tsx
Normal file
72
src/components/Layout.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShoppingCart, User, LogOut, Settings } from 'lucide-react';
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const { items } = useCart();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b-2 border-border bg-background">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<Link to="/" className="text-2xl font-bold">
|
||||
LearnHub
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link to="/products" className="hover:underline font-medium">
|
||||
Products
|
||||
</Link>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="hover:underline font-medium">
|
||||
Dashboard
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link to="/admin" className="hover:underline font-medium flex items-center gap-1">
|
||||
<Settings className="w-4 h-4" />
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleSignOut}>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/auth">
|
||||
<Button variant="outline" size="sm">
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/checkout">
|
||||
<Button variant="outline" size="sm" className="relative">
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
{items.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-primary text-primary-foreground text-xs w-5 h-5 flex items-center justify-center border border-border">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/contexts/CartContext.tsx
Normal file
54
src/contexts/CartContext.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CartContextType {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clearCart: () => void;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
|
||||
const addItem = (item: CartItem) => {
|
||||
setItems(prev => {
|
||||
if (prev.find(i => i.id === item.id)) return prev;
|
||||
return [...prev, item];
|
||||
});
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setItems(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const clearCart = () => {
|
||||
setItems([]);
|
||||
};
|
||||
|
||||
const total = items.reduce((sum, item) => sum + (item.sale_price ?? item.price), 0);
|
||||
|
||||
return (
|
||||
<CartContext.Provider value={{ items, addItem, removeItem, clearCart, total }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCart must be used within a CartProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
97
src/hooks/useAuth.tsx
Normal file
97
src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState, createContext, useContext, ReactNode } from 'react';
|
||||
import { User, Session } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
isAdmin: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
|
||||
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
(event, session) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
|
||||
if (session?.user) {
|
||||
setTimeout(() => {
|
||||
checkAdminRole(session.user.id);
|
||||
}, 0);
|
||||
} else {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
if (session?.user) {
|
||||
checkAdminRole(session.user.id);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const checkAdminRole = async (userId: string) => {
|
||||
const { data } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.eq('role', 'admin')
|
||||
.maybeSingle();
|
||||
|
||||
setIsAdmin(!!data);
|
||||
};
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
return { error };
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string, name: string) => {
|
||||
const redirectUrl = `${window.location.origin}/`;
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: redirectUrl,
|
||||
data: { name }
|
||||
}
|
||||
});
|
||||
return { error };
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, session, loading, isAdmin, signIn, signUp, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
373
src/pages/Admin.tsx
Normal file
373
src/pages/Admin.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
description: string;
|
||||
content: string;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const emptyProduct = {
|
||||
title: '',
|
||||
slug: '',
|
||||
type: 'consulting',
|
||||
description: '',
|
||||
content: '',
|
||||
meeting_link: '',
|
||||
recording_url: '',
|
||||
price: 0,
|
||||
sale_price: null as number | null,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
export default function Admin() {
|
||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [form, setForm] = useState(emptyProduct);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!user) {
|
||||
navigate('/auth');
|
||||
} else if (!isAdmin) {
|
||||
toast({ title: 'Access denied', description: 'Admin access required', variant: 'destructive' });
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
fetchProducts();
|
||||
}
|
||||
}
|
||||
}, [user, isAdmin, authLoading, navigate]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
setProducts(data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
};
|
||||
|
||||
const handleEdit = (product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setForm({
|
||||
title: product.title,
|
||||
slug: product.slug,
|
||||
type: product.type,
|
||||
description: product.description,
|
||||
content: product.content || '',
|
||||
meeting_link: product.meeting_link || '',
|
||||
recording_url: product.recording_url || '',
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
is_active: product.is_active,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingProduct(null);
|
||||
setForm(emptyProduct);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.title || !form.slug || form.price <= 0) {
|
||||
toast({ title: 'Validation error', description: 'Please fill in all required fields', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const productData = {
|
||||
title: form.title,
|
||||
slug: form.slug,
|
||||
type: form.type,
|
||||
description: form.description,
|
||||
content: form.content,
|
||||
meeting_link: form.meeting_link || null,
|
||||
recording_url: form.recording_url || null,
|
||||
price: form.price,
|
||||
sale_price: form.sale_price || null,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
|
||||
if (editingProduct) {
|
||||
const { error } = await supabase
|
||||
.from('products')
|
||||
.update(productData)
|
||||
.eq('id', editingProduct.id);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to update product', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Product updated' });
|
||||
setDialogOpen(false);
|
||||
fetchProducts();
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('products')
|
||||
.insert(productData);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Product created' });
|
||||
setDialogOpen(false);
|
||||
fetchProducts();
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this product?')) return;
|
||||
|
||||
const { error } = await supabase.from('products').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to delete product', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Product deleted' });
|
||||
fetchProducts();
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">Admin Panel</h1>
|
||||
<p className="text-muted-foreground">Manage your products</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={handleNew} className="shadow-sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Product
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto border-2 border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProduct ? 'Edit Product' : 'New Product'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title *</Label>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) });
|
||||
}}
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug *</Label>
|
||||
<Input
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm({ ...form, slug: e.target.value })}
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<SelectTrigger className="border-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="consulting">Consulting</SelectItem>
|
||||
<SelectItem value="webinar">Webinar</SelectItem>
|
||||
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="border-2"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Content (HTML)</Label>
|
||||
<Textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
className="border-2 font-mono text-sm"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Meeting Link</Label>
|
||||
<Input
|
||||
value={form.meeting_link}
|
||||
onChange={(e) => setForm({ ...form, meeting_link: e.target.value })}
|
||||
placeholder="https://meet.google.com/..."
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Recording URL</Label>
|
||||
<Input
|
||||
value={form.recording_url}
|
||||
onChange={(e) => setForm({ ...form, recording_url: e.target.value })}
|
||||
placeholder="https://youtube.com/..."
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Price *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.price}
|
||||
onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })}
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sale Price</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.sale_price || ''}
|
||||
onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
placeholder="Leave empty if no sale"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.is_active}
|
||||
onCheckedChange={(checked) => setForm({ ...form, is_active: checked })}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Product'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="font-medium">{product.title}</TableCell>
|
||||
<TableCell className="capitalize">{product.type}</TableCell>
|
||||
<TableCell>
|
||||
{product.sale_price ? (
|
||||
<span>
|
||||
<span className="font-bold">${product.sale_price}</span>
|
||||
<span className="text-muted-foreground line-through ml-2">${product.price}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-bold">${product.price}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
{product.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(product.id)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No products yet. Create your first product!
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
136
src/pages/Auth.tsx
Normal file
136
src/pages/Auth.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { z } from 'zod';
|
||||
|
||||
const emailSchema = z.string().email('Invalid email address');
|
||||
const passwordSchema = z.string().min(6, 'Password must be at least 6 characters');
|
||||
|
||||
export default function Auth() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { signIn, signUp, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
emailSchema.parse(email);
|
||||
passwordSchema.parse(password);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
toast({ title: 'Validation Error', description: err.errors[0].message, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (isLogin) {
|
||||
const { error } = await signIn(email, password);
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} else {
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Error', description: 'Name is required', variant: 'destructive' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { error } = await signUp(email, password, name);
|
||||
if (error) {
|
||||
if (error.message.includes('already registered')) {
|
||||
toast({ title: 'Error', description: 'This email is already registered. Please login instead.', variant: 'destructive' });
|
||||
} else {
|
||||
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
}
|
||||
} else {
|
||||
toast({ title: 'Success', description: 'Check your email to confirm your account' });
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md border-2 border-border shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{isLogin ? 'Login' : 'Sign Up'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isLogin ? 'Enter your credentials to access your account' : 'Create a new account to get started'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!isLogin && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full shadow-sm" disabled={loading}>
|
||||
{loading ? 'Loading...' : isLogin ? 'Login' : 'Sign Up'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Login'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/pages/Checkout.tsx
Normal file
158
src/pages/Checkout.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
export default function Checkout() {
|
||||
const { items, removeItem, clearCart, total } = useCart();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!user) {
|
||||
toast({ title: 'Login required', description: 'Please login to complete your purchase' });
|
||||
navigate('/auth');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
toast({ title: 'Cart is empty', description: 'Add some products to your cart first', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Create order
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
total_amount: total,
|
||||
status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError || !order) {
|
||||
toast({ title: 'Error', description: 'Failed to create order', variant: 'destructive' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create order items
|
||||
const orderItems = items.map(item => ({
|
||||
order_id: order.id,
|
||||
product_id: item.id,
|
||||
unit_price: item.sale_price ?? item.price,
|
||||
quantity: 1
|
||||
}));
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('order_items')
|
||||
.insert(orderItems);
|
||||
|
||||
if (itemsError) {
|
||||
toast({ title: 'Error', description: 'Failed to add order items', variant: 'destructive' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For demo: mark as paid and grant access
|
||||
await supabase
|
||||
.from('orders')
|
||||
.update({ status: 'paid' })
|
||||
.eq('id', order.id);
|
||||
|
||||
// Grant access to products
|
||||
const accessRecords = items.map(item => ({
|
||||
user_id: user.id,
|
||||
product_id: item.id
|
||||
}));
|
||||
|
||||
await supabase.from('user_access').insert(accessRecords);
|
||||
|
||||
clearCart();
|
||||
toast({ title: 'Success', description: 'Your order has been placed!' });
|
||||
navigate('/dashboard');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-8">Checkout</h1>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card className="border-2 border-border">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">Your cart is empty</p>
|
||||
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
|
||||
Browse Products
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className="border-2 border-border">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{item.title}</h3>
|
||||
<p className="text-sm text-muted-foreground capitalize">{item.type}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold">
|
||||
${item.sale_price ?? item.price}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeItem(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card className="border-2 border-border shadow-md sticky top-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Order Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between text-lg">
|
||||
<span>Total</span>
|
||||
<span className="font-bold">${total}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
className="w-full shadow-sm"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Processing...' : user ? 'Complete Purchase' : 'Login to Checkout'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
This is a demo checkout. No actual payment will be processed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
206
src/pages/Dashboard.tsx
Normal file
206
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ExternalLink, Video, Calendar } from 'lucide-react';
|
||||
|
||||
interface UserAccess {
|
||||
id: string;
|
||||
granted_at: string;
|
||||
expires_at: string | null;
|
||||
product: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
total_amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [access, setAccess] = useState<UserAccess[]>([]);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/auth');
|
||||
} else if (user) {
|
||||
fetchData();
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [accessRes, ordersRes] = await Promise.all([
|
||||
supabase
|
||||
.from('user_access')
|
||||
.select(`
|
||||
id,
|
||||
granted_at,
|
||||
expires_at,
|
||||
product:products (
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
meeting_link,
|
||||
recording_url,
|
||||
description
|
||||
)
|
||||
`)
|
||||
.eq('user_id', user!.id),
|
||||
supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('user_id', user!.id)
|
||||
.order('created_at', { ascending: false })
|
||||
]);
|
||||
|
||||
if (accessRes.data) {
|
||||
setAccess(accessRes.data as unknown as UserAccess[]);
|
||||
}
|
||||
if (ordersRes.data) {
|
||||
setOrders(ordersRes.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'bg-accent';
|
||||
case 'pending': return 'bg-secondary';
|
||||
case 'cancelled': return 'bg-destructive';
|
||||
case 'refunded': return 'bg-muted';
|
||||
default: return 'bg-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Skeleton className="h-10 w-1/3 mb-8" />
|
||||
<div className="grid gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
||||
<p className="text-muted-foreground mb-8">Manage your purchases and access your content</p>
|
||||
|
||||
<Tabs defaultValue="access" className="space-y-6">
|
||||
<TabsList className="border-2 border-border">
|
||||
<TabsTrigger value="access">My Access</TabsTrigger>
|
||||
<TabsTrigger value="orders">Order History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="access">
|
||||
{access.length === 0 ? (
|
||||
<Card className="border-2 border-border">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">You don't have access to any products yet</p>
|
||||
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
|
||||
Browse Products
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{access.map((item) => (
|
||||
<Card key={item.id} className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>{item.product.title}</CardTitle>
|
||||
<CardDescription className="capitalize">{item.product.type}</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-accent">Active</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground mb-4">{item.product.description}</p>
|
||||
<div className="flex gap-2">
|
||||
{item.product.meeting_link && (
|
||||
<Button asChild variant="outline" className="border-2">
|
||||
<a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Join Meeting
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{item.product.recording_url && (
|
||||
<Button asChild variant="outline" className="border-2">
|
||||
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Watch Recording
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders">
|
||||
{orders.length === 0 ? (
|
||||
<Card className="border-2 border-border">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No orders yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{orders.map((order) => (
|
||||
<Card key={order.id} className="border-2 border-border">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-sm text-muted-foreground">
|
||||
{order.id.slice(0, 8)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge className={getStatusColor(order.status)}>{order.status}</Badge>
|
||||
<span className="font-bold">${order.total_amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,58 @@
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, BookOpen, Video, Users } from 'lucide-react';
|
||||
|
||||
const Index = () => {
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
|
||||
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
|
||||
<Layout>
|
||||
<section className="container mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-6">
|
||||
Learn. Grow. Succeed.
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
||||
Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link to="/products">
|
||||
<Button size="lg" className="shadow-sm">
|
||||
Browse Products
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/auth">
|
||||
<Button size="lg" variant="outline" className="border-2">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</section>
|
||||
|
||||
export default Index;
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="border-2 border-border p-8 shadow-sm">
|
||||
<Users className="w-12 h-12 mb-4" />
|
||||
<h3 className="text-2xl font-bold mb-2">Consulting</h3>
|
||||
<p className="text-muted-foreground">
|
||||
One-on-one sessions with industry experts to solve your specific challenges.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-2 border-border p-8 shadow-sm">
|
||||
<Video className="w-12 h-12 mb-4" />
|
||||
<h3 className="text-2xl font-bold mb-2">Webinars</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Live and recorded sessions covering the latest trends and techniques.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-2 border-border p-8 shadow-sm">
|
||||
<BookOpen className="w-12 h-12 mb-4" />
|
||||
<h3 className="text-2xl font-bold mb-2">Bootcamps</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Intensive programs to master new skills in weeks, not months.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
129
src/pages/ProductDetail.tsx
Normal file
129
src/pages/ProductDetail.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
description: string;
|
||||
content: string;
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
}
|
||||
|
||||
export default function ProductDetail() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { addItem, items } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) fetchProduct();
|
||||
}, [slug]);
|
||||
|
||||
const fetchProduct = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('slug', slug)
|
||||
.eq('is_active', true)
|
||||
.maybeSingle();
|
||||
|
||||
if (error || !data) {
|
||||
toast({ title: 'Error', description: 'Product not found', variant: 'destructive' });
|
||||
} else {
|
||||
setProduct(data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product) return;
|
||||
addItem({
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
type: product.type,
|
||||
});
|
||||
toast({ title: 'Added to cart', description: `${product.title} has been added to your cart` });
|
||||
};
|
||||
|
||||
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Skeleton className="h-10 w-1/2 mb-4" />
|
||||
<Skeleton className="h-6 w-1/4 mb-8" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8 text-center">
|
||||
<h1 className="text-2xl font-bold">Product not found</h1>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||
<Badge className="bg-secondary">{product.type}</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{product.sale_price ? (
|
||||
<div>
|
||||
<span className="text-3xl font-bold">${product.sale_price}</span>
|
||||
<span className="text-muted-foreground line-through ml-2">${product.price}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-3xl font-bold">${product.price}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-muted-foreground mb-6">{product.description}</p>
|
||||
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: product.content || '<p>No content available</p>' }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isInCart}
|
||||
size="lg"
|
||||
className="shadow-sm"
|
||||
>
|
||||
{isInCart ? 'Already in Cart' : 'Add to Cart'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
135
src/pages/Products.tsx
Normal file
135
src/pages/Products.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
description: string;
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { addItem, items } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
toast({ title: 'Error', description: 'Failed to load products', variant: 'destructive' });
|
||||
} else {
|
||||
setProducts(data || []);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAddToCart = (product: Product) => {
|
||||
addItem({
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
type: product.type,
|
||||
});
|
||||
toast({ title: 'Added to cart', description: `${product.title} has been added to your cart` });
|
||||
};
|
||||
|
||||
const isInCart = (productId: string) => items.some(item => item.id === productId);
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'consulting': return 'bg-secondary';
|
||||
case 'webinar': return 'bg-accent';
|
||||
case 'bootcamp': return 'bg-muted';
|
||||
default: return 'bg-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Products</h1>
|
||||
<p className="text-muted-foreground mb-8">Browse our consulting, webinars, and bootcamps</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No products available yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((product) => (
|
||||
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl">{product.title}</CardTitle>
|
||||
<Badge className={getTypeColor(product.type)}>{product.type}</Badge>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">{product.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{product.sale_price ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold">${product.sale_price}</span>
|
||||
<span className="text-muted-foreground line-through">${product.price}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-bold">${product.price}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/products/${product.slug}`} className="flex-1">
|
||||
<Button variant="outline" className="w-full border-2">View Details</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => handleAddToCart(product)}
|
||||
disabled={isInCart(product.id)}
|
||||
className="shadow-xs"
|
||||
>
|
||||
{isInCart(product.id) ? 'In Cart' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user