initial site

This commit is contained in:
merrow committed 2026-10-11 22:24:54 +05:00
commit da5b259d06
167 files changed
+34157

No files matched your search

File diff suppressed because it is too large. Load diff
+405
View File
@@ -0,0 +1,405 @@
import { useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
Brain,
Castle,
Circle,
Club,
Crown,
Dices,
Eye,
Flame,
Ghost,
Grid3x3,
Hash,
Heart,
Landmark,
LayoutGrid,
Puzzle,
Search,
Skull,
Spade,
Target,
Timer,
Users,
X,
} from "lucide-react";
import {
boardGameCategories,
boardGameDifficultyLabels,
boardGames,
boardGamesNotice,
type BoardGame,
} from "../data/catalog";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
const iconMap: Record<string, typeof Dices> = {
eye: Eye,
skull: Skull,
spade: Spade,
crown: Crown,
dices: Dices,
grid: Grid3x3,
circle: Circle,
hash: Hash,
landmark: Landmark,
layout: LayoutGrid,
brain: Brain,
puzzle: Puzzle,
users: Users,
club: Club,
ghost: Ghost,
castle: Castle,
heart: Heart,
target: Target,
};
function GameCover({ game }: { game: BoardGame }) {
const Icon = iconMap[game.icon] ?? Dices;
return (
<div
className="relative flex h-full w-full flex-col items-center justify-center gap-3 p-5 text-center"
style={{
background: `radial-gradient(circle at 30% 20%, ${game.accent}33 0%, transparent 55%), linear-gradient(150deg, ${game.accent}1f 0%, #0a0a0f 60%, #00f0ff14 100%)`,
}}
>
{/* сетка — намёк на игровое поле */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.6) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.6) 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
<span
className="relative flex h-16 w-16 items-center justify-center rounded-2xl border border-white/15 bg-black/35 shadow-[0_0_30px_-4px_var(--accent)] backdrop-blur-sm transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
style={{ ["--accent" as string]: game.accent, color: game.accent }}
>
<Icon size={32} strokeWidth={1.7} aria-hidden="true" />
</span>
<span className="relative font-display text-sm font-bold leading-snug text-white/85">
{game.name}
</span>
</div>
);
}
function DifficultyDots({ level }: { level: BoardGame["difficulty"] }) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{[1, 2, 3].map((d) => (
<span
key={d}
className={cn(
"h-1.5 w-1.5 rounded-full",
d <= level ? "bg-neon-cyan shadow-[0_0_7px_rgba(0,240,255,0.85)]" : "bg-white/15"
)}
/>
))}
</span>
);
}
function GameCard({ game }: { game: BoardGame }) {
// «2 игрока / 2–4 игрока», но «5 игроков» — согласование по верхней границе
const max = game.maxPlayers;
const playerWord = max % 10 >= 2 && max % 10 <= 4 && (max % 100 < 12 || max % 100 > 14)
? "игрока"
: "игроков";
const players =
game.minPlayers === game.maxPlayers
? `${max} ${playerWord}`
: `${game.minPlayers}–${max} ${playerWord}`;
return (
<article className="group catalog-result glass flex h-full min-w-0 flex-col overflow-hidden rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:border-neon-cyan/40 hover:shadow-[0_18px_50px_-24px_rgba(0,240,255,0.45)]">
<div className="relative aspect-[4/3] w-full overflow-hidden bg-panel">
<GameCover game={game} />
<span className="absolute left-3 top-3 rounded-full border border-white/15 bg-black/50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur-md">
{game.category}
</span>
{game.hit && (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-full border border-neon-violet/60 bg-neon-violet/25 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-fuchsia-100 shadow-[0_0_14px_rgba(176,38,255,0.45)] backdrop-blur-md">
<Flame size={10} aria-hidden="true" />
Хит
</span>
)}
</div>
<div className="flex flex-1 flex-col p-4 sm:p-5">
<h2 className="font-display text-base font-bold leading-snug text-white sm:text-lg">
{game.name}
</h2>
<p className="mt-2 flex-1 text-xs leading-relaxed text-mist/60">{game.desc}</p>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-3 text-[11px]">
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-violet/40 bg-neon-violet/10 px-2.5 py-1 font-bold uppercase tracking-wider text-fuchsia-200">
<Users size={11} aria-hidden="true" />
{players}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-white/5 px-2.5 py-1 text-mist/70">
<Timer size={11} aria-hidden="true" />
{game.duration}
</span>
<span className="ml-auto inline-flex items-center gap-2 text-mist/55">
<DifficultyDots level={game.difficulty} />
{boardGameDifficultyLabels[game.difficulty]}
</span>
</div>
</div>
</article>
);
}
export default function BoardGamesPage() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [players, setPlayers] = useState("all");
const [difficulty, setDifficulty] = useState("all");
const searchRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return boardGames.filter((game) => {
if (category !== "all" && game.category !== category) return false;
if (difficulty !== "all" && String(game.difficulty) !== difficulty) return false;
if (players !== "all") {
const n = Number(players);
// игра подходит, если указанное число игроков попадает в диапазон
if (players === "8") {
if (game.maxPlayers < 8) return false;
} else if (n < game.minPlayers || n > game.maxPlayers) {
return false;
}
}
const text = normalize(`${game.name} ${game.category} ${game.desc}`);
return words.every((w) => text.includes(w));
});
}, [query, category, players, difficulty]);
const hasFilters =
query.length > 0 || category !== "all" || players !== "all" || difficulty !== "all";
const resetFilters = () => {
setQuery("");
setCategory("all");
setPlayers("all");
setDifficulty("all");
searchRef.current?.focus();
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden"
aria-hidden="true"
>
<img
src="./images/interior-sofas.jpg"
alt=""
className="h-full w-full object-cover opacity-20"
/>
<div className="absolute inset-0 bg-gradient-to-b from-void/75 via-void/60 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="board-games-title"
tabIndex={-1}
className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none"
>
Настольные <span className="text-glow-violet text-fuchsia-200">игры</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
{boardGames.length} игр на любую компанию: от шахмат на двоих до «Мафии» на шестнадцать
человек. Выбирай по числу игроков и сложности — и зови бармена.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">{boardGamesNotice}</p>
{/* фильтры */}
<form
role="search"
aria-label="Поиск по настольным играм"
onSubmit={(e) => e.preventDefault()}
className="mt-9 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,1fr))]"
>
<div className="min-w-0 sm:col-span-2 lg:col-span-1">
<label htmlFor="bg-search" className="mb-2 block text-xs font-semibold text-mist/70">
Название или описание
</label>
<div className="relative">
<Search
size={17}
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45"
aria-hidden="true"
/>
<input
ref={searchRef}
id="bg-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Например, мафия или карты"
autoComplete="off"
aria-controls="bg-results"
className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
aria-label="Очистить поиск"
onClick={() => {
setQuery("");
searchRef.current?.focus();
}}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="bg-category" className="mb-2 block text-xs font-semibold text-mist/70">
Тип игры
</label>
<select
id="bg-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
aria-controls="bg-results"
className={selectClass}
>
<option value="all">Все типы</option>
{boardGameCategories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="min-w-0">
<label htmlFor="bg-players" className="mb-2 block text-xs font-semibold text-mist/70">
Сколько вас
</label>
<select
id="bg-players"
value={players}
onChange={(e) => setPlayers(e.target.value)}
aria-controls="bg-results"
className={selectClass}
>
<option value="all">Любое число</option>
<option value="2">Вдвоём</option>
<option value="3">Втроём</option>
<option value="4">Вчетвером</option>
<option value="6">Шестеро</option>
<option value="8">Большая компания (8+)</option>
</select>
</div>
<div className="min-w-0">
<label htmlFor="bg-difficulty" className="mb-2 block text-xs font-semibold text-mist/70">
Сложность правил
</label>
<select
id="bg-difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
aria-controls="bg-results"
className={selectClass}
>
<option value="all">Любая</option>
<option value="1">Простая</option>
<option value="2">Средняя</option>
<option value="3">Сложная</option>
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p role="status" aria-live="polite" aria-atomic="true" className="text-xs text-mist/55">
Показано {filtered.length} из {boardGames.length}
</p>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Сбросить фильтры
</button>
)}
</div>
<div id="bg-results" className="min-w-0">
{filtered.length > 0 ? (
<ul
className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="Настольные игры"
>
{filtered.map((game) => (
<li key={game.id} className="min-w-0">
<GameCard game={game} />
</li>
))}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">Ничего не нашлось</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">
Попробуй другой тип игры или число игроков. Например, «мафия» или «нарды».
</p>
<button
type="button"
onClick={resetFilters}
className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Показать все игры
</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">
Настолки доступны в общем зале и в VIP. Для большой компании на «Мафию» или «Бункер»
лучше забронировать VIP-зал заранее.
</p>
<div className="flex flex-wrap gap-3">
<a
href="#vip"
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-5 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(176,38,255,0.4)] transition-all hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Аренда зала <ArrowUpRight size={16} aria-hidden="true" />
</a>
<a
href="#/games"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Игры на PS5 <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
</div>
);
}
+344
View File
@@ -0,0 +1,344 @@
import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowUpRight, Search, Snowflake, X } from "lucide-react";
import {
strengthLabels,
tobaccoBrandInfo,
tobaccoBrands,
tobaccoCatalogNotice,
tobaccoFlavors,
type TobaccoFlavor,
} from "../data/catalog";
import { tobaccoTranslations } from "../data/tobaccoTranslations";
import { isNovelty, useCustomCatalog, useStoplist } from "../lib/crm";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
/** Перевод показываем, только если он реально добавляет что-то к названию:
при совпадении или вложении одной строки в другую — скрываем. */
const textsSimilar = (a: string, b: string) => {
const na = normalize(a).replace(/[^a-zа-я0-9\s]/gi, " ").replace(/\s+/g, " ");
const nb = normalize(b).replace(/[^a-zа-я0-9\s]/gi, " ").replace(/\s+/g, " ");
if (!na || !nb) return false;
return na === nb || na.includes(nb) || nb.includes(na);
};
function StrengthDots({ level }: { level: TobaccoFlavor["strength"] }) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{[1, 2, 3].map((dot) => (
<span
key={dot}
className={cn(
"h-1.5 w-1.5 rounded-full",
dot <= level
? "bg-neon-cyan shadow-[0_0_7px_rgba(0,240,255,0.85)]"
: "bg-white/15",
)}
/>
))}
</span>
);
}
function TobaccoImage({ flavor }: { flavor: TobaccoFlavor }) {
const brand = tobaccoBrandInfo[flavor.brand];
const image = flavor.image ?? brand?.image ?? "./images/hookah-smoke.jpg";
return (
<div className="relative aspect-[16/10] w-full overflow-hidden bg-panel">
<img
src={image}
alt={`Упаковка или визуал бренда ${flavor.brand}: ${flavor.name}`}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.src = "./images/hookah-smoke.jpg";
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-void/95 via-void/20 to-black/10" />
<div className="absolute inset-x-4 bottom-3 flex items-end justify-between gap-3">
<div className="min-w-0">
<span className="block truncate text-[10px] font-bold uppercase tracking-[0.25em] text-white/55">
{flavor.brand}
</span>
<span className="mt-1 block truncate font-display text-sm font-bold text-white">
{flavor.name}
</span>
</div>
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-full shadow-[0_0_14px_currentColor]"
style={{ color: brand?.accent ?? "#00f0ff", backgroundColor: "currentColor" }}
/>
</div>
</div>
);
}
function FlavorCard({ flavor }: { flavor: TobaccoFlavor }) {
const brand = tobaccoBrandInfo[flavor.brand];
return (
<article
className={cn(
"catalog-result group glass flex h-full min-w-0 flex-col overflow-hidden rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",
!flavor.available && "opacity-65",
)}
>
<TobaccoImage flavor={flavor} />
<div className="flex flex-1 flex-col p-5">
<div className="flex items-start justify-between gap-3">
{brand?.url ? (
<a
href={brand.url}
target="_blank"
rel="noopener noreferrer"
title={`Страница производителя ${flavor.brand}`}
className="inline-flex min-h-8 min-w-0 items-center gap-1.5 text-[10px] font-bold uppercase tracking-[0.2em] text-neon-cyan/80 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_7px_rgba(0,240,255,0.5)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<span className="truncate">{flavor.brand}</span>
<ArrowUpRight size={12} className="shrink-0" aria-hidden="true" />
</a>
) : (
<span className="inline-flex min-h-8 items-center text-[10px] font-bold uppercase tracking-[0.2em] text-neon-cyan/65">
{flavor.brand}
</span>
)}
<span className="inline-flex shrink-0 items-center gap-2">
{isNovelty(flavor.createdAt) && (
<span className="inline-flex items-center rounded-full border border-neon-violet/50 bg-neon-violet/12 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-fuchsia-300 shadow-[0_0_14px_rgba(176,38,255,0.25)]">
Новинка
</span>
)}
<span
className={cn(
"text-[10px] font-bold uppercase tracking-wider",
flavor.available ? "text-emerald-300" : "text-red-300",
)}
>
{flavor.available ? "В наличии" : "Закончился"}
</span>
</span>
</div>
<h2 className="mt-1 font-display text-base font-bold leading-snug text-white sm:text-lg">
{flavor.name}
</h2>
{tobaccoTranslations[flavor.id] &&
!textsSimilar(flavor.name, tobaccoTranslations[flavor.id]) &&
!textsSimilar(flavor.note ?? "", tobaccoTranslations[flavor.id]) && (
<p className="mt-1 text-xs font-medium text-cyan-200/85">
{tobaccoTranslations[flavor.id]}
</p>
)}
{flavor.note && (
<p className="mt-1.5 flex-1 text-xs leading-relaxed text-mist/55">{flavor.note}</p>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-white/10 pt-3">
<span className="inline-flex items-center gap-2 text-xs text-mist/60">
<StrengthDots level={flavor.strength} />
{strengthLabels[flavor.strength]}
</span>
{flavor.cooling ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-cyan-200">
<Snowflake size={11} aria-hidden="true" />
Холодок
</span>
) : (
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-mist/40">
Без холодка
</span>
)}
</div>
</div>
</article>
);
}
export default function FlavorsPage() {
const [query, setQuery] = useState("");
const [brand, setBrand] = useState("all");
const [strength, setStrength] = useState("all");
const [cooling, setCooling] = useState("all");
const [availability, setAvailability] = useState("available");
const searchRef = useRef<HTMLInputElement>(null);
// стоп-лист из CRM: вкус в стопе = карточка «Закончился»
const stoplist = useStoplist();
// вкусы, добавленные владельцем через CRM («новинки»)
const customCatalog = useCustomCatalog();
// вкусы, удалённые владельцем, скрываются с сайта
const deletedTobaccoIds = useMemo(
() => new Set(customCatalog.deleted_tobacco),
[customCatalog],
);
// новинки всегда вверху списка
const flavors = useMemo(() => {
const list = [
...tobaccoFlavors
.filter((flavor) => !deletedTobaccoIds.has(flavor.id))
.map((flavor) =>
stoplist.tobacco.has(flavor.id) ? { ...flavor, available: false } : flavor,
),
...customCatalog.tobacco.map(
(item): TobaccoFlavor => ({
id: item.id,
name: item.flavor,
brand: item.brand,
strength: (Math.min(3, Math.max(1, item.strength)) as TobaccoFlavor["strength"]),
cooling: !!item.cooling,
note: item.note ?? undefined,
available: !stoplist.tobacco.has(item.id),
createdAt: item.created_at,
}),
),
];
list.sort((a, b) => (isNovelty(b.createdAt) ? 1 : 0) - (isNovelty(a.createdAt) ? 1 : 0));
return list;
}, [stoplist, customCatalog, deletedTobaccoIds]);
// бренды для фильтра: каталог + бренды из позиций владельца
const brandOptions = useMemo(() => {
const brands = new Set(tobaccoBrands);
for (const item of customCatalog.tobacco) brands.add(item.brand);
return [...brands].sort((a, b) => a.localeCompare(b, "ru"));
}, [customCatalog]);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return flavors.filter((flavor) => {
if (brand !== "all" && flavor.brand !== brand) return false;
if (strength !== "all" && String(flavor.strength) !== strength) return false;
if (cooling === "yes" && !flavor.cooling) return false;
if (cooling === "no" && flavor.cooling) return false;
if (availability === "available" && !flavor.available) return false;
if (availability === "out" && flavor.available) return false;
const text = normalize(
`${flavor.name} ${tobaccoTranslations[flavor.id] ?? ""} ${flavor.brand} ${
flavor.note ?? ""
} ${strengthLabels[flavor.strength]} ${flavor.cooling ? "холодок лёд" : ""}`,
);
return words.every((word) => text.includes(word));
});
}, [flavors, query, brand, strength, cooling, availability]);
const hasFilters =
query.length > 0 ||
brand !== "all" ||
strength !== "all" ||
cooling !== "all" ||
availability !== "available";
const resetFilters = () => {
setQuery("");
setBrand("all");
setStrength("all");
setCooling("all");
setAvailability("available");
searchRef.current?.focus();
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden" aria-hidden="true">
<img src="./images/menu-bg.jpg" alt="" className="h-full w-full object-cover opacity-20" />
<div className="absolute inset-0 bg-gradient-to-b from-void/70 via-void/55 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a href="#home" className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1 id="flavors-title" tabIndex={-1} className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none">
Табаки <span className="text-glow-violet text-fuchsia-200">в наличии</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
{flavors.length} позиций от {brandOptions.length} брендов. Нажми на название
производителя в карточке, чтобы открыть его страницу.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">{tobaccoCatalogNotice}</p>
<form role="search" aria-label="Поиск по каталогу табаков" onSubmit={(event) => event.preventDefault()} className="mt-9 grid min-w-0 gap-4 sm:grid-cols-2 xl:grid-cols-[minmax(0,1.5fr)_repeat(4,minmax(0,1fr))]">
<div className="min-w-0 sm:col-span-2 xl:col-span-1">
<label htmlFor="flavor-search" className="mb-2 block text-xs font-semibold text-mist/70">Вкус или бренд</label>
<div className="relative">
<Search size={17} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45" aria-hidden="true" />
<input ref={searchRef} id="flavor-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Например, Blackburn или малина" autoComplete="off" aria-controls="flavor-results" className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]" />
{query && (
<button type="button" aria-label="Очистить поиск" onClick={() => { setQuery(""); searchRef.current?.focus(); }} className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan">
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="flavor-brand" className="mb-2 block text-xs font-semibold text-mist/70">Бренд</label>
<select id="flavor-brand" value={brand} onChange={(event) => setBrand(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="all">Все бренды</option>
{brandOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="min-w-0">
<label htmlFor="flavor-strength" className="mb-2 block text-xs font-semibold text-mist/70">Крепость</label>
<select id="flavor-strength" value={strength} onChange={(event) => setStrength(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="all">Любая</option><option value="1">Лёгкая</option><option value="2">Средняя</option><option value="3">Крепкая</option>
</select>
</div>
<div className="min-w-0">
<label htmlFor="flavor-cooling" className="mb-2 block text-xs font-semibold text-mist/70">Холодок</label>
<select id="flavor-cooling" value={cooling} onChange={(event) => setCooling(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="all">Неважно</option><option value="yes">С холодком</option><option value="no">Без холодка</option>
</select>
</div>
<div className="min-w-0">
<label htmlFor="flavor-availability" className="mb-2 block text-xs font-semibold text-mist/70">Статус</label>
<select id="flavor-availability" value={availability} onChange={(event) => setAvailability(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="available">В наличии</option><option value="all">Все позиции</option><option value="out">Закончились</option>
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p role="status" aria-live="polite" aria-atomic="true" className="text-xs text-mist/55">Показано {filtered.length} из {tobaccoFlavors.length}</p>
{hasFilters && <button type="button" onClick={resetFilters} className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">Сбросить фильтры</button>}
</div>
<div id="flavor-results" className="min-w-0">
{filtered.length > 0 ? (
<ul className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3" aria-label="Табаки и вкусы">
{filtered.map((flavor) => <li key={flavor.id} className="min-w-0"><FlavorCard flavor={flavor} /></li>)}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">Ничего не нашлось</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">Попробуй другой вкус, бренд или статус наличия.</p>
<button type="button" onClick={resetFilters} className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">Показать всё в наличии</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">Не нашёл любимый вкус? Напиши нам — подскажем, что есть сегодня.</p>
<a href="https://vk.ru/shelby_nyagan" target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">
Спросить в VK <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowUpRight, Gamepad2, Search, User, Users, X } from "lucide-react";
import {
gameGenres,
gamesCatalogNotice,
ps5Games,
type Ps5Game,
} from "../data/catalog";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
function GameCover({ game }: { game: Ps5Game }) {
if (game.image) {
return (
<img
src={game.image}
alt={`Обложка ${game.name}`}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
);
}
// CSS-плейсхолдер, если обложки нет
return (
<div
className="flex h-full w-full flex-col items-center justify-center gap-3 p-4 text-center"
style={{
background: `linear-gradient(145deg, ${game.accent ?? "#b026ff"}33 0%, #0a0a0f 55%, #00f0ff22 100%)`,
}}
>
<Gamepad2 size={36} className="text-white/40" aria-hidden="true" />
<span className="font-display text-sm font-bold leading-snug text-white/70">
{game.name}
</span>
</div>
);
}
function GameCard({ game }: { game: Ps5Game }) {
return (
<article className="group catalog-result glass flex h-full min-w-0 flex-col overflow-hidden rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:border-neon-cyan/40 hover:shadow-[0_18px_50px_-24px_rgba(0,240,255,0.45)]">
{/* обложка */}
<div className="relative aspect-[3/4] w-full overflow-hidden bg-panel">
<GameCover game={game} />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-void/80 via-transparent to-transparent" />
<span className="absolute left-3 top-3 rounded-full border border-white/15 bg-black/50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur-md">
{game.genre}
</span>
</div>
{/* инфо */}
<div className="flex flex-1 flex-col p-4 sm:p-5">
<h2 className="font-display text-base font-bold leading-snug text-white sm:text-lg">
{game.name}
</h2>
<div className="mt-4 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-white/10 pt-3">
<span className="text-xs text-mist/55">{game.genre}</span>
{game.maxPlayers === 1 ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-cyan-200">
<User size={11} aria-hidden="true" />
Сингл
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-violet/40 bg-neon-violet/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-fuchsia-200">
<Users size={11} aria-hidden="true" />
до {game.maxPlayers} игроков
</span>
)}
</div>
</div>
</article>
);
}
export default function GamesPage() {
const [query, setQuery] = useState("");
const [genre, setGenre] = useState("all");
const [players, setPlayers] = useState("all");
const searchRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return ps5Games.filter((game) => {
if (genre !== "all" && game.genre !== genre) return false;
if (players === "1" && game.maxPlayers !== 1) return false;
if (players !== "all" && players !== "1" && game.maxPlayers < Number(players)) {
return false;
}
const text = normalize(`${game.name} ${game.genre}`);
return words.every((word) => text.includes(word));
});
}, [query, genre, players]);
const hasFilters = query.length > 0 || genre !== "all" || players !== "all";
const resetFilters = () => {
setQuery("");
setGenre("all");
setPlayers("all");
searchRef.current?.focus();
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden"
aria-hidden="true"
>
<img src="./images/vip-room.jpg" alt="" className="h-full w-full object-cover opacity-20" />
<div className="absolute inset-0 bg-gradient-to-b from-void/75 via-void/60 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="games-title"
tabIndex={-1}
className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none"
>
Игры на{" "}
<span className="text-glow-cyan text-cyan-300">PlayStation 5</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
{ps5Games.length} игр в VIP-зале: кооперативные для компании и сюжетные синглы.
Выбирай по жанру или количеству игроков — и бронируй комнату.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">
{gamesCatalogNotice}
</p>
{/* фильтры */}
<form
role="search"
aria-label="Поиск по каталогу игр"
onSubmit={(event) => event.preventDefault()}
className="mt-9 grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1.5fr)_repeat(2,minmax(0,1fr))]"
>
<div className="min-w-0">
<label htmlFor="game-search" className="mb-2 block text-xs font-semibold text-mist/70">
Название или жанр
</label>
<div className="relative">
<Search
size={17}
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45"
aria-hidden="true"
/>
<input
ref={searchRef}
id="game-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Например, Mortal или футбол"
autoComplete="off"
aria-controls="game-results"
className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
aria-label="Очистить поиск"
onClick={() => {
setQuery("");
searchRef.current?.focus();
}}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="game-genre" className="mb-2 block text-xs font-semibold text-mist/70">
Жанр
</label>
<select
id="game-genre"
value={genre}
onChange={(event) => setGenre(event.target.value)}
aria-controls="game-results"
className={selectClass}
>
<option value="all">Все жанры</option>
{gameGenres.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
<div className="min-w-0">
<label htmlFor="game-players" className="mb-2 block text-xs font-semibold text-mist/70">
Максимум игроков
</label>
<select
id="game-players"
value={players}
onChange={(event) => setPlayers(event.target.value)}
aria-controls="game-results"
className={selectClass}
>
<option value="all">Любое</option>
<option value="1">Сингл</option>
<option value="2">от 2</option>
<option value="4">от 4</option>
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p role="status" aria-live="polite" aria-atomic="true" className="text-xs text-mist/55">
Показано {filtered.length} из {ps5Games.length}
</p>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Сбросить фильтры
</button>
)}
</div>
<div id="game-results" className="min-w-0">
{filtered.length > 0 ? (
<ul
className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="Игры на PlayStation 5"
>
{filtered.map((game) => (
<li key={game.id} className="min-w-0">
<GameCard game={game} />
</li>
))}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">Ничего не нашлось</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">
Попробуй другой жанр или название. Например, «файтинг» или «Overcooked».
</p>
<button
type="button"
onClick={resetFilters}
className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Показать все игры
</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">
PS5 доступна только в VIP-зале (до 8 гостей). Забронируй комнату — и играй.
</p>
<div className="flex flex-wrap gap-3">
<a
href="#vip"
className={cn(
"inline-flex min-h-11 items-center gap-2 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-5 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(176,38,255,0.4)] transition-all hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
)}
>
Аренда VIP <ArrowUpRight size={16} aria-hidden="true" />
</a>
<a
href="https://vk.ru/shelby_nyagan"
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Уточнить в VK <ArrowUpRight size={16} aria-hidden="true" />
</a>
<a
href="#/board-games"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-violet/40 bg-neon-violet/10 px-5 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Настольные игры <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
</div>
);
}
+847
View File
@@ -0,0 +1,847 @@
import { useEffect, useMemo, useState, type ComponentType } from "react";
import {
Apple,
ArrowLeft,
ArrowUpRight,
BatteryCharging,
Beer,
Beef,
Cake,
Candy,
Coffee,
Croissant,
CupSoda,
Donut,
Drumstick,
Egg,
Flame,
GlassWater,
Grape,
History,
Leaf,
Milk,
Popcorn,
Minus,
Plus,
Search,
Sparkles,
Utensils,
UtensilsCrossed,
X,
Zap,
} from "lucide-react";
import MenuCart, { type MenuCartItem } from "../components/MenuCart";
import { barCategories, barMenuItems, DRINKS_DESSERTS_CATEGORY, LEMONADES_CATEGORY, SNACKS_CATEGORY, SWEETS_CATEGORY, type BarMenuItem } from "../data/barMenu";
import { beerList, BEER_CATEGORY, type BeerItem } from "../data/beerMenu";
import { menuPhotos } from "../data/menuPhotos";
import { authorTeas, TEA_CATEGORY, type TeaItem } from "../data/teaMenu";
import { crmUrl, isNovelty, useCustomCatalog, useStoplist, type CustomMenuItem } from "../lib/crm";
import { cn } from "../utils/cn";
type MenuTab =
| "ALL"
| typeof TEA_CATEGORY
| typeof BEER_CATEGORY
| typeof SNACKS_CATEGORY
| typeof DRINKS_DESSERTS_CATEGORY
| typeof SWEETS_CATEGORY
| typeof LEMONADES_CATEGORY;
type MenuIcon = ComponentType<{ size?: number; className?: string }>;
const menuIcons: Record<string, MenuIcon> = {
leaf: Leaf,
coffee: Coffee,
tea: Coffee,
milk: Milk,
beer: Beer,
glass: GlassWater,
cupsoda: CupSoda,
grape: Grape,
zap: Zap,
battery: BatteryCharging,
apple: Apple,
donut: Donut,
utensils: Utensils,
crossed: UtensilsCrossed,
drumstick: Drumstick,
croissant: Croissant,
beef: Beef,
egg: Egg,
cake: Cake,
candy: Candy,
popcorn: Popcorn,
};
const menuTabs: { id: MenuTab; label: string }[] = [
{ id: "ALL", label: "Все разделы" },
{ id: TEA_CATEGORY, label: TEA_CATEGORY },
{ id: BEER_CATEGORY, label: BEER_CATEGORY },
{ id: SNACKS_CATEGORY, label: SNACKS_CATEGORY },
{ id: DRINKS_DESSERTS_CATEGORY, label: DRINKS_DESSERTS_CATEGORY },
{ id: SWEETS_CATEGORY, label: SWEETS_CATEGORY },
{ id: LEMONADES_CATEGORY, label: LEMONADES_CATEGORY },
];
const barMetaById = new Map(barCategories.map((category) => [category.id, category]));
const CART_STORAGE_KEY = "neon-menu-cart";
/* Позиции, добавленные владельцем через CRM, подгоняем под формат каталога сайта */
const CUSTOM_ITEM_DESC = "Новинка в меню бара — попробуйте первыми!";
const fmtPrice = (price: number) => price.toLocaleString("ru-RU") + " ₽";
function customToTea(item: CustomMenuItem): TeaItem {
return {
id: item.id,
name: item.name,
category: item.category,
volume: item.portion || "900 мл",
price: item.price,
priceFormatted: fmtPrice(item.price),
desc: CUSTOM_ITEM_DESC,
ingredients: [],
accentColor: "#00f0ff",
};
}
function customToBeer(item: CustomMenuItem): BeerItem {
return {
id: item.id,
name: item.name,
category: item.category,
type: "Новинка",
volume: item.portion || "0,5 л",
price: item.price,
priceFormatted: fmtPrice(item.price),
desc: CUSTOM_ITEM_DESC,
accentColor: "#b026ff",
};
}
function customToBar(item: CustomMenuItem): BarMenuItem {
return {
id: item.id,
name: item.name,
category: item.category,
portion: item.portion ?? undefined,
price: item.price,
priceFormatted: fmtPrice(item.price),
desc: CUSTOM_ITEM_DESC,
icon: "utensils",
};
}
interface MenuCardItem {
id: string;
name: string;
category: string;
price: number;
priceFormatted: string;
portion?: string;
desc: string;
}
interface MenuItemCardProps {
item: MenuCardItem;
priceFormatted: string;
portion?: string;
badge?: string;
ingredients?: string[];
icon: MenuIcon;
accent: string;
image?: string;
cartQuantity: number;
available?: boolean;
novelty?: boolean;
onAdd: () => void;
onRemove: () => void;
}
function MenuItemCard({
item,
priceFormatted,
portion,
badge,
ingredients,
icon: Icon,
accent,
image,
cartQuantity,
available = true,
novelty = false,
onAdd,
onRemove,
}: MenuItemCardProps) {
const [imageVisible, setImageVisible] = useState(Boolean(image));
return (
<article
className={cn(
"catalog-result neon-frame group relative flex min-w-0 items-start gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-4.5 transition-all duration-300 hover:border-white/20 hover:bg-white/[0.06] sm:p-5",
available ? "hover:-translate-y-1" : "opacity-65",
)}
>
<div
className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full blur-2xl opacity-0 transition-opacity duration-500 group-hover:opacity-40"
style={{ backgroundColor: accent }}
aria-hidden="true"
/>
<div
className="neon-icon neon-frame relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 transition-all duration-300 group-hover:scale-105 group-hover:-rotate-2"
style={{
background: `linear-gradient(135deg, ${accent}33 0%, #0a0a0f 75%)`,
color: accent,
}}
>
{imageVisible && image ? (
<img
src={image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setImageVisible(false)}
/>
) : (
<Icon size={22} className="drop-shadow-[0_0_8px_currentColor]" />
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-baseline gap-2">
<h3 className="min-w-0 truncate font-display text-sm font-bold text-white sm:text-base">{item.name}</h3>
<span className="mx-1 hidden flex-1 border-b border-dotted border-white/20 sm:block" aria-hidden="true" />
<span
className="ml-auto shrink-0 font-display text-base font-extrabold text-white sm:ml-0 sm:text-lg"
style={{ textShadow: `0 0 14px ${accent}bb` }}
>
{priceFormatted}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-mist/65">{item.desc}</p>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{portion && (
<span
className="rounded-full border px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider"
style={{ borderColor: `${accent}55`, color: accent, backgroundColor: `${accent}14` }}
>
{portion}
</span>
)}
{badge && (
<span className="rounded-full border border-white/15 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold text-mist/60">
{badge}
</span>
)}
{ingredients?.slice(0, 3).map((ingredient) => (
<span
key={ingredient}
className="hidden rounded-full border border-white/10 bg-white/[0.02] px-2 py-0.5 text-[10px] text-mist/50 sm:inline-block"
>
{ingredient}
</span>
))}
{!available && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-400/40 bg-red-500/10 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-red-300">
<span className="h-1.5 w-1.5 rounded-full bg-red-400" aria-hidden="true" />
Нет в наличии
</span>
)}
{novelty && available && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-violet/50 bg-neon-violet/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-fuchsia-300 shadow-[0_0_14px_rgba(176,38,255,0.25)]">
<Sparkles size={10} aria-hidden="true" />
Новинка
</span>
)}
</div>
{!available ? (
<div
className="mt-3 inline-flex min-h-9 w-fit items-center rounded-lg border border-white/10 bg-white/[0.03] px-3 text-[11px] font-bold uppercase tracking-wider text-mist/45"
role="status"
aria-label={`${item.name} сейчас недоступна для онлайн-заказа`}
>
Нельзя заказать
</div>
) : cartQuantity === 0 ? (
<button
type="button"
onClick={onAdd}
className="mt-3 inline-flex min-h-9 w-fit items-center gap-1.5 rounded-lg border border-neon-cyan/35 bg-neon-cyan/8 px-3 text-[11px] font-bold uppercase tracking-wider text-cyan-100 transition-all hover:border-neon-cyan/60 hover:bg-neon-cyan/14 hover:shadow-[0_0_16px_rgba(0,240,255,0.14)]"
aria-label={`Добавить ${item.name} в корзину`}
>
<Plus size={14} />
В корзину
</button>
) : (
<div
className="mt-3 inline-flex min-h-9 w-fit self-start items-center rounded-lg border border-neon-cyan/35 bg-neon-cyan/8 text-cyan-100 shadow-[0_0_16px_rgba(0,240,255,0.08)]"
aria-label={`Количество ${item.name} в корзине: ${cartQuantity}`}
>
<button
type="button"
onClick={onRemove}
className="inline-flex h-9 w-9 items-center justify-center rounded-l-lg text-cyan-100/75 transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
aria-label={`Уменьшить количество ${item.name}`}
>
<Minus size={14} />
</button>
<span className="min-w-8 px-1 text-center text-xs font-extrabold tabular-nums">{cartQuantity}</span>
<button
type="button"
onClick={onAdd}
className="inline-flex h-9 w-9 items-center justify-center rounded-r-lg text-cyan-100/75 transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
aria-label={`Увеличить количество ${item.name}`}
>
<Plus size={14} />
</button>
</div>
)}
</div>
</article>
);
}
function CategoryHeader({
icon: Icon,
label,
tagline,
count,
accent,
}: {
icon: MenuIcon;
label: string;
tagline: string;
count: number;
accent: string;
}) {
return (
<div className="flex items-center gap-3 border-b border-white/10 pb-3">
<div
className="neon-icon neon-frame flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border transition-all duration-300 hover:scale-105"
style={{ borderColor: `${accent}55`, backgroundColor: `${accent}1f`, color: accent }}
>
<Icon size={20} className="drop-shadow-[0_0_6px_currentColor]" />
</div>
<div className="min-w-0">
<h2 className="font-display text-lg font-bold uppercase tracking-wider text-white sm:text-2xl">
{label}
<span className="ml-2.5 align-middle text-xs font-bold text-mist/45">({count})</span>
</h2>
<span className="block truncate text-xs text-mist/50">{tagline}</span>
</div>
</div>
);
}
function readStoredValue<T>(key: string, fallback: T): T {
if (typeof localStorage === "undefined") return fallback;
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function readStoredCart(): MenuCartItem[] {
const stored = readStoredValue<Array<MenuCartItem & { guest?: number }>>(CART_STORAGE_KEY, []);
const merged = new Map<string, MenuCartItem>();
stored.forEach((item) => {
const existing = merged.get(item.itemId);
if (existing) {
existing.quantity += item.quantity;
return;
}
merged.set(item.itemId, {
cartId: item.itemId,
itemId: item.itemId,
name: item.name,
category: item.category,
portion: item.portion,
price: item.price,
quantity: item.quantity,
});
});
return [...merged.values()];
}
export default function MenuPage() {
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<MenuTab>("ALL");
const [cart, setCart] = useState<MenuCartItem[]>(readStoredCart);
const [cartOpen, setCartOpen] = useState(false);
// стоп-лист приходит из CRM и обновляется раз в минуту
const stoplist = useStoplist();
// позиции, добавленные владельцем через CRM («новинки»)
const customCatalog = useCustomCatalog();
// кастомные позиции владельца, разложенные по форматам секций сайта
const customItems = useMemo(
() => ({
teas: customCatalog.menu.filter((item) => item.category === TEA_CATEGORY).map(customToTea),
beers: customCatalog.menu.filter((item) => item.category === BEER_CATEGORY).map(customToBeer),
bar: customCatalog.menu
.filter((item) => item.category !== TEA_CATEGORY && item.category !== BEER_CATEGORY)
.map(customToBar),
}),
[customCatalog],
);
// фото: загруженные владельцем перекрывают фото из каталога сайта
const photoMap = useMemo(() => {
const map: Record<string, string> = {};
for (const item of customCatalog.menu) {
if (item.image) map[item.id] = crmUrl(item.image);
}
return map;
}, [customCatalog]);
const photoFor = (id: string) => photoMap[id] ?? menuPhotos[id];
// подсветка карточек, заданная владельцем в CRM, перекрывает цвет секции
const crmAccents = customCatalog.accents;
const accentFor = (id: string, fallback: string) => crmAccents[id] ?? fallback;
// id позиций-«новинок» (добавлены владельцем за последние 7 дней)
const noveltyIds = useMemo(
() =>
new Set(
customCatalog.menu.filter((item) => isNovelty(item.created_at)).map((item) => item.id),
),
[customCatalog],
);
// позиции, удалённые владельцем через CRM, скрываются с сайта
const deletedMenuIds = useMemo(
() => new Set(customCatalog.deleted_menu),
[customCatalog],
);
// счётчики вкладок включают все позиции: из стоп-листа CRM помечаются «нет в наличии»
const menuTabsWithCounts = useMemo(
() =>
menuTabs.map((tab) => {
const teas = [...authorTeas, ...customItems.teas].filter((t) => !deletedMenuIds.has(t.id));
const beers = [...beerList, ...customItems.beers].filter((b) => !deletedMenuIds.has(b.id));
const bar = [...barMenuItems, ...customItems.bar].filter((i) => !deletedMenuIds.has(i.id));
const count =
tab.id === "ALL"
? teas.length + beers.length + bar.length
: teas.filter((t) => t.category === tab.id).length +
beers.filter((b) => b.category === tab.id).length +
bar.filter((i) => i.category === tab.id).length;
return { ...tab, count };
}),
[customItems, deletedMenuIds],
);
useEffect(() => {
try {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
} catch {
// Корзина остаётся рабочей в памяти, даже если хранилище недоступно.
}
}, [cart]);
const { filteredTeas, filteredBeers, snacks, drinks, sweets, lemonades } = useMemo(() => {
const normalizedQuery = query.toLocaleLowerCase("ru-RU").trim();
const matches = (text: string) => !normalizedQuery || text.toLocaleLowerCase("ru-RU").includes(normalizedQuery);
const isVisible = (category: MenuTab) => activeTab === "ALL" || activeTab === category;
const notDeleted = (id: string) => !deletedMenuIds.has(id);
// новинки всегда вверху списка
const novelFirst = <T extends { id: string }>(list: T[]) =>
[...list].sort((a, b) => (noveltyIds.has(b.id) ? 1 : 0) - (noveltyIds.has(a.id) ? 1 : 0));
const teas = isVisible(TEA_CATEGORY)
? novelFirst([...authorTeas, ...customItems.teas].filter((tea) => notDeleted(tea.id) && matches(`${tea.name} ${tea.desc} ${tea.ingredients.join(" ")}`)))
: [];
const beers = isVisible(BEER_CATEGORY)
? novelFirst([...beerList, ...customItems.beers].filter((beer) => notDeleted(beer.id) && matches(`${beer.name} ${beer.type} ${beer.desc} ${beer.volume}`)))
: [];
const barItems = novelFirst([...barMenuItems, ...customItems.bar].filter((item) => notDeleted(item.id) && matches(`${item.name} ${item.desc} ${item.category}`)));
return {
filteredTeas: teas,
filteredBeers: beers,
snacks: isVisible(SNACKS_CATEGORY) ? barItems.filter((item) => item.category === SNACKS_CATEGORY) : [],
drinks: isVisible(DRINKS_DESSERTS_CATEGORY) ? barItems.filter((item) => item.category === DRINKS_DESSERTS_CATEGORY) : [],
sweets: isVisible(SWEETS_CATEGORY) ? barItems.filter((item) => item.category === SWEETS_CATEGORY) : [],
lemonades: isVisible(LEMONADES_CATEGORY) ? barItems.filter((item) => item.category === LEMONADES_CATEGORY) : [],
};
}, [activeTab, query, customItems, deletedMenuIds, noveltyIds]);
const totalResults = filteredTeas.length + filteredBeers.length + snacks.length + drinks.length + sweets.length + lemonades.length;
const meta = (id: string) => barMetaById.get(id)!;
// «Повторить прошлый заказ»: снимок последней отправленной корзины.
// Показываем, пока корзина пуста; стоп-позиции из повторяемого набора убираем.
// NF-15: снимок — тоже PII гостя (стол, состав), храним не дольше 30 дней.
const readLastOrder = () => {
const v = readStoredValue<{ at?: number; table: string; items: Array<MenuCartItem> } | null>(
"neon-last-order",
null,
);
if (v && (!v.at || Date.now() - v.at > 30 * 24 * 60 * 60 * 1000)) {
try {
localStorage.removeItem("neon-last-order");
} catch {
// приватный режим браузера — удалять нечем
}
return null;
}
return v;
};
const [lastOrder, setLastOrder] = useState(readLastOrder);
// перечитываем снимок, когда корзина опустела (заказ отправлен или очищен):
// сразу после первого заказа кнопка «Повторить» тоже появляется
useEffect(() => {
if (cart.length === 0) {
setLastOrder(readLastOrder());
}
}, [cart.length]);
const repeatable = useMemo(() => {
if (!lastOrder || cart.length > 0 || !lastOrder.items?.length) return null;
const items = lastOrder.items
.filter((item) => !stoplist.menu.has(item.itemId))
.slice(0, 8);
return items.length > 0 ? items : null;
}, [lastOrder, cart.length, stoplist.menu]);
const repeatLastOrder = () => {
if (!repeatable) return;
setCart(
repeatable.map((item) => ({
cartId: item.itemId,
itemId: item.itemId,
name: item.name,
category: item.category ?? "",
portion: item.portion,
price: item.price,
quantity: Math.min(50, Math.max(1, item.quantity)),
})),
);
setCartOpen(true);
};
const addToCart = (item: MenuCardItem) => {
setCart((current) => {
const existing = current.find((cartItem) => cartItem.itemId === item.id);
if (existing) {
// сервер принимает максимум 50 шт позиции — не обещаем гостю больше
return current.map((cartItem) =>
cartItem.cartId === existing.cartId
? { ...cartItem, quantity: Math.min(50, cartItem.quantity + 1) }
: cartItem,
);
}
return [
...current,
{
cartId: item.id,
itemId: item.id,
name: item.name,
category: item.category,
portion: item.portion,
price: item.price,
quantity: 1,
},
];
});
};
const changeQuantity = (cartId: string, delta: number) => {
setCart((current) =>
current
.map((item) => (item.cartId === cartId ? { ...item, quantity: item.quantity + delta } : item))
.filter((item) => item.quantity > 0),
);
};
const changeMenuQuantity = (item: MenuCardItem, delta: number) => {
if (delta > 0) {
addToCart(item);
return;
}
changeQuantity(item.id, delta);
};
const removeCartItem = (cartId: string) => {
setCart((current) => current.filter((item) => item.cartId !== cartId));
};
const renderCard = (item: MenuCardItem, options: { icon: MenuIcon; accent: string; image?: string; portion?: string; badge?: string; ingredients?: string[] }) => (
<MenuItemCard
key={item.id}
item={item}
priceFormatted={item.priceFormatted}
portion={options.portion}
badge={options.badge}
ingredients={options.ingredients}
icon={options.icon}
accent={options.accent}
image={options.image}
cartQuantity={cart.find((cartItem) => cartItem.itemId === item.id)?.quantity ?? 0}
available={!stoplist.menu.has(item.id)}
novelty={noveltyIds.has(item.id)}
onAdd={() => changeMenuQuantity(item, 1)}
onRemove={() => changeMenuQuantity(item, -1)}
/>
);
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[36rem] overflow-hidden" aria-hidden="true">
<img src="./images/menu-bg.jpg" alt="" className="h-full w-full object-cover opacity-25" />
<div className="absolute inset-0 bg-gradient-to-b from-void/70 via-void/60 to-void" />
<div className="absolute -left-32 top-10 h-96 w-96 rounded-full bg-neon-violet/20 blur-[140px]" />
<div className="absolute -right-32 top-20 h-96 w-96 rounded-full bg-neon-cyan/20 blur-[140px]" />
</div>
<div className="relative mx-auto w-full max-w-6xl px-4 pt-24 sm:px-6 sm:pt-28 lg:px-8">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<div className="mt-6 flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-neon-cyan/50 bg-neon-cyan/10 px-3.5 py-1 text-xs font-bold uppercase tracking-widest text-cyan-200 shadow-[0_0_18px_rgba(0,240,255,0.3)]">
<Sparkles size={14} className="text-neon-cyan" />
Барное меню НЕОН
</span>
<span className="text-xs text-mist/40">• г. Нягань</span>
</div>
<h1 id="menu-page-title" tabIndex={-1} className="mt-4 font-display text-[clamp(1.85rem,6vw,3.5rem)] font-extrabold leading-[1.12] text-white outline-none">
Меню <span className="text-glow-cyan text-cyan-300">бара</span>
</h1>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-mist/75">
Авторские чаи 900 мл, охлаждённая пивная карта, горячие закуски, десерты и энергетики — всё, что нужно для отличного вечера.
</p>
<div className="mt-9 flex gap-2 overflow-x-auto pb-2 sm:flex-wrap sm:overflow-visible">
{menuTabsWithCounts.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 rounded-2xl border px-4 py-2.5 font-display text-xs font-bold uppercase tracking-wider transition-all duration-300 sm:text-sm",
activeTab === tab.id
? tab.id === "ALL"
? "border-white/40 bg-white/15 text-white shadow-[0_0_20px_rgba(255,255,255,0.2)] ring-1 ring-white/40"
: tab.id === TEA_CATEGORY
? "border-neon-cyan bg-neon-cyan/15 text-white shadow-[0_0_24px_rgba(0,240,255,0.35)] ring-1 ring-neon-cyan/60"
: tab.id === BEER_CATEGORY
? "border-neon-violet bg-neon-violet/20 text-white shadow-[0_0_24px_rgba(176,38,255,0.45)] ring-1 ring-neon-violet/60"
: "border-white/40 bg-white/10 text-white shadow-[0_0_18px_rgba(255,255,255,0.15)] ring-1 ring-white/30"
: "border-white/10 bg-white/[0.03] text-mist/60 hover:border-white/20 hover:text-white",
)}
>
<span>{tab.label}</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/75">{tab.count}</span>
</button>
))}
</div>
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full max-w-md">
<Search size={17} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45" aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск по всему меню…"
className="catalog-search h-12 w-full rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white"
aria-label="Очистить поиск"
>
<X size={17} />
</button>
)}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<div className="glass flex items-center gap-3 rounded-2xl border border-white/10 px-4 py-3 text-xs text-mist/75">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-neon-cyan/15 text-neon-cyan">
<Flame size={14} />
</span>
<span>
Показано позиций: <strong className="text-white">{totalResults}</strong>
</span>
</div>
</div>
</div>
{repeatable && (
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-neon-cyan/25 bg-neon-cyan/[0.05] px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-bold text-white">Повторить прошлый заказ?</p>
<p className="mt-0.5 truncate text-xs text-mist/60">
{repeatable.map((item) => `${item.quantity}× ${item.name}`).join(" · ")}
</p>
</div>
<button
type="button"
onClick={repeatLastOrder}
className="inline-flex min-h-10 shrink-0 cursor-pointer items-center gap-2 rounded-full border border-neon-cyan/50 bg-neon-cyan/10 px-5 text-xs font-bold uppercase tracking-wider text-cyan-100 transition-all hover:bg-neon-cyan/20 hover:shadow-[0_0_18px_rgba(0,240,255,0.35)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<History size={14} aria-hidden="true" />
Повторить
</button>
</div>
)}
{filteredTeas.length > 0 && (
<section className="mt-12" aria-label={TEA_CATEGORY}>
<CategoryHeader
icon={Coffee}
label={TEA_CATEGORY}
tagline="Все чайники 900 мл по единой цене 350 ₽ на натуральных ягодах и травах"
count={filteredTeas.length}
accent="#00f0ff"
/>
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{filteredTeas.map((tea) =>
renderCard(tea, {
icon: menuIcons.leaf,
accent: accentFor(tea.id, tea.accentColor),
image: photoFor(tea.id),
portion: tea.volume,
ingredients: tea.ingredients,
}),
)}
</div>
</section>
)}
{filteredBeers.length > 0 && (
<section className="mt-16" aria-label={BEER_CATEGORY}>
<CategoryHeader
icon={Beer}
label={BEER_CATEGORY}
tagline="14 охлаждённых сортов: светлые и тёмные лагеры, пшеничные бланши, эли и фруктовые"
count={filteredBeers.length}
accent="#b026ff"
/>
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{filteredBeers.map((beer) =>
renderCard(beer, {
icon: menuIcons.beer,
accent: accentFor(beer.id, beer.accentColor),
image: photoFor(beer.id),
portion: beer.volume,
badge: beer.type,
}),
)}
</div>
</section>
)}
{snacks.length > 0 && (
<section className="mt-16" aria-label={SNACKS_CATEGORY}>
<CategoryHeader icon={Drumstick} label={SNACKS_CATEGORY} tagline={meta(SNACKS_CATEGORY).tagline} count={snacks.length} accent={meta(SNACKS_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{snacks.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.utensils, accent: accentFor(item.id, meta(SNACKS_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{drinks.length > 0 && (
<section className="mt-16" aria-label={DRINKS_DESSERTS_CATEGORY}>
<CategoryHeader icon={Cake} label={DRINKS_DESSERTS_CATEGORY} tagline={meta(DRINKS_DESSERTS_CATEGORY).tagline} count={drinks.length} accent={meta(DRINKS_DESSERTS_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{drinks.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.cake, accent: accentFor(item.id, meta(DRINKS_DESSERTS_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{sweets.length > 0 && (
<section className="mt-16" aria-label={SWEETS_CATEGORY}>
<CategoryHeader icon={Candy} label={SWEETS_CATEGORY} tagline={meta(SWEETS_CATEGORY).tagline} count={sweets.length} accent={meta(SWEETS_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{sweets.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.candy, accent: accentFor(item.id, meta(SWEETS_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{lemonades.length > 0 && (
<section className="mt-16" aria-label={LEMONADES_CATEGORY}>
<CategoryHeader icon={Zap} label={LEMONADES_CATEGORY} tagline={meta(LEMONADES_CATEGORY).tagline} count={lemonades.length} accent={meta(LEMONADES_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{lemonades.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.zap, accent: accentFor(item.id, meta(LEMONADES_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{totalResults === 0 && (
<div className="py-20 text-center">
<h3 className="font-display text-lg font-bold text-white">Ничего не найдено</h3>
<p className="mt-2 text-sm text-mist/60">Попробуйте другой запрос — например, «чай», «козел», «чизкейк» или «BURN».</p>
<button
type="button"
onClick={() => {
setQuery("");
setActiveTab("ALL");
}}
className="mt-5 inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-6 py-2.5 text-xs font-bold uppercase tracking-wider text-cyan-200 transition-all hover:bg-neon-cyan/20"
>
Сбросить фильтры
</button>
</div>
)}
<div className="mt-16 rounded-3xl border border-neon-violet/30 bg-gradient-to-r from-neon-violet/15 via-panel/80 to-neon-cyan/10 p-6 sm:p-8">
<div className="flex flex-col items-center justify-between gap-6 sm:flex-row">
<div>
<span className="text-xs font-bold uppercase tracking-widest text-fuchsia-300">Бар лаунджа НЕОН</span>
<h2 className="mt-1 font-display text-xl font-bold text-white sm:text-2xl">Хотите забронировать столик?</h2>
<p className="mt-2 max-w-xl text-xs leading-relaxed text-mist/70 sm:text-sm">
Бронируйте основной зал, VIP с PS5 или кинозал — мы охладим напитки и приготовим чашу к вашему приходу.
</p>
</div>
<div className="flex flex-wrap gap-3">
<a
href="#contacts"
className="inline-flex min-h-12 items-center gap-2 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-6 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(176,38,255,0.45)] transition-all hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.7)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Забронировать стол
<ArrowUpRight size={16} />
</a>
<a
href="#/mixes"
className="inline-flex min-h-12 items-center gap-2 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-6 py-3 text-sm font-bold text-cyan-200 transition-all hover:bg-neon-cyan/20 hover:shadow-[0_0_24px_rgba(0,240,255,0.3)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Карта кальянов
</a>
</div>
</div>
</div>
</div>
<MenuCart
items={cart}
open={cartOpen}
stoppedItems={stoplist.menu}
onToggle={() => setCartOpen((open) => !open)}
onQuantityChange={changeQuantity}
onRemoveItem={removeCartItem}
onClear={() => setCart([])}
/>
</div>
);
}
+661
View File
@@ -0,0 +1,661 @@
import { useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
Bot,
Check,
Copy,
Search,
Snowflake,
Sparkles,
X,
} from "lucide-react";
import {
hookahMixes,
mixCoolingLabels,
mixFlavorTags,
mixStrengthLabels,
mixesCatalogNotice,
type CoolingLevel,
type HookahMix,
type MixStrength,
} from "../data/catalog";
import { FLAVORS } from "../data/flavors";
import { flavorTranslation, getFlavor, isColdFlavor } from "../lib/mixEngine";
import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
/* ------------------------------------------------------------------ */
/* Единая карточка микса: ИИ-витрина и миксы мастера в одной ленте, */
/* различие — только бейдж («Микс от ИИ» / «Микс от мастера»). */
/* Формат карточки — как в выдаче AI-сомелье: подробный состав */
/* с ролями, сверху — крепость, холодок и цена, снизу — копирование. */
/* ------------------------------------------------------------------ */
interface UnifiedItem {
/** название вкуса для строки состава */
title: string;
/** уточнение: русский перевод вкуса или бренд табака */
sub: string | null;
/** вкус-холодок (синяя точка и роль «Холодок») */
cold: boolean;
/** табак в каталоге сайта — по нему сверяется стоп-лист CRM */
catalogId: string | null;
/** этот вкус ушёл в стоп-лист */
stopped: boolean;
}
interface UnifiedMix {
key: string;
source: "ai" | "master";
name: string;
desc: string;
strength: MixStrength | null;
cooling: CoolingLevel | null;
price: string | null;
tags: string[];
items: UnifiedItem[];
/** хотя бы один вкус состава в стоп-листе CRM — карточка гасится */
stopped: boolean;
}
function itemsFromAi(mix: HookahMix): UnifiedItem[] {
return mix.items.map((item) => {
const flavor = getFlavor(item.flavorId);
const translation = flavor ? flavorTranslation(item.flavorId) : null;
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,
};
});
}
function itemsFromMaster(mix: CustomMix): UnifiedItem[] {
return mix.flavors.map((f) => {
// id вкуса CRM = catalogId в справочнике сайта — по нему понимаем холодок
const siteFlavor = FLAVORS.find((x) => x.catalogId === f.id || x.id === f.id);
return {
title: f.flavor,
sub: f.brand,
cold: !!siteFlavor && isColdFlavor(siteFlavor),
catalogId: f.id,
stopped: false,
};
});
}
/** Состав витринного микса, переопределённый в CRM: вкусы справочника CRM
* (id = catalogId сайта) в ролях «основа → добавка → оттенок». */
function itemsFromSiteEdit(flavors: { id: string; brand: string; flavor: string }[]): UnifiedItem[] {
return flavors.map((f) => {
const siteFlavor = FLAVORS.find((x) => x.catalogId === f.id || x.id === f.id);
return {
title: f.flavor,
sub: f.brand,
cold: !!siteFlavor && isColdFlavor(siteFlavor),
catalogId: f.id,
stopped: false,
};
});
}
const formatPrice = (rub: number) => `${rub.toLocaleString("ru-RU")} ₽`;
const roleLabel = (index: number, cold: boolean): string => {
if (cold) return "Холодок";
if (index === 0) return "Основа";
if (index === 1) return "Добавка";
return "Оттенок";
};
function segmentGradient(index: number, cold: boolean): string {
if (cold) return "linear-gradient(90deg,#67e8f9,#00f0ff)";
if (index === 0) return "linear-gradient(90deg,#7c3aed,#d946ef)";
if (index === 1) return "linear-gradient(90deg,#0891b2,#00f0ff)";
return "linear-gradient(90deg,#f59e0b,#f43f5e)";
}
function copyText(card: UnifiedMix): string {
return [
`Микс: ${card.name}`,
...card.items.map((i) => `• ${i.title}${i.sub ? ` (${i.sub})` : ""}`),
"",
card.desc,
"",
"Состав — только сочетание вкусов. Полный разбор и точные пропорции под вашу компанию сделает кальянщик, назовите название микса при заказе.",
"Кальянный лаунж «НЕОН», Нягань",
].join("\n");
}
function StrengthBar({ level }: { level: MixStrength }) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{[1, 2, 3, 4, 5].map((d) => (
<span
key={d}
className={cn(
"h-1.5 w-3 rounded-full transition-all duration-300",
d <= level
? "bg-neon-cyan shadow-[0_0_8px_rgba(0,240,255,0.85)]"
: "bg-white/12"
)}
/>
))}
</span>
);
}
function CoolingPill({ level }: { level: CoolingLevel }) {
if (level === 0) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-mist/40">
Без холодка
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-cyan-200">
<Snowflake size={11} aria-hidden="true" />
{mixCoolingLabels[level]}
</span>
);
}
function UnifiedMixCard({
card,
copied,
onCopy,
}: {
card: UnifiedMix;
copied: boolean;
onCopy: (card: UnifiedMix) => void;
}) {
const isMaster = card.source === "master";
return (
<article
className={cn(
"catalog-result glass flex h-full min-w-0 flex-col rounded-2xl p-5 transition-all duration-300 hover:-translate-y-1",
isMaster
? "hover:border-neon-cyan/40 hover:shadow-[0_18px_50px_-24px_rgba(0,240,255,0.4)]"
: "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",
card.stopped && "opacity-70 saturate-[0.65]",
)}
>
{/* бейдж источника + теги */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full border border-neon-cyan/50 bg-neon-cyan/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-cyan-100">
{isMaster ? <Sparkles size={10} aria-hidden="true" /> : <Bot size={10} aria-hidden="true" />}
{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) => (
<span
key={tag}
className="rounded-full border border-neon-violet/30 bg-neon-violet/8 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-fuchsia-200/85"
>
{tag}
</span>
))}
</div>
{/* название и описание */}
<h2 className="mt-3 font-display text-base font-bold leading-snug text-white sm:text-lg">
{card.name}
</h2>
{card.desc && (
<p className="mt-1.5 text-xs leading-relaxed text-mist/65">{card.desc}</p>
)}
{/* подробный состав с ролями — как в выдаче AI-сомелье */}
<div className="mt-4 flex-1">
<ul className="space-y-2">
{card.items.map((item, i) => (
<li key={`${card.key}-${i}`} className="flex items-start justify-between gap-3 text-xs">
<span className="flex min-w-0 items-start gap-2">
<span
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
style={{
background: item.stopped
? "linear-gradient(90deg,#f87171,#ef4444)"
: segmentGradient(i, item.cold),
}}
aria-hidden="true"
/>
<span className="min-w-0">
<span className={cn("block truncate font-semibold text-white/90", item.stopped && "text-red-300")}>{item.title}</span>
{item.sub && (
<span className="block truncate text-[11px] text-mist/45">{item.sub}</span>
)}
</span>
</span>
<span className="shrink-0 pt-0.5 text-[10px] uppercase tracking-wider text-mist/45">
{roleLabel(i, item.cold)}
</span>
</li>
))}
</ul>
<p className="mt-2 text-[11px] leading-relaxed text-mist/40">
Пропорции подберёт кальянщик под вашу чашу.
</p>
</div>
{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>
)}
{/* крепость · холодок · цена */}
<div className="mt-4 space-y-2.5 border-t border-white/10 pt-3">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-mist/55">Крепость</span>
<span className="flex items-center gap-2 text-mist/80">
{card.strength != null ? (
<>
<StrengthBar level={card.strength} />
<span className="text-[11px] font-semibold whitespace-nowrap">
{mixStrengthLabels[card.strength]}
</span>
</>
) : (
<span className="text-[11px] text-mist/40">—</span>
)}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-mist/55">Холодок</span>
{card.cooling != null ? (
<CoolingPill level={card.cooling} />
) : (
<span className="text-[11px] text-mist/40">—</span>
)}
</div>
<div className="flex items-center justify-between gap-2 pt-1 text-sm">
<span className="text-mist/55">Цена</span>
<span className="whitespace-nowrap font-display font-bold text-cyan-200">
{card.price ?? "—"}
</span>
</div>
</div>
{/* копирование рецепта */}
<button
type="button"
onClick={() => onCopy(card)}
aria-label={`Скопировать рецепт «${card.name}»`}
className={cn(
"mt-4 flex min-h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border text-xs font-bold transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neon-cyan",
copied
? "border-emerald-400/60 bg-emerald-400/15 text-emerald-200"
: "border-white/15 bg-white/5 text-white/85 hover:border-neon-cyan/50 hover:bg-neon-cyan/10 hover:text-white",
)}
>
{copied ? (
<>
<Check size={14} aria-hidden="true" /> Скопировано — покажите кальянщику
</>
) : (
<>
<Copy size={14} aria-hidden="true" /> Скопировать микс
</>
)}
</button>
</article>
);
}
export default function MixesPage() {
const [query, setQuery] = useState("");
const [strength, setStrength] = useState("all");
const [cooling, setCooling] = useState("all");
const [flavor, setFlavor] = useState("all");
const searchRef = useRef<HTMLInputElement>(null);
const catalog = useCustomCatalog();
const stoplist = useStoplist();
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const copiedTimer = useRef<number | null>(null);
// витрина ИИ: генерации из каталога сайта, поверх — правки из CRM
// («Авторские миксы сайта»): владелец мог поменять название, описание,
// крепость/холодок/цену, теги и состав; null правки = исходное значение
const aiCards: UnifiedMix[] = useMemo(
() =>
hookahMixes.map((mix) => {
const ed = catalog.site_edits.find((e) => e.id === mix.id);
return {
key: mix.id,
source: "ai" as const,
name: ed?.name ?? mix.name,
desc: ed?.note ?? mix.desc,
strength: (ed?.strength ?? mix.strength) as MixStrength,
cooling: (ed?.cooling ?? mix.cooling) as CoolingLevel,
price: ed?.price != null ? formatPrice(ed.price) : mix.price,
tags: ed?.tags?.length ? ed.tags : mix.flavorTags,
items: ed?.flavors?.length ? itemsFromSiteEdit(ed.flavors) : itemsFromAi(mix),
stopped: false,
};
}),
[catalog.site_edits],
);
// единая лента: миксы мастера (фирменные, из CRM) — первыми, затем витрина ИИ
const masterCards: UnifiedMix[] = useMemo(
() =>
catalog.mixes.map((mix) => ({
key: mix.id,
source: "master" as const,
name: mix.name,
desc: mix.note ?? "",
// незаданные величины не фильтруются: владелец мог их не указать
strength:
mix.strength != null && mix.strength >= 1 && mix.strength <= 5
? (mix.strength as MixStrength)
: null,
cooling:
mix.cooling != null && mix.cooling >= 0 && mix.cooling <= 3
? (mix.cooling as CoolingLevel)
: null,
price: mix.price != null ? `${mix.price.toLocaleString("ru-RU")} ₽` : null,
tags: (mix.tags ?? []).filter(Boolean),
items: itemsFromMaster(mix),
stopped: false,
})),
[catalog.mixes],
);
const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards, aiCards]);
// стоп-лист 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],
);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return markedCards.filter((m) => {
// крепость/холодок: фильтруем только миксы, у которых величина задана
if (strength !== "all" && !(m.strength != null && String(m.strength) === strength))
return false;
if (cooling !== "all" && !(m.cooling != null && String(m.cooling) === cooling))
return false;
if (flavor !== "all" && !m.tags.some((t) => normalize(t) === normalize(flavor)))
return false;
const text = normalize(
`${m.name} ${m.desc} ${m.tags.join(" ")} ${m.items.map((i) => `${i.title} ${i.sub ?? ""}`).join(" ")}`,
);
return words.every((word) => text.includes(word));
});
}, [markedCards, query, strength, cooling, flavor]);
const hasFilters =
query.length > 0 || strength !== "all" || cooling !== "all" || flavor !== "all";
const resetFilters = () => {
setQuery("");
setStrength("all");
setCooling("all");
setFlavor("all");
searchRef.current?.focus();
};
const handleCopy = (card: UnifiedMix) => {
navigator.clipboard?.writeText(copyText(card)).then(
() => {
setCopiedKey(card.key);
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
copiedTimer.current = window.setTimeout(() => setCopiedKey(null), 2500);
},
() => {
/* копирование недоступно — кнопка просто не подсветится */
},
);
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden"
aria-hidden="true"
>
<img src="./images/menu-bg.jpg" alt="" className="h-full w-full object-cover opacity-20" />
<div className="absolute inset-0 bg-gradient-to-b from-void/70 via-void/55 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="mixes-title"
tabIndex={-1}
className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none"
>
Миксы <span className="text-glow-violet text-fuchsia-200">НЕОНа</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
Автоподбор миксов и фирменные сочетания мастера — в одной ленте.
Фильтруй по крепости, холодку и вкусу — или собери свой уникальный.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">
{mixesCatalogNotice}
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<a
href="#/ai-mix"
className="inline-flex min-h-12 items-center gap-2.5 rounded-2xl border border-neon-cyan/50 bg-gradient-to-r from-neon-violet/25 via-neon-cyan/20 to-neon-violet/15 px-5 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(0,240,255,0.3)] transition-all hover:-translate-y-0.5 hover:border-neon-cyan hover:shadow-[0_0_36px_rgba(0,240,255,0.55)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<Bot size={18} className="text-neon-cyan" />
<span>Уникальный автоподбор микса</span>
<ArrowUpRight size={15} className="text-cyan-200" />
</a>
</div>
{/* фильтры */}
<form
role="search"
aria-label="Поиск по каталогу миксов"
onSubmit={(event) => event.preventDefault()}
className="mt-9 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.4fr)_repeat(3,minmax(0,1fr))]"
>
<div className="min-w-0 sm:col-span-2 lg:col-span-1">
<label htmlFor="mix-search" className="mb-2 block text-xs font-semibold text-mist/70">
Название, описание или вкус
</label>
<div className="relative">
<Search
size={17}
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45"
aria-hidden="true"
/>
<input
ref={searchRef}
id="mix-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Например, мята или Berry"
autoComplete="off"
aria-controls="mix-results"
className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
aria-label="Очистить поиск"
onClick={() => {
setQuery("");
searchRef.current?.focus();
}}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="mix-strength" className="mb-2 block text-xs font-semibold text-mist/70">
Крепость
</label>
<select
id="mix-strength"
value={strength}
onChange={(event) => setStrength(event.target.value)}
aria-controls="mix-results"
className={selectClass}
>
<option value="all">Любая</option>
<option value="1">Лёгкий</option>
<option value="2">Ниже среднего</option>
<option value="3">Средняя</option>
<option value="4">Выше средней</option>
<option value="5">Крепкий</option>
</select>
</div>
<div className="min-w-0">
<label
htmlFor="mix-cooling"
className="mb-2 block text-xs font-semibold text-mist/70"
>
Холодок
</label>
<select
id="mix-cooling"
value={cooling}
onChange={(event) => setCooling(event.target.value)}
aria-controls="mix-results"
className={selectClass}
>
<option value="all">Неважно</option>
<option value="0">Без холодка</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сильный</option>
</select>
</div>
<div className="min-w-0">
<label
htmlFor="mix-flavor"
className="mb-2 block text-xs font-semibold text-mist/70"
>
Вкус
</label>
<select
id="mix-flavor"
value={flavor}
onChange={(event) => setFlavor(event.target.value)}
aria-controls="mix-results"
className={selectClass}
>
<option value="all">Все вкусы</option>
{mixFlavorTags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p
role="status"
aria-live="polite"
aria-atomic="true"
className="text-xs text-mist/55"
>
Показано {filtered.length} из {markedCards.length}
</p>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Сбросить фильтры
</button>
)}
</div>
<div id="mix-results" className="min-w-0">
{filtered.length > 0 ? (
<ul
className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="Миксы для кальяна"
>
{filtered.map((card) => (
<li key={card.key} className="min-w-0">
<UnifiedMixCard card={card} copied={copiedKey === card.key} onCopy={handleCopy} />
</li>
))}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">
Ничего не нашлось
</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">
Попробуй другой вкус или крепость. Например, «мята» или «ягоды».
</p>
<button
type="button"
onClick={resetFilters}
className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Показать все миксы
</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">
Не нашёл любимый микс? Уточни полный ассортимент у мастера.
</p>
<a
href="https://vk.ru/shelby_nyagan"
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Спросить в VK <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
Check,
ClipboardList,
Clock,
Loader2,
PackageSearch,
Search,
XCircle,
} from "lucide-react";
import {
TRACK_CODE_RE,
cancelOrder,
fetchOrderStatus,
normalizeTrackCode,
type TrackInfo,
type TrackResult,
} from "../lib/crm";
import { cn } from "../utils/cn";
const LAST_CODE_KEY = "neon-last-track-code";
const POLL_MS = 15_000;
/** сколько минут гость может отменить случайный заказ (синхронно с CRM) */
const CANCEL_WINDOW_MS = 10 * 60_000;
const STEPS: { key: TrackInfo["status"]; label: string; hint: string }[] = [
{ key: "new", label: "Принят", hint: "заказ у бара" },
{ key: "active", label: "Готовится", hint: "официант взял в работу" },
{ key: "done", label: "Выдан", hint: "приятного аппетита" },
];
const STEP_INDEX: Record<TrackInfo["status"], number> = {
new: 0,
active: 1,
done: 2,
cancelled: 0,
};
const fmtTime = (iso: string | null | undefined) => {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime())
? null
: `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
function Steps({ info }: { info: TrackInfo }) {
const current = STEP_INDEX[info.status];
return (
<ol className="mt-6 flex items-stretch gap-2" aria-label="Этапы заказа">
{STEPS.map((step, i) => {
const done = i < current;
const active = i === current;
return (
<li key={step.key} className="flex min-w-0 flex-1 flex-col items-center text-center">
<div className="flex w-full items-center">
<span
className={cn(
"h-px flex-1",
i === 0 ? "bg-transparent" : done || active ? "bg-neon-cyan/60" : "bg-white/12"
)}
aria-hidden="true"
/>
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full border text-sm font-bold transition-all",
done && "border-emerald-400/50 bg-emerald-400/10 text-emerald-300",
active &&
"border-neon-cyan bg-neon-cyan/15 text-cyan-200 shadow-[0_0_22px_rgba(0,240,255,0.4)]",
!done && !active && "border-white/12 bg-white/[0.03] text-mist/40"
)}
>
{done ? <Check size={17} aria-hidden="true" /> : i + 1}
</span>
<span
className={cn(
"h-px flex-1",
i === STEPS.length - 1 ? "bg-transparent" : done ? "bg-neon-cyan/60" : "bg-white/12"
)}
aria-hidden="true"
/>
</div>
<span
className={cn(
"mt-2 text-xs font-bold",
done ? "text-emerald-300/80" : active ? "text-white" : "text-mist/40"
)}
>
{step.label}
</span>
<span className="mt-0.5 text-[10px] leading-snug text-mist/40">{step.hint}</span>
</li>
);
})}
</ol>
);
}
function StatusCard({ info, code, onRefresh }: { info: TrackInfo; code: string; onRefresh: () => void }) {
const created = fmtTime(info.created_at);
const accepted = fmtTime(info.accepted_at);
const delivered = fmtTime(info.delivered_at);
const [cancelState, setCancelState] = useState<{ phase: "idle" | "busy" | "done"; message?: string }>({
phase: "idle",
});
const cancelAllowed =
info.status === "new" &&
info.created_at != null &&
Date.now() - new Date(info.created_at).getTime() <= CANCEL_WINDOW_MS;
const doCancel = async () => {
if (cancelState.phase === "busy") return;
if (!window.confirm("Отменить этот заказ? Бар его ещё не начал готовить.")) return;
setCancelState({ phase: "busy" });
const res = await cancelOrder(code);
if (res.phase === "cancelled") {
setCancelState({ phase: "done" });
onRefresh();
} else {
setCancelState({ phase: "idle", message: res.message });
}
};
return (
<div className="glass mt-6 rounded-2xl p-5 sm:p-6" role="status" aria-live="polite">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-neon-cyan">
Заказ №{info.number}
</p>
<p className="mt-1 font-display text-lg font-bold text-white">{info.table}</p>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/5 px-3.5 py-1.5 text-sm font-bold text-mist/80">
<ClipboardList size={14} aria-hidden="true" />
{info.price.toLocaleString("ru-RU")} ₽
</span>
</div>
{info.status === "cancelled" ? (
<div className="mt-5 flex items-start gap-3 rounded-xl border border-red-400/30 bg-red-400/[0.07] px-4 py-3.5 text-sm text-red-200">
<XCircle size={17} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
Заказ отменён. Если это ошибка — позовите официанта или создайте новый заказ в корзине.
</span>
</div>
) : (
<>
<Steps info={info} />
{cancelAllowed && cancelState.phase !== "done" && (
<div className="mt-4">
<button
type="button"
onClick={() => void doCancel()}
disabled={cancelState.phase === "busy"}
className="inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-xl border border-red-400/40 px-4 text-xs font-bold uppercase tracking-wider text-red-200 transition-colors hover:bg-red-400/10 disabled:cursor-wait disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
{cancelState.phase === "busy" ? (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
) : (
<XCircle size={14} aria-hidden="true" />
)}
Отменить заказ
</button>
<p className="mt-1.5 text-[11px] text-mist/40">
Случайно отправили? Отмените сами — кнопка активна первые 10 минут, пока бар не начал
готовить.
</p>
{cancelState.message && (
<p className="mt-2 text-xs text-amber-200" role="alert">
{cancelState.message}
</p>
)}
</div>
)}
{cancelState.phase === "done" && (
<p className="mt-4 text-sm font-semibold text-red-200" role="status">
Заказ отменён — это видно и персоналу бара.
</p>
)}
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-2 border-t border-white/10 pt-4 text-xs text-mist/55">
<span className="inline-flex items-center gap-1.5">
<Clock size={12} aria-hidden="true" />
Создан{created ? ` в ${created}` : ""}
</span>
{accepted && <span>Взяли в работу в {accepted}</span>}
{delivered && <span className="text-emerald-300/80">Выдан в {delivered}</span>}
</div>
</>
)}
<p className="mt-4 text-[11px] text-mist/40">
Статус обновляется сам каждые 15 секунд, пока открыта эта страница.
</p>
</div>
);
}
export default function TrackOrderPage() {
// код приходит ссылкой из корзины: #/track?code=ABC123
const initial = useMemo(() => {
const qs = new URLSearchParams(window.location.hash.split("?")[1] ?? "");
const fromUrl = normalizeTrackCode(qs.get("code") ?? "");
if (fromUrl) return fromUrl;
const stored = localStorage.getItem(LAST_CODE_KEY) ?? "";
return TRACK_CODE_RE.test(stored) ? stored : "";
}, []);
const [code, setCode] = useState(initial);
const [checked, setChecked] = useState<string | null>(null);
const [result, setResult] = useState<TrackResult | null>(null);
const [busy, setBusy] = useState(false);
const lookup = useCallback(async (raw: string) => {
const c = normalizeTrackCode(raw);
if (!TRACK_CODE_RE.test(c)) return;
setBusy(true);
const res = await fetchOrderStatus(c);
setBusy(false);
setChecked(c);
setResult(res);
if (res.phase === "found") {
localStorage.setItem(LAST_CODE_KEY, c);
document.title = `Заказ №${res.info.number} | НЕОН, Нягань`;
} else {
document.title = "Статус заказа | НЕОН, Нягань";
}
}, []);
useEffect(() => {
if (initial) void lookup(initial);
}, [initial, lookup]);
// живой опрос, пока заказ найден; несуществующий код не долбим каждые 15 с
useEffect(() => {
if (!checked || result?.phase !== "found") return;
const timer = window.setInterval(() => void lookup(checked), POLL_MS);
return () => window.clearInterval(timer);
}, [checked, result?.phase, lookup]);
const valid = TRACK_CODE_RE.test(normalizeTrackCode(code));
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden" aria-hidden="true">
<img src="./images/hero-bg.jpg" alt="" className="h-full w-full object-cover opacity-15" />
<div className="absolute inset-0 bg-gradient-to-b from-void/75 via-void/60 to-void" />
</div>
<div className="relative mx-auto max-w-2xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="track-title"
tabIndex={-1}
className="mt-6 flex items-center gap-3 font-display text-[clamp(1.75rem,5.5vw,3rem)] font-bold leading-tight text-white outline-none"
>
<PackageSearch className="text-glow-cyan text-cyan-200" aria-hidden="true" />
Статус <span className="text-glow-violet text-fuchsia-200">заказа</span>
</h1>
<p className="mt-4 max-w-xl text-base leading-relaxed text-mist/70">
Введите код из подтверждения заказа — увидите, на каком этапе он находится:
принят, готовится или уже выдан.
</p>
<form
role="search"
aria-label="Проверка статуса заказа по коду"
onSubmit={(e) => {
e.preventDefault();
if (valid && !busy) void lookup(code);
}}
className="mt-8 flex flex-col gap-3 sm:flex-row"
>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="Код заказа, например AB12CD"
maxLength={6}
autoComplete="off"
spellCheck={false}
aria-label="Код заказа"
className="h-13 min-w-0 flex-1 rounded-xl border border-white/15 bg-panel/85 px-4 text-center font-display text-lg font-bold tracking-[0.35em] text-white outline-none transition-all placeholder:tracking-[0.12em] placeholder:text-mist/35 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)] sm:text-left"
/>
<button
type="submit"
disabled={!valid || busy}
className="inline-flex min-h-13 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-neon-cyan to-[#00b8d4] px-7 text-xs font-bold uppercase tracking-wider text-[#04252b] transition-all hover:shadow-[0_0_24px_rgba(0,240,255,0.4)] disabled:cursor-not-allowed disabled:opacity-35"
>
{busy ? <Loader2 size={16} className="animate-spin" aria-hidden="true" /> : <Search size={16} aria-hidden="true" />}
Проверить
</button>
</form>
{result?.phase === "found" && (
<StatusCard
info={result.info}
code={checked || code}
onRefresh={() => {
const c = checked || code;
if (c) void lookup(c);
}}
/>
)}
{result?.phase === "not-found" && (
<div
className="mt-6 flex items-start gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/[0.07] px-5 py-4 text-sm text-amber-200"
role="alert"
>
<XCircle size={17} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
Заказ с таким кодом не найден. Проверьте код — он из подтверждения после отправки
корзины. Заказы, созданные до появления кодов, не отслеживаются.
</span>
</div>
)}
{result?.phase === "error" && (
<div
className="mt-6 flex items-start gap-3 rounded-2xl border border-red-400/30 bg-red-400/[0.07] px-5 py-4 text-sm text-red-200"
role="alert"
>
<XCircle size={17} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{result.message}</span>
</div>
)}
<div className="mt-10 rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-sm leading-relaxed text-mist/60">
Где взять код? После отправки заказа из корзины он появляется на экране подтверждения.
Покажите код официанту — он найдёт заказ быстрее.
</div>
</div>
</div>
);
}