feat: implement La Rose storefront design (home, catalog, PDP)

Port the ready OpenPencil design (designs/design.fig) into the runnable
storefront (public/):

- design tokens + components (buttons, inputs, badges, chips, cards,
  skeletons, stepper, variants, pagination, states) from the canvas;
  Playfair Display + Inter, mobile-first at 390/1440.
- shell: topbar, header (nav, search, favourites, cart badge), mobile
  header, bottom nav, footer with real contacts and WhatsApp/Telegram.
- screens: Home (hero, trust, 11 categories, hits, delivery banner,
  info cards), Catalog (breadcrumbs, facets: price/categories/badges,
  sort, 6-per-page pagination, empty state), PDP (gallery + lightbox,
  variants 15/19/25, stepper, sticky buy, delivery block, tabs, specs,
  related).
- cart + single-page checkout + success built from the same tokens
  (not drawn on the canvas yet, flagged as follow-up).
- extend /api/catalog with category counts, variants, occasion, care,
  price range, badge counts and delivery constants; add /api/pages for
  info/FAQ content; export CONTENT_PAGES from the seed.

Money stays integer BYN minor units, formatted via Intl (en-US).
This commit is contained in:
semianiakavy 2026-10-05 16:33:55 +00:00
parent 32bb265252
commit 93b927dfdc
11 changed files with 4529 additions and 398 deletions

5
.codegraph/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore

BIN
designs/design.fig Normal file

Binary file not shown.

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>semianiakavy-la-rose - Dev server</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

File diff suppressed because it is too large Load diff

View file

