418 lines
18 KiB
JavaScript
418 lines
18 KiB
JavaScript
// Починка патча стоп-листа миксов: многострочные замены первого скрипта
|
||
// записались урезанными. Здесь каждый блок собирается из ОДНОСТРОЧНЫХ
|
||
// литералов (устойчиво к искажению записи), каждый фикс идемпотентен.
|
||
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");
|