- NestJS backend: auth, restaurants, orders, drivers, payments, tracking, reviews, zones, admin, email - Next.js 14 frontend: landing, restaurants, checkout, tracking, dashboards, onboarding - Expo mobile app: driver orders and earnings screens - PostgreSQL + PostGIS schema with seed data - Docker Compose for local dev (Postgres, Redis, OSRM) - MapLibre GL + OpenStreetMap integration - Stripe subscription and payment processing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import axios from 'axios'
|
|
|
|
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'
|
|
|
|
interface Zone {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
status: 'active' | 'coming_soon' | 'inactive'
|
|
priority: number
|
|
radius_km: number
|
|
center_lng: number
|
|
center_lat: number
|
|
boundary_geojson: string
|
|
restaurant_count: number
|
|
driver_count: number
|
|
}
|
|
|
|
const STATUS_CONFIG = {
|
|
active: { label: 'Active', color: 'bg-green-100 text-green-800 border-green-200', dot: 'bg-green-500' },
|
|
coming_soon: { label: 'Coming Soon', color: 'bg-amber-100 text-amber-800 border-amber-200', dot: 'bg-amber-400' },
|
|
inactive: { label: 'Inactive', color: 'bg-gray-100 text-gray-600 border-gray-200', dot: 'bg-gray-400' },
|
|
}
|
|
|
|
function ZoneMap({ zones, selectedId, onSelect }: { zones: Zone[]; selectedId: string | null; onSelect: (id: string) => void }) {
|
|
const mapRef = useRef<HTMLDivElement>(null)
|
|
const mapInstance = useRef<any>(null)
|
|
|
|
useEffect(() => {
|
|
if (!mapRef.current || typeof window === 'undefined') return
|
|
|
|
import('maplibre-gl').then(({ default: maplibre }) => {
|
|
if (mapInstance.current) return
|
|
|
|
const map = new maplibre.Map({
|
|
container: mapRef.current!,
|
|
style: `https://api.maptiler.com/maps/streets/style.json?key=${process.env.NEXT_PUBLIC_MAPTILER_KEY || 'demo'}`,
|
|
center: [-79.38, 43.65],
|
|
zoom: 9,
|
|
})
|
|
mapInstance.current = map
|
|
|
|
map.on('load', () => {
|
|
// Add zone boundaries as a GeoJSON layer
|
|
const features = zones
|
|
.filter(z => z.boundary_geojson)
|
|
.map(z => ({
|
|
type: 'Feature' as const,
|
|
properties: { id: z.id, name: z.name, status: z.status },
|
|
geometry: JSON.parse(z.boundary_geojson),
|
|
}))
|
|
|
|
map.addSource('zones', {
|
|
type: 'geojson',
|
|
data: { type: 'FeatureCollection', features },
|
|
})
|
|
|
|
// Fill
|
|
map.addLayer({
|
|
id: 'zones-fill',
|
|
type: 'fill',
|
|
source: 'zones',
|
|
paint: {
|
|
'fill-color': [
|
|
'match', ['get', 'status'],
|
|
'active', '#0d9488',
|
|
'coming_soon', '#f59e0b',
|
|
'#9ca3af',
|
|
],
|
|
'fill-opacity': 0.15,
|
|
},
|
|
})
|
|
|
|
// Outline
|
|
map.addLayer({
|
|
id: 'zones-outline',
|
|
type: 'line',
|
|
source: 'zones',
|
|
paint: {
|
|
'line-color': [
|
|
'match', ['get', 'status'],
|
|
'active', '#0d9488',
|
|
'coming_soon', '#f59e0b',
|
|
'#9ca3af',
|
|
],
|
|
'line-width': 2,
|
|
},
|
|
})
|
|
|
|
// Labels
|
|
map.addLayer({
|
|
id: 'zones-label',
|
|
type: 'symbol',
|
|
source: 'zones',
|
|
layout: {
|
|
'text-field': ['get', 'name'],
|
|
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
|
'text-size': 12,
|
|
},
|
|
paint: {
|
|
'text-color': '#1f2937',
|
|
'text-halo-color': '#ffffff',
|
|
'text-halo-width': 2,
|
|
},
|
|
})
|
|
|
|
// Click handler
|
|
map.on('click', 'zones-fill', (e) => {
|
|
const id = e.features?.[0]?.properties?.id
|
|
if (id) onSelect(id)
|
|
})
|
|
|
|
map.on('mouseenter', 'zones-fill', () => { map.getCanvas().style.cursor = 'pointer' })
|
|
map.on('mouseleave', 'zones-fill', () => { map.getCanvas().style.cursor = '' })
|
|
})
|
|
})
|
|
|
|
return () => {
|
|
mapInstance.current?.remove()
|
|
mapInstance.current = null
|
|
}
|
|
}, []) // only mount once
|
|
|
|
// Highlight selected zone
|
|
useEffect(() => {
|
|
if (!mapInstance.current || !selectedId) return
|
|
const map = mapInstance.current
|
|
if (!map.getLayer('zones-fill')) return
|
|
|
|
map.setPaintProperty('zones-fill', 'fill-opacity', [
|
|
'case', ['==', ['get', 'id'], selectedId], 0.35, 0.15,
|
|
])
|
|
}, [selectedId])
|
|
|
|
return (
|
|
<div ref={mapRef} className="w-full h-full rounded-xl overflow-hidden" />
|
|
)
|
|
}
|
|
|
|
export default function AdminZonesPage() {
|
|
const router = useRouter()
|
|
const [zones, setZones] = useState<Zone[]>([])
|
|
const [selected, setSelected] = useState<Zone | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem('token')
|
|
if (!token) { router.push('/login'); return }
|
|
fetchZones(token)
|
|
}, [])
|
|
|
|
const fetchZones = async (token?: string) => {
|
|
const t = token || localStorage.getItem('token')
|
|
try {
|
|
const { data } = await axios.get(`${API}/admin/zones`, {
|
|
headers: { Authorization: `Bearer ${t}` },
|
|
})
|
|
setZones(data)
|
|
if (data.length > 0 && !selected) setSelected(data[0])
|
|
} catch {
|
|
router.push('/login')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleStatusChange = async (zoneId: string, status: Zone['status']) => {
|
|
setSaving(true)
|
|
setMessage('')
|
|
try {
|
|
const token = localStorage.getItem('token')
|
|
await axios.patch(
|
|
`${API}/admin/zones/${zoneId}/status`,
|
|
{ status },
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
)
|
|
setZones(prev => prev.map(z => z.id === zoneId ? { ...z, status } : z))
|
|
setSelected(prev => prev?.id === zoneId ? { ...prev, status } : prev)
|
|
setMessage(`Zone status updated to "${STATUS_CONFIG[status].label}"`)
|
|
setTimeout(() => setMessage(''), 3000)
|
|
} catch (err: any) {
|
|
setMessage('Failed to update zone status')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const activeZones = zones.filter(z => z.status === 'active')
|
|
const comingSoonZones = zones.filter(z => z.status === 'coming_soon')
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
{/* Header */}
|
|
<div className="bg-gray-900 text-white">
|
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
|
<div>
|
|
<button onClick={() => router.push('/admin')} className="text-gray-400 hover:text-white text-sm mb-1">← Admin</button>
|
|
<h1 className="text-xl font-bold">Zone Management</h1>
|
|
</div>
|
|
<div className="flex gap-4 text-sm">
|
|
<span className="text-green-400 font-medium">{activeZones.length} active</span>
|
|
<span className="text-amber-400">{comingSoonZones.length} coming soon</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className="bg-teal-600 text-white text-sm text-center py-2">{message}</div>
|
|
)}
|
|
|
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Zone list */}
|
|
<div className="space-y-3">
|
|
<h2 className="font-semibold text-gray-900">GTA Delivery Zones</h2>
|
|
{loading ? (
|
|
<div className="text-gray-400 text-sm">Loading zones...</div>
|
|
) : (
|
|
zones.map(zone => {
|
|
const cfg = STATUS_CONFIG[zone.status]
|
|
const isSelected = selected?.id === zone.id
|
|
return (
|
|
<button
|
|
key={zone.id}
|
|
onClick={() => setSelected(zone)}
|
|
className={`w-full text-left p-4 rounded-xl border transition ${
|
|
isSelected
|
|
? 'border-teal-500 bg-teal-50 shadow-sm'
|
|
: 'border-gray-200 bg-white hover:border-teal-300'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="font-medium text-gray-900">{zone.name}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${cfg.color}`}>
|
|
{cfg.label}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-3 text-xs text-gray-500">
|
|
<span>{zone.restaurant_count} restaurants</span>
|
|
<span>{zone.driver_count} drivers</span>
|
|
<span>Priority {zone.priority}</span>
|
|
</div>
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Zone detail + map */}
|
|
<div className="lg:col-span-2 space-y-4">
|
|
{/* Map */}
|
|
<div className="h-72 lg:h-96">
|
|
{!loading && <ZoneMap zones={zones} selectedId={selected?.id || null} onSelect={id => setSelected(zones.find(z => z.id === id) || null)} />}
|
|
</div>
|
|
|
|
{/* Zone editor */}
|
|
{selected && (
|
|
<div className="bg-white rounded-xl border shadow-sm p-5">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-gray-900">{selected.name}</h3>
|
|
<p className="text-sm text-gray-500 mt-0.5">
|
|
{selected.center_lat?.toFixed(4)}, {selected.center_lng?.toFixed(4)} · {selected.radius_km}km radius
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2 text-sm">
|
|
<div className="text-center">
|
|
<p className="font-bold text-gray-900">{selected.restaurant_count}</p>
|
|
<p className="text-gray-500 text-xs">restaurants</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="font-bold text-gray-900">{selected.driver_count}</p>
|
|
<p className="text-gray-500 text-xs">drivers</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="font-bold text-gray-900">{selected.priority}</p>
|
|
<p className="text-gray-500 text-xs">priority</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status controls */}
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-700 mb-2">Zone Status</p>
|
|
<div className="flex gap-2">
|
|
{(['active', 'coming_soon', 'inactive'] as const).map(s => {
|
|
const cfg = STATUS_CONFIG[s]
|
|
const isCurrent = selected.status === s
|
|
return (
|
|
<button
|
|
key={s}
|
|
onClick={() => handleStatusChange(selected.id, s)}
|
|
disabled={isCurrent || saving}
|
|
className={`flex-1 py-2.5 px-3 rounded-lg border text-sm font-medium transition ${
|
|
isCurrent
|
|
? `${cfg.color} cursor-default`
|
|
: 'bg-white border-gray-300 text-gray-700 hover:border-teal-400 hover:text-teal-700 disabled:opacity-50'
|
|
}`}
|
|
>
|
|
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${cfg.dot}`} />
|
|
{cfg.label}
|
|
{isCurrent && ' ✓'}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Impact warning */}
|
|
{selected.status === 'active' && (
|
|
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
|
|
<strong>Live zone:</strong> {selected.restaurant_count} restaurants and {selected.driver_count} drivers
|
|
are actively using this zone. Deactivating will prevent new orders in this area.
|
|
</div>
|
|
)}
|
|
|
|
{selected.status === 'coming_soon' && (
|
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
|
Activating this zone will open delivery for restaurants and drivers in {selected.name}.
|
|
Make sure you have drivers ready!
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Launch checklist */}
|
|
<div className="bg-gray-900 text-white rounded-xl p-5">
|
|
<h3 className="font-semibold mb-3">Zone Launch Checklist</h3>
|
|
<div className="space-y-2 text-sm">
|
|
{zones.map(z => (
|
|
<div key={z.id} className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_CONFIG[z.status].dot}`} />
|
|
<span className="text-gray-300">{z.name}</span>
|
|
<span className="ml-auto text-gray-400 text-xs">
|
|
{z.restaurant_count}r · {z.driver_count}d
|
|
</span>
|
|
<span className={`text-xs ${z.status === 'active' ? 'text-green-400' : 'text-gray-500'}`}>
|
|
{STATUS_CONFIG[z.status].label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|