- 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>
245 lines
11 KiB
HTML
245 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Truck Stops & Parking | 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>
|
|
#map { height: 450px; width: 100%; border-radius: 0.75rem; }
|
|
</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">Truck Stops & Parking for Oversize Loads</h1>
|
|
<p class="text-lg text-gray-400 max-w-3xl">Find oversize-friendly truck stops, rest areas, and staging locations across the US. Community-verified with driver comments and entrance dimensions.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Filter Bar -->
|
|
<section class="max-w-7xl mx-auto px-4 pt-8 w-full">
|
|
<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 Truck Stops</label>
|
|
<input type="text" id="ts-search" oninput="filterStops()" placeholder="e.g. Iowa 80, Amarillo, TX..." 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="flex items-center gap-2 pb-1">
|
|
<input type="checkbox" id="ts-oversize-only" onchange="filterStops()" class="w-4 h-4 text-amber-500 border-slate-300 rounded focus:ring-amber-400">
|
|
<label for="ts-oversize-only" class="text-sm font-semibold text-slate-700 whitespace-nowrap">Oversize Friendly Only</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Map -->
|
|
<section class="max-w-7xl mx-auto px-4 pt-8 w-full">
|
|
<div class="bg-white rounded-2xl shadow-lg p-4">
|
|
<div id="map"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Truck Stop Cards -->
|
|
<section class="max-w-7xl mx-auto px-4 pt-8 pb-8 w-full">
|
|
<div id="ts-list" class="space-y-6"></div>
|
|
</section>
|
|
|
|
<!-- Submit a Location -->
|
|
<section class="max-w-7xl mx-auto px-4 pb-12 w-full">
|
|
<div class="bg-amber-50 border-2 border-amber-200 rounded-2xl p-8 text-center">
|
|
<h2 class="text-2xl font-bold text-slate-900 mb-2">Know an Oversize-Friendly Location?</h2>
|
|
<p class="text-slate-600 mb-4">Help the community by suggesting truck stops, rest areas, or staging lots that accommodate oversize loads.</p>
|
|
<button onclick="alert('POC: This would open a submission form for new truck stop locations. Feature coming in production release.')" class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-6 py-3 rounded-lg transition-colors shadow-md hover:shadow-lg">
|
|
📍 Submit a Location
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="main-footer"></div>
|
|
|
|
<script src="api.js"></script>
|
|
<script src="nav.js"></script>
|
|
<script>
|
|
renderNav('truckstops');
|
|
renderBanner();
|
|
renderFooter();
|
|
|
|
(async () => {
|
|
const MOCK_TRUCK_STOPS = await PilotEdge.getTruckStops();
|
|
|
|
// Facility emoji mapping
|
|
const facilityIcons = {
|
|
fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿',
|
|
mechanic: '🔧', scale: '⚖️', ev_charging: '🔌', hotel: '🏨',
|
|
trucking_museum: '🏛️', barber: '💈', chiropractor: '🦴', iron_skillet: '🍳'
|
|
};
|
|
|
|
let map, markers = [];
|
|
|
|
function initMap() {
|
|
map = L.map('map').setView([39.5, -98.5], 4);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 18
|
|
}).addTo(map);
|
|
addMarkers(MOCK_TRUCK_STOPS);
|
|
}
|
|
|
|
function addMarkers(stops) {
|
|
markers.forEach(m => map.removeLayer(m));
|
|
markers = [];
|
|
stops.forEach(stop => {
|
|
const color = stop.oversizeFriendly ? '#22c55e' : '#ef4444';
|
|
const marker = L.circleMarker([stop.location.lat, stop.location.lng], {
|
|
radius: 9, fillColor: color, color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.85
|
|
}).addTo(map);
|
|
marker.bindPopup(`
|
|
<div style="min-width:200px">
|
|
<strong style="font-size:14px">${stop.name}</strong><br>
|
|
<span style="color:#64748b">${stop.location.city}, ${stop.location.state}</span><br>
|
|
<span style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:600;color:#fff;background:${stop.oversizeFriendly ? '#22c55e' : '#ef4444'}">
|
|
${stop.oversizeFriendly ? 'OVERSIZE FRIENDLY' : 'NOT RECOMMENDED'}
|
|
</span><br>
|
|
<button onclick="scrollToCard('${stop.id}')" style="margin-top:8px;background:#f59e0b;color:#0f172a;border:none;padding:6px 14px;border-radius:8px;font-weight:700;font-size:12px;cursor:pointer">View Details</button>
|
|
</div>
|
|
`);
|
|
markers.push(marker);
|
|
});
|
|
}
|
|
|
|
function scrollToCard(id) {
|
|
const el = document.getElementById('card-' + id);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
el.classList.add('ring-2', 'ring-amber-400');
|
|
setTimeout(() => el.classList.remove('ring-2', 'ring-amber-400'), 2000);
|
|
}
|
|
}
|
|
|
|
function renderStops(stops) {
|
|
const list = document.getElementById('ts-list');
|
|
if (!stops.length) {
|
|
list.innerHTML = '<div class="text-center py-12 text-slate-500">No truck stops match your filters.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = stops.map(stop => `
|
|
<div id="card-${stop.id}" class="bg-white rounded-2xl shadow-lg p-6 transition-all duration-300">
|
|
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-4">
|
|
<div>
|
|
<h3 class="text-xl font-bold text-slate-900">${stop.name}</h3>
|
|
<p class="text-slate-500">${stop.location.city}, ${stop.location.state}</p>
|
|
</div>
|
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap ${stop.oversizeFriendly ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
|
|
${stop.oversizeFriendly ? '✅ OVERSIZE FRIENDLY' : '⛔ NOT RECOMMENDED FOR OVERSIZE'}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Specs Grid -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
|
<div class="text-xs text-slate-500 font-semibold mb-1">Entrance Width</div>
|
|
<div class="text-sm font-bold text-slate-800">${stop.entranceWidth}</div>
|
|
</div>
|
|
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
|
<div class="text-xs text-slate-500 font-semibold mb-1">Entrance Height</div>
|
|
<div class="text-sm font-bold text-slate-800">${stop.entranceHeight}</div>
|
|
</div>
|
|
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
|
<div class="text-xs text-slate-500 font-semibold mb-1">Lot Size</div>
|
|
<div class="text-sm font-bold text-slate-800">${stop.lotSize}</div>
|
|
</div>
|
|
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
|
<div class="text-xs text-slate-500 font-semibold mb-1">Oversize Capacity</div>
|
|
<div class="text-sm font-bold text-slate-800">${stop.oversizeCapacity}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Facilities -->
|
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
${stop.facilities.map(f => `
|
|
<span class="bg-slate-100 rounded-full px-3 py-1 text-xs font-medium text-slate-700">
|
|
${facilityIcons[f] || '📌'} ${f.replace(/_/g, ' ')}
|
|
</span>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<p class="text-sm text-slate-600 mb-5">${stop.description}</p>
|
|
|
|
<!-- Comments Section -->
|
|
<div class="border-t border-slate-200 pt-4">
|
|
<h4 class="text-sm font-bold text-slate-900 mb-3">💬 Driver Comments (${stop.comments.length})</h4>
|
|
<div class="space-y-3 mb-4">
|
|
${stop.comments.map(c => `
|
|
<div class="bg-slate-50 rounded-xl p-3">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="text-xs font-bold text-slate-700">${c.user}</span>
|
|
<span class="text-xs text-slate-400">${c.date}</span>
|
|
</div>
|
|
<p class="text-sm text-slate-600">${c.text}</p>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
<div id="comment-form-${stop.id}" class="hidden">
|
|
<textarea id="comment-text-${stop.id}" rows="3" placeholder="Share your experience at this location..." class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none mb-2"></textarea>
|
|
<div class="flex gap-2">
|
|
<button onclick="submitComment('${stop.id}')" class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-4 py-2 rounded-lg text-sm transition-colors">Submit Comment</button>
|
|
<button onclick="toggleCommentForm('${stop.id}')" class="bg-slate-200 hover:bg-slate-300 text-slate-700 font-bold px-4 py-2 rounded-lg text-sm transition-colors">Cancel</button>
|
|
</div>
|
|
</div>
|
|
<button id="comment-btn-${stop.id}" onclick="toggleCommentForm('${stop.id}')" class="text-sm font-semibold text-amber-600 hover:text-amber-700 transition-colors">
|
|
+ Add Comment
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function toggleCommentForm(id) {
|
|
const form = document.getElementById('comment-form-' + id);
|
|
const btn = document.getElementById('comment-btn-' + id);
|
|
const visible = !form.classList.contains('hidden');
|
|
form.classList.toggle('hidden');
|
|
btn.classList.toggle('hidden');
|
|
}
|
|
|
|
function submitComment(id) {
|
|
const text = document.getElementById('comment-text-' + id).value.trim();
|
|
if (!text) { alert('Please enter a comment.'); return; }
|
|
alert('POC: Comment submitted! In production, this would save to the database.\n\nYour comment: "' + text + '"');
|
|
document.getElementById('comment-text-' + id).value = '';
|
|
toggleCommentForm(id);
|
|
}
|
|
|
|
function filterStops() {
|
|
const query = document.getElementById('ts-search').value.toLowerCase();
|
|
const oversizeOnly = document.getElementById('ts-oversize-only').checked;
|
|
const filtered = MOCK_TRUCK_STOPS.filter(stop => {
|
|
const matchSearch = !query ||
|
|
stop.name.toLowerCase().includes(query) ||
|
|
stop.location.city.toLowerCase().includes(query) ||
|
|
stop.location.state.toLowerCase().includes(query);
|
|
const matchOversize = !oversizeOnly || stop.oversizeFriendly;
|
|
return matchSearch && matchOversize;
|
|
});
|
|
renderStops(filtered);
|
|
addMarkers(filtered);
|
|
}
|
|
|
|
// Initialize
|
|
initMap();
|
|
renderStops(MOCK_TRUCK_STOPS);
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|