145 lines
5.3 KiB
TypeScript
145 lines
5.3 KiB
TypeScript
/**
|
||
* Экспорт меню и табаков сайта в 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}`,
|
||
);
|