409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
'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>
|
||
)
|
||
}
|