Files
neon-bar86.ru/site/scripts/patch-stoplist-mixes.mjs
T
2026-10-11 22:24:54 +05:00

576 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Патч «больше миксов из табаков сайта + стоп-лист миксов».
// 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");