diff --git a/supabase/migrations/005_saas_marketplace.sql b/supabase/migrations/005_saas_marketplace.sql new file mode 100644 index 0000000..19f980c --- /dev/null +++ b/supabase/migrations/005_saas_marketplace.sql @@ -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); diff --git a/webapp/src/routes/marketplace.js b/webapp/src/routes/marketplace.js new file mode 100644 index 0000000..f054c8c --- /dev/null +++ b/webapp/src/routes/marketplace.js @@ -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; diff --git a/webapp/src/routes/public.js b/webapp/src/routes/public.js new file mode 100644 index 0000000..dc69d60 --- /dev/null +++ b/webapp/src/routes/public.js @@ -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; diff --git a/webapp/src/server.js b/webapp/src/server.js index fad7b41..c043ba3 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -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() })); diff --git a/webapp/src/views/pages/marketplace/index.ejs b/webapp/src/views/pages/marketplace/index.ejs new file mode 100644 index 0000000..e218ba2 --- /dev/null +++ b/webapp/src/views/pages/marketplace/index.ejs @@ -0,0 +1,118 @@ +<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %> + +
+
+

🚚 Load Marketplace

+

Browse available loads and place your bids

+
+ <% if (userRole === 'shipper') { %> + + Post a Load + <% } %> +
+ +<% if (error) { %> +
<%= error %>
+<% } %> + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Clear +
+
+
+ + +<% if (!loads || loads.length === 0) { %> +
+
🚚
+

No loads available

+

Check back soon for new freight opportunities

+ <% if (userRole === 'shipper') { %> + Post the First Load + <% } %> +
+<% } else { %> +
+ <% for (const load of loads) { %> +
+
+
+
+ <%= load.from_city %> → <%= load.to_city %> +
+ <% if (load.via) { %> +
via <%= load.via %>
+ <% } %> +
+ + <%= load.load_type ? load.load_type.toUpperCase() : 'FTL' %> + +
+ +
+
📅 Pickup: <%= load.pickup_date || 'Flexible' %>
+
📍 Weight: <%= load.weight_kg ? load.weight_kg + ' kg' : 'N/A' %>
+
💰 Budget: + <% if (load.budget_max) { %> + ₹ <%= load.budget_max.toLocaleString('en-IN') %> + <% if (load.budget_min) { %> - ₹ <%= load.budget_min.toLocaleString('en-IN') %><% } %> + <% } else { %> Open <% } %> +
+
👤 Shipper: <%= load.shippers?.name || 'N/A' %>
+
+ + <% if (load.material_type) { %> +
📦 <%= load.material_type %>
+ <% } %> + +
+
+ 👁 <%= load.views || 0 %> views · Expires <%= new Date(load.expires_at).toLocaleDateString('en-IN') %> +
+ View & Bid +
+ + <% if (userRole === 'driver') { %> + <% const myBid = myBids.find(b => b.load_id === load.id); %> + <% if (myBid) { %> +
+ Your bid: ₹ <%= myBid.amount.toLocaleString('en-IN') %> + + <%= myBid.status %> + +
+ <% } %> + <% } %> +
+ <% } %> +
+<% } %> + +<%- include('../partials/portal-footer') %> diff --git a/webapp/src/views/pages/marketplace/load-detail.ejs b/webapp/src/views/pages/marketplace/load-detail.ejs new file mode 100644 index 0000000..294ec98 --- /dev/null +++ b/webapp/src/views/pages/marketplace/load-detail.ejs @@ -0,0 +1,213 @@ +<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %> + +
+ ← Back to Marketplace + + +
+
+
+
+

<%= load.from_city %> → <%= load.to_city %>

