metatroncubeswdev 89cf37f5b5 Initial commit — The Vibe fair-trade delivery platform
- 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>
2026-03-04 13:26:55 -05:00

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>
)
}