- 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>
303 lines
14 KiB
HTML
303 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Oversize Load Board | PilotEdge</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
</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 flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||
<div>
|
||
<h1 class="text-3xl md:text-4xl font-bold mb-3">Oversize Load Board</h1>
|
||
<p class="text-lg text-gray-400">Active loads needing escort/pilot vehicle services. Carriers post free — escort operators browse and bid.</p>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button onclick="showPostModal()" class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-5 py-2.5 rounded-lg transition-colors whitespace-nowrap">
|
||
+ Post a Load
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Filters -->
|
||
<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 items-end">
|
||
<div class="flex-1">
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Search</label>
|
||
<input type="text" id="search-input" oninput="filterLoads()" placeholder="Search by location, carrier, or description..." 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="status-filter" onchange="filterLoads()" 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="posted" selected>Posted (Available)</option>
|
||
<option value="in_transit">In Transit</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Escorts Needed</label>
|
||
<select id="escorts-filter" onchange="filterLoads()" 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</option>
|
||
<option value="1">1 Escort</option>
|
||
<option value="2">2 Escorts</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Sort</label>
|
||
<select id="sort-select" onchange="filterLoads()" 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="date_asc">Departure (Soonest)</option>
|
||
<option value="date_desc">Departure (Latest)</option>
|
||
<option value="posted_desc">Recently Posted</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Results Count -->
|
||
<section class="max-w-7xl mx-auto px-4 pt-4">
|
||
<p id="results-count" class="text-sm text-slate-500 font-medium"></p>
|
||
</section>
|
||
|
||
<!-- Load Listings -->
|
||
<section class="max-w-7xl mx-auto px-4 py-4 pb-8">
|
||
<div id="load-list" class="space-y-4">
|
||
<!-- Populated by JS -->
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Subscription CTA -->
|
||
<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-center text-white">
|
||
<h3 class="text-xl font-bold mb-2">Escort Vehicle Operator?</h3>
|
||
<p class="text-gray-400 mb-4 max-w-lg mx-auto">Get unlimited load board access, instant notifications for new loads in your area, and priority bidding with a PilotEdge subscription.</p>
|
||
<button class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-6 py-3 rounded-lg transition-colors">
|
||
Subscribe — Starting at $49/mo
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Post Load Modal (simplified for POC) -->
|
||
<div id="post-modal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4">
|
||
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto p-8">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h2 class="text-xl font-bold text-slate-900">Post a Load</h2>
|
||
<button onclick="closePostModal()" class="text-slate-400 hover:text-slate-600 text-2xl">×</button>
|
||
</div>
|
||
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
|
||
<p class="text-amber-900 text-sm"><strong>POC Note:</strong> In production, this form would create a real listing. For now, this demonstrates the posting flow.</p>
|
||
</div>
|
||
<form onsubmit="handlePostLoad(event)" class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Carrier / Company Name</label>
|
||
<input type="text" required 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 class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Origin</label>
|
||
<input type="text" required placeholder="City, State" 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">Destination</label>
|
||
<input type="text" required placeholder="City, State" 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>
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Departure Date</label>
|
||
<input type="date" required 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">Load Description</label>
|
||
<textarea required rows="2" placeholder="What's being hauled?" 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"></textarea>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1">Dimensions (W×H×L)</label>
|
||
<input type="text" placeholder="16'×14'×135'" 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">Escorts Needed</label>
|
||
<select 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">
|
||
<option>1</option>
|
||
<option>2</option>
|
||
<option>3+</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg transition-colors">
|
||
Post Load
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="main-footer"></div>
|
||
|
||
<script src="api.js"></script>
|
||
<script src="nav.js"></script>
|
||
<script>
|
||
renderNav('loadboard');
|
||
renderBanner();
|
||
renderFooter();
|
||
|
||
(async () => {
|
||
const MOCK_LOAD_BOARD = await PilotEdge.getLoads();
|
||
|
||
function getStatusBadge(status) {
|
||
if (status === 'posted') return '<span class="bg-green-100 text-green-800 text-xs font-bold px-3 py-1 rounded-full">AVAILABLE</span>';
|
||
if (status === 'in_transit') return '<span class="bg-blue-100 text-blue-800 text-xs font-bold px-3 py-1 rounded-full">IN TRANSIT</span>';
|
||
return '<span class="bg-slate-100 text-slate-600 text-xs font-bold px-3 py-1 rounded-full">' + status.toUpperCase() + '</span>';
|
||
}
|
||
|
||
function daysUntil(dateStr) {
|
||
const today = new Date();
|
||
today.setHours(0,0,0,0);
|
||
const target = new Date(dateStr);
|
||
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||
if (diff < 0) return 'Departed';
|
||
if (diff === 0) return 'Today';
|
||
if (diff === 1) return 'Tomorrow';
|
||
return `In ${diff} days`;
|
||
}
|
||
|
||
function renderLoads(loads) {
|
||
const container = document.getElementById('load-list');
|
||
document.getElementById('results-count').textContent = `${loads.length} load${loads.length !== 1 ? 's' : ''} found`;
|
||
|
||
if (loads.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="bg-white rounded-2xl shadow-lg p-12 text-center">
|
||
<p class="text-slate-500 text-lg">No loads match your filters.</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = loads.map(load => `
|
||
<div class="bg-white rounded-2xl shadow-lg p-6 hover:shadow-xl transition-shadow">
|
||
<div class="flex flex-col lg:flex-row lg:items-start justify-between gap-4">
|
||
<!-- Route & Info -->
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-3 mb-3">
|
||
${getStatusBadge(load.status)}
|
||
<span class="text-xs text-slate-400 font-medium">${load.id}</span>
|
||
</div>
|
||
|
||
<!-- Route -->
|
||
<div class="flex items-center gap-3 mb-3">
|
||
<div class="text-center">
|
||
<p class="font-bold text-slate-900 text-lg">${load.origin.city}, ${load.origin.state}</p>
|
||
</div>
|
||
<div class="flex-shrink-0 text-amber-500 text-2xl px-2">→</div>
|
||
<div class="text-center">
|
||
<p class="font-bold text-slate-900 text-lg">${load.destination.city}, ${load.destination.state}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<p class="text-slate-600 mb-3">${load.description}</p>
|
||
|
||
<!-- Carrier -->
|
||
<p class="text-sm text-slate-500">Posted by <span class="font-semibold text-slate-700">${load.carrier}</span></p>
|
||
</div>
|
||
|
||
<!-- Details Sidebar -->
|
||
<div class="lg:w-72 flex-shrink-0 bg-slate-50 rounded-xl p-4 space-y-3">
|
||
<div class="flex justify-between">
|
||
<span class="text-sm text-slate-500">Departure</span>
|
||
<span class="text-sm font-bold text-slate-900">${new Date(load.departureDate).toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})} <span class="text-amber-600">(${daysUntil(load.departureDate)})</span></span>
|
||
</div>
|
||
<div class="border-t border-slate-200"></div>
|
||
<div class="flex justify-between">
|
||
<span class="text-sm text-slate-500">Width</span>
|
||
<span class="text-sm font-bold text-slate-900">${load.dimensions.width}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-sm text-slate-500">Height</span>
|
||
<span class="text-sm font-bold text-slate-900">${load.dimensions.height}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-sm text-slate-500">Length</span>
|
||
<span class="text-sm font-bold text-slate-900">${load.dimensions.length}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-sm text-slate-500">Weight</span>
|
||
<span class="text-sm font-bold text-slate-900">${load.dimensions.weight}</span>
|
||
</div>
|
||
<div class="border-t border-slate-200"></div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-sm text-slate-500">Escorts Needed</span>
|
||
<span class="bg-amber-100 text-amber-800 text-xs font-bold px-3 py-1 rounded-full">${load.escortsNeeded} vehicle${load.escortsNeeded > 1 ? 's' : ''}</span>
|
||
</div>
|
||
${load.status === 'posted' ? `
|
||
<button class="w-full mt-2 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-2 rounded-lg transition-colors text-sm">
|
||
Contact Carrier
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function filterLoads() {
|
||
const search = document.getElementById('search-input').value.toLowerCase();
|
||
const statusFilter = document.getElementById('status-filter').value;
|
||
const escortsFilter = document.getElementById('escorts-filter').value;
|
||
const sortBy = document.getElementById('sort-select').value;
|
||
|
||
let filtered = MOCK_LOAD_BOARD.filter(load => {
|
||
if (statusFilter !== 'all' && load.status !== statusFilter) return false;
|
||
if (escortsFilter !== 'all' && load.escortsNeeded !== parseInt(escortsFilter)) return false;
|
||
if (search) {
|
||
const searchText = `${load.origin.city} ${load.origin.state} ${load.destination.city} ${load.destination.state} ${load.carrier} ${load.description}`.toLowerCase();
|
||
if (!searchText.includes(search)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// Sort
|
||
filtered.sort((a, b) => {
|
||
if (sortBy === 'date_asc') return new Date(a.departureDate) - new Date(b.departureDate);
|
||
if (sortBy === 'date_desc') return new Date(b.departureDate) - new Date(a.departureDate);
|
||
if (sortBy === 'posted_desc') return new Date(b.postedDate) - new Date(a.postedDate);
|
||
return 0;
|
||
});
|
||
|
||
renderLoads(filtered);
|
||
}
|
||
|
||
// Initial render
|
||
filterLoads();
|
||
|
||
function showPostModal() {
|
||
document.getElementById('post-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function closePostModal() {
|
||
document.getElementById('post-modal').classList.add('hidden');
|
||
}
|
||
|
||
function handlePostLoad(e) {
|
||
e.preventDefault();
|
||
closePostModal();
|
||
alert('Load posted! (POC demo — in production, this would create a real listing.)');
|
||
}
|
||
|
||
// Close modal on backdrop click
|
||
document.getElementById('post-modal').addEventListener('click', function(e) {
|
||
if (e.target === this) closePostModal();
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|