// Патч «больше миксов из табаков сайта + стоп-лист миксов». // 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(", " mixes: T[],", " stoppedCatalogIds: ReadonlySet,", "): 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 ? "Микс от мастера" : "Микс от ИИ"}', " ", " {card.tags.map((tag) => (", ].join("\n"), [ ' {isMaster ? "Микс от мастера" : "Микс от ИИ"}', " ", " {card.stopped && (", ' ', " В стоп-листе", " ", " )}", " {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, '{item.title}', '{item.title}', "MixesPage: red title", ); src = rep( src, " {/* крепость · холодок · цена */}", [ " {card.stopped && (", '

', " Часть табаков из состава сейчас в стоп-листе — кальянщик подберёт замену.", "

", " )}", "", " {/* крепость · холодок · цена */}", ].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): 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");