[OWL] SaaS Marketplace: registration, marketplace, bidding, notifications
Some checks are pending
FreightDesk CI/CD / Lint & Test (push) Waiting to run
FreightDesk CI/CD / Build Docker Image (push) Blocked by required conditions
FreightDesk CI/CD / Deploy to Coolify (push) Blocked by required conditions

Database:
- Migration 005: SaaS marketplace tables (enhanced shippers, vehicles, loads, bids, negotiations, ratings, notifications, load_views)

Public Registration:
- GET/POST /register/shipper — self-registration with validation
- GET/POST /register/driver — self-registration with vehicle details
- Public landing page with tricolor design
- Auto-login after registration

Marketplace:
- GET /marketplace — browse loads with filters (from, to, type, sort)
- GET /marketplace/load/:id — load detail with bid info
- GET/POST /marketplace/post — post a load (shipper)
- GET /marketplace/notifications — notification center with real-time badge
- GET /marketplace/notifications/count — unread count API

Bidding System:
- POST /marketplace/bid — place a bid (driver)
- POST /marketplace/bid/:id/accept — accept bid (shipper, auto-rejects others)
- POST /marketplace/bid/:id/negotiate — counter-offer
- POST /marketplace/rate — submit rating/review
- Automatic notifications on bid/accept/reject

Views:
- Marketplace index with load cards and bid status
- Load detail with bid form (driver) or bid management (shipper)
- Post load form with full details
- Notification center with mark-read
- Portal header/footer partials for portal layout

Architecture:
- Added portalUser to res.locals
- Wired /marketplace route into server.js
- Landing page at / (redirects to dashboard if logged in)
This commit is contained in:
FreightDesk 2026-06-08 01:35:24 +00:00
parent c715d2aabb
commit 69d814c439
13 changed files with 1660 additions and 0 deletions

View file

