diff --git a/src/app/api/delivery/assignments/[id]/route.ts b/src/app/api/delivery/assignments/[id]/route.ts index 1df604e..3cbb650 100644 --- a/src/app/api/delivery/assignments/[id]/route.ts +++ b/src/app/api/delivery/assignments/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { getAuthenticatedUser, unauthorizedResponse } from '@/lib/auth/guard'; import type { DeliveryAssignmentStatus, OrderStatus, DeliveryAssignment, Order } from '@/lib/supabase/types'; // Map assignment status to order status (for picked_up and delivered only) @@ -14,6 +15,9 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { + const { error: authError, isDemoMode } = await getAuthenticatedUser(); + if (!isDemoMode && authError) return unauthorizedResponse(); + const { id } = await params; const supabase = await createClient(); const body = await request.json(); @@ -112,6 +116,9 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { + const { error: authError, isDemoMode } = await getAuthenticatedUser(); + if (!isDemoMode && authError) return unauthorizedResponse(); + const { id } = await params; const supabase = await createClient(); diff --git a/src/app/api/delivery/assignments/route.ts b/src/app/api/delivery/assignments/route.ts index f65c847..aca8794 100644 --- a/src/app/api/delivery/assignments/route.ts +++ b/src/app/api/delivery/assignments/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { getAuthenticatedUser, unauthorizedResponse } from '@/lib/auth/guard'; export async function GET(request: NextRequest) { try { + const { error: authError, isDemoMode } = await getAuthenticatedUser(); + if (!isDemoMode && authError) return unauthorizedResponse(); + const supabase = await createClient(); const { searchParams } = new URL(request.url); diff --git a/src/app/api/orders/[id]/status/route.ts b/src/app/api/orders/[id]/status/route.ts index d2fe365..c7f7efa 100644 --- a/src/app/api/orders/[id]/status/route.ts +++ b/src/app/api/orders/[id]/status/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { getAuthenticatedUser, unauthorizedResponse } from '@/lib/auth/guard'; import type { OrderStatus, Order, DeliveryAssignment } from '@/lib/supabase/types'; // Valid status transitions @@ -18,6 +19,9 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { + const { error: authError, isDemoMode } = await getAuthenticatedUser(); + if (!isDemoMode && authError) return unauthorizedResponse(); + const { id } = await params; const supabase = await createClient(); const body = await request.json(); @@ -120,6 +124,9 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { + const { error: authError, isDemoMode } = await getAuthenticatedUser(); + if (!isDemoMode && authError) return unauthorizedResponse(); + const { id } = await params; const supabase = await createClient(); diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index 946e60a..6e4c714 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -1,13 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { getAuthenticatedUser, unauthorizedResponse } from '@/lib/auth/guard'; import type { Order, DeliveryPerson } from '@/lib/supabase/types'; export async function POST(request: NextRequest) { try { + const { user: authUser, error: authError, isDemoMode } = await getAuthenticatedUser(); + if (!isDemoMode && authError) return unauthorizedResponse(); + const supabase = await createClient(); const body = await request.json(); - const { vendor_id, items, delivery_address, delivery_notes, customer_id } = body; + const { vendor_id, items, delivery_address, delivery_notes, customer_id: bodyCustomerId } = body; + const customer_id = isDemoMode ? bodyCustomerId : authUser!.id; // Validate required fields if (!vendor_id || !items?.length || !delivery_address || !customer_id) { @@ -109,6 +114,9 @@ export async function POST(request: NextRequest) { export async function GET(request: NextRequest) { try { + const { error: authError, isDemoMode } = await getAuthenticatedUser(); + if (!isDemoMode && authError) return unauthorizedResponse(); + const supabase = await createClient(); const { searchParams } = new URL(request.url); diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index 990a27f..670845e 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -9,11 +9,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { useCart } from '@/hooks/use-cart'; -import { DEMO_IDS } from '@/lib/config/demo'; +import { useAuth } from '@/hooks/use-auth'; export default function CheckoutPage() { const router = useRouter(); const { items, totalAmount, vendorId, clearCart } = useCart(); + const { userId } = useAuth(); const [address, setAddress] = useState(''); const [notes, setNotes] = useState(''); const [loading, setLoading] = useState(false); @@ -40,7 +41,7 @@ export default function CheckoutPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - customer_id: DEMO_IDS.CUSTOMER_ID, + customer_id: userId, vendor_id: vendorId, items: items.map((item) => ({ menu_item_id: item.menu_item_id, diff --git a/src/app/delivery/page.tsx b/src/app/delivery/page.tsx index 094b0c0..6d18e7d 100644 --- a/src/app/delivery/page.tsx +++ b/src/app/delivery/page.tsx @@ -8,7 +8,7 @@ import { Separator } from '@/components/ui/separator'; import { OrderStatusBadge } from '@/components/order-status'; import { useDeliveryAssignments, updateAssignmentStatus } from '@/hooks/use-orders'; import type { DeliveryAssignment, OrderWithDetails } from '@/lib/supabase/types'; -import { DEMO_IDS } from '@/lib/config/demo'; +import { useAuth } from '@/hooks/use-auth'; // Extended order type with vendor_accepted interface DeliveryOrder extends OrderWithDetails { @@ -16,7 +16,8 @@ interface DeliveryOrder extends OrderWithDetails { } export default function DeliveryDashboard() { - const { assignments, loading, error } = useDeliveryAssignments(DEMO_IDS.DELIVERY_PERSON_ID); + const { deliveryPersonId } = useAuth(); + const { assignments, loading, error } = useDeliveryAssignments(deliveryPersonId!); const [updating, setUpdating] = useState(null); const handleStatusUpdate = async ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 361f10a..eb1009a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { RoleProvider } from "@/hooks/use-role"; +import { AuthProvider } from "@/hooks/use-auth"; import { CartProvider } from "@/hooks/use-cart"; const geistSans = Geist({ @@ -43,9 +44,11 @@ export default function RootLayout({ )} - - {children} - + + + {children} + + diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..e1825ef --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Phone, Lock, Loader2 } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAuth } from '@/hooks/use-auth'; +import { DEMO_MODE } from '@/lib/config/demo'; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { signIn } = useAuth(); + const [phone, setPhone] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Demo mode doesn't need login + if (DEMO_MODE) { + router.push('/'); + return null; + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!phone.trim() || !password) return; + + setLoading(true); + setError(null); + + const { error: signInError } = await signIn(phone.trim(), password); + + if (signInError) { + setError(signInError); + setLoading(false); + } else { + const redirect = searchParams.get('redirect') || '/'; + router.push(redirect); + } + }; + + return ( +
+ + + Sign In +