+ <% if (load.via) { %>

via <%= load.via %>

<% } %> +
+ + <%= load.load_type ? load.load_type.toUpperCase() : 'FTL' %> + +
+
+
+
+
From
<%= load.from_city %>
+
To
<%= load.to_city %>
+ <% if (load.pickup_address) { %>
Pickup Address
<%= load.pickup_address %><% if (load.pickup_pincode) { %> - <%= load.pickup_pincode %><% } %>
<% } %> + <% if (load.delivery_address) { %>
Delivery Address
<%= load.delivery_address %><% if (load.delivery_pincode) { %> - <%= load.delivery_pincode %><% } %>
<% } %> +
Pickup Date
<%= load.pickup_date || 'Flexible' %>
+ <% if (load.delivery_date) { %>
Delivery Date
<%= load.delivery_date %>
<% } %> + <% if (load.weight_kg) { %>
Weight
<%= load.weight_kg %> kg
<% } %> + <% if (load.material_type) { %>
Material
<%= load.material_type %>
<% } %> + <% if (load.budget_min || load.budget_max) { %> +
Budget
+
+ <% if (load.budget_min && load.budget_max) { %> + ₹ <%= load.budget_min.toLocaleString('en-IN') %> - ₹ <%= load.budget_max.toLocaleString('en-IN') %> + <% } else if (load.budget_max) { %> + Up to ₹ <%= load.budget_max.toLocaleString('en-IN') %> + <% } else { %> + ₹ <%= load.budget_min.toLocaleString('en-IN') %>+ + <% } %> +
+ <% } %> +
Shipper
+
+ <%= load.shippers?.name || 'N/A' %> + <% if (load.shippers?.rating > 0) { %> +  ★ <%= load.shippers.rating.toFixed(1) %> + <% } %> + <% if (load.shippers?.total_shipments) { %> + · <%= load.shippers.total_shipments %> shipments + <% } %> +
+
Expires
<%= new Date(load.expires_at).toLocaleDateString('en-IN') %>
+
Views
<%= load.views || 0 %>
+
+ <% if (load.notes) { %> +
+ Additional Details: +

<%= load.notes %>

+
+ <% } %> +
+
+ + + <% if (userRole === 'driver') { %> +
+

💰 Your Bid

+
+ <% if (load.is_open && new Date(load.expires_at) > new Date()) { %> + <% if (!userBid) { %> +
+
+
+ + +
+
+
+ + +
+ +
+ <% } else { %> +
+
+
+ Your bid: ₹ <%= userBid.amount.toLocaleString('en-IN') %> + + <%= userBid.status %> + +
+ <% if (userBid.status === 'pending') { %> +
+
+ + +
+
+ <% } %> +
+ <% if (userBid.message) { %> +

"<%= userBid.message %>"

+ <% } %> +
+ <% } %> + <% } else { %> +
⚠ This load is no longer accepting bids.
+ <% } %> +
+
+ <% } %> + + + <% if (isShipperOwner && bids.length > 0) { %> +
+
+

💰 Bids Received (<%= bids.length %>)

+
+
+ + + + + + + + + + + + + <% for (const bid of bids) { %> + + + + + + + + + <% if (bid.message) { %> + + <% } %> + <% } %> + +
DriverVehicleRatingAmountStatusAction
+ <%= bid.vehicles?.driver_name || 'N/A' %> +
<%= bid.vehicles?.driver_phone || '' %>
+
+ <%= bid.vehicles?.number || 'N/A' %> + <% if (bid.vehicles?.vehicle_type) { %> + <%= bid.vehicles.vehicle_type %> + <% } %> + + <% if (bid.vehicles?.rating > 0) { %> + ★ <%= bid.vehicles.rating.toFixed(1) %> + (<%= bid.vehicles.total_trips || 0 %> trips) + <% } else { %>New<% } %> + ₹ <%= bid.amount.toLocaleString('en-IN') %> + + <%= bid.status %> + + + <% if (bid.status === 'pending') { %> +
+ +
+ +
+ + +
+ <% } %> +
"<%= bid.message %>"
+
+
+ <% } %> + + + <% if (isShipperOwner && bids.length === 0) { %> +
+
+
📩
+

No bids yet

+

Drivers will start bidding soon. Share your load to get more visibility.

+
+
+ <% } %> +
+ + + +<%- include('../partials/portal-footer') %> diff --git a/webapp/src/views/pages/marketplace/notifications.ejs b/webapp/src/views/pages/marketplace/notifications.ejs new file mode 100644 index 0000000..c4842a0 --- /dev/null +++ b/webapp/src/views/pages/marketplace/notifications.ejs @@ -0,0 +1,58 @@ +<%- include('../partials/portal-header', { activeMenu: 'notifications' }) %> + +
+
+

🔔 Notifications

+

Stay updated on bids, loads, and payments

+
+ +
+ +<% if (!notifications || notifications.length === 0) { %> +
+
🔔
+

No notifications

+

You're all caught up!

+
+<% } else { %> +
+
+ <% for (const n of notifications) { %> +
+
+
+ + <%= n.type === 'bid_received' ? '💰' : n.type === 'bid_accepted' ? '✅' : n.type === 'bid_rejected' ? '❌' : n.type === 'payment' ? '💼' : n.type === 'negotiation' ? '🔄' : n.type === 'load_assigned' ? '🚚' : '🔔' %> + + <%= n.title %> + <% if (!n.is_read) { %>NEW<% } %> +
+ <% if (n.message) { %> +

<%= n.message %>

+ <% } %> +
+ <%= new Date(n.created_at).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) %> +
+
+ <% if (!n.is_read) { %> + + <% } %> +
+ <% } %> +
+
+<% } %> + + + +<%- include('../partials/portal-footer') %> diff --git a/webapp/src/views/pages/marketplace/post.ejs b/webapp/src/views/pages/marketplace/post.ejs new file mode 100644 index 0000000..2497f3f --- /dev/null +++ b/webapp/src/views/pages/marketplace/post.ejs @@ -0,0 +1,121 @@ +<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %> + +
+
+

📤 Post a Load

+

Post your freight requirement and receive bids from verified drivers

+
+ ← Back to Marketplace +
+ +<% if (error) { %> +
<%= error %>
+<% } %> + +
+
+
+ + +

Route Details

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +

