initial site
This commit is contained in:
commit
da5b259d06
167 files changed
+34157
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user