+ Welcome back to Firefly +

+
+ +
+
+ + setPhone(e.target.value)} + className="h-12" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="h-12" + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Don't have an account?{' '} + + Create one + +

+
+
+
+ ); +} + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 051eb7d..139e276 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { ShoppingBag, Store, Truck } from "lucide-react"; +import { DEMO_MODE } from "@/lib/config/demo"; const roles = [ { @@ -33,6 +35,60 @@ const roles = [ ]; export default function Home() { + if (!DEMO_MODE) { + return ( +
+
+
+

Project Firefly

+
+
+ +
+
+
+

+ Community-powered food ordering for local co-ops +

+

+ Order from local vendors, delivered by your neighbors +

+
+ +
+ {roles.map((role) => ( +
+
+ +
+

{role.name}

+
+ ))} +
+ +
+ + + + + + + + + +
+
+
+
+ ); + } + return (
{/* Header */} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..b7eebf1 --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Phone, Lock, User, Loader2, ShoppingBag, Store, Truck } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAuth } from '@/hooks/use-auth'; +import { DEMO_MODE } from '@/lib/config/demo'; +import type { UserRole } from '@/types'; + +const roleOptions = [ + { + value: 'customer' as UserRole, + label: 'Customer', + description: 'Order food from local vendors', + icon: ShoppingBag, + color: 'border-green-500 bg-green-50 dark:bg-green-950', + selectedColor: 'border-green-500 ring-2 ring-green-500', + }, + { + value: 'vendor' as UserRole, + label: 'Vendor', + description: 'Sell food to the community', + icon: Store, + color: 'border-amber-500 bg-amber-50 dark:bg-amber-950', + selectedColor: 'border-amber-500 ring-2 ring-amber-500', + }, + { + value: 'delivery' as UserRole, + label: 'Delivery', + description: 'Deliver orders in your area', + icon: Truck, + color: 'border-blue-500 bg-blue-50 dark:bg-blue-950', + selectedColor: 'border-blue-500 ring-2 ring-blue-500', + }, +]; + +export default function RegisterPage() { + const router = useRouter(); + const { signUp } = useAuth(); + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('customer'); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Demo mode doesn't need registration + if (DEMO_MODE) { + router.push('/'); + return null; + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !phone.trim() || !password) return; + + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + setLoading(true); + setError(null); + + const { error: signUpError } = await signUp(phone.trim(), password, name.trim(), role); + + if (signUpError) { + setError(signUpError); + setLoading(false); + } else { + router.push('/'); + } + }; + + return ( +
+ + + Create Account + Join the Firefly community + + +
+
+ + setName(e.target.value)} + className="h-12" + required + /> +
+ +
+ + setPhone(e.target.value)} + className="h-12" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="h-12" + minLength={6} + required + /> +
+ + {/* Role Selection */} +
+ +
+ {roleOptions.map((opt) => ( + + ))} +
+

+ {roleOptions.find((o) => o.value === role)?.description} +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ); +} diff --git a/src/app/vendor/page.tsx b/src/app/vendor/page.tsx index bf0799f..9c2d328 100644 --- a/src/app/vendor/page.tsx +++ b/src/app/vendor/page.tsx @@ -8,7 +8,7 @@ import { Separator } from '@/components/ui/separator'; import { OrderStatusBadge } from '@/components/order-status'; import { useVendorOrders, updateOrderStatus, vendorAcceptOrder } from '@/hooks/use-orders'; import type { OrderWithDetails, DeliveryAssignment } from '@/lib/supabase/types'; -import { DEMO_IDS } from '@/lib/config/demo'; +import { useAuth } from '@/hooks/use-auth'; // Extended order type with delivery assignment and vendor_accepted interface VendorOrder extends OrderWithDetails { @@ -17,7 +17,8 @@ interface VendorOrder extends OrderWithDetails { } export default function VendorDashboard() { - const { orders, loading, error } = useVendorOrders(DEMO_IDS.VENDOR_ID); + const { vendorId } = useAuth(); + const { orders, loading, error } = useVendorOrders(vendorId!); const [updating, setUpdating] = useState(null); const handleAcceptOrder = async (orderId: string) => { diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx new file mode 100644 index 0000000..1ecbc69 --- /dev/null +++ b/src/hooks/use-auth.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useMemo, + ReactNode, +} from 'react'; +import { Session, User as SupabaseUser } from '@supabase/supabase-js'; +import { createClient } from '@/lib/supabase/client'; +import { DEMO_MODE, DEMO_IDS } from '@/lib/config/demo'; +import { useRole } from '@/hooks/use-role'; +import type { UserRole } from '@/types'; + +interface AuthContextType { + user: { + id: string; + phone: string; + name: string; + roles: UserRole[]; + default_role?: UserRole; + } | null; + session: Session | null; + loading: boolean; + signIn: (phone: string, password: string) => Promise<{ error: string | null }>; + signUp: ( + phone: string, + password: string, + name: string, + role: UserRole + ) => Promise<{ error: string | null }>; + signOut: () => Promise; + userId: string | null; + vendorId: string | null; + deliveryPersonId: string | null; +} + +const AuthContext = createContext(undefined); + +// Demo user mappings from seed.sql +const DEMO_USERS = { + customer: { + id: '00000000-0000-0000-0000-000000000001', + phone: '555-0101', + name: 'Demo Customer', + roles: ['customer'] as UserRole[], + default_role: 'customer' as UserRole, + }, + vendor: { + id: '00000000-0000-0000-0000-000000000002', + phone: '555-0102', + name: 'Maria Garcia', + roles: ['vendor'] as UserRole[], + default_role: 'vendor' as UserRole, + }, + delivery: { + id: '00000000-0000-0000-0000-000000000003', + phone: '555-0103', + name: 'Alex Johnson', + roles: ['delivery'] as UserRole[], + default_role: 'delivery' as UserRole, + }, +}; + +export function AuthProvider({ children }: { children: ReactNode }) { + const { currentRole } = useRole(); + + if (DEMO_MODE) { + return ( + + {children} + + ); + } + + return {children}; +} + +function DemoAuthProvider({ + children, + currentRole, +}: { + children: ReactNode; + currentRole: UserRole; +}) { + const value = useMemo(() => { + const demoUser = DEMO_USERS[currentRole]; + return { + user: demoUser, + session: null, + loading: false, + signIn: async () => ({ error: null }), + signUp: async () => ({ error: null }), + signOut: async () => {}, + userId: demoUser.id, + vendorId: DEMO_IDS.VENDOR_ID, + deliveryPersonId: DEMO_IDS.DELIVERY_PERSON_ID, + }; + }, [currentRole]); + + return {children}; +} + +function ProductionAuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [vendorId, setVendorId] = useState(null); + const [deliveryPersonId, setDeliveryPersonId] = useState(null); + const supabase = useMemo(() => { + if (typeof window === 'undefined') return null; + return createClient(); + }, []); + + const [loading, setLoading] = useState(!!supabase); + + const fetchUserProfile = useCallback( + async (authUser: SupabaseUser) => { + if (!supabase) return; + // Fetch public.users record + const { data: profile } = await supabase + .from('users') + .select('*') + .eq('id', authUser.id) + .single(); + + if (profile) { + setUser({ + id: profile.id, + phone: profile.phone || authUser.phone || '', + name: profile.name, + roles: profile.roles || ['customer'], + default_role: profile.default_role, + }); + + // Fetch vendor record if user has vendor role + if (profile.roles?.includes('vendor')) { + const { data: vendor } = await supabase + .from('vendors') + .select('id') + .eq('user_id', profile.id) + .single(); + setVendorId(vendor?.id || null); + } + + // Fetch delivery person record if user has delivery role + if (profile.roles?.includes('delivery')) { + const { data: dp } = await supabase + .from('delivery_persons') + .select('id') + .eq('user_id', profile.id) + .single(); + setDeliveryPersonId(dp?.id || null); + } + } + }, + [supabase] + ); + + useEffect(() => { + if (!supabase) return; + + // Get initial session + supabase.auth.getSession().then(({ data: { session: s } }) => { + setSession(s); + if (s?.user) { + fetchUserProfile(s.user).finally(() => setLoading(false)); + } else { + setLoading(false); + } + }); + + // Listen for auth changes + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, s) => { + setSession(s); + if (s?.user) { + fetchUserProfile(s.user); + } else { + setUser(null); + setVendorId(null); + setDeliveryPersonId(null); + } + }); + + return () => subscription.unsubscribe(); + }, [supabase, fetchUserProfile]); + + const signIn = useCallback( + async (phone: string, password: string) => { + if (!supabase) return { error: 'Not initialized' }; + const { error } = await supabase.auth.signInWithPassword({ + phone, + password, + }); + if (error) return { error: error.message }; + return { error: null }; + }, + [supabase] + ); + + const signUp = useCallback( + async (phone: string, password: string, name: string, role: UserRole) => { + if (!supabase) return { error: 'Not initialized' }; + const { data, error } = await supabase.auth.signUp({ + phone, + password, + }); + if (error) return { error: error.message }; + if (!data.user) return { error: 'Sign up failed' }; + + // Create public.users record + const { error: profileError } = await supabase.from('users').insert({ + id: data.user.id, + email: '', + name, + phone, + roles: [role], + default_role: role, + }); + + if (profileError) { + return { error: 'Account created but profile setup failed. Please contact support.' }; + } + + // Create role-specific records + if (role === 'vendor') { + await supabase.from('vendors').insert({ + user_id: data.user.id, + name: `${name}'s Kitchen`, + is_active: true, + }); + } else if (role === 'delivery') { + await supabase.from('delivery_persons').insert({ + user_id: data.user.id, + is_active: true, + is_available: true, + vehicle_type: 'bike', + }); + } + + return { error: null }; + }, + [supabase] + ); + + const signOut = useCallback(async () => { + if (!supabase) return; + await supabase.auth.signOut(); + setUser(null); + setVendorId(null); + setDeliveryPersonId(null); + }, [supabase]); + + const value = useMemo( + () => ({ + user, + session, + loading, + signIn, + signUp, + signOut, + userId: user?.id || null, + vendorId, + deliveryPersonId, + }), + [user, session, loading, signIn, signUp, signOut, vendorId, deliveryPersonId] + ); + + 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/lib/auth/guard.ts b/src/lib/auth/guard.ts new file mode 100644 index 0000000..e1c2537 --- /dev/null +++ b/src/lib/auth/guard.ts @@ -0,0 +1,32 @@ +import { createClient } from '@/lib/supabase/server'; +import { DEMO_MODE } from '@/lib/config/demo'; +import { NextResponse } from 'next/server'; +import type { User } from '@supabase/supabase-js'; + +interface AuthResult { + user: User | null; + error: string | null; + isDemoMode: boolean; +} + +export async function getAuthenticatedUser(): Promise { + if (DEMO_MODE) { + return { user: null, error: null, isDemoMode: true }; + } + + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return { user: null, error: 'Unauthorized', isDemoMode: false }; + } + + return { user, error: null, isDemoMode: false }; +} + +export function unauthorizedResponse() { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..1fafa01 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,70 @@ +import { createServerClient } from '@supabase/ssr'; +import { NextResponse, type NextRequest } from 'next/server'; + +const publicRoutes = ['/', '/login', '/register', '/vendors']; + +function isPublicRoute(pathname: string): boolean { + if (publicRoutes.includes(pathname)) return true; + // /vendors/[id] is also public (browsing) + if (pathname.startsWith('/vendors/')) return true; + return false; +} + +export async function middleware(request: NextRequest) { + // Demo mode: pass-through + if (process.env.NEXT_PUBLIC_DEMO_MODE === 'true') { + return NextResponse.next(); + } + + let supabaseResponse = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ); + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ); + }, + }, + } + ); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const { pathname } = request.nextUrl; + + // Redirect unauthenticated users from protected routes to login + if (!user && !isPublicRoute(pathname)) { + const url = request.nextUrl.clone(); + url.pathname = '/login'; + url.searchParams.set('redirect', pathname); + return NextResponse.redirect(url); + } + + // Redirect authenticated users away from login/register + if (user && (pathname === '/login' || pathname === '/register')) { + const url = request.nextUrl.clone(); + url.pathname = '/'; + return NextResponse.redirect(url); + } + + return supabaseResponse; +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|manifest.json|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +};