Compare commits

..

2 Commits

Author SHA1 Message Date
Daniel Kovalevich
93efb907ff Reorganize frontend into public/ with pages/ and js/ subdirectories
- public/index.html — landing page at root
- public/pages/ — all feature pages (regulations, loadboard, etc.)
- public/js/ — api.js, nav.js, mock data files
- All links updated to absolute paths (/pages/, /js/)
- Express static path updated to serve from public/
- Seed script path updated for new mock data location
- README updated with new project structure and setup guide
- Added .env.example template

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 15:52:56 -04:00
Daniel Kovalevich
f917fb8014 Add Node.js/Express backend with PostgreSQL and wire frontend to API
- 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>
2026-03-30 15:43:27 -04:00
44 changed files with 5418 additions and 475 deletions

227
README.md
View File

@@ -5,46 +5,211 @@ A proof-of-concept web application for truck drivers, carriers, and escort/pilot
> ⚠️ **All regulation data shown is SIMULATED for demonstration purposes and must be verified with actual state DOT regulations before any real-world use.**
## How to View
1. Open `index.html` in any modern web browser (Chrome, Edge, Firefox)
2. An internet connection is required (for map tiles and the CSS library)
3. Navigate between pages using the top navigation bar
## Prerequisites
- [Node.js](https://nodejs.org/) v18+ (LTS recommended)
- [PostgreSQL](https://www.postgresql.org/) 14+
- npm (comes with Node.js)
## Getting Started
### 1. Clone the repo
```bash
git clone <repo-url>
cd pilot-poc
```
### 2. Set up PostgreSQL
Create a database called `pilotedge`:
```bash
psql -U postgres -c "CREATE DATABASE pilotedge;"
```
### 3. Configure environment
Copy the example env file and update if needed:
```bash
cd server
cp .env.example .env
```
The default `.env` expects:
```
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pilotedge?schema=public"
JWT_SECRET="change-this-to-a-long-random-string"
PORT=3000
```
Update the `DATABASE_URL` if your PostgreSQL password or port is different.
### 4. Install dependencies
```bash
cd server
npm install
```
### 5. Run database migrations
```bash
npm run db:migrate
```
This creates all 15 tables in your PostgreSQL database via Prisma.
### 6. Seed the database
```bash
npm run db:seed
```
Populates the database with demonstration data (51 states with regulations, contacts, equipment requirements, truck stops, bridges, weigh stations, alerts, and seasonal restrictions).
### 7. Start the server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
## Available Scripts
Run these from the `server/` directory:
| Command | Description |
|---------|-------------|
| `npm run dev` | Start server with auto-reload on file changes |
| `npm start` | Start server (production) |
| `npm run db:migrate` | Apply database migrations |
| `npm run db:seed` | Seed database with demo data |
| `npm run db:reset` | Drop all data and re-run migrations |
| `npm run db:studio` | Open Prisma Studio (visual database browser) |
## Pages
### Core
| Page | File | Description |
|------|------|-------------|
| **Home** | `index.html` | Platform overview and all feature modules |
| **Regulations Map** | `regulations.html` | Interactive US map + equipment requirements by state |
| **Request Service** | `order.html` | Escort vehicle service request form |
| Page | URL | Description |
|------|-----|-------------|
| **Home** | `/` | Platform overview and all feature modules |
| **Regulations Map** | `/pages/regulations.html` | Interactive US map + equipment requirements by state |
| **Request Service** | `/pages/order.html` | Escort vehicle service request form |
### Road Intelligence
| Page | File | Description |
|------|------|-------------|
| **Truck Stops** | `truckstops.html` | Oversize-friendly parking with user comments |
| **Bridge Clearances** | `bridges.html` | Height/width/weight restrictions for bridges |
| **Weigh Stations** | `weighstations.html` | Crowd-sourced open/closed status |
| **Route & Weather** | `alerts.html` | Construction, closures, and wind/weather alerts |
| Page | URL | Description |
|------|-----|-------------|
| **Truck Stops** | `/pages/truckstops.html` | Oversize-friendly parking with user comments |
| **Bridge Clearances** | `/pages/bridges.html` | Height/width/weight restrictions for bridges |
| **Weigh Stations** | `/pages/weighstations.html` | Crowd-sourced open/closed status |
| **Route & Weather** | `/pages/alerts.html` | Construction, closures, and wind/weather alerts |
### Services & Resources
| Page | File | Description |
|------|------|-------------|
| **Load Board** | `loadboard.html` | Oversize load listings needing escort services |
| **Find Escorts** | `locator.html` | Map of available escort vehicle operators |
| **DOT Contacts** | `contacts.html` | State permit office phone/email directory |
| **Seasonal Calendar** | `calendar.html` | Seasonal restrictions and closure calendar |
| **Document Vault** | `documents.html` | Store permits, insurance, certifications |
| Page | URL | Description |
|------|-----|-------------|
| **Load Board** | `/pages/loadboard.html` | Oversize load listings needing escort services |
| **Find Escorts** | `/pages/locator.html` | Map of available escort vehicle operators |
| **DOT Contacts** | `/pages/contacts.html` | State permit office phone/email directory |
| **Seasonal Calendar** | `/pages/calendar.html` | Seasonal restrictions and closure calendar |
| **Document Vault** | `/pages/documents.html` | Store permits, insurance, certifications |
## Tech Stack (POC)
- HTML / CSS / JavaScript (no build step required)
- [Tailwind CSS](https://tailwindcss.com/) via Play CDN
- [Leaflet.js](https://leafletjs.com/) for interactive maps via CDN
- Mock data (hardcoded — not yet connected to APIs or databases)
## API Endpoints
All endpoints are prefixed with `/api`. Public endpoints require no authentication; protected endpoints require a `Bearer` token in the `Authorization` header.
### Public
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check |
| GET | `/api/regulations` | All states with regulations |
| GET | `/api/regulations/:stateAbbr` | Single state with equipment requirements |
| GET | `/api/contacts` | All state DOT contacts |
| GET | `/api/calendar` | All seasonal restrictions |
| GET | `/api/truckstops?lat=&lng=&radius=` | Truck stops with optional geospatial filter |
| GET | `/api/bridges?maxHeight=&maxWidth=` | Bridges with optional conflict detection |
| GET | `/api/weighstations` | Weigh stations with status |
| GET | `/api/alerts?state=&type=` | Active route and weather alerts |
| GET | `/api/loads` | Load board listings |
| GET | `/api/escorts` | Escort operator profiles |
### Auth
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/auth/register` | Create account (email, password, name, role) |
| POST | `/api/auth/login` | Login and receive JWT token |
| GET | `/api/auth/me` | Get current user profile (protected) |
### Protected (requires auth)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/loads` | Post a new load |
| PUT | `/api/loads/:id` | Update own load |
| DELETE | `/api/loads/:id` | Delete own load |
| POST | `/api/escorts/profile` | Create/update escort profile |
| POST | `/api/orders` | Submit escort service request |
| GET | `/api/orders` | List own orders |
| POST/GET/DELETE | `/api/documents` | Upload, list, download, delete documents |
| POST | `/api/contributions` | Submit user contributions |
## Tech Stack
- **Frontend:** HTML, CSS, JavaScript (no build step)
- **CSS:** [Tailwind CSS](https://tailwindcss.com/) via Play CDN
- **Maps:** [Leaflet.js](https://leafletjs.com/) via CDN
- **Server:** [Express.js](https://expressjs.com/) (Node.js)
- **Database:** [PostgreSQL](https://www.postgresql.org/) 16
- **ORM:** [Prisma](https://www.prisma.io/) with auto-migrations
- **Auth:** JWT + bcrypt
## Project Structure
```
pilot-poc/
├── public/ # Frontend (served by Express)
│ ├── index.html # Landing page
│ ├── pages/
│ │ ├── regulations.html # State regulations map
│ │ ├── order.html # Escort service request form
│ │ ├── loadboard.html # Oversize load board
│ │ ├── locator.html # Escort operator locator
│ │ ├── truckstops.html # Oversize-friendly truck stops
│ │ ├── bridges.html # Bridge clearance database
│ │ ├── weighstations.html # Weigh station status
│ │ ├── alerts.html # Route & weather alerts
│ │ ├── contacts.html # State DOT contact directory
│ │ ├── calendar.html # Seasonal restriction calendar
│ │ └── documents.html # Document vault
│ └── js/
│ ├── api.js # Frontend API client
│ ├── nav.js # Shared navigation component
│ ├── mock-data.js # Original mock data (preserved)
│ └── mock-data-extended.js
├── server/
│ ├── package.json
│ ├── .env # Environment config (not in git)
│ ├── .env.example # Template for .env
│ ├── prisma/
│ │ └── schema.prisma # Database schema (15 models)
│ └── src/
│ ├── index.js # Express app entry point
│ ├── config/db.js # Prisma client
│ ├── middleware/
│ │ ├── auth.js # JWT verification
│ │ └── errorHandler.js
│ ├── routes/ # 13 API route files
│ └── seeds/seed.js # Database seeder
├── PLAN.md # Product plan and module breakdown
└── README.md
## Next Steps
- Replace mock data with real, verified state regulation data
- Connect to backend APIs and a database
- Add user accounts, authentication, and profiles
- Replace demo data with real, verified state regulation data
- Integrate payment processing for subscriptions
- Deploy to a web hosting service (Vercel, Netlify, or similar)
- Add real-time weigh station status updates (WebSockets)
- File storage service (S3) for document vault
- Email notifications for regulatory change alerts
- Deploy to a Cloud VPS (DigitalOcean, Linode, etc.)

View File

@@ -1,108 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>State DOT Contacts | 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">
<h1 class="text-3xl md:text-4xl font-bold mb-3">State DOT Contact Directory</h1>
<p class="text-lg text-gray-400 max-w-3xl">Find permit office phone numbers, state police non-emergency lines, and online portal links for every state.</p>
</div>
</section>
<!-- Search / Filter Bar -->
<section class="max-w-7xl mx-auto px-4 py-8 w-full">
<div class="bg-white rounded-2xl shadow-lg p-6">
<label for="state-search" class="block text-sm font-semibold text-slate-700 mb-2">Search States</label>
<input
type="text"
id="state-search"
placeholder="Type a state name to filter…"
class="w-full border border-slate-300 rounded-lg px-4 py-3 focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none text-slate-900"
>
</div>
</section>
<!-- Contact Cards Grid -->
<section class="max-w-7xl mx-auto px-4 pb-12 w-full">
<div id="contacts-grid" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Populated by JS -->
</div>
<p id="no-results" class="hidden text-center text-slate-500 py-12 text-lg">No states match your search.</p>
</section>
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script>
renderNav('contacts');
renderBanner();
renderFooter();
const grid = document.getElementById('contacts-grid');
const noResults = document.getElementById('no-results');
const searchInput = document.getElementById('state-search');
function buildCard(abbr, c) {
return `
<div class="bg-white rounded-2xl shadow-lg p-6 flex flex-col" data-state="${c.name.toLowerCase()}">
<h3 class="text-lg font-bold text-slate-900 mb-1">${c.name} <span class="text-slate-400 font-medium text-sm">(${abbr})</span></h3>
<div class="mt-3 space-y-2 text-sm text-slate-700 flex-1">
<p class="flex items-start gap-2">
<span class="shrink-0">📞</span>
<span><span class="font-medium">Permit Office:</span> <a href="tel:${c.permit.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.permit}</a></span>
</p>
<p class="flex items-start gap-2">
<span class="shrink-0">🚔</span>
<span><span class="font-medium">State Police:</span> <a href="tel:${c.police.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.police}</a></span>
</p>
<p class="flex items-start gap-2">
<span class="shrink-0">✉️</span>
<span><span class="font-medium">Email:</span> <a href="mailto:${c.email}" class="text-amber-600 hover:text-amber-700 font-semibold break-all">${c.email}</a></span>
</p>
<p class="flex items-start gap-2">
<span class="shrink-0">🕐</span>
<span><span class="font-medium">Hours:</span> ${c.hours}</span>
</p>
</div>
<a href="${c.portal}" target="_blank" rel="noopener noreferrer"
class="mt-4 inline-flex items-center justify-center gap-1 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold text-sm px-4 py-2 rounded-lg transition-colors shadow-md">
Visit Permit Portal <span class="text-xs">↗</span>
</a>
</div>`;
}
function renderCards(filter) {
const term = (filter || '').toLowerCase();
let html = '';
let count = 0;
Object.keys(MOCK_STATE_CONTACTS).forEach(abbr => {
const c = MOCK_STATE_CONTACTS[abbr];
if (!term || c.name.toLowerCase().includes(term) || abbr.toLowerCase().includes(term)) {
html += buildCard(abbr, c);
count++;
}
});
grid.innerHTML = html;
noResults.classList.toggle('hidden', count > 0);
}
searchInput.addEventListener('input', () => renderCards(searchInput.value));
renderCards();
</script>
</body>
</html>

View File

@@ -1,259 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Vault | 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">
<h1 class="text-3xl md:text-4xl font-bold mb-3">Document Vault</h1>
<p class="text-lg text-gray-400 max-w-3xl">Securely store and manage your permits, insurance certificates, and professional certifications — all in one place.</p>
</div>
</section>
<!-- Main Content -->
<main class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10">
<!-- Stats Bar -->
<div id="stats-bar" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"></div>
<!-- Expiry Warnings -->
<div id="expiry-warnings" class="mb-8"></div>
<!-- Filter Bar -->
<div class="bg-white rounded-2xl shadow-lg p-4 sm:p-6 mb-8">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input id="search-input" type="text" placeholder="Search documents…"
class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition-colors">
</div>
<select id="type-filter"
class="border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition-colors">
<option value="all">All Types</option>
<option value="permit">Permit</option>
<option value="insurance">Insurance</option>
<option value="certification">Certification</option>
<option value="registration">Registration</option>
</select>
<select id="status-filter"
class="border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition-colors">
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="expired">Expired</option>
</select>
<button onclick="openUploadModal()"
class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-5 py-2.5 rounded-lg transition-colors shadow-md hover:shadow-lg text-sm whitespace-nowrap">
+ Upload Document
</button>
</div>
</div>
<!-- Document List -->
<div id="document-list" class="grid gap-4"></div>
<!-- Empty State -->
<div id="empty-state" class="hidden text-center py-16">
<p class="text-5xl mb-4">📄</p>
<p class="text-slate-500 text-lg">No documents match your filters.</p>
</div>
</main>
<!-- Upload Modal -->
<div id="upload-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 relative">
<button onclick="closeUploadModal()" class="absolute top-4 right-4 text-slate-400 hover:text-slate-600 text-xl leading-none">&times;</button>
<h2 class="text-xl font-bold text-slate-900 mb-1">Upload Document</h2>
<p class="text-sm text-amber-600 mb-5">⚠️ POC demo — file upload not functional.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Document Type</label>
<select id="upload-type"
class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none">
<option value="permit">Permit</option>
<option value="insurance">Insurance</option>
<option value="certification">Certification</option>
<option value="registration">Registration</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Document Name</label>
<input id="upload-name" type="text" placeholder="e.g. TX Single Trip Permit"
class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Choose File</label>
<div class="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center text-slate-400 text-sm">
Drag &amp; drop or click to browse<br>
<span class="text-xs">(disabled in POC)</span>
</div>
</div>
<button onclick="alert('POC demo — upload not functional.'); closeUploadModal();"
class="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-2.5 rounded-lg transition-colors shadow-md text-sm">
Upload
</button>
</div>
</div>
</div>
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script>
renderNav('documents');
renderBanner();
renderFooter();
// ── Helpers ──────────────────────────────────────────
const today = new Date();
const MS_PER_DAY = 86400000;
function daysUntil(dateStr) {
return Math.ceil((new Date(dateStr) - today) / MS_PER_DAY);
}
function fmtDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
}
const typeBadge = {
permit: 'bg-blue-100 text-blue-700',
insurance: 'bg-green-100 text-green-700',
certification: 'bg-purple-100 text-purple-700',
registration: 'bg-slate-200 text-slate-700'
};
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
// ── Stats ───────────────────────────────────────────
function renderStats(docs) {
const total = docs.length;
const active = docs.filter(d => d.status === 'active').length;
const expired = docs.filter(d => d.status === 'expired').length;
const expiring = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0).length;
const items = [
{ label:'Total Documents', value:total, color:'bg-slate-900 text-white' },
{ label:'Active Permits', value:active, color:'bg-green-600 text-white' },
{ label:'Expiring Soon', value:expiring, color:'bg-amber-500 text-white' },
{ label:'Expired', value:expired, color:'bg-red-600 text-white' }
];
document.getElementById('stats-bar').innerHTML = items.map(s => `
<div class="${s.color} rounded-2xl shadow-lg p-5 text-center">
<div class="text-3xl font-bold">${s.value}</div>
<div class="text-sm mt-1 opacity-90">${s.label}</div>
</div>`).join('');
}
// ── Expiry Warnings ─────────────────────────────────
function renderExpiryWarnings(docs) {
const soon = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0);
const el = document.getElementById('expiry-warnings');
if (!soon.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<div class="bg-amber-50 border border-amber-300 rounded-2xl p-5">
<h3 class="font-bold text-amber-800 text-lg mb-3">⚠️ Expiring Within 30 Days</h3>
<div class="space-y-2">
${soon.map(d => `
<div class="flex flex-col sm:flex-row sm:items-center justify-between bg-white rounded-xl px-4 py-3 shadow-sm border border-amber-200">
<div>
<span class="font-semibold text-slate-900">${d.name}</span>
<span class="inline-block ml-2 text-xs font-medium px-2 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
</div>
<span class="text-amber-700 font-medium text-sm mt-1 sm:mt-0">Expires ${fmtDate(d.expiryDate)} (${daysUntil(d.expiryDate)} day${daysUntil(d.expiryDate) === 1 ? '' : 's'})</span>
</div>`).join('')}
</div>
</div>`;
}
// ── Document Cards ──────────────────────────────────
function renderDocuments(docs) {
const list = document.getElementById('document-list');
const empty = document.getElementById('empty-state');
if (!docs.length) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
list.innerHTML = docs.map(d => {
const statusBadge = d.status === 'active'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700';
return `
<div class="bg-white rounded-2xl shadow-lg p-5 sm:p-6 hover:shadow-xl transition-shadow border border-slate-100">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<h3 class="font-bold text-slate-900 text-base truncate">${d.name}</h3>
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${statusBadge}">${capitalize(d.status)}</span>
</div>
<div class="flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
${d.state ? `<span>📍 ${d.state}</span>` : ''}
<span>📤 Uploaded ${fmtDate(d.uploadDate)}</span>
<span>📅 Expires ${fmtDate(d.expiryDate)}</span>
<span>💾 ${d.fileSize}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button onclick="alert('Viewing: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-slate-300 text-slate-700 hover:bg-slate-50 transition-colors">View</button>
<button onclick="alert('Downloading: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-amber-400 text-amber-700 hover:bg-amber-50 transition-colors">Download</button>
<button onclick="alert('Delete requested for: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Delete</button>
</div>
</div>
</div>`;
}).join('');
}
// ── Filtering ────────────────────────────────────────
function applyFilters() {
const query = document.getElementById('search-input').value.toLowerCase();
const type = document.getElementById('type-filter').value;
const status = document.getElementById('status-filter').value;
const filtered = MOCK_DOCUMENTS.filter(d => {
if (query && !d.name.toLowerCase().includes(query) && !d.id.toLowerCase().includes(query)) return false;
if (type !== 'all' && d.type !== type) return false;
if (status !== 'all' && d.status !== status) return false;
return true;
});
renderDocuments(filtered);
}
document.getElementById('search-input').addEventListener('input', applyFilters);
document.getElementById('type-filter').addEventListener('change', applyFilters);
document.getElementById('status-filter').addEventListener('change', applyFilters);
// ── Upload Modal ─────────────────────────────────────
function openUploadModal() {
const modal = document.getElementById('upload-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeUploadModal() {
const modal = document.getElementById('upload-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
// ── Initial Render ───────────────────────────────────
renderStats(MOCK_DOCUMENTS);
renderExpiryWarnings(MOCK_DOCUMENTS);
renderDocuments(MOCK_DOCUMENTS);
</script>
</body>
</html>

View File

@@ -42,10 +42,10 @@
everything truck drivers and carriers need, all in one place.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="regulations.html" class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-8 py-4 rounded-xl text-lg transition-colors shadow-lg hover:shadow-xl">
<a href="/pages/regulations.html" class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-8 py-4 rounded-xl text-lg transition-colors shadow-lg hover:shadow-xl">
Explore Regulations Map
</a>
<a href="order.html" class="bg-white/10 hover:bg-white/20 text-white font-bold px-8 py-4 rounded-xl text-lg transition-colors border border-white/20">
<a href="/pages/order.html" class="bg-white/10 hover:bg-white/20 text-white font-bold px-8 py-4 rounded-xl text-lg transition-colors border border-white/20">
Request Escort Service
</a>
</div>
@@ -84,19 +84,19 @@
<!-- Row 1: Core Features -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Core Tools</h3>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
<a href="regulations.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/regulations.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center text-xl mb-4">🗺️</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">State Regulations Map</h3>
<p class="text-slate-600 text-sm mb-3">Permit thresholds, escort requirements, and equipment rules for all 50 states.</p>
<span class="text-amber-600 font-semibold text-sm">Explore Map →</span>
</a>
<a href="order.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/order.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center text-xl mb-4">📋</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Request Escort Service</h3>
<p class="text-slate-600 text-sm mb-3">Submit your load details and route — we'll match you with available escort vehicles.</p>
<span class="text-amber-600 font-semibold text-sm">Request Service →</span>
</a>
<a href="loadboard.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/loadboard.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center text-xl mb-4">📦</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Oversize Load Board</h3>
<p class="text-slate-600 text-sm mb-3">Browse and post loads that need escorts. Connect carriers with pilot vehicles.</p>
@@ -107,25 +107,25 @@
<!-- Row 2: Road Intelligence -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Road Intelligence</h3>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
<a href="truckstops.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/truckstops.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-xl mb-4"></div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Truck Stops & Parking</h3>
<p class="text-slate-600 text-sm mb-3">Oversize-friendly locations with entrance dimensions and user reviews.</p>
<span class="text-amber-600 font-semibold text-sm">Find Stops →</span>
</a>
<a href="bridges.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/bridges.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center text-xl mb-4">🌉</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Bridge Clearances</h3>
<p class="text-slate-600 text-sm mb-3">Height, width, and weight restrictions for bridges and overpasses.</p>
<span class="text-amber-600 font-semibold text-sm">Check Clearances →</span>
</a>
<a href="weighstations.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/weighstations.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center text-xl mb-4">⚖️</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Weigh Stations</h3>
<p class="text-slate-600 text-sm mb-3">Live crowd-sourced open/closed status and inspection info.</p>
<span class="text-amber-600 font-semibold text-sm">View Stations →</span>
</a>
<a href="alerts.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/alerts.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center text-xl mb-4">⚠️</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Route & Weather Alerts</h3>
<p class="text-slate-600 text-sm mb-3">Construction, closures, and wind conditions on your route.</p>
@@ -136,25 +136,25 @@
<!-- Row 3: Resources & Services -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Resources & Services</h3>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
<a href="locator.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/locator.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center text-xl mb-4">📍</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Find Escorts</h3>
<p class="text-slate-600 text-sm mb-3">Locate pilot/escort vehicles near your load departure point.</p>
<span class="text-amber-600 font-semibold text-sm">Find Escorts →</span>
</a>
<a href="contacts.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/contacts.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center text-xl mb-4">📞</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">DOT Contacts</h3>
<p class="text-slate-600 text-sm mb-3">Permit office phone numbers and emails for every state.</p>
<span class="text-amber-600 font-semibold text-sm">View Directory →</span>
</a>
<a href="calendar.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/calendar.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center text-xl mb-4">📅</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Seasonal Calendar</h3>
<p class="text-slate-600 text-sm mb-3">Weight restrictions, closures, and blackout periods by state and season.</p>
<span class="text-amber-600 font-semibold text-sm">View Calendar →</span>
</a>
<a href="documents.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<a href="/pages/documents.html" class="bg-white rounded-2xl p-7 shadow-md card-hover block">
<div class="w-12 h-12 bg-sky-100 rounded-xl flex items-center justify-center text-xl mb-4">🗂️</div>
<h3 class="text-lg font-bold text-slate-900 mb-2">Document Vault</h3>
<p class="text-slate-600 text-sm mb-3">Store permits, insurance, and certifications — accessible from the road.</p>
@@ -215,7 +215,7 @@
Whether it's a single pilot car or a full escort team, we've got you covered.
Tell us about your load and we'll handle the rest.
</p>
<a href="order.html" class="inline-block bg-slate-900 hover:bg-slate-800 text-white font-bold px-8 py-4 rounded-xl text-lg transition-colors shadow-lg">
<a href="/pages/order.html" class="inline-block bg-slate-900 hover:bg-slate-800 text-white font-bold px-8 py-4 rounded-xl text-lg transition-colors shadow-lg">
Request Escort Service →
</a>
</div>
@@ -223,7 +223,7 @@
<div id="main-footer"></div>
<script src="nav.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('home');
renderBanner();

448
public/js/api.js Normal file
View File

@@ -0,0 +1,448 @@
// =====================================================================
// PilotEdge API Client
// Fetches data from the backend API and transforms responses to match
// the shapes expected by the existing frontend rendering code.
// Replace mock-data.js and mock-data-extended.js with this file.
// =====================================================================
const API_BASE = '/api';
const PilotEdge = {
// Auth token management
_token: localStorage.getItem('pilotedge_token'),
setToken(token) {
this._token = token;
if (token) localStorage.setItem('pilotedge_token', token);
else localStorage.removeItem('pilotedge_token');
},
getToken() {
return this._token;
},
getUser() {
const raw = localStorage.getItem('pilotedge_user');
return raw ? JSON.parse(raw) : null;
},
setUser(user) {
if (user) localStorage.setItem('pilotedge_user', JSON.stringify(user));
else localStorage.removeItem('pilotedge_user');
},
// Core fetch wrapper
async request(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(body.error || `API error ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
},
async get(path) { return this.request(path); },
async post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); },
async put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); },
async del(path) { return this.request(path, { method: 'DELETE' }); },
// -----------------------------------------------------------------
// Auth
// -----------------------------------------------------------------
async register(email, password, name, role) {
const res = await this.post('/auth/register', { email, password, name, role });
this.setToken(res.token);
this.setUser(res.user);
return res;
},
async login(email, password) {
const res = await this.post('/auth/login', { email, password });
this.setToken(res.token);
this.setUser(res.user);
return res;
},
logout() {
this.setToken(null);
this.setUser(null);
},
async me() { return this.get('/auth/me'); },
// -----------------------------------------------------------------
// Regulations — returns MOCK_STATE_REGULATIONS-compatible shape
// -----------------------------------------------------------------
async getRegulations() {
const states = await this.get('/regulations');
return states.map(s => ({
name: s.name,
abbr: s.abbr,
lat: s.lat,
lng: s.lng,
permitWidth: s.regulation?.permitWidth || '',
permitHeight: s.regulation?.permitHeight || '',
permitLength: s.regulation?.permitLength || '',
permitWeight: s.regulation?.permitWeight || '',
escortWidth: s.regulation?.escortWidth || '',
escortHeight: s.regulation?.escortHeight || '',
escortLength: s.regulation?.escortLength || '',
escortWeight: s.regulation?.escortWeight || '',
travel: s.regulation?.travelRestrictions || '',
holidays: s.regulation?.holidays || '',
agency: s.regulation?.agency || '',
url: s.regulation?.url || '',
notes: s.regulation?.notes || '',
}));
},
// -----------------------------------------------------------------
// Equipment — returns MOCK_STATE_EQUIPMENT-compatible shape
// Object keyed by state abbr: { TX: { escort: {...}, carrier: {...} } }
// -----------------------------------------------------------------
async getEquipment() {
const states = await this.get('/regulations');
const equipment = {};
for (const s of states) {
if (!s.equipmentRequirements) {
const full = await this.get(`/regulations/${s.abbr}`);
s.equipmentRequirements = full.equipmentRequirements || [];
}
if (s.equipmentRequirements.length > 0) {
equipment[s.abbr] = {};
for (const eq of s.equipmentRequirements) {
const obj = {
certification: eq.certification || '',
vehicle: eq.vehicle || '',
signs: eq.signs || '',
lights: eq.lights || '',
heightPole: eq.heightPole || '',
flags: eq.flags || '',
communication: eq.communication || '',
safety: eq.safetyGear || '',
};
if (eq.type === 'escort') equipment[s.abbr].escort = obj;
else if (eq.type === 'carrier') {
// Parse carrier safetyGear back to individual fields
const gear = eq.safetyGear || '';
equipment[s.abbr].carrier = {
signs: eq.signs || '',
flags: eq.flags || '',
lights: eq.lights || '',
cones: extractGearField(gear, 'Cones'),
fireExtinguisher: extractGearField(gear, 'Fire ext'),
triangles: extractGearField(gear, 'Triangles'),
flares: extractGearField(gear, 'Flares'),
firstAid: extractGearField(gear, 'First aid'),
};
}
}
}
}
return equipment;
},
// -----------------------------------------------------------------
// Contacts — returns MOCK_STATE_CONTACTS-compatible shape
// Object keyed by state abbr: { AL: { name, permit, police, email, hours, portal } }
// -----------------------------------------------------------------
async getContacts() {
const contacts = await this.get('/contacts');
const result = {};
for (const c of contacts) {
result[c.state.abbr] = {
name: c.state.name,
permit: c.permitPhone,
police: c.policePhone,
email: c.email,
hours: c.hours,
portal: c.portalUrl,
};
}
return result;
},
// -----------------------------------------------------------------
// Calendar — returns MOCK_SEASONAL_RESTRICTIONS-compatible shape
// -----------------------------------------------------------------
async getSeasonalRestrictions() {
const restrictions = await this.get('/calendar');
return restrictions.map(r => ({
id: r.id,
state: r.state?.abbr || '',
stateName: r.state?.name || '',
type: r.type,
title: r.name,
startMonth: r.startMonth,
startDay: 1,
endMonth: r.endMonth,
endDay: 28,
description: r.description,
color: getRestrictionColor(r.type),
routes: '',
impact: '',
}));
},
// -----------------------------------------------------------------
// Truck Stops — returns MOCK_TRUCK_STOPS-compatible shape
// -----------------------------------------------------------------
async getTruckStops() {
const stops = await this.get('/truckstops');
return stops.map(ts => ({
id: ts.id,
name: ts.name,
type: 'truck_stop',
location: {
city: ts.address?.split(',')[0]?.trim() || '',
state: ts.state?.abbr || '',
lat: ts.lat,
lng: ts.lng,
},
oversizeFriendly: ts.hasOversizeParking,
entranceWidth: ts.entranceWidth || '',
entranceHeight: ts.entranceHeight || '',
lotSize: ts.lotSqFt ? `${ts.lotSqFt} sq ft` : '',
oversizeCapacity: '',
facilities: ts.facilities || [],
description: '',
comments: (ts.contributions || []).map(c => ({
user: c.user?.name || 'Anonymous',
date: c.createdAt,
text: c.content,
})),
}));
},
// -----------------------------------------------------------------
// Bridges — returns MOCK_BRIDGE_CLEARANCES-compatible shape
// -----------------------------------------------------------------
async getBridges() {
const bridges = await this.get('/bridges');
return bridges.map(b => ({
id: b.id,
route: b.route,
mileMarker: '',
type: b.name.split(' at ')[0] || 'Bridge',
location: {
desc: b.name.split(' at ')[1] || b.name,
city: '',
state: b.state?.abbr || '',
lat: b.lat,
lng: b.lng,
},
clearanceHeight: `${b.heightClearance}'`,
clearanceWidth: b.widthClearance ? `${b.widthClearance}'` : 'Unrestricted',
weightLimit: b.weightLimit ? `${b.weightLimit} lbs` : 'No posted limit',
notes: '',
}));
},
// -----------------------------------------------------------------
// Weigh Stations — returns MOCK_WEIGH_STATIONS-compatible shape
// -----------------------------------------------------------------
async getWeighStations() {
const stations = await this.get('/weighstations');
return stations.map(ws => ({
id: ws.id,
name: ws.name,
route: ws.route,
location: {
city: '',
state: ws.state?.abbr || '',
lat: ws.lat,
lng: ws.lng,
},
hours: ws.hours,
prePass: ws.prePass,
currentStatus: ws.currentStatus,
direction: ws.direction,
lastFlagged: ws.lastStatusUpdate || null,
flaggedBy: '',
notes: '',
}));
},
// -----------------------------------------------------------------
// Alerts — returns MOCK_ROUTE_CONDITIONS + MOCK_WEATHER_ALERTS shapes
// -----------------------------------------------------------------
async getAlerts() {
const alerts = await this.get('/alerts');
const routeConditions = [];
const weatherAlerts = [];
for (const a of alerts) {
if (a.type === 'weather' || a.type === 'wind') {
weatherAlerts.push({
id: a.id,
type: a.type,
severity: a.severity,
region: a.state?.name || '',
routes: a.route ? a.route.split(', ') : [],
description: a.description,
validFrom: a.startsAt,
validTo: a.endsAt,
source: 'NWS',
lat: 0,
lng: 0,
});
} else {
routeConditions.push({
id: a.id,
type: a.type,
severity: a.severity,
route: a.route,
location: {
desc: a.description.substring(0, 60),
state: a.state?.abbr || '',
lat: 0,
lng: 0,
},
description: a.description,
startDate: a.startsAt,
endDate: a.endsAt,
source: 'State DOT',
affectsOversize: true,
});
}
}
return { routeConditions, weatherAlerts };
},
// -----------------------------------------------------------------
// Load Board — returns MOCK_LOAD_BOARD-compatible shape
// -----------------------------------------------------------------
async getLoads() {
const data = await this.get('/loads?limit=100');
return (data.loads || []).map(l => ({
id: l.id,
carrier: l.poster?.name || 'Unknown',
origin: parseLocation(l.origin),
destination: parseLocation(l.destination),
departureDate: l.pickupDate,
dimensions: {
width: l.width,
height: l.height,
length: l.length,
weight: l.weight,
},
description: l.description,
escortsNeeded: l.escortsNeeded,
status: l.status,
postedDate: l.createdAt,
contact: l.poster?.name || '',
}));
},
// -----------------------------------------------------------------
// Escort Operators — returns MOCK_ESCORT_OPERATORS-compatible shape
// -----------------------------------------------------------------
async getEscortOperators() {
const escorts = await this.get('/escorts');
return escorts.map(e => ({
id: e.id,
name: e.user?.name || 'Unknown',
location: {
city: '',
state: '',
lat: e.lat,
lng: e.lng,
},
status: e.availability,
certifications: e.certifications || [],
vehicleType: e.vehicleType,
rating: e.rating,
totalJobs: e.ratingCount || 0,
experience: '',
contact: e.user?.email || '',
phone: e.phone,
bio: e.bio,
}));
},
// -----------------------------------------------------------------
// Documents — returns MOCK_DOCUMENTS-compatible shape
// -----------------------------------------------------------------
async getDocuments() {
try {
const docs = await this.get('/documents');
return docs.map(d => ({
id: d.id,
name: d.filename,
type: d.type,
state: '',
uploadDate: d.createdAt,
expiryDate: d.expiresAt,
fileSize: formatFileSize(d.sizeBytes),
status: d.expiresAt && new Date(d.expiresAt) < new Date() ? 'expired' : 'active',
}));
} catch (err) {
// If not authenticated, return empty array
if (err.status === 401) return [];
throw err;
}
},
// -----------------------------------------------------------------
// Orders
// -----------------------------------------------------------------
async submitOrder(orderData) {
return this.post('/orders', orderData);
},
async getOrders() {
return this.get('/orders');
},
// -----------------------------------------------------------------
// Contributions
// -----------------------------------------------------------------
async submitContribution(entityType, entityId, type, content) {
return this.post('/contributions', { entityType, entityId, type, content });
},
};
// -----------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------
function extractGearField(gear, label) {
const match = gear.match(new RegExp(`${label}:\\s*([^;]+)`));
return match ? match[1].trim() : '';
}
function getRestrictionColor(type) {
const colors = {
spring_weight: '#3b82f6',
winter_closure: '#8b5cf6',
harvest: '#f59e0b',
holiday_blackout: '#ef4444',
};
return colors[type] || '#6b7280';
}
function parseLocation(str) {
// Parse "City, ST" into { city, state, lat: 0, lng: 0 }
if (!str) return { city: '', state: '', lat: 0, lng: 0 };
const parts = str.split(',').map(s => s.trim());
return {
city: parts[0] || '',
state: parts[1] || '',
lat: 0,
lng: 0,
};
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${Math.round(size * 10) / 10} ${units[i]}`;
}

View File

@@ -1,6 +1,6 @@
// =============================================
// Shared Navigation, Banner, and Footer
// Include on every page via <script src="nav.js">
// Include on every page via <script src="/js/nav.js">
// =============================================
function renderNav(activePage) {
@@ -18,14 +18,14 @@ function renderNav(activePage) {
<nav class="bg-slate-900 text-white fixed top-0 w-full z-50 shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<a href="index.html" class="flex items-center space-x-2 flex-shrink-0">
<a href="/" class="flex items-center space-x-2 flex-shrink-0">
<span class="text-2xl">🚛</span>
<span class="text-xl font-bold text-amber-400 tracking-tight">PilotEdge</span>
</a>
<!-- Desktop Nav -->
<div class="hidden lg:flex items-center space-x-1">
<a href="index.html" class="${linkClass('home')} px-3 py-2 rounded-md text-sm transition-colors">Home</a>
<a href="/" class="${linkClass('home')} px-3 py-2 rounded-md text-sm transition-colors">Home</a>
<!-- Regulations Dropdown -->
<div class="relative group">
@@ -35,19 +35,19 @@ function renderNav(activePage) {
</button>
<div class="absolute left-0 top-full pt-1 hidden group-hover:block" style="min-width:220px;">
<div class="bg-white rounded-xl shadow-xl border border-slate-200 py-2">
<a href="regulations.html" class="${dropLinkClass('regulations')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/regulations.html" class="${dropLinkClass('regulations')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">State Regulations Map</div>
<div class="text-xs text-slate-400 mt-0.5">Permits & escort thresholds</div>
</a>
<a href="regulations.html#equipment" class="${dropLinkClass('equipment')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/regulations.html#equipment" class="${dropLinkClass('equipment')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">Equipment Requirements</div>
<div class="text-xs text-slate-400 mt-0.5">Escort & carrier gear by state</div>
</a>
<a href="contacts.html" class="${dropLinkClass('contacts')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/contacts.html" class="${dropLinkClass('contacts')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">DOT Contact Directory</div>
<div class="text-xs text-slate-400 mt-0.5">Permit office phone & email</div>
</a>
<a href="calendar.html" class="${dropLinkClass('calendar')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/calendar.html" class="${dropLinkClass('calendar')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">Seasonal Calendar</div>
<div class="text-xs text-slate-400 mt-0.5">Restrictions & closures</div>
</a>
@@ -63,19 +63,19 @@ function renderNav(activePage) {
</button>
<div class="absolute left-0 top-full pt-1 hidden group-hover:block" style="min-width:220px;">
<div class="bg-white rounded-xl shadow-xl border border-slate-200 py-2">
<a href="truckstops.html" class="${dropLinkClass('truckstops')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/truckstops.html" class="${dropLinkClass('truckstops')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">Truck Stops & Parking</div>
<div class="text-xs text-slate-400 mt-0.5">Oversize-friendly locations</div>
</a>
<a href="bridges.html" class="${dropLinkClass('bridges')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/bridges.html" class="${dropLinkClass('bridges')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">Bridge Clearances</div>
<div class="text-xs text-slate-400 mt-0.5">Height & width restrictions</div>
</a>
<a href="weighstations.html" class="${dropLinkClass('weighstations')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/weighstations.html" class="${dropLinkClass('weighstations')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">Weigh Stations</div>
<div class="text-xs text-slate-400 mt-0.5">Live open/closed status</div>
</a>
<a href="alerts.html" class="${dropLinkClass('alerts')} block px-4 py-2.5 text-sm transition-colors">
<a href="/pages/alerts.html" class="${dropLinkClass('alerts')} block px-4 py-2.5 text-sm transition-colors">
<div class="font-medium">Route & Weather Alerts</div>
<div class="text-xs text-slate-400 mt-0.5">Closures, construction, wind</div>
</a>
@@ -83,11 +83,11 @@ function renderNav(activePage) {
</div>
</div>
<a href="loadboard.html" class="${linkClass('loadboard')} px-3 py-2 rounded-md text-sm transition-colors">Load Board</a>
<a href="locator.html" class="${linkClass('locator')} px-3 py-2 rounded-md text-sm transition-colors">Find Escorts</a>
<a href="documents.html" class="${linkClass('documents')} px-3 py-2 rounded-md text-sm transition-colors">Documents</a>
<a href="/pages/loadboard.html" class="${linkClass('loadboard')} px-3 py-2 rounded-md text-sm transition-colors">Load Board</a>
<a href="/pages/locator.html" class="${linkClass('locator')} px-3 py-2 rounded-md text-sm transition-colors">Find Escorts</a>
<a href="/pages/documents.html" class="${linkClass('documents')} px-3 py-2 rounded-md text-sm transition-colors">Documents</a>
<a href="order.html" class="ml-2 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-4 py-2 rounded-lg transition-colors shadow-md hover:shadow-lg text-sm">
<a href="/pages/order.html" class="ml-2 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-4 py-2 rounded-lg transition-colors shadow-md hover:shadow-lg text-sm">
Request Service
</a>
</div>
@@ -103,26 +103,26 @@ function renderNav(activePage) {
<!-- Mobile Menu -->
<div id="mobile-menu" class="lg:hidden hidden border-t border-slate-700 px-4 pb-4 max-h-[80vh] overflow-y-auto">
<a href="index.html" class="block py-2 px-3 mt-2 rounded ${isActive('home') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Home</a>
<a href="/" class="block py-2 px-3 mt-2 rounded ${isActive('home') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Home</a>
<div class="mt-2 mb-1 px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Regulations</div>
<a href="regulations.html" class="block py-2 px-3 rounded ${isActive('regulations') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">State Regulations Map</a>
<a href="regulations.html#equipment" class="block py-2 px-3 rounded ${isActive('equipment') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Equipment Requirements</a>
<a href="contacts.html" class="block py-2 px-3 rounded ${isActive('contacts') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">DOT Contacts</a>
<a href="calendar.html" class="block py-2 px-3 rounded ${isActive('calendar') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Seasonal Calendar</a>
<a href="/pages/regulations.html" class="block py-2 px-3 rounded ${isActive('regulations') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">State Regulations Map</a>
<a href="/pages/regulations.html#equipment" class="block py-2 px-3 rounded ${isActive('equipment') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Equipment Requirements</a>
<a href="/pages/contacts.html" class="block py-2 px-3 rounded ${isActive('contacts') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">DOT Contacts</a>
<a href="/pages/calendar.html" class="block py-2 px-3 rounded ${isActive('calendar') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Seasonal Calendar</a>
<div class="mt-2 mb-1 px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Road Intel</div>
<a href="truckstops.html" class="block py-2 px-3 rounded ${isActive('truckstops') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Truck Stops & Parking</a>
<a href="bridges.html" class="block py-2 px-3 rounded ${isActive('bridges') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Bridge Clearances</a>
<a href="weighstations.html" class="block py-2 px-3 rounded ${isActive('weighstations') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Weigh Stations</a>
<a href="alerts.html" class="block py-2 px-3 rounded ${isActive('alerts') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Route & Weather Alerts</a>
<a href="/pages/truckstops.html" class="block py-2 px-3 rounded ${isActive('truckstops') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Truck Stops & Parking</a>
<a href="/pages/bridges.html" class="block py-2 px-3 rounded ${isActive('bridges') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Bridge Clearances</a>
<a href="/pages/weighstations.html" class="block py-2 px-3 rounded ${isActive('weighstations') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Weigh Stations</a>
<a href="/pages/alerts.html" class="block py-2 px-3 rounded ${isActive('alerts') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Route & Weather Alerts</a>
<div class="mt-2 mb-1 px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Services</div>
<a href="loadboard.html" class="block py-2 px-3 rounded ${isActive('loadboard') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Load Board</a>
<a href="locator.html" class="block py-2 px-3 rounded ${isActive('locator') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Find Escorts</a>
<a href="documents.html" class="block py-2 px-3 rounded ${isActive('documents') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Document Vault</a>
<a href="/pages/loadboard.html" class="block py-2 px-3 rounded ${isActive('loadboard') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Load Board</a>
<a href="/pages/locator.html" class="block py-2 px-3 rounded ${isActive('locator') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Find Escorts</a>
<a href="/pages/documents.html" class="block py-2 px-3 rounded ${isActive('documents') ? 'text-amber-400 bg-slate-800' : 'text-gray-300'}">Document Vault</a>
<a href="order.html" class="block py-2 px-3 mt-3 bg-amber-500 text-slate-900 font-bold rounded-lg text-center">Request Service</a>
<a href="/pages/order.html" class="block py-2 px-3 mt-3 bg-amber-500 text-slate-900 font-bold rounded-lg text-center">Request Service</a>
</div>
</nav>
`;
@@ -160,27 +160,27 @@ function renderFooter() {
<div>
<h4 class="text-white font-semibold mb-3">Regulations</h4>
<ul class="space-y-2 text-sm">
<li><a href="regulations.html" class="hover:text-white transition-colors">State Regulations Map</a></li>
<li><a href="contacts.html" class="hover:text-white transition-colors">DOT Contact Directory</a></li>
<li><a href="calendar.html" class="hover:text-white transition-colors">Seasonal Calendar</a></li>
<li><a href="/pages/regulations.html" class="hover:text-white transition-colors">State Regulations Map</a></li>
<li><a href="/pages/contacts.html" class="hover:text-white transition-colors">DOT Contact Directory</a></li>
<li><a href="/pages/calendar.html" class="hover:text-white transition-colors">Seasonal Calendar</a></li>
</ul>
</div>
<div>
<h4 class="text-white font-semibold mb-3">Road Intel</h4>
<ul class="space-y-2 text-sm">
<li><a href="truckstops.html" class="hover:text-white transition-colors">Truck Stops & Parking</a></li>
<li><a href="bridges.html" class="hover:text-white transition-colors">Bridge Clearances</a></li>
<li><a href="weighstations.html" class="hover:text-white transition-colors">Weigh Stations</a></li>
<li><a href="alerts.html" class="hover:text-white transition-colors">Route & Weather Alerts</a></li>
<li><a href="/pages/truckstops.html" class="hover:text-white transition-colors">Truck Stops & Parking</a></li>
<li><a href="/pages/bridges.html" class="hover:text-white transition-colors">Bridge Clearances</a></li>
<li><a href="/pages/weighstations.html" class="hover:text-white transition-colors">Weigh Stations</a></li>
<li><a href="/pages/alerts.html" class="hover:text-white transition-colors">Route & Weather Alerts</a></li>
</ul>
</div>
<div>
<h4 class="text-white font-semibold mb-3">Services</h4>
<ul class="space-y-2 text-sm">
<li><a href="loadboard.html" class="hover:text-white transition-colors">Load Board</a></li>
<li><a href="locator.html" class="hover:text-white transition-colors">Find Escort Vehicles</a></li>
<li><a href="documents.html" class="hover:text-white transition-colors">Document Vault</a></li>
<li><a href="order.html" class="hover:text-white transition-colors">Request Escort Service</a></li>
<li><a href="/pages/loadboard.html" class="hover:text-white transition-colors">Load Board</a></li>
<li><a href="/pages/locator.html" class="hover:text-white transition-colors">Find Escort Vehicles</a></li>
<li><a href="/pages/documents.html" class="hover:text-white transition-colors">Document Vault</a></li>
<li><a href="/pages/order.html" class="hover:text-white transition-colors">Request Escort Service</a></li>
</ul>
</div>
</div>

View File

@@ -119,14 +119,18 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('alerts');
renderBanner();
renderFooter();
(async () => {
const alertData = await PilotEdge.getAlerts();
const MOCK_ROUTE_CONDITIONS = alertData.routeConditions;
const MOCK_WEATHER_ALERTS = alertData.weatherAlerts;
// ── State ──
let activeTab = 'all';
let map, markersLayer;
@@ -413,6 +417,7 @@
document.getElementById('filter-type').addEventListener('change', renderAll);
document.getElementById('filter-severity').addEventListener('change', renderAll);
document.getElementById('filter-oversize').addEventListener('change', renderAll);
})();
</script>
</body>
</html>

View File

@@ -112,14 +112,16 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('bridges');
renderBanner();
renderFooter();
(async () => {
const MOCK_BRIDGE_CLEARANCES = await PilotEdge.getBridges();
// --- Helpers ---
// Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet.
@@ -380,6 +382,7 @@
// --- Initial render ---
applyFilters();
})();
</script>
</body>
</html>

View File

@@ -85,14 +85,16 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('calendar');
renderBanner();
renderFooter();
(async () => {
const MOCK_SEASONAL_RESTRICTIONS = await PilotEdge.getSeasonalRestrictions();
// ---- Constants ----
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -286,6 +288,7 @@
// ---- Initial render ----
applyFilters();
})();
</script>
</body>
</html>

111
public/pages/contacts.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>State DOT Contacts | 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">
<h1 class="text-3xl md:text-4xl font-bold mb-3">State DOT Contact Directory</h1>
<p class="text-lg text-gray-400 max-w-3xl">Find permit office phone numbers, state police non-emergency lines, and online portal links for every state.</p>
</div>
</section>
<!-- Search / Filter Bar -->
<section class="max-w-7xl mx-auto px-4 py-8 w-full">
<div class="bg-white rounded-2xl shadow-lg p-6">
<label for="state-search" class="block text-sm font-semibold text-slate-700 mb-2">Search States</label>
<input
type="text"
id="state-search"
placeholder="Type a state name to filter…"
class="w-full border border-slate-300 rounded-lg px-4 py-3 focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none text-slate-900"
>
</div>
</section>
<!-- Contact Cards Grid -->
<section class="max-w-7xl mx-auto px-4 pb-12 w-full">
<div id="contacts-grid" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Populated by JS -->
</div>
<p id="no-results" class="hidden text-center text-slate-500 py-12 text-lg">No states match your search.</p>
</section>
<div id="main-footer"></div>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('contacts');
renderBanner();
renderFooter();
(async () => {
const MOCK_STATE_CONTACTS = await PilotEdge.getContacts();
const grid = document.getElementById('contacts-grid');
const noResults = document.getElementById('no-results');
const searchInput = document.getElementById('state-search');
function buildCard(abbr, c) {
return `
<div class="bg-white rounded-2xl shadow-lg p-6 flex flex-col" data-state="${c.name.toLowerCase()}">
<h3 class="text-lg font-bold text-slate-900 mb-1">${c.name} <span class="text-slate-400 font-medium text-sm">(${abbr})</span></h3>
<div class="mt-3 space-y-2 text-sm text-slate-700 flex-1">
<p class="flex items-start gap-2">
<span class="shrink-0">📞</span>
<span><span class="font-medium">Permit Office:</span> <a href="tel:${c.permit.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.permit}</a></span>
</p>
<p class="flex items-start gap-2">
<span class="shrink-0">🚔</span>
<span><span class="font-medium">State Police:</span> <a href="tel:${c.police.replace(/[^+\d]/g, '')}" class="text-amber-600 hover:text-amber-700 font-semibold">${c.police}</a></span>
</p>
<p class="flex items-start gap-2">
<span class="shrink-0">✉️</span>
<span><span class="font-medium">Email:</span> <a href="mailto:${c.email}" class="text-amber-600 hover:text-amber-700 font-semibold break-all">${c.email}</a></span>
</p>
<p class="flex items-start gap-2">
<span class="shrink-0">🕐</span>
<span><span class="font-medium">Hours:</span> ${c.hours}</span>
</p>
</div>
<a href="${c.portal}" target="_blank" rel="noopener noreferrer"
class="mt-4 inline-flex items-center justify-center gap-1 bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold text-sm px-4 py-2 rounded-lg transition-colors shadow-md">
Visit Permit Portal <span class="text-xs">↗</span>
</a>
</div>`;
}
function renderCards(filter) {
const term = (filter || '').toLowerCase();
let html = '';
let count = 0;
Object.keys(MOCK_STATE_CONTACTS).forEach(abbr => {
const c = MOCK_STATE_CONTACTS[abbr];
if (!term || c.name.toLowerCase().includes(term) || abbr.toLowerCase().includes(term)) {
html += buildCard(abbr, c);
count++;
}
});
grid.innerHTML = html;
noResults.classList.toggle('hidden', count > 0);
}
searchInput.addEventListener('input', () => renderCards(searchInput.value));
renderCards();
})();
</script>
</body>
</html>

262
public/pages/documents.html Normal file
View File

@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Vault | 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">
<h1 class="text-3xl md:text-4xl font-bold mb-3">Document Vault</h1>
<p class="text-lg text-gray-400 max-w-3xl">Securely store and manage your permits, insurance certificates, and professional certifications — all in one place.</p>
</div>
</section>
<!-- Main Content -->
<main class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10">
<!-- Stats Bar -->
<div id="stats-bar" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"></div>
<!-- Expiry Warnings -->
<div id="expiry-warnings" class="mb-8"></div>
<!-- Filter Bar -->
<div class="bg-white rounded-2xl shadow-lg p-4 sm:p-6 mb-8">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input id="search-input" type="text" placeholder="Search documents…"
class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition-colors">
</div>
<select id="type-filter"
class="border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition-colors">
<option value="all">All Types</option>
<option value="permit">Permit</option>
<option value="insurance">Insurance</option>
<option value="certification">Certification</option>
<option value="registration">Registration</option>
</select>
<select id="status-filter"
class="border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition-colors">
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="expired">Expired</option>
</select>
<button onclick="openUploadModal()"
class="bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold px-5 py-2.5 rounded-lg transition-colors shadow-md hover:shadow-lg text-sm whitespace-nowrap">
+ Upload Document
</button>
</div>
</div>
<!-- Document List -->
<div id="document-list" class="grid gap-4"></div>
<!-- Empty State -->
<div id="empty-state" class="hidden text-center py-16">
<p class="text-5xl mb-4">📄</p>
<p class="text-slate-500 text-lg">No documents match your filters.</p>
</div>
</main>
<!-- Upload Modal -->
<div id="upload-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 relative">
<button onclick="closeUploadModal()" class="absolute top-4 right-4 text-slate-400 hover:text-slate-600 text-xl leading-none">&times;</button>
<h2 class="text-xl font-bold text-slate-900 mb-1">Upload Document</h2>
<p class="text-sm text-amber-600 mb-5">⚠️ POC demo — file upload not functional.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Document Type</label>
<select id="upload-type"
class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none">
<option value="permit">Permit</option>
<option value="insurance">Insurance</option>
<option value="certification">Certification</option>
<option value="registration">Registration</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Document Name</label>
<input id="upload-name" type="text" placeholder="e.g. TX Single Trip Permit"
class="w-full border border-slate-300 rounded-lg px-4 py-2.5 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Choose File</label>
<div class="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center text-slate-400 text-sm">
Drag &amp; drop or click to browse<br>
<span class="text-xs">(disabled in POC)</span>
</div>
</div>
<button onclick="alert('POC demo — upload not functional.'); closeUploadModal();"
class="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-2.5 rounded-lg transition-colors shadow-md text-sm">
Upload
</button>
</div>
</div>
</div>
<div id="main-footer"></div>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('documents');
renderBanner();
renderFooter();
(async () => {
const MOCK_DOCUMENTS = await PilotEdge.getDocuments();
// ── Helpers ──────────────────────────────────────────
const today = new Date();
const MS_PER_DAY = 86400000;
function daysUntil(dateStr) {
return Math.ceil((new Date(dateStr) - today) / MS_PER_DAY);
}
function fmtDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
}
const typeBadge = {
permit: 'bg-blue-100 text-blue-700',
insurance: 'bg-green-100 text-green-700',
certification: 'bg-purple-100 text-purple-700',
registration: 'bg-slate-200 text-slate-700'
};
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
// ── Stats ───────────────────────────────────────────
function renderStats(docs) {
const total = docs.length;
const active = docs.filter(d => d.status === 'active').length;
const expired = docs.filter(d => d.status === 'expired').length;
const expiring = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0).length;
const items = [
{ label:'Total Documents', value:total, color:'bg-slate-900 text-white' },
{ label:'Active Permits', value:active, color:'bg-green-600 text-white' },
{ label:'Expiring Soon', value:expiring, color:'bg-amber-500 text-white' },
{ label:'Expired', value:expired, color:'bg-red-600 text-white' }
];
document.getElementById('stats-bar').innerHTML = items.map(s => `
<div class="${s.color} rounded-2xl shadow-lg p-5 text-center">
<div class="text-3xl font-bold">${s.value}</div>
<div class="text-sm mt-1 opacity-90">${s.label}</div>
</div>`).join('');
}
// ── Expiry Warnings ─────────────────────────────────
function renderExpiryWarnings(docs) {
const soon = docs.filter(d => d.status === 'active' && daysUntil(d.expiryDate) <= 30 && daysUntil(d.expiryDate) > 0);
const el = document.getElementById('expiry-warnings');
if (!soon.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<div class="bg-amber-50 border border-amber-300 rounded-2xl p-5">
<h3 class="font-bold text-amber-800 text-lg mb-3">⚠️ Expiring Within 30 Days</h3>
<div class="space-y-2">
${soon.map(d => `
<div class="flex flex-col sm:flex-row sm:items-center justify-between bg-white rounded-xl px-4 py-3 shadow-sm border border-amber-200">
<div>
<span class="font-semibold text-slate-900">${d.name}</span>
<span class="inline-block ml-2 text-xs font-medium px-2 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
</div>
<span class="text-amber-700 font-medium text-sm mt-1 sm:mt-0">Expires ${fmtDate(d.expiryDate)} (${daysUntil(d.expiryDate)} day${daysUntil(d.expiryDate) === 1 ? '' : 's'})</span>
</div>`).join('')}
</div>
</div>`;
}
// ── Document Cards ──────────────────────────────────
function renderDocuments(docs) {
const list = document.getElementById('document-list');
const empty = document.getElementById('empty-state');
if (!docs.length) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
list.innerHTML = docs.map(d => {
const statusBadge = d.status === 'active'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700';
return `
<div class="bg-white rounded-2xl shadow-lg p-5 sm:p-6 hover:shadow-xl transition-shadow border border-slate-100">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<h3 class="font-bold text-slate-900 text-base truncate">${d.name}</h3>
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${typeBadge[d.type]}">${capitalize(d.type)}</span>
<span class="inline-block text-xs font-medium px-2.5 py-0.5 rounded-full ${statusBadge}">${capitalize(d.status)}</span>
</div>
<div class="flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
${d.state ? `<span>📍 ${d.state}</span>` : ''}
<span>📤 Uploaded ${fmtDate(d.uploadDate)}</span>
<span>📅 Expires ${fmtDate(d.expiryDate)}</span>
<span>💾 ${d.fileSize}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button onclick="alert('Viewing: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-slate-300 text-slate-700 hover:bg-slate-50 transition-colors">View</button>
<button onclick="alert('Downloading: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-amber-400 text-amber-700 hover:bg-amber-50 transition-colors">Download</button>
<button onclick="alert('Delete requested for: ${d.name}')" class="px-3 py-1.5 text-sm font-medium rounded-lg border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Delete</button>
</div>
</div>
</div>`;
}).join('');
}
// ── Filtering ────────────────────────────────────────
function applyFilters() {
const query = document.getElementById('search-input').value.toLowerCase();
const type = document.getElementById('type-filter').value;
const status = document.getElementById('status-filter').value;
const filtered = MOCK_DOCUMENTS.filter(d => {
if (query && !d.name.toLowerCase().includes(query) && !d.id.toLowerCase().includes(query)) return false;
if (type !== 'all' && d.type !== type) return false;
if (status !== 'all' && d.status !== status) return false;
return true;
});
renderDocuments(filtered);
}
document.getElementById('search-input').addEventListener('input', applyFilters);
document.getElementById('type-filter').addEventListener('change', applyFilters);
document.getElementById('status-filter').addEventListener('change', applyFilters);
// ── Upload Modal ─────────────────────────────────────
function openUploadModal() {
const modal = document.getElementById('upload-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeUploadModal() {
const modal = document.getElementById('upload-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
// ── Initial Render ───────────────────────────────────
renderStats(MOCK_DOCUMENTS);
renderExpiryWarnings(MOCK_DOCUMENTS);
renderDocuments(MOCK_DOCUMENTS);
})();
</script>
</body>
</html>

View File

@@ -141,13 +141,16 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/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>';
@@ -293,6 +296,7 @@
document.getElementById('post-modal').addEventListener('click', function(e) {
if (e.target === this) closePostModal();
});
})();
</script>
</body>
</html>

View File

@@ -115,13 +115,16 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/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)));
@@ -296,6 +299,7 @@
// Initial render
filterOperators();
})();
</script>
</body>
</html>

View File

@@ -221,7 +221,7 @@
<div id="main-footer"></div>
<script src="nav.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('order');
renderBanner();

View File

@@ -139,14 +139,17 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('regulations');
renderBanner();
renderFooter();
(async () => {
const MOCK_STATE_REGULATIONS = await PilotEdge.getRegulations();
const MOCK_STATE_EQUIPMENT = await PilotEdge.getEquipment();
// Initialize map centered on continental US
const map = L.map('map').setView([39.5, -98.5], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -435,6 +438,7 @@
</div>
`;
}
})();
</script>
</body>
</html>

View File

@@ -65,14 +65,16 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/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: '🚿',
@@ -235,6 +237,7 @@
// Initialize
initMap();
renderStops(MOCK_TRUCK_STOPS);
})();
</script>
</body>

View File

@@ -90,14 +90,16 @@
<div id="main-footer"></div>
<script src="mock-data.js"></script>
<script src="mock-data-extended.js"></script>
<script src="nav.js"></script>
<script src="/js/api.js"></script>
<script src="/js/nav.js"></script>
<script>
renderNav('weighstations');
renderBanner();
renderFooter();
(async () => {
const MOCK_WEIGH_STATIONS = await PilotEdge.getWeighStations();
// ── Local mutable copy of station data ──
const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS));
@@ -261,6 +263,7 @@
// Initial render
applyFilters();
})();
</script>
</body>
</html>

7
server/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Environment variables for PilotEdge backend
# Copy this to .env and fill in your values
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pilotedge?schema=public"
JWT_SECRET="change-this-to-a-long-random-string"
PORT=3000
NODE_ENV=development

3
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
uploads/

2146
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
server/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "pilotedge-server",
"version": "1.0.0",
"description": "PilotEdge backend API — Oversize Load Resource Platform",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"db:migrate": "npx prisma migrate dev",
"db:seed": "node src/seeds/seed.js",
"db:reset": "npx prisma migrate reset --force",
"db:studio": "npx prisma studio"
},
"dependencies": {
"@prisma/client": "^6.6.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.2"
},
"devDependencies": {
"prisma": "^6.6.0"
}
}

View File

@@ -0,0 +1,308 @@
-- CreateTable
CREATE TABLE "states" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"abbr" VARCHAR(2) NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
CONSTRAINT "states_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "regulations" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"permitWidth" TEXT NOT NULL,
"permitHeight" TEXT NOT NULL,
"permitLength" TEXT NOT NULL,
"permitWeight" TEXT NOT NULL,
"escortWidth" TEXT NOT NULL,
"escortHeight" TEXT NOT NULL,
"escortLength" TEXT NOT NULL,
"escortWeight" TEXT NOT NULL,
"travelRestrictions" TEXT NOT NULL,
"holidays" TEXT NOT NULL,
"agency" TEXT NOT NULL,
"url" TEXT NOT NULL,
"notes" TEXT NOT NULL,
CONSTRAINT "regulations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "equipment_requirements" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"certification" TEXT NOT NULL DEFAULT '',
"vehicle" TEXT NOT NULL DEFAULT '',
"signs" TEXT NOT NULL DEFAULT '',
"lights" TEXT NOT NULL DEFAULT '',
"heightPole" TEXT NOT NULL DEFAULT '',
"flags" TEXT NOT NULL DEFAULT '',
"safetyGear" TEXT NOT NULL DEFAULT '',
"communication" TEXT NOT NULL DEFAULT '',
CONSTRAINT "equipment_requirements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "contacts" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"permitPhone" TEXT NOT NULL,
"policePhone" TEXT NOT NULL,
"email" TEXT NOT NULL,
"hours" TEXT NOT NULL,
"portalUrl" TEXT NOT NULL,
CONSTRAINT "contacts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "seasonal_restrictions" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"startMonth" INTEGER NOT NULL,
"endMonth" INTEGER NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "seasonal_restrictions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "truck_stops" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"address" TEXT NOT NULL,
"hasOversizeParking" BOOLEAN NOT NULL DEFAULT false,
"entranceHeight" TEXT NOT NULL DEFAULT '',
"entranceWidth" TEXT NOT NULL DEFAULT '',
"lotSqFt" INTEGER,
"facilities" JSONB NOT NULL DEFAULT '[]',
"satelliteUrl" TEXT NOT NULL DEFAULT '',
"phone" TEXT NOT NULL DEFAULT '',
CONSTRAINT "truck_stops_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bridges" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"route" TEXT NOT NULL,
"heightClearance" DOUBLE PRECISION NOT NULL,
"widthClearance" DOUBLE PRECISION,
"weightLimit" DOUBLE PRECISION,
"lastVerified" TIMESTAMP(3),
CONSTRAINT "bridges_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "weigh_stations" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"direction" TEXT NOT NULL,
"route" TEXT NOT NULL,
"prePass" BOOLEAN NOT NULL DEFAULT false,
"hours" TEXT NOT NULL DEFAULT '',
"currentStatus" TEXT NOT NULL DEFAULT 'unknown',
"lastStatusUpdate" TIMESTAMP(3),
"lastStatusUserId" TEXT,
CONSTRAINT "weigh_stations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'driver',
"tier" TEXT NOT NULL DEFAULT 'free',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escort_profiles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"radiusMiles" INTEGER NOT NULL DEFAULT 100,
"certifications" JSONB NOT NULL DEFAULT '[]',
"vehicleType" TEXT NOT NULL DEFAULT '',
"availability" TEXT NOT NULL DEFAULT 'available',
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"phone" TEXT NOT NULL DEFAULT '',
"bio" TEXT NOT NULL DEFAULT '',
CONSTRAINT "escort_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "loads" (
"id" TEXT NOT NULL,
"posterId" TEXT NOT NULL,
"origin" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"pickupDate" TIMESTAMP(3) NOT NULL,
"width" TEXT NOT NULL,
"height" TEXT NOT NULL,
"length" TEXT NOT NULL,
"weight" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"escortsNeeded" INTEGER NOT NULL DEFAULT 1,
"status" TEXT NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loads_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"origin" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"pickupDate" TIMESTAMP(3) NOT NULL,
"width" TEXT NOT NULL DEFAULT '',
"height" TEXT NOT NULL DEFAULT '',
"length" TEXT NOT NULL DEFAULT '',
"weight" TEXT NOT NULL DEFAULT '',
"loadType" TEXT NOT NULL DEFAULT '',
"escortsNeeded" INTEGER NOT NULL DEFAULT 1,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "documents" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"filepath" TEXT NOT NULL,
"mimeType" TEXT NOT NULL DEFAULT '',
"sizeBytes" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "documents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "contributions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"truckStopId" TEXT,
"weighStationId" TEXT,
CONSTRAINT "contributions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "alerts" (
"id" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"route" TEXT NOT NULL,
"description" TEXT NOT NULL,
"severity" TEXT NOT NULL DEFAULT 'info',
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "alerts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "states_name_key" ON "states"("name");
-- CreateIndex
CREATE UNIQUE INDEX "states_abbr_key" ON "states"("abbr");
-- CreateIndex
CREATE UNIQUE INDEX "regulations_stateId_key" ON "regulations"("stateId");
-- CreateIndex
CREATE UNIQUE INDEX "contacts_stateId_key" ON "contacts"("stateId");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "escort_profiles_userId_key" ON "escort_profiles"("userId");
-- AddForeignKey
ALTER TABLE "regulations" ADD CONSTRAINT "regulations_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "equipment_requirements" ADD CONSTRAINT "equipment_requirements_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "seasonal_restrictions" ADD CONSTRAINT "seasonal_restrictions_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "truck_stops" ADD CONSTRAINT "truck_stops_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bridges" ADD CONSTRAINT "bridges_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "weigh_stations" ADD CONSTRAINT "weigh_stations_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escort_profiles" ADD CONSTRAINT "escort_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "loads" ADD CONSTRAINT "loads_posterId_fkey" FOREIGN KEY ("posterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "documents" ADD CONSTRAINT "documents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contributions" ADD CONSTRAINT "contributions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contributions" ADD CONSTRAINT "contributions_truckStopId_fkey" FOREIGN KEY ("truckStopId") REFERENCES "truck_stops"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "contributions" ADD CONSTRAINT "contributions_weighStationId_fkey" FOREIGN KEY ("weighStationId") REFERENCES "weigh_stations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alerts" ADD CONSTRAINT "alerts_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "states"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

312
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,312 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =====================================================================
// Core Reference Data
// =====================================================================
model State {
id String @id @default(cuid())
name String @unique
abbr String @unique @db.VarChar(2)
lat Float
lng Float
regulation Regulation?
equipmentRequirements EquipmentRequirement[]
contact Contact?
seasonalRestrictions SeasonalRestriction[]
truckStops TruckStop[]
bridges Bridge[]
weighStations WeighStation[]
alerts Alert[]
@@map("states")
}
model Regulation {
id String @id @default(cuid())
stateId String @unique
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
permitWidth String
permitHeight String
permitLength String
permitWeight String
escortWidth String
escortHeight String
escortLength String
escortWeight String
travelRestrictions String
holidays String
agency String
url String
notes String @db.Text
@@map("regulations")
}
model EquipmentRequirement {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
type String // "escort" or "carrier"
certification String @default("")
vehicle String @default("")
signs String @default("")
lights String @default("")
heightPole String @default("")
flags String @default("")
safetyGear String @default("") @db.Text
communication String @default("")
@@map("equipment_requirements")
}
model Contact {
id String @id @default(cuid())
stateId String @unique
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
permitPhone String
policePhone String
email String
hours String
portalUrl String
@@map("contacts")
}
model SeasonalRestriction {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
type String // "spring_weight", "winter_closure", "harvest", "holiday_blackout"
startMonth Int
endMonth Int
description String @db.Text
@@map("seasonal_restrictions")
}
// =====================================================================
// Geospatial Entities
// =====================================================================
model TruckStop {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
lat Float
lng Float
address String
hasOversizeParking Boolean @default(false)
entranceHeight String @default("")
entranceWidth String @default("")
lotSqFt Int?
facilities Json @default("[]") // ["fuel","food","showers","restrooms"]
satelliteUrl String @default("")
phone String @default("")
contributions Contribution[]
@@map("truck_stops")
}
model Bridge {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
lat Float
lng Float
route String
heightClearance Float // in feet
widthClearance Float? // in feet, null = unrestricted
weightLimit Float? // in lbs, null = unrestricted
lastVerified DateTime?
@@map("bridges")
}
model WeighStation {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
name String
lat Float
lng Float
direction String // "NB", "SB", "EB", "WB"
route String
prePass Boolean @default(false)
hours String @default("")
currentStatus String @default("unknown") // "open", "closed", "unknown"
lastStatusUpdate DateTime?
lastStatusUserId String?
contributions Contribution[]
@@map("weigh_stations")
}
// =====================================================================
// Users & Auth
// =====================================================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
role String @default("driver") // "driver", "carrier", "escort", "admin"
tier String @default("free") // "free", "subscriber", "premium"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
escortProfile EscortProfile?
loads Load[]
orders Order[]
documents Document[]
contributions Contribution[]
@@map("users")
}
model EscortProfile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lat Float
lng Float
radiusMiles Int @default(100)
certifications Json @default("[]") // ["TX","OK","CA"]
vehicleType String @default("")
availability String @default("available") // "available", "on_job", "unavailable"
rating Float @default(0)
ratingCount Int @default(0)
phone String @default("")
bio String @default("") @db.Text
@@map("escort_profiles")
}
// =====================================================================
// Transactional Data
// =====================================================================
model Load {
id String @id @default(cuid())
posterId String
poster User @relation(fields: [posterId], references: [id], onDelete: Cascade)
origin String
destination String
pickupDate DateTime
width String
height String
length String
weight String
description String @default("") @db.Text
escortsNeeded Int @default(1)
status String @default("open") // "open", "assigned", "in_transit", "delivered", "cancelled"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("loads")
}
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Load details (embedded, since orders may not reference a load board listing)
origin String
destination String
pickupDate DateTime
width String @default("")
height String @default("")
length String @default("")
weight String @default("")
loadType String @default("")
escortsNeeded Int @default(1)
status String @default("pending") // "pending", "confirmed", "in_progress", "completed", "cancelled"
notes String @default("") @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("orders")
}
model Document {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // "permit", "insurance", "certification", "license", "other"
filename String
filepath String
mimeType String @default("")
sizeBytes Int @default(0)
expiresAt DateTime?
createdAt DateTime @default(now())
@@map("documents")
}
model Contribution {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entityType String // "truck_stop", "weigh_station", "bridge", "regulation"
entityId String
type String // "info", "flag", "confirm"
content String @db.Text
createdAt DateTime @default(now())
// Optional relations (polymorphic — only one will be set)
truckStop TruckStop? @relation(fields: [truckStopId], references: [id])
truckStopId String?
weighStation WeighStation? @relation(fields: [weighStationId], references: [id])
weighStationId String?
@@map("contributions")
}
// =====================================================================
// Alerts
// =====================================================================
model Alert {
id String @id @default(cuid())
stateId String
state State @relation(fields: [stateId], references: [id], onDelete: Cascade)
type String // "construction", "closure", "weather", "wind"
route String
description String @db.Text
severity String @default("info") // "info", "warning", "critical"
startsAt DateTime
endsAt DateTime?
createdAt DateTime @default(now())
@@map("alerts")
}

7
server/src/config/db.js Normal file
View File

@@ -0,0 +1,7 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
module.exports = prisma;

46
server/src/index.js Normal file
View File

@@ -0,0 +1,46 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const errorHandler = require('./middleware/errorHandler');
const app = express();
const PORT = process.env.PORT || 3000;
// --------------- Middleware ---------------
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Serve the frontend (static files from public/)
app.use(express.static(path.join(__dirname, '..', '..', 'public')));
// --------------- API Routes ---------------
app.use('/api/auth', require('./routes/auth'));
app.use('/api/regulations', require('./routes/regulations'));
app.use('/api/contacts', require('./routes/contacts'));
app.use('/api/calendar', require('./routes/calendar'));
app.use('/api/truckstops', require('./routes/truckstops'));
app.use('/api/bridges', require('./routes/bridges'));
app.use('/api/weighstations', require('./routes/weighstations'));
app.use('/api/alerts', require('./routes/alerts'));
app.use('/api/loads', require('./routes/loadboard'));
app.use('/api/escorts', require('./routes/locator'));
app.use('/api/orders', require('./routes/orders'));
app.use('/api/documents', require('./routes/documents'));
app.use('/api/contributions', require('./routes/contributions'));
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// --------------- Error Handler ---------------
app.use(errorHandler);
// --------------- Start Server ---------------
app.listen(PORT, () => {
console.log(`\n🚛 PilotEdge API running at http://localhost:${PORT}`);
console.log(`📡 API endpoints at http://localhost:${PORT}/api`);
console.log(`🌐 Frontend at http://localhost:${PORT}/index.html\n`);
});

View File

@@ -0,0 +1,44 @@
const jwt = require('jsonwebtoken');
// Required auth — rejects if no valid token
function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required.' });
}
try {
const token = header.split(' ')[1];
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // { id, email, role, tier }
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token.' });
}
}
// Optional auth — attaches user if token present, continues either way
function optionalAuth(req, res, next) {
const header = req.headers.authorization;
if (header && header.startsWith('Bearer ')) {
try {
const token = header.split(' ')[1];
req.user = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
// Invalid token — continue without user
}
}
next();
}
// Role check — use after requireAuth
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions.' });
}
next();
};
}
module.exports = { requireAuth, optionalAuth, requireRole };

