generated from wiz4apps/templates.OnlineShop
169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
import { createServer } from 'node:http';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { extname, join, normalize } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { CATEGORIES, readCatalog } from '../src/db/catalog';
|
|
import { imageUrl, readImages } from '../src/db/images';
|
|
import { STORE_CURRENCY } from '../src/store/constants';
|
|
|
|
/**
|
|
* Minimal storefront server for the basic frontend stage.
|
|
*
|
|
* The full Next.js application shell is a later layer (docs/PLAN.md §8, card #57).
|
|
* Until it lands this keeps the dev container green AND lets the storefront show the
|
|
* real seeded catalog: it serves `public/` plus one read-only JSON endpoint built from
|
|
* the same seed sources as the database (`db/seed/catalog.tsv` + `db/seed/images.tsv`).
|
|
*
|
|
* Routes:
|
|
* GET /api/catalog → { currency, count, categories, products[] }
|
|
* GET /images/... → product gallery files from public/
|
|
* GET /product/<slug> → index.html (client-side route, SPA fallback)
|
|
* GET /<file with ext> → public/<file> or 404
|
|
*/
|
|
|
|
const PUBLIC_DIR = fileURLToPath(new URL('../public', import.meta.url));
|
|
const INDEX_HTML = join(PUBLIC_DIR, 'index.html');
|
|
|
|
function readPort(): number {
|
|
const inline = process.argv.find((arg) => arg.startsWith('--port='));
|
|
if (inline) return Number(inline.split('=')[1]);
|
|
const idx = process.argv.indexOf('--port');
|
|
if (idx >= 0 && process.argv[idx + 1]) return Number(process.argv[idx + 1]);
|
|
return Number(process.env.PORT ?? 3000);
|
|
}
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.mjs': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.map': 'application/json; charset=utf-8',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.webp': 'image/webp',
|
|
'.gif': 'image/gif',
|
|
'.avif': 'image/avif',
|
|
'.ico': 'image/x-icon',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
};
|
|
|
|
interface ApiImage {
|
|
url: string;
|
|
alt: string;
|
|
position: number;
|
|
}
|
|
|
|
interface ApiCategory {
|
|
slug: string;
|
|
name: string;
|
|
}
|
|
|
|
interface ApiProduct {
|
|
slug: string;
|
|
name: string;
|
|
description: string;
|
|
composition: string;
|
|
priceMinor: number;
|
|
oldPriceMinor: number | null;
|
|
currency: string;
|
|
badge: 'hit' | 'new' | 'sale' | null;
|
|
stock: number;
|
|
stems: number | null;
|
|
heightCm: number;
|
|
deliveryTime: string;
|
|
categories: string[];
|
|
images: ApiImage[];
|
|
}
|
|
|
|
interface ApiCatalog {
|
|
currency: string;
|
|
count: number;
|
|
categories: ApiCategory[];
|
|
products: ApiProduct[];
|
|
}
|
|
|
|
/** Build the storefront payload from the same seed files the database is seeded from. */
|
|
function buildCatalog(): ApiCatalog {
|
|
const imagesBySlug = new Map<string, ApiImage[]>();
|
|
for (const image of readImages()) {
|
|
const list = imagesBySlug.get(image.slug) ?? [];
|
|
list.push({ url: imageUrl(image.slug, image.position), alt: image.alt, position: image.position });
|
|
imagesBySlug.set(image.slug, list);
|
|
}
|
|
|
|
const products: ApiProduct[] = readCatalog().map((product) => ({
|
|
slug: product.slug,
|
|
name: product.name,
|
|
description: product.description,
|
|
composition: product.composition,
|
|
priceMinor: product.priceMinor,
|
|
oldPriceMinor: product.oldPriceMinor,
|
|
currency: product.currency,
|
|
badge: product.badge,
|
|
stock: product.stock,
|
|
stems: product.stems,
|
|
heightCm: product.heightCm,
|
|
deliveryTime: product.deliveryTime,
|
|
categories: product.categories,
|
|
images: (imagesBySlug.get(product.slug) ?? []).sort((a, b) => a.position - b.position),
|
|
}));
|
|
|
|
return {
|
|
currency: STORE_CURRENCY,
|
|
count: products.length,
|
|
categories: CATEGORIES.filter((category) => category.visible).map(({ slug, name }) => ({ slug, name })),
|
|
products,
|
|
};
|
|
}
|
|
|
|
const catalog = buildCatalog();
|
|
|
|
function sendJson(res: import('node:http').ServerResponse, status: number, body: unknown): void {
|
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function sendBody(res: import('node:http').ServerResponse, status: number, body: Buffer | string, type: string): void {
|
|
res.writeHead(status, { 'content-type': type });
|
|
res.end(body);
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const requestPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
|
|
|
|
if (requestPath === '/api/catalog' || requestPath === '/api/catalog/') {
|
|
sendJson(res, 200, catalog);
|
|
return;
|
|
}
|
|
|
|
const relative = normalize(requestPath === '/' ? '/index.html' : requestPath).replace(/^(\.\.[/\\])+/, '');
|
|
const filePath = join(PUBLIC_DIR, relative);
|
|
try {
|
|
const body = await readFile(filePath);
|
|
sendBody(res, 200, body, CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream');
|
|
return;
|
|
} catch {
|
|
// Client-side routes (e.g. /product/<slug>) have no file extension: serve the shell.
|
|
if (!extname(requestPath)) {
|
|
try {
|
|
const shell = await readFile(INDEX_HTML);
|
|
sendBody(res, 200, shell, CONTENT_TYPES['.html']);
|
|
return;
|
|
} catch {
|
|
// fall through to 404
|
|
}
|
|
}
|
|
sendBody(res, 404, 'Not found', 'text/plain; charset=utf-8');
|
|
}
|
|
});
|
|
|
|
const port = readPort();
|
|
server.listen(port, () => {
|
|
console.log(`La Rose storefront listening on http://localhost:${port} (${catalog.count} products)`);
|
|
});
|