- Server: Express.js with 13 API route files (auth, regulations, contacts, calendar, truck stops, bridges, weigh stations, alerts, load board, escort locator, orders, documents, contributions) - Database: PostgreSQL with Prisma ORM, 15 models covering all modules - Auth: JWT + bcrypt with role-based access control (driver/carrier/escort/admin) - Geospatial: Haversine distance filtering on truck stops, bridges, escorts - Seed script: Imports all existing mock data (51 states, contacts, equipment, truck stops, bridges, weigh stations, alerts, seasonal restrictions) - Frontend: All 10 data-driven pages now fetch from /api instead of mock-data.js - API client (api.js): Compatibility layer that transforms API responses to match existing frontend rendering code, minimizing page-level changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
306 lines
13 KiB
HTML
306 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Find Escort Vehicles | PilotEdge</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<style>
|
|
#locator-map { height: 500px; width: 100%; border-radius: 0.75rem; }
|
|
.operator-card.active { ring: 2px solid #f59e0b; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-50 min-h-screen flex flex-col">
|
|
|
|
<div id="main-nav"></div>
|
|
<div id="poc-banner"></div>
|
|
|
|
<!-- Page Header -->
|
|
<section class="bg-slate-900 text-white pt-24 pb-12 px-4">
|
|
<div class="max-w-7xl mx-auto">
|
|
<h1 class="text-3xl md:text-4xl font-bold mb-3">Find Escort Vehicles</h1>
|
|
<p class="text-lg text-gray-400 max-w-3xl">Browse available pilot/escort vehicle operators near your load's departure point. Click a marker on the map or browse the list below.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Filter Bar -->
|
|
<section class="max-w-7xl mx-auto px-4 pt-8">
|
|
<div class="bg-white rounded-2xl shadow-lg p-6">
|
|
<div class="flex flex-col md:flex-row gap-4">
|
|
<div class="flex-1">
|
|
<label class="block text-sm font-semibold text-slate-700 mb-1">Search by State or Name</label>
|
|
<input type="text" id="op-search" oninput="filterOperators()" placeholder="e.g. Texas, Mike's Pilot Car..." class="w-full border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-semibold text-slate-700 mb-1">Status</label>
|
|
<select id="op-status" onchange="filterOperators()" class="border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none">
|
|
<option value="all">All</option>
|
|
<option value="available">Available Now</option>
|
|
<option value="on_job">On a Job</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-semibold text-slate-700 mb-1">Certified In</label>
|
|
<select id="op-cert" onchange="filterOperators()" class="border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none">
|
|
<option value="all">Any State</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Map + List Layout -->
|
|
<section class="max-w-7xl mx-auto px-4 py-8">
|
|
<div class="grid lg:grid-cols-5 gap-6">
|
|
<!-- Map (3 cols) -->
|
|
<div class="lg:col-span-3">
|
|
<div class="bg-white rounded-2xl shadow-lg p-4">
|
|
<div id="locator-map"></div>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="flex gap-6 mt-3 px-2 text-sm text-slate-500">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-4 h-4 rounded-full bg-green-500 inline-block border-2 border-white shadow"></span>
|
|
Available
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-4 h-4 rounded-full bg-amber-500 inline-block border-2 border-white shadow"></span>
|
|
On a Job
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Operator List (2 cols) -->
|
|
<div class="lg:col-span-2">
|
|
<div id="operator-count" class="text-sm text-slate-500 font-medium mb-3"></div>
|
|
<div id="operator-list" class="space-y-4 max-h-[560px] overflow-y-auto pr-1">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Operator Detail Panel -->
|
|
<section id="operator-detail" class="max-w-7xl mx-auto px-4 pb-8 hidden">
|
|
<div class="bg-white rounded-2xl shadow-lg p-8">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h2 id="op-detail-name" class="text-2xl font-bold text-slate-900"></h2>
|
|
<button onclick="document.getElementById('operator-detail').classList.add('hidden')" class="text-slate-400 hover:text-slate-600 text-2xl">×</button>
|
|
</div>
|
|
<div id="op-detail-content">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- CTA for Operators -->
|
|
<section class="max-w-7xl mx-auto px-4 pb-8">
|
|
<div class="bg-gradient-to-r from-slate-800 to-slate-900 rounded-2xl p-8 text-white">
|
|
<div class="grid md:grid-cols-2 gap-8 items-center">
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-2">Are You an Escort Vehicle Operator?</h3>
|
|
<p class="text-gray-400">Add your location to our map and get discovered by carriers and truck drivers looking for escort services in your area.</p>
|
|
</div>
|
|
<div class="text-center md:text-right">
|
|
<button onclick="alert('Coming soon! In production, this would open a registration/profile creation flow.')" class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-6 py-3 rounded-lg transition-colors">
|
|
Register Your Service →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="main-footer"></div>
|
|
|
|
<script src="api.js"></script>
|
|
<script src="nav.js"></script>
|
|
<script>
|
|
renderNav('locator');
|
|
renderBanner();
|
|
renderFooter();
|
|
|
|
(async () => {
|
|
const MOCK_ESCORT_OPERATORS = await PilotEdge.getEscortOperators();
|
|
|
|
// Populate certification filter
|
|
const allCerts = new Set();
|
|
MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c)));
|
|
const certSelect = document.getElementById('op-cert');
|
|
Array.from(allCerts).sort().forEach(state => {
|
|
certSelect.add(new Option(state, state));
|
|
});
|
|
|
|
// Initialize map
|
|
const locatorMap = L.map('locator-map').setView([37.5, -95], 4);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 18
|
|
}).addTo(locatorMap);
|
|
|
|
let markers = {};
|
|
|
|
function addOperatorMarkers(operators) {
|
|
// Clear existing markers
|
|
Object.values(markers).forEach(m => locatorMap.removeLayer(m));
|
|
markers = {};
|
|
|
|
operators.forEach(op => {
|
|
const color = op.status === 'available' ? '#22c55e' : '#f59e0b';
|
|
const marker = L.circleMarker([op.location.lat, op.location.lng], {
|
|
radius: 10,
|
|
fillColor: color,
|
|
color: '#fff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.9
|
|
}).addTo(locatorMap);
|
|
|
|
marker.bindPopup(`
|
|
<div style="min-width:200px;">
|
|
<strong style="font-size:14px;">${op.name}</strong><br>
|
|
<span style="color:#666; font-size:12px;">${op.location.city}, ${op.location.state}</span><br>
|
|
<span style="color:${op.status === 'available' ? '#16a34a' : '#d97706'}; font-size:12px; font-weight:600;">${op.status === 'available' ? '● Available' : '● On a Job'}</span><br>
|
|
<span style="font-size:12px;">⭐ ${op.rating} · ${op.totalJobs} jobs</span><br><br>
|
|
<button onclick="showOperatorDetail('${op.id}')" style="background:#f59e0b; color:#0f172a; font-weight:700; padding:6px 16px; border-radius:6px; border:none; cursor:pointer; width:100%;">
|
|
View Profile
|
|
</button>
|
|
</div>
|
|
`);
|
|
|
|
markers[op.id] = marker;
|
|
});
|
|
}
|
|
|
|
function renderOperatorList(operators) {
|
|
const container = document.getElementById('operator-list');
|
|
document.getElementById('operator-count').textContent = `${operators.length} operator${operators.length !== 1 ? 's' : ''} found`;
|
|
|
|
if (operators.length === 0) {
|
|
container.innerHTML = `<div class="bg-white rounded-xl p-8 text-center text-slate-500">No operators match your filters.</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = operators.map(op => `
|
|
<div class="bg-white rounded-xl shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer" onclick="showOperatorDetail('${op.id}')">
|
|
<div class="flex items-start justify-between mb-2">
|
|
<h3 class="font-bold text-slate-900">${op.name}</h3>
|
|
<span class="flex-shrink-0 w-3 h-3 rounded-full ${op.status === 'available' ? 'bg-green-500' : 'bg-amber-500'} mt-1.5"></span>
|
|
</div>
|
|
<p class="text-sm text-slate-500 mb-2">${op.location.city}, ${op.location.state}</p>
|
|
<div class="flex items-center gap-3 text-sm mb-3">
|
|
<span class="text-amber-500">⭐ ${op.rating}</span>
|
|
<span class="text-slate-400">·</span>
|
|
<span class="text-slate-600">${op.totalJobs} jobs</span>
|
|
<span class="text-slate-400">·</span>
|
|
<span class="text-slate-600">${op.experience}</span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
${op.certifications.map(c => `<span class="bg-slate-100 text-slate-600 text-xs font-medium px-2 py-0.5 rounded">${c}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function showOperatorDetail(id) {
|
|
const op = MOCK_ESCORT_OPERATORS.find(o => o.id === id);
|
|
if (!op) return;
|
|
|
|
// Center map on operator
|
|
locatorMap.setView([op.location.lat, op.location.lng], 7);
|
|
if (markers[id]) markers[id].openPopup();
|
|
|
|
document.getElementById('op-detail-name').textContent = op.name;
|
|
document.getElementById('op-detail-content').innerHTML = `
|
|
<div class="grid md:grid-cols-2 gap-8">
|
|
<div>
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<span class="inline-block w-4 h-4 rounded-full ${op.status === 'available' ? 'bg-green-500' : 'bg-amber-500'}"></span>
|
|
<span class="font-semibold ${op.status === 'available' ? 'text-green-700' : 'text-amber-700'}">${op.status === 'available' ? 'Available for Jobs' : 'Currently on a Job'}</span>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div class="bg-slate-50 px-4 py-3 rounded-lg">
|
|
<span class="text-sm text-slate-500 block">Location</span>
|
|
<span class="font-semibold text-slate-900">${op.location.city}, ${op.location.state}</span>
|
|
</div>
|
|
<div class="bg-slate-50 px-4 py-3 rounded-lg">
|
|
<span class="text-sm text-slate-500 block">Experience</span>
|
|
<span class="font-semibold text-slate-900">${op.experience} · ${op.totalJobs} completed jobs</span>
|
|
</div>
|
|
<div class="bg-slate-50 px-4 py-3 rounded-lg">
|
|
<span class="text-sm text-slate-500 block">Rating</span>
|
|
<span class="font-semibold text-slate-900">⭐ ${op.rating} / 5.0</span>
|
|
</div>
|
|
<div class="bg-slate-50 px-4 py-3 rounded-lg">
|
|
<span class="text-sm text-slate-500 block">Vehicle</span>
|
|
<span class="font-semibold text-slate-900">${op.vehicleType}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="mb-4">
|
|
<h3 class="font-bold text-slate-900 mb-2">Certified In</h3>
|
|
<div class="flex flex-wrap gap-2">
|
|
${op.certifications.map(c => `<span class="bg-amber-100 text-amber-800 text-sm font-semibold px-3 py-1 rounded-full">${c}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<h3 class="font-bold text-slate-900 mb-2">About</h3>
|
|
<p class="text-slate-600">${op.bio}</p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 rounded-xl p-4 space-y-2">
|
|
<h3 class="font-bold text-slate-900 mb-2">Contact</h3>
|
|
<p class="text-sm"><span class="text-slate-500">Email:</span> <a href="mailto:${op.contact}" class="text-amber-600 hover:text-amber-700 font-medium">${op.contact}</a></p>
|
|
<p class="text-sm"><span class="text-slate-500">Phone:</span> <a href="tel:${op.phone}" class="text-amber-600 hover:text-amber-700 font-medium">${op.phone}</a></p>
|
|
</div>
|
|
|
|
${op.status === 'available' ? `
|
|
<a href="order.html" class="block mt-4 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg transition-colors text-center">
|
|
Request This Operator
|
|
</a>
|
|
` : `
|
|
<div class="mt-4 bg-slate-100 text-slate-500 font-medium py-3 rounded-lg text-center text-sm">
|
|
Currently unavailable — check back later
|
|
</div>
|
|
`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const detailEl = document.getElementById('operator-detail');
|
|
detailEl.classList.remove('hidden');
|
|
detailEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
function filterOperators() {
|
|
const search = document.getElementById('op-search').value.toLowerCase();
|
|
const statusFilter = document.getElementById('op-status').value;
|
|
const certFilter = document.getElementById('op-cert').value;
|
|
|
|
let filtered = MOCK_ESCORT_OPERATORS.filter(op => {
|
|
if (statusFilter !== 'all' && op.status !== statusFilter) return false;
|
|
if (certFilter !== 'all' && !op.certifications.includes(certFilter)) return false;
|
|
if (search) {
|
|
const searchText = `${op.name} ${op.location.city} ${op.location.state} ${op.certifications.join(' ')} ${op.bio}`.toLowerCase();
|
|
if (!searchText.includes(search)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
renderOperatorList(filtered);
|
|
addOperatorMarkers(filtered);
|
|
}
|
|
|
|
// Initial render
|
|
filterOperators();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|