@ -3,31 +3,232 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="La Rose — доставка цветов и подарков по Минску." />
<title>La Rose — цветы и подарки</title>
<meta name="theme-color" content="#D6336C" />
<meta
name="description"
content="La Rose — доставка свежих букетов и подарков по Минску за 2 часа. 93 букета, 281 реальное фото, бесплатная доставка от 100 BYN."
/>
<title>La Rose — цветы и подарки с доставкой по Минску</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<a class="skip-link" href="#app">Перейти к содержимому</a>
<header class="site-header">
<a class="brand" href="/" data-link>
<span class="brand-name">La&nbsp;Rose</span>
<span class="brand-sub">цветы и подарки · Минск</span>
</a>
<nav class="site-nav">
<a href="/" data-link>Каталог</a>
<span class="nav-note">Доставка по Минску 2–4 часа</span>
</nav>
<div class="topbar">
<div class="container topbar__inner">
<p class="topbar__note">Минск · доставка цветов и подарков до 2 часов · онлайн-заказы 24/7</p>
<div class="topbar__right">
<span class="topbar__pickup">Самовывоз: Притыцкого, 10 · 09:00–21:00</span>
<a
class="pill pill--wa"
href="https://wa.me/375296573000"
target="_blank"
rel="noopener"
>
<span class="pill__ico" aria-hidden="true">✆</span> WhatsApp
</a>
<a class="pill pill--tg" href="https://t.me/bot_a_nik_vsop" target="_blank" rel="noopener">
<span class="pill__ico" aria-hidden="true">➤</span> Telegram
</a>
<a class="topbar__phone" href="tel:+375296573000">+375 (29) 657-30-00</a>
</div>
</div>
</div>
<div class="header">
<div class="container header__inner">
<a class="brand" href="/" data-link aria-label="La Rose — на главную">
<span class="brand__name">La&nbsp;Rose</span>
<span class="brand__sub">ЦВЕТЫ · ПОДАРКИ · МИНСК</span>
</a>
<nav class="mainnav" aria-label="Каталог">
<ul id="nav-links" class="mainnav__list"></ul>
<div class="mainnav__more-wrap">
<button type="button" class="mainnav__more" id="more-btn" aria-expanded="false" aria-controls="menu-panel">
Ещё <span aria-hidden="true">▾</span>
</button>
<div id="menu-panel" class="menu-panel" hidden></div>
</div>
</nav>
<div class="header__actions">
<form class="search" id="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="search-input">Поиск по каталогу</label>
<svg class="search__ico" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M16.5 16.5 21 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<input id="search-input" name="query" type="search" placeholder="Найти букет…" autocomplete="off" />
</form>
<a class="iconbtn iconbtn--fav" id="fav-link" href="/favorites" data-link aria-label="Избранное">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 20s-7-4.4-7-9.4A3.9 3.9 0 0 1 12 7.6 3.9 3.9 0 0 1 19 10.6c0 5-7 9.4-7 9.4Z"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
</svg>
<span class="count" id="fav-count" hidden>0</span>
</a>
<a class="cartbtn" href="/cart" data-link aria-label="Корзина">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 8h12l-1 11H7L6 8Z"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
<path d="M9.5 8a2.5 2.5 0 0 1 5 0" fill="none" stroke="currentColor" stroke-width="1.8" />
</svg>
<span class="count" id="cart-count" hidden>0</span>
</a>
</div>
<div class="mobile-actions">
<button type="button" class="iconbtn iconbtn--round" id="m-search" aria-label="Поиск">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M16.5 16.5 21 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<a class="iconbtn iconbtn--round" id="m-fav" href="/favorites" data-link aria-label="Избранное">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 20s-7-4.4-7-9.4A3.9 3.9 0 0 1 12 7.6 3.9 3.9 0 0 1 19 10.6c0 5-7 9.4-7 9.4Z"
fill="none"
stroke="currentColor"
stroke-width="1.8"
/>
</svg>
<span class="count count--m" id="fav-count-m" hidden>0</span>
</a>
<a class="cartbtn cartbtn--m" href="/cart" data-link aria-label="Корзина">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 8h12l-1 11H7L6 8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M9.5 8a2.5 2.5 0 0 1 5 0" fill="none" stroke="currentColor" stroke-width="1.8" />
</svg>
<span class="count count--m" id="cart-count-m" hidden>0</span>
</a>
<button type="button" class="iconbtn iconbtn--round" id="burger" aria-label="Меню" aria-expanded="false">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
</div>
<div class="mobile-search" id="mobile-search" hidden>
<form class="container mobile-search__inner" id="mobile-search-form" role="search" action="/search" method="get">
<label class="sr-only" for="mobile-search-input">Поиск по каталогу</label>
<input id="mobile-search-input" name="query" type="search" placeholder="Найти букет…" autocomplete="off" />
<button type="submit" class="btn btn--primary btn--sm">Найти</button>
</form>
</div>
<div class="mobile-menu" id="mobile-menu" hidden></div>
</header>
<main id="app" class="app" aria-live="polite">
<p class="state">Загрузка каталога…</p>
<div class="container">
<div class="skeleton skeleton--hero"></div>
</div>
</main>
<footer class="site-footer">
<span>La Rose — базовый фронтенд (этап каталога).</span>
<span>Данные: сид каталога · 93 товара</span>
<div class="container footer__grid">
<div class="footer__brand">
<span class="footer__logo">La&nbsp;Rose</span>
<p class="footer__about">
Доставка свежих букетов и подарков по Минску с 2018 года. Свои флористы, свой курьер,
фото — это реальные букеты.
</p>
<div class="footer__pills">
<a class="pill pill--wa" href="https://wa.me/375296573000" target="_blank" rel="noopener">WhatsApp</a>
<a class="pill pill--tg" href="https://t.me/bot_a_nik_vsop" target="_blank" rel="noopener">Telegram</a>
</div>
</div>
<nav class="footer__col" aria-label="Каталог">
<h2 class="footer__title">Каталог</h2>
<ul class="footer__list" id="footer-catalog"></ul>
</nav>
<nav class="footer__col" aria-label="Покупателям">
<h2 class="footer__title">Покупателям</h2>
<ul class="footer__list">
<li><a href="/pages/dostavka" data-link>Доставка и оплата</a></li>
<li><a href="/pages/kak-zakazat" data-link>Как заказать</a></li>
<li><a href="/pages/faq" data-link>Вопросы и ответы</a></li>
<li><a href="/pages/vozvrat" data-link>Возврат</a></li>
<li><a href="/pages/b2b" data-link>Корпоративным клиентам</a></li>
</ul>
</nav>
<div class="footer__col">
<h2 class="footer__title">Контакты и часы работы</h2>
<ul class="footer__list footer__list--contacts">
<li><a href="tel:+375296573000">+375 (29) 657-30-00</a></li>
<li><a href="tel:+375336573000">+375 (33) 657-30-00</a></li>
<li><a href="mailto:info@la-rose.by">info@la-rose.by</a></li>
<li>ул. Притыцкого, 10 · м. Пушкинская</li>
<li>Ежедневно 09:00–21:00</li>
</ul>
</div>
</div>
<div class="footer__bottom">
<div class="container footer__bottom-inner">
<span>© 2018–2026 La Rose · Минск. Все цены в BYN.</span>
<span>ООО «Диапазон Плюс» · УНП 190438401 · демо-витрина, платёжный провайдер — mock</span>
</div>
</div>
</footer>
<nav class="bottomnav" aria-label="Мобильная навигация">
<a class="bottomnav__item" href="/" data-link data-nav="home">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1v-8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>
<span>Главная</span>
</a>
<a class="bottomnav__item" href="/catalog" data-link data-nav="catalog">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h7v7H4V5Zm9 0h7v7h-7V5ZM4 14h7v5H4v-5Zm9 0h7v5h-7v-5Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<span>Каталог</span>
</a>
<a class="bottomnav__item" href="https://wa.me/375296573000" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 0 0-7.7 13.7L3 21l4.4-1.2A9 9 0 1 0 12 3Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<span>Чаты</span>
</a>
<a class="bottomnav__item" href="/cart" data-link data-nav="cart">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8h12l-1 11H7L6 8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M9.5 8a2.5 2.5 0 0 1 5 0" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span>Корзина</span>
</a>
</nav>
<div class="lightbox" id="lightbox" hidden>
<button type="button" class="lightbox__close" id="lb-close" aria-label="Закрыть">✕</button>
<button type="button" class="lightbox__nav lightbox__nav--prev" id="lb-prev" aria-label="Предыдущее фото">‹</button>
<figure class="lightbox__figure">
<img id="lb-img" src="" alt="" />
<figcaption id="lb-cap" class="lightbox__cap"></figcaption>
</figure>
<button type="button" class="lightbox__nav lightbox__nav--next" id="lb-next" aria-label="Следующее фото">›</button>
</div>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script type="module" src="/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,8 @@ 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';
import { CONTENT_PAGES } from '../src/db/seed';
import { DELIVERY, STORE, STORE_CURRENCY } from '../src/store/constants';
/**
* Minimal storefront server for the basic frontend stage.
@ -15,9 +16,10 @@ import { STORE_CURRENCY } from '../src/store/constants';
* the same seed sources as the database (`db/seed/catalog.tsv` + `db/seed/images.tsv`).
*
* Routes:
* GET /api/catalog → { currency, count, categories, products[] }
* GET /api/catalog → { currency, count, priceRange, badges, delivery, categories[], products[] }
* GET /api/pages → { pages[] } (info/FAQ content from the seed)
* GET /images/... → product gallery files from public/
* GET /product/<slug> → index.html (client-side route, SPA fallback)
* GET /<route without ext> → index.html (client-side route, SPA fallback)
* GET /<file with ext> → public/<file> or 404
*/
@ -58,16 +60,28 @@ interface ApiImage {
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;
@ -76,17 +90,57 @@ interface ApiProduct {
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[]>();
@ -97,10 +151,12 @@ function buildCatalog(): ApiCatalog {
}
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,
@ -109,19 +165,59 @@ function buildCatalog(): ApiCatalog {
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,
categories: CATEGORIES.filter((category) => category.visible).map(({ slug, name }) => ({ slug, name })),
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 }));
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' });
@ -141,6 +237,11 @@ const server = createServer(async (req, res) => {
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 {

View file

@ -29,7 +29,7 @@ const PLACEHOLDER_CATEGORY_SLUGS = [
'hidden-3',
];
const CONTENT_PAGES = [
export const CONTENT_PAGES = [
{ slug: 'o-kompanii', title: 'О компании', body: 'La Rose — минская доставка цветов и подарков. Работаем ежедневно, собираем букеты вручную.' },
{ slug: 'dostavka', title: 'Доставка', body: 'Курьер по Минску и самовывоз из салона. Бесплатно при заказе от 100 BYN, иначе 15 BYN. За МКАД — 15 BYN + 0.50 BYN/км.' },
{ slug: 'oplata', title: 'Оплата', body: 'Оплата картой онлайн (Visa, Mastercard, МИР, Белкарт, ЕРИП) или наличными курьеру при получении.' },

24
src/main.js Normal file
View file

@ -0,0 +1,24 @@
import './style.css'
const projectName = "semianiakavy-la-rose"
document.querySelector('#app').innerHTML = `
<main class="banner">
<span class="pill">Dev server</span>
<h1 id="project-name"></h1>
<p class="lede">
This repository had no application files yet, so this auto-generated starter
page was created for it.
</p>
<ul class="facts">
<li>Edit <code>src/main.js</code> or <code>src/style.css</code> - Vite hot-reloads the page instantly.</li>
<li>Dependencies are installed inside the container on every start (<code>pnpm install</code>).</li>
<li>Live URL: <code id="live-url"></code></li>
<li>Commit and push these files to keep them in the repository.</li>
</ul>
<p class="hint">Starter files: <code>index.html</code>, <code>src/main.js</code>, <code>src/style.css</code></p>
</main>
`
document.getElementById('project-name').textContent = projectName
document.getElementById('live-url').textContent = window.location.origin

81
src/style.css Normal file
View file

@ -0,0 +1,81 @@
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--fg: #1b1f24;
--muted: #5b6472;
--card: #ffffff;
--border: #e3e6eb;
--pill-bg: #e8eefc;
--pill-fg: #1f4fd8;
--code-bg: #f0f2f5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1216;
--fg: #e7eaee;
--muted: #9aa4b2;
--card: #171b21;
--border: #262c35;
--pill-bg: #1b2740;
--pill-fg: #9db8ff;
--code-bg: #10141a;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
}
.banner {
width: min(560px, 100%);
padding: 32px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--card);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: var(--pill-bg);
color: var(--pill-fg);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1 {
margin: 16px 0 8px;
font-size: 28px;
line-height: 1.2;
word-break: break-word;
}
.lede { margin: 0 0 20px; color: var(--muted); }
.facts { margin: 0; padding-left: 20px; }
.facts li { margin-bottom: 8px; }
code {
padding: 1px 5px;
border-radius: 5px;
background: var(--code-bg);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
}
.hint { margin: 20px 0 0; color: var(--muted); font-size: 13px; }