feat: visual polish, nav login link, pricing badge fix, cursor fix, button contrast

- Hero mockup: enhanced 3D perspective and shadow
- Testimonials: illustrated SVG avatars
- Growth pricing card: visual prominence (scale, gradient, badge)
- Most Popular badge: repositioned to avoid overlapping heading
- Nav: added Log In link next to Start Free Trial
- Fixed btn-primary text colour on anchor tags (white on blue)
- Fixed cursor: default on all non-interactive elements
- Disabled user-select on non-form content to prevent text caret
This commit is contained in:
Peter Foster
2026-02-14 14:17:15 +00:00
parent d431d0fcfa
commit f969ecae04
69 changed files with 23884 additions and 471 deletions

264
server.js Executable file → Normal file
View File

@@ -5,6 +5,17 @@ import pg from 'pg';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import {
createCheckoutSession,
getSubscriptionStatus,
createPortalSession,
handleWebhookEvent,
verifyWebhookSignature
} from './stripe-billing.js';
import {
attachSubscription,
requireActiveSubscription
} from './subscription-middleware.js';
dotenv.config();
@@ -15,6 +26,11 @@ const pool = new pg.Pool({
// Middleware
app.use(cors());
// Raw body parser for webhooks (must be before express.json())
app.use('/api/billing/webhook', express.raw({ type: 'application/json' }));
// JSON parser for all other routes
app.use(express.json());
const limiter = rateLimit({
@@ -36,6 +52,9 @@ const verifyToken = (req, res, next) => {
}
};
// Attach subscription info to request (after token verification)
app.use('/api/', attachSubscription(pool));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
@@ -99,31 +118,124 @@ app.post('/api/auth/login', async (req, res) => {
}
});
// GET /api/tenders
// GET /api/tenders - Enhanced with filters
app.get('/api/tenders', verifyToken, async (req, res) => {
try {
const { search, sort, limit, offset } = req.query;
const { search, sort, limit, offset, sources, min_value, max_value, deadline_days, sectors } = req.query;
let query = 'SELECT * FROM tenders WHERE status = $1';
const params = ['open'];
let paramIndex = 2;
// Search filter
if (search) {
query += ` AND (title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
query += ` ORDER BY ${sort === 'value' ? 'value_high DESC' : 'deadline ASC'} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
params.push(Math.min(parseInt(limit) || 20, 100), parseInt(offset) || 0);
// Source filter
if (sources) {
const sourceList = sources.split(',').map(s => s.trim());
const placeholders = sourceList.map(() => `$${paramIndex++}`).join(',');
query += ` AND source IN (${placeholders})`;
params.push(...sourceList);
}
// Value range filter
if (min_value) {
query += ` AND value_high >= $${paramIndex}`;
params.push(parseFloat(min_value));
paramIndex++;
}
if (max_value) {
query += ` AND value_high <= $${paramIndex}`;
params.push(parseFloat(max_value));
paramIndex++;
}
// Deadline filter
if (deadline_days) {
const daysNum = parseInt(deadline_days);
query += ` AND deadline <= CURRENT_DATE + INTERVAL '${daysNum} days'`;
}
// Sector filter
if (sectors) {
const sectorList = sectors.split(',').map(s => s.trim());
const placeholders = sectorList.map(() => `$${paramIndex++}`).join(',');
query += ` AND sector IN (${placeholders})`;
params.push(...sectorList);
}
// Count total before pagination
const countQuery = query.replace('SELECT *', 'SELECT COUNT(*) as count');
const countResult = await pool.query(countQuery, params);
const totalCount = parseInt(countResult.rows[0].count);
// Sorting
query += ` ORDER BY ${sort === 'value' ? 'value_high DESC' : 'deadline ASC'}`;
// Pagination
query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
const pageLimit = Math.min(parseInt(limit) || 20, 100);
const pageOffset = parseInt(offset) || 0;
params.push(pageLimit, pageOffset);
const result = await pool.query(query, params);
res.json({ tenders: result.rows, total: result.rows.length });
res.json({ tenders: result.rows, total: totalCount });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to fetch tenders' });
}
});
// GET /api/tenders/stats - Dashboard statistics
app.get('/api/tenders/stats', verifyToken, async (req, res) => {
try {
// Total open tenders
const totalResult = await pool.query(
'SELECT COUNT(*) as count FROM tenders WHERE status = $1',
['open']
);
const total = parseInt(totalResult.rows[0].count);
// New this week
const newResult = await pool.query(
'SELECT COUNT(*) as count FROM tenders WHERE status = $1 AND created_at >= CURRENT_DATE - INTERVAL \'7 days\'',
['open']
);
const newThisWeek = parseInt(newResult.rows[0].count);
// Closing soon (next 7 days)
const closingResult = await pool.query(
'SELECT COUNT(*) as count FROM tenders WHERE status = $1 AND deadline <= CURRENT_DATE + INTERVAL \'7 days\' AND deadline >= CURRENT_DATE',
['open']
);
const closingSoon = parseInt(closingResult.rows[0].count);
// By source
const sourceResult = await pool.query(
'SELECT source, COUNT(*) as count FROM tenders WHERE status = $1 GROUP BY source',
['open']
);
const bySource = sourceResult.rows.reduce((acc, row) => {
acc[row.source] = parseInt(row.count);
return acc;
}, {});
res.json({
total,
new_this_week: newThisWeek,
closing_soon: closingSoon,
matched_to_profile: 0,
by_source: bySource
});
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to fetch statistics' });
}
});
// GET /api/tenders/:id
app.get('/api/tenders/:id', verifyToken, async (req, res) => {
try {
@@ -176,6 +288,148 @@ app.get('/api/matches', verifyToken, async (req, res) => {
}
});
// GET /api/alerts/preferences
app.get('/api/alerts/preferences', verifyToken, async (req, res) => {
try {
const result = await pool.query(
'SELECT id, user_id, keywords, sectors, min_value, max_value, locations, authority_types, created_at, updated_at FROM profiles WHERE user_id = $1',
[req.user.id]
);
if (result.rows.length === 0) {
return res.json({ preferences: null });
}
res.json({ preferences: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to fetch alert preferences' });
}
});
// POST /api/alerts/preferences
app.post('/api/alerts/preferences', verifyToken, async (req, res) => {
try {
const { keywords, sectors, min_value, max_value, locations, authority_types } = req.body;
// Validate value ranges
if (min_value && max_value && min_value > max_value) {
return res.status(400).json({ error: 'min_value cannot be greater than max_value' });
}
const result = await pool.query(
`INSERT INTO profiles (user_id, keywords, sectors, min_value, max_value, locations, authority_types)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE SET
keywords = $2, sectors = $3, min_value = $4, max_value = $5, locations = $6, authority_types = $7, updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, keywords, sectors, min_value, max_value, locations, authority_types, created_at, updated_at`,
[req.user.id, keywords || [], sectors || [], min_value || null, max_value || null, locations || [], authority_types || []]
);
res.json({
preferences: result.rows[0],
message: 'Alert preferences updated successfully'
});
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to save alert preferences' });
}
});
// ===== BILLING ROUTES =====
// POST /api/billing/checkout - Create a checkout session
app.post('/api/billing/checkout', verifyToken, async (req, res) => {
try {
const { plan, successUrl, cancelUrl } = req.body;
if (!plan || !successUrl || !cancelUrl) {
return res.status(400).json({ error: 'plan, successUrl, and cancelUrl are required' });
}
const user = await pool.query('SELECT email FROM users WHERE id = $1', [req.user.id]);
if (user.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
const session = await createCheckoutSession(
pool,
req.user.id,
user.rows[0].email,
plan,
successUrl,
cancelUrl
);
res.json({
sessionId: session.id,
url: session.url
});
} catch (error) {
console.error('Checkout error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/billing/webhook - Stripe webhook handler
app.post('/api/billing/webhook', async (req, res) => {
const signature = req.headers['stripe-signature'];
try {
const event = verifyWebhookSignature(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
await handleWebhookEvent(pool, event);
res.json({ received: true });
} catch (error) {
console.error('Webhook error:', error.message);
res.status(400).json({ error: 'Webhook signature verification failed' });
}
});
// GET /api/billing/subscription - Get current subscription status
app.get('/api/billing/subscription', verifyToken, async (req, res) => {
try {
const subscription = await getSubscriptionStatus(pool, req.user.id);
if (!subscription) {
return res.json({
subscription: null,
message: 'No active subscription. User is on free tier.'
});
}
res.json({ subscription });
} catch (error) {
console.error('Subscription status error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/billing/portal - Create Stripe Customer Portal session
app.post('/api/billing/portal', verifyToken, async (req, res) => {
try {
const { returnUrl } = req.body;
if (!returnUrl) {
return res.status(400).json({ error: 'returnUrl is required' });
}
const session = await createPortalSession(pool, req.user.id, returnUrl);
res.json({
url: session.url
});
} catch (error) {
console.error('Portal session error:', error);
res.status(500).json({ error: error.message });
}
});
// Error handling
app.use((err, req, res, next) => {
console.error(err);