Compare commits
2 Commits
260f7c4928
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93efb907ff | ||
|
|
f917fb8014 |
227
README.md
227
README.md
@@ -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.**
|
> ⚠️ **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
|
## Prerequisites
|
||||||
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)
|
- [Node.js](https://nodejs.org/) v18+ (LTS recommended)
|
||||||
3. Navigate between pages using the top navigation bar
|
- [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
|
## Pages
|
||||||
|
|
||||||
### Core
|
### Core
|
||||||
| Page | File | Description |
|
| Page | URL | Description |
|
||||||
|------|------|-------------|
|
|------|-----|-------------|
|
||||||
| **Home** | `index.html` | Platform overview and all feature modules |
|
| **Home** | `/` | Platform overview and all feature modules |
|
||||||
| **Regulations Map** | `regulations.html` | Interactive US map + equipment requirements by state |
|
| **Regulations Map** | `/pages/regulations.html` | Interactive US map + equipment requirements by state |
|
||||||
| **Request Service** | `order.html` | Escort vehicle service request form |
|
| **Request Service** | `/pages/order.html` | Escort vehicle service request form |
|
||||||
|
|
||||||
### Road Intelligence
|
### Road Intelligence
|
||||||
| Page | File | Description |
|
| Page | URL | Description |
|
||||||
|------|------|-------------|
|
|------|-----|-------------|
|
||||||
| **Truck Stops** | `truckstops.html` | Oversize-friendly parking with user comments |
|
| **Truck Stops** | `/pages/truckstops.html` | Oversize-friendly parking with user comments |
|
||||||
| **Bridge Clearances** | `bridges.html` | Height/width/weight restrictions for bridges |
|
| **Bridge Clearances** | `/pages/bridges.html` | Height/width/weight restrictions for bridges |
|
||||||
| **Weigh Stations** | `weighstations.html` | Crowd-sourced open/closed status |
|
| **Weigh Stations** | `/pages/weighstations.html` | Crowd-sourced open/closed status |
|
||||||
| **Route & Weather** | `alerts.html` | Construction, closures, and wind/weather alerts |
|
| **Route & Weather** | `/pages/alerts.html` | Construction, closures, and wind/weather alerts |
|
||||||
|
|
||||||
### Services & Resources
|
### Services & Resources
|
||||||
| Page | File | Description |
|
| Page | URL | Description |
|
||||||
|------|------|-------------|
|
|------|-----|-------------|
|
||||||
| **Load Board** | `loadboard.html` | Oversize load listings needing escort services |
|
| **Load Board** | `/pages/loadboard.html` | Oversize load listings needing escort services |
|
||||||
| **Find Escorts** | `locator.html` | Map of available escort vehicle operators |
|
| **Find Escorts** | `/pages/locator.html` | Map of available escort vehicle operators |
|
||||||
| **DOT Contacts** | `contacts.html` | State permit office phone/email directory |
|
| **DOT Contacts** | `/pages/contacts.html` | State permit office phone/email directory |
|
||||||
| **Seasonal Calendar** | `calendar.html` | Seasonal restrictions and closure calendar |
|
| **Seasonal Calendar** | `/pages/calendar.html` | Seasonal restrictions and closure calendar |
|
||||||
| **Document Vault** | `documents.html` | Store permits, insurance, certifications |
|
| **Document Vault** | `/pages/documents.html` | Store permits, insurance, certifications |
|
||||||
|
|
||||||
## Tech Stack (POC)
|
## API Endpoints
|
||||||
- HTML / CSS / JavaScript (no build step required)
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/) via Play CDN
|
All endpoints are prefixed with `/api`. Public endpoints require no authentication; protected endpoints require a `Bearer` token in the `Authorization` header.
|
||||||
- [Leaflet.js](https://leafletjs.com/) for interactive maps via CDN
|
|
||||||
- Mock data (hardcoded — not yet connected to APIs or databases)
|
### 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
|
## Next Steps
|
||||||
- Replace mock data with real, verified state regulation data
|
- Replace demo data with real, verified state regulation data
|
||||||
- Connect to backend APIs and a database
|
|
||||||
- Add user accounts, authentication, and profiles
|
|
||||||
- Integrate payment processing for subscriptions
|
- 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.)
|
||||||
|
|||||||
108
contacts.html
108
contacts.html
@@ -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>
|
|
||||||
259
documents.html
259
documents.html
@@ -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">×</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 & 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>
|
|
||||||
@@ -42,10 +42,10 @@
|
|||||||
everything truck drivers and carriers need, all in one place.
|
everything truck drivers and carriers need, all in one place.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<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
|
Explore Regulations Map
|
||||||
</a>
|
</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
|
Request Escort Service
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,19 +84,19 @@
|
|||||||
<!-- Row 1: Core Features -->
|
<!-- Row 1: Core Features -->
|
||||||
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Core Tools</h3>
|
<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">
|
<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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">Explore Map →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">Request Service →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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 -->
|
<!-- Row 2: Road Intelligence -->
|
||||||
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Road Intelligence</h3>
|
<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">
|
<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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">Find Stops →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">Check Clearances →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">View Stations →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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 -->
|
<!-- Row 3: Resources & Services -->
|
||||||
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Resources & Services</h3>
|
<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">
|
<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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">Find Escorts →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">View Directory →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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>
|
<span class="text-amber-600 font-semibold text-sm">View Calendar →</span>
|
||||||
</a>
|
</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>
|
<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>
|
<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>
|
<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.
|
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.
|
Tell us about your load and we'll handle the rest.
|
||||||
</p>
|
</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 →
|
Request Escort Service →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('home');
|
renderNav('home');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
448
public/js/api.js
Normal file
448
public/js/api.js
Normal 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]}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// =============================================
|
// =============================================
|
||||||
// Shared Navigation, Banner, and Footer
|
// 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) {
|
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">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-16">
|
<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-2xl">🚛</span>
|
||||||
<span class="text-xl font-bold text-amber-400 tracking-tight">PilotEdge</span>
|
<span class="text-xl font-bold text-amber-400 tracking-tight">PilotEdge</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Nav -->
|
<!-- Desktop Nav -->
|
||||||
<div class="hidden lg:flex items-center space-x-1">
|
<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 -->
|
<!-- Regulations Dropdown -->
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
@@ -35,19 +35,19 @@ function renderNav(activePage) {
|
|||||||
</button>
|
</button>
|
||||||
<div class="absolute left-0 top-full pt-1 hidden group-hover:block" style="min-width:220px;">
|
<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">
|
<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="font-medium">State Regulations Map</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Permits & escort thresholds</div>
|
<div class="text-xs text-slate-400 mt-0.5">Permits & escort thresholds</div>
|
||||||
</a>
|
</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="font-medium">Equipment Requirements</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Escort & carrier gear by state</div>
|
<div class="text-xs text-slate-400 mt-0.5">Escort & carrier gear by state</div>
|
||||||
</a>
|
</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="font-medium">DOT Contact Directory</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Permit office phone & email</div>
|
<div class="text-xs text-slate-400 mt-0.5">Permit office phone & email</div>
|
||||||
</a>
|
</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="font-medium">Seasonal Calendar</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Restrictions & closures</div>
|
<div class="text-xs text-slate-400 mt-0.5">Restrictions & closures</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -63,19 +63,19 @@ function renderNav(activePage) {
|
|||||||
</button>
|
</button>
|
||||||
<div class="absolute left-0 top-full pt-1 hidden group-hover:block" style="min-width:220px;">
|
<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">
|
<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="font-medium">Truck Stops & Parking</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Oversize-friendly locations</div>
|
<div class="text-xs text-slate-400 mt-0.5">Oversize-friendly locations</div>
|
||||||
</a>
|
</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="font-medium">Bridge Clearances</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Height & width restrictions</div>
|
<div class="text-xs text-slate-400 mt-0.5">Height & width restrictions</div>
|
||||||
</a>
|
</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="font-medium">Weigh Stations</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Live open/closed status</div>
|
<div class="text-xs text-slate-400 mt-0.5">Live open/closed status</div>
|
||||||
</a>
|
</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="font-medium">Route & Weather Alerts</div>
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Closures, construction, wind</div>
|
<div class="text-xs text-slate-400 mt-0.5">Closures, construction, wind</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -83,11 +83,11 @@ function renderNav(activePage) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="loadboard.html" class="${linkClass('loadboard')} px-3 py-2 rounded-md text-sm transition-colors">Load Board</a>
|
<a href="/pages/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="/pages/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/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
|
Request Service
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,26 +103,26 @@ function renderNav(activePage) {
|
|||||||
|
|
||||||
<!-- Mobile Menu -->
|
<!-- 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">
|
<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>
|
<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="/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="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/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="/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="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/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>
|
<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="/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="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/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="/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="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/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>
|
<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="/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="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/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/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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
`;
|
`;
|
||||||
@@ -160,27 +160,27 @@ function renderFooter() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 class="text-white font-semibold mb-3">Regulations</h4>
|
<h4 class="text-white font-semibold mb-3">Regulations</h4>
|
||||||
<ul class="space-y-2 text-sm">
|
<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="/pages/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="/pages/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/calendar.html" class="hover:text-white transition-colors">Seasonal Calendar</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-white font-semibold mb-3">Road Intel</h4>
|
<h4 class="text-white font-semibold mb-3">Road Intel</h4>
|
||||||
<ul class="space-y-2 text-sm">
|
<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="/pages/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="/pages/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="/pages/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/alerts.html" class="hover:text-white transition-colors">Route & Weather Alerts</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-white font-semibold mb-3">Services</h4>
|
<h4 class="text-white font-semibold mb-3">Services</h4>
|
||||||
<ul class="space-y-2 text-sm">
|
<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="/pages/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="/pages/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="/pages/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/order.html" class="hover:text-white transition-colors">Request Escort Service</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,14 +119,18 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="nav.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
renderNav('alerts');
|
renderNav('alerts');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const alertData = await PilotEdge.getAlerts();
|
||||||
|
const MOCK_ROUTE_CONDITIONS = alertData.routeConditions;
|
||||||
|
const MOCK_WEATHER_ALERTS = alertData.weatherAlerts;
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
let activeTab = 'all';
|
let activeTab = 'all';
|
||||||
let map, markersLayer;
|
let map, markersLayer;
|
||||||
@@ -413,6 +417,7 @@
|
|||||||
document.getElementById('filter-type').addEventListener('change', renderAll);
|
document.getElementById('filter-type').addEventListener('change', renderAll);
|
||||||
document.getElementById('filter-severity').addEventListener('change', renderAll);
|
document.getElementById('filter-severity').addEventListener('change', renderAll);
|
||||||
document.getElementById('filter-oversize').addEventListener('change', renderAll);
|
document.getElementById('filter-oversize').addEventListener('change', renderAll);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -112,14 +112,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="nav.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
renderNav('bridges');
|
renderNav('bridges');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_BRIDGE_CLEARANCES = await PilotEdge.getBridges();
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
// Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet.
|
// Parse a clearance string like '13\'6"' or '14\'0"' into decimal feet.
|
||||||
@@ -380,6 +382,7 @@
|
|||||||
|
|
||||||
// --- Initial render ---
|
// --- Initial render ---
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -85,14 +85,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="nav.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
renderNav('calendar');
|
renderNav('calendar');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_SEASONAL_RESTRICTIONS = await PilotEdge.getSeasonalRestrictions();
|
||||||
|
|
||||||
// ---- Constants ----
|
// ---- Constants ----
|
||||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
@@ -286,6 +288,7 @@
|
|||||||
|
|
||||||
// ---- Initial render ----
|
// ---- Initial render ----
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
111
public/pages/contacts.html
Normal file
111
public/pages/contacts.html
Normal 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
262
public/pages/documents.html
Normal 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">×</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 & 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>
|
||||||
@@ -141,13 +141,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('loadboard');
|
renderNav('loadboard');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_LOAD_BOARD = await PilotEdge.getLoads();
|
||||||
|
|
||||||
function getStatusBadge(status) {
|
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 === '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>';
|
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) {
|
document.getElementById('post-modal').addEventListener('click', function(e) {
|
||||||
if (e.target === this) closePostModal();
|
if (e.target === this) closePostModal();
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -115,13 +115,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('locator');
|
renderNav('locator');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_ESCORT_OPERATORS = await PilotEdge.getEscortOperators();
|
||||||
|
|
||||||
// Populate certification filter
|
// Populate certification filter
|
||||||
const allCerts = new Set();
|
const allCerts = new Set();
|
||||||
MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c)));
|
MOCK_ESCORT_OPERATORS.forEach(op => op.certifications.forEach(c => allCerts.add(c)));
|
||||||
@@ -296,6 +299,7 @@
|
|||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
filterOperators();
|
filterOperators();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
renderNav('order');
|
renderNav('order');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
@@ -139,14 +139,17 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="nav.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
renderNav('regulations');
|
renderNav('regulations');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_STATE_REGULATIONS = await PilotEdge.getRegulations();
|
||||||
|
const MOCK_STATE_EQUIPMENT = await PilotEdge.getEquipment();
|
||||||
|
|
||||||
// Initialize map centered on continental US
|
// Initialize map centered on continental US
|
||||||
const map = L.map('map').setView([39.5, -98.5], 4);
|
const map = L.map('map').setView([39.5, -98.5], 4);
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
@@ -435,6 +438,7 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -65,14 +65,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="nav.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
renderNav('truckstops');
|
renderNav('truckstops');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_TRUCK_STOPS = await PilotEdge.getTruckStops();
|
||||||
|
|
||||||
// Facility emoji mapping
|
// Facility emoji mapping
|
||||||
const facilityIcons = {
|
const facilityIcons = {
|
||||||
fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿',
|
fuel: '⛽', food: '🍔', restrooms: '🚻', showers: '🚿',
|
||||||
@@ -235,6 +237,7 @@
|
|||||||
// Initialize
|
// Initialize
|
||||||
initMap();
|
initMap();
|
||||||
renderStops(MOCK_TRUCK_STOPS);
|
renderStops(MOCK_TRUCK_STOPS);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
@@ -90,14 +90,16 @@
|
|||||||
|
|
||||||
<div id="main-footer"></div>
|
<div id="main-footer"></div>
|
||||||
|
|
||||||
<script src="mock-data.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="mock-data-extended.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="nav.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
renderNav('weighstations');
|
renderNav('weighstations');
|
||||||
renderBanner();
|
renderBanner();
|
||||||
renderFooter();
|
renderFooter();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const MOCK_WEIGH_STATIONS = await PilotEdge.getWeighStations();
|
||||||
|
|
||||||
// ── Local mutable copy of station data ──
|
// ── Local mutable copy of station data ──
|
||||||
const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS));
|
const stations = JSON.parse(JSON.stringify(MOCK_WEIGH_STATIONS));
|
||||||
|
|
||||||
@@ -261,6 +263,7 @@
|
|||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
applyFilters();
|
applyFilters();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
7
server/.env.example
Normal file
7
server/.env.example
Normal 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
3
server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
uploads/
|
||||||
2146
server/package-lock.json
generated
Normal file
2146
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
server/package.json
Normal file
26
server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
308
server/prisma/migrations/20260330193158_init/migration.sql
Normal file
308
server/prisma/migrations/20260330193158_init/migration.sql
Normal 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;
|
||||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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
312
server/prisma/schema.prisma
Normal 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
7
server/src/config/db.js
Normal 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
46
server/src/index.js
Normal 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`);
|
||||||
|
});
|
||||||
44
server/src/middleware/auth.js
Normal file
44
server/src/middleware/auth.js
Normal 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 };
|
||||||
24
server/src/middleware/errorHandler.js
Normal file
24
server/src/middleware/errorHandler.js
Normal 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;
|
||||||
54
server/src/routes/alerts.js
Normal file
54
server/src/routes/alerts.js
Normal 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
89
server/src/routes/auth.js
Normal 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;
|
||||||
87
server/src/routes/bridges.js
Normal file
87
server/src/routes/bridges.js
Normal 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;
|
||||||
34
server/src/routes/calendar.js
Normal file
34
server/src/routes/calendar.js
Normal 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;
|
||||||
34
server/src/routes/contacts.js
Normal file
34
server/src/routes/contacts.js
Normal 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;
|
||||||
74
server/src/routes/contributions.js
Normal file
74
server/src/routes/contributions.js
Normal 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;
|
||||||
119
server/src/routes/documents.js
Normal file
119
server/src/routes/documents.js
Normal 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;
|
||||||
119
server/src/routes/loadboard.js
Normal file
119
server/src/routes/loadboard.js
Normal 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;
|
||||||
111
server/src/routes/locator.js
Normal file
111
server/src/routes/locator.js
Normal 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;
|
||||||
92
server/src/routes/orders.js
Normal file
92
server/src/routes/orders.js
Normal 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;
|
||||||
38
server/src/routes/regulations.js
Normal file
38
server/src/routes/regulations.js
Normal 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;
|
||||||
86
server/src/routes/truckstops.js
Normal file
86
server/src/routes/truckstops.js
Normal 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;
|
||||||
86
server/src/routes/weighstations.js
Normal file
86
server/src/routes/weighstations.js
Normal 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
346
server/src/seeds/seed.js
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user