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

+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(","));