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' }) %> + +
Browse available loads and place your bids
+Check back soon for new freight opportunities
+ <% if (userRole === 'shipper') { %> + Post the First Load + <% } %> +via <%= load.via %>
<% } %> +<%= load.notes %>
+"<%= userBid.message %>"
+ <% } %> +| Driver | +Vehicle | +Rating | +Amount | +Status | +Action | +
|---|---|---|---|---|---|
|
+ <%= 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 %>" | |||||
Drivers will start bidding soon. Share your load to get more visibility.
+Stay updated on bids, loads, and payments
+You're all caught up!
+<%= n.message %>
+ <% } %> +Post your freight requirement and receive bids from verified drivers
+भारत का फ्रेट मार्केटप्लेस
+Connect shippers with verified truck drivers. Post loads, get competitive bids, negotiate prices — all in one platform.
+ +Get multiple bids from verified drivers. Choose the best price for your load.
+All drivers and shippers are verified. Track records and ratings visible.
+Track your shipment in real-time. Get notifications at every milestone.
+Escrow payment protection. Pay only when delivery is confirmed.
+Post loads directly from WhatsApp. No app needed for basic operations.
+Connect with drivers and shippers across all states of India.
+Sign up as shipper or driver. Quick verification process.
+Shippers post loads. Drivers browse available loads.
+Drivers bid. Shippers compare and negotiate prices.
+Track delivery. Release payment on confirmation.
+Join FreightDesk to find loads and grow your business
+Join FreightDesk to post loads and find reliable drivers
+