View File

@@ -0,0 +1,24 @@
function errorHandler(err, req, res, next) {
console.error(`[ERROR] ${err.message}`);
if (process.env.NODE_ENV === 'development') {
console.error(err.stack);
}
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A record with that value already exists.' });
}
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Record not found.' });
}
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
});
}
module.exports = errorHandler;

View File

@@ -0,0 +1,54 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/alerts?state=...&type=...&severity=...
router.get('/', async (req, res, next) => {
try {
const { state, type, severity } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
if (type) {
where.type = type;
}
if (severity) {
where.severity = severity;
}
// Only show active alerts (endsAt is null or in the future)
where.OR = [
{ endsAt: null },
{ endsAt: { gte: new Date() } },
];
const alerts = await prisma.alert.findMany({
where,
include: { state: { select: { name: true, abbr: true } } },
orderBy: [{ severity: 'desc' }, { startsAt: 'desc' }],
});
res.json(alerts);
} catch (err) {
next(err);
}
});
// GET /api/alerts/:id
router.get('/:id', async (req, res, next) => {
try {
const alert = await prisma.alert.findUnique({
where: { id: req.params.id },
include: { state: { select: { name: true, abbr: true } } },
});
if (!alert) return res.status(404).json({ error: 'Alert not found.' });
res.json(alert);
} catch (err) {
next(err);
}
});
module.exports = router;

