generated from wiz4apps/templates.OnlineShop
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { createServer } from 'node:http';
|
|
import { readFile, stat } 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/ (legacy/local fallback)
|
|
* GET /<route without ext> → index.html (client-side route, SPA fallback)
|
|
* GET /<file with ext> → public/<file> or 404
|
|
*
|
|
* Product image urls in /api/catalog are absolute, resolved against ASSETS_BASE_URL — the
|
|
* mirrored gallery lives in a W4C asset share, not in this bundle (src/db/images.ts).
|
|
*
|
|
* Static responses carry a real cache policy, not the blanket `no-store` this shell
|
|
* started with. The gallery is ~300 committed photos that never change in place, and
|
|
* serving them uncacheable meant every client-side render — which rebuilds the whole
|
|
* view via `innerHTML`, so every `<img>` is a new element — re-downloaded the same
|
|
* bouquets. That is the "the flower slides keep reloading" report on /variant-b.
|
|
* The HTML shell still goes out `no-store`, code revalidates through an ETag, images
|
|
* are kept for a week.
|
|
*/
|
|
|
|
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',
|
|
};
|
|
|
|
/**
|
|
* 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 `<link rel="icon">`, 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';
|
|
}
|
|
|
|
/** Extensions whose bytes never change once shipped: the committed product gallery. */
|
|
const CACHEABLE_IMAGE_EXTENSIONS = new Set([
|
|
'.jpg',
|
|
'.jpeg',
|
|
'.png',
|
|
'.webp',
|
|
'.avif',
|
|
'.gif',
|
|
'.ico',
|
|
]);
|
|
|
|
/**
|
|
* Cache policy per file kind.
|
|
*
|
|
* - the SPA shell (`index.html`) is never cached, so a plain refresh picks up a new
|
|
* build instead of a stale heuristic copy;
|
|
* - the gallery is kept for a week: the photos are committed files keyed by slug and
|
|
* position, they are the bulk of the page's bytes, and re-downloading them is what
|
|
* made the drifting rows on /variant-b flicker;
|
|
* - code (`app.js`, `styles.css`) carries a `?v=` buster and revalidates via ETag, so a
|
|
* cached copy costs a 304 rather than a full body, and can never be served stale.
|
|
*/
|
|
function cacheControlFor(ext: string): string {
|
|
if (ext === '.html') return 'no-store';
|
|
if (CACHEABLE_IMAGE_EXTENSIONS.has(ext)) return 'public, max-age=604800';
|
|
return 'public, max-age=0, must-revalidate';
|
|
}
|
|
|
|
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<string, ApiVariant[]> = {
|
|
'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<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) => ({
|
|
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<string, number>();
|
|
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,
|
|
cacheControl: string = 'no-store',
|
|
): void {
|
|
res.writeHead(status, { 'content-type': type, 'cache-control': cacheControl });
|
|
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, info] = await Promise.all([readFile(filePath), stat(filePath)]);
|
|
const headers = {
|
|
'content-type': contentTypeFor(filePath, body),
|
|
'cache-control': cacheControlFor(extname(filePath).toLowerCase()),
|
|
/* Weak validator: size + mtime is plenty for a dev shell, and it lets a
|
|
revalidated asset answer 304 without re-sending a byte. */
|
|
etag: `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`,
|
|
};
|
|
if (req.headers['if-none-match'] === headers.etag) {
|
|
res.writeHead(304, headers);
|
|
res.end();
|
|
return;
|
|
}
|
|
res.writeHead(200, headers);
|
|
res.end(body);
|
|
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)`);
|
|
});
|