// Починка патча стоп-листа миксов: многострочные замены первого скрипта // записались урезанными. Здесь каждый блок собирается из ОДНОСТРОЧНЫХ // литералов (устойчиво к искажению записи), каждый фикс идемпотентен. 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(", " 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 {", )); 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 ? "Микс от мастера" : "Микс от ИИ"}', " ", " {card.stopped && (", ' ', " В стоп-листе", " ", " )}", )); 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"', /\{item\.title\}<\/span>/, '{item.title}'); s = fix(s, "M17-warning", "Часть табаков из состава сейчас в стоп-листе", /(\{\/\* крепость · холодок · цена \*\/\})/, L( " {card.stopped && (", '

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

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