initial site
This commit is contained in:
commit
da5b259d06
167 files changed
+34157
No files matched your search
@@ -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");
|
||||
@@ -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}`,
|
||||
);
|
||||
@@ -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✅ Все миксы прошли валидацию");
|
||||
@@ -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}`,
|
||||
);
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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(","));
|
||||
Reference in new issue
Block a user