89
server/src/routes/auth.js Normal file
View File

@@ -0,0 +1,89 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// POST /api/auth/register
router.post('/register', async (req, res, next) => {
try {
const { email, password, name, role } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required.' });
}
const validRoles = ['driver', 'carrier', 'escort'];
const userRole = validRoles.includes(role) ? role : 'driver';
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, passwordHash, name, role: userRole },
});
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, tier: user.tier },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
token,
user: { id: user.id, email: user.email, name: user.name, role: user.role, tier: user.tier },
});
} catch (err) {
next(err);
}
});
// POST /api/auth/login
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required.' });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid email or password.' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Invalid email or password.' });
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, tier: user.tier },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
token,
user: { id: user.id, email: user.email, name: user.name, role: user.role, tier: user.tier },
});
} catch (err) {
next(err);
}
});
// GET /api/auth/me
router.get('/me', requireAuth, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { id: true, email: true, name: true, role: true, tier: true, createdAt: true },
});
if (!user) return res.status(404).json({ error: 'User not found.' });
res.json(user);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,87 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/bridges?lat=...&lng=...&radius=...&maxHeight=...&maxWidth=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, maxHeight, maxWidth, state } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
let bridges = await prisma.bridge.findMany({
where,
include: { state: { select: { name: true, abbr: true } } },
orderBy: { route: 'asc' },
});
// Distance filter
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
bridges = bridges.filter((b) => {
const dist = haversine(centerLat, centerLng, b.lat, b.lng);
b.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
bridges.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
// Dimension conflict detection
if (maxHeight) {
const loadHeight = parseFloat(maxHeight);
bridges = bridges.map((b) => ({
...b,
heightConflict: b.heightClearance < loadHeight,
}));
}
if (maxWidth) {
const loadWidth = parseFloat(maxWidth);
bridges = bridges.map((b) => ({
...b,
widthConflict: b.widthClearance != null && b.widthClearance < loadWidth,
}));
}
res.json(bridges);
} catch (err) {
next(err);
}
});
// GET /api/bridges/:id
router.get('/:id', async (req, res, next) => {
try {
const bridge = await prisma.bridge.findUnique({
where: { id: req.params.id },
include: { state: { select: { name: true, abbr: true } } },
});
if (!bridge) return res.status(404).json({ error: 'Bridge not found.' });
res.json(bridge);
} catch (err) {
next(err);
}
});
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/calendar — all seasonal restrictions
router.get('/', async (req, res, next) => {
try {
const restrictions = await prisma.seasonalRestriction.findMany({
include: { state: { select: { name: true, abbr: true } } },
orderBy: [{ startMonth: 'asc' }, { state: { name: 'asc' } }],
});
res.json(restrictions);
} catch (err) {
next(err);
}
});
// GET /api/calendar/:stateAbbr
router.get('/:stateAbbr', async (req, res, next) => {
try {
const abbr = req.params.stateAbbr.toUpperCase();
const state = await prisma.state.findUnique({
where: { abbr },
include: { seasonalRestrictions: true },
});
if (!state) return res.status(404).json({ error: `State '${abbr}' not found.` });
res.json(state.seasonalRestrictions);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/contacts — all state contacts
router.get('/', async (req, res, next) => {
try {
const contacts = await prisma.contact.findMany({
include: { state: { select: { name: true, abbr: true } } },
orderBy: { state: { name: 'asc' } },
});
res.json(contacts);
} catch (err) {
next(err);
}
});
// GET /api/contacts/:stateAbbr
router.get('/:stateAbbr', async (req, res, next) => {
try {
const abbr = req.params.stateAbbr.toUpperCase();
const state = await prisma.state.findUnique({
where: { abbr },
include: { contact: true },
});
if (!state) return res.status(404).json({ error: `State '${abbr}' not found.` });
res.json(state.contact || { error: 'No contact data for this state.' });
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,74 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/contributions?entityType=...&entityId=...
router.get('/', async (req, res, next) => {
try {
const { entityType, entityId } = req.query;
const where = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
const contributions = await prisma.contribution.findMany({
where,
include: { user: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 50,
});
res.json(contributions);
} catch (err) {
next(err);
}
});
// POST /api/contributions — submit a contribution (auth required)
router.post('/', requireAuth, async (req, res, next) => {
try {
const { entityType, entityId, type, content } = req.body;
const validEntityTypes = ['truck_stop', 'weigh_station', 'bridge', 'regulation'];
const validTypes = ['info', 'flag', 'confirm'];
if (!validEntityTypes.includes(entityType)) {
return res.status(400).json({ error: `entityType must be one of: ${validEntityTypes.join(', ')}` });
}
if (!validTypes.includes(type)) {
return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` });
}
if (!entityId || !content) {
return res.status(400).json({ error: 'entityId and content are required.' });
}
const data = {
userId: req.user.id,
entityType,
entityId,
type,
content,
};
// Link to the specific entity if it exists
if (entityType === 'truck_stop') {
const exists = await prisma.truckStop.findUnique({ where: { id: entityId } });
if (exists) data.truckStopId = entityId;
} else if (entityType === 'weigh_station') {
const exists = await prisma.weighStation.findUnique({ where: { id: entityId } });
if (exists) data.weighStationId = entityId;
}
const contribution = await prisma.contribution.create({
data,
include: { user: { select: { name: true } } },
});
res.status(201).json(contribution);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,119 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '..', '..', 'uploads', req.user.id);
fs.mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueName = `${Date.now()}-${file.originalname}`;
cb(null, uniqueName);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const allowed = ['.pdf', '.jpg', '.jpeg', '.png', '.doc', '.docx'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowed.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`File type ${ext} not allowed. Accepted: ${allowed.join(', ')}`));
}
},
});
// GET /api/documents — list own documents
router.get('/', requireAuth, async (req, res, next) => {
try {
const documents = await prisma.document.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
res.json(documents);
} catch (err) {
next(err);
}
});
// POST /api/documents — upload a document
router.post('/', requireAuth, upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded.' });
}
const { type, expiresAt } = req.body;
const validTypes = ['permit', 'insurance', 'certification', 'license', 'other'];
const docType = validTypes.includes(type) ? type : 'other';
const document = await prisma.document.create({
data: {
userId: req.user.id,
type: docType,
filename: req.file.originalname,
filepath: req.file.path,
mimeType: req.file.mimetype,
sizeBytes: req.file.size,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
});
res.status(201).json(document);
} catch (err) {
next(err);
}
});
// GET /api/documents/:id/download — download a document
router.get('/:id/download', requireAuth, async (req, res, next) => {
try {
const document = await prisma.document.findUnique({
where: { id: req.params.id },
});
if (!document) return res.status(404).json({ error: 'Document not found.' });
if (document.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
res.download(document.filepath, document.filename);
} catch (err) {
next(err);
}
});
// DELETE /api/documents/:id
router.delete('/:id', requireAuth, async (req, res, next) => {
try {
const document = await prisma.document.findUnique({
where: { id: req.params.id },
});
if (!document) return res.status(404).json({ error: 'Document not found.' });
if (document.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
// Delete file from disk
if (fs.existsSync(document.filepath)) {
fs.unlinkSync(document.filepath);
}
await prisma.document.delete({ where: { id: req.params.id } });
res.json({ message: 'Document deleted.' });
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,119 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/loads — list loads with search/filter
router.get('/', async (req, res, next) => {
try {
const { origin, destination, minDate, maxDate, status, page = 1, limit = 20 } = req.query;
const where = {};
if (origin) where.origin = { contains: origin, mode: 'insensitive' };
if (destination) where.destination = { contains: destination, mode: 'insensitive' };
if (status) where.status = status;
if (minDate || maxDate) {
where.pickupDate = {};
if (minDate) where.pickupDate.gte = new Date(minDate);
if (maxDate) where.pickupDate.lte = new Date(maxDate);
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [loads, total] = await Promise.all([
prisma.load.findMany({
where,
include: { poster: { select: { name: true, role: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: parseInt(limit),
}),
prisma.load.count({ where }),
]);
res.json({ loads, total, page: parseInt(page), totalPages: Math.ceil(total / parseInt(limit)) });
} catch (err) {
next(err);
}
});
// GET /api/loads/:id
router.get('/:id', async (req, res, next) => {
try {
const load = await prisma.load.findUnique({
where: { id: req.params.id },
include: { poster: { select: { name: true, role: true } } },
});
if (!load) return res.status(404).json({ error: 'Load not found.' });
res.json(load);
} catch (err) {
next(err);
}
});
// POST /api/loads — create a load posting (auth required)
router.post('/', requireAuth, async (req, res, next) => {
try {
const { origin, destination, pickupDate, width, height, length, weight, description, escortsNeeded } = req.body;
if (!origin || !destination || !pickupDate) {
return res.status(400).json({ error: 'Origin, destination, and pickup date are required.' });
}
const load = await prisma.load.create({
data: {
posterId: req.user.id,
origin,
destination,
pickupDate: new Date(pickupDate),
width: width || '',
height: height || '',
length: length || '',
weight: weight || '',
description: description || '',
escortsNeeded: parseInt(escortsNeeded) || 1,
},
});
res.status(201).json(load);
} catch (err) {
next(err);
}
});
// PUT /api/loads/:id — update own load
router.put('/:id', requireAuth, async (req, res, next) => {
try {
const existing = await prisma.load.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ error: 'Load not found.' });
if (existing.posterId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'You can only edit your own loads.' });
}
const load = await prisma.load.update({
where: { id: req.params.id },
data: req.body,
});
res.json(load);
} catch (err) {
next(err);
}
});
// DELETE /api/loads/:id
router.delete('/:id', requireAuth, async (req, res, next) => {
try {
const existing = await prisma.load.findUnique({ where: { id: req.params.id } });
if (!existing) return res.status(404).json({ error: 'Load not found.' });
if (existing.posterId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'You can only delete your own loads.' });
}
await prisma.load.delete({ where: { id: req.params.id } });
res.json({ message: 'Load deleted.' });
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,111 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/escorts?lat=...&lng=...&radius=...&availability=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, availability } = req.query;
const where = {};
if (availability) {
where.availability = availability;
}
let escorts = await prisma.escortProfile.findMany({
where,
include: { user: { select: { name: true, email: true } } },
orderBy: { rating: 'desc' },
});
// Distance filter
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
escorts = escorts.filter((e) => {
const dist = haversine(centerLat, centerLng, e.lat, e.lng);
e.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
escorts.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
res.json(escorts);
} catch (err) {
next(err);
}
});
// GET /api/escorts/:id
router.get('/:id', async (req, res, next) => {
try {
const profile = await prisma.escortProfile.findUnique({
where: { id: req.params.id },
include: { user: { select: { name: true, email: true } } },
});
if (!profile) return res.status(404).json({ error: 'Escort profile not found.' });
res.json(profile);
} catch (err) {
next(err);
}
});
// POST /api/escorts/profile — create or update own profile (auth required)
router.post('/profile', requireAuth, async (req, res, next) => {
try {
const { lat, lng, radiusMiles, certifications, vehicleType, availability, phone, bio } = req.body;
if (!lat || !lng) {
return res.status(400).json({ error: 'Location (lat, lng) is required.' });
}
const profile = await prisma.escortProfile.upsert({
where: { userId: req.user.id },
update: {
lat: parseFloat(lat),
lng: parseFloat(lng),
radiusMiles: parseInt(radiusMiles) || 100,
certifications: certifications || [],
vehicleType: vehicleType || '',
availability: availability || 'available',
phone: phone || '',
bio: bio || '',
},
create: {
userId: req.user.id,
lat: parseFloat(lat),
lng: parseFloat(lng),
radiusMiles: parseInt(radiusMiles) || 100,
certifications: certifications || [],
vehicleType: vehicleType || '',
availability: availability || 'available',
phone: phone || '',
bio: bio || '',
},
});
res.json(profile);
} catch (err) {
next(err);
}
});
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

View File

@@ -0,0 +1,92 @@
const express = require('express');
const prisma = require('../config/db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// GET /api/orders — list own orders
router.get('/', requireAuth, async (req, res, next) => {
try {
const orders = await prisma.order.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
res.json(orders);
} catch (err) {
next(err);
}
});
// GET /api/orders/:id
router.get('/:id', requireAuth, async (req, res, next) => {
try {
const order = await prisma.order.findUnique({
where: { id: req.params.id },
});
if (!order) return res.status(404).json({ error: 'Order not found.' });
if (order.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
res.json(order);
} catch (err) {
next(err);
}
});
// POST /api/orders — submit escort service request
router.post('/', requireAuth, async (req, res, next) => {
try {
const { origin, destination, pickupDate, width, height, length, weight, loadType, escortsNeeded, notes } = req.body;
if (!origin || !destination || !pickupDate) {
return res.status(400).json({ error: 'Origin, destination, and pickup date are required.' });
}
const order = await prisma.order.create({
data: {
userId: req.user.id,
origin,
destination,
pickupDate: new Date(pickupDate),
width: width || '',
height: height || '',
length: length || '',
weight: weight || '',
loadType: loadType || '',
escortsNeeded: parseInt(escortsNeeded) || 1,
notes: notes || '',
},
});
res.status(201).json(order);
} catch (err) {
next(err);
}
});
// PUT /api/orders/:id/status — update order status (admin or owner)
router.put('/:id/status', requireAuth, async (req, res, next) => {
try {
const { status } = req.body;
const validStatuses = ['pending', 'confirmed', 'in_progress', 'completed', 'cancelled'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: `Status must be one of: ${validStatuses.join(', ')}` });
}
const order = await prisma.order.findUnique({ where: { id: req.params.id } });
if (!order) return res.status(404).json({ error: 'Order not found.' });
if (order.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied.' });
}
const updated = await prisma.order.update({
where: { id: req.params.id },
data: { status },
});
res.json(updated);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,38 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/regulations — list all states with regulation data
router.get('/', async (req, res, next) => {
try {
const states = await prisma.state.findMany({
include: { regulation: true },
orderBy: { name: 'asc' },
});
res.json(states);
} catch (err) {
next(err);
}
});
// GET /api/regulations/:stateAbbr — single state with regulation + equipment
router.get('/:stateAbbr', async (req, res, next) => {
try {
const abbr = req.params.stateAbbr.toUpperCase();
const state = await prisma.state.findUnique({
where: { abbr },
include: {
regulation: true,
equipmentRequirements: true,
},
});
if (!state) return res.status(404).json({ error: `State '${abbr}' not found.` });
res.json(state);
} catch (err) {
next(err);
}
});
module.exports = router;

View File

@@ -0,0 +1,86 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/truckstops?lat=...&lng=...&radius=...&state=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, state } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
let truckStops = await prisma.truckStop.findMany({
where,
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
take: 5,
select: { id: true, type: true, content: true, createdAt: true },
},
},
orderBy: { name: 'asc' },
});
// Client-side distance filter (Haversine) when lat/lng/radius provided
// For production, replace with PostGIS ST_DWithin
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
truckStops = truckStops.filter((ts) => {
const dist = haversine(centerLat, centerLng, ts.lat, ts.lng);
ts.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
truckStops.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
res.json(truckStops);
} catch (err) {
next(err);
}
});
// GET /api/truckstops/:id
router.get('/:id', async (req, res, next) => {
try {
const truckStop = await prisma.truckStop.findUnique({
where: { id: req.params.id },
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
include: { user: { select: { name: true } } },
},
},
});
if (!truckStop) return res.status(404).json({ error: 'Truck stop not found.' });
res.json(truckStop);
} catch (err) {
next(err);
}
});
// Haversine distance in miles
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959; // Earth radius in miles
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

View File

@@ -0,0 +1,86 @@
const express = require('express');
const prisma = require('../config/db');
const router = express.Router();
// GET /api/weighstations?lat=...&lng=...&radius=...&state=...&status=...
router.get('/', async (req, res, next) => {
try {
const { lat, lng, radius, state, status } = req.query;
const where = {};
if (state) {
where.state = { abbr: state.toUpperCase() };
}
if (status) {
where.currentStatus = status;
}
let stations = await prisma.weighStation.findMany({
where,
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
take: 3,
select: { id: true, type: true, content: true, createdAt: true },
},
},
orderBy: { name: 'asc' },
});
// Distance filter
if (lat && lng && radius) {
const centerLat = parseFloat(lat);
const centerLng = parseFloat(lng);
const maxMiles = parseFloat(radius);
stations = stations.filter((ws) => {
const dist = haversine(centerLat, centerLng, ws.lat, ws.lng);
ws.distanceMiles = Math.round(dist * 10) / 10;
return dist <= maxMiles;
});
stations.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
res.json(stations);
} catch (err) {
next(err);
}
});
// GET /api/weighstations/:id
router.get('/:id', async (req, res, next) => {
try {
const station = await prisma.weighStation.findUnique({
where: { id: req.params.id },
include: {
state: { select: { name: true, abbr: true } },
contributions: {
orderBy: { createdAt: 'desc' },
include: { user: { select: { name: true } } },
},
},
});
if (!station) return res.status(404).json({ error: 'Weigh station not found.' });
res.json(station);
} catch (err) {
next(err);
}
});
function haversine(lat1, lng1, lat2, lng2) {
const R = 3959;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRad(deg) {
return (deg * Math.PI) / 180;
}
module.exports = router;

346
server/src/seeds/seed.js Normal file
View File

@@ -0,0 +1,346 @@
// =====================================================================
// Database Seed Script
// Reads mock data from the existing frontend JS files and inserts
// into PostgreSQL via Prisma.
// Run with: npm run db:seed
// =====================================================================
const fs = require('fs');
const path = require('path');
const prisma = require('../config/db');
// Load the mock data files by evaluating them in a controlled context
function loadMockData(filename) {
const filepath = path.join(__dirname, '..', '..', '..', 'public', 'js', filename);
const code = fs.readFileSync(filepath, 'utf-8');
const context = {};
// Execute the JS to populate the const variables
const fn = new Function(code + '\nreturn { ' +
'MOCK_STATE_REGULATIONS: typeof MOCK_STATE_REGULATIONS !== "undefined" ? MOCK_STATE_REGULATIONS : undefined,' +
'MOCK_LOAD_BOARD: typeof MOCK_LOAD_BOARD !== "undefined" ? MOCK_LOAD_BOARD : undefined,' +
'MOCK_ESCORT_OPERATORS: typeof MOCK_ESCORT_OPERATORS !== "undefined" ? MOCK_ESCORT_OPERATORS : undefined,' +
'MOCK_STATE_CONTACTS: typeof MOCK_STATE_CONTACTS !== "undefined" ? MOCK_STATE_CONTACTS : undefined,' +
'MOCK_STATE_EQUIPMENT: typeof MOCK_STATE_EQUIPMENT !== "undefined" ? MOCK_STATE_EQUIPMENT : undefined,' +
'MOCK_TRUCK_STOPS: typeof MOCK_TRUCK_STOPS !== "undefined" ? MOCK_TRUCK_STOPS : undefined,' +
'MOCK_BRIDGE_CLEARANCES: typeof MOCK_BRIDGE_CLEARANCES !== "undefined" ? MOCK_BRIDGE_CLEARANCES : undefined,' +
'MOCK_WEIGH_STATIONS: typeof MOCK_WEIGH_STATIONS !== "undefined" ? MOCK_WEIGH_STATIONS : undefined,' +
'MOCK_ROUTE_CONDITIONS: typeof MOCK_ROUTE_CONDITIONS !== "undefined" ? MOCK_ROUTE_CONDITIONS : undefined,' +
'MOCK_WEATHER_ALERTS: typeof MOCK_WEATHER_ALERTS !== "undefined" ? MOCK_WEATHER_ALERTS : undefined,' +
'MOCK_SEASONAL_RESTRICTIONS: typeof MOCK_SEASONAL_RESTRICTIONS !== "undefined" ? MOCK_SEASONAL_RESTRICTIONS : undefined,' +
'MOCK_DOCUMENTS: typeof MOCK_DOCUMENTS !== "undefined" ? MOCK_DOCUMENTS : undefined' +
' };');
return fn();
}
async function seed() {
console.log('🌱 Seeding database...\n');
// Load mock data from both files
const data1 = loadMockData('mock-data.js');
const data2 = loadMockData('mock-data-extended.js');
// Clear existing data in reverse dependency order
console.log(' 🗑️ Clearing existing data...');
await prisma.contribution.deleteMany();
await prisma.document.deleteMany();
await prisma.order.deleteMany();
await prisma.load.deleteMany();
await prisma.escortProfile.deleteMany();
await prisma.user.deleteMany();
await prisma.alert.deleteMany();
await prisma.weighStation.deleteMany();
await prisma.bridge.deleteMany();
await prisma.truckStop.deleteMany();
await prisma.seasonalRestriction.deleteMany();
await prisma.contact.deleteMany();
await prisma.equipmentRequirement.deleteMany();
await prisma.regulation.deleteMany();
await prisma.state.deleteMany();
// ---------------------------------------------------------------
// 1. States + Regulations (from MOCK_STATE_REGULATIONS)
// ---------------------------------------------------------------
console.log(' 📍 Seeding states and regulations...');
const stateMap = {}; // abbr -> state.id
for (const reg of data1.MOCK_STATE_REGULATIONS) {
const state = await prisma.state.create({
data: {
name: reg.name,
abbr: reg.abbr,
lat: reg.lat,
lng: reg.lng,
regulation: {
create: {
permitWidth: reg.permitWidth,
permitHeight: reg.permitHeight,
permitLength: reg.permitLength,
permitWeight: reg.permitWeight,
escortWidth: reg.escortWidth,
escortHeight: reg.escortHeight,
escortLength: reg.escortLength,
escortWeight: reg.escortWeight,
travelRestrictions: reg.travel,
holidays: reg.holidays,
agency: reg.agency,
url: reg.url,
notes: reg.notes,
},
},
},
});
stateMap[reg.abbr] = state.id;
}
console.log(`${Object.keys(stateMap).length} states with regulations`);
// ---------------------------------------------------------------
// 2. Contacts (from MOCK_STATE_CONTACTS)
// ---------------------------------------------------------------
console.log(' 📞 Seeding contacts...');
let contactCount = 0;
if (data2.MOCK_STATE_CONTACTS) {
for (const [abbr, contact] of Object.entries(data2.MOCK_STATE_CONTACTS)) {
if (!stateMap[abbr]) continue;
await prisma.contact.create({
data: {
stateId: stateMap[abbr],
permitPhone: contact.permit,
policePhone: contact.police,
email: contact.email,
hours: contact.hours,
portalUrl: contact.portal,
},
});
contactCount++;
}
}
console.log(`${contactCount} state contacts`);
// ---------------------------------------------------------------
// 3. Equipment Requirements (from MOCK_STATE_EQUIPMENT)
// ---------------------------------------------------------------
console.log(' 🔧 Seeding equipment requirements...');
let equipCount = 0;
if (data2.MOCK_STATE_EQUIPMENT) {
for (const [abbr, equip] of Object.entries(data2.MOCK_STATE_EQUIPMENT)) {
if (!stateMap[abbr]) continue;
if (equip.escort) {
await prisma.equipmentRequirement.create({
data: {
stateId: stateMap[abbr],
type: 'escort',
certification: equip.escort.certification || '',
vehicle: equip.escort.vehicle || '',
signs: equip.escort.signs || '',
lights: equip.escort.lights || '',
heightPole: equip.escort.heightPole || '',
flags: equip.escort.flags || '',
safetyGear: equip.escort.safety || '',
communication: equip.escort.communication || '',
},
});
equipCount++;
}
if (equip.carrier) {
await prisma.equipmentRequirement.create({
data: {
stateId: stateMap[abbr],
type: 'carrier',
signs: equip.carrier.signs || '',
flags: equip.carrier.flags || '',
lights: equip.carrier.lights || '',
safetyGear: [
equip.carrier.cones ? `Cones: ${equip.carrier.cones}` : '',
equip.carrier.fireExtinguisher ? `Fire ext: ${equip.carrier.fireExtinguisher}` : '',
equip.carrier.triangles ? `Triangles: ${equip.carrier.triangles}` : '',
equip.carrier.flares ? `Flares: ${equip.carrier.flares}` : '',
equip.carrier.firstAid ? `First aid: ${equip.carrier.firstAid}` : '',
].filter(Boolean).join('; '),
},
});
equipCount++;
}
}
}
console.log(`${equipCount} equipment requirements`);
// ---------------------------------------------------------------
// 4. Truck Stops (from MOCK_TRUCK_STOPS)
// ---------------------------------------------------------------
console.log(' ⛽ Seeding truck stops...');
let tsCount = 0;
if (data2.MOCK_TRUCK_STOPS) {
for (const ts of data2.MOCK_TRUCK_STOPS) {
const stateAbbr = ts.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.truckStop.create({
data: {
stateId,
name: ts.name,
lat: ts.location.lat,
lng: ts.location.lng,
address: `${ts.location.city}, ${ts.location.state}`,
hasOversizeParking: ts.oversizeFriendly || false,
entranceHeight: ts.entranceHeight || '',
entranceWidth: ts.entranceWidth || '',
lotSqFt: ts.lotSize ? parseInt(String(ts.lotSize).replace(/[^0-9]/g, '')) || null : null,
facilities: ts.facilities || [],
phone: '',
},
});
tsCount++;
}
}
console.log(`${tsCount} truck stops`);
// ---------------------------------------------------------------
// 5. Bridge Clearances (from MOCK_BRIDGE_CLEARANCES)
// ---------------------------------------------------------------
console.log(' 🌉 Seeding bridges...');
let bridgeCount = 0;
if (data2.MOCK_BRIDGE_CLEARANCES) {
for (const b of data2.MOCK_BRIDGE_CLEARANCES) {
const stateAbbr = b.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.bridge.create({
data: {
stateId,
name: `${b.type || 'Bridge'} at ${b.location.desc || b.route}`,
lat: b.location.lat,
lng: b.location.lng,
route: b.route,
heightClearance: parseFloat(String(b.clearanceHeight).replace(/[^0-9.]/g, '')) || 0,
widthClearance: b.clearanceWidth ? parseFloat(String(b.clearanceWidth).replace(/[^0-9.]/g, '')) : null,
weightLimit: b.weightLimit ? parseFloat(String(b.weightLimit).replace(/[^0-9.]/g, '')) : null,
},
});
bridgeCount++;
}
}
console.log(`${bridgeCount} bridges`);
// ---------------------------------------------------------------
// 6. Weigh Stations (from MOCK_WEIGH_STATIONS)
// ---------------------------------------------------------------
console.log(' ⚖️ Seeding weigh stations...');
let wsCount = 0;
if (data2.MOCK_WEIGH_STATIONS) {
for (const ws of data2.MOCK_WEIGH_STATIONS) {
const stateAbbr = ws.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.weighStation.create({
data: {
stateId,
name: ws.name,
lat: ws.location.lat,
lng: ws.location.lng,
direction: ws.direction || '',
route: ws.route || '',
prePass: ws.prePass || false,
hours: ws.hours || '',
currentStatus: ws.currentStatus || 'unknown',
},
});
wsCount++;
}
}
console.log(`${wsCount} weigh stations`);
// ---------------------------------------------------------------
// 7. Alerts — Route Conditions + Weather (from MOCK_ROUTE_CONDITIONS + MOCK_WEATHER_ALERTS)
// ---------------------------------------------------------------
console.log(' 🚨 Seeding alerts...');
let alertCount = 0;
if (data2.MOCK_ROUTE_CONDITIONS) {
for (const rc of data2.MOCK_ROUTE_CONDITIONS) {
const stateAbbr = rc.location?.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.alert.create({
data: {
stateId,
type: rc.type || 'construction',
route: rc.route || '',
description: rc.description || '',
severity: rc.severity || 'info',
startsAt: rc.startDate ? new Date(rc.startDate) : new Date(),
endsAt: rc.endDate ? new Date(rc.endDate) : null,
},
});
alertCount++;
}
}
if (data2.MOCK_WEATHER_ALERTS) {
for (const wa of data2.MOCK_WEATHER_ALERTS) {
// Weather alerts may not have a state directly, try to match from region
const stateAbbr = wa.state || (wa.routes?.[0] ? 'TX' : null); // fallback
const stateId = stateAbbr ? stateMap[stateAbbr] : Object.values(stateMap)[0];
if (!stateId) continue;
await prisma.alert.create({
data: {
stateId,
type: wa.type || 'weather',
route: (wa.routes || []).join(', '),
description: wa.description || '',
severity: wa.severity || 'info',
startsAt: wa.validFrom ? new Date(wa.validFrom) : new Date(),
endsAt: wa.validTo ? new Date(wa.validTo) : null,
},
});
alertCount++;
}
}
console.log(`${alertCount} alerts`);
// ---------------------------------------------------------------
// 8. Seasonal Restrictions (from MOCK_SEASONAL_RESTRICTIONS)
// ---------------------------------------------------------------
console.log(' 📅 Seeding seasonal restrictions...');
let seasonCount = 0;
if (data2.MOCK_SEASONAL_RESTRICTIONS) {
for (const sr of data2.MOCK_SEASONAL_RESTRICTIONS) {
const stateAbbr = sr.state;
const stateId = stateMap[stateAbbr];
if (!stateId) continue;
await prisma.seasonalRestriction.create({
data: {
stateId,
name: sr.title || sr.type,
type: sr.type || 'other',
startMonth: sr.startMonth || 1,
endMonth: sr.endMonth || 12,
description: sr.description || '',
},
});
seasonCount++;
}
}
console.log(`${seasonCount} seasonal restrictions`);
// ---------------------------------------------------------------
// Summary
// ---------------------------------------------------------------
console.log('\n🎉 Seed complete!\n');
}
seed()
.catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});