@ -0,0 +1,146 @@
-- ============================================================
-- FreightDesk — Migration 005: SaaS Marketplace Foundation
-- Adds shipper/driver self-registration, load marketplace, bidding
-- ============================================================
-- ============================================================
-- 1. ENHANCE SHIPPERS TABLE (self-registration support)
-- ============================================================
ALTER TABLE shippers ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id);
ALTER TABLE shipppers ADD COLUMN IF NOT EXISTS is_verified BOOLEAN DEFAULT false;
ALTER TABLE shipppers ADD COLUMN IF NOT EXISTS verification_token TEXT;
ALTER TABLE shipppers ADD COLUMN IF NOT EXISTS company_name TEXT;
ALTER TABLE shipppers ADD COLUMN IF NOT EXISTS gst_number TEXT;
ALTER TABLE shipppers ADD COLUMN IF NOT EXISTS pan_number TEXT;
ALTER TABLE shipppers ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE shippers ADD COLUMN IF NOT EXISTS pincode TEXT;
ALTER TABLE shippers ADD COLUMN IF NOT EXISTS rating DECIMAL(3,2) DEFAULT 0;
ALTER TABLE shippers ADD COLUMN IF NOT EXISTS total_shipments INTEGER DEFAULT 0;
-- ============================================================
-- 2. ENHANCE VEHICLES/DRIVERS TABLE (self-registration support)
-- ============================================================
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id);
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS driver_name TEXT;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS driver_phone TEXT;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS driver_license TEXT;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS is_verified BOOLEAN DEFAULT false;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS verification_token TEXT;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS vehicle_type TEXT; -- 'mini_truck', 'truck', 'trailer', 'container'
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS capacity_tons DECIMAL(6,2);
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS body_type TEXT; -- 'open', 'closed', 'container', 'tanker'
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS current_lat DECIMAL(10,8);
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS current_lng DECIMAL(11,8);
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS current_city TEXT;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS is_available BOOLEAN DEFAULT true;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS rating DECIMAL(3,2) DEFAULT 0;
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS total_trips INTEGER DEFAULT 0;
-- ============================================================
-- 3. ENHANCE LOADS TABLE (marketplace support)
-- ============================================================
ALTER TABLE loads ADD COLUMN IF NOT EXISTS posted_by UUID REFERENCES auth.users(id);
ALTER TABLE loads ADD COLUMN IF NOT EXISTS load_type TEXT; -- 'ftl', 'ptl', 'parcel'
ALTER TABLE loads ADD COLUMN IF NOT EXISTS weight_kg INTEGER;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS material_type TEXT;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS packaging_type TEXT;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS pickup_address TEXT;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS pickup_pincode TEXT;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS pickup_lat DECIMAL(10,8);
ALTER TABLE loads ADD COLUMN IF NOT EXISTS pickup_lng DECIMAL(11,8);
ALTER TABLE loads ADD COLUMN IF NOT EXISTS delivery_address TEXT;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS delivery_pincode TEXT;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS delivery_lat DECIMAL(10,8);
ALTER TABLE loads ADD COLUMN IF NOT EXISTS delivery_lng DECIMAL(11,8);
ALTER TABLE loads ADD COLUMN IF NOT EXISTS pickup_date DATE;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS delivery_date DATE;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS budget_min INTEGER;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS budget_max INTEGER;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS is_open BOOLEAN DEFAULT true; -- open for bidding
ALTER TABLE loads ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS accepted_bid_id UUID;
ALTER TABLE loads ADD COLUMN IF NOT EXISTS views INTEGER DEFAULT 0;
-- ============================================================
-- 4. BIDS TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS bids (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
load_id UUID NOT NULL REFERENCES loads(id) ON DELETE CASCADE,
driver_id UUID NOT NULL REFERENCES vehicles(id),
shipper_id UUID REFERENCES shippers(id),
amount INTEGER NOT NULL,
message TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn', 'expired')),
valid_until TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(load_id, driver_id) -- one bid per driver per load
);
CREATE INDEX IF NOT EXISTS idx_bids_load ON bids(load_id);
CREATE INDEX IF NOT EXISTS idx_bids_driver ON bids(driver_id);
CREATE INDEX IF NOT EXISTS idx_bids_status ON bids(status);
-- ============================================================
-- 5. NEGOTIATION / COUNTER-OFFERS TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS negotiations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bid_id UUID NOT NULL REFERENCES bids(id) ON DELETE CASCADE,
proposed_by UUID NOT NULL, -- user_id who proposed
proposed_amount INTEGER NOT NULL,
message TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'countered')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_negotiations_bid ON negotiations(bid_id);
-- ============================================================
-- 6. RATINGS & REVIEWS TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_user_id UUID NOT NULL,
to_user_id UUID NOT NULL,
load_id UUID REFERENCES loads(id),
driver_id UUID REFERENCES vehicles(id),
shipper_id UUID REFERENCES shippers(id),
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
review TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ratings_to_user ON ratings(to_user_id);
CREATE INDEX IF NOT EXISTS idx_ratings_driver ON ratings(driver_id);
CREATE INDEX IF NOT EXISTS idx_ratings_shipper ON ratings(shipper_id);
-- ============================================================
-- 7. NOTIFICATIONS TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
type TEXT NOT NULL CHECK (type IN ('bid_received', 'bid_accepted', 'bid_rejected', 'negotiation', 'load_assigned', 'delivery_update', 'payment', 'system')),
title TEXT NOT NULL,
message TEXT,
data JSONB DEFAULT '{}',
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, is_read) WHERE is_read = false;
-- ============================================================
-- 8. LOAD VIEWS TABLE (analytics)
-- ============================================================
CREATE TABLE IF NOT EXISTS load_views (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
load_id UUID NOT NULL REFERENCES loads(id) ON DELETE CASCADE,
viewer_id UUID,
viewed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_load_views_load ON load_views(load_id);

View file

@ -0,0 +1,314 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { asyncHandler } = require('../middleware/security');
// ============================================================
// MIDDLEWARE
// ============================================================
function requirePortalAuth(req, res, next) {
if (!req.session.portalUser) {
return res.redirect('/portal/login?redirect=' + encodeURIComponent(req.originalUrl));
}
next();
}
function requireRole(role) {
return (req, res, next) => {
if (req.session.portalUser?.role !== role) {
return res.status(403).render('pages/errors/403', { message: 'Access denied' });
}
next();
};
}
// ============================================================
// LOAD MARKETPLACE — Browse available loads
// ============================================================
router.get('/', requirePortalAuth, asyncHandler(async (req, res) => {
const { from_city, to_city, load_type, min_budget, max_budget, sort } = req.query;
let query = supabase
.from('loads')
.select('*, shippers(name, phone, rating)')
.eq('is_open', true)
.gt('expires_at', new Date().toISOString())
.order(sort === 'budget' ? 'budget_max' : 'created_at', { ascending: false });
if (from_city) query = query.ilike('from_city', `%${from_city}%`);
if (to_city) query = query.ilike('to_city', `%${to_city}%`);
if (load_type) query = query.eq('load_type', load_type);
if (min_budget) query = query.gte('budget_max', parseInt(min_budget));
if (max_budget) query = query.lte('budget_max', parseInt(max_budget));
const { data: loads, error } = await query;
// Get user's existing bids
let myBids = [];
if (req.session.portalUser?.role === 'driver' && req.session.portalUser?.driver_id) {
const { data: bids } = await supabase
.from('bids')
.select('load_id, status, amount')
.eq('driver_id', req.session.portalUser.driver_id);
myBids = bids || [];
}
res.render('pages/marketplace/index', {
loads: loads || [],
myBids,
error: error ? error.message : null,
filters: req.query,
userRole: req.session.portalUser?.role,
});
}));
// ============================================================
// LOAD DETAIL (within marketplace)
// ============================================================
router.get('/load/:id', requirePortalAuth, asyncHandler(async (req, res) => {
const { data: load, error } = await supabase
.from('loads')
.select('*, shippers(name, phone, rating, total_shipments, company_name)')
.eq('id', req.params.id)
.single();
if (error || !load) {
return res.status(404).send('Load not found');
}
// Record view
await supabase.from('load_views').insert({ load_id: load.id, viewer_id: req.session.portalUser.id }).select();
await supabase.from('loads').update({ views: (load.views || 0) + 1 }).eq('id', load.id);
let bids = [];
let userBid = null;
const isShipperOwner = req.session.portalUser?.role === 'shipper';
if (isShipperOwner) {
const { data: bidData } = await supabase
.from('bids')
.select('*, vehicles(number, driver_name, driver_phone, vehicle_type, rating, total_trips, capacity_tons)')
.eq('load_id', load.id)
.order('amount', { ascending: true });
bids = bidData || [];
}
if (req.session.portalUser?.role === 'driver' && req.session.portalUser?.driver_id) {
const { data: bidData } = await supabase
.from('bids')
.select('*')
.eq('load_id', load.id)
.eq('driver_id', req.session.portalUser.driver_id)
.single();
userBid = bidData || null;
}
res.render('pages/marketplace/load-detail', {
load,
bids,
userBid,
isShipperOwner,
userRole: req.session.portalUser?.role,
});
}));
// ============================================================
// POST A LOAD (shipper only)
// ============================================================
router.get('/post', requirePortalAuth, requireRole('shipper'), (req, res) => {
res.render('pages/marketplace/post', { error: null, formData: {} });
});
router.post('/post', requirePortalAuth, requireRole('shipper'), asyncHandler(async (req, res) => {
const {
from_city, to_city, via, load_type, weight_kg, material_type,
pickup_address, pickup_pincode, pickup_date,
delivery_address, delivery_pincode, delivery_date,
budget_min, budget_max, description, expires_in_days,
} = req.body;
const errors = [];
if (!from_city) errors.push('From city is required');
if (!to_city) errors.push('To city is required');
if (!pickup_date) errors.push('Pickup date is required');
if (errors.length > 0) {
return res.render('pages/marketplace/post', { error: errors.join(', '), formData: req.body });
}
const { data: shipper } = await supabase
.from('shippers')
.select('id')
.eq('phone', req.session.portalUser.username)
.single();
const expiresAt = expires_in_days
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: new Date(Date.now() + 7 * 86400000).toISOString();
const { error: insertError } = await supabase.from('loads').insert({
shipper_id: shipper?.id,
from_city, to_city,
via: via || null,
load_type: load_type || 'ftl',
weight_kg: weight_kg ? parseInt(weight_kg) : null,
material_type: material_type || null,
pickup_address: pickup_address || null,
pickup_pincode: pickup_pincode || null,
pickup_date: pickup_date || null,
delivery_address: delivery_address || null,
delivery_pincode: delivery_pincode || null,
delivery_date: delivery_date || null,
budget_min: budget_min ? parseInt(budget_min) : null,
budget_max: budget_max ? parseInt(budget_max) : null,
notes: description || null,
status: 'pending lead',
is_open: true,
expires_at: expiresAt,
});
if (insertError) {
return res.render('pages/marketplace/post', { error: 'Failed: ' + insertError.message, formData: req.body });
}
res.redirect('/marketplace/?posted=success');
}));
// ============================================================
// BIDDING
// ============================================================
// POST /marketplace/bid — submit a bid
router.post('/bid', requirePortalAuth, requireRole('driver'), asyncHandler(async (req, res) => {
const { load_id, amount, message } = req.body;
const driverId = req.session.portalUser?.driver_id;
if (!driverId) return res.status(400).json({ error: 'Driver profile not found' });
if (!amount || parseInt(amount) <= 0) return res.status(400).json({ error: 'Valid bid amount required' });
const { data: load } = await supabase.from('loads').select('id, is_open, expires_at, shipper_id').eq('id', load_id).single();
if (!load || !load.is_open) return res.status(400).json({ error: 'Load not accepting bids' });
if (new Date(load.expires_at) < new Date()) return res.status(400).json({ error: 'Load expired' });
// Check existing bid
const { data: existing } = await supabase.from('bids').select('id').eq('load_id', load_id).eq('driver_id', driverId).single();
if (existing) return res.status(400).json({ error: 'You already bid on this load' });
const { data: bid, error } = await supabase.from('bids').insert({
load_id, driver_id: driverId, shipper_id: load.shipper_id,
amount: parseInt(amount), message: message || null, status: 'pending',
}).select().single();
if (error) return res.status(400).json({ error: error.message });
// Notify shipper
await supabase.from('notifications').insert({
user_id: load.shipper_id, type: 'bid_received',
title: 'New Bid Received',
message: `${parseInt(amount).toLocaleString('en-IN')} bid on your load`,
data: { load_id, bid_id: bid.id, amount },
});
res.json({ success: true, bid });
}));
// POST /marketplace/bid/:bidId/accept — accept a bid
router.post('/bid/:bidId/accept', requirePortalAuth, requireRole('shipper'), asyncHandler(async (req, res) => {
const { data: bid } = await supabase
.from('bids')
.select('*, loads(id, from_city, to_city)')
.eq('id', req.params.bidId).single();
if (!bid) return res.status(404).json({ error: 'Bid not found' });
await supabase.from('bids').update({ status: 'accepted', updated_at: new Date().toISOString() }).eq('id', req.params.bidId);
await supabase.from('bids').update({ status: 'rejected', updated_at: new Date().toISOString() }).eq('load_id', bid.load_id).neq('id', req.params.bidId);
await supabase.from('loads').update({
status: 'assigned vehicle', accepted_bid_id: req.params.bidId,
driver_freight: bid.amount, is_open: false,
}).eq('id', bid.load_id);
await supabase.from('notifications').insert({
user_id: bid.driver_id, type: 'bid_accepted',
title: 'Bid Accepted!',
message: `Your ₹${bid.amount.toLocaleString('en-IN')} bid accepted for ${bid.loads.from_city}${bid.loads.to_city}`,
data: { load_id: bid.load_id, bid_id: bid.id },
});
res.json({ success: true });
}));
// POST /marketplace/bid/:bidId/negotiate — counter-offer
router.post('/bid/:bidId/negotiate', requirePortalAuth, asyncHandler(async (req, res) => {
const { proposed_amount, message } = req.body;
if (!proposed_amount || parseInt(proposed_amount) <= 0) return res.status(400).json({ error: 'Valid amount required' });
const { error } = await supabase.from('negotiations').insert({
bid_id: req.params.bidId, proposed_by: req.session.portalUser.id,
proposed_amount: parseInt(proposed_amount), message: message || null,
});
if (error) return res.status(400).json({ error: error.message });
await supabase.from('bids').update({ status: 'negotiating' }).eq('id', req.params.bidId);
res.json({ success: true });
}));
// ============================================================
// RATINGS
// ============================================================
router.post('/rate', requirePortalAuth, asyncHandler(async (req, res) => {
const { to_user_id, load_id, driver_id, shipper_id, rating, review } = req.body;
if (!rating || rating < 1 || rating > 5) return res.status(400).json({ error: 'Rating 1-5 required' });
const { error } = await supabase.from('ratings').insert({
from_user_id: req.session.portalUser.id, to_user_id,
load_id: load_id || null, driver_id: driver_id || null, shipper_id: shipper_id || null,
rating: parseInt(rating), review: review || null,
});
if (error) return res.status(400).json({ error: error.message });
res.json({ success: true });
}));
// ============================================================
// NOTIFICATIONS
// ============================================================
router.get('/notifications', requirePortalAuth, asyncHandler(async (req, res) => {
const { data } = await supabase
.from('notifications')
.select('*')
.eq('user_id', req.session.portalUser.id)
.order('created_at', { ascending: false })
.limit(50);
res.render('pages/marketplace/notifications', { notifications: data || [] });
}));
// GET /marketplace/notifications/count — unread count (for badge)
router.get('/notifications/count', requirePortalAuth, asyncHandler(async (req, res) => {
const { count } = await supabase
.from('notifications')
.select('*', { count: 'exact', head: true })
.eq('user_id', req.session.portalUser.id)
.eq('is_read', false);
res.json({ count: count || 0 });
}));
router.post('/notifications/:id/read', requirePortalAuth, asyncHandler(async (req, res) => {
await supabase.from('notifications').update({ is_read: true }).eq('id', req.params.id);
res.json({ success: true });
}));
router.post('/notifications/read-all', requirePortalAuth, asyncHandler(async (req, res) => {
await supabase.from('notifications').update({ is_read: true }).eq('user_id', req.session.portalUser.id).eq('is_read', false);
res.json({ success: true });
}));
module.exports = router;

224
webapp/src/routes/public.js Normal file
View file

@ -0,0 +1,224 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const supabase = require('../services/supabase');
const { asyncHandler } = require('../middleware/security');
// ============================================================
// SHIPPER SELF-REGISTRATION
// ============================================================
// GET /register/shipper
router.get('/shipper', (req, res) => {
if (req.session.portalUser) {
return res.redirect('/portal/dashboard');
}
res.render('pages/public/register-shipper', { error: null, formData: {} });
});
// POST /register/shipper
router.post('/shipper', asyncHandler(async (req, res) => {
const { name, email, phone, password, confirm_password, company_name, gst_number, city, state, pincode } = req.body;
// Validation
const errors = [];
if (!name || name.length < 2) errors.push('Name is required');
if (!phone || phone.length < 10) errors.push('Valid phone number is required');
if (!password || password.length < 6) errors.push('Password must be at least 6 characters');
if (password !== confirm_password) errors.push('Passwords do not match');
if (!city) errors.push('City is required');
// Check if phone already exists
const { data: existing } = await supabase
.from('portal_users')
.select('id')
.eq('username', phone)
.single();
if (existing) {
errors.push('This phone number is already registered');
}
if (errors.length > 0) {
return res.render('pages/public/register-shipper', {
error: errors.join(', '),
formData: req.body,
});
}
// Create portal user
const password_hash = await bcrypt.hash(password, 12);
const { data: portalUser, error: userError } = await supabase
.from('portal_users')
.insert({
username: phone,
password_hash,
role: 'shipper',
is_active: true,
})
.select()
.single();
if (userError) {
return res.render('pages/public/register-shipper', {
error: 'Registration failed: ' + userError.message,
formData: req.body,
});
}
// Create shipper profile
const { error: shipperError } = await supabase
.from('shippers')
.insert({
name,
email,
phone,
company_name: company_name || null,
gst_number: gst_number || null,
city: city || null,
state: state || null,
pincode: pincode || null,
});
if (shipperError) {
// Rollback portal user
await supabase.from('portal_users').delete().eq('id', portalUser.id);
return res.render('pages/public/register-shipper', {
error: 'Registration failed: ' + shipperError.message,
formData: req.body,
});
}
// Auto-login after registration
req.session.portalUser = {
id: portalUser.id,
username: portalUser.username,
role: 'shipper',
};
res.redirect('/portal/dashboard');
}));
// ============================================================
-- DRIVER SELF-REGISTRATION
-- ============================================================
// GET /register/driver
router.get('/driver', (req, res) => {
if (req.session.portalUser) {
return res.redirect('/portal/dashboard');
}
res.render('pages/public/register-driver', { error: null, formData: {} });
});
// POST /register/driver
router.post('/driver', asyncHandler(async (req, res) => {
const { name, email, phone, password, confirm_password, vehicle_number, vehicle_type, capacity_tons, driver_license, current_city } = req.body;
// Validation
const errors = [];
if (!name || name.length < 2) errors.push('Name is required');
if (!phone || phone.length < 10) errors.push('Valid phone number is required');
if (!password || password.length < 6) errors.push('Password must be at least 6 characters');
if (password !== confirm_password) errors.push('Passwords do not match');
if (!vehicle_number) errors.push('Vehicle number is required');
// Check if phone already exists
const { data: existing } = await supabase
.from('portal_users')
.select('id')
.eq('username', phone)
.single();
if (existing) {
errors.push('This phone number is already registered');
}
if (errors.length > 0) {
return res.render('pages/public/register-driver', {
error: errors.join(', '),
formData: req.body,
});
}
// Create portal user
const password_hash = await bcrypt.hash(password, 12);
const { data: portalUser, error: userError } = await supabase
.from('portal_users')
.insert({
username: phone,
password_hash,
role: 'driver',
is_active: true,
})
.select()
.single();
if (userError) {
return res.render('pages/public/register-driver', {
error: 'Registration failed: ' + userError.message,
formData: req.body,
});
}
// Create vehicle/driver profile
const { error: vehicleError } = await supabase
.from('vehicles')
.insert({
number: vehicle_number.toUpperCase().replace(/\s/g, ''),
driver_name: name,
phone,
vehicle_type: vehicle_type || 'truck',
capacity_tons: capacity_tons ? parseFloat(capacity_tons) : null,
driver_license: driver_license || null,
current_city: current_city || null,
is_available: true,
});
if (vehicleError) {
await supabase.from('portal_users').delete().eq('id', portalUser.id);
return res.render('pages/public/register-driver', {
error: 'Registration failed: ' + vehicleError.message,
formData: req.body,
});
}
// Link vehicle to portal user
const { data: vehicle } = await supabase
.from('vehicles')
.select('id')
.eq('number', vehicle_number.toUpperCase().replace(/\s/g, ''))
.single();
if (vehicle) {
await supabase.from('portal_users').update({ driver_id: vehicle.id }).eq('id', portalUser.id);
}
// Auto-login
req.session.portalUser = {
id: portalUser.id,
username: portalUser.username,
role: 'driver',
driver_id: vehicle?.id,
};
res.redirect('/portal/dashboard');
}));
// ============================================================
-- PUBLIC LANDING PAGE
-- ============================================================
// GET / — redirect to dashboard if logged in, else landing page
router.get('/', (req, res) => {
if (req.session.portalUser) {
return res.redirect('/portal/dashboard');
}
// If admin is logged in, go to admin dashboard
if (req.session.userId) {
return res.redirect('/dashboard');
}
res.render('pages/public/landing');
});
module.exports = router;

View file

@ -91,6 +91,7 @@ app.use(sanitizeBody);
// Make helpers available to all views
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
res.locals.portalUser = req.session.portalUser || null;
res.locals.appName = 'FreightDesk';
res.locals.appNameHi = 'फ्रेटडेस्क';
res.locals.formatINR = formatINR;
@ -209,6 +210,8 @@ app.use('/portal', require('./routes/portal'));
app.use('/invoices', require('./routes/invoices'));
app.use('/portal-users', require('./routes/portal-users'));
app.use('/api', require('./routes/api'));
app.use('/', require('./routes/public'));
app.use('/marketplace', require('./routes/marketplace'));
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));

