[OWL] SaaS Marketplace: registration, marketplace, bidding, notifications
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:
parent
c715d2aabb
commit
69d814c439
13 changed files with 1660 additions and 0 deletions
146
supabase/migrations/005_saas_marketplace.sql
Normal file
146
supabase/migrations/005_saas_marketplace.sql
Normal 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);
|
||||
314
webapp/src/routes/marketplace.js
Normal file
314
webapp/src/routes/marketplace.js
Normal 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
224
webapp/src/routes/public.js
Normal 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;
|
||||
|
|
@ -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() }));
|
||||
|
|
|
|||
118
webapp/src/views/pages/marketplace/index.ejs
Normal file
118
webapp/src/views/pages/marketplace/index.ejs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">🚚 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">🚚</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 %> → <%= 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;">📅 Pickup:</span> <%= load.pickup_date || 'Flexible' %></div>
|
||||
<div><span style="color:#666;">📍 Weight:</span> <%= load.weight_kg ? load.weight_kg + ' kg' : 'N/A' %></div>
|
||||
<div><span style="color:#666;">💰 Budget:</span>
|
||||
<% if (load.budget_max) { %>
|
||||
₹ <%= load.budget_max.toLocaleString('en-IN') %>
|
||||
<% if (load.budget_min) { %> - ₹ <%= load.budget_min.toLocaleString('en-IN') %><% } %>
|
||||
<% } else { %> Open <% } %>
|
||||
</div>
|
||||
<div><span style="color:#666;">👤 Shipper:</span> <%= load.shippers?.name || 'N/A' %></div>
|
||||
</div>
|
||||
|
||||
<% if (load.material_type) { %>
|
||||
<div style="font-size:12px;color:#666;margin-bottom:8px;">📦 <%= 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;">
|
||||
👁 <%= load.views || 0 %> views · Expires <%= new Date(load.expires_at).toLocaleDateString('en-IN') %>
|
||||
</div>
|
||||
<a href="/marketplace/load/<%= load.id %>" class="btn btn-sm btn-primary">View & 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>₹ <%= 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') %>
|
||||
213
webapp/src/views/pages/marketplace/load-detail.ejs
Normal file
213
webapp/src/views/pages/marketplace/load-detail.ejs
Normal 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">← 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 %> → <%= 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) { %>
|
||||
₹ <%= 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') %>+
|
||||
<% } %>
|
||||
</dd>
|
||||
<% } %>
|
||||
<dt>Shipper</dt>
|
||||
<dd>
|
||||
<%= load.shippers?.name || 'N/A' %>
|
||||
<% if (load.shippers?.rating > 0) { %>
|
||||
<span style="color:#f59e0b;">★</span> <%= load.shippers.rating.toFixed(1) %>
|
||||
<% } %>
|
||||
<% if (load.shippers?.total_shipments) { %>
|
||||
· <%= 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">💰 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 (₹) *</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;">₹ <%= 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">⚠ 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">💰 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) { %>
|
||||
★ <%= 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;">₹ <%= 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;">📩</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') %>
|
||||
58
webapp/src/views/pages/marketplace/notifications.ejs
Normal file
58
webapp/src/views/pages/marketplace/notifications.ejs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<%- include('../partials/portal-header', { activeMenu: 'notifications' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">🔔 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">🔔</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' ? '💰' : n.type === 'bid_accepted' ? '✅' : n.type === 'bid_rejected' ? '❌' : n.type === 'payment' ? '💼' : n.type === 'negotiation' ? '🔄' : n.type === 'load_assigned' ? '🚚' : '🔔' %>
|
||||
</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') %>
|
||||
121
webapp/src/views/pages/marketplace/post.ejs
Normal file
121
webapp/src/views/pages/marketplace/post.ejs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<%- include('../partials/portal-header', { activeMenu: 'marketplace' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">📤 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">← 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 (₹)</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 (₹)</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 & Receive Bids</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/portal-footer') %>
|
||||
189
webapp/src/views/pages/public/landing.ejs
Normal file
189
webapp/src/views/pages/public/landing.ejs
Normal 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">🌐</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">🏢 Register as Shipper</a>
|
||||
<a href="/register/driver" class="btn btn-outline-white">🚚 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">💰</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">🔒</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">📈</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">💼</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">📱</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">🌐</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>© 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>
|
||||
101
webapp/src/views/pages/public/register-driver.ejs
Normal file
101
webapp/src/views/pages/public/register-driver.ejs
Normal 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">🚚</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>
|
||||
92
webapp/src/views/pages/public/register-shipper.ejs
Normal file
92
webapp/src/views/pages/public/register-shipper.ejs
Normal 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">🏢</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>
|
||||
22
webapp/src/views/partials/portal-footer.ejs
Normal file
22
webapp/src/views/partials/portal-footer.ejs
Normal 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>
|
||||
59
webapp/src/views/partials/portal-header.ejs
Normal file
59
webapp/src/views/partials/portal-header.ejs
Normal 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">🌐</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">
|
||||
🔔
|
||||
<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">👤 <%= 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' : '' %>">🏢 Dashboard</a>
|
||||
<a href="/marketplace" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'marketplace' ? 'active' : '' %>">🚚 Marketplace</a>
|
||||
<% if (portalUser && portalUser.role === 'shipper') { %>
|
||||
<a href="/marketplace/post" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'post' ? 'active' : '' %>">📤 Post Load</a>
|
||||
<a href="/portal/my-loads" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'my-loads' ? 'active' : '' %>">📑 My Loads</a>
|
||||
<a href="/portal/payments" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'payments' ? 'active' : '' %>">💰 Payments</a>
|
||||
<% } %>
|
||||
<% if (portalUser && portalUser.role === 'driver') { %>
|
||||
<a href="/portal/my-trips" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'my-trips' ? 'active' : '' %>">🚚 My Trips</a>
|
||||
<a href="/portal/earnings" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'earnings' ? 'active' : '' %>">💰 Earnings</a>
|
||||
<% } %>
|
||||
<a href="/marketplace/notifications" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'notifications' ? 'active' : '' %>">🔔 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">🏠 Main Site</a>
|
||||
<a href="/portal/login" class="sidebar-link">🔒 Switch Account</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content" style="flex:1;padding:24px;background:#f8f9fa;overflow:auto;">
|
||||
Loading…
Reference in a new issue