diff --git a/src/App.tsx b/src/App.tsx index 18daf2e..6368947 100644 --- a/src/App.tsx +++ b/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 = () => ( - - - - - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..9c0b3aa --- /dev/null +++ b/src/components/Layout.tsx @@ -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 ( +
+
+
+ + LearnHub + + + +
+
+ +
{children}
+
+ ); +} diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx new file mode 100644 index 0000000..187df06 --- /dev/null +++ b/src/contexts/CartContext.tsx @@ -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(undefined); + +export function CartProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + + 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 ( + + {children} + + ); +} + +export function useCart() { + const context = useContext(CartContext); + if (context === undefined) { + throw new Error('useCart must be used within a CartProvider'); + } + return context; +} diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx new file mode 100644 index 0000000..69d1371 --- /dev/null +++ b/src/hooks/useAuth.tsx @@ -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; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [session, setSession] = useState(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 ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 0000000..d5feead --- /dev/null +++ b/src/pages/Admin.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingProduct, setEditingProduct] = useState(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 ( + +
+ + +
+
+ ); + } + + return ( + +
+
+
+

Admin Panel

+

Manage your products

+
+ + + + + + + {editingProduct ? 'Edit Product' : 'New Product'} + +
+
+
+ + { + setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) }); + }} + className="border-2" + /> +
+
+ + setForm({ ...form, slug: e.target.value })} + className="border-2" + /> +
+
+ +
+ + +
+ +
+ +