View file

@ -0,0 +1,118 @@
<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128666; Load Marketplace</h1>
<p class="page-subtitle">Browse available loads and place your bids</p>
</div>
<% if (userRole === 'shipper') { %>
<a href="/marketplace/post" class="btn btn-primary">+ Post a Load</a>
<% } %>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<!-- Filters -->
<div class="card mb-3">
<div class="card-body">
<form method="GET" action="/marketplace" style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;">
<div class="form-group" style="margin:0;min-width:140px;">
<label class="form-label">From</label>
<input type="text" name="from_city" class="form-input" value="<%= filters.from_city || '' %>" placeholder="Any city">
</div>
<div class="form-group" style="margin:0;min-width:140px;">
<label class="form-label">To</label>
<input type="text" name="to_city" class="form-input" value="<%= filters.to_city || '' %>" placeholder="Any city">
</div>
<div class="form-group" style="margin:0;min-width:120px;">
<label class="form-label">Type</label>
<select name="load_type" class="form-input">
<option value="">All</option>
<option value="ftl" <%= filters.load_type === 'ftl' ? 'selected' : '' %>>FTL</option>
<option value="ptl" <%= filters.load_type === 'ptl' ? 'selected' : '' %>>PTL</option>
<option value="parcel" <%= filters.load_type === 'parcel' ? 'selected' : '' %>>Parcel</option>
</select>
</div>
<div class="form-group" style="margin:0;min-width:120px;">
<label class="form-label">Sort</label>
<select name="sort" class="form-input">
<option value="recent" <%= filters.sort === 'recent' ? 'selected' : '' %>>Recent</option>
<option value="budget" <%= filters.sort === 'budget' ? 'selected' : '' %>>Budget</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">Filter</button>
<a href="/marketplace" class="btn btn-outline">Clear</a>
</form>
</div>
</div>
<!-- Load Cards -->
<% if (!loads || loads.length === 0) { %>
<div class="empty-state">
<div class="empty-icon">&#128666;</div>
<h3>No loads available</h3>
<p>Check back soon for new freight opportunities</p>
<% if (userRole === 'shipper') { %>
<a href="/marketplace/post" class="btn btn-primary mt-2">Post the First Load</a>
<% } %>
</div>
<% } else { %>
<div class="loads-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;">
<% for (const load of loads) { %>
<div class="load-card" style="background:#fff;border:1px solid #e0ddd5;border-radius:12px;padding:20px;transition:box-shadow 0.2s;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;">
<div>
<div style="font-size:18px;font-weight:700;color:#000080;">
<%= load.from_city %> &rarr; <%= load.to_city %>
</div>
<% if (load.via) { %>
<div style="font-size:12px;color:#666;margin-top:2px;">via <%= load.via %></div>
<% } %>
</div>
<span class="badge" style="background:<%= load.load_type === 'ftl' ? '#e8f5e9' : load.load_type === 'ptl' ? '#fff3e0' : '#e3f2fd' %>;color:<%= load.load_type === 'ftl' ? '#2e7d32' : load.load_type === 'ptl' ? '#e65100' : '#1565c0' %>;">
<%= load.load_type ? load.load_type.toUpperCase() : 'FTL' %>
</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;font-size:13px;">
<div><span style="color:#666;">&#128197; Pickup:</span> <%= load.pickup_date || 'Flexible' %></div>
<div><span style="color:#666;">&#128205; Weight:</span> <%= load.weight_kg ? load.weight_kg + ' kg' : 'N/A' %></div>
<div><span style="color:#666;">&#128176; Budget:</span>
<% if (load.budget_max) { %>
&#8377; <%= load.budget_max.toLocaleString('en-IN') %>
<% if (load.budget_min) { %> - &#8377; <%= load.budget_min.toLocaleString('en-IN') %><% } %>
<% } else { %> Open <% } %>
</div>
<div><span style="color:#666;">&#128100; Shipper:</span> <%= load.shippers?.name || 'N/A' %></div>
</div>
<% if (load.material_type) { %>
<div style="font-size:12px;color:#666;margin-bottom:8px;">&#128230; <%= load.material_type %></div>
<% } %>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px;padding-top:12px;border-top:1px solid #f0ede5;">
<div style="font-size:11px;color:#999;">
&#128065; <%= load.views || 0 %> views &middot; Expires <%= new Date(load.expires_at).toLocaleDateString('en-IN') %>
</div>
<a href="/marketplace/load/<%= load.id %>" class="btn btn-sm btn-primary">View &amp; Bid</a>
</div>
<% if (userRole === 'driver') { %>
<% const myBid = myBids.find(b => b.load_id === load.id); %>
<% if (myBid) { %>
<div style="margin-top:8px;padding:8px;background:#f0f4ff;border-radius:6px;font-size:12px;">
Your bid: <strong>&#8377; <%= myBid.amount.toLocaleString('en-IN') %></strong>
<span class="badge badge-<%= myBid.status === 'accepted' ? 'success' : myBid.status === 'rejected' ? 'danger' : 'warning' %>" style="margin-left:4px;">
<%= myBid.status %>
</span>
</div>
<% } %>
<% } %>
</div>
<% } %>
</div>
<% } %>
<%- include('../partials/portal-footer') %>

View file

@ -0,0 +1,213 @@
<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %>
<div style="max-width:800px;margin:0 auto;">
<a href="/marketplace" class="btn btn-sm btn-outline mb-3">&larr; Back to Marketplace</a>
<!-- Load Details Card -->
<div class="card mb-3">
<div class="card-header" style="background:linear-gradient(135deg,#000080,#1a1a9a);color:white;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<h2 style="font-size:24px;margin:0;"><%= load.from_city %> &rarr; <%= load.to_city %></h2>
<% if (load.via) { %><p style="margin:4px 0 0;opacity:0.8;">via <%= load.via %></p><% } %>
</div>
<span class="badge" style="background:rgba(255,255,255,0.2);color:white;">
<%= load.load_type ? load.load_type.toUpperCase() : 'FTL' %>
</span>
</div>
</div>
<div class="card-body">
<div class="detail-list">
<dt>From</dt><dd><%= load.from_city %></dd>
<dt>To</dt><dd><%= load.to_city %></dd>
<% if (load.pickup_address) { %><dt>Pickup Address</dt><dd><%= load.pickup_address %><% if (load.pickup_pincode) { %> - <%= load.pickup_pincode %><% } %></dd><% } %>
<% if (load.delivery_address) { %><dt>Delivery Address</dt><dd><%= load.delivery_address %><% if (load.delivery_pincode) { %> - <%= load.delivery_pincode %><% } %></dd><% } %>
<dt>Pickup Date</dt><dd><%= load.pickup_date || 'Flexible' %></dd>
<% if (load.delivery_date) { %><dt>Delivery Date</dt><dd><%= load.delivery_date %></dd><% } %>
<% if (load.weight_kg) { %><dt>Weight</dt><dd><%= load.weight_kg %> kg</dd><% } %>
<% if (load.material_type) { %><dt>Material</dt><dd><%= load.material_type %></dd><% } %>
<% if (load.budget_min || load.budget_max) { %>
<dt>Budget</dt>
<dd>
<% if (load.budget_min && load.budget_max) { %>
&#8377; <%= load.budget_min.toLocaleString('en-IN') %> - &#8377; <%= load.budget_max.toLocaleString('en-IN') %>
<% } else if (load.budget_max) { %>
Up to &#8377; <%= load.budget_max.toLocaleString('en-IN') %>
<% } else { %>
&#8377; <%= load.budget_min.toLocaleString('en-IN') %>+
<% } %>
</dd>
<% } %>
<dt>Shipper</dt>
<dd>
<%= load.shippers?.name || 'N/A' %>
<% if (load.shippers?.rating > 0) { %>
&nbsp;<span style="color:#f59e0b;">&#9733;</span> <%= load.shippers.rating.toFixed(1) %>
<% } %>
<% if (load.shippers?.total_shipments) { %>
&middot; <%= load.shippers.total_shipments %> shipments
<% } %>
</dd>
<dt>Expires</dt><dd><%= new Date(load.expires_at).toLocaleDateString('en-IN') %></dd>
<dt>Views</dt><dd><%= load.views || 0 %></dd>
</div>
<% if (load.notes) { %>
<div style="margin-top:16px;padding:12px;background:#f8f9fa;border-radius:8px;">
<strong>Additional Details:</strong>
<p style="margin:4px 0 0;font-size:14px;color:#555;"><%= load.notes %></p>
</div>
<% } %>
</div>
</div>
<!-- Driver: Bid Section -->
<% if (userRole === 'driver') { %>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">&#128176; Your Bid</h3></div>
<div class="card-body">
<% if (load.is_open && new Date(load.expires_at) > new Date()) { %>
<% if (!userBid) { %>
<form id="bidForm">
<div class="form-row">
<div class="form-group">
<label class="form-label">Your Bid Amount (&#8377;) *</label>
<input type="number" name="amount" class="form-input" required min="1" placeholder="Enter your price">
</div>
</div>
<div class="form-group">
<label class="form-label">Message (optional)</label>
<textarea name="message" class="form-input" rows="2" placeholder="Why should this load be assigned to you?"></textarea>
</div>
<button type="submit" class="btn btn-primary">Place Bid</button>
</form>
<% } else { %>
<div style="padding:12px;background:#f0f4ff;border-radius:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
Your bid: <strong style="font-size:20px;color:#000080;">&#8377; <%= userBid.amount.toLocaleString('en-IN') %></strong>
<span class="badge badge-<%= userBid.status === 'accepted' ? 'success' : userBid.status === 'rejected' ? 'danger' : 'warning' %>" style="margin-left:8px;">
<%= userBid.status %>
</span>
</div>
<% if (userBid.status === 'pending') { %>
<form method="POST" action="/marketplace/bid/<%= userBid.id %>/negotiate">
<div style="display:flex;gap:8px;">
<input type="number" name="proposed_amount" class="form-input" style="width:120px;" placeholder="Counter ₹" required>
<button type="submit" class="btn btn-sm btn-secondary">Counter</button>
</div>
</form>
<% } %>
</div>
<% if (userBid.message) { %>
<p style="margin-top:8px;font-size:13px;color:#666;">"<%= userBid.message %>"</p>
<% } %>
</div>
<% } %>
<% } else { %>
<div class="alert">&#9888; This load is no longer accepting bids.</div>
<% } %>
</div>
</div>
<% } %>
<!-- Shipper: Bids Received -->
<% if (isShipperOwner && bids.length > 0) { %>
<div class="card mb-3">
<div class="card-header">
<h3 class="card-title">&#128176; Bids Received (<%= bids.length %>)</h3>
</div>
<div class="card-body" style="padding:0;">
<table class="table">
<thead>
<tr>
<th>Driver</th>
<th>Vehicle</th>
<th>Rating</th>
<th>Amount</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<% for (const bid of bids) { %>
<tr>
<td>
<strong><%= bid.vehicles?.driver_name || 'N/A' %></strong>
<div style="font-size:12px;color:#666;"><%= bid.vehicles?.driver_phone || '' %></div>
</td>
<td>
<%= bid.vehicles?.number || 'N/A' %>
<% if (bid.vehicles?.vehicle_type) { %>
<span class="badge badge-gray"><%= bid.vehicles.vehicle_type %></span>
<% } %>
</td>
<td>
<% if (bid.vehicles?.rating > 0) { %>
&#9733; <%= bid.vehicles.rating.toFixed(1) %>
<span style="color:#666;font-size:11px;">(<%= bid.vehicles.total_trips || 0 %> trips)</span>
<% } else { %>New<% } %>
</td>
<td style="font-weight:700;">&#8377; <%= bid.amount.toLocaleString('en-IN') %></td>
<td>
<span class="badge badge-<%= bid.status === 'accepted' ? 'success' : bid.status === 'rejected' ? 'danger' : 'warning' %>">
<%= bid.status %>
</span>
</td>
<td>
<% if (bid.status === 'pending') { %>
<form method="POST" action="/marketplace/bid/<%= bid.id %>/accept" style="display:inline;">
<button type="submit" class="btn btn-sm btn-success">Accept</button>
</form>
<button class="btn btn-sm btn-danger" onclick="rejectBid('<%= bid.id %>')">Reject</button>
<form method="POST" action="/marketplace/bid/<%= bid.id %>/negotiate" style="display:inline;">
<input type="number" name="proposed_amount" placeholder="Counter ₹" style="width:80px;" class="form-input form-input-sm">
<button type="submit" class="btn btn-sm btn-secondary">Counter</button>
</form>
<% } %>
</td>
</tr>
<% if (bid.message) { %>
<tr><td colspan="6" style="padding:4px 16px 12px;color:#666;font-size:13px;">"<%= bid.message %>"</td></tr>
<% } %>
<% } %>
</tbody>
</table>
</div>
</div>
<% } %>
<!-- Shipper: No bids yet -->
<% if (isShipperOwner && bids.length === 0) { %>
<div class="card">
<div class="card-body" style="text-align:center;padding:32px;">
<div style="font-size:40px;">&#128233;</div>
<h3>No bids yet</h3>
<p class="text-muted">Drivers will start bidding soon. Share your load to get more visibility.</p>
</div>
</div>
<% } %>
</div>
<script>
document.getElementById('bidForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const amount = form.amount.value;
const message = form.message.value;
const res = await fetch('/marketplace/bid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ load_id: '<%= load.id %>', amount, message })
});
const data = await res.json();
if (data.success) {
alert('Bid placed successfully!');
location.reload();
} else {
alert(data.error || 'Failed to place bid');
}
});
</script>
<%- include('../partials/portal-footer') %>

