2026-03-06 09:05:31 -05:00

409 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import usePlacesAutocomplete, { getGeocode, getLatLng } from 'use-places-autocomplete'
import { api } from '@/lib/api'
import { useCart } from '@/lib/cart'
// ============================================================
// CHECKOUT PAGE
// 1. Address input + zone check
// 2. Tip selection
// 3. Transparent pricing breakdown
// 4. Virtual payment (mock — no Stripe required)
// ============================================================
export default function CheckoutPage() {
const { items, subtotal, restaurantId, restaurantName, clearCart, deliveryFee } = useCart()
const router = useRouter()
const [streetAddress, setStreetAddress] = useState('')
const [city, setCity] = useState('')
const [postalCode, setPostalCode] = useState('')
const [coords, setCoords] = useState<{ lng: number; lat: number } | null>(null)
const [tip, setTip] = useState(0)
const [instructions, setInstructions] = useState('')
const [orderId, setOrderId] = useState('')
const [step, setStep] = useState<'address' | 'payment'>('address')
const [zoneError, setZoneError] = useState('')
const [loading, setLoading] = useState(false)
const [mapsReady, setMapsReady] = useState(false)
const [paymentBreakdown, setPaymentBreakdown] = useState<any>(null)
const cartSubtotal = subtotal()
const total = cartSubtotal + deliveryFee + tip
// Auth guard — redirect unauthenticated users to login
useEffect(() => {
const token = localStorage.getItem('vibe_token') || localStorage.getItem('token')
if (!token) router.replace('/login?redirect=/checkout')
}, [])
// Redirect if cart is empty (but not after payment when cart is intentionally cleared)
useEffect(() => {
if (items.length === 0 && step === 'address') router.replace('/restaurants')
}, [items.length, step])
// Load Google Maps Places API
useEffect(() => {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
if (!apiKey) return // No key → fall back to plain inputs
if (typeof window !== 'undefined' && (window as any).google?.maps?.places) {
setMapsReady(true)
return
}
const existing = document.querySelector('script[data-gmaps]')
if (existing) { existing.addEventListener('load', () => setMapsReady(true)); return }
const script = document.createElement('script')
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`
script.async = true
script.setAttribute('data-gmaps', '1')
script.onload = () => setMapsReady(true)
document.head.appendChild(script)
}, [])
const handleProceedToPayment = async () => {
if (!streetAddress.trim()) return
// Fall back to Liberty Village centre if no geocode available
const deliveryCoords = coords ?? { lng: -79.4196, lat: 43.6389 }
const fullAddress = [streetAddress, city, postalCode].filter(Boolean).join(', ')
setLoading(true)
setZoneError('')
try {
// 1. Verify delivery address is in service area
const zoneCheck = await api.get('/zones/check', {
params: { lng: deliveryCoords.lng, lat: deliveryCoords.lat },
})
if (!zoneCheck.data.inServiceArea) {
setZoneError("Sorry, your address is outside our current service area. We're expanding soon!")
setLoading(false)
return
}
// 2. Place order
const orderData = await api.post('/orders', {
restaurantId,
items: items.map((i) => ({ menuItemId: i.menuItemId, quantity: i.quantity })),
deliveryAddress: fullAddress,
deliveryLng: deliveryCoords.lng,
deliveryLat: deliveryCoords.lat,
tipAmount: tip,
specialInstructions: instructions,
})
const order = orderData.data.order
setOrderId(order.id)
// 3. Process virtual payment immediately
const payData = await api.post(`/payments/orders/${order.id}/pay-virtual`)
setPaymentBreakdown(payData.data.breakdown)
setStep('payment')
} catch (err: any) {
setZoneError(err.response?.data?.message || 'Failed to place order. Please try again.')
} finally {
setLoading(false)
}
}
const TIP_OPTIONS = [0, 2, 3, 5, 8]
const canProceed = streetAddress.trim().length > 0
if (step === 'payment') {
return (
<VirtualPaymentConfirmation
orderId={orderId}
restaurantName={restaurantName || ''}
breakdown={paymentBreakdown}
onContinue={() => {
const id = orderId
clearCart()
router.push(`/orders/${id}/track`)
}}
/>
)
}
return (
<div className="min-h-screen bg-vibe-cream">
<div className="bg-white border-b border-slate-100 px-6 py-4">
<div className="max-w-lg mx-auto flex items-center gap-3">
<button onClick={() => router.back()} className="text-slate-500 hover:text-vibe-dark"></button>
<h1 className="font-bold text-vibe-dark">Checkout</h1>
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-6 space-y-4">
{/* Restaurant */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Ordering from</p>
<p className="font-semibold text-vibe-dark">{restaurantName}</p>
</div>
{/* Delivery address */}
<div className="bg-white rounded-2xl border border-slate-100 p-4 space-y-3">
<h2 className="font-semibold text-vibe-dark">Delivery Address</h2>
{mapsReady ? (
<AddressAutocomplete
onSelect={(street, resolvedCity, resolvedPostal, resolvedCoords) => {
setStreetAddress(street)
setCity(resolvedCity)
setPostalCode(resolvedPostal)
setCoords(resolvedCoords)
}}
/>
) : (
<input
type="text"
value={streetAddress}
onChange={(e) => setStreetAddress(e.target.value)}
placeholder="Street address"
className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-400 mb-1">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Toronto"
className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Postal Code</label>
<input
type="text"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="M6J 1E6"
className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
</div>
{zoneError && (
<p className="text-red-600 text-sm bg-red-50 rounded-xl px-3 py-2">{zoneError}</p>
)}
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Delivery instructions (optional) — buzz code, leave at door, etc."
rows={2}
className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal resize-none"
/>
</div>
{/* Order items */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<h2 className="font-semibold text-vibe-dark mb-3">Your Order</h2>
<div className="space-y-2">
{items.map((item) => (
<div key={item.menuItemId} className="flex justify-between text-sm">
<span className="text-slate-600">{item.quantity}× {item.name}</span>
<span className="text-vibe-dark font-medium">${(Number(item.price) * item.quantity).toFixed(2)}</span>
</div>
))}
</div>
</div>
{/* Tip */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<h2 className="font-semibold text-vibe-dark mb-1">Tip your driver</h2>
<p className="text-xs text-slate-400 mb-3">100% of tips go directly to your driver always.</p>
<div className="flex gap-2 flex-wrap">
{TIP_OPTIONS.map((t) => (
<button
key={t}
onClick={() => setTip(t)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition ${
tip === t
? 'bg-vibe-teal text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{t === 0 ? 'No tip' : `$${t}`}
</button>
))}
</div>
</div>
{/* Price breakdown */}
<div className="bg-white rounded-2xl border border-slate-100 p-4 space-y-2 text-sm">
<h2 className="font-semibold text-vibe-dark mb-1">Price Breakdown</h2>
<div className="flex justify-between text-slate-600"><span>Subtotal</span><span>${cartSubtotal.toFixed(2)}</span></div>
<div className="flex justify-between text-slate-600"><span>Delivery fee (flat)</span><span>${deliveryFee.toFixed(2)}</span></div>
{tip > 0 && <div className="flex justify-between text-vibe-green"><span>Tip (100% to driver)</span><span>${tip.toFixed(2)}</span></div>}
<div className="flex justify-between font-bold text-vibe-dark pt-2 border-t border-slate-100">
<span>Total</span><span>${total.toFixed(2)}</span>
</div>
<div className="bg-vibe-green/5 rounded-xl p-2 text-xs text-slate-500 mt-2">
No service fee. No hidden charges. No credit card needed virtual payment.
</div>
</div>
<button
onClick={handleProceedToPayment}
disabled={!canProceed || loading}
className="w-full bg-vibe-teal text-white py-4 rounded-xl font-semibold hover:bg-teal-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? 'Placing order...' : `Place Order · $${total.toFixed(2)}`}
</button>
</div>
</div>
)
}
// ---- Google Places autocomplete component ----
function AddressAutocomplete({
onSelect,
}: {
onSelect: (
streetAddress: string,
city: string,
postalCode: string,
coords: { lng: number; lat: number },
) => void
}) {
const {
ready,
value,
suggestions: { status, data },
setValue,
clearSuggestions,
} = usePlacesAutocomplete({
requestOptions: {
componentRestrictions: { country: 'ca' },
types: ['address'],
},
debounce: 300,
})
const handleSelect = async (description: string) => {
setValue(description, false)
clearSuggestions()
try {
const results = await getGeocode({ address: description })
const { lat, lng } = await getLatLng(results[0])
const components = results[0].address_components
let resolvedCity = ''
let resolvedPostal = ''
let streetNumber = ''
let route = ''
for (const comp of components) {
if (comp.types.includes('street_number')) streetNumber = comp.long_name
if (comp.types.includes('route')) route = comp.long_name
if (comp.types.includes('locality')) resolvedCity = comp.long_name
if (comp.types.includes('postal_code')) resolvedPostal = comp.long_name
}
const streetAddress = [streetNumber, route].filter(Boolean).join(' ') || description
onSelect(streetAddress, resolvedCity, resolvedPostal, { lng, lat })
} catch {
onSelect(description, '', '', { lng: -79.4196, lat: 43.6389 })
}
}
return (
<div className="relative">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={!ready}
placeholder="Start typing your street address..."
className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
autoComplete="off"
/>
{status === 'OK' && data.length > 0 && (
<ul className="absolute z-30 w-full bg-white border border-slate-200 rounded-xl mt-1 shadow-lg overflow-hidden">
{data.map(({ place_id, description, structured_formatting }) => (
<li
key={place_id}
onClick={() => handleSelect(description)}
className="px-4 py-3 text-sm hover:bg-vibe-cream cursor-pointer border-b border-slate-50 last:border-0"
>
<span className="font-medium text-vibe-dark">{structured_formatting.main_text}</span>
<span className="text-slate-400 text-xs ml-1">{structured_formatting.secondary_text}</span>
</li>
))}
</ul>
)}
</div>
)
}
// ---- Virtual Payment Confirmation ----
function VirtualPaymentConfirmation({
orderId, restaurantName, breakdown, onContinue,
}: {
orderId: string; restaurantName: string;
breakdown: { customerPaid: number; restaurantReceives: number; driverReceives: number; platformFee: number } | null;
onContinue: () => void;
}) {
return (
<div className="min-h-screen bg-vibe-cream">
<div className="bg-white border-b border-slate-100 px-6 py-4">
<div className="max-w-lg mx-auto">
<h1 className="font-bold text-vibe-dark">Order Confirmed!</h1>
<p className="text-slate-500 text-sm">{restaurantName}</p>
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-6 space-y-4">
<div className="bg-white rounded-2xl border border-slate-100 p-6 text-center">
<div className="text-5xl mb-3"></div>
<h2 className="text-xl font-bold text-vibe-dark mb-1">Payment Successful</h2>
<p className="text-slate-500 text-sm">Your order is being prepared.</p>
</div>
{breakdown && (
<div className="bg-white rounded-2xl border border-slate-100 p-4 space-y-2 text-sm">
<h3 className="font-semibold text-vibe-dark mb-2">How your money moves</h3>
<div className="flex justify-between text-slate-600">
<span>You paid</span>
<span className="font-medium">${Number(breakdown.customerPaid).toFixed(2)}</span>
</div>
<div className="flex justify-between text-vibe-green">
<span>Restaurant receives</span>
<span className="font-medium">${Number(breakdown.restaurantReceives).toFixed(2)}</span>
</div>
<div className="flex justify-between text-blue-600">
<span>Driver receives (delivery + tip)</span>
<span className="font-medium">${Number(breakdown.driverReceives).toFixed(2)}</span>
</div>
<div className="flex justify-between text-slate-400 text-xs pt-1 border-t border-slate-100">
<span>Platform fee</span>
<span>${Number(breakdown.platformFee).toFixed(2)}</span>
</div>
</div>
)}
<div className="bg-vibe-teal/5 border border-vibe-teal/20 rounded-xl p-3 text-xs text-slate-600 text-center">
No hidden fees. No commission taken from restaurants. 100% of tips go to drivers.
</div>
<button
onClick={onContinue}
className="w-full bg-vibe-teal text-white py-4 rounded-xl font-semibold hover:bg-teal-700 transition"
>
Track My Order
</button>
</div>
</div>
)
}