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 { CONTENT_PAGES } from '../src/db/seed'; import { BRAND, DELIVERY, STORE, STORE_CURRENCY } from '../src/store/constants'; import { ensureBrandLogo } from './fetch-logo'; import { ensureBrandFavicon, isPng } from './fetch-favicon'; /** * 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`). * * At boot it also mirrors the store wordmark (scripts/fetch-logo.ts) so the header and * footer logo is served from `public/images/brand/logo_footer.png`, not hotlinked. * * Routes: * GET /api/catalog → { currency, count, priceRange, badges, delivery, categories[], products[] } * GET /api/pages → { pages[] } (info/FAQ content from the seed) * GET /favicon.ico → the mirrored site icon (PNG payload, served as image/png) * GET /images/... → product gallery files from public/ * GET / → index.html (client-side route, SPA fallback) * GET / → public/ 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 = { '.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', }; /** * Content type for a file on disk. `/favicon.ico` is the one exception: the mirrored * icon is a PNG payload under an `.ico` name (that is how the source site ships it), so * the signature is sniffed and the file labelled `image/png` — browsers accept either * for ``, and a truthful type beats a wrong one. */ function contentTypeFor(filePath: string, body: Buffer): string { const ext = extname(filePath).toLowerCase(); if (ext === '.ico' && isPng(body)) return 'image/png'; return CONTENT_TYPES[ext] ?? 'application/octet-stream'; } interface ApiImage { url: string; alt: string; position: number; } interface ApiVariant { label: string; priceMinor: number; stock: number; } interface ApiCategory { slug: string; name: string; description: string | null; /** R13: hidden source categories stay reachable by direct URL but never in the menu. */ visible: boolean; count: number; } interface ApiProduct { n: number; slug: string; name: string; description: string; composition: string; care: string; priceMinor: number; oldPriceMinor: number | null; currency: string; badge: 'hit' | 'new' | 'sale' | null; stock: number; stems: number | null; heightCm: number; deliveryTime: string; occasion: string; categories: string[]; images: ApiImage[]; variants: ApiVariant[]; } interface ApiCatalog { currency: string; count: number; priceRange: { minMinor: number; maxMinor: number }; badges: { hit: number; new: number; sale: number }; delivery: { freeOverMinor: number; courierFeeMinor: number; leadTimeText: string; outsideMkadText: string; pickupAddress: string; workingHours: string; phone1: string; phone2: string; email: string; whatsapp: string; telegram: string; }; categories: ApiCategory[]; products: ApiProduct[]; } /** Variants seeded in src/db/seed.ts §5 (source PDP «15/19/25 роз»). */ const PRODUCT_VARIANTS: Record = { 'buket-iz-krasnykh-roz-s-evkaliptom': [ { label: '15 роз', priceMinor: 198_00, stock: 10 }, { label: '19 роз', priceMinor: 240_00, stock: 6 }, { label: '25 роз', priceMinor: 325_00, stock: 4 }, ], }; /** Occasion facet, same mapping the seed writes to product_feature (§5). */ function occasionFor(categories: string[]): string { if (categories.includes('svadebnye-bukety-nevesty')) return 'Невесте'; if (categories.includes('monobuket')) return 'Любимой'; if ( categories.includes('kompozitsii') || categories.includes('tsvety-v-korzine') || categories.includes('tsvety-v-yashchike') ) { return 'Коллеге, Партнёру'; } return 'Любимой, Маме, Другу'; } /** Build the storefront payload from the same seed files the database is seeded from. */ function buildCatalog(): ApiCatalog { const imagesBySlug = new Map(); 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) => ({ n: product.n, slug: product.slug, name: product.name, description: product.description, composition: product.composition, care: product.care, priceMinor: product.priceMinor, oldPriceMinor: product.oldPriceMinor, currency: product.currency, badge: product.badge, stock: product.stock, stems: product.stems, heightCm: product.heightCm, deliveryTime: product.deliveryTime, occasion: occasionFor(product.categories), categories: product.categories, images: (imagesBySlug.get(product.slug) ?? []).sort((a, b) => a.position - b.position), variants: PRODUCT_VARIANTS[product.slug] ?? [], })); const countByCategory = new Map(); for (const product of products) { for (const slug of new Set(product.categories)) { countByCategory.set(slug, (countByCategory.get(slug) ?? 0) + 1); } } const prices = products.map((product) => product.priceMinor); return { currency: STORE_CURRENCY, count: products.length, priceRange: { minMinor: prices.length ? Math.min(...prices) : 0, maxMinor: prices.length ? Math.max(...prices) : 0, }, badges: { hit: products.filter((product) => product.badge === 'hit').length, new: products.filter((product) => product.badge === 'new').length, sale: products.filter((product) => product.badge === 'sale').length, }, delivery: { freeOverMinor: DELIVERY.freeOverMinor, courierFeeMinor: DELIVERY.courierFeeMinor, leadTimeText: DELIVERY.leadTimeText, outsideMkadText: DELIVERY.outsideMkadText, pickupAddress: STORE.pickupAddress, workingHours: STORE.workingHours, phone1: STORE.phone1, phone2: STORE.phone2, email: STORE.email, whatsapp: STORE.whatsapp, telegram: STORE.telegram, }, categories: CATEGORIES.map((category) => ({ slug: category.slug, name: category.name, description: category.description, visible: category.visible, count: countByCategory.get(category.slug) ?? 0, })), products, }; } const catalog = buildCatalog(); const pages = CONTENT_PAGES.map(({ slug, title, body }) => ({ slug, title, body })); // Brand assets: keep the local copies of the wordmark and the site icon in place. // Non-fatal — if a source is unreachable we log it and keep serving (both files are // normally committed already). try { const logo = await ensureBrandLogo(); if (logo === 'downloaded') console.log(`dev-server: fetched the store logo (${BRAND.logoLocalPath})`); } catch (error) { console.warn(`dev-server: store logo unavailable — ${(error as Error).message}`); } try { const icon = await ensureBrandFavicon(); if (icon === 'downloaded') console.log(`dev-server: fetched the site icon (${BRAND.faviconLocalPath})`); } catch (error) { console.warn(`dev-server: site icon unavailable — ${(error as Error).message}`); } 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 { // Dev shell: never cache assets, so a plain refresh always shows the latest // public/app.js + public/styles.css instead of a stale heuristically cached copy. res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' }); 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; } if (requestPath === '/api/pages' || requestPath === '/api/pages/') { sendJson(res, 200, { pages }); 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, contentTypeFor(filePath, body)); return; } catch { // Client-side routes (e.g. /product/) 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)`); });