Multi-page public site (Next.js), Node/Express backend with Postgres, and Vite admin/account app, restructured from a single-page layout into dedicated service, promotions, cases, about, FAQ, and blog pages for SEO. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
110 lines
3.6 KiB
JavaScript
110 lines
3.6 KiB
JavaScript
const express = require('express');
|
|
const rateLimit = require('express-rate-limit');
|
|
const pool = require('../db');
|
|
|
|
const router = express.Router();
|
|
|
|
router.get('/services', async (req, res) => {
|
|
const { rows: categories } = await pool.query(
|
|
'SELECT id, slug, title, description, icon FROM service_categories ORDER BY sort_order, id'
|
|
);
|
|
const { rows: items } = await pool.query(
|
|
'SELECT id, category_id, name, price_text, period, note FROM service_items WHERE active = true ORDER BY sort_order, id'
|
|
);
|
|
|
|
const grouped = categories
|
|
.map((cat) => ({ ...cat, items: items.filter((it) => it.category_id === cat.id) }))
|
|
.filter((cat) => cat.items.length > 0);
|
|
|
|
res.json(grouped);
|
|
});
|
|
|
|
router.get('/promotions', async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
'SELECT id, title, price_text, old_price_text, description FROM promotions WHERE active = true ORDER BY sort_order, id'
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.get('/reviews', async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
"SELECT id, name, role, rating, text, created_at FROM reviews WHERE status = 'approved' ORDER BY created_at DESC"
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.get('/cases', async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
'SELECT id, title, tag, status, tone, description FROM cases WHERE active = true ORDER BY sort_order, id'
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.get('/faqs', async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
'SELECT id, question, answer FROM faqs WHERE active = true ORDER BY sort_order, id'
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.get('/blog', async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
'SELECT id, slug, title, excerpt, published_at FROM blog_posts WHERE published = true ORDER BY published_at DESC'
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.get('/blog/:slug', async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
'SELECT id, slug, title, excerpt, content, published_at FROM blog_posts WHERE slug = $1 AND published = true',
|
|
[req.params.slug]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
|
res.json(rows[0]);
|
|
});
|
|
|
|
router.get('/contact', async (req, res) => {
|
|
const { rows } = await pool.query('SELECT address, phone, email, hours, map_embed FROM contact_info WHERE id = 1');
|
|
res.json(rows[0] || { address: '', phone: '', email: '', hours: '', map_embed: '' });
|
|
});
|
|
|
|
const reviewLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 5,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'too many reviews submitted, try again later' },
|
|
});
|
|
|
|
router.post('/reviews', reviewLimiter, async (req, res) => {
|
|
const { name, role, rating, text, website } = req.body || {};
|
|
|
|
// honeypot field - real users never fill it in, bots often do
|
|
if (website) return res.status(201).json({ ok: true });
|
|
|
|
if (!name || !text || !rating) {
|
|
return res.status(400).json({ error: 'name, rating and text are required' });
|
|
}
|
|
const ratingNum = Number(rating);
|
|
if (!Number.isInteger(ratingNum) || ratingNum < 1 || ratingNum > 5) {
|
|
return res.status(400).json({ error: 'rating must be an integer from 1 to 5' });
|
|
}
|
|
|
|
await pool.query(
|
|
"INSERT INTO reviews (name, role, rating, text, status) VALUES ($1, $2, $3, $4, 'pending')",
|
|
[String(name).slice(0, 120), role ? String(role).slice(0, 160) : null, ratingNum, String(text).slice(0, 2000)]
|
|
);
|
|
|
|
res.status(201).json({ ok: true });
|
|
});
|
|
|
|
router.post('/stats/visit', async (req, res) => {
|
|
await pool.query(
|
|
`INSERT INTO visits (date, count) VALUES (CURRENT_DATE, 1)
|
|
ON CONFLICT (date) DO UPDATE SET count = visits.count + 1`
|
|
);
|
|
res.status(204).end();
|
|
});
|
|
|
|
module.exports = router;
|