Load Details

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Budget

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+
+ +<%- include('../partials/portal-footer') %> diff --git a/webapp/src/views/pages/public/landing.ejs b/webapp/src/views/pages/public/landing.ejs new file mode 100644 index 0000000..7771211 --- /dev/null +++ b/webapp/src/views/pages/public/landing.ejs @@ -0,0 +1,189 @@ + + + + + + FreightDesk — India's Freight Marketplace + + + + + + +
+
+
🌐
+
+ फ्रेटडेस्क + FreightDesk +
+
+
+ Login + Register +
+
+ + +
+
+ + + +
+

India's Freight Marketplace

+

भारत का फ्रेट मार्केटप्लेस

+

Connect shippers with verified truck drivers. Post loads, get competitive bids, negotiate prices — all in one platform.

+
+ 🏢 Register as Shipper + 🚚 Register as Driver +
+
+ + +
+
+
+
500+
+
Verified Drivers
+
+
+
200+
+
Active Shippers
+
+
+
10,000+
+
Loads Delivered
+
+
+
₹50L+
+
Freight Value
+
+
+
+ + +
+

Why FreightDesk?

+
+
+
💰
+

Competitive Bidding

+

Get multiple bids from verified drivers. Choose the best price for your load.

+
+
+
🔒
+

Verified Partners

+

All drivers and shippers are verified. Track records and ratings visible.

+
+
+
📈
+

Real-time Tracking

+

Track your shipment in real-time. Get notifications at every milestone.

+
+
+
💼
+

Secure Payments

+

Escrow payment protection. Pay only when delivery is confirmed.

+
+
+
📱
+

WhatsApp Integration

+

Post loads directly from WhatsApp. No app needed for basic operations.

+
+
+
🌐
+

Pan-India Network

+

Connect with drivers and shippers across all states of India.

+
+
+
+ + +
+

How It Works

+
+
+
1
+

Register

+

Sign up as shipper or driver. Quick verification process.

+
+
+
2
+

Post / Browse

+

Shippers post loads. Drivers browse available loads.

+
+
+
3
+

Bid & Negotiate

+

Drivers bid. Shippers compare and negotiate prices.

+
+
+
4
+

Deliver & Pay

+

Track delivery. Release payment on confirmation.

+
+
+
+ + +
+
+

© 2026 FreightDesk — India's Freight Marketplace

+

Govt. of India Initiative · Ministry of Road Transport & Highways

+
+ + diff --git a/webapp/src/views/pages/public/register-driver.ejs b/webapp/src/views/pages/public/register-driver.ejs new file mode 100644 index 0000000..3eefe44 --- /dev/null +++ b/webapp/src/views/pages/public/register-driver.ejs @@ -0,0 +1,101 @@ + + + + + + Register as Driver — FreightDesk + + + + +
+
+
+
🚚
+

ड्राइवर पंजीकरण

+

Register as Driver

+

Join FreightDesk to find loads and grow your business

+
+ + <% if (error) { %> +
<%= error %>
+ <% } %> + +
+

Personal Details

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +

Vehicle Details

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +

Account

+
+
+ + +
+
+ + +
+
+ + +
+ +
+

Already registered? Login here

+

Register as Shipper instead

+
+
+
+
+ + + diff --git a/webapp/src/views/pages/public/register-shipper.ejs b/webapp/src/views/pages/public/register-shipper.ejs new file mode 100644 index 0000000..5efef0b --- /dev/null +++ b/webapp/src/views/pages/public/register-shipper.ejs @@ -0,0 +1,92 @@ + + + + + + Register as Shipper — FreightDesk + + + + +
+
+
+
🏢
+

शिपर पंजीकरण

+

Register as Shipper

+

Join FreightDesk to post loads and find reliable drivers

+
+ + <% if (error) { %> +
<%= error %>
+ <% } %> + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+

Already registered? Login here

+

Register as Driver instead

+
+
+
+
+ + + diff --git a/webapp/src/views/partials/portal-footer.ejs b/webapp/src/views/partials/portal-footer.ejs new file mode 100644 index 0000000..699a129 --- /dev/null +++ b/webapp/src/views/partials/portal-footer.ejs @@ -0,0 +1,22 @@ + + + + + + + diff --git a/webapp/src/views/partials/portal-header.ejs b/webapp/src/views/partials/portal-header.ejs new file mode 100644 index 0000000..9e08c1b --- /dev/null +++ b/webapp/src/views/partials/portal-header.ejs @@ -0,0 +1,59 @@ + + + + + + <%= typeof title !== 'undefined' ? title : 'FreightDesk Portal' %> + + + + + +
+
+
🌐
+
+ फ्रेटडेस्क + FreightDesk + PORTAL +
+
+
+ + 🔔 + 0 + + 👤 <%= portalUser ? portalUser.username : '' %> + + <%= portalUser ? portalUser.role : '' %> + + Logout +
+
+ + +
+
+
+ Menu + 🏢 Dashboard + 🚚 Marketplace + <% if (portalUser && portalUser.role === 'shipper') { %> + 📤 Post Load + 📑 My Loads + 💰 Payments + <% } %> + <% if (portalUser && portalUser.role === 'driver') { %> + 🚚 My Trips + 💰 Earnings + <% } %> + 🔔 Notifications +
+
+ Quick Links + 🏠 Main Site + 🔒 Switch Account +
+
+ +