- Server: Express.js with 13 API route files (auth, regulations, contacts, calendar, truck stops, bridges, weigh stations, alerts, load board, escort locator, orders, documents, contributions) - Database: PostgreSQL with Prisma ORM, 15 models covering all modules - Auth: JWT + bcrypt with role-based access control (driver/carrier/escort/admin) - Geospatial: Haversine distance filtering on truck stops, bridges, escorts - Seed script: Imports all existing mock data (51 states, contacts, equipment, truck stops, bridges, weigh stations, alerts, seasonal restrictions) - Frontend: All 10 data-driven pages now fetch from /api instead of mock-data.js - API client (api.js): Compatibility layer that transforms API responses to match existing frontend rendering code, minimizing page-level changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
449 lines
14 KiB
JavaScript
449 lines
14 KiB
JavaScript
// =====================================================================
|
|
// 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]}`;
|
|
}
|