View file

@ -0,0 +1,58 @@
<%- include('../partials/portal-header', { activeMenu: 'notifications' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128276; Notifications</h1>
<p class="page-subtitle">Stay updated on bids, loads, and payments</p>
</div>
<button class="btn btn-sm btn-outline" onclick="markAllRead()">Mark All Read</button>
</div>
<% if (!notifications || notifications.length === 0) { %>
<div class="empty-state">
<div class="empty-icon">&#128276;</div>
<h3>No notifications</h3>
<p>You're all caught up!</p>
</div>
<% } else { %>
<div class="card">
<div class="card-body" style="padding:0;">
<% for (const n of notifications) { %>
<div class="notification-item" style="padding:16px 20px;border-bottom:1px solid #f0ede5;display:flex;justify-content:space-between;align-items:flex-start;<%= !n.is_read ? 'background:#f0f4ff;' : '' %>">
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-size:18px;">
<%= n.type === 'bid_received' ? '&#128176;' : n.type === 'bid_accepted' ? '&#9989;' : n.type === 'bid_rejected' ? '&#10060;' : n.type === 'payment' ? '&#128188;' : n.type === 'negotiation' ? '&#128260;' : n.type === 'load_assigned' ? '&#128666;' : '&#128276;' %>
</span>
<strong style="font-size:14px;"><%= n.title %></strong>
<% if (!n.is_read) { %><span class="badge badge-primary" style="font-size:10px;">NEW</span><% } %>
</div>
<% if (n.message) { %>
<p style="margin:0;font-size:13px;color:#666;"><%= n.message %></p>
<% } %>
<div style="font-size:11px;color:#999;margin-top:4px;">
<%= new Date(n.created_at).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) %>
</div>
</div>
<% if (!n.is_read) { %>
<button class="btn btn-sm btn-outline" onclick="markRead('<%= n.id %>')" style="margin-left:12px;">Read</button>
<% } %>
</div>
<% } %>
</div>
</div>
<% } %>
<script>
async function markRead(id) {
await fetch('/marketplace/notifications/' + id + '/read', { method: 'POST' });
location.reload();
}
async function markAllRead() {
await fetch('/marketplace/notifications/read-all', { method: 'POST' });
location.reload();
}
</script>
<%- include('../partials/portal-footer') %>

View file

@ -0,0 +1,121 @@
<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128228; Post a Load</h1>
<p class="page-subtitle">Post your freight requirement and receive bids from verified drivers</p>
</div>
<a href="/marketplace" class="btn btn-outline">&larr; Back to Marketplace</a>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<div class="card">
<div class="card-body">
<form method="POST" action="/marketplace/post">
<input type="hidden" name="_csrf" value="<%= typeof _csrf !== 'undefined' ? _csrf : '' %>">
<h4 style="margin:0 0 16px;color:#000080;">Route Details</h4>
<div class="form-row">
<div class="form-group">
<label class="form-label">From City *</label>
<input type="text" name="from_city" class="form-input" required value="<%= formData.from_city || '' %>" placeholder="Origin city">
</div>
<div class="form-group">
<label class="form-label">Via (optional)</label>
<input type="text" name="via" class="form-input" value="<%= formData.via || '' %>" placeholder="Intermediate city">
</div>
<div class="form-group">
<label class="form-label">To City *</label>
<input type="text" name="to_city" class="form-input" required value="<%= formData.to_city || '' %>" placeholder="Destination city">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Pickup Address</label>
<input type="text" name="pickup_address" class="form-input" value="<%= formData.pickup_address || '' %>" placeholder="Full pickup address">
</div>
<div class="form-group">
<label class="form-label">Pickup Pincode</label>
<input type="text" name="pickup_pincode" class="form-input" value="<%= formData.pickup_pincode || '' %>" placeholder="6-digit pincode" pattern="[0-9]{6}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Delivery Address</label>
<input type="text" name="delivery_address" class="form-input" value="<%= formData.delivery_address || '' %>" placeholder="Full delivery address">
</div>
<div class="form-group">
<label class="form-label">Delivery Pincode</label>
<input type="text" name="delivery_pincode" class="form-input" value="<%= formData.delivery_pincode || '' %>" placeholder="6-digit pincode" pattern="[0-9]{6}">
</div>
</div>
<h4 style="margin:24px 0 16px;color:#000080;">Load Details</h4>
<div class="form-row">
<div class="form-group">
<label class="form-label">Pickup Date *</label>
<input type="date" name="pickup_date" class="form-input" required value="<%= formData.pickup_date || '' %>">
</div>
<div class="form-group">
<label class="form-label">Delivery Date</label>
<input type="date" name="delivery_date" class="form-input" value="<%= formData.delivery_date || '' %>">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Load Type</label>
<select name="load_type" class="form-input">
<option value="ftl" <%= formData.load_type === 'ftl' ? 'selected' : '' %>>FTL (Full Truckload)</option>
<option value="ptl" <%= formData.load_type === 'ptl' ? 'selected' : '' %>>PTL (Part Truckload)</option>
<option value="parcel" <%= formData.load_type === 'parcel' ? 'selected' : '' %>>Parcel</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Weight (kg)</label>
<input type="number" name="weight_kg" class="form-input" value="<%= formData.weight_kg || '' %>" placeholder="Total weight in kg">
</div>
<div class="form-group">
<label class="form-label">Material Type</label>
<input type="text" name="material_type" class="form-input" value="<%= formData.material_type || '' %>" placeholder="e.g. Electronics, Furniture">
</div>
</div>
<h4 style="margin:24px 0 16px;color:#000080;">Budget</h4>
<div class="form-row">
<div class="form-group">
<label class="form-label">Min Budget (&#8377;)</label>
<input type="number" name="budget_min" class="form-input" value="<%= formData.budget_min || '' %>" placeholder="Minimum you'll pay">
</div>
<div class="form-group">
<label class="form-label">Max Budget (&#8377;)</label>
<input type="number" name="budget_max" class="form-input" value="<%= formData.budget_max || '' %>" placeholder="Maximum you'll pay">
</div>
<div class="form-group">
<label class="form-label">Expires In</label>
<select name="expires_in_days" class="form-input">
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7" selected>7 days</option>
<option value="14">14 days</option>
<option value="30">30 days</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Additional Details</label>
<textarea name="description" class="form-input" rows="3" placeholder="Any special requirements, handling instructions, contact details..."><%= formData.description || '' %></textarea>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block">Post Load &amp; Receive Bids</button>
</form>
</div>
</div>
<%- include('../partials/portal-footer') %>

View file

@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FreightDesk — India's Freight Marketplace</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<style>
.landing-hero {
background: linear-gradient(135deg, #000080 0%, #1a1a9a 50%, #000080 100%);
color: white;
padding: 80px 20px;
text-align: center;
}
.landing-hero h1 { font-size: 42px; margin-bottom: 12px; }
.landing-hero .hi { font-size: 24px; opacity: 0.9; margin-bottom: 8px; }
.landing-hero p { font-size: 18px; opacity: 0.8; max-width: 600px; margin: 0 auto 32px; }
.landing-hero .cta-buttons { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
.landing-hero .btn { padding: 14px 32px; font-size: 16px; }
.btn-white { background: white; color: #000080; }
.btn-white:hover { background: #f0f0f0; }
.btn-outline-white { background: transparent; color: white; border: 2px solid white; }
.btn-outline-white:hover { background: rgba(255,255,255,0.1); }
.features-section { padding: 60px 20px; max-width: 1100px; margin: 0 auto; }
.features-section h2 { text-align: center; font-size: 28px; margin-bottom: 40px; color: #000080; }
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
.feature-card { padding: 24px; border-radius: 12px; border: 1px solid #e0ddd5; text-align: center; }
.feature-icon { font-size: 40px; margin-bottom: 12px; }
.feature-card h3 { font-size: 18px; margin-bottom: 8px; }
.feature-card p { color: #666; font-size: 14px; }
.stats-section { background: #f8f9fa; padding: 40px 20px; text-align: center; }
.stats-grid { display: flex; justify-content: center; gap: 48px; flex-wrap: wrap; max-width: 800px; margin: 0 auto; }
.stat-item .number { font-size: 36px; font-weight: 700; color: #000080; }
.stat-item .label { font-size: 14px; color: #666; }
.how-section { padding: 60px 20px; max-width: 900px; margin: 0 auto; }
.how-section h2 { text-align: center; font-size: 28px; margin-bottom: 40px; color: #000080; }
.how-steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 24px; }
.how-step { text-align: center; }
.how-step .step-num { width: 48px; height: 48px; background: #000080; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 700; margin: 0 auto 12px; }
.how-step h4 { margin-bottom: 6px; }
.how-step p { font-size: 13px; color: #666; }
.footer-landing { background: #1a1a2e; color: white; padding: 40px 20px; text-align: center; }
.footer-landing .tricolor { display: flex; height: 3px; max-width: 200px; margin: 0 auto 20px; }
.footer-landing .tricolor span { flex: 1; }
.footer-landing .tricolor span:nth-child(1) { background: #FF9933; }
.footer-landing .tricolor span:nth-child(2) { background: #FFFFFF; }
.footer-landing .tricolor span:nth-child(3) { background: #138808; }
.footer-landing p { opacity: 0.7; font-size: 13px; }
@media (max-width: 600px) {
.landing-hero h1 { font-size: 28px; }
.landing-hero .hi { font-size: 18px; }
.landing-hero p { font-size: 15px; }
.stats-grid { gap: 24px; }
.stat-item .number { font-size: 28px; }
}
</style>
</head>
<body>
<!-- Navbar -->
<nav class="topbar" style="position:relative;">
<div class="topbar-brand">
<div class="emblem">&#127760;</div>
<div class="brand-text">
<span class="brand-hi">फ्रेटडेस्क</span>
<span class="brand-en">FreightDesk</span>
</div>
</div>
<div class="topbar-actions">
<a href="/portal/login" class="btn btn-sm btn-outline" style="color:white;border-color:rgba(255,255,255,0.5);">Login</a>
<a href="/register/shipper" class="btn btn-sm btn-white">Register</a>
</div>
</nav>
<!-- Hero -->
<section class="landing-hero">
<div class="tricolor" style="display:flex;height:4px;max-width:120px;margin:0 auto 24px;">
<span style="flex:1;background:#FF9933;"></span>
<span style="flex:1;background:#fff;"></span>
<span style="flex:1;background:#138808;"></span>
</div>
<h1>India's Freight Marketplace</h1>
<p class="hi">भारत का फ्रेट मार्केटप्लेस</p>
<p>Connect shippers with verified truck drivers. Post loads, get competitive bids, negotiate prices — all in one platform.</p>
<div class="cta-buttons">
<a href="/register/shipper" class="btn btn-white">&#127970; Register as Shipper</a>
<a href="/register/driver" class="btn btn-outline-white">&#128666; Register as Driver</a>
</div>
</section>
<!-- Stats -->
<section class="stats-section">
<div class="stats-grid">
<div class="stat-item">
<div class="number">500+</div>
<div class="label">Verified Drivers</div>
</div>
<div class="stat-item">
<div class="number">200+</div>
<div class="label">Active Shippers</div>
</div>
<div class="stat-item">
<div class="number">10,000+</div>
<div class="label">Loads Delivered</div>
</div>
<div class="stat-item">
<div class="number">₹50L+</div>
<div class="label">Freight Value</div>
</div>
</div>
</section>
<!-- Features -->
<section class="features-section">
<h2>Why FreightDesk?</h2>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">&#128176;</div>
<h3>Competitive Bidding</h3>
<p>Get multiple bids from verified drivers. Choose the best price for your load.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128274;</div>
<h3>Verified Partners</h3>
<p>All drivers and shippers are verified. Track records and ratings visible.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128200;</div>
<h3>Real-time Tracking</h3>
<p>Track your shipment in real-time. Get notifications at every milestone.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128188;</div>
<h3>Secure Payments</h3>
<p>Escrow payment protection. Pay only when delivery is confirmed.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128241;</div>
<h3>WhatsApp Integration</h3>
<p>Post loads directly from WhatsApp. No app needed for basic operations.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#127760;</div>
<h3>Pan-India Network</h3>
<p>Connect with drivers and shippers across all states of India.</p>
</div>
</div>
</section>
<!-- How it works -->
<section class="how-section">
<h2>How It Works</h2>
<div class="how-steps">
<div class="how-step">
<div class="step-num">1</div>
<h4>Register</h4>
<p>Sign up as shipper or driver. Quick verification process.</p>
</div>
<div class="how-step">
<div class="step-num">2</div>
<h4>Post / Browse</h4>
<p>Shippers post loads. Drivers browse available loads.</p>
</div>
<div class="how-step">
<div class="step-num">3</div>
<h4>Bid & Negotiate</h4>
<p>Drivers bid. Shippers compare and negotiate prices.</p>
</div>
<div class="how-step">
<div class="step-num">4</div>
<h4>Deliver & Pay</h4>
<p>Track delivery. Release payment on confirmation.</p>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer-landing">
<div class="tricolor"><span></span><span></span><span></span></div>
<p>&copy; 2026 FreightDesk — India's Freight Marketplace</p>
<p style="margin-top:8px;">Govt. of India Initiative · Ministry of Road Transport & Highways</p>
</footer>
</body>
</html>

View file

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register as Driver — FreightDesk</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="auth-page">
<div class="login-page">
<div class="login-container" style="max-width:520px;">
<div class="login-header">
<div class="login-emblem">&#128666;</div>
<h1 class="login-title-hi">ड्राइवर पंजीकरण</h1>
<h2 class="login-title-en">Register as Driver</h2>
<p class="login-tagline">Join FreightDesk to find loads and grow your business</p>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<form method="POST" action="/register/driver" class="login-form">
<h4 style="margin:0 0 12px;font-size:14px;color:#000080;">Personal Details</h4>
<div class="form-row">
<div class="form-group">
<label class="form-label">Full Name *</label>
<input type="text" name="name" class="form-input" required value="<%= formData.name || '' %>" placeholder="Your name">
</div>
<div class="form-group">
<label class="form-label">Phone Number *</label>
<input type="tel" name="phone" class="form-input" required value="<%= formData.phone || '' %>" placeholder="10-digit mobile" pattern="[0-9]{10}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-input" value="<%= formData.email || '' %>" placeholder="email@example.com">
</div>
<div class="form-group">
<label class="form-label">Driving License</label>
<input type="text" name="driver_license" class="form-input" value="<%= formData.driver_license || '' %>" placeholder="License number">
</div>
</div>
<h4 style="margin:16px 0 12px;font-size:14px;color:#000080;">Vehicle Details</h4>
<div class="form-row">
<div class="form-group">
<label class="form-label">Vehicle Number *</label>
<input type="text" name="vehicle_number" class="form-input" required value="<%= formData.vehicle_number || '' %>" placeholder="e.g. KL01AB1234" style="text-transform:uppercase;">
</div>
<div class="form-group">
<label class="form-label">Vehicle Type</label>
<select name="vehicle_type" class="form-input">
<option value="mini_truck" <%= formData.vehicle_type === 'mini_truck' ? 'selected' : '' %>>Mini Truck</option>
<option value="truck" <%= formData.vehicle_type === 'truck' ? 'selected' : '' %>>Truck</option>
<option value="trailer" <%= formData.vehicle_type === 'trailer' ? 'selected' : '' %>>Trailer</option>
<option value="container" <%= formData.vehicle_type === 'container' ? 'selected' : '' %>>Container</option>
<option value="tanker" <%= formData.vehicle_type === 'tanker' ? 'selected' : '' %>>Tanker</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Capacity (Tons)</label>
<input type="number" name="capacity_tons" class="form-input" value="<%= formData.capacity_tons || '' %>" placeholder="e.g. 10" step="0.5" min="0.5" max="40">
</div>
<div class="form-group">
<label class="form-label">Current City</label>
<input type="text" name="current_city" class="form-input" value="<%= formData.current_city || '' %>" placeholder="Where are you now?">
</div>
</div>
<h4 style="margin:16px 0 12px;font-size:14px;color:#000080;">Account</h4>
<div class="form-row">
<div class="form-group">
<label class="form-label">Password *</label>
<input type="password" name="password" class="form-input" required minlength="6" placeholder="Min 6 characters">
</div>
<div class="form-group">
<label class="form-label">Confirm Password *</label>
<input type="password" name="confirm_password" class="form-input" required minlength="6" placeholder="Re-enter password">
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Driver Account</button>
</form>
<div class="login-footer">
<p>Already registered? <a href="/portal/login">Login here</a></p>
<p style="margin-top:8px;"><a href="/register/shipper">Register as Shipper instead</a></p>
<div class="footer-tricolor" style="margin-top:16px;"><span></span><span></span><span></span></div>
</div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register as Shipper — FreightDesk</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="auth-page">
<div class="login-page">
<div class="login-container" style="max-width:520px;">
<div class="login-header">
<div class="login-emblem">&#127970;</div>
<h1 class="login-title-hi">शिपर पंजीकरण</h1>
<h2 class="login-title-en">Register as Shipper</h2>
<p class="login-tagline">Join FreightDesk to post loads and find reliable drivers</p>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<form method="POST" action="/register/shipper" class="login-form">
<div class="form-row">
<div class="form-group">
<label class="form-label">Full Name *</label>
<input type="text" name="name" class="form-input" required value="<%= formData.name || '' %>" placeholder="Your name">
</div>
<div class="form-group">
<label class="form-label">Phone Number *</label>
<input type="tel" name="phone" class="form-input" required value="<%= formData.phone || '' %>" placeholder="10-digit mobile" pattern="[0-9]{10}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-input" value="<%= formData.email || '' %>" placeholder="email@example.com">
</div>
<div class="form-group">
<label class="form-label">GST Number</label>
<input type="text" name="gst_number" class="form-input" value="<%= formData.gst_number || '' %>" placeholder="Optional">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Company Name</label>
<input type="text" name="company_name" class="form-input" value="<%= formData.company_name || '' %>" placeholder="Optional">
</div>
<div class="form-group">
<label class="form-label">City *</label>
<input type="text" name="city" class="form-input" required value="<%= formData.city || '' %>" placeholder="Your city">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">State</label>
<input type="text" name="state" class="form-input" value="<%= formData.state || '' %>" placeholder="State">
</div>
<div class="form-group">
<label class="form-label">Pincode</label>
<input type="text" name="pincode" class="form-input" value="<%= formData.pincode || '' %}" placeholder="6-digit pincode" pattern="[0-9]{6}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Password *</label>
<input type="password" name="password" class="form-input" required minlength="6" placeholder="Min 6 characters">
</div>
<div class="form-group">
<label class="form-label">Confirm Password *</label>
<input type="password" name="confirm_password" class="form-input" required minlength="6" placeholder="Re-enter password">
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Shipper Account</button>
</form>
<div class="login-footer">
<p>Already registered? <a href="/portal/login">Login here</a></p>
<p style="margin-top:8px;"><a href="/register/driver">Register as Driver instead</a></p>
<div class="footer-tricolor" style="margin-top:16px;"><span></span><span></span><span></span></div>
</div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,22 @@
</main>
</div>
<script src="/js/app.js"></script>
<script>
// Fetch unread notification count for badge
(async function() {
try {
const res = await fetch('/marketplace/notifications/count');
if (res.ok) {
const data = await res.json();
const badge = document.getElementById('notif-badge');
if (badge && data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.style.display = 'block';
}
}
} catch(e) {}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof title !== 'undefined' ? title : 'FreightDesk Portal' %></title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- Portal Topbar -->
<nav class="topbar">
<div class="topbar-brand">
<div class="emblem">&#127760;</div>
<div class="brand-text">
<span class="brand-hi">फ्रेटडेस्क</span>
<span class="brand-en">FreightDesk</span>
<span style="font-size:11px;background:#f59e0b;color:#fff;padding:2px 8px;border-radius:4px;margin-left:8px;">PORTAL</span>
</div>
</div>
<div class="topbar-actions" style="display:flex;align-items:center;gap:12px;">
<a href="/marketplace/notifications" class="btn-icon" style="position:relative;" title="Notifications">
&#128276;
<span id="notif-badge" style="position:absolute;top:-4px;right:-4px;background:#dc3545;color:white;font-size:10px;padding:1px 5px;border-radius:10px;display:none;">0</span>
</a>
<span class="user-name">&#128100; <%= portalUser ? portalUser.username : '' %></span>
<span class="badge badge-<%= portalUser && portalUser.role === 'shipper' ? 'success' : 'primary' %>">
<%= portalUser ? portalUser.role : '' %>
</span>
<a href="/portal/logout" class="btn btn-sm btn-outline" style="color:white;border-color:rgba(255,255,255,0.5);">Logout</a>
</div>
</nav>
<!-- Portal Sidebar + Content -->
<div style="display:flex;min-height:calc(100vh - 64px);">
<aside class="sidebar" style="width:220px;flex-shrink:0;background:#fff;border-right:1px solid #e0ddd5;">
<div class="sidebar-section" style="padding:12px 16px;">
<span class="sidebar-title">Menu</span>
<a href="/portal/dashboard" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'dashboard' ? 'active' : '' %>">&#127970; Dashboard</a>
<a href="/marketplace" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'marketplace' ? 'active' : '' %>">&#128666; Marketplace</a>
<% if (portalUser && portalUser.role === 'shipper') { %>
<a href="/marketplace/post" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'post' ? 'active' : '' %>">&#128228; Post Load</a>
<a href="/portal/my-loads" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'my-loads' ? 'active' : '' %>">&#128209; My Loads</a>
<a href="/portal/payments" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'payments' ? 'active' : '' %>">&#128176; Payments</a>
<% } %>
<% if (portalUser && portalUser.role === 'driver') { %>
<a href="/portal/my-trips" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'my-trips' ? 'active' : '' %>">&#128666; My Trips</a>
<a href="/portal/earnings" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'earnings' ? 'active' : '' %>">&#128176; Earnings</a>
<% } %>
<a href="/marketplace/notifications" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'notifications' ? 'active' : '' %>">&#128276; Notifications</a>
</div>
<div class="sidebar-section" style="padding:12px 16px;border-top:1px solid #e0ddd5;">
<span class="sidebar-title">Quick Links</span>
<a href="/" class="sidebar-link">&#127968; Main Site</a>
<a href="/portal/login" class="sidebar-link">&#128274; Switch Account</a>
</div>
</aside>
<main class="main-content" style="flex:1;padding:24px;background:#f8f9fa;overflow:auto;">