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:
gpt-engineer-app[bot]
2025-12-18 08:06:32 +00:00
11 changed files with 1441 additions and 20 deletions

View File

@@ -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>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
<AuthProvider>
<CartProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View File

@@ -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>
</div>
</div>
);
};
<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>
</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
View 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
View 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>
);
}