initial site

This commit is contained in:
merrow committed 2026-10-11 22:24:54 +05:00
commit da5b259d06
167 files changed
+34157

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.dockerignore
Dockerfile
.git
*.log
+65
View File
@@ -0,0 +1,65 @@
# Аудит и изменения проекта НЕОН
## Что проверено
- Исходная структура проекта, `package.json`, TypeScript-конфигурация и Vite-конфигурация.
- Страница меню и все три набора данных: авторский чай, пивная карта, барное меню.
- Все 57 файлов в `public/menu` и `public/menu/manifest.json`.
- Поиск по проекту на предмет старого реестра изображений и неиспользуемых ссылок.
- TypeScript-проверка и самотест движка миксов.
## Что изменено
### Фотографии меню
Создан единый `src/data/menuPhotos.ts` с привязкой по `id` позиции меню.
- 51 из 51 текущих позиций меню имеют привязанные фотографии.
- Все 51 файла реально существуют в `public/menu`.
- Для авторского чая использованы соответствующие изображения из того же набора; названия файлов исторически отличаются от текущих названий позиций.
- 6 файлов из `public/menu` намеренно не привязаны: это изображения, которым в текущем каталоге нет соответствующей позиции (`photo`, два кальяна и три прочих позиции/сборы).
Старый `src/data/menuImages.ts` удалён: он ссылался на несуществующие пути `images/tea` и `images/beer` и содержал неиспользуемые fallback-цепочки.
### Карточки меню
- Фото добавлены непосредственно в существующие карточки без смены базовой стилистики.
- Добавлена lazy-loading загрузка изображений.
- При ошибке загрузки карточка автоматически возвращается к исходной иконке.
- Кнопка `В корзину` интегрирована в существующую карточку.
### Корзина и управление количеством
`src/components/MenuCart.tsx` и `src/pages/MenuPage.tsx` получили косметический рефакторинг UX.
- На карточке позиции вместо однонаправленной кнопки теперь используется `В корзину`, а после добавления — полноценный степпер `− / количество / +`.
- Уменьшение количества из меню сразу действует на выбранного гостя и удаляет позицию при снижении количества до нуля.
- Выбор активного гостя в меню переведён с выпадающего списка на компактные переключатели.
- В корзине количество гостей задаётся степпером 1–12, без громоздкого select.
- Активный гость в корзине выбирается отдельными кнопками; новые позиции из меню попадают выбранному гостю.
- Позиции по-прежнему группируются по гостям, но карточки стали компактнее и понятнее; перераспределение позиции между гостями сохранено.
- Итоговая сумма, копирование заказа, очистка, `Esc`, блокировка прокрутки и хранение в `localStorage` сохранены.
- Заказ никуда автоматически не отправляется — это рабочий список для оформления у официанта.
### Очистка проекта
- Из моделей `teaMenu`, `beerMenu`, `barMenu` убраны старые поля/импорты изображений, которые больше не нужны.
- Удалена дублирующаяся папка `public/images/menu`: приложение её не использовало, а сами актуальные фото находятся в `public/menu`.
- Контейнерный Caddy удалён: Caddy работает на уровне хоста.
- Docker-инфраструктура приложения возвращена отдельным `docker-compose.yml` + `Dockerfile`; HTTPS и доменная маршрутизация остаются на хостовом Caddy.
- В контейнере используется nginx только как HTTP-сервер для собранного Vite `dist`.
- `src/index.css`, `package.json` и `package-lock.json` не изменялись.
## Проверка
- Статически проверены изменённые файлы и связи между карточками меню, активным гостем и корзиной.
- `docker-compose.yml` и `Dockerfile` физически присутствуют в финальном архиве.
- Полный `vite build` в этой среде не удалось выполнить: пакетные зависимости из `package-lock.json` не удалось восстановить через npm из-за недоступного registry/cache-пакета, поэтому результат сборки здесь не выдаётся за проверенный.
- Финальный архив не содержит `node_modules` и `dist`; зависимости и production-сборка должны создаваться в целевой среде.
## Docker runtime
- `docker-compose.yml` restored for the frontend container; Caddy is not containerized.
- `Dockerfile` restored as a multi-stage build: Node builds the Vite app, nginx serves the resulting static files.
- The app is published only on `127.0.0.1:8080`, so host-level Caddy can reverse-proxy to `http://127.0.0.1:8080` without exposing the app directly to the network.
- `nginx.conf` contains SPA fallback and a `/healthz` endpoint.
- `.dockerignore` excludes build/dependency artifacts from the Docker build context.
+23
View File
@@ -0,0 +1,23 @@
# Build the Vite app in Node, then serve the static bundle with a minimal web server.
# NF-20: дайджест-пины базовых образов (воспроизводимая сборка).
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --spider --quiet http://127.0.0.1/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+147
View File
@@ -0,0 +1,147 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="НЕОН — кальянный лаунж в Нягани. Авторские миксы, PlayStation 5, авторский чай и кофе. Атмосфера, которую видно в темноте."
/>
<meta name="theme-color" content="#0a0a0f" />
<title>НЕОН — кальянный лаунж · Атмосфера, которую видно в темноте</title>
<link rel="canonical" href="https://neon-bar86.ru/" />
<!-- Open Graph: превью в соцсетях и мессенджерах -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="НЕОН — кальянный лаунж" />
<meta property="og:title" content="НЕОН — кальянный лаунж в Нягани" />
<meta
property="og:description"
content="Авторские миксы, PlayStation 5, авторский чай и кофе. Проспект Нефтяников, 15. Бронь: +7 (912) 901-72-17."
/>
<meta property="og:url" content="https://neon-bar86.ru/" />
<meta property="og:image" content="https://neon-bar86.ru/images/og-cover.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="ru_RU" />
<meta name="twitter:card" content="summary_large_image" />
<!-- JSON-LD: карточка организации для Яндекс/Google/Bing.
BarOrPub — подтип LocalBusiness (DrinkingEstablishment): для лаунжа
точнее ресторанных типов, все локальные поля наследуются. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BarOrPub",
"name": "НЕОН — кальянный лаунж",
"url": "https://neon-bar86.ru/",
"telephone": "+7 912 901-72-17",
"image": "https://neon-bar86.ru/images/og-cover.jpg",
"description": "Кальянный лаунж-бар НЕОН в Нягани: авторские кальяны и миксы, PlayStation 5, настольные игры, авторский чай и кофе. Бронь столов и аренда VIP-зала.",
"priceRange": "₽₽",
"currenciesAccepted": "RUB",
"address": {
"@type": "PostalAddress",
"streetAddress": "проспект Нефтяников, 15",
"addressLocality": "Нягань",
"addressRegion": "Ханты-Мансийский автономный округ — Югра",
"addressCountry": "RU"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "[ВПИСАТЬ: широта, напр. 60.9449]",
"longitude": "[ВПИСАТЬ: долгота, напр. 65.6510]"
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Tuesday", "Wednesday", "Thursday", "Sunday"],
"opens": "19:00",
"closes": "01:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Friday", "Saturday"],
"opens": "19:00",
"closes": "03:00"
}
],
"sameAs": ["https://vk.ru/shelby_nyagan"]
}
</script>
<!-- Верификация вебмастер-панелей: раскомментируй строку и впиши код,
полученный в панели (какой код откуда — см. SEO-REGISTRATIONS.md).
До активации панель верификацию не пройдёт — это ожидаемо. -->
<!-- <meta name="yandex-verification" content="[ВПИСАТЬ_КОД_ЯНДЕКС]" /> -->
<!-- <meta name="google-site-verification" content="[ВПИСАТЬ_КОД_GOOGLE]" /> -->
<!-- <meta name="msvalidate.01" content="[ВПИСАТЬ_КОД_BING]" /> -->
<!-- <meta name="mailru-verification" content="[ВПИСАТЬ_КОД_MAILRU]" /> -->
<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=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<!-- Favicon реальными файлами, а не data-URI: Яндекс и Google забирают
иконку по адресу /favicon.ico (data-URI поисковики не читают —
в выдаче вместо иконки серый глобус). Файлы генерит
scripts/make-favicon.py, лежат в public/ и попадают в корень сборки. -->
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head>
<body>
<!-- Статический H1 + SEO-абзац: виден поисковику и гостю без JS.
React (createRoot) полностью заменяет содержимое #root при
монтировании, поэтому на живой странице блока не будет.
Маркеры SEO:FALLBACK_* нужны scripts/prerender.tsx: между ними
в собранный index.html вставляется пререндер-снапшот лендинга. -->
<div id="root"><!--SEO:FALLBACK_START-->
<section
style="max-width: 52rem; margin: 0 auto; padding: 96px 24px; font-family: system-ui, sans-serif; color: #f2f3f8; background: #0a0a0f; min-height: 100vh"
>
<h1>Кальянный лаунж НЕОН в Нягани</h1>
<p>
Кальянный лаунж-бар НЕОН (Нягань, проспект Нефтяников, 15) — авторские
миксы и кальяны, PlayStation 5, настольные игры, авторский чай и кофе.
Бронь столов и аренда VIP-зала: +7 (912) 901-72-17. Часы работы:
вт–чт и вс 19:00–01:00, пт–сб 19:00–03:00, понедельник — выходной.
</p>
<noscript>
<p>Для интерактивного меню и заказа включите JavaScript. Телефон: +7 (912) 901-72-17.</p>
</noscript>
</section>
<!--SEO:FALLBACK_END--></div>
<!-- Счётчики аналитики: раскомментируй после получения идентификатора.
Яндекс.Метрика: metrika.yandex.ru → создать счётчик → заменить
[ВПИСАТЬ_ИД_МЕТРИКИ] на номер счётчика (число). Не добавляй два
одинаковых счётчика: один сайт = один код на страницу.
<script type="text/javascript">
(function (m, e, t, r, i, k, a) {
m[i] = m[i] || function () { (m[i].a = m[i].a || []).push(arguments); };
m[i].l = 1 * new Date();
for (var j = 0; j < document.scripts.length; j++) { if (document.scripts[j].src === r) { return; } }
k = e.createElement(t), a = e.getElementsByTagName(t)[0], k.async = 1, k.src = r, a.parentNode.insertBefore(k, a);
})(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym([ВПИСАТЬ_ИД_МЕТРИКИ], "init", { clickmap: true, trackLinks: true, accurateTrackBounce: true, webvisor: true });
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/[ВПИСАТЬ_ИД_МЕТРИКИ]" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
-->
<!-- Google Analytics 4: analytics.google.com → Администратор → Потоки
данных → Идентификатор G-XXXXXXX. Вставить вместо [ВПИСАТЬ_ИД_GA4].
<script async src="https://www.googletagmanager.com/gtag/js?id=[ВПИСАТЬ_ИД_GA4]"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "[ВПИСАТЬ_ИД_GA4]");
</script>
-->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
# Брауз-тормоз на флуд заказами: с одного IP не чаще 10 заказов в минуту
# (burst 10 сглаживает «двойной клик»). Работает ДО запроса в CRM.
limit_req_zone $binary_remote_addr zone=orders_zone:1m rate=10r/m;
# Общий тормоз на прочее /api/ (NF-04): флуд catalog/stoplist выедал пул БД
# CRM. Легитимный сайт опрашивает эти эндпоинты раз в минуту-десять,
# 120r/m с burst 60 — с большим запасом. Панель персонала ходит на CRM
# напрямую (127.0.0.1:47613) и этот лимит не замечает.
limit_req_zone $binary_remote_addr zone=api_zone:1m rate=120r/m;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Реальный IP гостя для лимитеров CRM. Доверие к X-Forwarded-For —
# только 127.0.0.1 и docker-подсеть compose (NF-10): раньше верили всем
# RFC1918-пирам, и LAN-клиент при экспоузе сайта мог вращать per-IP
# лимитеры подделкой заголовка. Подсеть зафиксирована в
# docker-compose.yml (networks.default.ipam) — при смене её там
# обновите и здесь.
set_real_ip_from 127.0.0.1;
set_real_ip_from 172.18.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# single-file сборка (~750 KB html) без gzip = лишний трафик гостям
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_types text/css application/javascript application/json image/svg+xml application/xml text/plain;
server_tokens off;
# Базовые заголовки безопасности. Внимание nginx: location со своим
# add_header (например /media/) НЕ наследует их — там заголовки повторены.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# CSP: бандл инлайнен в single-file index.html (поэтому 'unsafe-inline' у
# script-src), плюс Метрика/GA и Google Fonts. object/base/frame-ancestors
# наглухо закрыты — внедрённый в страницу тег не подгрузит и не отправит.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
# Заказы и стоп-лист живут в CRM-контейнере (см. docker-compose.yml).
# Точный location = /api/orders ниже переопределяет этот для POST заказов
# со своей зоной (приоритет nginx: точный бьёт префиксный).
location /api/ {
limit_req zone=api_zone burst=60 nodelay;
limit_req_status 429;
proxy_pass http://crm:47613;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
# Точка создания заказа — отдельный тормоз поверх лимитеров CRM
# (nginx отвечает 429 раньше, чем запрос дойдёт до Python).
location = /api/orders {
limit_req zone=orders_zone burst=10 nodelay;
limit_req_status 429;
proxy_pass http://crm:47613;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
# Входная точка SPA: ТОЛЬКО no-cache — в single-file сборке внутри
# index.html инлайнен весь бандл, закэшировать его на 7 дней = гости
# не увидят обновлений сайта до конца срока кэша. (Точное совпадение
# бьёт регэксп-локейшен расширений ниже; прочие .html честно 404.)
location = /index.html {
add_header Cache-Control "no-cache";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
}
# SEO-файлы: точный Content-Type и кэш не больше часа
# (types {} сбрасывает mime.types — иначе .xml ушёл бы как text/xml)
location = /robots.txt {
types { }
default_type text/plain;
expires 1h;
}
location = /sitemap.xml {
types { }
default_type application/xml;
expires 1h;
}
# Загруженные владельцем фото позиций — из CRM (/media/<id>.<ext>).
# ^~ обязателен: без него регэксп-локейшен расширений (*.jpg, *.avif…)
# перехватывал бы запросы, и все фото отдавали 404 вместо прокси в CRM.
location ^~ /media/ {
proxy_pass http://crm:47613;
proxy_http_version 1.1;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, max-age=604800";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
}
# Vite SPA fallback: client-side routes should resolve to index.html.
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 'ok\n';
}
# Файлы с расширениями отдаются только с диска: несуществующие — честный
# 404, а не index.html (soft-404 вреден для поисковиков). SPA-fallback
# выше работает только для путей без расширения.
location ~* \.(?:css|js|mjs|cjs|json|map|webmanifest|txt|xml|svg|ico|png|jpg|jpeg|gif|webp|avif|jfif|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|ogg|pdf|html)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
try_files $uri =404;
}
}
+2655
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "react-vite-tailwind",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && tsx scripts/prerender.tsx",
"prerender": "tsx scripts/prerender.tsx",
"preview": "vite preview",
"export:crm": "tsx scripts/export-crm-seed.ts",
"test:engine": "tsx scripts/engine-selftest.ts"
},
"dependencies": {
"clsx": "2.1.1",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.34.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.17",
"@types/node": "22.19.17",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"tailwindcss": "4.1.17",
"tsx": "^4.23.15",
"typescript": "5.9.3",
"vite": "7.3.7",
"vite-plugin-singlefile": "2.3.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='#0A0A0F'/><text x='32' y='45' font-family='Arial Black, Arial' font-size='34' font-weight='900' text-anchor='middle' fill='#00F0FF'>Н</text></svg>

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+287
View File
@@ -0,0 +1,287 @@
[
{
"file": "menu/photo.avif",
"title": "photo",
"source": "https://media.dodostatic.com/image/11eeb86b25350d0eadad0aec31155793.avif"
},
{
"file": "menu/kalyan-lyuboy-kreposti.avif",
"title": "Кальян любой крепости",
"source": "https://media.dodostatic.com/image/019c413a46397891b34db8e49083915b.avif"
},
{
"file": "menu/retro-kalyan-khalil-mamoon.avif",
"title": "Ретро кальян: khalil mamoon",
"source": "https://media.dodostatic.com/image/019c4813920f7666900e24d50a0a9ea2.avif"
},
{
"file": "menu/pryanyy-apelsin.avif",
"title": "Пряный апельсин",
"source": "https://media.dodostatic.com/image/019c413cfc6071fea8a2c4ec8b641fb9.avif"
},
{
"file": "menu/myatnaya-ezhevika.avif",
"title": "Мятная ежевика",
"source": "https://media.dodostatic.com/image/019c2b9f74757209a2aa263b65df27e5.avif"
},
{
"file": "menu/malinovyy-s-imbirem.avif",
"title": "Малиновый с имбирем",
"source": "https://media.dodostatic.com/image/019c2b9fdc00768ba05ff316f8d622cd.avif"
},
{
"file": "menu/myatnaya-malina.avif",
"title": "Мятная малина",
"source": "https://media.dodostatic.com/image/019c2f32f55071f0ba37559a8e3b0206.avif"
},
{
"file": "menu/osennyaya-oblepikha.avif",
"title": "Осенняя облепиха",
"source": "https://media.dodostatic.com/image/019c281096fe7710809269443a7225fd.avif"
},
{
"file": "menu/yagodnyy-vzryv.avif",
"title": "Ягодный взрыв",
"source": "https://media.dodostatic.com/image/019c20ad560979c1b2074a1e1acf60a3.avif"
},
{
"file": "menu/vishnevyy-sad.avif",
"title": "Вишневый сад",
"source": "https://media.dodostatic.com/image/019c2f31bcf875acbceb7a6f61ae0194.avif"
},
{
"file": "menu/mangovyy-ray.avif",
"title": "Манговый рай",
"source": "https://media.dodostatic.com/image/019c41528cfc78dd81f72f40ce843aae.avif"
},
{
"file": "menu/staryy-melnik-svetloe.avif",
"title": "Старый мельник светлое",
"source": "https://media.dodostatic.com/image/019c20aedd9d7883abc396bff921d760.avif"
},
{
"file": "menu/staryy-melnik-temnoe.avif",
"title": "Старый мельник темное",
"source": "https://media.dodostatic.com/image/019c20af48177999b63855e09d42230e.avif"
},
{
"file": "menu/kozel-svetlyy.avif",
"title": "Kozel светлый",
"source": "https://media.dodostatic.com/image/019c20afbec970edb4dab7fcbe931230.avif"
},
{
"file": "menu/kozel-temnyy.avif",
"title": "Kozel темный",
"source": "https://media.dodostatic.com/image/019c20b0aed478129986b27612ce598b.avif"
},
{
"file": "menu/bud.avif",
"title": "Bud",
"source": "https://media.dodostatic.com/image/019c20b1083d72cc939ad919afe83989.avif"
},
{
"file": "menu/stella.avif",
"title": "Stella",
"source": "https://media.dodostatic.com/image/019c20ae14e3769b9cef85f063c0ea80.avif"
},
{
"file": "menu/krone-blanche.avif",
"title": "Krone Blanche",
"source": "https://media.dodostatic.com/image/019c20b1705470b699f9622d41cbbe21.avif"
},
{
"file": "menu/erzmann.avif",
"title": "Erzmann",
"source": "https://media.dodostatic.com/image/019c5205b1fb7623b40922be846d1be5.avif"
},
{
"file": "menu/el-capulco.avif",
"title": "El Capulco",
"source": "https://media.dodostatic.com/image/019c21d0b739773e812f5335fd98e5c5.avif"
},
{
"file": "menu/spatten.avif",
"title": "Spatten",
"source": "https://media.dodostatic.com/image/019c20b2fa6a72fca73c653d52e9d1c6.avif"
},
{
"file": "menu/hoegaarden.avif",
"title": "Hoegaarden",
"source": "https://media.dodostatic.com/image/019c20b37d0278ef88419f1879912261.avif"
},
{
"file": "menu/corona-extra.avif",
"title": "Corona Extra",
"source": "https://media.dodostatic.com/image/019c20b3d8677935bd60a789260a7144.avif"
},
{
"file": "menu/redd-s.avif",
"title": "Redd's",
"source": "https://media.dodostatic.com/image/019c20b66c7a7948a337569742166027.avif"
},
{
"file": "menu/pablo-s-s-sokom-layma.avif",
"title": "Pablo's с соком лайма",
"source": "https://media.dodostatic.com/image/019c388bd52575f5841f61a9d7b324a7.avif"
},
{
"file": "menu/lukovye-koltsa.avif",
"title": "Луковые кольца",
"source": "https://media.dodostatic.com/image/019c5cf1ac96718980e6e71591fc11d7.avif"
},
{
"file": "menu/kartofel-fri.avif",
"title": "Картофель Фри",
"source": "https://media.dodostatic.com/image/019c41687d447256a1d04a957f905d4d.avif"
},
{
"file": "menu/krylyshki-baffalo.avif",
"title": "Крылышки Баффало",
"source": "https://media.dodostatic.com/image/019c48143b66725cb9a3680801523673.avif"
},
{
"file": "menu/syrnye-palochki.avif",
"title": "Сырные палочки",
"source": "https://media.dodostatic.com/image/019c48148e8073bab97dc8caa6100c9b.avif"
},
{
"file": "menu/stripsy.avif",
"title": "Стрипсы",
"source": "https://media.dodostatic.com/image/019c4814f0ed7520802b4f729d2236ee.avif"
},
{
"file": "menu/nagetsy.avif",
"title": "Нагетсы",
"source": "https://media.dodostatic.com/image/019c4815a75175099263d2b610c0ba71.avif"
},
{
"file": "menu/chernyy-chay.avif",
"title": "Черный чай",
"source": "https://media.dodostatic.com/image/019c20b4459874dab1f68c8e2dab7bab.avif"
},
{
"file": "menu/chernyy-chay-s-chabretsom.avif",
"title": "Черный чай с чабрецом",
"source": "https://media.dodostatic.com/image/019c4816ce22743ea114cce7cba1c18d.avif"
},
{
"file": "menu/molochnyy-ulun.avif",
"title": "Молочный Улун",
"source": "https://media.dodostatic.com/image/019c20b4bcbb729d865667412da59cea.avif"
},
{
"file": "menu/amerikano.avif",
"title": "Американо",
"source": "https://media.dodostatic.com/image/019c9f5ef3d2785a9728fdf2e30663a3.avif"
},
{
"file": "menu/kapuchino.avif",
"title": "Капучино",
"source": "https://media.dodostatic.com/image/019c9f60418470678bccabd479e205fd.avif"
},
{
"file": "menu/chizkeyk-nyu-york.avif",
"title": "Чизкейк Нью-Йорк",
"source": "https://media.dodostatic.com/image/019c28113de37748aaf16491fc5cb061.avif"
},
{
"file": "menu/chizkeyk-nyu-york-klubnichnyy.avif",
"title": "Чизкейк Нью-Йорк клубничный",
"source": "https://media.dodostatic.com/image/019c335d507571838b4855a5f5e6c79a.avif"
},
{
"file": "menu/chizkeyk-nyu-york-malina.avif",
"title": "Чизкейк Нью-Йорк Малина",
"source": "https://media.dodostatic.com/image/019c2811f09e70d6b6bff47e85e69808.avif"
},
{
"file": "menu/tort-krasnyy-barkhat.avif",
"title": "Торт Красный бархат",
"source": "https://media.dodostatic.com/image/019c20b57c56743896b879e290de3a9a.avif"
},
{
"file": "menu/tort-medovik.avif",
"title": "Торт Медовик",
"source": "https://media.dodostatic.com/image/019c20b5ef56790ebeb1f747e79d02f5.avif"
},
{
"file": "menu/alpen-gold.avif",
"title": "Alpen gold",
"source": "https://media.dodostatic.com/image/019c5cf23ec977548b241b9ac6c14df5.avif"
},
{
"file": "menu/milka.avif",
"title": "Milka",
"source": "https://media.dodostatic.com/image/019c5cf2aa3f71eeb997c74b4cea9f9d.avif"
},
{
"file": "menu/chipsy-leys.avif",
"title": "Чипсы Leys",
"source": "https://media.dodostatic.com/image/019d61b7f4247566a6e64be7736f3389.avif"
},
{
"file": "menu/3-korochki.avif",
"title": "3 Корочки",
"source": "https://media.dodostatic.com/image/019c52063b3177debd91e3afab378ba5.avif"
},
{
"file": "menu/khrustim-team.avif",
"title": "Хрустим team",
"source": "https://media.dodostatic.com/image/019c9f943dbf72be84d4e741078dd41c.avif"
},
{
"file": "menu/natakhtari-grusha.avif",
"title": "НАТАХТАРИ ГРУША",
"source": "https://media.dodostatic.com/image/019c38923a277682909a25bf8749f841.avif"
},
{
"file": "menu/natakhtari-feykhoa.avif",
"title": "НАТАХТАРИ ФЕЙХОА",
"source": "https://media.dodostatic.com/image/019c41407e7b7804bff17d671c715123.avif"
},
{
"file": "menu/natakhtari-saperavi.avif",
"title": "НАТАХТАРИ САПЕРАВИ",
"source": "https://media.dodostatic.com/image/019c414a38e07168bd4b646b9fa2ed4e.avif"
},
{
"file": "menu/klubnika-kivi.avif",
"title": "Клубника Киви",
"source": "https://media.dodostatic.com/image/019c4817cf2e73f2bfacb43c0706eabf.avif"
},
{
"file": "menu/feykhoa.avif",
"title": "Фейхоа",
"source": "https://media.dodostatic.com/image/019c4818bd6e76779f4a8df44cb1d8b2.avif"
},
{
"file": "menu/burn-originalnyy.avif",
"title": "BURN Оригинальный",
"source": "https://media.dodostatic.com/image/019c481b4eb27671ac6f73da19a0c793.avif"
},
{
"file": "menu/burn-yabloko-kivi.avif",
"title": "BURN Яблоко Киви",
"source": "https://media.dodostatic.com/image/019c520715f07505919d92eab9d0c67d.avif"
},
{
"file": "menu/burn-so-vkusom-mango.avif",
"title": "BURN Со вкусом МАНГО",
"source": "https://media.dodostatic.com/image/019c5208afea73c3ada99f755b97c9b2.avif"
},
{
"file": "menu/adrenaline-rush-absolyutnaya-energiya.avif",
"title": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ",
"source": "https://media.dodostatic.com/image/019c520a306d769e9e0592c5c087567f.avif"
},
{
"file": "menu/probkovyy-sbor.avif",
"title": "Пробковый сбор",
"source": "https://media.dodostatic.com/image/019c9f56a5df7489ac257e70bd892a4b.avif"
},
{
"file": "menu/porcha-imuschestva.avif",
"title": "Порча имущества",
"source": "https://media.dodostatic.com/image/019c9f570847798ebf8992ddd71b59c7.avif"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Disallow: /api/
Allow: /
Sitemap: https://neon-bar86.ru/sitemap.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://neon-bar86.ru/</loc>
<lastmod>2026-09-29</lastmod>
<priority>1.0</priority>
</url>
</urlset>
+143
View File
@@ -0,0 +1,143 @@
// Патч: под словесным знаком «НЕОН» появляется подпись «(ex Shelby)» —
// прежнее название бара. Подпись живёт до конца 2026 года: с 1 января 2027
// клиент прячет её сам (useEffect), пересборка не требуется.
// Скрипт не идемпотентен: повторный запуск откажется работать.
import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
const HEADER = "src/components/Header.tsx";
const FOOTER = "src/components/Footer.tsx";
const die = (msg) => {
console.error("PATCH_FAIL: " + msg);
process.exit(1);
};
// ---------- Header.tsx ----------
let src = readFileSync(HEADER, "utf8");
copyFileSync(HEADER, HEADER + ".bak");
const MARK = "export function Logo(";
const first = src.indexOf(MARK);
if (first === -1) die("Logo declaration not found in Header.tsx");
if (src.indexOf(MARK, first + 1) !== -1) die("Logo declared more than once");
// конец списка параметров: скобочный баланс от первой ( после MARK
let i = src.indexOf("(", first);
let parenDepth = 0;
for (; i < src.length; i++) {
if (src[i] === "(") parenDepth++;
else if (src[i] === ")") {
parenDepth--;
if (parenDepth === 0) {
i++;
break;
}
}
}
// пропускаем пробелы до тела функции
while (i < src.length && /\s/.test(src[i])) i++;
if (src[i] !== "{") die("Logo body brace not found");
// тело функции: баланс фигурных скобок
let depth = 0;
let end = -1;
for (; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end === -1) die("Logo body end not found");
const original = src.slice(first, end + 1);
if (!original.includes("</a>") || !original.includes("ОН")) {
die("Logo body does not look like the wordmark: " + JSON.stringify(original.slice(0, 240)));
}
console.log("LOGO_FOUND: " + original.length + " chars, href=" + (original.includes('#home') ? "#home" : "?"));
const replacement = `const FORMER_NAME_DEADLINE = new Date("2027-01-01T00:00:00").getTime();
export function Logo({ className }: { className?: string }) {
// «(ex Shelby)» — прежнее название бара; подпись видна до конца 2026 года,
// после новогодней ночи клиент прячет её сам, пересборка не требуется.
// Изначально true, чтобы пререндеренный HTML и первая клиентская отрисовка
// совпадали (без hydration-мисматча); эффект убирает надпись после дедлайна.
const [showFormerName, setShowFormerName] = useState(true);
useEffect(() => {
if (Date.now() >= FORMER_NAME_DEADLINE) setShowFormerName(false);
}, []);
return (
<a
href="#home"
className={cn(
"group inline-flex flex-col font-display text-xl font-extrabold tracking-[0.18em] sm:text-2xl",
className,
)}
>
<span className="flex">
<span className="text-glow-violet text-white transition-all duration-300 group-hover:text-fuchsia-200">
НЕ
</span>
<span className="text-glow-cyan text-white transition-all duration-300 group-hover:text-cyan-200">
ОН
</span>
</span>
{showFormerName && (
<span className="mt-1 text-[9px] font-semibold tracking-[0.32em] text-mist/45 transition-colors duration-300 group-hover:text-mist/75">
(ex Shelby)
</span>
)}
</a>
);
}`;
src = src.slice(0, first) + replacement + src.slice(end + 1);
// импорты useState/useEffect, если их ещё нет до объявления Logo
const beforeLogo = src.slice(0, src.indexOf("export function Logo"));
if (!/\buseEffect\b/.test(beforeLogo)) {
if (/import \{[^}]*\} from "react";/.test(src)) {
src = src.replace(/import \{([^}]*)\} from "react";/, (m, names) => {
const names2 = names
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const n of ["useState", "useEffect"]) {
if (!names2.includes(n)) names2.push(n);
}
return `import { ${names2.join(", ")} } from "react";`;
});
} else {
src = `import { useEffect, useState } from "react";\n` + src;
}
console.log("REACT_IMPORT_PATCHED");
} else {
console.log("REACT_IMPORT_ALREADY_THERE");
}
writeFileSync(HEADER, src);
console.log("HEADER_PATCHED");
// ---------- Footer.tsx ----------
const footer = readFileSync(FOOTER, "utf8");
if (/<Logo\b/.test(footer)) {
console.log("FOOTER_USES_SHARED_LOGO");
} else {
console.log("FOOTER_NEEDS_MANUAL: no <Logo usage; wordmark occurrences:");
let idx = footer.indexOf("ОН");
let n = 0;
while (idx !== -1 && n < 5) {
console.log(
"--- @" + idx + ": " + JSON.stringify(footer.slice(Math.max(0, idx - 140), idx + 180)),
);
idx = footer.indexOf("ОН", idx + 1);
n++;
}
}
console.log("PATCH_OK");
+54
View File
@@ -0,0 +1,54 @@
// E2E стоп-листа: логин кальянщиком → стоп табака витринного микса → проверка.
const BASE = process.env.CRM_URL ?? "http://127.0.0.1:47613";
// F-12: креды только через окружение — в коде их нет
const CRM_LOGIN = process.env.CRM_LOGIN;
const CRM_PASSWORD = process.env.CRM_PASSWORD;
if (!CRM_LOGIN || !CRM_PASSWORD) {
console.error("Задайте CRM_LOGIN и CRM_PASSWORD (учётка кальянщика)");
process.exit(1);
}
const ID = "bliss-lime";
const j = async (path, opts = {}) => {
const r = await fetch(BASE + path, {
...opts,
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
signal: AbortSignal.timeout(8000),
});
const text = await r.text();
let data = null;
try {
data = JSON.parse(text);
} catch {
data = null;
}
return { status: r.status, data };
};
const login = await j("/api/login", {
method: "POST",
body: JSON.stringify({ login: CRM_LOGIN, password: CRM_PASSWORD }),
});
if (login.status !== 200 || !login.data?.token) {
console.log("E2E_LOGIN_FAIL status=" + login.status);
process.exit(1);
}
const token = login.data.token;
const before = await j("/api/stoplist");
const beforeIds = before.data?.stopped_tobacco ?? [];
const patch = await j(`/api/tobacco/${ID}`, {
method: "PATCH",
headers: { "X-Auth-Token": token },
body: JSON.stringify({ stopped: true }),
});
const after = await j("/api/stoplist");
const afterIds = after.data?.stopped_tobacco ?? [];
console.log(
`E2E login=${login.status} role=${login.data?.role ?? login.data?.user?.role ?? "?"} ` +
`before=${beforeIds.length} patch=${patch.status} after=${afterIds.length} ` +
`contains=${afterIds.includes(ID) ? 1 : 0}`,
);
+293
View File
@@ -0,0 +1,293 @@
/* Мини-тесты движка миксов. Запуск: npm run test:engine (tsx) */
import {
CATEGORY_LABELS,
FLAVORS,
PROFILES,
type FlavorCategory,
type FlavorStrength,
type ProfileId,
} from "../src/data/flavors";
import {
buildFlavorVariations,
buildFocusList,
encodeShareLink,
generateFreshMixSet,
generateMixSet,
getFlavor,
isColdFlavor,
mulberry32,
parseShareLink,
randomSeed,
validateMix,
type GeneratedMix,
type GenerateOptions,
type PriorityFocus,
type SharePayload,
} from "../src/lib/mixEngine";
const profiles = Object.keys(PROFILES) as ProfileId[];
const strengths: FlavorStrength[] = ["light", "medium", "strong"];
const categories = Object.keys(CATEGORY_LABELS) as FlavorCategory[];
const warmFlavors = FLAVORS.filter((f) => !isColdFlavor(f));
const warmCategories = categories.filter((c) => c !== "cooling");
const failures: string[] = [];
const stats = {
total: 0,
fallback: 0,
unverified: 0,
pairRepeats: 0,
shortSets: 0,
twoPart: 0,
threePart: 0,
withFrost: 0,
byProfile: {} as Record<string, number>,
byStrength: {} as Record<string, number>,
};
function covers(mix: GeneratedMix, focus: PriorityFocus): boolean {
return mix.items.some((i) => {
const f = getFlavor(i.flavorId)!;
return focus.kind === "flavor" ? f.id === focus.id : f.category === focus.category;
});
}
function structuralChecks(mix: GeneratedMix, seed: number) {
if (mix.items.length < 2 || mix.items.length > 3)
failures.push(`seed=${seed}: длина ${mix.items.length}`);
const sum = mix.items.reduce((s, i) => s + i.percent, 0);
if (sum !== 100) failures.push(`seed=${seed}: сумма ${sum}`);
const cold = mix.items.filter((i) => isColdFlavor(getFlavor(i.flavorId)!));
if (cold.length > 1) failures.push(`seed=${seed}: несколько холодков`);
cold.forEach((c) => {
if (c.percent > 15) failures.push(`seed=${seed}: холодок ${c.percent}%`);
});
// «Гипотеза для кальянщика» может нарушать правила качества — она помечена
if (mix.unverified) return;
const [base, add] = mix.items.map((i) => getFlavor(i.flavorId)!);
if (base.intensity >= 4 && add.intensity >= 4)
failures.push(`seed=${seed}: основа и добавка оба ≥4`);
}
function runSet(profile: ProfileId, opts: GenerateOptions, seed: number) {
const { mixes } = generateMixSet(profile, opts, seed);
const label = `seed=${seed} ${profile}/${opts.strength}/cold=${opts.cooling}`;
if (mixes.length < 3) stats.shortSets += 1;
// В одной выдаче составы уникальны (жёстко), пары — по возможности
const signatures = new Set<string>();
const pairs = new Set<string>();
for (const mix of mixes) {
if (signatures.has(mix.signature)) failures.push(`${label}: повтор состава ${mix.signature}`);
signatures.add(mix.signature);
pairs.add(mix.pairKey);
}
stats.pairRepeats += mixes.length - pairs.size;
const focusList = buildFocusList(opts);
for (const mix of mixes) {
stats.total += 1;
stats.byProfile[profile] = (stats.byProfile[profile] ?? 0) + 1;
stats.byStrength[opts.strength] = (stats.byStrength[opts.strength] ?? 0) + 1;
if (mix.items.length === 2) stats.twoPart += 1;
if (mix.items.length === 3) stats.threePart += 1;
if (mix.items.some((i) => isColdFlavor(getFlavor(i.flavorId)!))) stats.withFrost += 1;
if (mix.curated) stats.fallback += 1;
if (mix.unverified) stats.unverified += 1;
// Приоритет: каждый микс содержит хотя бы один приоритетный вкус/категорию
if (focusList.length > 0 && !mix.curated && !focusList.some((f) => covers(mix, f)))
failures.push(`${label}: в миксе нет приоритета [${mix.items.map((i) => i.flavorId)}]`);
// Исключения всегда сильнее: ни один микс не содержит стоп-лист
for (const item of mix.items) {
const f = getFlavor(item.flavorId)!;
if (opts.excludedFlavorIds.includes(f.id)) failures.push(`${label}: ${f.id} из стоп-листа`);
if (opts.excludedCategories.includes(f.category) && !mix.curated)
failures.push(`${label}: категория ${f.category} из стоп-листа`);
}
if (mix.curated) {
// кураторская заготовка не может быть гипотезой: она проверена мастером
if (mix.unverified) failures.push(`${label}: заготовка помечена гипотезой`);
const structural = validateMix(mix.items, profile, opts, true);
if (!structural.ok) failures.push(`${label} curated: ${structural.errors.join("; ")}`);
} else if (!mix.unverified) {
const full = validateMix(mix.items, profile, opts);
if (!full.ok) failures.push(`${label}: ${full.errors.join("; ")}`);
}
structuralChecks(mix, seed);
}
// Несколько приоритетов распределяются по карточкам
if (focusList.length > 1 && !mixes.some((m) => m.curated)) {
const covered = focusList.filter((f) => mixes.some((m) => covers(m, f))).length;
const expected = Math.min(focusList.length, mixes.length);
if (covered < expected)
failures.push(`${label}: приоритеты не распределены (${covered}/${expected})`);
}
}
const base = (strength: FlavorStrength, cooling: boolean): GenerateOptions => ({
strength,
cooling,
excludedCategories: [],
excludedFlavorIds: [],
});
/* Сценарий А: все профили × крепости × холодок, без стоп-листа.
Кураторские заготовки («Проверен мастером») — легитимная часть выдачи. */
for (const profile of profiles)
for (const strength of strengths)
for (const cooling of [false, true])
for (let n = 0; n < 3; n++) runSet(profile, base(strength, cooling), randomSeed());
/* Сценарий Б: случайные параметры + исключённая категория */
for (let n = 0; n < 60; n++) {
const seed = randomSeed();
const opts = base(strengths[(seed >>> 2) % 3], seed % 2 === 0);
if (n % 4 === 0) opts.excludedCategories = [categories[(seed >>> 4) % categories.length]];
runSet(profiles[seed % profiles.length], opts, seed);
}
/* Сценарий В: один приоритетный вкус — для каждого вкуса, с холодком и без */
for (const flavor of warmFlavors) {
const strength = (flavor.strength === "any" ? "medium" : flavor.strength) as FlavorStrength;
for (const cooling of [false, true]) {
runSet("fresh", { ...base(strength, cooling), priorityFlavorIds: [flavor.id] }, randomSeed());
}
}
/* Сценарий Г: несколько приоритетных вкусов (2–4 штуки) */
for (let n = 0; n < 40; n++) {
const seed = randomSeed();
const rng = mulberry32(seed);
const count = 2 + Math.floor(rng() * 3);
const picked = new Set<string>();
while (picked.size < count) picked.add(warmFlavors[Math.floor(rng() * warmFlavors.length)].id);
runSet(
profiles[seed % profiles.length],
{ ...base(strengths[(seed >>> 3) % 3], seed % 2 === 0), priorityFlavorIds: [...picked] },
seed,
);
}
/* Сценарий Д: приоритетные категории (+ иногда вкус) */
for (let n = 0; n < 40; n++) {
const seed = randomSeed();
const rng = mulberry32(seed);
const cats = new Set<FlavorCategory>();
const count = 1 + Math.floor(rng() * 2);
while (cats.size < count) cats.add(warmCategories[Math.floor(rng() * warmCategories.length)]);
const opts: GenerateOptions = {
...base(strengths[(seed >>> 3) % 3], seed % 2 === 0),
priorityCategories: [...cats],
};
if (n % 3 === 0) opts.priorityFlavorIds = [warmFlavors[seed % warmFlavors.length].id];
runSet(profiles[seed % profiles.length], opts, seed);
}
/* Сценарий Е: вариации на каждый вкус × крепость.
Составы без долей уникальны: у холодного вкуса и узких стоп-листов
уникальных наборов может быть меньше 6, но не меньше 3. */
let variationCount = 0;
for (const flavor of FLAVORS) {
for (const strength of strengths) {
const variants = buildFlavorVariations(flavor.id, strength, 123456789);
variationCount += variants.length;
if (variants.length < 3)
failures.push(`вариаций для ${flavor.id}/${strength}: ${variants.length}, меньше 3`);
if (new Set(variants.map((v) => v.signature)).size !== variants.length)
failures.push(`повторы вариаций ${flavor.id}/${strength}`);
for (const v of variants) {
if (!v.items.some((i) => i.flavorId === flavor.id))
failures.push(`вариация ${flavor.id}/${strength} без целевого вкуса`);
const structural = validateMix(v.items, v.profile, base(strength, false), true);
if (!structural.ok && !v.unverified)
failures.push(`вариация ${flavor.id}/${strength}: ${structural.errors.join("; ")}`);
}
}
}
/* Детерминизм и воспроизводимость «свежей» выдачи по seed */
const detOpts: GenerateOptions = {
...base("strong", true),
priorityFlavorIds: ["white-grape", "honey-melon"],
priorityCategories: ["citrus"],
};
const deterministic =
JSON.stringify(generateMixSet("fresh", detOpts, 424242)) ===
JSON.stringify(generateMixSet("fresh", detOpts, 424242));
if (!deterministic) failures.push("детерминизм нарушен");
const fresh = generateFreshMixSet("fresh", detOpts, []);
const reproduced =
JSON.stringify(generateMixSet("fresh", detOpts, fresh.seed).mixes) === JSON.stringify(fresh.mixes);
if (!reproduced) failures.push("seed «свежей» выдачи не воспроизводит подборку");
/* Анти-повтор между выдачами (мягкий, считаем статистику) */
let crossRepeats = 0;
let history: string[] = [];
for (let n = 0; n < 20; n++) {
const res = generateFreshMixSet("fresh", base("strong", false), history);
crossRepeats += res.mixes.filter((m) => history.includes(m.pairKey)).length;
history = res.newHistory;
}
/* Share: v2 туда-обратно + совместимость со старыми v1-ссылками */
const payload: SharePayload = {
seed: 123456,
profile: "fresh",
strength: "medium",
cooling: true,
excludedCategories: ["spices"],
excludedFlavorIds: ["cookie-uc"],
priorityCategories: ["berries"],
priorityFlavorIds: ["green-day", "pinkman"],
};
const link = encodeShareLink(payload);
const roundTrip = parseShareLink(link);
if (JSON.stringify(roundTrip) !== JSON.stringify(payload))
failures.push(`share v2: ${link} → ${JSON.stringify(roundTrip)}`);
const legacy = parseShareLink("#mix=v1:777:berry:strong:0:spices");
if (
!legacy ||
legacy.seed !== 777 ||
legacy.excludedCategories.join() !== "spices" ||
legacy.priorityFlavorIds.length !== 0
)
failures.push("share v1 больше не разбирается");
if (parseShareLink("#mix=v2:1:unknown:strong:0::::") !== null)
failures.push("share: принят неизвестный профиль");
/* Статистика */
const avgLength = (stats.twoPart * 2 + stats.threePart * 3) / Math.max(1, stats.total);
console.log("=== Self-test движка миксов ===");
console.log(`Всего миксов: ${stats.total}`);
console.log(`Кураторских fallback: ${stats.fallback}`);
console.log(`Гипотез «спроси мастера»: ${stats.unverified}`);
console.log(`Повторов пар в выдаче: ${stats.pairRepeats} (составы при этом разные)`);
console.log(`Неполных выдач (<3 миксов): ${stats.shortSets}`);
console.log(`2-компонентных: ${stats.twoPart}, 3-компонентных: ${stats.threePart}`);
console.log(`Средняя длина: ${avgLength.toFixed(2)} вкуса`);
console.log(`С холодком: ${stats.withFrost}`);
console.log(`Вариаций на все вкусы: ${variationCount} (уникальные составы)`);
console.log(`Повторов пар между выдачами: ${crossRepeats} за 20 генераций`);
console.log("По профилям:", stats.byProfile);
console.log("По крепости:", stats.byStrength);
console.log(`Детерминизм seed: ${deterministic ? "OK" : "FAIL"}`);
console.log(`Воспроизводимость по seed: ${reproduced ? "OK" : "FAIL"}`);
console.log(`Share v2 / v1: ${roundTrip && legacy ? "OK" : "FAIL"} ${link}`);
if (failures.length > 0) {
console.error(`\n❌ Провалено проверок: ${failures.length}`);
failures.slice(0, 25).forEach((f) => console.error(" - " + f));
process.exit(1);
}
console.log("\n✅ Все миксы прошли валидацию");
+144
View File
@@ -0,0 +1,144 @@
/**
* Экспорт меню и табаков сайта в seed-JSON для CRM (crm/seed/*.json).
*
* Запуск из папки site: npm run export:crm
* CRM при старте сеет этими данными таблицы menu_items / tobacco_flavors,
* библиотеку игр (games.json, обложки -> seed/covers/) и отдаёт
* авторские миксы сайта во вкладку «Миксы» (mixes.json).
* После изменения меню на сайте — перегенерируй файлы и пересобери CRM.
*/
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { barMenuItems } from "../src/data/barMenu";
import { beerList } from "../src/data/beerMenu";
import { authorTeas } from "../src/data/teaMenu";
import {
boardGameDifficultyLabels,
boardGames,
hookahMixes,
ps5Games,
tobaccoFlavors,
} from "../src/data/catalog";
// Атомарная запись (F-6): обрыв экспорта не оставит битый seed — рестарт CRM
// с повреждённым файлом роняется fail-closed'ом и не вычистит каталог.
const outDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../crm/seed");
const writeSeedJson = (name: string, data: unknown) => {
const target = resolve(outDir, name);
const tmp = target + ".tmp";
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
renameSync(tmp, target);
};
const siteRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const gamesImagesDir = resolve(siteRoot, "public/images/games");
const menu = [
...authorTeas.map((tea) => ({
id: tea.id,
name: tea.name,
category: tea.category,
price: tea.price,
portion: tea.volume,
})),
...beerList.map((beer) => ({
id: beer.id,
name: beer.name,
category: beer.category,
price: beer.price,
portion: beer.volume,
})),
...barMenuItems.map((item) => ({
id: item.id,
name: item.name,
category: item.category,
price: item.price,
portion: item.portion ?? null,
})),
];
// Стоп-лист в CRM живёт на уровне отдельных вкусов, а не брендов:
// экспортируем все вкусы каталога (включая «Добавку» — её вкусы тоже
// участвуют в конструкторе миксов).
const tobacco = tobaccoFlavors
.map((f) => ({
id: f.id,
brand: f.brand,
flavor: f.name,
note: f.note ?? null,
strength: f.strength,
cooling: f.cooling,
}))
.sort(
(a, b) =>
a.brand.localeCompare(b.brand, "ru") || a.flavor.localeCompare(b.flavor, "ru"),
);
mkdirSync(outDir, { recursive: true });
writeSeedJson("menu.json", menu);
writeSeedJson("tobacco.json", tobacco);
// ------------------------------------------------------------------ игры
// PS5: note = жанр и игроки; обложка копируется в crm/seed/covers/
// (.jfif переименовывается в .jpg — у CRM mimetype для .jfif не настроен).
// Настолки: note = категория, игроки, время, сложность.
const coversDir = resolve(outDir, "covers");
mkdirSync(coversDir, { recursive: true });
const coverFile = (image: string): string | null => {
if (!image) return null;
const srcName = image.split("/").pop() ?? "";
const src = resolve(gamesImagesDir, srcName);
if (!existsSync(src)) return null;
const dstName = srcName.toLowerCase().endsWith(".jfif")
? srcName.slice(0, -5) + ".jpg"
: srcName;
cpSync(src, resolve(coversDir, dstName));
return dstName;
};
const games = [
...ps5Games.map((g) => ({
seed_id: g.id,
kind: "ps5",
title: g.name,
note: `${g.genre} · до ${g.maxPlayers} игрок${g.maxPlayers === 1 ? "а" : "ов"}`,
cover: coverFile(g.image),
})),
...boardGames.map((g) => ({
seed_id: `board-${g.id}`,
kind: "board",
title: g.name,
note: `${g.category} · ${g.minPlayers}–${g.maxPlayers} игроков · ${g.duration} · `
+ `${boardGameDifficultyLabels[g.difficulty].toLowerCase()}${g.hit ? " · хит" : ""}`,
cover: null,
difficulty: g.difficulty,
icon: g.icon,
accent: g.accent,
})),
];
// ------------------------------------------------------------------ миксы сайта
// Авторские миксы из каталога сайта: CRM показывает их во вкладке «Миксы»
// только на чтение — на сайте они рендерятся сами, дубли в mix_recipes не нужны.
const siteMixes = hookahMixes.map((m) => ({
id: m.id,
name: m.name,
note: m.desc,
strength: m.strength,
cooling: m.cooling,
flavor_tags: m.flavorTags,
price: m.price,
featured: m.featured ?? false,
}));
writeSeedJson("games.json", games);
writeSeedJson("mixes.json", siteMixes);
const coversCopied = readdirSync(coversDir).length;
console.log(
`menu.json: ${menu.length} позиций, tobacco.json: ${tobacco.length} вкусов,`
+ ` games.json: ${games.length} игр (обложек скопировано: ${coversCopied}),`
+ ` mixes.json: ${siteMixes.length} миксов -> ${outDir}`,
);
+51
View File
@@ -0,0 +1,51 @@
# Генератор favicon-набора сайта НЕОН: python scripts/make-favicon.py
# Запускается вручную (нужен Pillow: pip install pillow). Рисует фирменный
# логотип «Н» и складывает файлы в public/ — Vite копирует их в корень
# сборки, nginx отдаёт /favicon.ico поисковикам (Яндекс/Google забирают
# иконку именно с адреса /favicon.ico, data-URI из <head> они не читают).
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "public"
BG = (10, 10, 15, 255) # #0A0A0F
FG = (0, 240, 255, 255) # #00F0FF
FONT = r"C:\Windows\Fonts\ariblk.ttf" # Arial Black — как в SVG-логотипе
RADIUS_RATIO = 14 / 64 # скругление углов из SVG (rx=14 при 64)
def draw_badge(size: int, rounded: bool) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
if rounded:
d.rounded_rectangle([0, 0, size - 1, size - 1],
radius=round(size * RADIUS_RATIO), fill=BG)
else:
# apple-touch-icon: без прозрачных углов — iOS сам скругляет маской
d.rectangle([0, 0, size, size], fill=BG)
font = ImageFont.truetype(FONT, round(size * 34 / 64))
d.text((size / 2, size * 0.53), "Н", font=font, fill=FG, anchor="mm")
return img
OUT.mkdir(exist_ok=True)
# вектор — браузеры с поддержкой SVG-иконок берут его, файл не зависит от шрифтов
(OUT / "favicon.svg").write_text(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>"
"<rect width='64' height='64' rx='14' fill='#0A0A0F'/>"
"<text x='32' y='45' font-family='Arial Black, Arial' font-size='34' "
"font-weight='900' text-anchor='middle' fill='#00F0FF'>Н</text></svg>",
encoding="utf-8",
)
master = draw_badge(512, rounded=True)
for px in (48, 32, 16):
master.resize((px, px), Image.LANCZOS).save(OUT / f"favicon-{px}x{px}.png")
# favicon.ico: 16+32+48 в одном файле — его запрашивают поисковики напрямую
master.save(OUT / "favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
draw_badge(180, rounded=False).save(OUT / "apple-touch-icon.png")
print("OK: favicon.ico, favicon.svg, favicon-16x16.png, favicon-32x32.png,",
"favicon-48x48.png, apple-touch-icon.png ->", OUT)
+575
View File
@@ -0,0 +1,575 @@
// Патч «больше миксов из табаков сайта + стоп-лист миксов».
// 1) mixEngine: generateShowcaseMixes принимает исключённые вкусы и берёт
// до 2 миксов на комбинацию «профиль × крепость × холодок»;
// + withStopWarnings/STOP_LIST_WARNING (помечает миксы со стоп-табаком).
// 2) catalog: блок витрины переносится ПОСЛЕ tobaccoFlavors, витрина
// собирается только из табаков, реально доступных на сайте.
// 3) MixesPage: стоп-лист CRM — микс уходит в стоп-лист (бейдж, гашение,
// красная подсветка стоп-вкусов, предупреждение на карточке).
// 4) AiMixPage: выдача и микс мастера перепроверяются на стоп-табак
// (share-восстановление и мастер-подбор не применяли стоп-лист).
// 5) HeroActions: русское множественное для счётчика миксов.
// Каждый якорь проверяется на точное число вхождений; при расхождении —
// die() без записи файла. Повторный запуск: пропускает уже пропатченное.
import { readFileSync, writeFileSync, copyFileSync, rmSync, existsSync } from "node:fs";
const die = (msg) => {
console.error("PATCH_FAIL: " + msg);
process.exit(1);
};
const rep = (src, from, to, label) => {
const parts = src.split(from);
if (parts.length - 1 !== 1) die(`${label}: found ${parts.length - 1}, expected 1`);
return parts.join(to);
};
/* ------------------------- 1. mixEngine.ts ------------------------- */
{
const FILE = "src/lib/mixEngine.ts";
let src = readFileSync(FILE, "utf8");
if (src.includes("withStopWarnings")) {
console.log("1 mixEngine: ALREADY_PATCHED");
} else {
copyFileSync(FILE, FILE + ".bak");
src = rep(
src,
"export interface ShowcaseMix {",
[
"/* ---------------- CRM stop-list: warnings in results ------------------- */",
"",
"/** Warning on a mix card whose tobacco has gone into the CRM stop-list. */",
'export const STOP_LIST_WARNING =',
' "Some tobaccos in the composition are in the stop-list — the hookah lounge attendant will pick a replacement.";',
"",
"/**",
" * Marks mixes whose composition contains a stopped tobacco (matching by",
" * the engine flavor's catalogId). If there is nothing to change, returns",
" * the same references — safe to call on every stop-list poll.",
" */",
"export function withStopWarnings<T extends { items: { flavorId: string }[]; warning?: string }>(",
" mixes: T[],",
" stoppedCatalogIds: ReadonlySet<string>,",
"): T[] {",
" if (stoppedCatalogIds.size === 0) return mixes;",
" let changed = false;",
" const next = mixes.map((mix) => {",
" const hit = mix.items.some((item) => {",
" const flavor = byId.get(item.flavorId);",
" return !!flavor && stoppedCatalogIds.has(flavor.catalogId ?? flavor.id);",
" });",
" if (!hit || mix.warning === STOP_LIST_WARNING) return mix;",
" changed = true;",
" return { ...mix, warning: STOP_LIST_WARNING };",
" });",
" return changed ? next : mixes;",
"}",
"",
"export interface ShowcaseMix {",
].join("\n"),
"mixEngine: helpers before ShowcaseMix",
);
src = rep(
src,
"export function generateShowcaseMixes(): ShowcaseMix[] {",
"export function generateShowcaseMixes(excludedFlavorIds: string[] = []): ShowcaseMix[] {",
"mixEngine: showcase signature",
);
src = rep(
src,
[
" excludedCategories: [],",
" excludedFlavorIds: [],",
" }, seed++);",
" const pick = mixes.find((m) => !m.unverified && !seen.has(m.signature));",
" if (!pick) continue;",
" seen.add(pick.signature);",
" let name = pick.name;",
" if (seenNames.has(name)) {",
" name = v.cooling ? `${name} · с холодком` : `${name} · без холодка`;",
" let n = 2;",
" while (seenNames.has(name)) {",
" name = `${pick.name} · вариант ${n}`;",
" n += 1;",
" }",
" }",
" seenNames.add(name);",
" out.push({ mix: { ...pick, name }, strength: v.level, cooling: v.cold });",
].join("\n"),
[
" excludedCategories: [],",
" excludedFlavorIds,",
" }, seed++);",
" // до двух миксов на комбинацию «профиль × крепость × холодок»:",
" // витрина заметно больше, составы по-прежнему уникальны и без гипотез",
" const picks = mixes",
" .filter((m) => !m.unverified && !seen.has(m.signature))",
" .slice(0, 2);",
" for (const pick of picks) {",
" seen.add(pick.signature);",
" let name = pick.name;",
" if (seenNames.has(name)) {",
" name = v.cooling ? `${name} · с холодком` : `${name} · без холодка`;",
" let n = 2;",
" while (seenNames.has(name)) {",
" name = `${pick.name} · вариант ${n}`;",
" n += 1;",
" }",
" }",
" seenNames.add(name);",
" out.push({ mix: { ...pick, name }, strength: v.level, cooling: v.cold });",
" }",
].join("\n"),
"mixEngine: showcase picks",
);
writeFileSync(FILE, src);
console.log("1 mixEngine: PATCHED");
}
}
/* -------------------------- 2. catalog.ts -------------------------- */
{
const FILE = "src/data/catalog.ts";
let src = readFileSync(FILE, "utf8");
if (src.includes("siteUnavailableFlavorIds")) {
console.log("2 catalog: ALREADY_PATCHED");
} else {
copyFileSync(FILE, FILE + ".bak");
src = rep(
src,
'import { CATEGORY_LABELS } from "./flavors";',
'import { CATEGORY_LABELS, FLAVORS } from "./flavors";',
"catalog: FLAVORS import",
);
// вырезаем блок витрины (до tobaccoFlavors ещё не инициализирован)
const BLOCK_START = "/** Витрина: генерации движка по всем профилям/крепостям/холодку. */";
const BLOCK_END =
' "Цены указаны за один кальян. Крепкие миксы — 1 800 ₽, остальные — 1 400 ₽.";';
const startIdx = src.indexOf(BLOCK_START);
if (startIdx === -1) die("catalog: showcase block start not found");
if (src.indexOf(BLOCK_START, startIdx + 1) !== -1) die("catalog: block start not unique");
const endIdx = src.indexOf(BLOCK_END, startIdx);
if (endIdx === -1) die("catalog: showcase block end not found");
const blockEnd = endIdx + BLOCK_END.length;
let block = src.slice(startIdx, blockEnd);
block = rep(
block,
"const showcase = generateShowcaseMixes();",
[
"/** Табаки, которых нет на сайте (или они помечены недоступными), в витрину",
" * не берём: каждый микс собран из реального ассортимента «Вкусов». */",
"const siteUnavailableFlavorIds: string[] = FLAVORS.filter((f) => {",
" const t = tobaccoFlavors.find((x) => x.id === (f.catalogId ?? f.id));",
" return !!t && !t.available;",
"}).map((f) => f.id);",
"",
"/** Витрина: генерации движка по всем профилям/крепостям/холодку. */",
"const showcase = generateShowcaseMixes(siteUnavailableFlavorIds);",
].join("\n"),
"catalog: showcase call",
);
// удаляем блок из старого места
src = src.slice(0, startIdx) + src.slice(blockEnd);
// вставляем перед секцией PlayStation 5
const psIdx = src.indexOf("/* PlayStation 5");
if (psIdx === -1) die("catalog: PS5 section not found");
const boxStart = src.lastIndexOf("/*", psIdx - 1);
if (boxStart === -1) die("catalog: PS5 comment box not found");
src = src.slice(0, boxStart) + block + "\n" + src.slice(boxStart);
writeFileSync(FILE, src);
console.log("2 catalog: PATCHED (block moved, " + block.length + " chars)");
}
}
/* ------------------------ 3. MixesPage.tsx ------------------------- */
{
const FILE = "src/pages/MixesPage.tsx";
let src = readFileSync(FILE, "utf8");
if (src.includes("markedCards")) {
console.log("3 MixesPage: ALREADY_PATCHED");
} else {
copyFileSync(FILE, FILE + ".bak");
src = rep(
src,
'import { useCustomCatalog, type CustomMix } from "../lib/crm";',
'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";',
"MixesPage: crm import",
);
src = rep(
src,
[
" /** вкус-холодок (синяя точка и роль «Холодок») */",
" cold: boolean;",
"}",
].join("\n"),
[
" /** вкус-холодок (синяя точка и роль «Холодок») */",
" cold: boolean;",
" /** табак в каталоге сайта — по нему сверяется стоп-лист CRM */",
" catalogId: string | null;",
" /** этот вкус ушёл в стоп-лист */",
" stopped: boolean;",
"}",
].join("\n"),
"MixesPage: UnifiedItem",
);
src = rep(
src,
[
" items: UnifiedItem[];",
"}",
].join("\n"),
[
" items: UnifiedItem[];",
" /** хотя бы один вкус состава в стоп-листе CRM — карточка гасится */",
" stopped: boolean;",
"}",
].join("\n"),
"MixesPage: UnifiedMix",
);
src = rep(
src,
[
" return {",
" title: flavor?.name ?? item.flavorId,",
" sub: flavor && flavor.name !== translation ? translation : null,",
" cold: !!flavor && isColdFlavor(flavor),",
" };",
].join("\n"),
[
" return {",
" title: flavor?.name ?? item.flavorId,",
" sub: flavor && flavor.name !== translation ? translation : null,",
" cold: !!flavor && isColdFlavor(flavor),",
" catalogId: flavor ? flavor.catalogId ?? flavor.id : null,",
" stopped: false,",
" };",
].join("\n"),
"MixesPage: itemsFromAi",
);
src = rep(
src,
[
" return {",
" title: f.flavor,",
" sub: f.brand,",
" cold: !!siteFlavor && isColdFlavor(siteFlavor),",
" };",
].join("\n"),
[
" return {",
" title: f.flavor,",
" sub: f.brand,",
" cold: !!siteFlavor && isColdFlavor(siteFlavor),",
" catalogId: f.id,",
" stopped: false,",
" };",
].join("\n"),
"MixesPage: itemsFromMaster",
);
src = rep(
src,
" items: itemsFromAi(mix),\n}));",
" items: itemsFromAi(mix),\n stopped: false,\n}));",
"MixesPage: aiCards stopped",
);
src = rep(
src,
" items: itemsFromMaster(mix),\n })),",
" items: itemsFromMaster(mix),\n stopped: false,\n })),",
"MixesPage: masterCards stopped",
);
src = rep(
src,
" const catalog = useCustomCatalog();",
" const catalog = useCustomCatalog();\n const stoplist = useStoplist();",
"MixesPage: useStoplist hook",
);
src = rep(
src,
" const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards]);",
[
" const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards]);",
"",
" // стоп-лист CRM: микс с остановленным табаком уходит в стоп-лист —",
" // карточка гасится, стоп-вкусы в составе подсвечиваются красным",
" const markedCards = useMemo(",
" () =>",
" allCards.map((card) => {",
" const items = card.items.map((item) => ({",
" ...item,",
" stopped: item.catalogId != null && stoplist.tobacco.has(item.catalogId),",
" }));",
" return { ...card, items, stopped: items.some((item) => item.stopped) };",
" }),",
" [allCards, stoplist.tobacco],",
" );",
].join("\n"),
"MixesPage: markedCards",
);
src = rep(
src,
" return allCards.filter((m) => {",
" return markedCards.filter((m) => {",
"MixesPage: filtered source",
);
src = rep(
src,
" }, [allCards, query, strength, cooling, flavor]);",
" }, [markedCards, query, strength, cooling, flavor]);",
"MixesPage: filtered deps",
);
src = rep(
src,
" Показано {filtered.length} из {allCards.length}",
" Показано {filtered.length} из {markedCards.length}",
"MixesPage: counter line",
);
src = rep(
src,
[
' : "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",',
" )}",
].join("\n"),
[
' : "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",',
' card.stopped && "opacity-70 saturate-[0.65]",',
" )}",
].join("\n"),
"MixesPage: card dim",
);
src = rep(
src,
[
' {isMaster ? "Микс от мастера" : "Микс от ИИ"}',
" </span>",
" {card.tags.map((tag) => (",
].join("\n"),
[
' {isMaster ? "Микс от мастера" : "Микс от ИИ"}',
" </span>",
" {card.stopped && (",
' <span className="inline-flex items-center gap-1 rounded-full border border-red-400/50 bg-red-400/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-red-200">',
" В стоп-листе",
" </span>",
" )}",
" {card.tags.map((tag) => (",
].join("\n"),
"MixesPage: stop badge",
);
src = rep(
src,
" style={{ background: segmentGradient(i, item.cold) }}",
[
" style={{",
" background: item.stopped",
' ? "linear-gradient(90deg,#f87171,#ef4444)"',
" : segmentGradient(i, item.cold),",
" }}",
].join("\n"),
"MixesPage: red dot",
);
src = rep(
src,
'<span className="block truncate font-semibold text-white/90">{item.title}</span>',
'<span className={cn("block truncate font-semibold text-white/90", item.stopped && "text-red-300")}>{item.title}</span>',
"MixesPage: red title",
);
src = rep(
src,
" {/* крепость · холодок · цена */}",
[
" {card.stopped && (",
' <p className="mt-3 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-[11px] leading-relaxed text-red-100">',
" Часть табаков из состава сейчас в стоп-листе — кальянщик подберёт замену.",
" </p>",
" )}",
"",
" {/* крепость · холодок · цена */}",
].join("\n"),
"MixesPage: stop warning line",
);
writeFileSync(FILE, src);
console.log("3 MixesPage: PATCHED");
}
}
/* ------------------------ 4. AiMixPage.tsx ------------------------- */
{
const FILE = "src/pages/AiMixPage.tsx";
let src = readFileSync(FILE, "utf8");
if (src.includes("withMasterStopNote")) {
console.log("4 AiMixPage: ALREADY_PATCHED");
} else {
copyFileSync(FILE, FILE + ".bak");
src = rep(
src,
'} from "../lib/mixEngine";',
" withStopWarnings,\n} from \"../lib/mixEngine\";",
"AiMixPage: engine import",
);
src = rep(
src,
"function pickMasterMix(",
[
"/** Предупреждение о стоп-табаке в миксе мастера: дописывается в note его",
" * карточки (note рендерится на карточке «Микс от мастера» в выдаче).",
" * Идемпотентно и обратимо: табак вернулся из стопа — note откатывается. */",
'const MASTER_STOP_NOTE =',
' "⚠ В составе есть табак из стоп-листа — мастер подберёт замену.";',
"",
"function withMasterStopNote(mix: CustomMix, stopped: ReadonlySet<string>): CustomMix {",
" const hit = mix.flavors.some((f) => stopped.has(f.id));",
' const base = (mix.note ?? "").split(MASTER_STOP_NOTE)[0].trim();',
" if (!hit) {",
' return base === (mix.note ?? "").trim() ? mix : { ...mix, note: base || null };',
" }",
' const next = [base, MASTER_STOP_NOTE].filter(Boolean).join(" ");',
' return next === (mix.note ?? "").trim() ? mix : { ...mix, note: next };',
"}",
"",
"function pickMasterMix(",
].join("\n"),
"AiMixPage: withMasterStopNote",
);
src = rep(
src,
[
" [currentOptions, stopExcludedIds],",
" );",
].join("\n"),
[
" [currentOptions, stopExcludedIds],",
" );",
"",
" /* Выдача и микс мастера перепроверяются на стоп-табак при каждом обновлении",
" стоп-листа: восстановление из share-ссылки собирается без стоп-исключений",
" (у каждого получателя свой стоп-лист), поэтому состав может содержать",
" остановленный табак — тогда на карточке появляется предупреждение. */",
' const stopKey = useMemo(() => [...stoplist.tobacco].sort().join("|"), [stoplist]);',
" useEffect(() => {",
' const stopped = new Set(stopKey ? stopKey.split("|") : []);',
" setResults((prev) => (prev ? withStopWarnings(prev, stopped) : prev));",
" setMasterPick((prev) => (prev ? withMasterStopNote(prev, stopped) : prev));",
" }, [stopKey]);",
].join("\n"),
"AiMixPage: stopKey effect",
);
src = rep(
src,
" setResults(mixes);",
" setResults(withStopWarnings(mixes, stoplist.tobacco));",
"AiMixPage: finish results",
);
src = rep(
src,
" setResults(generateMixSet(shared.profile, options, shared.seed).mixes);",
" setResults(",
" withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),",
" );",
"AiMixPage: share restore",
);
src = rep(
src,
" setMasterPick(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed));",
" setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed), stoplist.tobacco),",
" );",
"AiMixPage: finish master pick",
);
src = rep(
src,
" setMasterPick(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed));",
" setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),",
" );",
"AiMixPage: catalog-load master pick",
);
writeFileSync(FILE, src);
console.log("4 AiMixPage: PATCHED");
}
}
/* ----------------------- 5. HeroActions.tsx ------------------------ */
{
const FILE = "src/components/HeroActions.tsx";
let src = readFileSync(FILE, "utf8");
if (src.includes("mixPlural")) {
console.log("5 HeroActions: ALREADY_PATCHED");
} else {
copyFileSync(FILE, FILE + ".bak");
src = rep(
src,
"const chips: Chip[] = [",
[
"/* 51 микс / 52 микса / 55 миксов — русское множественное для счётчика */",
"const mixPlural = (n: number): string => {",
" const mod10 = n % 10;",
" const mod100 = n % 100;",
' if (mod10 === 1 && mod100 !== 11) return `${n} микс`;',
' if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${n} микса`;',
" return `${n} миксов`;",
"};",
"",
"const chips: Chip[] = [",
].join("\n"),
"HeroActions: mixPlural helper",
);
src = rep(
src,
" { href: \"#/mixes\", icon: Sparkles, label: `${hookahMixes.length} миксов`, tone: \"violet\" },",
' { href: "#/mixes", icon: Sparkles, label: mixPlural(hookahMixes.length), tone: "violet" },',
"HeroActions: chip label",
);
writeFileSync(FILE, src);
console.log("5 HeroActions: PATCHED");
}
}
/* зачистка временных диагностических скриптов */
for (const junk of ["scripts/diag-flavor-map.ts", "scripts/inspect-aimix.mjs"]) {
if (existsSync(junk)) {
rmSync(junk);
console.log("removed: " + junk);
}
}
console.log("PATCH_OK");
+69
View File
@@ -0,0 +1,69 @@
/**
* Пререндер лендинга в статический HTML (SEO).
*
* Работает БЕЗ браузера: react-dom/server рендерит <App /> в Node, готовый
* DOM вставляется между маркерами <!--SEO:FALLBACK_*--> в dist/index.html.
* useEffect в SSR не исполняется, поэтому вызовов /api/* в снапшоте нет —
* страница полностью статична; стоп-лист подмешивается уже на клиенте.
*
* Снапшот заменяется React (createRoot) при монтировании — SPA работает
* как раньше. Повторные запуски идемпотентны (маркеры сохраняются).
*
* Ошибка рендера не ломает сборку: остаётся статический fallback-блок
* (H1 + SEO-абзац), в консоль пишется предупреждение.
*
* Запуск: npm run build (vite build && tsx scripts/prerender.tsx)
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import App from "../src/App";
const here = dirname(fileURLToPath(import.meta.url));
const distIndex = join(here, "..", "dist", "index.html");
// флаг для src/lib/prerender.ts (секции рендерятся «видимыми») — до рендера
(globalThis as { __NEON_PRERENDER__?: boolean }).__NEON_PRERENDER__ = true;
// App читает document.title в useRef — минимальный полифилл для Node
if (typeof (globalThis as { document?: unknown }).document === "undefined") {
(globalThis as { document?: unknown }).document = { title: "" };
}
const START = "<!--SEO:FALLBACK_START-->";
const END = "<!--SEO:FALLBACK_END-->";
if (!existsSync(distIndex)) {
console.error("[prerender] dist/index.html не найден — сначала vite build");
process.exit(1);
}
const html = readFileSync(distIndex, "utf-8");
if (!html.includes(START) || !html.includes(END)) {
console.warn("[prerender] маркеры SEO:FALLBACK_* не найдены — снапшот не вставлен");
process.exit(0);
}
try {
const snapshot = renderToStaticMarkup(<App />);
if (!snapshot.includes("<h1")) {
throw new Error("в снапшоте нет <h1> — похоже, отрендерилась не главная");
}
const updated = html.replace(
new RegExp(`${START}[\\s\\S]*?${END}`),
`${START}${snapshot}${END}`,
);
writeFileSync(distIndex, updated, "utf-8");
console.log(
`[prerender] снапшот вставлен: ${Math.round(snapshot.length / 1024)} KB внутрь #root`,
);
} catch (error) {
console.warn(
"[prerender] рендер не удался, в dist остался статический fallback:",
error instanceof Error ? error.message : error,
);
process.exit(0);
}
+417
View File
@@ -0,0 +1,417 @@
// Починка патча стоп-листа миксов: многострочные замены первого скрипта
// записались урезанными. Здесь каждый блок собирается из ОДНОСТРОЧНЫХ
// литералов (устойчиво к искажению записи), каждый фикс идемпотентен.
import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
const L = (...lines) => lines.join("\n");
const log = [];
const ensureBak = (f) => { if (!existsSync(f + ".bak")) copyFileSync(f, f + ".bak"); };
const save = (f, src) => { ensureBak(f); writeFileSync(f, src); };
function fix(src, tag, has, re, to) {
if (src.includes(has)) { log.push(tag + "=skip"); return src; }
if (!re.test(src)) { log.push(tag + "=NOMATCH"); return src; }
log.push(tag + "=ok");
return src.replace(re, () => to);
}
/* --------------------------- mixEngine.ts --------------------------- */
{
const F = "src/lib/mixEngine.ts";
let s = readFileSync(F, "utf8");
s = fix(s, "E1-signature",
"generateShowcaseMixes(excludedFlavorIds",
/export function generateShowcaseMixes\(\): ShowcaseMix\[\] \{/,
"export function generateShowcaseMixes(excludedFlavorIds: string[] = []): ShowcaseMix[] {");
s = fix(s, "E2-helpers",
"withStopWarnings",
/export interface ShowcaseMix \{/,
L(
"/* ---------------- CRM stop-list: предупреждения в выдаче ------------------- */",
"",
"/** Предупреждение на карточке микса, чей табак ушёл в стоп-лист CRM. */",
"export const STOP_LIST_WARNING =",
' "В составе есть табак из стоп-листа — кальянщик подберёт замену.";',
"",
"/**",
" * Помечает миксы, в составе которых есть табак из стоп-листа CRM",
" * (сверка по catalogId вкуса движка). Если менять нечего, возвращает",
" * те же ссылки — безопасно вызывать на каждом опросе стоп-листа.",
" */",
"export function withStopWarnings<T extends { items: { flavorId: string }[]; warning?: string }>(",
" mixes: T[],",
" stoppedCatalogIds: ReadonlySet<string>,",
"): T[] {",
" if (stoppedCatalogIds.size === 0) return mixes;",
" let changed = false;",
" const next = mixes.map((mix) => {",
" const hit = mix.items.some((item) => {",
" const flavor = byId.get(item.flavorId);",
" return !!flavor && stoppedCatalogIds.has(flavor.catalogId ?? flavor.id);",
" });",
" if (!hit || mix.warning === STOP_LIST_WARNING) return mix;",
" changed = true;",
" return { ...mix, warning: STOP_LIST_WARNING };",
" });",
" return changed ? next : mixes;",
"}",
"",
"export interface ShowcaseMix {",
));
s = fix(s, "E3-opts",
"excludedFlavorIds,\n }, seed++);",
/excludedCategories: \[\],\n excludedFlavorIds: \[\],\n \}, seed\+\+\);/,
"excludedCategories: [],\n excludedFlavorIds,\n }, seed++);");
s = fix(s, "E3-picks",
"const picks = mixes",
/const pick = mixes\.find\(\(m\) => !m\.unverified && !seen\.has\(m\.signature\)\);[\s\S]*?out\.push\(\{ mix: \{ \.\.\.pick, name \}, strength: v\.level, cooling: v\.cold \}\);/,
L(
"const picks = mixes",
" .filter((m) => !m.unverified && !seen.has(m.signature))",
" .slice(0, 2);",
" for (const pick of picks) {",
" seen.add(pick.signature);",
" let name = pick.name;",
" if (seenNames.has(name)) {",
" name = v.cooling ? `${name} · с холодком` : `${name} · без холодка`;",
" let n = 2;",
" while (seenNames.has(name)) {",
" name = `${pick.name} · вариант ${n}`;",
" n += 1;",
" }",
" }",
" seenNames.add(name);",
" out.push({ mix: { ...pick, name }, strength: v.level, cooling: v.cold });",
" }",
));
save(F, s);
console.log("mixEngine: " + log.join(" "));
log.length = 0;
}
/* ---------------------------- catalog.ts ---------------------------- */
{
const F = "src/data/catalog.ts";
const s = readFileSync(F, "utf8");
const okCall = s.includes("generateShowcaseMixes(siteUnavailableFlavorIds)");
const okImport = s.includes('CATEGORY_LABELS, FLAVORS } from "./flavors"');
const okOrder = s.indexOf("const tobaccoFlavors") !== -1 &&
s.indexOf("const showcase = generateShowcaseMixes") > s.indexOf("const tobaccoFlavors");
console.log("catalog: call=" + (okCall ? 1 : 0) + " import=" + (okImport ? 1 : 0) + " order=" + (okOrder ? 1 : 0));
if (!okCall || !okImport || !okOrder) die("catalog needs manual repair");
}
/* --------------------------- MixesPage.tsx -------------------------- */
{
const F = "src/pages/MixesPage.tsx";
let s = readFileSync(F, "utf8");
s = fix(s, "M1-import",
'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";',
/import \{ useCustomCatalog, type CustomMix \} from "\.\.\/lib\/crm";/,
'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";');
s = fix(s, "M2-item-fields",
"catalogId: string | null;",
/( \/\*\* вкус-холодок \(синяя точка и роль «Холодок»\) \*\/\n cold: boolean;\n\})/,
L(
" /** вкус-холодок (синяя точка и роль «Холодок») */",
" cold: boolean;",
" /** табак в каталоге сайта — по нему сверяется стоп-лист CRM */",
" catalogId: string | null;",
" /** этот вкус ушёл в стоп-лист */",
" stopped: boolean;",
"}",
));
s = fix(s, "M3-mix-field",
"карточка гасится",
/( items: UnifiedItem\[\];\n\})/,
L(
" items: UnifiedItem[];",
" /** хотя бы один вкус состава в стоп-листе CRM — карточка гасится */",
" stopped: boolean;",
"}",
));
s = fix(s, "M4-items-ai",
"catalogId: flavor ? flavor.catalogId ?? flavor.id : null,",
/( cold: !!flavor && isColdFlavor\(flavor\),\n \};)/,
L(
" cold: !!flavor && isColdFlavor(flavor),",
" catalogId: flavor ? flavor.catalogId ?? flavor.id : null,",
" stopped: false,",
" };",
));
s = fix(s, "M5-items-master",
" catalogId: f.id,",
/( cold: !!siteFlavor && isColdFlavor\(siteFlavor\),\n \};)/,
L(
" cold: !!siteFlavor && isColdFlavor(siteFlavor),",
" catalogId: f.id,",
" stopped: false,",
" };",
));
s = fix(s, "M6-aicards",
" items: itemsFromAi(mix),\n stopped: false,\n}));",
/( items: itemsFromAi\(mix\),\n\}\)\);)/,
" items: itemsFromAi(mix),\n stopped: false,\n}));");
s = fix(s, "M7-mastercards",
" items: itemsFromMaster(mix),\n stopped: false,\n })),",
/( items: itemsFromMaster\(mix\),\n \}\)\),)/,
" items: itemsFromMaster(mix),\n stopped: false,\n })),");
s = fix(s, "M8-hook",
" const stoplist = useStoplist();",
/( const catalog = useCustomCatalog\(\);)/,
" const catalog = useCustomCatalog();\n const stoplist = useStoplist();");
s = fix(s, "M9-marked",
"const markedCards = useMemo(",
/( const allCards = useMemo\(\(\) => \[\.\.\.masterCards, \.\.\.aiCards\], \[masterCards\]\);)/,
L(
" const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards]);",
"",
" // стоп-лист CRM: микс с остановленным табаком уходит в стоп-лист —",
" // карточка гасится, стоп-вкусы в составе подсвечиваются красным",
" const markedCards = useMemo(",
" () =>",
" allCards.map((card) => {",
" const items = card.items.map((item) => ({",
" ...item,",
" stopped: item.catalogId != null && stoplist.tobacco.has(item.catalogId),",
" }));",
" return { ...card, items, stopped: items.some((item) => item.stopped) };",
" }),",
" [allCards, stoplist.tobacco],",
" );",
));
s = fix(s, "M10-filter",
"return markedCards.filter((m) => {",
/return allCards\.filter\(\(m\) => \{/,
"return markedCards.filter((m) => {");
s = fix(s, "M11-deps",
"[markedCards, query, strength, cooling, flavor]",
/\}, \[allCards, query, strength, cooling, flavor\]\);/,
"}, [markedCards, query, strength, cooling, flavor]);");
s = fix(s, "M12-counter",
"из {markedCards.length}",
/из \{allCards\.length\}/,
"из {markedCards.length}");
s = fix(s, "M13-dim",
'card.stopped && "opacity-70 saturate-[0.65]",',
/(: "hover:border-neon-violet\/40 hover:shadow-\[0_18px_50px_-24px_rgba\(176,38,255,0\.5\)\]",\n \)\})/,
': "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",\n card.stopped && "opacity-70 saturate-[0.65]",\n )}');
s = fix(s, "M14-badge",
"bg-red-400/12",
/(\{isMaster \? "Микс от мастера" : "Микс от ИИ"\}\n <\/span>)/,
L(
'{isMaster ? "Микс от мастера" : "Микс от ИИ"}',
" </span>",
" {card.stopped && (",
' <span className="inline-flex items-center gap-1 rounded-full border border-red-400/50 bg-red-400/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-red-200">',
" В стоп-листе",
" </span>",
" )}",
));
s = fix(s, "M15-dot",
'"linear-gradient(90deg,#f87171,#ef4444)"',
/style=\{\{ background: segmentGradient\(i, item\.cold\) \}\}/,
'style={{ background: item.stopped ? "linear-gradient(90deg,#f87171,#ef4444)" : segmentGradient(i, item.cold) }}');
s = fix(s, "M16-title",
'item.stopped && "text-red-300"',
/<span className="block truncate font-semibold text-white\/90">\{item\.title\}<\/span>/,
'<span className={cn("block truncate font-semibold text-white/90", item.stopped && "text-red-300")}>{item.title}</span>');
s = fix(s, "M17-warning",
"Часть табаков из состава сейчас в стоп-листе",
/(\{\/\* крепость · холодок · цена \*\/\})/,
L(
" {card.stopped && (",
' <p className="mt-3 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-[11px] leading-relaxed text-red-100">',
" Часть табаков из состава сейчас в стоп-листе — кальянщик подберёт замену.",
" </p>",
" )}",
"",
" {/* крепость · холодок · цена */}",
));
save(F, s);
console.log("MixesPage: " + log.join(" "));
log.length = 0;
}
/* --------------------------- AiMixPage.tsx -------------------------- */
{
const F = "src/pages/AiMixPage.tsx";
let s = readFileSync(F, "utf8");
s = fix(s, "A1-import",
" withStopWarnings,\n} from",
/\} from "\.\.\/lib\/mixEngine";/,
' withStopWarnings,\n} from "../lib/mixEngine";');
s = fix(s, "A2-note-fn",
"function withMasterStopNote",
/function pickMasterMix\(/,
L(
"/** Предупреждение о стоп-табаке в миксе мастера: дописывается в note его",
" * карточки (note рендерится на карточке «Микс от мастера» в выдаче).",
" * Идемпотентно и обратимо: табак вернулся из стопа — note откатывается. */",
"const MASTER_STOP_NOTE =",
' "⚠ В составе есть табак из стоп-листа — мастер подберёт замену.";',
"",
"function withMasterStopNote(mix: CustomMix, stopped: ReadonlySet<string>): CustomMix {",
" const hit = mix.flavors.some((f) => stopped.has(f.id));",
' const base = (mix.note ?? "").split(MASTER_STOP_NOTE)[0].trim();',
" if (!hit) {",
' return base === (mix.note ?? "").trim() ? mix : { ...mix, note: base || null };',
" }",
' const next = [base, MASTER_STOP_NOTE].filter(Boolean).join(" ");',
' return next === (mix.note ?? "").trim() ? mix : { ...mix, note: next };',
"}",
"",
"function pickMasterMix(",
));
s = fix(s, "A3-stopkey",
"const stopKey = useMemo(",
/\[currentOptions, stopExcludedIds\],\n \);/,
L(
"[currentOptions, stopExcludedIds],",
" );",
"",
" /* Выдача и микс мастера перепроверяются на стоп-табак при каждом обновлении",
" стоп-листа: восстановление из share-ссылки собирается без стоп-исключений",
" (у каждого получателя свой стоп-лист), поэтому состав может содержать",
" остановленный табак — тогда на карточке появляется предупреждение. */",
' const stopKey = useMemo(() => [...stoplist.tobacco].sort().join("|"), [stoplist]);',
" useEffect(() => {",
' const stopped = new Set(stopKey ? stopKey.split("|") : []);',
" setResults((prev) => (prev ? withStopWarnings(prev, stopped) : prev));",
" setMasterPick((prev) => (prev ? withMasterStopNote(prev, stopped) : prev));",
" }, [stopKey]);",
));
s = fix(s, "A4-finish-results",
"setResults(withStopWarnings(mixes, stoplist.tobacco));",
/setResults\(mixes\);/,
"setResults(withStopWarnings(mixes, stoplist.tobacco));");
s = fix(s, "A5-share",
"withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco)",
/setResults\(generateMixSet\(shared\.profile, options, shared\.seed\)\.mixes\);/,
L(
"setResults(",
" withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),",
" );",
));
// первый патч урезал замену до «setResults(» — чиним урезанный вариант
if (!s.includes("withStopWarnings(generateMixSet(shared.profile")) {
const re5b = /setResults\(\s*\n\s*setLastRun\(\{ seed: shared\.seed, profile: shared\.profile, options \}\);/;
if (re5b.test(s)) {
s = s.replace(re5b, () =>
L(
"setResults(",
" withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),",
" );",
" setLastRun({ seed: shared.seed, profile: shared.profile, options });",
));
log.push("A5b-share-truncated=ok");
} else {
log.push("A5b-share-truncated=NOMATCH");
const i = s.indexOf("setLastRun({ seed: shared.seed");
console.log("CTX5: " + JSON.stringify(s.slice(Math.max(0, i - 300), i + 120)));
}
}
s = fix(s, "A6-finish-master",
"withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed)",
/setMasterPick\(\s*\n\s*setHistory\(newHistory\);/,
L(
"setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed), stoplist.tobacco),",
" );",
" setHistory(newHistory);",
));
s = fix(s, "A7-load-master",
"withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options",
/setMasterPick\(pickMasterMix\(catalog\.mixes, lastRun\.options, lastRun\.profile, lastRun\.seed\)\);/,
L(
"setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),",
" );",
));
// первый патч урезал и эту замену — чиним урезанный вариант
if (!s.includes("withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options")) {
const re7b = /setMasterPick\(\s*\n\s*\}, \[catalog\.mixes, lastRun, masterPick\]\);/;
if (re7b.test(s)) {
s = s.replace(re7b, () =>
L(
"setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),",
" );",
" }, [catalog.mixes, lastRun, masterPick]);",
));
log.push("A7b-load-master-truncated=ok");
} else {
log.push("A7b-load-master-truncated=NOMATCH");
const j = s.indexOf("[catalog.mixes, lastRun, masterPick]");
console.log("CTX7: " + JSON.stringify(s.slice(Math.max(0, j - 300), j + 60)));
}
}
save(F, s);
console.log("AiMixPage: " + log.join(" "));
log.length = 0;
}
/* ------------------------- HeroActions.tsx -------------------------- */
{
const F = "src/components/HeroActions.tsx";
let s = readFileSync(F, "utf8");
s = fix(s, "H1-helper",
"const mixPlural = (n: number): string => {",
/const chips: Chip\[\] = \[/,
L(
"/* 51 микс / 52 микса / 55 миксов — русское множественное для счётчика */",
"const mixPlural = (n: number): string => {",
" const mod10 = n % 10;",
" const mod100 = n % 100;",
" if (mod10 === 1 && mod100 !== 11) return `${n} микс`;",
" if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${n} микса`;",
" return `${n} миксов`;",
"};",
"",
"const chips: Chip[] = [",
));
s = fix(s, "H2-label",
"label: mixPlural(hookahMixes.length)",
/label: `\$\{hookahMixes\.length\} миксов`/,
"label: mixPlural(hookahMixes.length)");
save(F, s);
console.log("HeroActions: " + log.join(" "));
}
console.log("REPAIR_DONE");
+57
View File
@@ -0,0 +1,57 @@
// Русификация блока STOP_LIST_WARNING в mixEngine (первый патч записал
// его с английским текстом). Только строки, счётчики проверяются.
import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
const F = "src/lib/mixEngine.ts";
let s = readFileSync(F, "utf8");
if (!existsSync(F + ".bak2")) copyFileSync(F, F + ".bak2");
const rep = (from, to, label) => {
const parts = s.split(from);
if (parts.length - 1 !== 1) die(`${label}: found ${parts.length - 1}, expected 1`);
s = parts.join(to);
console.log(label + "=ok");
};
function die(msg) {
console.error("RUS_FAIL: " + msg);
process.exit(1);
}
rep(
"/* ---------------- CRM stop-list: warnings in results ------------------- */",
"/* ------------------- Стоп-лист CRM: предупреждения в выдаче ---------------- */",
"section-comment",
);
rep(
"/** Warning on a mix card whose tobacco has gone into the CRM stop-list. */",
"/** Предупреждение на карточке микса, чей табак ушёл в стоп-лист CRM. */",
"const-comment",
);
rep(
" * Marks mixes whose composition contains a stopped tobacco (matching by",
" * Помечает миксы, в составе которых есть табак из стоп-листа CRM",
"jsdoc-1",
);
rep(
" * the engine flavor's catalogId). If there is nothing to change, returns",
" * (сверка по catalogId вкуса движка). Если менять нечего, возвращает",
"jsdoc-2",
);
rep(
" * the same references — safe to call on every stop-list poll.",
" * те же ссылки — безопасно вызывать на каждом опросе стоп-листа.",
"jsdoc-3",
);
rep(
'"Some tobaccos in the composition are in the stop-list — the hookah lounge attendant will pick a replacement.";',
'"В составе есть табак из стоп-листа — кальянщик подберёт замену.";',
"warning-text",
);
writeFileSync(F, s);
console.log("RUS_OK");
+13
View File
@@ -0,0 +1,13 @@
// Первый витринный микс: имя и catalogId табаков состава (для e2e стоп-листа).
import { hookahMixes } from "../src/data/catalog";
import { FLAVORS } from "../src/data/flavors";
const first = hookahMixes[0];
const ids = first.items
.map((i) => {
const f = FLAVORS.find((x) => x.id === i.flavorId);
return f ? (f.catalogId ?? f.id) : null;
})
.filter((x): x is string => !!x);
console.log("FIRST_MIX=" + first.name);
console.log("ITEM_IDS=" + ids.join(","));
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import Preloader from "./components/Preloader";
import AgeGate from "./components/AgeGate";
import Header from "./components/Header";
import Hero from "./components/Hero";
import AtmosphereCarousel from "./components/AtmosphereCarousel";
import MenuCarousel from "./components/MenuCarousel";
import VipCalculator from "./components/VipCalculator";
import Features from "./components/Features";
import Contacts from "./components/Contacts";
import Footer from "./components/Footer";
import FlavorsPage from "./pages/FlavorsPage";
import GamesPage from "./pages/GamesPage";
import MixesPage from "./pages/MixesPage";
import AiMixPage from "./pages/AiMixPage";
import BoardGamesPage from "./pages/BoardGamesPage";
import MenuPage from "./pages/MenuPage";
import TrackOrderPage from "./pages/TrackOrderPage";
import { useHashLocation } from "./hooks/useHashLocation";
import { isPrerender } from "./lib/prerender";
type CatalogPage = "flavors" | "games" | "mixes" | "ai-mix" | "board-games" | "menu" | "track" | null;
function getCatalogPage(hash: string): CatalogPage {
if (hash === "#/flavors") return "flavors";
if (hash === "#/games") return "games";
if (hash === "#/mixes") return "mixes";
if (hash === "#/ai-mix" || hash === "#/mix-builder") return "ai-mix";
if (hash.startsWith("#mix=")) return "ai-mix"; // share-ссылка на генерацию
if (hash === "#/board-games") return "board-games";
if (hash === "#/menu" || hash === "#/tea") return "menu";
if (hash.startsWith("#/track")) return "track"; // #/track и #/track?code=XXX
return null;
}
export default function App() {
const [loaded, setLoaded] = useState(false);
const hash = useHashLocation();
const catalogPage = getCatalogPage(hash);
const isCatalog = catalogPage !== null;
const wasCatalog = useRef(isCatalog);
const homeTitle = useRef(document.title);
// в пререндер-снапшоте рендерим финальное видимое состояние страницы
const prerender = isPrerender();
const started = loaded || prerender;
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const t = setTimeout(() => {
document.body.style.overflow = previousOverflow;
setLoaded(true);
}, 2450);
return () => {
clearTimeout(t);
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
if (catalogPage === "flavors") {
document.title = "Вкусы табака | НЕОН, Нягань";
} else if (catalogPage === "games") {
document.title = "Игры PS5 | НЕОН, Нягань";
} else if (catalogPage === "mixes") {
document.title = "Миксы кальянов | НЕОН, Нягань";
} else if (catalogPage === "ai-mix") {
document.title = "Автоподбор микса | НЕОН, Нягань";
} else if (catalogPage === "board-games") {
document.title = "Настольные игры | НЕОН, Нягань";
} else if (catalogPage === "menu") {
document.title = "Меню бара: Авторский чай и Пивная карта | НЕОН, Нягань";
} else if (catalogPage === "track") {
document.title = "Статус заказа | НЕОН, Нягань";
} else {
document.title = homeTitle.current;
}
}, [catalogPage]);
useEffect(() => {
if (!loaded) return;
const returningToHome = wasCatalog.current && !isCatalog;
wasCatalog.current = isCatalog;
if (catalogPage === "flavors") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("flavors-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "games") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("games-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "mixes") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("mixes-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "ai-mix") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("ai-mix-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "board-games") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("board-games-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "menu") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("menu-page-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "track") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("track-title")?.focus({ preventScroll: true });
return;
}
// The landing section mounts after the hash changes when leaving the catalog.
const section = document.getElementById(hash.slice(1) || "home");
if (!section) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
section.scrollIntoView({
block: "start",
behavior: returningToHome || reducedMotion ? "instant" : "smooth",
});
if (returningToHome) {
section.tabIndex = -1;
section.focus({ preventScroll: true });
}
}, [hash, catalogPage, isCatalog, loaded]);
return (
<div className="relative min-h-screen w-full max-w-full overflow-x-hidden bg-void font-sans text-mist antialiased">
<div className="noise" aria-hidden />
{/* в пререндер-снапшоте прелоадер и age-gate не попадают в HTML:
бот должен видеть финальную страницу, а не оверлеи */}
{!prerender && <Preloader done={loaded} />}
{!prerender && <AgeGate ready={loaded} />}
<Header started={started} />
<main className="w-full max-w-full overflow-x-hidden">
{catalogPage === "flavors" ? (
<FlavorsPage />
) : catalogPage === "games" ? (
<GamesPage />
) : catalogPage === "mixes" ? (
<MixesPage />
) : catalogPage === "ai-mix" ? (
<AiMixPage />
) : catalogPage === "board-games" ? (
<BoardGamesPage />
) : catalogPage === "menu" ? (
<MenuPage />
) : catalogPage === "track" ? (
<TrackOrderPage />
) : (
<>
<Hero started={started} />
<AtmosphereCarousel />
<MenuCarousel />
<Features />
<VipCalculator />
<Contacts />
</>
)}
</main>
<Footer />
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from "react";
const STORAGE_KEY = "neon-age-confirmed";
function readConfirmed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === "yes";
} catch {
return false;
}
}
export default function AgeGate({ ready }: { ready: boolean }) {
const [confirmed, setConfirmed] = useState(readConfirmed);
const [declined, setDeclined] = useState(false);
const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
const open = ready && !confirmed;
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
confirmButtonRef.current?.focus({ preventScroll: true });
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
if (!open) return null;
const accept = () => {
try {
localStorage.setItem(STORAGE_KEY, "yes");
} catch {
// даже без localStorage пускаем — окно просто покажется при следующем визите
}
setConfirmed(true);
};
return (
<div
className="fixed inset-0 z-[120] flex items-center justify-center overflow-y-auto px-4 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="age-gate-title"
aria-describedby="age-gate-desc"
>
<div className="absolute inset-0 bg-void/90 backdrop-blur-md" aria-hidden="true" />
<div className="age-gate relative w-full max-w-md rounded-3xl border border-neon-violet/40 bg-panel/95 p-7 shadow-[0_0_60px_-12px_rgba(176,38,255,0.55)] sm:p-9">
<div className="pointer-events-none absolute -top-16 left-1/2 h-40 w-40 -translate-x-1/2 rounded-full bg-neon-violet/25 blur-[90px]" aria-hidden="true" />
<div className="relative mx-auto flex h-20 w-20 animate-[neonBreathe_6s_ease-in-out_infinite] items-center justify-center rounded-full border-2 border-neon-violet/60 bg-neon-violet/10 shadow-[0_0_30px_rgba(176,38,255,0.5)]">
<span className="font-display text-2xl font-extrabold text-white text-glow-violet">18+</span>
</div>
{declined ? (
<div className="relative">
<h2 id="age-gate-title" className="mt-6 text-center font-display text-2xl font-extrabold text-white">
До встречи позже
</h2>
<p className="mt-3 text-center text-sm leading-relaxed text-mist/75">
Спасибо за честность! Сайт и заведение доступны только для лиц старше 18 лет.
Берегите здоровье — и приходите, когда подрастёте.
</p>
<button
type="button"
onClick={() => setDeclined(false)}
className="mt-7 inline-flex min-h-12 w-full items-center justify-center rounded-full border border-white/15 bg-white/[0.04] px-6 py-3 text-sm font-bold text-mist transition-all hover:border-white/30 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Нажал случайно — вернуться
</button>
</div>
) : (
<div className="relative">
<h2 id="age-gate-title" className="mt-6 text-center font-display text-2xl font-extrabold text-white">
Строго <span className="text-glow-violet text-fuchsia-300">18+</span>
</h2>
<p id="age-gate-desc" className="mt-3 text-center text-sm leading-relaxed text-mist/75">
Кальянный лаундж «НЕОН» — заведение строго для совершеннолетних. Сайт содержит
информацию о табачной продукции и напитках, предназначенную для лиц старше 18 лет.
</p>
<div className="mt-7 flex flex-col gap-3">
<button
ref={confirmButtonRef}
type="button"
onClick={accept}
className="inline-flex min-h-12 w-full items-center justify-center rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-6 py-3 text-sm font-bold text-white shadow-[0_0_25px_rgba(176,38,255,0.45)] transition-all hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.7)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Мне есть 18 лет — войти
</button>
<button
type="button"
onClick={() => setDeclined(true)}
className="inline-flex min-h-12 w-full items-center justify-center rounded-full border border-white/15 bg-white/[0.03] px-6 py-3 text-sm font-semibold text-mist/70 transition-all hover:border-white/30 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Мне меньше 18
</button>
</div>
<p className="mt-5 text-center text-[11px] leading-relaxed text-mist/40">
Нажимая кнопку, вы подтверждаете своё совершеннолетие.
</p>
</div>
)}
</div>
</div>
);
}
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more