initial site

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

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import Preloader from "./components/Preloader";
import AgeGate from "./components/AgeGate";
import Header from "./components/Header";
import Hero from "./components/Hero";
import AtmosphereCarousel from "./components/AtmosphereCarousel";
import MenuCarousel from "./components/MenuCarousel";
import VipCalculator from "./components/VipCalculator";
import Features from "./components/Features";
import Contacts from "./components/Contacts";
import Footer from "./components/Footer";
import FlavorsPage from "./pages/FlavorsPage";
import GamesPage from "./pages/GamesPage";
import MixesPage from "./pages/MixesPage";
import AiMixPage from "./pages/AiMixPage";
import BoardGamesPage from "./pages/BoardGamesPage";
import MenuPage from "./pages/MenuPage";
import TrackOrderPage from "./pages/TrackOrderPage";
import { useHashLocation } from "./hooks/useHashLocation";
import { isPrerender } from "./lib/prerender";
type CatalogPage = "flavors" | "games" | "mixes" | "ai-mix" | "board-games" | "menu" | "track" | null;
function getCatalogPage(hash: string): CatalogPage {
if (hash === "#/flavors") return "flavors";
if (hash === "#/games") return "games";
if (hash === "#/mixes") return "mixes";
if (hash === "#/ai-mix" || hash === "#/mix-builder") return "ai-mix";
if (hash.startsWith("#mix=")) return "ai-mix"; // share-ссылка на генерацию
if (hash === "#/board-games") return "board-games";
if (hash === "#/menu" || hash === "#/tea") return "menu";
if (hash.startsWith("#/track")) return "track"; // #/track и #/track?code=XXX
return null;
}
export default function App() {
const [loaded, setLoaded] = useState(false);
const hash = useHashLocation();
const catalogPage = getCatalogPage(hash);
const isCatalog = catalogPage !== null;
const wasCatalog = useRef(isCatalog);
const homeTitle = useRef(document.title);
// в пререндер-снапшоте рендерим финальное видимое состояние страницы
const prerender = isPrerender();
const started = loaded || prerender;
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const t = setTimeout(() => {
document.body.style.overflow = previousOverflow;
setLoaded(true);
}, 2450);
return () => {
clearTimeout(t);
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
if (catalogPage === "flavors") {
document.title = "Вкусы табака | НЕОН, Нягань";
} else if (catalogPage === "games") {
document.title = "Игры PS5 | НЕОН, Нягань";
} else if (catalogPage === "mixes") {
document.title = "Миксы кальянов | НЕОН, Нягань";
} else if (catalogPage === "ai-mix") {
document.title = "Автоподбор микса | НЕОН, Нягань";
} else if (catalogPage === "board-games") {
document.title = "Настольные игры | НЕОН, Нягань";
} else if (catalogPage === "menu") {
document.title = "Меню бара: Авторский чай и Пивная карта | НЕОН, Нягань";
} else if (catalogPage === "track") {
document.title = "Статус заказа | НЕОН, Нягань";
} else {
document.title = homeTitle.current;
}
}, [catalogPage]);
useEffect(() => {
if (!loaded) return;
const returningToHome = wasCatalog.current && !isCatalog;
wasCatalog.current = isCatalog;
if (catalogPage === "flavors") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("flavors-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "games") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("games-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "mixes") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("mixes-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "ai-mix") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("ai-mix-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "board-games") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("board-games-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "menu") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("menu-page-title")?.focus({ preventScroll: true });
return;
}
if (catalogPage === "track") {
window.scrollTo({ top: 0, behavior: "instant" });
document.getElementById("track-title")?.focus({ preventScroll: true });
return;
}
// The landing section mounts after the hash changes when leaving the catalog.
const section = document.getElementById(hash.slice(1) || "home");
if (!section) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
section.scrollIntoView({
block: "start",
behavior: returningToHome || reducedMotion ? "instant" : "smooth",
});
if (returningToHome) {
section.tabIndex = -1;
section.focus({ preventScroll: true });
}
}, [hash, catalogPage, isCatalog, loaded]);
return (
<div className="relative min-h-screen w-full max-w-full overflow-x-hidden bg-void font-sans text-mist antialiased">
<div className="noise" aria-hidden />
{/* в пререндер-снапшоте прелоадер и age-gate не попадают в HTML:
бот должен видеть финальную страницу, а не оверлеи */}
{!prerender && <Preloader done={loaded} />}
{!prerender && <AgeGate ready={loaded} />}
<Header started={started} />
<main className="w-full max-w-full overflow-x-hidden">
{catalogPage === "flavors" ? (
<FlavorsPage />
) : catalogPage === "games" ? (
<GamesPage />
) : catalogPage === "mixes" ? (
<MixesPage />
) : catalogPage === "ai-mix" ? (
<AiMixPage />
) : catalogPage === "board-games" ? (
<BoardGamesPage />
) : catalogPage === "menu" ? (
<MenuPage />
) : catalogPage === "track" ? (
<TrackOrderPage />
) : (
<>
<Hero started={started} />
<AtmosphereCarousel />
<MenuCarousel />
<Features />
<VipCalculator />
<Contacts />
</>
)}
</main>
<Footer />
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from "react";
const STORAGE_KEY = "neon-age-confirmed";
function readConfirmed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === "yes";
} catch {
return false;
}
}
export default function AgeGate({ ready }: { ready: boolean }) {
const [confirmed, setConfirmed] = useState(readConfirmed);
const [declined, setDeclined] = useState(false);
const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
const open = ready && !confirmed;
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
confirmButtonRef.current?.focus({ preventScroll: true });
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
if (!open) return null;
const accept = () => {
try {
localStorage.setItem(STORAGE_KEY, "yes");
} catch {
// даже без localStorage пускаем — окно просто покажется при следующем визите
}
setConfirmed(true);
};
return (
<div
className="fixed inset-0 z-[120] flex items-center justify-center overflow-y-auto px-4 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="age-gate-title"
aria-describedby="age-gate-desc"
>
<div className="absolute inset-0 bg-void/90 backdrop-blur-md" aria-hidden="true" />
<div className="age-gate relative w-full max-w-md rounded-3xl border border-neon-violet/40 bg-panel/95 p-7 shadow-[0_0_60px_-12px_rgba(176,38,255,0.55)] sm:p-9">
<div className="pointer-events-none absolute -top-16 left-1/2 h-40 w-40 -translate-x-1/2 rounded-full bg-neon-violet/25 blur-[90px]" aria-hidden="true" />
<div className="relative mx-auto flex h-20 w-20 animate-[neonBreathe_6s_ease-in-out_infinite] items-center justify-center rounded-full border-2 border-neon-violet/60 bg-neon-violet/10 shadow-[0_0_30px_rgba(176,38,255,0.5)]">
<span className="font-display text-2xl font-extrabold text-white text-glow-violet">18+</span>
</div>
{declined ? (
<div className="relative">
<h2 id="age-gate-title" className="mt-6 text-center font-display text-2xl font-extrabold text-white">
До встречи позже
</h2>
<p className="mt-3 text-center text-sm leading-relaxed text-mist/75">
Спасибо за честность! Сайт и заведение доступны только для лиц старше 18 лет.
Берегите здоровье — и приходите, когда подрастёте.
</p>
<button
type="button"
onClick={() => setDeclined(false)}
className="mt-7 inline-flex min-h-12 w-full items-center justify-center rounded-full border border-white/15 bg-white/[0.04] px-6 py-3 text-sm font-bold text-mist transition-all hover:border-white/30 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Нажал случайно — вернуться
</button>
</div>
) : (
<div className="relative">
<h2 id="age-gate-title" className="mt-6 text-center font-display text-2xl font-extrabold text-white">
Строго <span className="text-glow-violet text-fuchsia-300">18+</span>
</h2>
<p id="age-gate-desc" className="mt-3 text-center text-sm leading-relaxed text-mist/75">
Кальянный лаундж «НЕОН» — заведение строго для совершеннолетних. Сайт содержит
информацию о табачной продукции и напитках, предназначенную для лиц старше 18 лет.
</p>
<div className="mt-7 flex flex-col gap-3">
<button
ref={confirmButtonRef}
type="button"
onClick={accept}
className="inline-flex min-h-12 w-full items-center justify-center rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-6 py-3 text-sm font-bold text-white shadow-[0_0_25px_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"
>
Мне есть 18 лет — войти
</button>
<button
type="button"
onClick={() => setDeclined(true)}
className="inline-flex min-h-12 w-full items-center justify-center rounded-full border border-white/15 bg-white/[0.03] px-6 py-3 text-sm font-semibold text-mist/70 transition-all hover:border-white/30 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Мне меньше 18
</button>
</div>
<p className="mt-5 text-center text-[11px] leading-relaxed text-mist/40">
Нажимая кнопку, вы подтверждаете своё совершеннолетие.
</p>
</div>
)}
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import Autoplay from "embla-carousel-autoplay";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../utils/cn";
import { Reveal, SectionHeading } from "./shared";
const slides = [
{
src: "./images/interior-sofas.jpg",
alt: "Уютные диваны с мягкой фиолетовой подсветкой в лаунж-зоне «НЕОН»",
tag: "Лаунж-зона",
title: "Мягкий свет, глубокие диваны",
desc: "Зоны для двоих и больших компаний — каждый уголок в своей неоновой гамме.",
},
{
src: "./images/interior-bar.jpg",
alt: "Барная стойка с подсветкой бутылок в неоновых тонах",
tag: "Бар",
title: "Бар в центре событий",
desc: "Авторские лимонады, чайная карта и кофе — всё в сиянии бутылочных огней.",
},
{
src: "./images/hookah-smoke.jpg",
alt: "Кальян крупным планом на тёмном столе, дым красиво подсвечен неоном",
tag: "Кальян",
title: "Дым, который светится",
desc: "Каждый микс собираем при тебе — густой дым красиво ловит неоновый свет.",
},
{
src: "./images/vip-room.jpg",
alt: "VIP-комната для компаний с неоновой подсветкой и большим диваном",
tag: "VIP-комната",
title: "VIP для своих",
desc: "Отдельная комната для компаний до 8 человек — со своей музыкой и атмосферой.",
},
];
export default function AtmosphereCarousel() {
const autoplay = useRef(
Autoplay({ delay: 4500, stopOnInteraction: false, stopOnMouseEnter: true })
);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [autoplay.current]);
const [selected, setSelected] = useState(0);
const onSelect = useCallback(() => {
if (emblaApi) setSelected(emblaApi.selectedScrollSnap());
}, [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
onSelect();
emblaApi.on("select", onSelect);
return () => {
emblaApi.off("select", onSelect);
};
}, [emblaApi, onSelect]);
const prev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
const next = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
return (
<section id="interior" className="relative overflow-hidden py-24 sm:py-32">
<div className="mx-auto max-w-7xl px-5 sm:px-8">
<Reveal>
<SectionHeading
tag="Интерьер"
title={
<>
Почувствуй наш <span className="text-glow-violet text-fuchsia-300">вайб</span>
</>
}
desc="Четыре пространства — четыре настроения. Листай и выбирай своё."
/>
</Reveal>
<Reveal delay={150}>
<div className="relative mt-14">
{/* carousel */}
<div ref={emblaRef} className="overflow-hidden">
<div className="flex">
{slides.map((s, i) => (
<div key={i} className="relative min-w-0 flex-[0_0_100%]">
<figure className="relative h-[440px] overflow-hidden rounded-[1.75rem] border border-white/10 sm:h-[560px]">
<img
src={s.src}
alt={s.alt}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-void/95 via-void/20 to-void/10" />
<span className="absolute right-6 top-5 font-display text-5xl font-extrabold text-white/15">
0{i + 1}
</span>
<figcaption className="glass absolute bottom-5 left-5 right-5 max-w-md rounded-2xl p-5 sm:bottom-7 sm:left-7 sm:right-auto sm:p-7">
<span className="text-[10px] font-bold uppercase tracking-[0.3em] text-neon-cyan/90">
{s.tag}
</span>
<h3 className="mt-2 font-display text-lg font-bold leading-snug text-white sm:text-2xl">
{s.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-mist/70">{s.desc}</p>
</figcaption>
</figure>
</div>
))}
</div>
</div>
{/* arrows */}
<button
onClick={prev}
aria-label="Предыдущий слайд"
className="glass absolute left-4 top-1/2 z-20 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white transition-all duration-300 hover:border-neon-cyan/50 hover:shadow-[0_0_24px_rgba(0,240,255,0.45)]"
>
<ChevronLeft size={22} />
</button>
<button
onClick={next}
aria-label="Следующий слайд"
className="glass absolute right-4 top-1/2 z-20 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white transition-all duration-300 hover:border-neon-cyan/50 hover:shadow-[0_0_24px_rgba(0,240,255,0.45)]"
>
<ChevronRight size={22} />
</button>
</div>
</Reveal>
{/* dots */}
<Reveal delay={250}>
<div className="mt-9 flex items-center justify-center gap-2.5">
{slides.map((_, i) => (
<button
key={i}
onClick={() => emblaApi?.scrollTo(i)}
aria-label={`Слайд ${i + 1}`}
className={cn(
"h-2 rounded-full transition-all duration-400",
selected === i
? "w-9 bg-gradient-to-r from-neon-violet to-neon-cyan shadow-[0_0_14px_rgba(176,38,255,0.75)]"
: "w-2 bg-white/20 hover:bg-white/40"
)}
/>
))}
</div>
</Reveal>
</div>
</section>
);
}
+235
View File
@@ -0,0 +1,235 @@
import { Clock, ExternalLink, MapPin, Phone } from "lucide-react";
import { Reveal, SectionHeading, VkIcon } from "./shared";
import { barMoment, isBarOpen } from "../lib/barHours";
const socials = [
{ label: "VK", href: "https://vk.ru/shelby_nyagan", icon: VkIcon },
];
function isOpenNow() {
// время бара (UTC+5), а не устройства гостя: гость из другого пояса
// должен видеть то же «Открыто/Закрыто», что и бар
return isBarOpen(barMoment());
}
export default function Contacts() {
const open = isOpenNow();
return (
<section id="contacts" className="relative overflow-hidden py-24 sm:py-32">
{/* ambient glows */}
<div className="pointer-events-none absolute -left-32 top-24 h-[420px] w-[420px] rounded-full bg-neon-violet/12 blur-[150px]" />
<div className="pointer-events-none absolute -right-24 bottom-10 h-[380px] w-[380px] rounded-full bg-neon-cyan/10 blur-[140px]" />
<div className="relative mx-auto max-w-7xl px-5 sm:px-8">
<Reveal>
<SectionHeading
tag="Контакты"
title={
<>
Мы <span className="text-glow-cyan text-cyan-300">ждём тебя</span>
</>
}
desc="Бронируй стол заранее — вечера в НЕОНе разлетаются быстро."
/>
</Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-[1fr_1.15fr]">
{/* info card */}
<Reveal delay={120}>
<div className="glass flex h-full flex-col justify-between gap-9 rounded-3xl p-5 sm:p-9">
<div className="space-y-7">
<div className="flex gap-4">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-neon-violet/15 text-neon-cyan">
<MapPin size={19} />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-mist/45">
Адрес
</p>
<p className="mt-1.5 font-semibold text-white">
проспект Нефтяников, 15, Нягань
</p>
<p className="mt-1 text-sm text-mist/55">
Ханты-Мансийский автономный округ
</p>
</div>
</div>
<div className="flex gap-4">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-neon-violet/15 text-neon-cyan">
<Phone size={19} />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-mist/45">
Телефон
</p>
<a
href="tel:+79129017217"
className="mt-1.5 block font-semibold text-white transition-colors hover:text-neon-cyan"
>
+7 (912) 901-72-17
</a>
<p className="mt-1 text-sm text-mist/55">звонок или сообщение</p>
</div>
</div>
<div className="flex gap-4">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-neon-violet/15 text-neon-cyan">
<Clock size={19} />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-mist/45">
Часы работы
</p>
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${
open
? "border-emerald-400/40 bg-emerald-400/10 text-emerald-300"
: "border-white/15 bg-white/5 text-mist/50"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
open ? "bg-emerald-400 animate-pulse-dot" : "bg-mist/40"
}`}
/>
{open ? "Открыто" : "Закрыто"}
</span>
</div>
<p className="mt-1.5 font-semibold text-white">
Пн · <span className="font-medium text-mist/50">выходной</span>
</p>
<p className="mt-1 font-semibold text-white">Вт–Чт, Вс · 19:00 — 01:00</p>
<p className="mt-1 font-semibold text-white">Пт–Сб · 19:00 — 03:00</p>
</div>
</div>
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-mist/45">
Мы в соцсети
</p>
<div className="mt-4 flex flex-wrap gap-3">
{socials.map((s) => (
<a
key={s.label}
href={s.href}
target="_blank"
rel="noreferrer"
className="glass inline-flex items-center gap-2.5 rounded-xl px-5 py-3 text-sm font-semibold text-mist/80 transition-all duration-300 hover:-translate-y-0.5 hover:border-neon-cyan/50 hover:text-white hover:shadow-[0_0_24px_rgba(0,240,255,0.35)]"
>
<s.icon size={17} className="text-neon-cyan" />
{s.label}
</a>
))}
</div>
</div>
</div>
</Reveal>
{/* stylized dark map */}
<Reveal delay={220}>
<div
role="region"
aria-label="Карта: как найти кальянный лаунж НЕОН в Нягани"
className="relative min-h-[440px] overflow-hidden rounded-3xl border border-white/10 bg-[#0c0c14] lg:h-full"
>
<div className="map-grid absolute inset-0" />
{/* streets & blocks */}
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 900 620"
preserveAspectRatio="xMidYMid slice"
aria-hidden
>
<defs>
<linearGradient id="avenueGlow" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor="#b026ff" />
<stop offset="100%" stopColor="#00f0ff" />
</linearGradient>
</defs>
{/* city blocks */}
<g fill="#10101a" stroke="#1c1c2a" strokeWidth="1.5">
<rect x="40" y="40" width="240" height="160" rx="10" />
<rect x="330" y="40" width="220" height="160" rx="10" />
<rect x="610" y="60" width="250" height="200" rx="10" />
<rect x="40" y="260" width="200" height="200" rx="10" />
<rect x="290" y="260" width="240" height="140" rx="10" />
<rect x="580" y="320" width="280" height="180" rx="10" />
<rect x="60" y="500" width="300" height="90" rx="10" />
<rect x="420" y="450" width="120" height="140" rx="10" />
</g>
{/* park */}
<g>
<rect x="290" y="440" width="200" height="140" rx="16" fill="#0d1f1b" stroke="#16302a" strokeWidth="1.5" />
<circle cx="340" cy="490" r="14" fill="#12251f" />
<circle cx="390" cy="520" r="18" fill="#12251f" />
<circle cx="440" cy="480" r="12" fill="#12251f" />
</g>
{/* streets */}
<g stroke="#20203a" strokeWidth="13" strokeLinecap="round">
<line x1="0" y1="230" x2="900" y2="230" />
<line x1="0" y1="420" x2="900" y2="420" />
<line x1="300" y1="0" x2="300" y2="620" />
<line x1="550" y1="0" x2="550" y2="620" />
</g>
{/* main glowing avenue */}
<line x1="-20" y1="620" x2="920" y2="60" stroke="#26264a" strokeWidth="26" strokeLinecap="round" />
<line x1="-20" y1="620" x2="920" y2="60" stroke="url(#avenueGlow)" strokeWidth="26" strokeLinecap="round" opacity="0.14" />
{/* river */}
<path
d="M -20 560 C 180 520, 300 600, 560 585 S 860 520, 920 440"
fill="none"
stroke="#0c2434"
strokeWidth="44"
strokeLinecap="round"
/>
</svg>
{/* vignette */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_35%,rgba(10,10,15,0.75)_100%)]" />
{/* neon marker */}
<div className="absolute left-1/2 top-[46%] -translate-x-1/2 -translate-y-1/2">
<span className="relative flex h-5 w-5">
<span className="absolute inline-flex h-full w-full rounded-full bg-neon-violet opacity-70 animate-ping-slow" />
<span className="relative inline-flex h-5 w-5 rounded-full border border-white/40 bg-gradient-to-br from-neon-violet to-neon-cyan shadow-[0_0_24px_rgba(176,38,255,0.95)]" />
</span>
</div>
{/* marker label */}
<div className="glass absolute left-1/2 top-[56%] flex max-w-[calc(100%-2.5rem)] -translate-x-1/2 items-center gap-2 rounded-full px-3.5 py-2 text-center text-[11px] font-semibold text-white sm:text-xs">
<MapPin size={12} className="shrink-0 text-neon-cyan" />
проспект Нефтяников, 15
</div>
{/* hint chip */}
<div className="glass absolute left-5 top-5 max-w-[230px] rounded-2xl px-4 py-3 text-xs leading-relaxed text-mist/70">
Мы на проспекте Нефтяников в Нягани — ищи фиолетовую вывеску НЕОН.
</div>
{/* link */}
<a
href="https://yandex.ru/maps/?text=%D0%9D%D1%8F%D0%B3%D0%B0%D0%BD%D1%8C%2C%20%D0%BF%D1%80%D0%BE%D1%81%D0%BF%D0%B5%D0%BA%D1%82%20%D0%9D%D0%B5%D1%84%D1%82%D1%8F%D0%BD%D0%B8%D0%BA%D0%BE%D0%B2%2C%2015"
target="_blank"
rel="noreferrer"
className="absolute bottom-5 right-5 inline-flex 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_28px_rgba(176,38,255,0.5)] transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_0_44px_rgba(176,38,255,0.75)]"
>
Открыть в Яндекс.Картах
<ExternalLink size={15} />
</a>
</div>
</Reveal>
</div>
</div>
</section>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { Component, type ReactNode } from "react";
type Props = { children: ReactNode };
type State = { error: Error | null };
/**
* Ловит ошибку рендера, чтобы вместо белого экрана гость видел заглушку.
* Данные приходят из статических файлов и CRM — при опечатке владельца
* в data-файлах сайт обязан деградировать, а не падать целиком.
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error) {
console.error("Ошибка интерфейса:", error);
}
render() {
if (this.state.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-void px-6 text-center">
<p className="font-display text-2xl font-bold text-white">Что-то сломалось</p>
<p className="max-w-md text-sm text-mist/65">
Перезагрузите страницу — обычно это помогает. Если ошибка повторяется,
позвоните нам: +7 (912) 901-72-17
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="min-h-11 cursor-pointer rounded-full border border-neon-cyan/60 bg-neon-cyan/10 px-6 py-2.5 text-sm font-bold uppercase tracking-wider text-cyan-100 transition-colors hover:bg-neon-cyan/20"
>
Перезагрузить
</button>
</div>
);
}
return this.props.children;
}
}
+89
View File
@@ -0,0 +1,89 @@
import { ArrowUpRight, Car, Coffee, Gamepad2, Wind } from "lucide-react";
import { Reveal, SectionHeading } from "./shared";
type Feature = {
icon: typeof Wind;
title: string;
desc: string;
link?: { label: string; href: string; external?: boolean };
};
const features: Feature[] = [
{
icon: Wind,
title: "80+ вкусов табака",
desc: "Большая коллекция: от классики до редких авторских миксов. Кальян от 1 400 ₽.",
link: { label: "Смотреть вкусы", href: "#/flavors" },
},
{
icon: Coffee,
title: "Авторский чай — 350 ₽",
desc: "Чайная карта, облепиха с имбирём и кофе, который хочется фотографировать.",
link: { label: "Чайная карта в меню", href: "#/menu" },
},
{
icon: Gamepad2,
title: "PS5 и настольные игры",
desc: "FIFA и Mortal Kombat на большом экране, а для компании — «Мафия», «Бункер», нарды и шахматы.",
link: { label: "Настольные игры", href: "#/board-games" },
},
{
icon: Car,
title: "Бесплатная парковка",
desc: "Своя парковка для гостей прямо у входа. Приезжайте на машинах — место найдётся.",
},
];
export default function Features() {
return (
<section id="features" className="relative overflow-hidden py-24 sm:py-32">
{/* faint orbs */}
<div className="pointer-events-none absolute left-1/2 top-0 h-[380px] w-[620px] -translate-x-1/2 rounded-full bg-neon-violet/8 blur-[160px]" />
<div className="relative mx-auto max-w-7xl px-5 sm:px-8">
<Reveal>
<SectionHeading
tag="Преимущества"
title={
<>
Почему <span className="text-glow-violet text-fuchsia-300">НЕОН</span>
</>
}
desc="Мелочей не бывает: мы продумали всё, чтобы твой вечер прошёл идеально."
/>
</Reveal>
<div className="mt-14 grid gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-4">
{features.map((f, i) => (
<Reveal key={f.title} delay={i * 110}>
<div className="group glass relative h-full overflow-hidden rounded-3xl p-7 transition-all duration-500 hover:-translate-y-2 hover:border-neon-violet/40 hover:shadow-[0_24px_70px_-20px_rgba(176,38,255,0.4)]">
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-neon-violet/10 blur-2xl transition-colors duration-500 group-hover:bg-neon-cyan/15" />
<div className="relative inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-gradient-to-br from-neon-violet/25 to-neon-cyan/10 text-neon-cyan shadow-[0_0_22px_rgba(0,240,255,0.15)] transition-all duration-500 group-hover:scale-110 group-hover:shadow-[0_0_34px_rgba(0,240,255,0.45)]">
<f.icon size={26} strokeWidth={1.7} />
</div>
<h3 className="relative mt-6 font-display text-lg font-bold text-white">
{f.title}
</h3>
<p className="relative mt-2.5 text-sm leading-relaxed text-mist/60">
{f.desc}
</p>
{f.link && (
<a
href={f.link.href}
{...(f.link.external
? { target: "_blank", rel: "noreferrer" }
: {})}
className="relative mt-4 inline-flex min-h-11 items-center gap-1.5 text-sm font-bold text-neon-cyan transition-all duration-300 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"
>
{f.link.label}
<ArrowUpRight size={15} aria-hidden="true" />
</a>
)}
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { Logo } from "./Header";
import { VkIcon } from "./shared";
const links = [
{ label: "Главная", href: "#home" },
{ label: "Интерьер", href: "#interior" },
{ label: "Цены", href: "#prices" },
{ label: "Меню", href: "#/menu" },
{ label: "Аренда залов", href: "#vip" },
{ label: "Статус заказа", href: "#/track" },
{ label: "Контакты", href: "#contacts" },
];
const socials = [
{ label: "VK", href: "https://vk.ru/shelby_nyagan", icon: VkIcon },
];
export default function Footer() {
return (
<footer className="overflow-hidden border-t border-white/5 bg-black/30">
<div className="mx-auto max-w-7xl px-5 py-12 sm:px-8">
<div className="flex flex-col items-center justify-between gap-7 sm:flex-row">
<Logo className="text-lg" />
<nav className="flex flex-wrap items-center justify-center gap-x-7 gap-y-3">
{links.map((l) => (
<a
key={l.href}
href={l.href}
className="text-sm font-semibold text-mist/50 transition-colors duration-300 hover:text-white"
>
{l.label}
</a>
))}
</nav>
<div className="flex items-center gap-3">
{socials.map((s) => (
<a
key={s.label}
href={s.href}
target="_blank"
rel="noreferrer"
aria-label={s.label}
className="glass flex h-10 w-10 items-center justify-center rounded-full text-mist/70 transition-all duration-300 hover:border-neon-cyan/50 hover:text-neon-cyan hover:shadow-[0_0_20px_rgba(0,240,255,0.4)]"
>
<s.icon size={16} />
</a>
))}
</div>
</div>
<div className="mt-10 flex flex-col items-center justify-between gap-4 border-t border-white/5 pt-7 text-xs text-mist/40 sm:flex-row">
<p>© 2026 Кальянный лаунж «НЕОН». Все права защищены.</p>
<p className="flex items-center gap-3">
<span className="rounded-full border border-white/10 px-2.5 py-1 font-bold tracking-wider text-mist/60">
18+
</span>
Сделано с любовью к дыму и свету
</p>
</div>
</div>
</footer>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useState } from "react";
import { Menu, X } from "lucide-react";
import { cn } from "../utils/cn";
const links = [
{ label: "Главная", href: "#home" },
{ label: "Интерьер", href: "#interior" },
{ label: "Цены", href: "#prices" },
{ label: "Аренда залов", href: "#vip" },
{ label: "Статус заказа", href: "#/track" },
{ label: "Контакты", href: "#contacts" },
];
const FORMER_NAME_DEADLINE = new Date("2027-01-01T00:00:00").getTime();
export function Logo({ className }: { className?: string }) {
// «(ex Shelby)» — прежнее название бара; подпись видна до конца 2026 года,
// после новогодней ночи клиент прячет её сам, пересборка не требуется.
// Изначально true, чтобы пререндеренный HTML и первая клиентская отрисовка
// совпадали (без hydration-мисматча); эффект убирает надпись после дедлайна.
const [showFormerName, setShowFormerName] = useState(true);
useEffect(() => {
if (Date.now() >= FORMER_NAME_DEADLINE) setShowFormerName(false);
}, []);
return (
<a
href="#home"
className={cn(
"group inline-flex flex-col font-display text-xl font-extrabold tracking-[0.18em] sm:text-2xl",
className,
)}
>
<span className="flex">
<span className="text-glow-violet text-white transition-all duration-300 group-hover:text-fuchsia-200">
НЕ
</span>
<span className="text-glow-cyan text-white transition-all duration-300 group-hover:text-cyan-200">
ОН
</span>
</span>
{showFormerName && (
<span className="mt-1 text-[9px] font-semibold tracking-[0.32em] text-mist/45 transition-colors duration-300 group-hover:text-mist/75">
(ex Shelby)
</span>
)}
</a>
);
}
export default function Header({ started }: { started: boolean }) {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40);
const closeMenu = () => setOpen(false);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("hashchange", closeMenu);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("hashchange", closeMenu);
};
}, []);
return (
<header
className={cn(
"fixed inset-x-0 top-0 z-50 transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]",
started ? "translate-y-0 opacity-100" : "-translate-y-6 opacity-0",
scrolled
? "border-b border-white/5 bg-void/80 py-3 shadow-[0_10px_45px_rgba(0,0,0,0.55)] backdrop-blur-xl"
: "bg-transparent py-5"
)}
>
<div className="mx-auto flex max-w-7xl items-center justify-between px-5 sm:px-8">
<Logo />
{/* desktop nav */}
<nav className="hidden items-center gap-6 md:flex lg:gap-8">
{links.map((l) => (
<a
key={l.href}
href={l.href}
className="group relative text-sm font-semibold text-mist/70 transition-colors duration-300 hover:text-white"
>
{l.label}
<span className="absolute -bottom-1.5 left-0 h-px w-full origin-left scale-x-0 bg-gradient-to-r from-neon-violet to-neon-cyan shadow-[0_0_10px_rgba(176,38,255,0.9)] transition-transform duration-300 group-hover:scale-x-100" />
</a>
))}
</nav>
<div className="flex items-center gap-3">
<a
href="#/menu"
className="rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-4 py-2.5 text-xs font-bold text-white shadow-[0_0_24px_rgba(176,38,255,0.4)] max-lg:shadow-[0_0_34px_6px_rgba(176,38,255,0.75)] menu-glow-mobile transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.65)] active:scale-[0.97] sm:px-5 sm:text-sm"
>
Меню
</a>
<a
href="#contacts"
className="hidden rounded-full border border-neon-violet/60 bg-neon-violet/10 px-5 py-2.5 text-sm font-bold text-white transition-all duration-300 hover:-translate-y-0.5 hover:border-neon-cyan hover:bg-neon-cyan/10 hover:shadow-[0_0_26px_rgba(0,240,255,0.45)] lg:inline-flex"
>
Забронировать стол
</a>
{/* mobile toggle */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? "Закрыть меню" : "Открыть меню"}
aria-expanded={open}
aria-controls="mobile-navigation"
className="glass flex h-11 w-11 items-center justify-center rounded-full text-white transition-colors hover:border-neon-cyan/40 md:hidden"
>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
</div>
{/* mobile panel */}
<div
id="mobile-navigation"
inert={!open}
className={cn(
"overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] md:hidden",
open ? "max-h-[34rem] opacity-100" : "max-h-0 opacity-0"
)}
>
<nav className="mx-5 mt-3 mb-4 flex flex-col gap-1 rounded-2xl border border-white/8 bg-void/95 p-4 backdrop-blur-xl">
{links.map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setOpen(false)}
className="rounded-xl px-4 py-3 text-sm font-semibold text-mist/80 transition-colors hover:bg-white/5 hover:text-white"
>
{l.label}
</a>
))}
<a
href="#/menu"
onClick={() => setOpen(false)}
className="mt-2 rounded-xl border border-neon-cyan/45 bg-neon-cyan/10 px-4 py-3 text-center text-sm font-bold text-white transition-shadow hover:shadow-[0_0_22px_rgba(0,240,255,0.4)]"
>
Меню (авторский чай)
</a>
<a
href="#contacts"
onClick={() => setOpen(false)}
className="rounded-xl border border-neon-violet/60 bg-neon-violet/15 px-4 py-3 text-center text-sm font-bold text-white transition-shadow hover:shadow-[0_0_22px_rgba(176,38,255,0.45)]"
>
Забронировать стол
</a>
</nav>
</div>
</header>
);
}
+151
View File
@@ -0,0 +1,151 @@
import type { ReactNode } from "react";
import { ArrowRight, Coffee, Flame, MapPin, Sparkles } from "lucide-react";
import { cn } from "../utils/cn";
import HeroActions from "./HeroActions";
function Enter({
show,
delay,
children,
className,
}: {
show: boolean;
delay: number;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"transition-all duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform",
show ? "translate-y-0 opacity-100" : "translate-y-9 opacity-0",
className
)}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
const marqueeItems = [
"Искусство дыма",
"Неоновый свет",
"Авторские миксы",
"Музыка до утра",
"PlayStation 5",
"Авторский чай",
];
export default function Hero({ started }: { started: boolean }) {
return (
<section id="home" tabIndex={-1} className="relative flex min-h-screen items-center overflow-hidden outline-none">
{/* background */}
<div className="absolute inset-0">
<img
src="./images/hero-bg.jpg"
alt="Тёмный интерьер кальянного лаунджа «НЕОН» с фиолетовой и циановой неоновой подсветкой"
className="h-full w-full object-cover opacity-60 animate-kenburns"
/>
<div className="absolute inset-0 bg-gradient-to-b from-void/75 via-void/30 to-void" />
<div className="absolute inset-0 bg-gradient-to-r from-void/85 via-transparent to-void/60" />
</div>
{/* neon orbs */}
<div className="pointer-events-none absolute -left-32 top-1/4 h-[480px] w-[480px] rounded-full bg-neon-violet/20 blur-[150px] animate-float-slow" />
<div className="pointer-events-none absolute -right-24 bottom-1/4 h-[420px] w-[420px] rounded-full bg-neon-cyan/12 blur-[140px] animate-float-slower" />
{/* headline light spill */}
<div className="pointer-events-none absolute -left-[10%] top-[6%] h-[460px] w-[72%] rounded-full bg-neon-violet/10 blur-[130px]" />
{/* content */}
<div className="relative z-10 mx-auto w-full max-w-7xl px-5 pb-44 pt-32 sm:px-8">
<Enter show={started} delay={100}>
<div className="glass inline-flex items-center gap-2.5 rounded-full px-4 py-2">
<Sparkles size={13} className="text-neon-cyan" />
<span className="text-[10px] font-bold uppercase tracking-[0.3em] text-mist/80 sm:text-[11px]">
Кальянный лаунж · Нягань
</span>
</div>
</Enter>
<h1 className="hero-breathe mt-7 font-display text-[clamp(1.85rem,8.2vw,4.5rem)] font-extrabold leading-[1.08] text-white">
<Enter show={started} delay={220}>
<span className="hero-glow-white">Атмосфера,</span>
</Enter>
<Enter show={started} delay={340}>
<span className="hero-glow-gradient block bg-gradient-to-r from-neon-violet via-fuchsia-300 to-neon-cyan bg-clip-text text-transparent">
которую видно
</span>
</Enter>
<Enter show={started} delay={460}>
<span className="text-outline hero-glow-outline block">в темноте</span>
</Enter>
</h1>
<Enter show={started} delay={580}>
<p className="mt-7 max-w-xl text-base leading-relaxed text-mist/75 sm:text-lg">
Авторские миксы и уютный лаунж в Нягани. Погрузись в
атмосферу НЕОНа — дым, свет и музыка до утра.
</p>
</Enter>
<Enter show={started} delay={700}>
<div className="mt-10 flex flex-wrap items-center gap-4">
<a
href="#/menu"
className="group inline-flex items-center gap-2.5 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-8 py-4 text-sm font-bold text-white shadow-[0_0_32px_rgba(176,38,255,0.45)] max-lg:shadow-[0_0_48px_8px_rgba(176,38,255,0.8)] menu-glow-mobile transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_0_55px_rgba(176,38,255,0.7)] active:scale-[0.97]"
>
Открыть меню
<ArrowRight size={17} className="transition-transform duration-300 group-hover:translate-x-1" />
</a>
<a
href="#contacts"
className="inline-flex items-center gap-2.5 rounded-full border border-neon-cyan/40 px-8 py-4 text-sm font-bold text-neon-cyan transition-all duration-300 hover:-translate-y-0.5 hover:bg-neon-cyan/10 hover:shadow-[0_0_32px_rgba(0,240,255,0.35)]"
>
<MapPin size={17} />
Как нас найти
</a>
</div>
</Enter>
<Enter show={started} delay={820}>
<HeroActions />
</Enter>
<Enter show={started} delay={920}>
<div className="mt-3.5 flex flex-wrap items-center gap-2.5">
<span className="inline-flex items-center gap-2 rounded-full border border-neon-violet/40 bg-neon-violet/10 py-2 pl-3 pr-4 shadow-[0_0_20px_-6px_rgba(176,38,255,0.6)] backdrop-blur-md">
<Flame size={14} className="shrink-0 text-fuchsia-200" />
<span className="text-xs font-semibold text-mist/70">Кальян от</span>
<span className="text-glow-violet whitespace-nowrap font-display text-sm font-extrabold text-white">
1 400 ₽
</span>
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/8 py-2 pl-3 pr-4 shadow-[0_0_20px_-6px_rgba(0,240,255,0.5)] backdrop-blur-md">
<Coffee size={14} className="shrink-0 text-neon-cyan" />
<span className="text-xs font-semibold text-mist/70">Чай</span>
<span className="text-glow-cyan whitespace-nowrap font-display text-sm font-extrabold text-white">
350 ₽
</span>
</span>
</div>
</Enter>
</div>
{/* marquee */}
<div className="absolute inset-x-0 bottom-0 z-10 overflow-hidden border-t border-white/5 bg-void/40 py-4 backdrop-blur-md">
<div className="flex w-max animate-marquee">
{[...marqueeItems, ...marqueeItems].map((t, i) => (
<span
key={i}
className="mx-5 flex items-center gap-10 whitespace-nowrap font-display text-[11px] uppercase tracking-[0.4em] text-mist/35 sm:text-xs"
>
{t}
<span className="h-1.5 w-1.5 rotate-45 bg-neon-violet/80 shadow-[0_0_10px_rgba(176,38,255,0.9)]" />
</span>
))}
</div>
</div>
</section>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { ArrowUpRight, Bot, Dices, Flame, Gamepad2, Sparkles } from "lucide-react";
import { cn } from "../utils/cn";
import { boardGames, hookahMixes, tobaccoFlavors } from "../data/catalog";
type Chip = {
href: string;
icon: typeof Dices;
label: string;
tone: "violet" | "cyan";
};
/* Подсветка постоянная (не только hover): на тач-устройствах hover нет,
иначе чипы там выглядят «не подсвеченными». Тона чередуем. */
const toneStyles = {
violet:
"border-neon-violet/50 bg-neon-violet/10 text-white shadow-[0_0_16px_-4px_rgba(176,38,255,0.55)] hover:border-neon-violet hover:bg-neon-violet/20 hover:shadow-[0_0_28px_-2px_rgba(176,38,255,0.8)]",
cyan: "border-neon-cyan/40 bg-neon-cyan/8 text-white shadow-[0_0_16px_-4px_rgba(0,240,255,0.5)] hover:border-neon-cyan hover:bg-neon-cyan/15 hover:shadow-[0_0_28px_-2px_rgba(0,240,255,0.75)]",
} as const;
const toneIcon = {
violet: { icon: "text-fuchsia-300", arrow: "text-fuchsia-200/70" },
cyan: { icon: "text-neon-cyan", arrow: "text-cyan-200/70" },
} as const;
/* 51 микс / 52 микса / 55 миксов — русское множественное для счётчика */
const mixPlural = (n: number): string => {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return `${n} микс`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${n} микса`;
return `${n} миксов`;
};
const chips: Chip[] = [
{ href: "#/mixes", icon: Sparkles, label: mixPlural(hookahMixes.length), tone: "violet" },
{ href: "#/flavors", icon: Flame, label: `${tobaccoFlavors.length}+ вкусов табака`, tone: "cyan" },
{ href: "#/games", icon: Gamepad2, label: "PlayStation 5", tone: "violet" },
{ href: "#/board-games", icon: Dices, label: `${boardGames.length} настолок`, tone: "cyan" },
];
export default function HeroActions() {
return (
<div className="mt-10 flex flex-wrap gap-3">
<a
href="#/ai-mix"
className="group inline-flex min-h-11 max-w-full items-center gap-2.5 rounded-full border border-neon-cyan/60 bg-gradient-to-r from-neon-violet/25 via-neon-cyan/20 to-neon-violet/15 px-4.5 py-2.5 text-sm font-bold text-white backdrop-blur-lg shadow-[0_0_24px_rgba(0,240,255,0.35)] transition-all duration-300 hover:border-neon-cyan hover:shadow-[0_0_38px_rgba(0,240,255,0.65)] hover:-translate-y-0.5 active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<Bot size={16} className="shrink-0 text-neon-cyan" aria-hidden="true" />
<span>Автоподбор микса</span>
<ArrowUpRight
size={14}
className="shrink-0 text-cyan-200 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</a>
{chips.map((chip) => (
<a
key={chip.href}
href={chip.href}
className={cn(
"group inline-flex min-h-11 max-w-full items-center gap-2.5 rounded-full border px-4 py-2.5 text-sm font-semibold text-white backdrop-blur-lg transition-all duration-300 hover:-translate-y-0.5 active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan",
toneStyles[chip.tone]
)}
>
<chip.icon size={15} className={cn("shrink-0", toneIcon[chip.tone].icon)} aria-hidden="true" />
<span>{chip.label}</span>
<ArrowUpRight
size={14}
className={cn(
"shrink-0 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5",
toneIcon[chip.tone].arrow
)}
aria-hidden="true"
/>
</a>
))}
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useCallback, useEffect, useRef, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import Autoplay from "embla-carousel-autoplay";
import { ArrowRight, Bot, ChevronLeft, ChevronRight, Coffee, Flame, Sparkles } from "lucide-react";
import { cn } from "../utils/cn";
import { Reveal, SectionHeading } from "./shared";
import { signatureMixes, strengthLabels, type SignatureMix } from "../data/catalog";
const items = signatureMixes;
function MenuCard({ item, index }: { item: SignatureMix; index: number }) {
return (
<article className="group relative h-full rounded-3xl bg-gradient-to-br from-neon-violet/50 via-white/10 to-neon-cyan/40 p-[1px] transition-all duration-500 hover:-translate-y-2 hover:shadow-[0_24px_70px_-18px_rgba(176,38,255,0.4)]">
<div className="holo-sheen relative flex h-full flex-col rounded-[calc(1.5rem-1px)] bg-panel/85 p-5 backdrop-blur-xl sm:p-7">
<span className="pointer-events-none absolute right-5 top-4 select-none font-display text-6xl font-extrabold text-white/[0.05]">
0{index + 1}
</span>
{item.badge && (
<span
className={cn(
"inline-flex w-fit items-center rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-[0.2em]",
item.badge.hot
? "border-neon-violet/60 bg-neon-violet/15 text-fuchsia-200 shadow-[0_0_18px_rgba(176,38,255,0.35)]"
: "border-white/15 bg-white/5 text-mist/60"
)}
>
{item.badge.label}
</span>
)}
<h3 className="mt-5 font-display text-lg font-bold leading-snug text-white transition-colors duration-300 group-hover:text-cyan-100 sm:text-xl">
{item.name}
</h3>
<p className="mt-3 flex-1 text-sm leading-relaxed text-mist/65">{item.desc}</p>
{/* strength */}
<div className="mt-6 flex items-center gap-2.5">
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-mist/45">
Крепость · {strengthLabels[item.strength]}
</span>
<span className="flex items-center gap-1.5">
{[1, 2, 3].map((d) => (
<span
key={d}
className={cn(
"h-1.5 w-1.5 rounded-full",
d <= item.strength
? "bg-neon-cyan shadow-[0_0_8px_rgba(0,240,255,0.9)]"
: "bg-white/15"
)}
/>
))}
</span>
</div>
<div className="mt-5 h-px bg-gradient-to-r from-neon-violet/40 via-white/10 to-neon-cyan/40" />
<div className="mt-5 flex items-center justify-between">
<span className="font-display text-2xl font-bold text-white text-glow-cyan">
{item.price}
</span>
<a
href="#contacts"
aria-label={`Забронировать и попробовать ${item.name}`}
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 text-mist/70 transition-all duration-300 group-hover:border-neon-cyan/60 group-hover:text-neon-cyan group-hover:shadow-[0_0_20px_rgba(0,240,255,0.45)]"
>
<ArrowRight size={18} />
</a>
</div>
</div>
</article>
);
}
export default function MenuCarousel() {
const autoplay = useRef(
Autoplay({ delay: 5200, stopOnInteraction: false, stopOnMouseEnter: true })
);
const [emblaRef, emblaApi] = useEmblaCarousel(
{ loop: true, align: "start" },
[autoplay.current]
);
const [canPrev, setCanPrev] = useState(true);
const [canNext, setCanNext] = useState(true);
const prev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
const next = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
const update = () => {
setCanPrev(emblaApi.canScrollPrev());
setCanNext(emblaApi.canScrollNext());
};
update();
emblaApi.on("select", update);
return () => {
emblaApi.off("select", update);
};
}, [emblaApi]);
return (
<section id="prices" className="relative overflow-hidden py-24 sm:py-32">
{/* smoky backdrop */}
<div className="absolute inset-0">
<img
src="./images/menu-bg.jpg"
alt=""
aria-hidden
className="h-full w-full object-cover opacity-25"
/>
<div className="absolute inset-0 bg-gradient-to-b from-void via-void/70 to-void" />
</div>
<div className="relative mx-auto max-w-7xl px-5 sm:px-8">
<div className="flex flex-col gap-8 sm:flex-row sm:items-end sm:justify-between">
<Reveal>
<SectionHeading
align="left"
tag="Меню"
title={
<>
Что <span className="text-glow-cyan text-cyan-300">попробовать</span>
</>
}
desc="Авторские миксы от наших кальянных мастеров. Не нашёл своё — соберём любой вкус под тебя. Цена единая — без сюрпризов в чеке."
/>
</Reveal>
<Reveal delay={120} className="hidden sm:block">
<div className="flex gap-3">
<button
onClick={prev}
disabled={!canPrev}
aria-label="Назад"
className="glass flex h-12 w-12 items-center justify-center rounded-full text-white transition-all duration-300 hover:border-neon-violet/60 hover:shadow-[0_0_24px_rgba(176,38,255,0.45)] disabled:opacity-40 disabled:hover:shadow-none"
>
<ChevronLeft size={22} />
</button>
<button
onClick={next}
disabled={!canNext}
aria-label="Вперёд"
className="glass flex h-12 w-12 items-center justify-center rounded-full text-white transition-all duration-300 hover:border-neon-cyan/60 hover:shadow-[0_0_24px_rgba(0,240,255,0.45)] disabled:opacity-40 disabled:hover:shadow-none"
>
<ChevronRight size={22} />
</button>
</div>
</Reveal>
</div>
<Reveal delay={150}>
<div className="mt-10 grid gap-3 sm:grid-cols-2 sm:gap-4">
<div className="relative overflow-hidden rounded-3xl border border-neon-violet/40 bg-gradient-to-br from-neon-violet/25 via-panel/90 to-panel/90 p-5 shadow-[0_0_45px_-12px_rgba(176,38,255,0.6)] sm:p-7">
<div className="pointer-events-none absolute -right-12 -top-12 h-40 w-40 rounded-full bg-neon-violet/30 blur-3xl" />
<div className="relative flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="flex flex-1 basis-[190px] items-center gap-3 sm:gap-4">
<span className="flex shrink-0 items-center justify-center rounded-2xl border border-neon-violet/50 bg-neon-violet/20 p-2.5 text-fuchsia-200 shadow-[0_0_22px_rgba(176,38,255,0.5)] sm:p-3.5">
<Flame size={20} className="sm:hidden" />
<Flame size={24} className="hidden sm:block" />
</span>
<div>
<p className="font-display text-base font-bold text-white sm:text-xl">
Кальян от
</p>
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-mist/55 sm:text-xs sm:tracking-[0.2em]">
от 1 400 ₽
</p>
</div>
</div>
<span className="text-glow-violet ml-auto shrink-0 whitespace-nowrap font-display text-2xl font-extrabold text-white sm:text-4xl">
1 400 ₽
</span>
</div>
</div>
<div className="relative overflow-hidden rounded-3xl border border-neon-cyan/35 bg-gradient-to-br from-neon-cyan/15 via-panel/90 to-panel/90 p-5 shadow-[0_0_45px_-12px_rgba(0,240,255,0.45)] sm:p-7">
<div className="pointer-events-none absolute -right-12 -top-12 h-40 w-40 rounded-full bg-neon-cyan/20 blur-3xl" />
<div className="relative flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="flex flex-1 basis-[190px] items-center gap-3 sm:gap-4">
<span className="flex shrink-0 items-center justify-center rounded-2xl border border-neon-cyan/50 bg-neon-cyan/10 p-2.5 text-neon-cyan shadow-[0_0_22px_rgba(0,240,255,0.4)] sm:p-3.5">
<Coffee size={20} className="sm:hidden" />
<Coffee size={24} className="hidden sm:block" />
</span>
<div>
<p className="font-display text-base font-bold text-white sm:text-xl">
Авторский чай
</p>
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-mist/55 sm:text-xs sm:tracking-[0.2em]">
Чайник на компанию
</p>
</div>
</div>
<span className="text-glow-cyan ml-auto shrink-0 whitespace-nowrap font-display text-2xl font-extrabold text-white sm:text-4xl">
350 ₽
</span>
</div>
</div>
</div>
</Reveal>
<Reveal delay={180}>
<div ref={emblaRef} className="mt-10 cursor-grab overflow-hidden active:cursor-grabbing">
<div className="-ml-4 flex pb-3">
{items.map((item, i) => (
<div
key={i}
className="min-w-0 shrink-0 grow-0 basis-[88%] pl-4 pr-6 sm:basis-[58%] lg:basis-[36%] xl:basis-[31%]"
>
<MenuCard item={item} index={i} />
</div>
))}
</div>
</div>
</Reveal>
<Reveal delay={260}>
<div className="mt-10 flex flex-col items-center justify-between gap-5 sm:flex-row">
<p className="max-w-md text-center text-xs leading-relaxed text-mist/45 sm:text-left">
* Кальян от <span className="font-bold text-mist/70">1 400 ₽</span>,
чай — <span className="font-bold text-mist/70">350 ₽</span>.
</p>
<div className="flex flex-wrap justify-center gap-3 sm:justify-end">
<a
href="#/ai-mix"
className="inline-flex items-center gap-2 rounded-full border border-neon-cyan/60 bg-gradient-to-r from-neon-violet/25 via-neon-cyan/25 to-neon-violet/20 px-6 py-3.5 text-sm font-bold text-white shadow-[0_0_25px_rgba(0,240,255,0.35)] transition-all duration-300 hover:-translate-y-0.5 hover:border-neon-cyan hover:shadow-[0_0_38px_rgba(0,240,255,0.6)]"
>
<Bot size={16} className="text-neon-cyan" />
Автоподбор микса
<Sparkles size={14} className="text-cyan-200" />
</a>
<a
href="#/mixes"
className="inline-flex items-center gap-2.5 rounded-full border border-neon-violet/50 bg-neon-violet/10 px-6 py-3.5 text-sm font-bold text-white transition-all duration-300 hover:-translate-y-0.5 hover:border-neon-cyan hover:shadow-[0_0_28px_rgba(0,240,255,0.4)]"
>
Все миксы
<ArrowRight size={16} />
</a>
<a
href="#/menu"
className="inline-flex items-center gap-2.5 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-7 py-3.5 text-sm font-bold text-white shadow-[0_0_25px_rgba(176,38,255,0.4)] transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_0_40px_rgba(176,38,255,0.65)]"
>
Карта меню
<ArrowRight size={16} />
</a>
<a
href="#contacts"
className="inline-flex items-center gap-2.5 rounded-full border border-neon-violet/50 bg-neon-violet/10 px-7 py-3.5 text-sm font-bold text-white transition-all duration-300 hover:-translate-y-0.5 hover:border-neon-cyan hover:shadow-[0_0_28px_rgba(0,240,255,0.4)]"
>
Забронировать
</a>
</div>
</div>
</Reveal>
</div>
</section>
);
}
+704
View File
@@ -0,0 +1,704 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
AlertTriangle,
Check,
ChevronDown,
ClipboardCopy,
Clock,
Minus,
Plus,
Search,
Send,
ShoppingBag,
Trash2,
X,
} from "lucide-react";
import { cn } from "../utils/cn";
import { useBarOrderWindow } from "../lib/barHours";
import { submitOrder, useTableOptions, type SubmittedOrder } from "../lib/crm";
export interface MenuCartItem {
cartId: string;
itemId: string;
name: string;
category: string;
portion?: string;
price: number;
quantity: number;
}
interface MenuCartProps {
items: MenuCartItem[];
open: boolean;
/** id позиций, ушедших в стоп-лист CRM (их нельзя заказать) */
stoppedItems: ReadonlySet<string>;
onToggle: () => void;
onQuantityChange: (cartId: string, delta: number) => void;
onRemoveItem: (cartId: string) => void;
onClear: () => void;
}
/** Статичный список столиков: fallback, когда CRM недоступна. Живой
* справочник залов ведёт владелец в CRM (site грузит его через useTableOptions). */
const TABLE_OPTIONS = [
"Стол 1", "Стол 2", "Стол 3", "Стол 4", "Стол 5",
"VIP-зал", "Кинозал",
];
// стол обязателен: без выбранного столика заказ не отправить
const tableChoicesOf = (tables: string[]): { value: string; label: string }[] => [
{ value: "", label: "Выберите столик" },
...tables.map((option) => ({ value: option, label: option })),
];
/**
* Кастомный выбор столика вместо нативного <select>: список нативного
* селекта рисует браузер и стилям не подчиняется (белый фон, синяя
* подсветка поверх тёмной темы). Здесь — свой listbox в стиле НЕОНа,
* с клавиатурой и aria. Открывается вверх: у корзины поле у нижнего края.
*/
function TableSelect({
value,
onChange,
error,
choices,
}: {
value: string;
onChange: (value: string) => void;
error?: boolean;
choices: { value: string; label: string }[];
}) {
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
const openList = () => {
setActiveIdx(Math.max(0, choices.findIndex((option) => option.value === value)));
setOpen(true);
};
const choose = (idx: number) => {
onChange(choices[idx].value);
setOpen(false);
};
const onTriggerKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (!open) {
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
openList();
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIdx((idx) => (idx + 1) % choices.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIdx((idx) => (idx - 1 + choices.length) % choices.length);
} else if (event.key === "Home") {
event.preventDefault();
setActiveIdx(0);
} else if (event.key === "End") {
event.preventDefault();
setActiveIdx(choices.length - 1);
} else if (event.key === "Enter") {
event.preventDefault();
choose(activeIdx);
} else if (event.key === "Escape") {
setOpen(false);
}
};
const current = choices.find((option) => option.value === value) ?? choices[0];
return (
<div ref={rootRef} className="relative col-span-2 min-w-0">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="Столик"
aria-invalid={error || undefined}
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={onTriggerKeyDown}
className={cn(
"flex h-11 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border bg-white/[0.04] px-3.5 text-sm outline-none transition-all",
error
? "border-red-400/60 shadow-[0_0_0_3px_rgba(248,113,113,0.12)]"
: open
? "border-neon-cyan/60 shadow-[0_0_18px_rgba(0,240,255,0.12)]"
: "border-white/15 hover:border-white/25 focus-visible:border-neon-cyan/60",
)}
>
<span className={cn("truncate", value === "" ? "text-mist/40" : "text-white")}>
{current.label}
</span>
<ChevronDown
size={16}
className={cn("shrink-0 text-mist/50 transition-transform", open && "rotate-180")}
aria-hidden="true"
/>
</button>
{open && (
<ul
role="listbox"
aria-label="Столик"
className="absolute bottom-full left-0 z-30 mb-2 w-full overflow-hidden rounded-xl border border-neon-cyan/35 bg-panel/95 py-1.5 shadow-[0_18px_50px_rgba(0,0,0,0.65)] backdrop-blur-xl"
>
{choices.map((option, idx) => {
const selected = option.value === value;
const highlighted = idx === activeIdx;
return (
<li key={option.value} role="none">
<button
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => setActiveIdx(idx)}
onClick={() => choose(idx)}
className={cn(
"flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-2.5 text-left text-sm transition-colors",
highlighted ? "bg-neon-cyan/10 text-white" : "text-mist/85",
)}
>
<span className="truncate">{option.label}</span>
{selected && <Check size={15} className="shrink-0 text-neon-cyan" aria-hidden="true" />}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
/** NF-15: сколько дней хранить в localStorage PII гостя (имя/телефон/стол)
* и снимок последнего заказа — на общем устройстве запись не должна жить вечно */
const GUEST_INFO_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/** имя/телефон/стол с прошлого заказа — гость не заполняет их повторно */
function readGuestInfo(): { name: string; phone: string; table: string } {
if (typeof localStorage === "undefined") return { name: "", phone: "", table: "" };
try {
const raw = localStorage.getItem("neon-guest-info");
if (!raw) return { name: "", phone: "", table: "" };
const v = JSON.parse(raw) as Record<string, unknown>;
// NF-15: записи без отметки времени (старые) или старше 30 дней — стираем
const ts = typeof v.ts === "number" ? v.ts : 0;
if (!ts || Date.now() - ts > GUEST_INFO_TTL_MS) {
localStorage.removeItem("neon-guest-info");
return { name: "", phone: "", table: "" };
}
return {
name: typeof v.name === "string" ? v.name : "",
phone: typeof v.phone === "string" ? v.phone : "",
table: typeof v.table === "string" ? v.table : "",
};
} catch {
return { name: "", phone: "", table: "" };
}
}
/** снимок последнего отправленного заказа — для кнопки «Повторить» */
export function saveLastOrderSnapshot(items: MenuCartItem[], table: string) {
if (typeof localStorage === "undefined") return;
localStorage.setItem(
"neon-last-order",
JSON.stringify({
at: Date.now(),
table,
items: items.map((i) => ({
itemId: i.itemId,
name: i.name,
category: i.category,
portion: i.portion,
price: i.price,
quantity: i.quantity,
})),
}),
);
}
type SubmitState =
| { phase: "idle" }
| { phase: "sending" }
| { phase: "done"; order: SubmittedOrder }
| { phase: "error"; message: string };
export default function MenuCart({
items,
open,
stoppedItems,
onToggle,
onQuantityChange,
onRemoveItem,
onClear,
}: MenuCartProps) {
const [copied, setCopied] = useState(false);
const [remembered] = useState(readGuestInfo);
const [guestName, setGuestName] = useState(remembered.name);
const [guestPhone, setGuestPhone] = useState(remembered.phone);
const [comment, setComment] = useState("");
// honeypot: человек поле не видит (уведено за экран), боты заполняют
const [hpCheck, setHpCheck] = useState("");
// живой справочник столиков из CRM (fallback — статичный список из кода)
const tables = useTableOptions(TABLE_OPTIONS);
// «заказ по времени»: пока бар закрыт (до окна открытия) заказ не отправить.
// Сервер проверяет то же самое — здесь показываем причину гостю заранее.
const barWindow = useBarOrderWindow();
const ordersBlocked = barWindow.state === "closed";
// стол обязателен: старая запись «Сайт» из localStorage не считается выбором
const [table, setTable] = useState(remembered.table);
const [tableError, setTableError] = useState(false);
const [submitState, setSubmitState] = useState<SubmitState>({ phase: "idle" });
const handleTableChange = (value: string) => {
setTable(value);
if (value) setTableError(false);
};
// сохранённый столик удалён из справочника CRM — сбрасываем выбор
useEffect(() => {
if (table && !tables.includes(table)) {
setTable("");
setTableError(false);
}
}, [table, tables]);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onToggle();
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [onToggle, open]);
const orderableItems = items.filter((item) => !stoppedItems.has(item.itemId));
const stoppedInCart = items.filter((item) => stoppedItems.has(item.itemId));
const orderableTotal = orderableItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
const copyOrder = async () => {
// копия — fallback «заказать у официанта»: только доступные позиции,
// как в реальной отправке, иначе у официанта завышенная сумма
const lines = orderableItems.map((item) => {
const lineTotal = item.price * item.quantity;
const portion = item.portion ? `, ${item.portion}` : "";
return `- ${item.name}${portion} × ${item.quantity} = ${lineTotal.toLocaleString("ru-RU")} ₽`;
});
const text = ["НЕОН — заказ", "", ...lines, "", `Итого: ${orderableTotal.toLocaleString("ru-RU")} ₽`].join("\n");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
const sendOrder = async () => {
if (orderableItems.length === 0 || submitState.phase === "sending" || ordersBlocked) return;
// без столика из справочника заказ не отправляем: гость должен
// указать, куда нести (столик выбирается из живого списка CRM)
if (!tables.includes(table)) {
setTableError(true);
return;
}
setSubmitState({ phase: "sending" });
try {
const order = await submitOrder({
tableName: table,
guestName: guestName.trim() || undefined,
guestPhone: guestPhone.trim() || undefined,
comment: comment.trim() || undefined,
hpCheck: hpCheck || undefined,
items: orderableItems.map((item) => ({
id: item.itemId,
name: item.portion ? `${item.name} (${item.portion})` : item.name,
price: item.price,
qty: item.quantity,
})),
});
if (order.code) localStorage.setItem("neon-last-track-code", order.code);
// запоминаем контакты и состав: в следующий раз гость повторяет заказ
// одной кнопкой, ничего не набирая заново (NF-15: с отметкой времени —
// запись старше 30 дней стирается при чтении)
localStorage.setItem(
"neon-guest-info",
JSON.stringify({ ts: Date.now(), name: guestName.trim(), phone: guestPhone.trim(), table }),
);
saveLastOrderSnapshot(orderableItems, table);
setSubmitState({ phase: "done", order });
onClear();
setComment("");
} catch (error) {
setSubmitState({
phase: "error",
message: error instanceof Error ? error.message : "Не удалось отправить заказ",
});
}
};
const removeStopped = () => {
stoppedInCart.forEach((item) => onRemoveItem(item.cartId));
};
const resetSubmitState = () => setSubmitState({ phase: "idle" });
const inputClass =
"h-11 w-full rounded-xl border border-white/15 bg-white/[0.04] px-3.5 text-sm text-white outline-none transition-all placeholder:text-mist/35 focus:border-neon-cyan/60";
return (
<>
<button
type="button"
onClick={() => {
resetSubmitState();
onToggle();
}}
className="fixed bottom-5 right-5 z-50 inline-flex min-h-13 items-center gap-2 rounded-full border border-neon-cyan/50 bg-panel/95 px-5 py-3 text-sm font-bold text-white shadow-[0_0_28px_rgba(0,240,255,0.25)] backdrop-blur-xl transition-all hover:-translate-y-0.5 hover:border-neon-cyan hover:shadow-[0_0_38px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neon-cyan sm:bottom-7 sm:right-7"
aria-label="Открыть корзину меню"
>
<ShoppingBag size={18} className="text-neon-cyan" aria-hidden="true" />
<span>Корзина</span>
<span className="inline-flex min-w-6 items-center justify-center rounded-full bg-neon-cyan/15 px-2 py-0.5 text-xs text-cyan-200">
{stoppedInCart.length > 0
? `${orderableItems.length}+${stoppedInCart.length}`
: orderableItems.length}
</span>
</button>
{open && (
<div className="fixed inset-0 z-[60] flex justify-end" role="dialog" aria-modal="true" aria-label="Корзина меню">
<button
type="button"
onClick={onToggle}
className="absolute inset-0 cursor-default bg-black/65 backdrop-blur-[2px]"
aria-label="Закрыть корзину"
/>
<aside className="relative z-10 flex h-full w-full max-w-xl flex-col border-l border-white/10 bg-void/95 shadow-[-20px_0_60px_rgba(0,0,0,0.45)] backdrop-blur-xl">
<header className="flex items-center justify-between border-b border-white/10 px-5 py-4 sm:px-6">
<div>
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.18em] text-neon-cyan">
<ShoppingBag size={15} aria-hidden="true" />
Заказ на стол
</div>
<h2 className="mt-1 font-display text-xl font-bold text-white">Корзина</h2>
</div>
<button
type="button"
onClick={onToggle}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/[0.03] text-mist/60 transition-all hover:border-white/20 hover:bg-white/[0.06] hover:text-white"
aria-label="Закрыть корзину"
>
<X size={18} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 sm:px-6">
{submitState.phase === "done" ? (
<div className="flex min-h-64 flex-col items-center justify-center text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-emerald-400/40 bg-emerald-400/10 text-emerald-300">
<Check size={26} />
</div>
<h3 className="mt-4 font-display text-base font-bold text-white">
Заказ №{submitState.order.number} принят!
</h3>
{submitState.order.code && (
<>
<p className="mt-3 text-xs text-mist/55">Код отслеживания заказа</p>
<p className="mt-1 select-all rounded-xl border border-neon-cyan/40 bg-neon-cyan/10 px-5 py-2.5 font-display text-2xl font-bold tracking-[0.3em] text-cyan-100 shadow-[0_0_24px_rgba(0,240,255,0.25)]">
{submitState.order.code}
</p>
</>
)}
<p className="mt-3 max-w-xs text-xs leading-relaxed text-mist/55">
{submitState.order.code
? "По нему видно, на каком этапе заказ. Покажите код официанту — он найдёт заказ быстрее."
: "Бар уже видит его в CRM. Официант подойдёт для подтверждения — сохраните номер заказа."}
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
{submitState.order.code && (
<a
href={`#/track?code=${submitState.order.code}`}
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-gradient-to-r from-neon-cyan to-[#00b8d4] px-5 py-2.5 text-xs font-bold uppercase tracking-wider text-[#04252b] transition-all hover:shadow-[0_0_24px_rgba(0,240,255,0.4)]"
>
<Search size={14} aria-hidden="true" />
Отследить заказ
</a>
)}
<button
type="button"
onClick={resetSubmitState}
className="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>
) : items.length === 0 ? (
<div className="flex min-h-64 flex-col items-center justify-center text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-mist/40">
<ShoppingBag size={24} />
</div>
<h3 className="mt-4 font-display text-base font-bold text-white">Корзина пуста</h3>
<p className="mt-2 max-w-xs text-xs leading-relaxed text-mist/50">
Добавьте позиции из меню — заказ соберётся здесь автоматически.
</p>
</div>
) : (
<div className="space-y-2.5">
{items.map((item) => {
const stopped = stoppedItems.has(item.itemId);
return (
<div
key={item.cartId}
className={
stopped
? "rounded-xl border border-red-400/25 bg-red-400/[0.04] p-3 opacity-75"
: "rounded-xl border border-white/8 bg-black/15 p-3 transition-colors hover:border-white/12"
}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">{item.name}</p>
<p className="mt-0.5 text-[11px] text-mist/45">
{item.category}{item.portion ? ` · ${item.portion}` : ""}
{stopped && <span className="text-red-300"> · нет в наличии</span>}
</p>
</div>
<span className="shrink-0 text-sm font-extrabold text-white">
{(item.price * item.quantity).toLocaleString("ru-RU")} ₽
</span>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
{stopped ? (
<button
type="button"
onClick={removeStopped}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-red-400/30 bg-red-400/10 px-3 text-[11px] font-bold uppercase tracking-wider text-red-200 transition-all hover:bg-red-400/15"
>
<Trash2 size={13} />
Убрать из заказа
</button>
) : (
<span />
)}
<div className="inline-flex items-center rounded-lg border border-white/10 bg-white/[0.03]">
<button
type="button"
onClick={() => onQuantityChange(item.cartId, -1)}
className="flex h-9 w-9 items-center justify-center text-mist/55 transition-colors hover:text-white"
aria-label={`Уменьшить количество ${item.name}`}
>
<Minus size={14} />
</button>
<span className="w-7 text-center text-xs font-bold text-white">{item.quantity}</span>
<button
type="button"
onClick={() => onQuantityChange(item.cartId, 1)}
className="flex h-9 w-9 items-center justify-center text-mist/55 transition-colors hover:text-white"
aria-label={`Увеличить количество ${item.name}`}
>
<Plus size={14} />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<footer className="border-t border-white/10 bg-panel/80 px-5 py-4 sm:px-6">
{submitState.phase === "done" ? (
<div className="text-center text-xs text-mist/55">Заказ передан в бар ✓</div>
) : (
<>
{/* «заказ по времени»: расписание бара (UTC+5); сервер проверяет то же */}
{barWindow.state === "closed" && (
<div
className="mb-3 flex items-start gap-2 rounded-xl border border-amber-400/30 bg-amber-400/[0.07] px-3 py-2.5 text-[11px] leading-relaxed text-amber-200"
role="alert"
>
<Clock size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{barWindow.hint}</span>
</div>
)}
{barWindow.state === "preorder" && (
<div className="mb-3 flex items-start gap-2 rounded-xl border border-neon-cyan/30 bg-neon-cyan/[0.07] px-3 py-2.5 text-[11px] leading-relaxed text-cyan-200">
<Clock size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{barWindow.hint}</span>
</div>
)}
{stoppedInCart.length > 0 && (
<div className="mb-3 flex items-start gap-2 rounded-xl border border-amber-400/30 bg-amber-400/[0.07] px-3 py-2.5 text-[11px] leading-relaxed text-amber-200">
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
{stoppedInCart.length === 1
? `«${stoppedInCart[0].name}» ушла в стоп-лист — она не будет отправлена и не войдёт в сумму.`
: `${stoppedInCart.length} позиций из корзины ушли в стоп-лист — они не будут отправлены и не войдут в сумму.`}
</span>
</div>
)}
{submitState.phase === "error" && (
<div className="mb-3 flex items-start gap-2 rounded-xl border border-red-400/30 bg-red-400/[0.07] px-3 py-2.5 text-[11px] leading-relaxed text-red-200" role="alert">
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{submitState.message} Можно скопировать заказ и назвать его официанту.</span>
</div>
)}
<div className="mb-3 grid grid-cols-2 gap-2">
<label className="min-w-0">
<span className="sr-only">Как вас зовут</span>
<input
type="text"
value={guestName}
onChange={(event) => setGuestName(event.target.value)}
placeholder="Ваше имя (необязательно)"
maxLength={60}
className={inputClass}
/>
</label>
<label className="min-w-0">
<span className="sr-only">Телефон</span>
<input
type="tel"
value={guestPhone}
onChange={(event) => setGuestPhone(event.target.value)}
placeholder="Телефон (необязательно)"
maxLength={20}
className={inputClass}
/>
</label>
<TableSelect
value={table}
onChange={handleTableChange}
error={tableError}
choices={tableChoicesOf(tables)}
/>
{tableError && (
<p className="col-span-2 -mt-1 text-[11px] font-semibold text-red-300" role="alert">
Выберите столик — без него заказ не принять
</p>
)}
<label className="col-span-2 min-w-0">
<span className="sr-only">Комментарий к заказу</span>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Комментарий персоналу: пожелания к кальяну, крепость, куда подать…"
maxLength={300}
rows={2}
className={`${inputClass} min-h-11 resize-none py-2.5`}
/>
</label>
{/* honeypot: за пределами экрана, не в табуляции; боты
заполняют всё подряд — сервер молча отбрасывает */}
<label className="col-span-2 absolute -left-[9999px] top-auto h-px w-px overflow-hidden" aria-hidden="true">
<span>Сайт-заметка</span>
<input
type="text"
name="hp_check"
value={hpCheck}
onChange={(event) => setHpCheck(event.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</label>
</div>
<div className="flex items-end justify-between gap-3">
<span className="text-sm text-mist/55">
{orderableItems.length < items.length ? "Итого за заказное" : "Итого"}
</span>
<strong className="font-display text-2xl font-extrabold text-white">
{orderableTotal.toLocaleString("ru-RU")} ₽
</strong>
</div>
<div className="mt-4 grid gap-2">
<button
type="button"
onClick={sendOrder}
disabled={orderableItems.length === 0 || submitState.phase === "sending" || ordersBlocked}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-neon-cyan to-[#00b8d4] px-4 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"
>
{submitState.phase === "sending" ? (
"Отправляем…"
) : ordersBlocked ? (
<>
<Clock size={15} />
Бар закрыт — заказы позже
</>
) : (
<>
<Send size={15} />
Отправить заказ в бар
</>
)}
</button>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={onClear}
disabled={items.length === 0}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-4 text-xs font-bold uppercase tracking-wider text-mist/70 transition-all hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
>
<Trash2 size={15} />
Очистить
</button>
<button
type="button"
onClick={copyOrder}
disabled={items.length === 0}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-neon-cyan/40 bg-neon-cyan/10 px-4 text-xs font-bold uppercase tracking-wider text-cyan-100 transition-all hover:bg-neon-cyan/15 disabled:cursor-not-allowed disabled:opacity-35"
>
{copied ? <Check size={15} /> : <ClipboardCopy size={15} />}
{copied ? "Скопировано" : "Скопировать"}
</button>
</div>
</div>
</>
)}
</footer>
</aside>
</div>
)}
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { cn } from "../utils/cn";
export default function Preloader({ done }: { done: boolean }) {
return (
<div
aria-hidden
className={cn(
"fixed inset-0 z-[100] flex flex-col items-center justify-center overflow-hidden bg-void transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)]",
done ? "-translate-y-full pointer-events-none invisible" : "translate-y-0"
)}
>
{/* ambient glows */}
<div className="absolute left-1/2 top-1/2 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-neon-violet/15 blur-[130px]" />
<div className="absolute left-1/4 top-2/3 h-56 w-56 rounded-full bg-neon-cyan/10 blur-[110px]" />
{/* neon sign */}
<div className="relative flex items-baseline">
{"НЕОН".split("").map((ch, i) => (
<span
key={i}
className={cn(
"neon-letter font-display text-[clamp(3rem,17vw,5rem)] font-extrabold tracking-[0.1em] text-white sm:text-8xl",
i < 2 ? "text-glow-violet" : "text-glow-cyan"
)}
style={{ animationDelay: `${0.25 + i * 0.22}s` }}
>
{ch}
</span>
))}
</div>
<div
className="neon-letter mt-5 text-[10px] font-bold uppercase tracking-[0.55em] text-mist/50 sm:text-xs"
style={{ animationDelay: "1.35s" }}
>
hookah lounge · нягань
</div>
{/* progress line */}
<div className="mt-12 h-px w-44 overflow-hidden rounded-full bg-white/10 sm:w-56">
<div
className="h-full bg-gradient-to-r from-neon-violet to-neon-cyan shadow-[0_0_14px_rgba(0,240,255,0.9)]"
style={{ animation: "loadbar 1.9s 0.15s cubic-bezier(0.65,0,0.35,1) both" }}
/>
</div>
</div>
);
}
+377
View File
@@ -0,0 +1,377 @@
import { useState } from "react";
import {
ArrowRight,
BadgeCheck,
Check,
Clock,
Crown,
Film,
Info,
Minus,
Phone,
Plus,
Receipt,
Users,
Wallet,
} from "lucide-react";
import { cn } from "../utils/cn";
import { Reveal, SectionHeading } from "./shared";
const FREE_HOURS = 2;
const MIN_HOURS = 2;
const MAX_HOURS = 8;
const BASE_GUESTS_LIMIT = 4;
const fmt = (n: number) => n.toLocaleString("ru-RU");
const roomConfigs = {
vip: {
name: "VIP-зал",
icon: Crown,
capacity: 8,
accent: "violet" as const,
firstTwoHoursPrice: 2500,
hourlyPrice: 1000,
perks: ["PS5 — игровая приставка только здесь", "Отдельная комната со своей музыкой", "Неоновая подсветка на выбор"],
},
cinema: {
name: "Кинозал",
icon: Film,
capacity: 15,
accent: "cyan" as const,
firstTwoHoursPrice: 3500,
hourlyPrice: 1500,
perks: ["Проектор и большой экран", "Уютные диваны для киносеанса", "Отдельное пространство до 15 человек"],
},
} as const;
type RoomId = keyof typeof roomConfigs;
const depositFacts = [
{
icon: Wallet,
title: "Депозит — это ваш заказ заранее",
desc: "Депозит — невозвратная сумма, которую вносите при бронировании. Она полностью идёт на кальяны, чай и закуски.",
},
{
icon: BadgeCheck,
title: "Фиксирует зал за вами",
desc: "Депозит закрепляет зал за компанией на выбранное время — мы никому его не отдадим.",
},
{
icon: Receipt,
title: "Заказали больше — доплата сверх",
desc: "Если счёт превысил депозит, платите только разницу сверх суммы.",
},
];
export default function VipCalculator() {
const [roomId, setRoomId] = useState<RoomId>("vip");
const [hours, setHours] = useState(3);
const [guests, setGuests] = useState(4);
const roomConfig = roomConfigs[roomId];
const capacity = roomConfig.capacity;
const isLargeGroup = guests > BASE_GUESTS_LIMIT;
const firstTwoHoursCost = roomConfig.firstTwoHoursPrice;
const extraHours = Math.max(0, hours - FREE_HOURS);
const extraHourlyCost = extraHours * roomConfig.hourlyPrice;
// ≤4 guests: всё идёт в депозит; >4: половина, вторая половина — аренда.
const total = firstTwoHoursCost + extraHourlyCost;
const depositShare = isLargeGroup ? 0.5 : 1.0;
const deposit = Math.round(total * depositShare);
const rental = total - deposit;
const perPerson = Math.ceil(total / guests / 10) * 10;
const switchRoom = (id: RoomId) => {
setRoomId(id);
setGuests((g) => Math.min(g, roomConfigs[id].capacity));
};
const pct = ((hours - MIN_HOURS) / (MAX_HOURS - MIN_HOURS)) * 100;
return (
<section id="vip" className="relative overflow-hidden py-24 sm:py-32">
{/* backdrop */}
<div className="absolute inset-0">
<img
src="./images/vip-room.jpg"
alt=""
aria-hidden
className="h-full w-full object-cover opacity-15"
/>
<div className="absolute inset-0 bg-gradient-to-b from-void via-void/85 to-void" />
</div>
<div className="pointer-events-none absolute -left-24 top-1/3 h-[400px] w-[400px] rounded-full bg-neon-violet/12 blur-[150px]" />
<div className="relative mx-auto max-w-7xl px-5 sm:px-8">
<Reveal>
<SectionHeading
tag="Аренда залов"
title={
<>
Посчитай <span className="text-glow-violet text-fuchsia-300">свой вечер</span>
</>
}
desc="VIP-зал: первые 2 ч — 2 500 ₽, далее 1 000 ₽/ч. Кинозал: первые 2 ч — 3 500 ₽, далее 1 500 ₽/ч. До 4 гостей весь платёж идёт в депозит (покрывает заказ), от 5 гостей — 50% в депозит, 50% аренда."
/>
</Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-[1.15fr_1fr]">
{/* calculator */}
<Reveal delay={120}>
<div className="glass relative h-full overflow-hidden rounded-3xl p-5 sm:p-8">
<div className="pointer-events-none absolute -right-16 -top-16 h-48 w-48 rounded-full bg-neon-violet/20 blur-3xl" />
{/* room switcher */}
<div className="relative">
<span className="text-[10px] font-bold uppercase tracking-[0.3em] text-mist/50">
Выберите зал
</span>
<div className="mt-3 grid grid-cols-2 gap-2 rounded-2xl border border-white/10 bg-black/25 p-2">
{(Object.keys(roomConfigs) as RoomId[]).map((id) => {
const r = roomConfigs[id];
const active = id === roomId;
return (
<button
key={id}
onClick={() => switchRoom(id)}
className={cn(
"flex items-center justify-center gap-2.5 rounded-xl px-3 py-3 text-sm font-bold transition-all duration-300 sm:px-4",
active &&
(r.accent === "violet"
? "border border-neon-violet/70 bg-neon-violet/15 text-white shadow-[0_0_24px_rgba(176,38,255,0.45)]"
: "border border-neon-cyan/70 bg-neon-cyan/10 text-white shadow-[0_0_24px_rgba(0,240,255,0.35)]"),
!active &&
"border border-transparent text-mist/55 hover:border-white/15 hover:text-white"
)}
>
<r.icon
size={17}
className={active ? "shrink-0 text-neon-cyan" : "shrink-0 text-mist/50"}
/>
<span className="flex min-w-0 flex-col items-start leading-tight">
<span className="truncate">{r.name}</span>
<span className="truncate text-[10px] font-semibold uppercase tracking-wider opacity-70">
до {r.capacity} чел.
</span>
</span>
</button>
);
})}
</div>
</div>
{/* hours */}
<div className="relative mt-8">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 text-sm font-semibold text-mist/70">
<Clock size={15} className="text-neon-cyan" />
Сколько часов
</span>
<span className="text-glow-cyan font-display text-xl font-extrabold text-white">
{hours} ч
</span>
</div>
<input
type="range"
min={MIN_HOURS}
max={MAX_HOURS}
step={1}
value={hours}
onChange={(e) => setHours(Number(e.target.value))}
aria-label="Количество часов аренды зала"
className="neon-range mt-4"
style={{
backgroundImage: `linear-gradient(90deg, #b026ff 0%, #00f0ff ${pct}%, rgba(255,255,255,0.12) ${pct}%)`,
}}
/>
<div className="mt-2 flex justify-between text-[10px] font-bold uppercase tracking-widest text-mist/35">
<span>{MIN_HOURS} ч</span>
<span>{MAX_HOURS} ч</span>
</div>
</div>
{/* guests */}
<div className="relative mt-7 flex items-center justify-between gap-4">
<span className="inline-flex min-w-0 items-center gap-2 text-sm font-semibold text-mist/70">
<Users size={15} className="shrink-0 text-neon-cyan" />
<span className="truncate">
Гостей <span className="text-mist/45">(до {capacity})</span>
</span>
</span>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => setGuests((g) => Math.max(1, g - 1))}
aria-label="Убрать гостя"
className="flex h-9 w-9 items-center justify-center rounded-full border border-white/15 text-mist/70 transition-all duration-300 hover:border-neon-violet/60 hover:text-white hover:shadow-[0_0_16px_rgba(176,38,255,0.4)]"
>
<Minus size={15} />
</button>
<span className="w-10 text-center font-display text-lg font-bold text-white">
{guests}
</span>
<button
onClick={() => setGuests((g) => Math.min(capacity, g + 1))}
aria-label="Добавить гостя"
className="flex h-9 w-9 items-center justify-center rounded-full border border-white/15 text-mist/70 transition-all duration-300 hover:border-neon-cyan/60 hover:text-white hover:shadow-[0_0_16px_rgba(0,240,255,0.4)]"
>
<Plus size={15} />
</button>
</div>
</div>
{/* perks */}
<div className="relative mt-7">
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-mist/50">
Что в зале
</span>
<ul className="mt-3 space-y-2">
{roomConfig.perks.map((p) => (
<li key={p} className="flex items-center gap-2.5 text-sm text-mist/70">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-neon-cyan/40 bg-neon-cyan/10">
<Check size={11} className="text-neon-cyan" />
</span>
{p}
</li>
))}
</ul>
</div>
{/* breakdown */}
<div className="relative mt-7 space-y-3 rounded-2xl border border-white/8 bg-black/25 p-4 sm:p-5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-mist/65">
Первые {FREE_HOURS} ч {isLargeGroup ? `(половина → депозит)` : `→ депозит`}
</span>
<span className="shrink-0 font-bold text-white">{fmt(firstTwoHoursCost)} ₽</span>
</div>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-mist/65">
Доп. часы: {extraHours} × {fmt(roomConfig.hourlyPrice)} ₽
</span>
<span className="shrink-0 font-bold text-white">{fmt(extraHourlyCost)} ₽</span>
</div>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="inline-flex items-center gap-2 text-emerald-300/90">
<Wallet size={14} className="shrink-0" />
В депозит (= ваш заказ)
</span>
<span className="shrink-0 font-bold text-white">{fmt(deposit)} ₽</span>
</div>
{rental > 0 && (
<div className="flex items-center justify-between gap-3 text-sm">
<span className="inline-flex items-center gap-2 text-mist/65">
<Info size={14} className="shrink-0 text-mist/50" />
Аренда зала
</span>
<span className="shrink-0 font-bold text-white">{fmt(rental)} ₽</span>
</div>
)}
<div className="h-px bg-gradient-to-r from-neon-violet/40 via-white/10 to-neon-cyan/40" />
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
<div>
<span className="block text-[10px] font-bold uppercase tracking-[0.25em] text-mist/50">
Итого
</span>
<span className="mt-1 block text-xs text-mist/45">
≈ {fmt(perPerson)} ₽ с человека
</span>
</div>
<span
key={total}
className="text-glow-violet total-pop whitespace-nowrap font-display text-3xl font-extrabold text-white sm:text-4xl"
>
{fmt(total)} ₽
</span>
</div>
</div>
<p className="relative mt-4 text-xs leading-relaxed text-mist/45">
Депозит ({fmt(deposit)} ₽) невозвратный, но полностью идёт в счёт вашего заказа: кальяны, чай, закуски. Если заказали больше — доплата сверх суммы.
</p>
<div className="relative mt-6 flex flex-wrap gap-3">
<a
href="#contacts"
className="group inline-flex flex-1 items-center justify-center gap-2.5 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-6 py-3.5 text-sm font-bold text-white shadow-[0_0_28px_rgba(176,38,255,0.45)] transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_0_45px_rgba(176,38,255,0.7)]"
>
Забронировать {roomConfig.name}
<ArrowRight
size={16}
className="transition-transform duration-300 group-hover:translate-x-1"
/>
</a>
<a
href="tel:+79129017217"
className="inline-flex items-center justify-center gap-2.5 rounded-full border border-neon-cyan/40 px-6 py-3.5 text-sm font-bold text-neon-cyan transition-all duration-300 hover:-translate-y-0.5 hover:bg-neon-cyan/10 hover:shadow-[0_0_28px_rgba(0,240,255,0.35)]"
>
<Phone size={15} />
Позвонить
</a>
</div>
</div>
</Reveal>
{/* what is a deposit */}
<Reveal delay={220}>
<div className="flex h-full flex-col gap-4">
<div className="glass rounded-3xl p-5 sm:p-7">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-neon-cyan/40 bg-neon-cyan/10 text-neon-cyan">
<Info size={17} />
</span>
<h3 className="font-display text-lg font-bold text-white">
Что такое депозит?
</h3>
</div>
<p className="mt-4 text-sm leading-relaxed text-mist/65">
Депозит — <span className="font-bold text-white">невозвратная сумма</span>, которую вы вносите при бронировании. Она <span className="font-bold text-white">целиком идёт в счёт вашего заказа</span>. Если заказали больше — доплата сверх суммы.
Для компаний до 4 человек все взносы уходят в депозит (аренда зала бесплатна).
Свыше 4 человек — половина суммы идёт в депозит, вторая половина — аренда зала.
</p>
</div>
<div className="flex flex-1 flex-col gap-4">
{depositFacts.map((f) => (
<div
key={f.title}
className="glass group flex flex-1 items-start gap-4 rounded-2xl p-5 transition-all duration-500 hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-22px_rgba(176,38,255,0.5)]"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-gradient-to-br from-neon-violet/25 to-neon-cyan/10 text-neon-cyan transition-transform duration-500 group-hover:scale-110">
<f.icon size={18} />
</span>
<div className="min-w-0">
<p className="font-display text-sm font-bold text-white">{f.title}</p>
<p className="mt-1.5 text-xs leading-relaxed text-mist/60">{f.desc}</p>
</div>
</div>
))}
</div>
<div className="rounded-2xl border border-neon-violet/30 bg-neon-violet/8 p-5">
<p className="text-xs leading-relaxed text-mist/70">
<span className="font-bold text-white">Пример VIP, до 4 чел.</span> Компания 4 человека на 4 часа:
2 500 + 2×1 000, итого <span className="font-bold text-white">4 500 ₽</span>.{" "}
Всё уходит в депозит → ваш заказ по меню 4 500 ₽.
</p>
</div>
<div className="rounded-2xl border border-neon-cyan/30 bg-neon-cyan/8 p-5">
<p className="text-xs leading-relaxed text-mist/70">
<span className="font-bold text-white">Пример Кинозал, 5+ чел.</span> Компания 5 человек на 4 часа:
3 500 + 2×1 500, итого <span className="font-bold text-white">6 500 ₽</span>.{" "}
Депозит = 50% (3 250 ₽), аренда = 3 250 ₽.
</p>
</div>
</div>
</Reveal>
</div>
</div>
</section>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "../utils/cn";
import { isPrerender } from "../lib/prerender";
/* ---------- Scroll reveal wrapper ---------- */
export function Reveal({
children,
className,
delay = 0,
y = 32,
}: {
children: ReactNode;
className?: string;
delay?: number;
y?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
// в пререндер-снапшоте секции сразу «видимые»: статический HTML должен
// содержать текст без скрытия (opacity:0 боты трактуют как невидимый)
const [visible, setVisible] = useState(isPrerender());
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
obs.disconnect();
}
},
{ threshold: 0.12, rootMargin: "0px 0px -48px 0px" }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div
ref={ref}
className={cn(
"transition-all duration-[950ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform",
className
)}
style={{
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "none" : `translateY(${y}px)`,
}}
>
{children}
</div>
);
}
/* ---------- Section heading ---------- */
export function SectionHeading({
tag,
title,
desc,
align = "center",
}: {
tag: string;
title: ReactNode;
desc?: string;
align?: "left" | "center";
}) {
const centered = align === "center";
return (
<div className={cn("max-w-2xl", centered && "mx-auto text-center")}>
<div
className={cn(
"flex items-center gap-3",
centered && "justify-center"
)}
>
<span className="h-1.5 w-1.5 rounded-full bg-neon-cyan animate-pulse-dot" />
<span className="text-[11px] font-bold uppercase tracking-[0.35em] text-neon-cyan/80">
{tag}
</span>
</div>
<h2 className="mt-4 font-display text-[clamp(1.6rem,6.5vw,2.75rem)] font-bold leading-[1.15] text-white">
{title}
</h2>
{desc && (
<p className="mt-5 text-base leading-relaxed text-mist/65">{desc}</p>
)}
</div>
);
}
/* ---------- Brand icons (не входят в lucide) ---------- */
type BrandIconProps = { size?: number; className?: string };
export function VkIcon({ size = 18, className }: BrandIconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" width={size} height={size} className={className} aria-hidden>
<path d="M12.8 17.5c-5.3 0-8.7-3.7-8.8-9.8h2.7c.1 4.5 2.1 6.4 3.6 6.8V7.7h2.6v4c1.5-.2 3-1.8 3.6-4h2.6c-.4 2.7-2 4.3-3.2 5 1.2.5 3 2 3.7 4.8h-2.9c-.5-1.8-1.9-3.2-3.8-3.4v3.4h-.1Z" />
</svg>
);
}
+365
View File
@@ -0,0 +1,365 @@
/**
* БАРНОЕ МЕНЮ: закуски, напитки и десерты, снеки, лимонады и энергетики.
*
*/
export const SNACKS_CATEGORY = "ЗАКУСКИ";
export const DRINKS_DESSERTS_CATEGORY = "НАПИТКИ И ДЕСЕРТЫ";
export const SWEETS_CATEGORY = "СНЕКИ И СЛАДОСТИ";
export const LEMONADES_CATEGORY = "ЛИМОНАДЫ И ЭНЕРГЕТИКИ";
export interface BarCategoryMeta {
id: string;
label: string;
accent: string; // неоновый акцент категории
tagline: string;
}
export const barCategories: BarCategoryMeta[] = [
{
id: SNACKS_CATEGORY,
label: SNACKS_CATEGORY,
accent: "#f59e0b", // янтарный — горячая кухня
tagline: "Горячие закуски к кальяну и пиву — хрустящие, сытные, на компанию",
},
{
id: DRINKS_DESSERTS_CATEGORY,
label: DRINKS_DESSERTS_CATEGORY,
accent: "#fb7185", // розовый — десерты
tagline: "Чай, кофе и домашние десерты — сладкое продолжение вечера",
},
{
id: SWEETS_CATEGORY,
label: SWEETS_CATEGORY,
accent: "#c084fc", // лавандовый — сладости
tagline: "Шоколад, чипсы и хрустящие снеки — то, что уходит под дым",
},
{
id: LEMONADES_CATEGORY,
label: LEMONADES_CATEGORY,
accent: "#a3e635", // электрический лайм — энергия
tagline: "Грузинские лимонады и энергетики — холодные и бодрящие",
},
];
export interface BarMenuItem {
id: string;
name: string;
category: string;
portion?: string; // «8 шт», «130 гр», «900 мл»
price: number;
priceFormatted: string;
desc: string;
icon: string; // ключ иконки, см. barMenuIcons в MenuPage
}
export const barMenuItems: BarMenuItem[] = [
/* ------------------------------ ЗАКУСКИ ------------------------------ */
{
id: "onion-rings",
name: "Луковые кольца",
category: SNACKS_CATEGORY,
portion: "8 шт",
price: 220,
priceFormatted: "220 ₽",
desc: "Хрустящие кольца лука в золотистой панировке. Подаём с сырным соусом.",
icon: "donut",
},
{
id: "fries",
name: "Картофель Фри",
category: SNACKS_CATEGORY,
portion: "130 гр",
price: 210,
priceFormatted: "210 ₽",
desc: "Классический фри с морской солью — горячий и хрустящий.",
icon: "utensils",
},
{
id: "buffalo-wings",
name: "Крылышки Баффало",
category: SNACKS_CATEGORY,
portion: "6 шт",
price: 350,
priceFormatted: "350 ₽",
desc: "Сочные крылья в остром соусе Баффало. Острота — средняя, вкус — мощный.",
icon: "drumstick",
},
{
id: "cheese-sticks",
name: "Сырные палочки",
category: SNACKS_CATEGORY,
portion: "6 шт",
price: 310,
priceFormatted: "310 ₽",
desc: "Тягучий моцарелла в хрустящей панировке. Идеально под томатный дип.",
icon: "croissant",
},
{
id: "strips",
name: "Стрипсы",
category: SNACKS_CATEGORY,
portion: "6 шт",
price: 320,
priceFormatted: "320 ₽",
desc: "Куриные стрипсы в специях — румяные снаружи, нежные внутри.",
icon: "beef",
},
{
id: "nuggets",
name: "Нагетсы",
category: SNACKS_CATEGORY,
portion: "8 шт",
price: 320,
priceFormatted: "320 ₽",
desc: "Золотистые нагетсы из куриного филе. Подаём с двумя соусами на выбор.",
icon: "egg",
},
/* ----------------------- НАПИТКИ И ДЕСЕРТЫ ----------------------- */
{
id: "black-tea",
name: "Черный чай",
category: DRINKS_DESSERTS_CATEGORY,
portion: "900 мл",
price: 250,
priceFormatted: "250 ₽",
desc: "Классический чёрный чай в стеклянном чайнике — глубокий и согревающий.",
icon: "leaf",
},
{
id: "black-tea-thyme",
name: "Черный чай с чабрецом",
category: DRINKS_DESSERTS_CATEGORY,
portion: "900 мл",
price: 250,
priceFormatted: "250 ₽",
desc: "Чёрный чай с веточкой чабреца — травяной аромат и мягкое тепло.",
icon: "leaf",
},
{
id: "milk-oolong",
name: "Молочный Улун",
category: DRINKS_DESSERTS_CATEGORY,
portion: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Нежный улун с бархатными сливочными нотами. Легенда чайной карты.",
icon: "milk",
},
{
id: "americano",
name: "Американо",
category: DRINKS_DESSERTS_CATEGORY,
portion: "150 мл",
price: 150,
priceFormatted: "150 ₽",
desc: "Крепкий зерновой кофе на свежем помоле. Чистый и бодрящий.",
icon: "coffee",
},
{
id: "cappuccino",
name: "Капучино",
category: DRINKS_DESSERTS_CATEGORY,
portion: "200 мл",
price: 200,
priceFormatted: "200 ₽",
desc: "Эспрессо с шелковистой молочной пеной. По желанию — с сиропом.",
icon: "coffee",
},
{
id: "cheesecake-ny",
name: "Чизкейк Нью-Йорк",
category: DRINKS_DESSERTS_CATEGORY,
portion: "120 гр",
price: 250,
priceFormatted: "250 ₽",
desc: "Классический сливочный чизкейк на песочной основе.",
icon: "cake",
},
{
id: "red-velvet",
name: "Торт Красный бархат",
category: DRINKS_DESSERTS_CATEGORY,
portion: "120 гр",
price: 250,
priceFormatted: "250 ₽",
desc: "Бархатные коржи с кремом из сливочного сыра.",
icon: "cake",
},
{
id: "medovik",
name: "Торт Медовик",
category: DRINKS_DESSERTS_CATEGORY,
portion: "120 гр",
price: 250,
priceFormatted: "250 ₽",
desc: "Домашний медовик со сметанным кремом — тает во рту.",
icon: "cake",
},
{
id: "cheesecake-strawberry",
name: "Чизкейк Нью-Йорк клубничный",
category: DRINKS_DESSERTS_CATEGORY,
portion: "120 гр",
price: 250,
priceFormatted: "250 ₽",
desc: "Чизкейк с клубничным соусом и свежими ягодами.",
icon: "cake",
},
{
id: "cheesecake-raspberry",
name: "Чизкейк Нью-Йорк Малина",
category: DRINKS_DESSERTS_CATEGORY,
portion: "120 гр",
price: 250,
priceFormatted: "250 ₽",
desc: "Сливочный чизкейк с малиновым кули и лёгкой кислинкой.",
icon: "cake",
},
/* ------------------------- СНЕКИ И СЛАДОСТИ ------------------------- */
{
id: "alpen-gold",
name: "Alpen Gold",
category: SWEETS_CATEGORY,
price: 210,
priceFormatted: "210 ₽",
desc: "Молочный шоколад с орехами и карамелью.",
icon: "candy",
},
{
id: "milka",
name: "Milka",
category: SWEETS_CATEGORY,
price: 230,
priceFormatted: "230 ₽",
desc: "Нежный молочный шоколад из альпийского молока.",
icon: "candy",
},
{
id: "leys",
name: "Чипсы Leys",
category: SWEETS_CATEGORY,
portion: "90 гр",
price: 210,
priceFormatted: "210 ₽",
desc: "Хрустящие картофельные чипсы — классика под дым.",
icon: "popcorn",
},
{
id: "korochki",
name: "3 Корочки",
category: SWEETS_CATEGORY,
portion: "60 гр",
price: 150,
priceFormatted: "150 ₽",
desc: "Хрустящие ржаные корочки со специями.",
icon: "popcorn",
},
{
id: "hrustim",
name: "Хрустим team",
category: SWEETS_CATEGORY,
portion: "40 гр",
price: 100,
priceFormatted: "100 ₽",
desc: "Лёгкий хрустящий снек — маленькая пачка, большое удовольствие.",
icon: "popcorn",
},
/* --------------------- ЛИМОНАДЫ И ЭНЕРГЕТИКИ --------------------- */
{
id: "natakhtari-pear",
name: "НАТАХТАРИ ГРУША",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 200,
priceFormatted: "200 ₽",
desc: "Грузинский газированный напиток со вкусом спелой груши.",
icon: "glass",
},
{
id: "strawberry-kiwi",
name: "Клубника Киви",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 150,
priceFormatted: "150 ₽",
desc: "Ягодный лимонад: клубника и киви в одном бокале.",
icon: "cupsoda",
},
{
id: "feijoa",
name: "Фейхоа",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 150,
priceFormatted: "150 ₽",
desc: "Освежающий лимонад с экзотическим вкусом фейхоа.",
icon: "cupsoda",
},
{
id: "natakhtari-feijoa",
name: "НАТАХТАРИ ФЕЙХОА",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 200,
priceFormatted: "200 ₽",
desc: "Грузинская газировка с ярким вкусом фейхоа.",
icon: "glass",
},
{
id: "natakhtari-saperavi",
name: "НАТАХТАРИ САПЕРАВИ",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 200,
priceFormatted: "200 ₽",
desc: "Виноградный напиток из грузинского сорта Саперави.",
icon: "grape",
},
{
id: "burn-original",
name: "BURN Оригинальный",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 250,
priceFormatted: "250 ₽",
desc: "Классический энергетик BURN — заряд на всю ночь.",
icon: "zap",
},
{
id: "adrenaline-rush",
name: "Adrenaline RUSH Абсолютная ЭНЕРГИЯ",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 250,
priceFormatted: "250 ₽",
desc: "Adrenaline Rush — абсолютная энергия без компромиссов.",
icon: "battery",
},
{
id: "burn-apple-kiwi",
name: "BURN Яблоко Киви",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 250,
priceFormatted: "250 ₽",
desc: "BURN со вкусом яблока и киви — кислинка плюс энергия.",
icon: "apple",
},
{
id: "burn-mango",
name: "BURN Со вкусом МАНГО",
category: LEMONADES_CATEGORY,
portion: "1 шт",
price: 250,
priceFormatted: "250 ₽",
desc: "Тропический BURN с насыщенным вкусом спелого манго.",
icon: "zap",
},
];
/** Суммарный список всех барных позиций (для поиска и счётчиков) */
export const allBarItems = barMenuItems;
+170
View File
@@ -0,0 +1,170 @@
export interface BeerItem {
id: string;
name: string;
category: string;
type: string; // "Светлое", "Тёмное", "Пшеничное", "Фруктовое / Сидр", "Лагер"
volume: string;
price: number;
priceFormatted: string;
desc: string;
accentColor: string;
}
export const BEER_CATEGORY = "ПИВНАЯ КАРТА";
export const beerList: BeerItem[] = [
{
id: "bud",
name: "Bud",
category: BEER_CATEGORY,
type: "Светлый лагер",
volume: "0,44 л",
price: 250,
priceFormatted: "250 ₽",
desc: "Легендарный американский светлый лагер с чистым освежающим вкусом и тонким хмелевым ароматом.",
accentColor: "#ef4444",
},
{
id: "kozel-dark",
name: "Kozel темный",
category: BEER_CATEGORY,
type: "Тёмный лагер",
volume: "0,45 л",
price: 220,
priceFormatted: "220 ₽",
desc: "Чешский бархатистый тёмный лагер с приятными нотами карамельного солода, шоколада и мягкой горчинкой.",
accentColor: "#a855f7",
},
{
id: "stary-melnik-light",
name: "Старый мельник светлое",
category: BEER_CATEGORY,
type: "Светлое классическое",
volume: "0,45 л",
price: 200,
priceFormatted: "200 ₽",
desc: "Традиционное светлое пиво из отборного ячменного солода с мягким сбалансированным вкусом.",
accentColor: "#eab308",
},
{
id: "krone-blanche",
name: "Krone Blanche",
category: BEER_CATEGORY,
type: "Белое пшеничное",
volume: "0,45 л",
price: 220,
priceFormatted: "220 ₽",
desc: "Освежающее пшеничное белое пиво с яркими цитрусовыми оттенками цедры апельсина и нотками кориандра.",
accentColor: "#00f0ff",
},
{
id: "erzmann",
name: "Erzmann",
category: BEER_CATEGORY,
type: "Классический лагер",
volume: "0,45 л",
price: 220,
priceFormatted: "220 ₽",
desc: "Европейский светлый лагер с благородным солодовым телом и освежающим сухим послевкусием.",
accentColor: "#38bdf8",
},
{
id: "el-capulco",
name: "El Capulco",
category: BEER_CATEGORY,
type: "Мексиканский лагер",
volume: "0,45 л",
price: 250,
priceFormatted: "250 ₽",
desc: "Лёгкое мексиканское пиво золотистого оттенка с нотками агавы, кукурузных хлопьев и мягкой цитрусовой свежестью.",
accentColor: "#facc15",
},
{
id: "stella",
name: "Stella",
category: BEER_CATEGORY,
type: "Премиум лагер",
volume: "0,44 л",
price: 250,
priceFormatted: "250 ₽",
desc: "Знаменитый бельгийский премиальный лагер Stella Artois с чистой искристостью и благородной горчинкой сортов Saaz.",
accentColor: "#ef4444",
},
{
id: "kozel-light",
name: "Kozel светлый",
category: BEER_CATEGORY,
type: "Светлый лагер",
volume: "0,45 л",
price: 220,
priceFormatted: "220 ₽",
desc: "Лёгкий чешский светлый лагер с тонким ароматом хмеля и золотистой пенной шапкой.",
accentColor: "#f59e0b",
},
{
id: "stary-melnik-dark",
name: "Старый мельник темное",
category: BEER_CATEGORY,
type: "Тёмный эль / лагер",
volume: "1 шт",
price: 200,
priceFormatted: "200 ₽",
desc: "Глубокий рубиново-тёмный цвет, мягкая солодовая сладость с лёгкими хлебно-карамельными тонами.",
accentColor: "#c084fc",
},
{
id: "pablos-lime",
name: "Pablo's с соком лайма",
category: BEER_CATEGORY,
type: "Пивной напиток / лайм",
volume: "1 шт",
price: 200,
priceFormatted: "200 ₽",
desc: "Освежающий яркий пивной напиток с натуральным соком лайма — бодрящий, кисленький и летний.",
accentColor: "#22c55e",
},
{
id: "redds",
name: "Redd's",
category: BEER_CATEGORY,
type: "Фруктовое / яблочное",
volume: "1 шт",
price: 200,
priceFormatted: "200 ₽",
desc: "Лёгкий фильтрованный напиток с искристым кисло-сладким вкусом спелых сочных яблок.",
accentColor: "#f43f5e",
},
{
id: "spaten",
name: "Spatten",
category: BEER_CATEGORY,
type: "Мюнхенский хель",
volume: "0,45 л",
price: 250,
priceFormatted: "250 ₽",
desc: "Классический немецкий Spaten Münchner Hell — эталон баварского пивоварения с мягким сбалансированным вкусом.",
accentColor: "#10b981",
},
{
id: "hoegaarden",
name: "Hoegaarden",
category: BEER_CATEGORY,
type: "Бланш / Пшеничное",
volume: "0,45 л",
price: 220,
priceFormatted: "220 ₽",
desc: "Аутентичный бельгийский нефильтрованный бланш с пикантным акцентом кюрасао и семян кориандра.",
accentColor: "#fbbf24",
},
{
id: "corona-extra",
name: "Corona Extra",
category: BEER_CATEGORY,
type: "Мексиканский лагер",
volume: "1 шт",
price: 250,
priceFormatted: "250 ₽",
desc: "Всемирно известный мексиканский лагер в прозрачной бутылке — пьётся холодным с долькой сочного лайма.",
accentColor: "#38bdf8",
},
];
+681
View File
@@ -0,0 +1,681 @@
export type TobaccoStrength = 1 | 2 | 3;
export const strengthLabels: Record<TobaccoStrength, string> = {
1: "лёгкая",
2: "средняя",
3: "крепкая",
};
/* ------------------------------------------------------------------ */
/* Авторские миксы кальянов */
/* Формат: название · крепость (1–5) · холодок (0–3) · вкусы · цена */
/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* Миксы «НЕОНа» */
/* Страница «Миксы НЕОНа» показывает витрину генераций AI-сомелье */
/* (те же миксы, что собирает вкладка «Автоподбор микса», — см. */
/* mixEngine.generateShowcaseMixes). Фирменные миксы бара хозяин */
/* добавляет в CRM — на сайте это блок «Миксы мастера». */
/* ------------------------------------------------------------------ */
import { CATEGORY_LABELS, FLAVORS } from "./flavors";
import { generateShowcaseMixes, getFlavor } from "../lib/mixEngine";
export type MixStrength = 1 | 2 | 3 | 4 | 5;
export type CoolingLevel = 0 | 1 | 2 | 3;
export type HookahMix = {
id: string;
name: string;
desc: string;
strength: MixStrength;
cooling: CoolingLevel; // 0 = нет холодка, 3 = максимальный
flavorTags: string[]; // напр. "ягоды", "цитрус", "мята"
price: string;
featured?: boolean;
/** состав витринного микса: вкусы движка в порядке «основа → добавка → оттенок» */
items: { flavorId: string }[];
};
export const mixStrengthLabels: Record<MixStrength, string> = {
1: "Лёгкая",
2: "Ниже среднего",
3: "Средняя",
4: "Выше средней",
5: "Крепкая",
};
export const mixCoolingLabels: Record<CoolingLevel, string> = {
0: "Без холодка",
1: "Лёгкая",
2: "Средний",
3: "Сильный",
};
const PRICE_BASE = "1 400 ₽";
const PRICE_STRONG = "1 800 ₽";
/* ------------------------------------------------------------------ */
/* Авторские миксы — показываются в карусели на главной */
/* ------------------------------------------------------------------ */
export type SignatureMix = {
id: string;
name: string;
desc: string;
price: string;
strength: TobaccoStrength;
badge?: { label: string; hot?: boolean };
};
const BASE_PRICE = "1 400 ₽";
export const signatureMixes: SignatureMix[] = [
{
id: "neon-signature",
name: "НЕОН Signature Mix",
desc: "Виноград, мята и тропические ноты. Наш фирменный микс, с которого начинают гости.",
price: BASE_PRICE,
strength: 1,
badge: { label: "Хит", hot: true },
},
{
id: "berry-haze",
name: "Berry Haze",
desc: "Лесные ягоды и сливки. Десертный вкус с густым молочным дымом.",
price: BASE_PRICE,
strength: 1,
badge: { label: "Новинка", hot: true },
},
{
id: "citrus-punch",
name: "Citrus Punch",
desc: "Цитрус, лайм и лёгкое охлаждение. Яркий и освежающий, как неоновая вывеска.",
price: BASE_PRICE,
strength: 2,
},
{
id: "midnight-grape",
name: "Midnight Grape",
desc: "Тёмный виноград с мятой. Глубокий вечерний вкус для долгих разговоров.",
price: BASE_PRICE,
strength: 1,
},
{
id: "cyan-breeze",
name: "Cyan Breeze",
desc: "Арбуз, лёд и мята. Самый холодный микс в нашей карте. Свежесть до утра.",
price: BASE_PRICE,
strength: 1,
},
{
id: "violet-dream",
name: "Violet Dream",
desc: "Дыня, ежевика и лаванда. Мягкий микс с цветочным шлейфом.",
price: BASE_PRICE,
strength: 1,
},
{
id: "dark-mango",
name: "Dark Mango",
desc: "Манго, маракуйя и холодок. Тропические ноты для долгого вечера.",
price: BASE_PRICE,
strength: 2,
},
{
id: "electric-cola",
name: "Electric Cola",
desc: "Кола, вишня и лёд. Знакомый вкус в неоновом исполнении.",
price: BASE_PRICE,
strength: 2,
},
];
/* ------------------------------------------------------------------ */
/* Справочник табаков — страница «Вкусы» */
/* Формат: вкус · крепость · фирма · холодок */
/* ------------------------------------------------------------------ */
export type TobaccoFlavor = {
id: string;
name: string;
brand: string;
strength: TobaccoStrength;
cooling: boolean;
note?: string;
available: boolean;
image?: string;
createdAt?: string; // заполнено для вкусов, добавленных владельцем через CRM
};
export type TobaccoBrandInfo = {
url?: string;
image: string;
accent: string;
};
export const tobaccoBrandInfo: Record<string, TobaccoBrandInfo> = {
Blackburn: { url: "https://burntobacco.com/", image: "./images/hookah-smoke.jpg", accent: "#ff3636" },
Overdose: { url: "https://overdose.pro/", image: "./images/hookah-smoke.jpg", accent: "#ff8a00" },
Banger: { url: "https://www.bangertobacco.com/", image: "./images/interior-bar.jpg", accent: "#d4af37" },
Undercoal: { url: "https://musthave.ru/", image: "./images/hookah-smoke.jpg", accent: "#ff4d8d" },
Darkside: { url: "https://darksidecorp.ru/", image: "https://static.tildacdn.com/tild3036-6566-4231-a239-313362636638/mainFooter.png", accent: "#ef2b2d" },
"Хулиган / WOOD": { url: "https://hlgn.cc/tobacco", image: "https://static.tildacdn.com/tild6662-6138-4437-b334-303162643234/_2____1.png", accent: "#ff453a" },
Hupreme: { url: "https://t.me/s/Hypremetc", image: "./images/interior-sofas.jpg", accent: "#b026ff" },
"JENT Alcohol Line": { url: "https://vk.com/jent.club", image: "https://sun9-6.userapi.com/impg/VSLGmsKem1jEfrzMwQ2zntx8tvmHu-mhR7h8iA/dja__TnVak0.jpg", accent: "#e2b04a" },
DEUS: { url: "https://t.me/deus_tobacco", image: "./images/hookah-smoke.jpg", accent: "#a7a7a7" },
Северный: { url: "https://www.tabak-sever.ru/", image: "./images/interior-sofas.jpg", accent: "#a8d7ff" },
Bliss: { url: "https://taplink.cc/blisstobacco", image: "https://p.taplink.st/a/0/3/b/e/bc523d.png?2", accent: "#78a7d2" },
Muassel: { url: "http://muassel.ru/", image: "https://muassel.ru/images/22_1.jpg", accent: "#d5a760" },
Chabacco: { url: "https://chabacco.ru/", image: "https://static.tildacdn.com/tild3466-3934-4163-b131-653731646231/banner-new-chabacco-.png", accent: "#ffca28" },
"Joy Kiwichanga": { url: "https://alphahookah.ru/mix-joy/", image: "https://alphahookah.ru/upload/iblock/7e9/ah3jnujz5k06qd717ml9wo52t0s3bi3v.png", accent: "#00d68f" },
Хулиган: { url: "https://hlgn.cc/tobacco", image: "https://static.tildacdn.com/tild6662-6138-4437-b334-303162643234/_2____1.png", accent: "#ff453a" },
Сарма: { url: "https://sarmahookah.ru/", image: "https://static.tildacdn.com/tild6465-6337-4363-b365-376134396533/image_7.png", accent: "#e44e2d" },
Добавка: { image: "./images/menu-bg.jpg", accent: "#00f0ff" },
};
const tobacco = (
id: string,
brand: string,
name: string,
note: string | undefined,
strength: TobaccoStrength,
cooling = false,
available = true,
image?: string,
): TobaccoFlavor => ({ id, brand, name, note, strength, cooling, available, image });
export const tobaccoFlavors: TobaccoFlavor[] = [
tobacco("bb-cola", "Blackburn", "Классическая кола", undefined, 3),
tobacco("bb-lulo", "Blackburn", "Lulo", undefined, 3),
tobacco("bb-real-pf", "Blackburn", "Real P.F.", "Маракуйя", 3),
tobacco("bb-white-grape", "Blackburn", "White Grape", "Белый виноград", 3),
tobacco("bb-forest-raspberry", "Blackburn", "Лесная малина", undefined, 3),
tobacco("bb-brownie", "Blackburn", "Брауни", undefined, 3),
tobacco("bb-blackberry-lemonade", "Blackburn", "Blackberry Lemonade", "Ежевичный лимонад", 3),
tobacco("bb-lemon-shock", "Blackburn", "Lemon Shock", "Ультракислый лимон", 3),
tobacco("bb-blueberry", "Blackburn", "Blueberry", "Голубика", 3),
tobacco("bb-apple-shock", "Blackburn", "Apple Shock", "Кислое зелёное яблоко", 3),
tobacco("bb-red-energy", "Blackburn", "Red Energy", "Энергетик", 3),
tobacco("bb-kiwi-stoner", "Blackburn", "Kiwi Stoner", "Смузи из киви", 3),
tobacco("bb-bananini", "Blackburn", "Bananini", "Банан", 3),
tobacco("bb-honey-melon", "Blackburn", "Медовая дыня", undefined, 3),
tobacco("bb-pineapple", "Blackburn", "Pineapple", "Сочный ананас", 3),
tobacco("bb-grape-lollipop", "Blackburn", "Виноградный чупа-чупс", undefined, 3),
tobacco("bb-fir", "Blackburn", "Ёлка", undefined, 3, true),
tobacco("bb-red-orange", "Blackburn", "Красный апельсин", undefined, 3),
tobacco("bb-juicy-mango", "Blackburn", "Сочное манго", undefined, 3),
tobacco("bb-basilic", "Blackburn", "Basilic", "Базилик", 3),
tobacco("bb-berry-sorbet", "Blackburn", "Ягодный сорбет", "С грейпфрутом", 3),
tobacco("bb-ananas-shock", "Blackburn", "Ananas Shock", "Кислый ананас", 3),
tobacco("bb-after-8", "Blackburn", "After 8", "Шоколад-мята", 3, true),
tobacco("bb-overdose", "Blackburn", "Overdose", "Лимон-лайм", 3),
tobacco("bb-pina-colada", "Blackburn", "Pina Colada", undefined, 3),
tobacco("bb-pistachio-ice", "Blackburn", "Pistachio Ice Snow", "Фисташковое мороженое", 3, true),
tobacco("bb-pomelo", "Blackburn", "Pomelo", undefined, 3),
tobacco("bb-pear-lemonade", "Blackburn", "Pear Lemonade", "Грушевый лимонад", 3),
tobacco("od-lost-futura", "Overdose", "Lost Futura", "Мускусная вишня", 3),
tobacco("od-frustyle", "Overdose", "Frustyle", "Кактус-лайм", 3),
tobacco("od-black-banano", "Overdose", "Black Banano", "Амбровый банан", 3),
tobacco("od-gin-cucumber", "Overdose", "Gin Cucumber", "Огуречный джин", 3),
tobacco("banger-black-white", "Banger", "Black and White", "Печенье Oreo", 2),
tobacco("banger-cream-soda", "Banger", "Cream Soda", "Крем-сода", 2),
tobacco("banger-sexy", "Banger", "Sexy", "Грейпфрут, клубника, малина", 2),
tobacco("banger-grapefruit", "Banger", "Grapefruit", "Грейпфрут", 2),
tobacco("banger-green-day", "Banger", "Green Day", "Яблоко, киви, кислинка", 2),
tobacco("banger-cola-bella", "Banger", "Cola Bella", "Ванильная кола", 2),
tobacco("uc-pinkman", "Undercoal", "Pinkman", "Клубника, малина, грейпфрут", 2),
tobacco("uc-coconut-shake", "Undercoal", "Кокосовый шейк", undefined, 2, false, false),
tobacco("uc-caribbean-rum", "Undercoal", "Caribbean Rum", undefined, 2),
tobacco("uc-mango-rose", "Undercoal", "Манго, маракуйя, личи и роза", undefined, 2),
tobacco("uc-tropical-juice", "Undercoal", "Тропический сок", undefined, 2),
tobacco("uc-cactus-kiwi", "Undercoal", "Кактус, киви и абсент", undefined, 2),
tobacco("uc-milky-oolong", "Undercoal", "Чай «Молочный улун»", undefined, 2),
tobacco("uc-cranberry", "Undercoal", "Клюква", undefined, 2),
tobacco("uc-pear-tarragon", "Undercoal", "Груша, лимон и тархун", undefined, 2),
tobacco("uc-cinnamon-roll", "Undercoal", "Cinnamon Roll", undefined, 2),
tobacco("uc-cookie", "Undercoal", "Cookie", "Печенье", 2),
tobacco("ds-alpine-trip", "Darkside", "Алтайский трип", "Хвоя, фейхоа, эвкалипт", 2, true),
tobacco("wood-begemot", "Хулиган / WOOD", "Begemot", "Бергамот-мандарин", 2),
tobacco("wood-boo", "Хулиган / WOOD", "Boo", "Яблоко-гранат", 2),
tobacco("wood-old", "Хулиган / WOOD", "Old", "Лимон с мёдом", 2),
tobacco("wood-berry-ninja", "Хулиган / WOOD", "Берри Ниндзя", "Лесные ягоды", 2),
tobacco("wood-sir-hard", "Хулиган / WOOD", "Sir Hard", "Воздушный рис", 3),
tobacco("hupreme-milk-tea", "Hupreme", "Чай с молоком", undefined, 2),
tobacco("hupreme-gooseberry", "Hupreme", "Крыжовник", undefined, 2),
tobacco("hupreme-guava", "Hupreme", "Гуава", undefined, 2),
tobacco("jent-blue-agave", "JENT Alcohol Line", "Blue Agave", "«Текила Бум»", 2),
tobacco("jent-havana", "JENT Alcohol Line", "Havana 290", "Мохито", 2, true),
tobacco("jent-cuba-libre", "JENT Alcohol Line", "Cuba Libre", undefined, 2),
tobacco("deus-blackberry-wine", "DEUS", "Blackberry Wine", "Ежевичное вино", 2),
tobacco("deus-guinness", "DEUS", "Guinness", "Тёмное пиво", 2),
tobacco("deus-peonies", "DEUS", "Peonies", "Пионы", 2),
tobacco("deus-violet-zephyr", "DEUS", "Violet Zephyr", "Фиалковый зефир", 2),
tobacco("deus-cherry-jelly", "DEUS", "Cherry Jelly", "Вишнёвое желе", 2),
tobacco("deus-pumpkin-pie", "DEUS", "Pumpkin Pie", "Тыквенный пирог с миндалём", 2),
tobacco("deus-blueberry-yogurt", "DEUS", "Blueberry Yogurt", "Черничный йогурт", 2),
tobacco("deus-base", "DEUS", "Base", "Табачный лист", 2),
tobacco("deus-champagne", "DEUS", "Champagne", "Игристое вино", 2),
tobacco("north-flower", "Северный", "Цветочный", undefined, 3),
tobacco("north-feijoa", "Северный", "Финдеперцовый фейхоа", undefined, 3),
tobacco("north-elderberry", "Северный", "Бузина", undefined, 3),
tobacco("north-sorbet", "Северный", "Sorbet", "Фруктовый", 3),
tobacco("bliss-mango-chamomile", "Bliss", "Манго-ромашка", undefined, 1),
tobacco("bliss-peach-yogurt", "Bliss", "Персик-йогурт", undefined, 1),
tobacco("bliss-lime", "Bliss", "Лайм", undefined, 1),
tobacco("bliss-love-shot", "Bliss", "Love Shot", "Маракуйя, арбуз, лимон, холодок", 1, true),
tobacco("bliss-strawberry", "Bliss", "Клубника", undefined, 1),
tobacco("muassel-cinnamon-gum", "Muassel", "Жвачка с корицей", undefined, 3),
tobacco("chabacco-strawberry-milksake", "Chabacco", "Mix", "Клубничный милкшейк", 2),
tobacco("joy-kiwichanga", "Joy Kiwichanga", "Киви, фейхоа", undefined, 2, false, true, "https://alphahookah.ru/upload/iblock/7e9/ah3jnujz5k06qd717ml9wo52t0s3bi3v.png"),
tobacco("hooligan-grapefruit", "Хулиган", "Грейпфрутовый сок", undefined, 2),
tobacco("sarma-drunk-pear", "Сарма", "Дачная груша", undefined, 2),
tobacco("sarma-corn-paprika", "Сарма", "Кукуруза-паприка", undefined, 2),
tobacco("hooligan-flash", "Хулиган", "«Флеш»", "Медовая малина", 2),
tobacco("add-north", "Добавка", "Север", "Бустер", 1, true),
tobacco("add-cooling", "Добавка", "Холодок", undefined, 1, true),
];
export const tobaccoBrands = [...new Set(tobaccoFlavors.map((f) => f.brand))].sort((a, b) =>
a.localeCompare(b, "ru")
);
export const tobaccoCatalogNotice =
"Актуальный ассортимент по вашей карте. Изображение показывает упаковку или визуал бренда; наличие отмечено в каждой карточке.";
/** Витрина: генерации движка по всем профилям/крепостям/холодку. */
/** Табаки, которых нет на сайте (или они помечены недоступными), в витрину
* не берём: каждый микс собран из реального ассортимента «Вкусов». */
const siteUnavailableFlavorIds: string[] = FLAVORS.filter((f) => {
const t = tobaccoFlavors.find((x) => x.id === (f.catalogId ?? f.id));
return !!t && !t.available;
}).map((f) => f.id);
/** Витрина: генерации движка по всем профилям/крепостям/холодку. */
const showcase = generateShowcaseMixes(siteUnavailableFlavorIds);
/** Тег фильтра «Вкус» из категории вкуса состава; холодок-добавка — «лёд». */
const flavorTag = (flavorId: string): string | null => {
const f = getFlavor(flavorId);
if (!f) return null;
return f.category === "cooling" ? "лёд" : CATEGORY_LABELS[f.category];
};
export const hookahMixes: HookahMix[] = showcase.map(({ mix, strength, cooling }, index) => ({
id: `showcase-${index + 1}`,
name: mix.name,
desc: mix.description,
strength,
cooling,
flavorTags: [...new Set(mix.items.map((i) => flavorTag(i.flavorId)).filter((t): t is string => !!t))],
price: strength >= 5 ? PRICE_STRONG : PRICE_BASE,
featured: index < 5,
items: mix.items.map((i) => ({ flavorId: i.flavorId })),
}));
export const mixFlavorTags = [...new Set(hookahMixes.flatMap((m) => m.flavorTags))].sort(
(a, b) => a.localeCompare(b, "ru")
);
export const mixesCatalogNotice =
"Цены указаны за один кальян. Крепкие миксы — 1 800 ₽, остальные — 1 400 ₽.";
/* ------------------------------------------------------------------ */
/* PlayStation 5 */
/* Формат: название · изображение · жанр · макс. игроков */
/* ------------------------------------------------------------------ */
export type Ps5Game = {
id: string;
name: string;
genre: string;
maxPlayers: number;
image: string; // путь к обложке, пустая строка = CSS-плейсхолдер
accent?: string; // цвет для плейсхолдера
};
export const ps5Games: Ps5Game[] = [
{
id: "fifa-25",
name: "FIFA 25",
genre: "Футбольный симулятор",
maxPlayers: 4,
image: "/images/games/fifa25.jfif",
},
{
id: "ufc-5",
name: "UFC 5",
genre: "Файтинг",
maxPlayers: 2,
image: "/images/games/ufc5.jfif",
},
{
id: "mortal-kombat-1",
name: "Mortal Kombat 1",
genre: "Файтинг",
maxPlayers: 2,
image: "/images/games/mk1.jpg",
},
{
id: "move-or-die",
name: "Move or Die",
genre: "Кооперативная аркада",
maxPlayers: 4,
image: "/images/games/mod.jpg",
},
{
id: "overcooked",
name: "Overcooked",
genre: "Кооперативная аркада",
maxPlayers: 4,
image: "/images/games/over.jpg",
},
/* --- сингл-игры --- */
{
id: "god-of-war-ragnarok",
name: "God of War Ragnarök",
genre: "Экшен-приключение",
maxPlayers: 1,
image: "/images/games/gowr.jpg",
},
{
id: "god-of-war",
name: "God of War",
genre: "Экшен-приключение",
maxPlayers: 1,
image: "/images/games/gow.jpg",
},
{
id: "spider-man-remastered",
name: "Marvel's Spider-Man Remastered",
genre: "Экшен-приключение",
maxPlayers: 1,
image: "/images/games/smr.jpg",
},
];
export const gameGenres = [...new Set(ps5Games.map((g) => g.genre))].sort((a, b) =>
a.localeCompare(b, "ru")
);
export const gamesCatalogNotice =
"Количество геймпадов уточняйте при бронировании VIP-зала.";
/* ------------------------------------------------------------------ */
/* Настольные игры */
/* Формат: название · тип · игроки (мин–макс) · время · сложность */
/* ------------------------------------------------------------------ */
export type BoardGameCategory =
| "Карточные"
| "Классика"
| "Компанейские"
| "Стратегия"
| "На логику";
export type BoardGameDifficulty = 1 | 2 | 3; // 1 — правила за минуту, 3 — надо вникать
export type BoardGame = {
id: string;
name: string;
category: BoardGameCategory;
minPlayers: number;
maxPlayers: number;
duration: string; // «20–40 мин»
difficulty: BoardGameDifficulty;
desc: string;
icon: string; // ключ иконки для карточки
accent: string; // цвет плейсхолдера
hit?: boolean;
};
export const boardGameDifficultyLabels: Record<BoardGameDifficulty, string> = {
1: "Простая",
2: "Средняя",
3: "Сложная",
};
export const boardGames: BoardGame[] = [
{
id: "mafia",
name: "Мафия",
category: "Компанейские",
minPlayers: 6,
maxPlayers: 16,
duration: "40–90 мин",
difficulty: 1,
desc: "Город засыпает — просыпается мафия. Психологическая игра на блеф и дедукцию для большой компании.",
icon: "eye",
accent: "#b026ff",
hit: true,
},
{
id: "bunker",
name: "Бункер",
category: "Компанейские",
minPlayers: 4,
maxPlayers: 16,
duration: "40–60 мин",
difficulty: 1,
desc: "Апокалипсис близко, а мест в бункере на всех не хватит. Докажи, что именно ты нужен человечеству.",
icon: "skull",
accent: "#00f0ff",
hit: true,
},
{
id: "cards",
name: "Игральные карты",
category: "Карточные",
minPlayers: 2,
maxPlayers: 8,
duration: "10–60 мин",
difficulty: 1,
desc: "Классическая колода 54 карты: дурак, покер, 21, бридж — играйте во что угодно.",
icon: "spade",
accent: "#ff4d6d",
hit: true,
},
{
id: "chess",
name: "Шахматы",
category: "Классика",
minPlayers: 2,
maxPlayers: 2,
duration: "20–60 мин",
difficulty: 3,
desc: "Королевская игра. Дуэль умов на 64 клетках под неоновым светом.",
icon: "crown",
accent: "#e0e0e0",
},
{
id: "backgammon",
name: "Нарды",
category: "Классика",
minPlayers: 2,
maxPlayers: 2,
duration: "20–40 мин",
difficulty: 2,
desc: "Длинные и короткие нарды. Кости, стратегия и немного удачи — идеально под кальян.",
icon: "dices",
accent: "#f4a261",
hit: true,
},
{
id: "domino",
name: "Домино",
category: "Классика",
minPlayers: 2,
maxPlayers: 4,
duration: "15–30 мин",
difficulty: 1,
desc: "Забей «козла» — простая и азартная игра, в которую можно играть часами.",
icon: "grid",
accent: "#ffd23d",
},
{
id: "checkers",
name: "Шашки",
category: "Классика",
minPlayers: 2,
maxPlayers: 2,
duration: "10–30 мин",
difficulty: 1,
desc: "Русские шашки и поддавки. Быстро, азартно, знакомо с детства.",
icon: "circle",
accent: "#4dff88",
},
{
id: "uno",
name: "UNO",
category: "Карточные",
minPlayers: 2,
maxPlayers: 10,
duration: "15–30 мин",
difficulty: 1,
desc: "Скинь все карты первым. +4, пропуск хода и разворот — главное, не забудь крикнуть «УНО!».",
icon: "hash",
accent: "#ff3636",
hit: true,
},
{
id: "monopoly",
name: "Монополия",
category: "Стратегия",
minPlayers: 2,
maxPlayers: 6,
duration: "60–120 мин",
difficulty: 2,
desc: "Скупай улицы, строй отели и разоряй друзей. Классика, которая ссорит компании уже сто лет.",
icon: "landmark",
accent: "#2ec27e",
},
{
id: "jenga",
name: "Дженга",
category: "Компанейские",
minPlayers: 2,
maxPlayers: 8,
duration: "10–20 мин",
difficulty: 1,
desc: "Вытаскивай брусок и клади наверх. Кто уронит башню — тот и проиграл. Твёрдая рука обязательна.",
icon: "layout",
accent: "#d4af37",
},
{
id: "alias",
name: "Alias",
category: "Компанейские",
minPlayers: 4,
maxPlayers: 12,
duration: "30–60 мин",
difficulty: 1,
desc: "Объясни слово команде, не называя его. Игра на скорость мысли и командную химию.",
icon: "brain",
accent: "#ff8a00",
},
{
id: "imaginarium",
name: "Имаджинариум",
category: "Компанейские",
minPlayers: 4,
maxPlayers: 7,
duration: "30–60 мин",
difficulty: 1,
desc: "Загадай ассоциацию к карточке с сюрреалистичной картинкой. Творческая игра для душевной компании.",
icon: "puzzle",
accent: "#c77dff",
},
{
id: "crocodile",
name: "Крокодил",
category: "Компанейские",
minPlayers: 4,
maxPlayers: 16,
duration: "20–60 мин",
difficulty: 1,
desc: "Покажи слово жестами — без звуков и подсказок. Гарантированно смешно.",
icon: "users",
accent: "#00c2a8",
},
{
id: "poker",
name: "Покерный набор",
category: "Карточные",
minPlayers: 2,
maxPlayers: 9,
duration: "60–180 мин",
difficulty: 2,
desc: "Фишки, колоды и дилерская кнопка. Техасский холдем на серьёзных щах — но только на интерес.",
icon: "club",
accent: "#1fa37a",
},
{
id: "exploding-kittens",
name: "Взрывные котята",
category: "Карточные",
minPlayers: 2,
maxPlayers: 5,
duration: "15 мин",
difficulty: 1,
desc: "Русская рулетка с котиками. Тяни карту и молись, чтобы не попался взрывной котёнок.",
icon: "ghost",
accent: "#ff6b9d",
},
{
id: "catan",
name: "Колонизаторы (Catan)",
category: "Стратегия",
minPlayers: 3,
maxPlayers: 4,
duration: "60–90 мин",
difficulty: 2,
desc: "Строй дороги и поселения, торгуй ресурсами. Одна из лучших стратегий в истории настолок.",
icon: "castle",
accent: "#e9c46a",
},
{
id: "dixit",
name: "Диксит",
category: "Компанейские",
minPlayers: 3,
maxPlayers: 6,
duration: "30 мин",
difficulty: 1,
desc: "Расскажи историю по картинке так, чтобы угадала часть игроков — но не все.",
icon: "heart",
accent: "#f77f00",
},
{
id: "codenames",
name: "Кодовые имена",
category: "На логику",
minPlayers: 4,
maxPlayers: 8,
duration: "15–30 мин",
difficulty: 2,
desc: "Капитаны дают ассоциации одним словом, команда ищет своих агентов. Напряжённо и умно.",
icon: "target",
accent: "#4895ef",
},
];
export const boardGameCategories = [...new Set(boardGames.map((g) => g.category))].sort(
(a, b) => a.localeCompare(b, "ru")
);
export const boardGamesNotice =
"Все игры бесплатны для гостей — просто попросите у бармена. Наличие конкретной игры уточняйте на месте.";
+628
View File
@@ -0,0 +1,628 @@
/* ------------------------------------------------------------------ */
/* База вкусов для rule-based генератора миксов */
/* Владелец сайта добавляет новые вкусы только в этот файл: */
/* 1) дописать объект в массив FLAVORS; */
/* 2) если вводит новую категорию — добавить её в CATEGORY_MATRIX. */
/* Код движка и UI трогать не нужно. */
/* ------------------------------------------------------------------ */
export type FlavorCategory =
| "fruits"
| "berries"
| "citrus"
| "tropical"
| "dessert"
| "drinks"
| "spices"
| "cooling";
export type FlavorStrength = "light" | "medium" | "strong";
export type FlavorRole = "base" | "accent";
export type ProfileId =
| "fresh"
| "dessert"
| "tropical"
| "berry"
| "citrus";
export interface Flavor {
id: string; // slug, латиницей — используется в share-ссылках
catalogId?: string; // id этого вкуса в каталоге tobaccoFlavors (data/catalog.ts), если слаги различаются; по нему работает стоп-лист CRM
name: string; // название для гостя
brand: string; // производитель (для карточки)
category: FlavorCategory;
intensity: 1 | 2 | 3 | 4 | 5; // «громкость» вкуса, 1 — фон, 5 — доминанта
sweetness: number; // 0–100
sourness: number; // 0–100
cooling: number; // 0–100, собственная ментоловость вкуса
roles: FlavorRole[]; // в каких ролях может стоять в чаше
pairs_with: FlavorCategory[]; // предпочтительные категории-партнёры
avoid_with: string[]; // жёсткий запрет: id или категория
strength: FlavorStrength | "any"; // any — добавка, подходит под любую крепость
popularity: number; // 0–1, стартовый вес в случайном выборе
}
/* Категории, с которых микс может начинаться (основа) по профилям */
export const PROFILES: Record<
ProfileId,
{ label: string; baseCategories: FlavorCategory[] }
> = {
fresh: { label: "Свежий", baseCategories: ["citrus", "fruits", "berries"] },
dessert: {
label: "Десертный",
baseCategories: ["dessert", "drinks", "tropical", "berries"],
},
tropical: { label: "Тропический", baseCategories: ["tropical", "fruits"] },
berry: { label: "Ягодный", baseCategories: ["berries"] },
citrus: { label: "Цитрусовый", baseCategories: ["citrus"] },
};
export const CATEGORY_LABELS: Record<FlavorCategory, string> = {
fruits: "Фрукты",
berries: "Ягоды",
citrus: "Цитрус",
tropical: "Тропики",
dessert: "Десерты",
drinks: "Напитки",
spices: "Пряности",
cooling: "Холодок",
};
export const STRENGTH_LABELS: Record<FlavorStrength, string> = {
light: "Лёгкая",
medium: "Средняя",
strong: "Крепкая",
};
/* Матрица совместимости категорий:
1 — отлично дружат;
0 — нейтрально, генератор берёт, но с весом ×0.5;
-1 — конфликт, такая пара запрещена. */
export const CATEGORY_MATRIX: Record<
FlavorCategory,
Partial<Record<FlavorCategory, number>>
> = {
berries: {
berries: 1,
citrus: 1,
fruits: 1,
tropical: 1,
dessert: 1, // ягоды со сливками
drinks: 0,
spices: 0,
cooling: 1,
},
citrus: {
citrus: 1,
fruits: 1,
tropical: 1,
dessert: 0,
drinks: 1, // лимон в колу/лимонад
spices: 1, // лайм с базиликом (мохито)
cooling: 1,
},
fruits: {
fruits: 1,
tropical: 1,
dessert: 1, // яблочный пирог
drinks: 0,
spices: 0,
cooling: 1,
},
tropical: {
tropical: 1,
dessert: 1, // банан со сливками, пина-колада
drinks: 1,
spices: 0,
cooling: 1,
},
dessert: {
dessert: 1,
drinks: 1, // ванильная кола, печенье с латте
spices: 0,
cooling: 1, // шоколад-мята, фисташковый лёд
},
drinks: {
drinks: 1,
spices: -1, // кола с базиликом не работает
cooling: 1,
},
spices: {
spices: 0,
cooling: 1, // мята-базилик
},
cooling: {
cooling: -1, // два холодка в одной чаше — перебор
},
};
/* 20 актуальных вкусов из ассортимента бара */
export const FLAVORS: Flavor[] = [
/* ---------------- Крепкие (Blackburn) ---------------- */
{
id: "white-grape",
catalogId: "bb-white-grape",
name: "White Grape — белый виноград",
brand: "Blackburn",
category: "berries",
intensity: 3,
sweetness: 72,
sourness: 18,
cooling: 0,
roles: ["base"],
pairs_with: ["dessert", "citrus", "cooling"],
avoid_with: [],
strength: "strong",
popularity: 0.55,
},
{
id: "blueberry-bb",
catalogId: "bb-blueberry",
name: "Blueberry — голубика",
brand: "Blackburn",
category: "berries",
intensity: 4,
sweetness: 60,
sourness: 30,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["dessert", "citrus", "cooling"],
avoid_with: [],
strength: "strong",
popularity: 0.7,
},
{
id: "lemon-shock",
catalogId: "bb-lemon-shock",
name: "Lemon Shock — ультракислый лимон",
brand: "Blackburn",
category: "citrus",
intensity: 5,
sweetness: 25,
sourness: 92,
cooling: 0,
roles: ["accent", "base"],
pairs_with: ["berries", "fruits", "tropical", "cooling"],
avoid_with: ["dessert"],
strength: "strong",
popularity: 0.75,
},
{
id: "pineapple-bb",
catalogId: "bb-pineapple",
name: "Pineapple — сочный ананас",
brand: "Blackburn",
category: "tropical",
intensity: 4,
sweetness: 75,
sourness: 35,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["citrus", "dessert", "cooling"],
avoid_with: [],
strength: "strong",
popularity: 0.6,
},
{
id: "mango-bb",
catalogId: "bb-juicy-mango",
name: "Сочное манго",
brand: "Blackburn",
category: "tropical",
intensity: 4,
sweetness: 80,
sourness: 15,
cooling: 0,
roles: ["base"],
pairs_with: ["citrus", "dessert", "berries"],
avoid_with: [],
strength: "strong",
popularity: 0.66,
},
{
id: "bananini",
catalogId: "bb-bananini",
name: "Bananini — банан",
brand: "Blackburn",
category: "tropical",
intensity: 3,
sweetness: 78,
sourness: 5,
cooling: 0,
roles: ["base"],
pairs_with: ["dessert", "berries"],
avoid_with: ["citrus"], // банан с кислым лимоном распадается
strength: "strong",
popularity: 0.42,
},
{
id: "brownie",
catalogId: "bb-brownie",
name: "Брауни",
brand: "Blackburn",
category: "dessert",
intensity: 4,
sweetness: 85,
sourness: 0,
cooling: 0,
roles: ["base"],
pairs_with: ["berries", "drinks", "tropical"],
avoid_with: ["citrus"], // шоколад + агрессивная кислота = конфликт
strength: "strong",
popularity: 0.5,
},
{
id: "pistachio-ice-snow",
catalogId: "bb-pistachio-ice",
name: "Pistachio Ice Snow — фисташковое мороженое",
brand: "Blackburn",
category: "dessert",
intensity: 3,
sweetness: 70,
sourness: 5,
cooling: 35,
roles: ["base", "accent"],
pairs_with: ["drinks", "berries"],
avoid_with: [],
strength: "strong",
popularity: 0.45,
},
{
id: "basilic",
catalogId: "bb-basilic",
name: "Basilic — базилик",
brand: "Blackburn",
category: "spices",
intensity: 3,
sweetness: 10,
sourness: 20,
cooling: 0,
roles: ["accent"],
pairs_with: ["citrus", "cooling", "fruits"],
avoid_with: ["drinks"],
strength: "strong",
popularity: 0.38,
},
{
id: "classic-cola",
catalogId: "bb-cola",
name: "Классическая кола",
brand: "Blackburn",
category: "drinks",
intensity: 4,
sweetness: 65,
sourness: 10,
cooling: 0,
roles: ["base"],
pairs_with: ["dessert", "citrus", "cooling"],
avoid_with: ["spices"],
strength: "strong",
popularity: 0.58,
},
{
id: "honey-melon",
catalogId: "bb-honey-melon",
name: "Медовая дыня",
brand: "Blackburn",
category: "fruits",
intensity: 3,
sweetness: 68,
sourness: 12,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["citrus", "cooling", "tropical"],
avoid_with: [],
strength: "strong",
popularity: 0.44,
},
/* ---------------- Средняя крепость (Undercoal / Banger) ---------------- */
{
id: "pinkman",
catalogId: "uc-pinkman",
name: "Pinkman — клубника, малина, грейпфрут",
brand: "Undercoal",
category: "berries",
intensity: 4,
sweetness: 45,
sourness: 60,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["citrus", "dessert", "cooling"],
avoid_with: [],
strength: "medium",
popularity: 0.72,
},
{
id: "pear-lemon-tarhun",
catalogId: "uc-pear-tarragon",
name: "Груша, лимон и тархун",
brand: "Undercoal",
category: "citrus",
intensity: 3,
sweetness: 40,
sourness: 55,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["fruits", "berries", "cooling"],
avoid_with: [],
strength: "medium",
popularity: 0.5,
},
{
id: "cookie-uc",
catalogId: "uc-cookie",
name: "Cookie — печенье",
brand: "Undercoal",
category: "dessert",
intensity: 3,
sweetness: 80,
sourness: 5,
cooling: 0,
roles: ["base"],
pairs_with: ["drinks", "berries", "tropical"],
avoid_with: [],
strength: "medium",
popularity: 0.52,
},
{
id: "green-day",
catalogId: "banger-green-day",
name: "Green Day — яблоко, киви, кислинка",
brand: "Banger",
category: "fruits",
intensity: 3,
sweetness: 35,
sourness: 55,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["citrus", "cooling", "berries"],
avoid_with: [],
strength: "medium",
popularity: 0.6,
},
/* ---------------- Лёгкая крепость (Bliss) ---------------- */
{
id: "bliss-lime",
name: "Лайм",
brand: "Bliss",
category: "citrus",
intensity: 4,
sweetness: 30,
sourness: 70,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["berries", "fruits", "tropical", "cooling"],
avoid_with: [],
strength: "light",
popularity: 0.57,
},
{
id: "bliss-pomelo",
catalogId: "bb-pomelo",
name: "Помело",
brand: "Bliss",
category: "citrus",
intensity: 3,
sweetness: 35,
sourness: 55,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["tropical", "fruits", "berries"],
avoid_with: [],
strength: "light",
popularity: 0.48,
},
{
id: "bliss-mango-chamomile",
name: "Манго-ромашка",
brand: "Bliss",
category: "tropical",
intensity: 3,
sweetness: 70,
sourness: 20,
cooling: 0,
roles: ["base"],
pairs_with: ["citrus", "berries", "dessert"],
avoid_with: [],
strength: "light",
popularity: 0.55,
},
{
id: "bliss-strawberry",
name: "Клубника",
brand: "Bliss",
category: "berries",
intensity: 3,
sweetness: 62,
sourness: 35,
cooling: 0,
roles: ["base", "accent"],
pairs_with: ["citrus", "dessert", "cooling", "tropical"],
avoid_with: [],
strength: "light",
popularity: 0.63,
},
/* ---------------- Добавка-холодок (под любую крепость) ---------------- */
{
id: "frost",
catalogId: "add-cooling",
name: "Super Frost — холодок",
brand: "Добавка",
category: "cooling",
intensity: 3,
sweetness: 0,
sourness: 0,
cooling: 100,
roles: ["accent"],
pairs_with: ["citrus", "fruits", "berries", "tropical", "drinks", "dessert"],
avoid_with: ["spices"],
strength: "any",
popularity: 0.9, // добавка есть всегда, высока вероятность попадания
},
];
/* ------------------------------------------------------------------ */
/* Кураторские миксы — страховка, если генератор за 50 попыток */
/* не собрал валидный микс (например, узкий профиль + стоп-лист). */
/* ------------------------------------------------------------------ */
export interface CuratedMix {
id: string;
name: string;
profile: ProfileId;
strength: FlavorStrength;
cooling: boolean;
recipe: { flavorId: string; percent: number }[];
}
export const CURATED_MIXES: CuratedMix[] = [
{
id: "cur-cyan-breeze",
name: "Циановый бриз",
profile: "fresh",
strength: "medium",
cooling: true,
recipe: [
{ flavorId: "green-day", percent: 55 },
{ flavorId: "pinkman", percent: 30 },
{ flavorId: "frost", percent: 15 },
],
},
{
id: "cur-frost-melon",
name: "Морозная дыня",
profile: "fresh",
strength: "strong",
cooling: true,
recipe: [
{ flavorId: "honey-melon", percent: 55 },
{ flavorId: "lemon-shock", percent: 30 },
{ flavorId: "frost", percent: 15 },
],
},
{
id: "cur-midnight-grape",
name: "Полночный виноград",
profile: "berry",
strength: "strong",
cooling: false,
recipe: [
{ flavorId: "white-grape", percent: 60 },
{ flavorId: "blueberry-bb", percent: 40 },
],
},
{
id: "cur-velvet-plombir",
name: "Бархатный пломбир",
profile: "dessert",
strength: "strong",
cooling: false,
recipe: [
{ flavorId: "pistachio-ice-snow", percent: 55 },
{ flavorId: "brownie", percent: 30 },
{ flavorId: "classic-cola", percent: 15 },
],
},
{
id: "cur-sunset-lagoon",
name: "Закатная лагуна",
profile: "tropical",
strength: "light",
cooling: true,
recipe: [
{ flavorId: "bliss-mango-chamomile", percent: 60 },
{ flavorId: "bliss-lime", percent: 25 },
{ flavorId: "frost", percent: 15 },
],
},
{
id: "cur-neon-citrus",
name: "Неоновый цитрус",
profile: "citrus",
strength: "light",
cooling: false,
recipe: [
{ flavorId: "bliss-pomelo", percent: 65 },
{ flavorId: "bliss-lime", percent: 35 },
],
},
];
/** Эпитет в трёх родах — согласуется с названием вкуса («Морозная дыня») */
export interface EpithetForms {
m: string;
f: string;
n: string;
}
/* Неоновые эпитеты для генерации названий по профилям */
export const PROFILE_EPITHETS: Record<ProfileId, EpithetForms[]> = {
fresh: [
{ m: "Морозный", f: "Морозная", n: "Морозное" },
{ m: "Ледяной", f: "Ледяная", n: "Ледяное" },
{ m: "Кристальный", f: "Кристальная", n: "Кристальное" },
{ m: "Полярный", f: "Полярная", n: "Полярное" },
],
dessert: [
{ m: "Бархатный", f: "Бархатная", n: "Бархатное" },
{ m: "Ночной", f: "Ночная", n: "Ночное" },
{ m: "Вельветовый", f: "Вельветовая", n: "Вельветовое" },
{ m: "Сливочный", f: "Сливочная", n: "Сливочное" },
],
tropical: [
{ m: "Тропический", f: "Тропическая", n: "Тропическое" },
{ m: "Закатный", f: "Закатная", n: "Закатное" },
{ m: "Солнечный", f: "Солнечная", n: "Солнечное" },
{ m: "Спелый", f: "Спелая", n: "Спелое" },
],
berry: [
{ m: "Неоновый", f: "Неоновая", n: "Неоновое" },
{ m: "Пурпурный", f: "Пурпурная", n: "Пурпурное" },
{ m: "Космический", f: "Космическая", n: "Космическое" },
{ m: "Ягодный", f: "Ягодная", n: "Ягодное" },
],
citrus: [
{ m: "Искрящийся", f: "Искрящаяся", n: "Искрящееся" },
{ m: "Звонкий", f: "Звонкая", n: "Звонкое" },
{ m: "Цитрусовый", f: "Цитрусовая", n: "Цитрусовое" },
{ m: "Бодрящий", f: "Бодрящая", n: "Бодрящее" },
],
};
/* Фирменные «хвосты» для однострочного описания по профилям */
/* Перевод вкуса для гостей: часть из названий миксов пришла на английском,
поэтому даём русское прочтение каждого вкуса. */
export const FLAVOR_TRANSLATIONS: Record<string, string> = {
"white-grape": "Белый виноград",
"blueberry-bb": "Голубика",
"lemon-shock": "Ультракислый лимон",
"pineapple-bb": "Сочный ананас",
"mango-bb": "Сочное манго",
bananini: "Банан",
brownie: "Шоколадный брауни",
"pistachio-ice-snow": "Фисташковое мороженое",
basilic: "Базилик",
"classic-cola": "Классическая кола",
"honey-melon": "Медовая дыня",
pinkman: "Клубника, малина и грейпфрут",
"pear-lemon-tarhun": "Груша, лимон и тархун",
"cookie-uc": "Печенье",
"green-day": "Яблоко, киви и кислинка",
"bliss-lime": "Лайм",
"bliss-pomelo": "Помело",
"bliss-mango-chamomile": "Манго-ромашка",
"bliss-strawberry": "Клубника",
frost: "Холодок (Super Frost)",
};
export const PROFILE_TAILS: Record<ProfileId, string[]> = {
fresh: ["свежесть до утра", "ледяной и чистый финиш", "прохлада с первой затяжки"],
dessert: ["для долгих разговоров", "густое сладкое послевкусие", "мягкий десертный дым"],
tropical: ["как закат у бара", "сочный тропический характер", "волна спелых фруктов"],
berry: ["яркая ягодная волна", "сочная ягодная кислинка", "спелые ягоды в каждом вдохе"],
citrus: ["бодрая цитрусовая искра", "звонкая кислинка на выдохе", "энергия чистого цитруса"],
};
+72
View File
@@ -0,0 +1,72 @@
/**
* Реестр фотографий, которые реально присутствуют в public/menu.
* Ключи — id позиций меню, поэтому фотографию нельзя случайно привязать к соседнему товару.
*
* Для авторского чая используются файлы из того же набора изображений:
* названия фотографий исторически отличаются от названий позиций в каталоге,
* но визуально и по составу соответствуют конкретным позициям.
*/
export const menuPhotos: Record<string, string> = {
// Авторский чай
"blackberry-mint": "./menu/myatnaya-ezhevika.avif",
"raspberry-mint": "./menu/myatnaya-malina.avif",
"seabuckthorn-orange": "./menu/osennyaya-oblepikha.avif",
"seabuckthorn-cinnamon": "./menu/pryanyy-apelsin.avif",
"forest-berries": "./menu/yagodnyy-vzryv.avif",
"mango-seabuckthorn": "./menu/mangovyy-ray.avif",
"raspberry-ginger": "./menu/malinovyy-s-imbirem.avif",
// Пивная карта
bud: "./menu/bud.avif",
"kozel-dark": "./menu/kozel-temnyy.avif",
"stary-melnik-light": "./menu/staryy-melnik-svetloe.avif",
"krone-blanche": "./menu/krone-blanche.avif",
erzmann: "./menu/erzmann.avif",
"el-capulco": "./menu/el-capulco.avif",
stella: "./menu/stella.avif",
"kozel-light": "./menu/kozel-svetlyy.avif",
"stary-melnik-dark": "./menu/staryy-melnik-temnoe.avif",
"pablos-lime": "./menu/pablo-s-s-sokom-layma.avif",
redds: "./menu/redd-s.avif",
spaten: "./menu/spatten.avif",
hoegaarden: "./menu/hoegaarden.avif",
"corona-extra": "./menu/corona-extra.avif",
// Закуски
"onion-rings": "./menu/lukovye-koltsa.avif",
fries: "./menu/kartofel-fri.avif",
"buffalo-wings": "./menu/krylyshki-baffalo.avif",
"cheese-sticks": "./menu/syrnye-palochki.avif",
strips: "./menu/stripsy.avif",
nuggets: "./menu/nagetsy.avif",
// Напитки и десерты
"black-tea": "./menu/chernyy-chay.avif",
"black-tea-thyme": "./menu/chernyy-chay-s-chabretsom.avif",
"milk-oolong": "./menu/molochnyy-ulun.avif",
americano: "./menu/amerikano.avif",
cappuccino: "./menu/kapuchino.avif",
"cheesecake-ny": "./menu/chizkeyk-nyu-york.avif",
"red-velvet": "./menu/tort-krasnyy-barkhat.avif",
medovik: "./menu/tort-medovik.avif",
"cheesecake-strawberry": "./menu/chizkeyk-nyu-york-klubnichnyy.avif",
"cheesecake-raspberry": "./menu/chizkeyk-nyu-york-malina.avif",
// Снеки и сладости
"alpen-gold": "./menu/alpen-gold.avif",
milka: "./menu/milka.avif",
leys: "./menu/chipsy-leys.avif",
korochki: "./menu/3-korochki.avif",
hrustim: "./menu/khrustim-team.avif",
// Лимонады и энергетики
"natakhtari-pear": "./menu/natakhtari-grusha.avif",
"strawberry-kiwi": "./menu/klubnika-kivi.avif",
feijoa: "./menu/feykhoa.avif",
"natakhtari-feijoa": "./menu/natakhtari-feykhoa.avif",
"natakhtari-saperavi": "./menu/natakhtari-saperavi.avif",
"burn-original": "./menu/burn-originalnyy.avif",
"adrenaline-rush": "./menu/adrenaline-rush-absolyutnaya-energiya.avif",
"burn-apple-kiwi": "./menu/burn-yabloko-kivi.avif",
"burn-mango": "./menu/burn-so-vkusom-mango.avif",
};
+94
View File
@@ -0,0 +1,94 @@
export interface TeaItem {
id: string;
name: string;
category: string;
volume: string;
price: number;
priceFormatted: string;
desc: string;
ingredients: string[];
accentColor: string; // Неоновый цвет рамки
}
export const TEA_CATEGORY = "АВТОРСКИЙ ЧАЙ";
export const authorTeas: TeaItem[] = [
{
id: "blackberry-mint",
name: "Ежевика-мята",
category: TEA_CATEGORY,
volume: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Глубокий рубиновый настой спелой ежевики со свежей мятой. Освежающий, с лёгкой природной кислинкой и долгим послевкусием.",
ingredients: ["Спелая ежевика", "Перечная мята", "Чайный сбор", "Тростниковый сахар"],
accentColor: "#a855f7",
},
{
id: "raspberry-mint",
name: "Малина-мята",
category: TEA_CATEGORY,
volume: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Нежный ягодный чай на основе сочной лесной малины и ароматных мятных листьев. Согревающий и насыщенный вкус.",
ingredients: ["Лесная малина", "Свежая мята", "Цветочный чай", "Натуральный сироп"],
accentColor: "#ec4899",
},
{
id: "seabuckthorn-orange",
name: "Облепиха-апельсин",
category: TEA_CATEGORY,
volume: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Золотистый витаминный чай с сочными кружочками апельсина и отборной сибирской облепихой. Яркий цитрусовый заряд.",
ingredients: ["Ягоды облепихи", "Свежий апельсин", "Апельсиновый сок", "Мёд"],
accentColor: "#f59e0b",
},
{
id: "seabuckthorn-cinnamon",
name: "Облепиха-корица",
category: TEA_CATEGORY,
volume: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Пряный согревающий купаж с цельными палочками корицы, кружками апельсина и яркой облепихой. Идеально под вечерний кальян.",
ingredients: ["Облепиха", "Палочки корицы", "Дольки апельсина", "Гвоздика", "Мёд"],
accentColor: "#f97316",
},
{
id: "forest-berries",
name: "Лесные ягоды",
category: TEA_CATEGORY,
volume: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Густой ягодный микс из спелой ежевики и малины. Насыщенный цвет, природная сладость и лёгкая ягодная терпкость.",
ingredients: ["Ежевика", "Малина", "Чёрный байховый чай", "Ягодный концентрат"],
accentColor: "#d946ef",
},
{
id: "mango-seabuckthorn",
name: "Манго-облепиха",
category: TEA_CATEGORY,
volume: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Экзотический микс нежной мякоти манго и сибирской облепихи. Солнечный тропический вкус с бархатистой текстурой.",
ingredients: ["Кусочки спелого манго", "Облепиха", "Тропический чай", "Фруктовый нектар"],
accentColor: "#eab308",
},
{
id: "raspberry-ginger",
name: "Малина-имбирь",
category: TEA_CATEGORY,
volume: "900 мл",
price: 350,
priceFormatted: "350 ₽",
desc: "Пикантное сочетание спелой малины и тонких слайсов свежего имбиря. Бодрит, согревает и оставляет приятную лёгкую остринку.",
ingredients: ["Малина", "Свежий корень имбиря", "Лимонный сок", "Чайный сбор"],
accentColor: "#ef4444",
},
];
+117
View File
@@ -0,0 +1,117 @@
/* Переводы вкусов для каталога табаков (src/data/catalog.ts).
Ключ — id табака из массива tobaccoFlavors.
Показывается на карточке как «Перевод вкуса», чтобы гость сразу понимал,
что кроется за английским названием. */
export const tobaccoTranslations: Record<string, string> = {
/* ---------------- Blackburn ---------------- */
"bb-cola": "Классическая кола",
"bb-lulo": "Луло (колумбийский цитрус)",
"bb-real-pf": "Маракуйя",
"bb-white-grape": "Белый виноград",
"bb-forest-raspberry": "Лесная малина",
"bb-brownie": "Шоколадный брауни",
"bb-blackberry-lemonade": "Ежевичный лимонад",
"bb-lemon-shock": "Ультракислый лимон",
"bb-blueberry": "Голубика",
"bb-apple-shock": "Кислое зелёное яблоко",
"bb-red-energy": "Энергетик",
"bb-kiwi-stoner": "Смузи из киви",
"bb-bananini": "Банан",
"bb-honey-melon": "Медовая дыня",
"bb-pineapple": "Сочный ананас",
"bb-grape-lollipop": "Виноградный чупа-чупс",
"bb-fir": "Ёлка (хвоя)",
"bb-red-orange": "Красный апельсин",
"bb-juicy-mango": "Сочное манго",
"bb-basilic": "Базилик",
"bb-berry-sorbet": "Ягодный сорбет с грейпфрутом",
"bb-ananas-shock": "Кислый ананас",
"bb-after-8": "Шоколад-мята",
"bb-overdose": "Лимон-лайм",
"bb-pina-colada": "Пина-колада",
"bb-pistachio-ice-snow": "Фисташковое мороженое",
"bb-pomelo": "Помело",
"bb-pear-lemonade": "Грушевый лимонад",
/* ---------------- Overdose ---------------- */
"od-lost-futura": "Мускусная вишня",
"od-frustyle": "Кактус-лайм",
"od-black-banano": "Амбровый банан",
"od-gin-cucumber": "Огуречный джин",
/* ---------------- Banger ---------------- */
"banger-black-white": "Печенье «Орео»",
"banger-cream-soda": "Крем-сода",
"banger-sexy": "Грейпфрут, клубника и малина",
"banger-grapefruit": "Грейпфрут",
"banger-green-day": "Яблоко, киви и кислинка",
"banger-cola-bella": "Ванильная кола",
/* ---------------- Undercoal ---------------- */
"uc-pinkman": "Клубника, малина и грейпфрут",
"uc-coconut-shake": "Кокосовый шейк",
"uc-caribbean-rum": "Карибский ром",
"uc-mango-rose": "Манго, маракуйя, личи и роза",
"uc-tropical-juice": "Тропический сок",
"uc-cactus-kiwi": "Кактус, киви и абсент",
"uc-milky-oolong": "Чай «Молочный улун»",
"uc-cranberry": "Клюква",
"uc-pear-tarragon": "Груша, лимон и тархун",
"uc-cinnamon-roll": "Коричная булочка",
"uc-cookie": "Печенье",
/* ---------------- Darkside ---------------- */
"ds-alpine-trip": "Хвоя, фейхоа и эвкалипт",
/* ---------------- Хулиган / WOOD ---------------- */
"wood-begemot": "Бергамот-мандарин",
"wood-boo": "Яблоко-гранат",
"wood-old": "Лимон с мёдом",
"wood-berry-ninja": "Лесные ягоды",
"wood-sir-hard": "Воздушный рис",
/* ---------------- Hupreme ---------------- */
"hupreme-milk-tea": "Чай с молоком",
"hupreme-gooseberry": "Крыжовник",
"hupreme-guava": "Гуава",
/* ---------------- JENT Alcohol Line ---------------- */
"jent-blue-agave": "Текила «Бум»",
"jent-havana": "Мохито",
"jent-cuba-libre": "Куба-Либре",
/* ---------------- DEUS ---------------- */
"deus-blackberry-wine": "Ежевичное вино",
"deus-guinness": "Тёмное пиво",
"deus-peonies": "Пионы",
"deus-violet-zephyr": "Фиалковый зефир",
"deus-cherry-jelly": "Вишнёвое желе",
"deus-pumpkin-pie": "Тыквенный пирог с миндалём",
"deus-blueberry-yogurt": "Черничный йогурт",
"deus-base": "Чистый табачный лист",
"deus-champagne": "Игристое вино (шампанское)",
/* ---------------- Северный ---------------- */
"north-flower": "Цветочный микс",
"north-feijoa": "Фейхоа с перцем",
"north-elderberry": "Бузина",
"north-sorbet": "Фруктовый сорбет",
/* ---------------- Bliss ---------------- */
"bliss-mango-chamomile": "Манго-ромашка",
"bliss-peach-yogurt": "Персик-йогурт",
"bliss-lime": "Лайм",
"bliss-love-shot": "Маракуйя, арбуз, лимон с холодком",
/* ---------------- Остальные ---------------- */
"muassel-cinnamon-gum": "Жвачка с корицей",
"chabacco-strawberry-milksake": "Клубничный милкшейк",
"joy-kiwichanga": "Киви и фейхоа",
"hooligan-grapefruit": "Грейпфрутовый сок",
"sarma-drunk-pear": "Дачная груша",
"sarma-corn-paprika": "Кукуруза с паприкой",
"hooligan-flash": "«Флеш» (медовая малина)",
"add-north": "Бустер «Север» (ароматизатор)",
"add-cooling": "Холодок (ароматизатор)",
};
+15
View File
@@ -0,0 +1,15 @@
import { useSyncExternalStore } from "react";
function subscribe(onChange: () => void) {
window.addEventListener("hashchange", onChange);
return () => window.removeEventListener("hashchange", onChange);
}
// Hash routing keeps the catalog refreshable on static hosting without rewrites.
export function useHashLocation() {
return useSyncExternalStore(
subscribe,
() => window.location.hash,
() => "",
);
}
+518
View File
@@ -0,0 +1,518 @@
@import "tailwindcss";
@theme {
--font-display: "Unbounded", "Manrope", ui-sans-serif, sans-serif;
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
--color-void: #0a0a0f;
--color-panel: #101018;
--color-mist: #e0e0e0;
--color-neon-violet: #b026ff;
--color-neon-cyan: #00f0ff;
--animate-kenburns: kenburns 24s ease-in-out infinite alternate;
--animate-marquee: marquee 32s linear infinite;
--animate-float-slow: floaty 9s ease-in-out infinite;
--animate-float-slower: floaty 14s ease-in-out infinite reverse;
--animate-pulse-dot: pulseDot 2.2s ease-in-out infinite;
--animate-ping-slow: pingSlow 2.6s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes kenburns {
from {
transform: scale(1.04) translateY(0);
}
to {
transform: scale(1.16) translateY(-2.5%);
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes floaty {
0%,
100% {
transform: translateY(0) translateX(0);
}
50% {
transform: translateY(-22px) translateX(12px);
}
}
@keyframes pulseDot {
0%,
100% {
opacity: 1;
box-shadow: 0 0 10px rgba(0, 240, 255, 0.9);
}
50% {
opacity: 0.35;
box-shadow: 0 0 2px rgba(0, 240, 255, 0.4);
}
}
@keyframes pingSlow {
0% {
transform: scale(1);
opacity: 0.7;
}
80%,
100% {
transform: scale(2.8);
opacity: 0;
}
}
@keyframes letterFlicker {
0% {
opacity: 0;
}
8% {
opacity: 1;
}
14% {
opacity: 0.12;
}
22% {
opacity: 1;
}
30% {
opacity: 0.3;
}
44% {
opacity: 1;
}
58% {
opacity: 0.55;
}
100% {
opacity: 1;
}
}
@keyframes loadbar {
from {
width: 0%;
}
to {
width: 100%;
}
}
html {
scroll-behavior: smooth;
overflow-x: hidden;
max-width: 100vw;
width: 100%;
}
body {
background-color: #0a0a0f;
color: #e0e0e0;
font-family: "Manrope", ui-sans-serif, system-ui, sans-serif;
overflow-x: hidden;
max-width: 100vw;
width: 100%;
position: relative;
}
#root {
overflow-x: hidden;
max-width: 100vw;
width: 100%;
position: relative;
}
/* слова никогда не рвём по буквам — только целиком по пробелам */
* {
overflow-wrap: normal;
word-break: normal;
}
h1,
h2,
h3 {
text-wrap: balance;
}
::selection {
background: rgba(176, 38, 255, 0.45);
color: #ffffff;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #0a0a0f;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(176, 38, 255, 0.55), rgba(0, 240, 255, 0.55));
border-radius: 8px;
border: 2px solid #0a0a0f;
}
section[id] {
scroll-margin-top: 84px;
}
/* ---------- utilities ---------- */
.glass {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.text-glow-violet {
text-shadow:
0 0 14px rgba(176, 38, 255, 0.65),
0 0 52px rgba(176, 38, 255, 0.35);
}
.text-glow-cyan {
text-shadow:
0 0 14px rgba(0, 240, 255, 0.6),
0 0 52px rgba(0, 240, 255, 0.3);
}
.text-outline {
-webkit-text-stroke: 1.5px rgba(224, 224, 224, 0.38);
color: transparent;
}
.neon-letter {
opacity: 0;
animation: letterFlicker 1.7s both;
}
.noise {
position: fixed;
inset: 0;
z-index: 95;
pointer-events: none;
opacity: 0.05;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* holographic sheen sweep on hover */
.holo-sheen {
position: relative;
overflow: hidden;
}
.holo-sheen::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 30%,
rgba(0, 240, 255, 0.1) 45%,
rgba(176, 38, 255, 0.14) 55%,
transparent 70%
);
transform: translateX(-130%);
transition: transform 0.9s ease;
pointer-events: none;
}
.holo-sheen:hover::before {
transform: translateX(130%);
}
/* ---------- hero headline neon glow (soft) ---------- */
.hero-glow-white {
text-shadow:
0 0 10px rgba(255, 255, 255, 0.22),
0 0 28px rgba(176, 38, 255, 0.32),
0 0 64px rgba(176, 38, 255, 0.16);
}
.hero-glow-gradient {
filter:
drop-shadow(0 0 8px rgba(232, 170, 255, 0.32))
drop-shadow(0 0 24px rgba(176, 38, 255, 0.26));
}
.hero-glow-outline {
filter:
drop-shadow(0 0 8px rgba(0, 240, 255, 0.38))
drop-shadow(0 0 24px rgba(0, 240, 255, 0.18));
}
@keyframes neonBreathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.965;
}
}
.hero-breathe {
animation: neonBreathe 6s ease-in-out infinite;
}
.map-grid {
background-image:
linear-gradient(rgba(0, 240, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.05) 1px, transparent 1px);
background-size: 44px 44px;
}
/* ---------- neon range slider ---------- */
.neon-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
outline: none;
cursor: pointer;
}
.neon-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #b026ff, #00f0ff);
border: 2px solid rgba(255, 255, 255, 0.55);
box-shadow: 0 0 18px rgba(176, 38, 255, 0.85);
cursor: pointer;
transition: transform 0.2s ease;
}
.neon-range::-webkit-slider-thumb:hover {
transform: scale(1.14);
}
.neon-range::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
background: linear-gradient(135deg, #b026ff, #00f0ff);
border: 2px solid rgba(255, 255, 255, 0.55);
box-shadow: 0 0 18px rgba(176, 38, 255, 0.85);
cursor: pointer;
}
.neon-range:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 4px rgba(0, 240, 255, 0.3), 0 0 18px rgba(176, 38, 255, 0.85);
}
@keyframes totalPop {
0% {
transform: scale(0.94);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.total-pop {
animation: totalPop 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes disclosureReveal {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.games-disclosure {
animation: disclosureReveal 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes catalogReveal {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.flavors-page {
animation: catalogReveal 400ms ease-out;
}
.catalog-result {
animation: catalogReveal 240ms ease-out;
}
.catalog-search::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
@media (prefers-reduced-motion: reduce) {
.games-disclosure,
.flavors-page,
.catalog-result {
animation: none;
}
}
/* ---------- меню: неоновая подсветка иконок ---------- */
/* цвет подсветки берётся из currentColor элемента (inline-стиль accent) */
.neon-icon {
box-shadow:
0 0 12px color-mix(in srgb, currentColor 32%, transparent),
inset 0 0 12px color-mix(in srgb, currentColor 16%, transparent);
}
.neon-icon:hover,
.group:hover .neon-icon {
box-shadow:
0 0 24px color-mix(in srgb, currentColor 62%, transparent),
0 0 46px color-mix(in srgb, currentColor 28%, transparent),
inset 0 0 14px color-mix(in srgb, currentColor 24%, transparent);
}
/* старые браузеры без color-mix(): текущий цвет не затемнить, отдаём
статичную циановую подсветку — иначе иконки в меню без свечения */
@supports not (color: color-mix(in srgb, red, blue)) {
.neon-icon {
box-shadow:
0 0 12px rgba(0, 240, 255, 0.35),
inset 0 0 12px rgba(0, 240, 255, 0.18);
}
.neon-icon:hover,
.group:hover .neon-icon {
box-shadow:
0 0 24px rgba(0, 240, 255, 0.62),
0 0 46px rgba(0, 240, 255, 0.28),
inset 0 0 14px rgba(0, 240, 255, 0.24);
}
}
/* ---------- меню: переливающаяся рамка при наведении ---------- */
@property --neon-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.neon-frame::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
/* значение по умолчанию: без регистрации @property (Safari < 16.4) var()
пустая и конический градиент целиком отваливается */
--neon-angle: 0deg;
background: conic-gradient(
from var(--neon-angle),
#b026ff,
#00f0ff,
#ff4ecd,
#00f0ff,
#b026ff
);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.neon-frame:hover::before,
.group:hover .neon-frame::before {
opacity: 1;
animation: neonFrameSpin 3s linear infinite;
}
@keyframes neonFrameSpin {
to {
--neon-angle: 360deg;
}
}
/* ---------- всплывающее окно 18+ ---------- */
@keyframes ageGateIn {
from {
opacity: 0;
transform: scale(0.92) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.age-gate {
animation: ageGateIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.age-gate {
animation: none;
}
.neon-frame:hover::before,
.group:hover .neon-frame::before {
animation: none;
}
}
/* Глобальный kill-switch анимаций (kenburns, marquee, floaty, pulse…):
частичные правила выше гасят только reveal-эффекты. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ---------- меню-кнопки: усиленная подсветка на телефонах/планшетах ---------- */
/* на тач-устройствах hover нет — свечение постоянное, с мягкой пульсацией;
desktop (lg+) не трогаем. prefers-reduced-motion гасит пульсацию глобально —
статичная усиленная тень остаётся из max-lg-утилит в классах кнопок */
@keyframes menuGlowPulse {
0%,
100% {
box-shadow:
0 0 30px 5px rgba(176, 38, 255, 0.7),
0 0 70px 12px rgba(176, 38, 255, 0.35);
}
50% {
box-shadow:
0 0 50px 10px rgba(176, 38, 255, 0.95),
0 0 110px 20px rgba(176, 38, 255, 0.5);
}
}
@media (max-width: 1023px) {
.menu-glow-mobile {
animation: menuGlowPulse 2.8s ease-in-out infinite;
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Расписание бара и окно приёма онлайн-заказов.
*
* Бар живёт по фиксированному UTC+5 — время считается от сдвинутой эпохи,
* поэтому часовой пояс устройства гостя не влияет на результат. Заказы
* принимаются, когда бар открыт, и «под открытие»: не раньше, чем за час
* до 19:00. Днём, пока бар закрыт, заказать нельзя.
*
* Это UX-дублирование: сервер (crm/server.py, bar_orders_gate) проверяет то
* же самое на своей стороне и является историей в последней инстанции.
*/
import { useEffect, useState } from "react";
/** Смещение барного часового пояса от UTC, минут (+5 часов). */
const BAR_TZ_OFFSET_MIN = 5 * 60;
/** Все сессии начинаются в 19:00 барного времени. */
const OPEN_MIN = 19 * 60;
/** До скольки утра работает «хвост» сессии, по дню недели (0 = вс). */
const CLOSE_MIN: readonly number[] = [
3 * 60, // вс: хвост субботы до 03:00
1 * 60, // пн: только хвост воскресенья до 01:00, сессий не открываем
0, // вт: ночного хвоста нет — в пн бар не работал
1 * 60, // ср: хвост вторника до 01:00
1 * 60, // чт: хвост среды до 01:00
1 * 60, // пт: хвост четверга до 01:00
3 * 60, // сб: хвост пятницы до 03:00
];
/** Понедельник — единственный день без своей сессии. */
const CLOSED_DAY = 1;
export interface BarMoment {
/** день недели барного времени, 0 = вс … 6 = сб */
day: number;
/** минуты от полуночи барного времени */
minutes: number;
}
/** Текущее барное время (UTC+5) независимо от часового пояса устройства. */
export function barMoment(now: Date = new Date()): BarMoment {
const shifted = new Date(now.getTime() + BAR_TZ_OFFSET_MIN * 60_000);
return {
day: shifted.getUTCDay(),
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
};
}
/** Открыт ли бар в указанный момент барного времени. */
export function isBarOpen({ day, minutes }: BarMoment): boolean {
if (minutes >= OPEN_MIN) return day !== CLOSED_DAY;
return minutes < CLOSE_MIN[day];
}
const DAY_WHEN = ["в воскресенье", "в понедельник", "во вторник", "в среду", "в четверг", "в пятницу", "в субботу"];
export type BarOrderWindow =
| { state: "open" }
| { state: "preorder"; opensAtMs: number; hint: string }
| { state: "closed"; opensAtMs: number; hint: string };
/** Момент следующего открытия (19:00 барного времени, кроме пн), ms эпохи. */
function nextOpeningMs(now: Date): number | null {
const shifted = new Date(now.getTime() + BAR_TZ_OFFSET_MIN * 60_000);
const base = Date.UTC(
shifted.getUTCFullYear(),
shifted.getUTCMonth(),
shifted.getUTCDate(),
19,
0,
0,
);
for (let d = 0; d < 8; d++) {
const candShifted = base + d * 86_400_000;
if (new Date(candShifted).getUTCDay() === CLOSED_DAY) continue;
const candRealMs = candShifted - BAR_TZ_OFFSET_MIN * 60_000;
if (candRealMs > now.getTime()) return candRealMs;
}
return null;
}
/** «сегодня» / «завтра» / «в пятницу» для момента открытия (барное время). */
function openingWhen(now: Date, opensAtMs: number): string {
const shiftedDay = (ms: number) => Math.floor((ms + BAR_TZ_OFFSET_MIN * 60_000) / 86_400_000);
const opensDay = shiftedDay(opensAtMs);
const today = shiftedDay(now.getTime());
const weekday = new Date(opensAtMs + BAR_TZ_OFFSET_MIN * 60_000).getUTCDay();
if (opensDay === today) return "сегодня";
if (opensDay === today + 1) return "завтра";
return DAY_WHEN[weekday];
}
/**
* Окно приёма заказов в момент now:
* - open — бар открыт, заказы идут как обычно;
* - preorder — до открытия не больше часа: можно заказать «к открытию»;
* - closed — бар закрыт и до открытия больше часа: заказ не принять.
*/
export function barOrderWindow(now: Date = new Date()): BarOrderWindow {
if (isBarOpen(barMoment(now))) return { state: "open" };
const opensAtMs = nextOpeningMs(now);
if (opensAtMs === null) return { state: "open" }; // расписание не кончается; не блокируем
const when = openingWhen(now, opensAtMs);
if (opensAtMs - now.getTime() <= 60 * 60_000) {
return { state: "preorder", opensAtMs, hint: `Открываемся ${when} в 19:00 — заказ примем к открытию` };
}
return {
state: "closed",
opensAtMs,
hint: `Бар закрыт — онлайн-заказы открываются за час до работы: ${when} в 19:00. Позвоните нам: +7 (912) 901-72-17`,
};
}
/** Окно приёма заказов как реактивное состояние: пересчитывается само,
* чтобы открытая надолго корзина «увидела» наступление часа открытия. */
export function useBarOrderWindow(pollMs = 30_000): BarOrderWindow {
const [win, setWin] = useState<BarOrderWindow>(() => barOrderWindow());
useEffect(() => {
const tick = () => setWin(barOrderWindow());
tick();
const timer = window.setInterval(tick, pollMs);
return () => window.clearInterval(timer);
}, [pollMs]);
return win;
}
+376
View File
@@ -0,0 +1,376 @@
/**
* Мост сайт ⇄ CRM (neon-bar86.ru).
*
* CRM живёт отдельным контейнером: в проде nginx проксирует /api/ на неё,
* в dev — Vite-прокси из vite.config.ts. Свой адрес можно задать через
* VITE_CRM_API_URL (например http://192.168.1.10:47613 для отладки).
*
* Всё деградирует молча: если CRM недоступна, сайт продолжает работать —
* просто без онлайн-заказов и со статичным стоп-листом из кода.
*/
import { useEffect, useState } from "react";
/** BASE = префикс CRM API. import.meta.env есть только в Vite-сборке —
* в Node (пререндер) читаем безопасно через optional chaining. */
const BASE = ((import.meta as { env?: Record<string, string | undefined> }).env?.VITE_CRM_API_URL ?? "").replace(/\/+$/, "");
/** Ссылка на ресурс CRM (например, загруженное фото позиции /media/...). */
export const crmUrl = (path: string) => BASE + path;
export interface Stoplist {
/** id позиций меню в стопе (совпадают с id в barMenu/beerMenu/teaMenu) */
menu: ReadonlySet<string>;
/** бренды табака в стопе (совпадают с brand в catalog/flavors) */
tobacco: ReadonlySet<string>;
/** true — CRM ответила и данные живые */
online: boolean;
}
const EMPTY_STOPLIST: Stoplist = { menu: new Set(), tobacco: new Set(), online: false };
/** Опрос стоп-листа раз в 60 с; ошибки не всплывают на гостей. */
export function useStoplist(pollMs = 60_000): Stoplist {
const [stoplist, setStoplist] = useState<Stoplist>(EMPTY_STOPLIST);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const r = await fetch(`${BASE}/api/stoplist`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(String(r.status));
const data = (await r.json()) as { stopped_menu: string[]; stopped_tobacco: string[] };
if (!alive) return;
setStoplist({
menu: new Set(data.stopped_menu ?? []),
tobacco: new Set(data.stopped_tobacco ?? []),
online: true,
});
} catch {
// одиночный сбой (таймаут/5xx) не должен «возвращать в наличие»
// стоп-позиции: держим последние известные данные, гасим только
// флаг online — заказ всё равно упадёт с понятной ошибкой
if (alive) setStoplist((prev) => (prev.online ? { ...prev, online: false } : prev));
}
};
load();
const timer = window.setInterval(load, pollMs);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [pollMs]);
return stoplist;
}
export interface SubmitOrderItem {
id: string;
name: string;
price: number;
qty: number;
}
export interface SubmitOrderPayload {
tableName: string;
guestName?: string;
guestPhone?: string;
/** пожелания гостя персоналу (официант/кальянщик видят в CRM) */
comment?: string;
items: SubmitOrderItem[];
/** honeypot против ботов: человек поле не видит и не заполняет */
hpCheck?: string;
}
export interface SubmittedOrder {
/** внутренний id заказа (стабилен, сбрасывается номер — не он) */
id: number;
/** недельный номер заказа — именно его видит гость и официант */
number: number;
price: number;
/** код отслеживания заказа — гость проверяет по нему статус на сайте */
code: string;
}
/** Отправка заказа из корзины в CRM. Бросает ошибку с человекочитаемым текстом. */
export async function submitOrder(payload: SubmitOrderPayload): Promise<SubmittedOrder> {
const r = await fetch(`${BASE}/api/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(8000),
body: JSON.stringify({
table_name: payload.tableName || "Сайт",
guest_name: payload.guestName || undefined,
guest_phone: payload.guestPhone || undefined,
comment: payload.comment || undefined,
items: payload.items,
hp_check: payload.hpCheck || undefined,
}),
});
if (!r.ok) {
// сервер отвечает понятным текстом («бар закрыт», «меню изменилось»,
// «такого столика нет») — показываем его гостю, а не общий сбой
if (r.status === 429) {
throw new Error("Слишком много заказов подряд — попробуйте через пару минут");
}
let message = "CRM не приняла заказ (ошибка " + r.status + ")";
try {
const data = (await r.json()) as { error?: unknown } | null;
if (data && typeof data.error === "string" && data.error) message = data.error;
} catch {
// тело не JSON (например, HTML от nginx) — остаёмся на общем тексте
}
throw new Error(message);
}
const data = (await r.json()) as { id: number; number?: number; price: number; code?: string };
return {
id: data.id,
number: typeof data.number === "number" ? data.number : data.id,
price: data.price,
code: typeof data.code === "string" ? data.code : "",
};
}
/* ------------------------------------------------------------------
Отслеживание заказа по коду (страница «Статус заказа»)
------------------------------------------------------------------ */
export type TrackStatus = "new" | "active" | "done" | "cancelled";
export interface TrackInfo {
id: number;
/** недельный номер заказа — тот, что гость видел при создании */
number: number;
table: string;
status: TrackStatus;
price: number;
created_at: string | null;
accepted_at: string | null;
delivered_at: string | null;
}
export type TrackResult =
| { phase: "found"; info: TrackInfo }
| { phase: "not-found" }
| { phase: "error"; message: string };
/** Код нормализуем так же, как сервер: uppercase, без пробелов. */
export const normalizeTrackCode = (raw: string) =>
raw.trim().toUpperCase().replace(/\s+/g, "");
export const TRACK_CODE_RE = /^[A-Z0-9]{6}$/;
/** Статус заказа по коду. not-found — код неверный; error — CRM недоступна. */
export async function fetchOrderStatus(rawCode: string): Promise<TrackResult> {
const code = normalizeTrackCode(rawCode);
if (!TRACK_CODE_RE.test(code)) return { phase: "not-found" };
try {
const r = await fetch(`${BASE}/api/track/${code}`, {
signal: AbortSignal.timeout(6000),
});
if (r.status === 404) return { phase: "not-found" };
if (!r.ok) return { phase: "error", message: "CRM не отвечает (ошибка " + r.status + ")" };
const info = (await r.json()) as TrackInfo;
return { phase: "found", info };
} catch {
return { phase: "error", message: "Нет связи с баром — попробуйте ещё раз" };
}
}
/* ------------------------------------------------------------------
Отмена случайного заказа гостем (пока статус «Новый» и ≤10 минут)
------------------------------------------------------------------ */
export type CancelResult =
| { phase: "cancelled"; number: number }
| { phase: "error"; message: string };
/** Гость отменяет случайно отправленный заказ по коду отслеживания. */
export async function cancelOrder(rawCode: string): Promise<CancelResult> {
const code = normalizeTrackCode(rawCode);
if (!TRACK_CODE_RE.test(code)) return { phase: "error", message: "Неверный код заказа" };
try {
const r = await fetch(`${BASE}/api/track/${code}/cancel`, {
method: "POST",
signal: AbortSignal.timeout(6000),
});
if (r.status === 404) return { phase: "error", message: "Заказ с таким кодом не найден" };
const data = (await r.json().catch(() => null)) as { error?: string; number?: number } | null;
if (!r.ok) {
return {
phase: "error",
message: data?.error ?? "Не получилось отменить — позвоните в бар: +7 (912) 901-72-17",
};
}
return { phase: "cancelled", number: typeof data?.number === "number" ? data.number : 0 };
} catch {
return { phase: "error", message: "Нет связи с баром — попробуйте ещё раз" };
}
}
/* ------------------------------------------------------------------
Позиции, добавленные владельцем через CRM («новинки» на неделю)
------------------------------------------------------------------ */
export interface CustomMenuItem {
id: string;
name: string;
category: string;
price: number;
portion: string | null;
/** путь к фото, загруженному владельцем (/media/...) */
image?: string | null;
created_at: string;
}
export interface CustomTobacco {
id: string;
brand: string;
flavor: string;
note: string | null;
strength: number;
cooling: boolean;
created_at: string;
}
/** Микс, добавленный владельцем в CRM: состав вкусов без пропорций.
* strength/cooling/price — расширенная информация (CRM, необязательная):
* крепость 1–5, холодок 0–3, цена ₽; null — владелец не указал.
* tags — ярлыки («ягоды», «цитрус», «лёд»…): по их пересечению с запросом
* гостя AI-сомелье подмешивает микс мастера в свою выдачу. */
export interface CustomMix {
id: string;
name: string;
flavors: { id: string; brand: string; flavor: string }[];
note: string | null;
strength: number | null;
cooling: number | null;
price: number | null;
tags: string[] | null;
created_at: string;
}
/** Правка витринного микса сайта из CRM («Авторские миксы сайта»).
* null — поле не менялось, витрина берёт исходное значение из кода. */
export interface SiteMixEdit {
id: string;
name: string | null;
note: string | null;
strength: number | null;
cooling: number | null;
price: number | null;
tags: string[] | null;
flavors: { id: string; brand: string; flavor: string }[] | null;
}
export interface CustomCatalog {
menu: CustomMenuItem[];
tobacco: CustomTobacco[];
mixes: CustomMix[];
/** правки витринных миксов сайта — применяются поверх hookahMixes */
site_edits: SiteMixEdit[];
/** id позиций/вкусов, удалённых владельцем, — сайт скрывает их из каталога */
deleted_menu: string[];
deleted_tobacco: string[];
/** подсветка карточек (#hex) для ЛЮБЫХ позиций меню с выставленным акцентом
* (владелец задаёт в CRM) — накладывается поверх цветов сайта */
accents: Record<string, string>;
online: boolean;
}
const EMPTY_CATALOG: CustomCatalog = {
menu: [],
tobacco: [],
mixes: [],
site_edits: [],
deleted_menu: [],
deleted_tobacco: [],
accents: {},
online: false,
};
export const NOVELTY_DAYS = 7;
/** Список столиков для корзины: справочник залов CRM (владелец ведёт его
* в CRM — «Залы»). Один запрос при открытии корзины; если CRM недоступна,
* сайт работает на статичном списке из кода. */
export function useTableOptions(fallback: string[]): string[] {
const [tables, setTables] = useState<string[]>(fallback);
useEffect(() => {
let alive = true;
(async () => {
try {
const r = await fetch(`${BASE}/api/halls/names`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(String(r.status));
const data = (await r.json()) as { tables?: string[] };
if (!alive) return;
const list = (data.tables ?? []).filter((t) => typeof t === "string" && t.trim());
if (list.length) setTables(list);
} catch {
/* CRM не отвечает — остаёмся на статичном списке */
}
})();
return () => {
alive = false;
};
}, []);
return tables;
}
/** Позиция считается «новинкой» неделю после добавления владельцем. */
export function isNovelty(createdAt?: string | null): boolean {
if (!createdAt) return false;
const ts = new Date(createdAt).getTime();
return Number.isFinite(ts) && Date.now() - ts < NOVELTY_DAYS * 86_400_000;
}
/** Каталог, добавленный владельцем: грузится один раз, обновляется раз в 10 минут. */
export function useCustomCatalog(pollMs = 600_000): CustomCatalog {
const [catalog, setCatalog] = useState<CustomCatalog>(EMPTY_CATALOG);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const r = await fetch(`${BASE}/api/catalog`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(String(r.status));
const data = (await r.json()) as {
menu?: CustomMenuItem[];
tobacco?: CustomTobacco[];
mixes?: CustomMix[];
site_mix_edits?: SiteMixEdit[];
deleted_menu?: string[];
deleted_tobacco?: string[];
accents?: Record<string, string>;
};
if (!alive) return;
setCatalog({
menu: data.menu ?? [],
tobacco: data.tobacco ?? [],
mixes: data.mixes ?? [],
site_edits: data.site_mix_edits ?? [],
deleted_menu: data.deleted_menu ?? [],
deleted_tobacco: data.deleted_tobacco ?? [],
accents: data.accents ?? {},
online: true,
});
} catch {
// см. useStoplist: не сбрасываем каталог при одиночном сбое опроса
if (alive) setCatalog((prev) => (prev.online ? { ...prev, online: false } : prev));
}
};
load();
const timer = window.setInterval(load, pollMs);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [pollMs]);
return catalog;
}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
/**
* Флаг статического пререндера (scripts/prerender.tsx).
*
* Пререндер собирает SEO-снапшот лендинга в Node: флаг выставляется только
* в этом процессе, в браузере он всегда false и поведение не меняется.
* В снапшоте рендерим финальное видимое состояние: без прелоадера и
* age-gate, reveal-анимации считаем уже проигранными.
*
* ВАЖНО: функция, а не константа — ESM-импорты выполняются до тела
* prerender-скрипта, глобал в момент импорта ещё не установлен.
*/
export function isPrerender(): boolean {
return (globalThis as { __NEON_PRERENDER__?: boolean }).__NEON_PRERENDER__ === true;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>
);
File diff suppressed because it is too large. Load diff
+405
View File
@@ -0,0 +1,405 @@
import { useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
Brain,
Castle,
Circle,
Club,
Crown,
Dices,
Eye,
Flame,
Ghost,
Grid3x3,
Hash,
Heart,
Landmark,
LayoutGrid,
Puzzle,
Search,
Skull,
Spade,
Target,
Timer,
Users,
X,
} from "lucide-react";
import {
boardGameCategories,
boardGameDifficultyLabels,
boardGames,
boardGamesNotice,
type BoardGame,
} from "../data/catalog";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
const iconMap: Record<string, typeof Dices> = {
eye: Eye,
skull: Skull,
spade: Spade,
crown: Crown,
dices: Dices,
grid: Grid3x3,
circle: Circle,
hash: Hash,
landmark: Landmark,
layout: LayoutGrid,
brain: Brain,
puzzle: Puzzle,
users: Users,
club: Club,
ghost: Ghost,
castle: Castle,
heart: Heart,
target: Target,
};
function GameCover({ game }: { game: BoardGame }) {
const Icon = iconMap[game.icon] ?? Dices;
return (
<div
className="relative flex h-full w-full flex-col items-center justify-center gap-3 p-5 text-center"
style={{
background: `radial-gradient(circle at 30% 20%, ${game.accent}33 0%, transparent 55%), linear-gradient(150deg, ${game.accent}1f 0%, #0a0a0f 60%, #00f0ff14 100%)`,
}}
>
{/* сетка — намёк на игровое поле */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.6) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.6) 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
<span
className="relative flex h-16 w-16 items-center justify-center rounded-2xl border border-white/15 bg-black/35 shadow-[0_0_30px_-4px_var(--accent)] backdrop-blur-sm transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
style={{ ["--accent" as string]: game.accent, color: game.accent }}
>
<Icon size={32} strokeWidth={1.7} aria-hidden="true" />
</span>
<span className="relative font-display text-sm font-bold leading-snug text-white/85">
{game.name}
</span>
</div>
);
}
function DifficultyDots({ level }: { level: BoardGame["difficulty"] }) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{[1, 2, 3].map((d) => (
<span
key={d}
className={cn(
"h-1.5 w-1.5 rounded-full",
d <= level ? "bg-neon-cyan shadow-[0_0_7px_rgba(0,240,255,0.85)]" : "bg-white/15"
)}
/>
))}
</span>
);
}
function GameCard({ game }: { game: BoardGame }) {
// «2 игрока / 2–4 игрока», но «5 игроков» — согласование по верхней границе
const max = game.maxPlayers;
const playerWord = max % 10 >= 2 && max % 10 <= 4 && (max % 100 < 12 || max % 100 > 14)
? "игрока"
: "игроков";
const players =
game.minPlayers === game.maxPlayers
? `${max} ${playerWord}`
: `${game.minPlayers}–${max} ${playerWord}`;
return (
<article className="group catalog-result glass flex h-full min-w-0 flex-col overflow-hidden rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:border-neon-cyan/40 hover:shadow-[0_18px_50px_-24px_rgba(0,240,255,0.45)]">
<div className="relative aspect-[4/3] w-full overflow-hidden bg-panel">
<GameCover game={game} />
<span className="absolute left-3 top-3 rounded-full border border-white/15 bg-black/50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur-md">
{game.category}
</span>
{game.hit && (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-full border border-neon-violet/60 bg-neon-violet/25 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-fuchsia-100 shadow-[0_0_14px_rgba(176,38,255,0.45)] backdrop-blur-md">
<Flame size={10} aria-hidden="true" />
Хит
</span>
)}
</div>
<div className="flex flex-1 flex-col p-4 sm:p-5">
<h2 className="font-display text-base font-bold leading-snug text-white sm:text-lg">
{game.name}
</h2>
<p className="mt-2 flex-1 text-xs leading-relaxed text-mist/60">{game.desc}</p>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-3 text-[11px]">
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-violet/40 bg-neon-violet/10 px-2.5 py-1 font-bold uppercase tracking-wider text-fuchsia-200">
<Users size={11} aria-hidden="true" />
{players}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-white/5 px-2.5 py-1 text-mist/70">
<Timer size={11} aria-hidden="true" />
{game.duration}
</span>
<span className="ml-auto inline-flex items-center gap-2 text-mist/55">
<DifficultyDots level={game.difficulty} />
{boardGameDifficultyLabels[game.difficulty]}
</span>
</div>
</div>
</article>
);
}
export default function BoardGamesPage() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [players, setPlayers] = useState("all");
const [difficulty, setDifficulty] = useState("all");
const searchRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return boardGames.filter((game) => {
if (category !== "all" && game.category !== category) return false;
if (difficulty !== "all" && String(game.difficulty) !== difficulty) return false;
if (players !== "all") {
const n = Number(players);
// игра подходит, если указанное число игроков попадает в диапазон
if (players === "8") {
if (game.maxPlayers < 8) return false;
} else if (n < game.minPlayers || n > game.maxPlayers) {
return false;
}
}
const text = normalize(`${game.name} ${game.category} ${game.desc}`);
return words.every((w) => text.includes(w));
});
}, [query, category, players, difficulty]);
const hasFilters =
query.length > 0 || category !== "all" || players !== "all" || difficulty !== "all";
const resetFilters = () => {
setQuery("");
setCategory("all");
setPlayers("all");
setDifficulty("all");
searchRef.current?.focus();
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden"
aria-hidden="true"
>
<img
src="./images/interior-sofas.jpg"
alt=""
className="h-full w-full object-cover opacity-20"
/>
<div className="absolute inset-0 bg-gradient-to-b from-void/75 via-void/60 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="board-games-title"
tabIndex={-1}
className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none"
>
Настольные <span className="text-glow-violet text-fuchsia-200">игры</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
{boardGames.length} игр на любую компанию: от шахмат на двоих до «Мафии» на шестнадцать
человек. Выбирай по числу игроков и сложности — и зови бармена.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">{boardGamesNotice}</p>
{/* фильтры */}
<form
role="search"
aria-label="Поиск по настольным играм"
onSubmit={(e) => e.preventDefault()}
className="mt-9 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,1fr))]"
>
<div className="min-w-0 sm:col-span-2 lg:col-span-1">
<label htmlFor="bg-search" className="mb-2 block text-xs font-semibold text-mist/70">
Название или описание
</label>
<div className="relative">
<Search
size={17}
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45"
aria-hidden="true"
/>
<input
ref={searchRef}
id="bg-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Например, мафия или карты"
autoComplete="off"
aria-controls="bg-results"
className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
aria-label="Очистить поиск"
onClick={() => {
setQuery("");
searchRef.current?.focus();
}}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="bg-category" className="mb-2 block text-xs font-semibold text-mist/70">
Тип игры
</label>
<select
id="bg-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
aria-controls="bg-results"
className={selectClass}
>
<option value="all">Все типы</option>
{boardGameCategories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="min-w-0">
<label htmlFor="bg-players" className="mb-2 block text-xs font-semibold text-mist/70">
Сколько вас
</label>
<select
id="bg-players"
value={players}
onChange={(e) => setPlayers(e.target.value)}
aria-controls="bg-results"
className={selectClass}
>
<option value="all">Любое число</option>
<option value="2">Вдвоём</option>
<option value="3">Втроём</option>
<option value="4">Вчетвером</option>
<option value="6">Шестеро</option>
<option value="8">Большая компания (8+)</option>
</select>
</div>
<div className="min-w-0">
<label htmlFor="bg-difficulty" className="mb-2 block text-xs font-semibold text-mist/70">
Сложность правил
</label>
<select
id="bg-difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
aria-controls="bg-results"
className={selectClass}
>
<option value="all">Любая</option>
<option value="1">Простая</option>
<option value="2">Средняя</option>
<option value="3">Сложная</option>
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p role="status" aria-live="polite" aria-atomic="true" className="text-xs text-mist/55">
Показано {filtered.length} из {boardGames.length}
</p>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Сбросить фильтры
</button>
)}
</div>
<div id="bg-results" className="min-w-0">
{filtered.length > 0 ? (
<ul
className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="Настольные игры"
>
{filtered.map((game) => (
<li key={game.id} className="min-w-0">
<GameCard game={game} />
</li>
))}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">Ничего не нашлось</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">
Попробуй другой тип игры или число игроков. Например, «мафия» или «нарды».
</p>
<button
type="button"
onClick={resetFilters}
className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Показать все игры
</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">
Настолки доступны в общем зале и в VIP. Для большой компании на «Мафию» или «Бункер»
лучше забронировать VIP-зал заранее.
</p>
<div className="flex flex-wrap gap-3">
<a
href="#vip"
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-5 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(176,38,255,0.4)] transition-all hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Аренда зала <ArrowUpRight size={16} aria-hidden="true" />
</a>
<a
href="#/games"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Игры на PS5 <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
</div>
);
}
+344
View File
@@ -0,0 +1,344 @@
import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowUpRight, Search, Snowflake, X } from "lucide-react";
import {
strengthLabels,
tobaccoBrandInfo,
tobaccoBrands,
tobaccoCatalogNotice,
tobaccoFlavors,
type TobaccoFlavor,
} from "../data/catalog";
import { tobaccoTranslations } from "../data/tobaccoTranslations";
import { isNovelty, useCustomCatalog, useStoplist } from "../lib/crm";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
/** Перевод показываем, только если он реально добавляет что-то к названию:
при совпадении или вложении одной строки в другую — скрываем. */
const textsSimilar = (a: string, b: string) => {
const na = normalize(a).replace(/[^a-zа-я0-9\s]/gi, " ").replace(/\s+/g, " ");
const nb = normalize(b).replace(/[^a-zа-я0-9\s]/gi, " ").replace(/\s+/g, " ");
if (!na || !nb) return false;
return na === nb || na.includes(nb) || nb.includes(na);
};
function StrengthDots({ level }: { level: TobaccoFlavor["strength"] }) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{[1, 2, 3].map((dot) => (
<span
key={dot}
className={cn(
"h-1.5 w-1.5 rounded-full",
dot <= level
? "bg-neon-cyan shadow-[0_0_7px_rgba(0,240,255,0.85)]"
: "bg-white/15",
)}
/>
))}
</span>
);
}
function TobaccoImage({ flavor }: { flavor: TobaccoFlavor }) {
const brand = tobaccoBrandInfo[flavor.brand];
const image = flavor.image ?? brand?.image ?? "./images/hookah-smoke.jpg";
return (
<div className="relative aspect-[16/10] w-full overflow-hidden bg-panel">
<img
src={image}
alt={`Упаковка или визуал бренда ${flavor.brand}: ${flavor.name}`}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.src = "./images/hookah-smoke.jpg";
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-void/95 via-void/20 to-black/10" />
<div className="absolute inset-x-4 bottom-3 flex items-end justify-between gap-3">
<div className="min-w-0">
<span className="block truncate text-[10px] font-bold uppercase tracking-[0.25em] text-white/55">
{flavor.brand}
</span>
<span className="mt-1 block truncate font-display text-sm font-bold text-white">
{flavor.name}
</span>
</div>
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-full shadow-[0_0_14px_currentColor]"
style={{ color: brand?.accent ?? "#00f0ff", backgroundColor: "currentColor" }}
/>
</div>
</div>
);
}
function FlavorCard({ flavor }: { flavor: TobaccoFlavor }) {
const brand = tobaccoBrandInfo[flavor.brand];
return (
<article
className={cn(
"catalog-result group glass flex h-full min-w-0 flex-col overflow-hidden rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",
!flavor.available && "opacity-65",
)}
>
<TobaccoImage flavor={flavor} />
<div className="flex flex-1 flex-col p-5">
<div className="flex items-start justify-between gap-3">
{brand?.url ? (
<a
href={brand.url}
target="_blank"
rel="noopener noreferrer"
title={`Страница производителя ${flavor.brand}`}
className="inline-flex min-h-8 min-w-0 items-center gap-1.5 text-[10px] font-bold uppercase tracking-[0.2em] text-neon-cyan/80 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_7px_rgba(0,240,255,0.5)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<span className="truncate">{flavor.brand}</span>
<ArrowUpRight size={12} className="shrink-0" aria-hidden="true" />
</a>
) : (
<span className="inline-flex min-h-8 items-center text-[10px] font-bold uppercase tracking-[0.2em] text-neon-cyan/65">
{flavor.brand}
</span>
)}
<span className="inline-flex shrink-0 items-center gap-2">
{isNovelty(flavor.createdAt) && (
<span className="inline-flex items-center rounded-full border border-neon-violet/50 bg-neon-violet/12 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-fuchsia-300 shadow-[0_0_14px_rgba(176,38,255,0.25)]">
Новинка
</span>
)}
<span
className={cn(
"text-[10px] font-bold uppercase tracking-wider",
flavor.available ? "text-emerald-300" : "text-red-300",
)}
>
{flavor.available ? "В наличии" : "Закончился"}
</span>
</span>
</div>
<h2 className="mt-1 font-display text-base font-bold leading-snug text-white sm:text-lg">
{flavor.name}
</h2>
{tobaccoTranslations[flavor.id] &&
!textsSimilar(flavor.name, tobaccoTranslations[flavor.id]) &&
!textsSimilar(flavor.note ?? "", tobaccoTranslations[flavor.id]) && (
<p className="mt-1 text-xs font-medium text-cyan-200/85">
{tobaccoTranslations[flavor.id]}
</p>
)}
{flavor.note && (
<p className="mt-1.5 flex-1 text-xs leading-relaxed text-mist/55">{flavor.note}</p>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-white/10 pt-3">
<span className="inline-flex items-center gap-2 text-xs text-mist/60">
<StrengthDots level={flavor.strength} />
{strengthLabels[flavor.strength]}
</span>
{flavor.cooling ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-cyan-200">
<Snowflake size={11} aria-hidden="true" />
Холодок
</span>
) : (
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-mist/40">
Без холодка
</span>
)}
</div>
</div>
</article>
);
}
export default function FlavorsPage() {
const [query, setQuery] = useState("");
const [brand, setBrand] = useState("all");
const [strength, setStrength] = useState("all");
const [cooling, setCooling] = useState("all");
const [availability, setAvailability] = useState("available");
const searchRef = useRef<HTMLInputElement>(null);
// стоп-лист из CRM: вкус в стопе = карточка «Закончился»
const stoplist = useStoplist();
// вкусы, добавленные владельцем через CRM («новинки»)
const customCatalog = useCustomCatalog();
// вкусы, удалённые владельцем, скрываются с сайта
const deletedTobaccoIds = useMemo(
() => new Set(customCatalog.deleted_tobacco),
[customCatalog],
);
// новинки всегда вверху списка
const flavors = useMemo(() => {
const list = [
...tobaccoFlavors
.filter((flavor) => !deletedTobaccoIds.has(flavor.id))
.map((flavor) =>
stoplist.tobacco.has(flavor.id) ? { ...flavor, available: false } : flavor,
),
...customCatalog.tobacco.map(
(item): TobaccoFlavor => ({
id: item.id,
name: item.flavor,
brand: item.brand,
strength: (Math.min(3, Math.max(1, item.strength)) as TobaccoFlavor["strength"]),
cooling: !!item.cooling,
note: item.note ?? undefined,
available: !stoplist.tobacco.has(item.id),
createdAt: item.created_at,
}),
),
];
list.sort((a, b) => (isNovelty(b.createdAt) ? 1 : 0) - (isNovelty(a.createdAt) ? 1 : 0));
return list;
}, [stoplist, customCatalog, deletedTobaccoIds]);
// бренды для фильтра: каталог + бренды из позиций владельца
const brandOptions = useMemo(() => {
const brands = new Set(tobaccoBrands);
for (const item of customCatalog.tobacco) brands.add(item.brand);
return [...brands].sort((a, b) => a.localeCompare(b, "ru"));
}, [customCatalog]);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return flavors.filter((flavor) => {
if (brand !== "all" && flavor.brand !== brand) return false;
if (strength !== "all" && String(flavor.strength) !== strength) return false;
if (cooling === "yes" && !flavor.cooling) return false;
if (cooling === "no" && flavor.cooling) return false;
if (availability === "available" && !flavor.available) return false;
if (availability === "out" && flavor.available) return false;
const text = normalize(
`${flavor.name} ${tobaccoTranslations[flavor.id] ?? ""} ${flavor.brand} ${
flavor.note ?? ""
} ${strengthLabels[flavor.strength]} ${flavor.cooling ? "холодок лёд" : ""}`,
);
return words.every((word) => text.includes(word));
});
}, [flavors, query, brand, strength, cooling, availability]);
const hasFilters =
query.length > 0 ||
brand !== "all" ||
strength !== "all" ||
cooling !== "all" ||
availability !== "available";
const resetFilters = () => {
setQuery("");
setBrand("all");
setStrength("all");
setCooling("all");
setAvailability("available");
searchRef.current?.focus();
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden" aria-hidden="true">
<img src="./images/menu-bg.jpg" alt="" className="h-full w-full object-cover opacity-20" />
<div className="absolute inset-0 bg-gradient-to-b from-void/70 via-void/55 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a href="#home" className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1 id="flavors-title" tabIndex={-1} className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none">
Табаки <span className="text-glow-violet text-fuchsia-200">в наличии</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
{flavors.length} позиций от {brandOptions.length} брендов. Нажми на название
производителя в карточке, чтобы открыть его страницу.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">{tobaccoCatalogNotice}</p>
<form role="search" aria-label="Поиск по каталогу табаков" onSubmit={(event) => event.preventDefault()} className="mt-9 grid min-w-0 gap-4 sm:grid-cols-2 xl:grid-cols-[minmax(0,1.5fr)_repeat(4,minmax(0,1fr))]">
<div className="min-w-0 sm:col-span-2 xl:col-span-1">
<label htmlFor="flavor-search" className="mb-2 block text-xs font-semibold text-mist/70">Вкус или бренд</label>
<div className="relative">
<Search size={17} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45" aria-hidden="true" />
<input ref={searchRef} id="flavor-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Например, Blackburn или малина" autoComplete="off" aria-controls="flavor-results" className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]" />
{query && (
<button type="button" aria-label="Очистить поиск" onClick={() => { setQuery(""); searchRef.current?.focus(); }} className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan">
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="flavor-brand" className="mb-2 block text-xs font-semibold text-mist/70">Бренд</label>
<select id="flavor-brand" value={brand} onChange={(event) => setBrand(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="all">Все бренды</option>
{brandOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="min-w-0">
<label htmlFor="flavor-strength" className="mb-2 block text-xs font-semibold text-mist/70">Крепость</label>
<select id="flavor-strength" value={strength} onChange={(event) => setStrength(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="all">Любая</option><option value="1">Лёгкая</option><option value="2">Средняя</option><option value="3">Крепкая</option>
</select>
</div>
<div className="min-w-0">
<label htmlFor="flavor-cooling" className="mb-2 block text-xs font-semibold text-mist/70">Холодок</label>
<select id="flavor-cooling" value={cooling} onChange={(event) => setCooling(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="all">Неважно</option><option value="yes">С холодком</option><option value="no">Без холодка</option>
</select>
</div>
<div className="min-w-0">
<label htmlFor="flavor-availability" className="mb-2 block text-xs font-semibold text-mist/70">Статус</label>
<select id="flavor-availability" value={availability} onChange={(event) => setAvailability(event.target.value)} aria-controls="flavor-results" className={selectClass}>
<option value="available">В наличии</option><option value="all">Все позиции</option><option value="out">Закончились</option>
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p role="status" aria-live="polite" aria-atomic="true" className="text-xs text-mist/55">Показано {filtered.length} из {tobaccoFlavors.length}</p>
{hasFilters && <button type="button" onClick={resetFilters} className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">Сбросить фильтры</button>}
</div>
<div id="flavor-results" className="min-w-0">
{filtered.length > 0 ? (
<ul className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3" aria-label="Табаки и вкусы">
{filtered.map((flavor) => <li key={flavor.id} className="min-w-0"><FlavorCard flavor={flavor} /></li>)}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">Ничего не нашлось</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">Попробуй другой вкус, бренд или статус наличия.</p>
<button type="button" onClick={resetFilters} className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">Показать всё в наличии</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">Не нашёл любимый вкус? Напиши нам — подскажем, что есть сегодня.</p>
<a href="https://vk.ru/shelby_nyagan" target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan">
Спросить в VK <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowUpRight, Gamepad2, Search, User, Users, X } from "lucide-react";
import {
gameGenres,
gamesCatalogNotice,
ps5Games,
type Ps5Game,
} from "../data/catalog";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
function GameCover({ game }: { game: Ps5Game }) {
if (game.image) {
return (
<img
src={game.image}
alt={`Обложка ${game.name}`}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
);
}
// CSS-плейсхолдер, если обложки нет
return (
<div
className="flex h-full w-full flex-col items-center justify-center gap-3 p-4 text-center"
style={{
background: `linear-gradient(145deg, ${game.accent ?? "#b026ff"}33 0%, #0a0a0f 55%, #00f0ff22 100%)`,
}}
>
<Gamepad2 size={36} className="text-white/40" aria-hidden="true" />
<span className="font-display text-sm font-bold leading-snug text-white/70">
{game.name}
</span>
</div>
);
}
function GameCard({ game }: { game: Ps5Game }) {
return (
<article className="group catalog-result glass flex h-full min-w-0 flex-col overflow-hidden rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:border-neon-cyan/40 hover:shadow-[0_18px_50px_-24px_rgba(0,240,255,0.45)]">
{/* обложка */}
<div className="relative aspect-[3/4] w-full overflow-hidden bg-panel">
<GameCover game={game} />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-void/80 via-transparent to-transparent" />
<span className="absolute left-3 top-3 rounded-full border border-white/15 bg-black/50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur-md">
{game.genre}
</span>
</div>
{/* инфо */}
<div className="flex flex-1 flex-col p-4 sm:p-5">
<h2 className="font-display text-base font-bold leading-snug text-white sm:text-lg">
{game.name}
</h2>
<div className="mt-4 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-white/10 pt-3">
<span className="text-xs text-mist/55">{game.genre}</span>
{game.maxPlayers === 1 ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-cyan-200">
<User size={11} aria-hidden="true" />
Сингл
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-violet/40 bg-neon-violet/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-fuchsia-200">
<Users size={11} aria-hidden="true" />
до {game.maxPlayers} игроков
</span>
)}
</div>
</div>
</article>
);
}
export default function GamesPage() {
const [query, setQuery] = useState("");
const [genre, setGenre] = useState("all");
const [players, setPlayers] = useState("all");
const searchRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return ps5Games.filter((game) => {
if (genre !== "all" && game.genre !== genre) return false;
if (players === "1" && game.maxPlayers !== 1) return false;
if (players !== "all" && players !== "1" && game.maxPlayers < Number(players)) {
return false;
}
const text = normalize(`${game.name} ${game.genre}`);
return words.every((word) => text.includes(word));
});
}, [query, genre, players]);
const hasFilters = query.length > 0 || genre !== "all" || players !== "all";
const resetFilters = () => {
setQuery("");
setGenre("all");
setPlayers("all");
searchRef.current?.focus();
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden"
aria-hidden="true"
>
<img src="./images/vip-room.jpg" alt="" className="h-full w-full object-cover opacity-20" />
<div className="absolute inset-0 bg-gradient-to-b from-void/75 via-void/60 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="games-title"
tabIndex={-1}
className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none"
>
Игры на{" "}
<span className="text-glow-cyan text-cyan-300">PlayStation 5</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
{ps5Games.length} игр в VIP-зале: кооперативные для компании и сюжетные синглы.
Выбирай по жанру или количеству игроков — и бронируй комнату.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">
{gamesCatalogNotice}
</p>
{/* фильтры */}
<form
role="search"
aria-label="Поиск по каталогу игр"
onSubmit={(event) => event.preventDefault()}
className="mt-9 grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1.5fr)_repeat(2,minmax(0,1fr))]"
>
<div className="min-w-0">
<label htmlFor="game-search" className="mb-2 block text-xs font-semibold text-mist/70">
Название или жанр
</label>
<div className="relative">
<Search
size={17}
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45"
aria-hidden="true"
/>
<input
ref={searchRef}
id="game-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Например, Mortal или футбол"
autoComplete="off"
aria-controls="game-results"
className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
aria-label="Очистить поиск"
onClick={() => {
setQuery("");
searchRef.current?.focus();
}}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="game-genre" className="mb-2 block text-xs font-semibold text-mist/70">
Жанр
</label>
<select
id="game-genre"
value={genre}
onChange={(event) => setGenre(event.target.value)}
aria-controls="game-results"
className={selectClass}
>
<option value="all">Все жанры</option>
{gameGenres.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
<div className="min-w-0">
<label htmlFor="game-players" className="mb-2 block text-xs font-semibold text-mist/70">
Максимум игроков
</label>
<select
id="game-players"
value={players}
onChange={(event) => setPlayers(event.target.value)}
aria-controls="game-results"
className={selectClass}
>
<option value="all">Любое</option>
<option value="1">Сингл</option>
<option value="2">от 2</option>
<option value="4">от 4</option>
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p role="status" aria-live="polite" aria-atomic="true" className="text-xs text-mist/55">
Показано {filtered.length} из {ps5Games.length}
</p>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Сбросить фильтры
</button>
)}
</div>
<div id="game-results" className="min-w-0">
{filtered.length > 0 ? (
<ul
className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="Игры на PlayStation 5"
>
{filtered.map((game) => (
<li key={game.id} className="min-w-0">
<GameCard game={game} />
</li>
))}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">Ничего не нашлось</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">
Попробуй другой жанр или название. Например, «файтинг» или «Overcooked».
</p>
<button
type="button"
onClick={resetFilters}
className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Показать все игры
</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">
PS5 доступна только в VIP-зале (до 8 гостей). Забронируй комнату — и играй.
</p>
<div className="flex flex-wrap gap-3">
<a
href="#vip"
className={cn(
"inline-flex min-h-11 items-center gap-2 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-5 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(176,38,255,0.4)] transition-all hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
)}
>
Аренда VIP <ArrowUpRight size={16} aria-hidden="true" />
</a>
<a
href="https://vk.ru/shelby_nyagan"
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Уточнить в VK <ArrowUpRight size={16} aria-hidden="true" />
</a>
<a
href="#/board-games"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-violet/40 bg-neon-violet/10 px-5 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Настольные игры <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
</div>
);
}
+847
View File
@@ -0,0 +1,847 @@
import { useEffect, useMemo, useState, type ComponentType } from "react";
import {
Apple,
ArrowLeft,
ArrowUpRight,
BatteryCharging,
Beer,
Beef,
Cake,
Candy,
Coffee,
Croissant,
CupSoda,
Donut,
Drumstick,
Egg,
Flame,
GlassWater,
Grape,
History,
Leaf,
Milk,
Popcorn,
Minus,
Plus,
Search,
Sparkles,
Utensils,
UtensilsCrossed,
X,
Zap,
} from "lucide-react";
import MenuCart, { type MenuCartItem } from "../components/MenuCart";
import { barCategories, barMenuItems, DRINKS_DESSERTS_CATEGORY, LEMONADES_CATEGORY, SNACKS_CATEGORY, SWEETS_CATEGORY, type BarMenuItem } from "../data/barMenu";
import { beerList, BEER_CATEGORY, type BeerItem } from "../data/beerMenu";
import { menuPhotos } from "../data/menuPhotos";
import { authorTeas, TEA_CATEGORY, type TeaItem } from "../data/teaMenu";
import { crmUrl, isNovelty, useCustomCatalog, useStoplist, type CustomMenuItem } from "../lib/crm";
import { cn } from "../utils/cn";
type MenuTab =
| "ALL"
| typeof TEA_CATEGORY
| typeof BEER_CATEGORY
| typeof SNACKS_CATEGORY
| typeof DRINKS_DESSERTS_CATEGORY
| typeof SWEETS_CATEGORY
| typeof LEMONADES_CATEGORY;
type MenuIcon = ComponentType<{ size?: number; className?: string }>;
const menuIcons: Record<string, MenuIcon> = {
leaf: Leaf,
coffee: Coffee,
tea: Coffee,
milk: Milk,
beer: Beer,
glass: GlassWater,
cupsoda: CupSoda,
grape: Grape,
zap: Zap,
battery: BatteryCharging,
apple: Apple,
donut: Donut,
utensils: Utensils,
crossed: UtensilsCrossed,
drumstick: Drumstick,
croissant: Croissant,
beef: Beef,
egg: Egg,
cake: Cake,
candy: Candy,
popcorn: Popcorn,
};
const menuTabs: { id: MenuTab; label: string }[] = [
{ id: "ALL", label: "Все разделы" },
{ id: TEA_CATEGORY, label: TEA_CATEGORY },
{ id: BEER_CATEGORY, label: BEER_CATEGORY },
{ id: SNACKS_CATEGORY, label: SNACKS_CATEGORY },
{ id: DRINKS_DESSERTS_CATEGORY, label: DRINKS_DESSERTS_CATEGORY },
{ id: SWEETS_CATEGORY, label: SWEETS_CATEGORY },
{ id: LEMONADES_CATEGORY, label: LEMONADES_CATEGORY },
];
const barMetaById = new Map(barCategories.map((category) => [category.id, category]));
const CART_STORAGE_KEY = "neon-menu-cart";
/* Позиции, добавленные владельцем через CRM, подгоняем под формат каталога сайта */
const CUSTOM_ITEM_DESC = "Новинка в меню бара — попробуйте первыми!";
const fmtPrice = (price: number) => price.toLocaleString("ru-RU") + " ₽";
function customToTea(item: CustomMenuItem): TeaItem {
return {
id: item.id,
name: item.name,
category: item.category,
volume: item.portion || "900 мл",
price: item.price,
priceFormatted: fmtPrice(item.price),
desc: CUSTOM_ITEM_DESC,
ingredients: [],
accentColor: "#00f0ff",
};
}
function customToBeer(item: CustomMenuItem): BeerItem {
return {
id: item.id,
name: item.name,
category: item.category,
type: "Новинка",
volume: item.portion || "0,5 л",
price: item.price,
priceFormatted: fmtPrice(item.price),
desc: CUSTOM_ITEM_DESC,
accentColor: "#b026ff",
};
}
function customToBar(item: CustomMenuItem): BarMenuItem {
return {
id: item.id,
name: item.name,
category: item.category,
portion: item.portion ?? undefined,
price: item.price,
priceFormatted: fmtPrice(item.price),
desc: CUSTOM_ITEM_DESC,
icon: "utensils",
};
}
interface MenuCardItem {
id: string;
name: string;
category: string;
price: number;
priceFormatted: string;
portion?: string;
desc: string;
}
interface MenuItemCardProps {
item: MenuCardItem;
priceFormatted: string;
portion?: string;
badge?: string;
ingredients?: string[];
icon: MenuIcon;
accent: string;
image?: string;
cartQuantity: number;
available?: boolean;
novelty?: boolean;
onAdd: () => void;
onRemove: () => void;
}
function MenuItemCard({
item,
priceFormatted,
portion,
badge,
ingredients,
icon: Icon,
accent,
image,
cartQuantity,
available = true,
novelty = false,
onAdd,
onRemove,
}: MenuItemCardProps) {
const [imageVisible, setImageVisible] = useState(Boolean(image));
return (
<article
className={cn(
"catalog-result neon-frame group relative flex min-w-0 items-start gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-4.5 transition-all duration-300 hover:border-white/20 hover:bg-white/[0.06] sm:p-5",
available ? "hover:-translate-y-1" : "opacity-65",
)}
>
<div
className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full blur-2xl opacity-0 transition-opacity duration-500 group-hover:opacity-40"
style={{ backgroundColor: accent }}
aria-hidden="true"
/>
<div
className="neon-icon neon-frame relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 transition-all duration-300 group-hover:scale-105 group-hover:-rotate-2"
style={{
background: `linear-gradient(135deg, ${accent}33 0%, #0a0a0f 75%)`,
color: accent,
}}
>
{imageVisible && image ? (
<img
src={image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setImageVisible(false)}
/>
) : (
<Icon size={22} className="drop-shadow-[0_0_8px_currentColor]" />
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-baseline gap-2">
<h3 className="min-w-0 truncate font-display text-sm font-bold text-white sm:text-base">{item.name}</h3>
<span className="mx-1 hidden flex-1 border-b border-dotted border-white/20 sm:block" aria-hidden="true" />
<span
className="ml-auto shrink-0 font-display text-base font-extrabold text-white sm:ml-0 sm:text-lg"
style={{ textShadow: `0 0 14px ${accent}bb` }}
>
{priceFormatted}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-mist/65">{item.desc}</p>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{portion && (
<span
className="rounded-full border px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider"
style={{ borderColor: `${accent}55`, color: accent, backgroundColor: `${accent}14` }}
>
{portion}
</span>
)}
{badge && (
<span className="rounded-full border border-white/15 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold text-mist/60">
{badge}
</span>
)}
{ingredients?.slice(0, 3).map((ingredient) => (
<span
key={ingredient}
className="hidden rounded-full border border-white/10 bg-white/[0.02] px-2 py-0.5 text-[10px] text-mist/50 sm:inline-block"
>
{ingredient}
</span>
))}
{!available && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-400/40 bg-red-500/10 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-red-300">
<span className="h-1.5 w-1.5 rounded-full bg-red-400" aria-hidden="true" />
Нет в наличии
</span>
)}
{novelty && available && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-violet/50 bg-neon-violet/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-fuchsia-300 shadow-[0_0_14px_rgba(176,38,255,0.25)]">
<Sparkles size={10} aria-hidden="true" />
Новинка
</span>
)}
</div>
{!available ? (
<div
className="mt-3 inline-flex min-h-9 w-fit items-center rounded-lg border border-white/10 bg-white/[0.03] px-3 text-[11px] font-bold uppercase tracking-wider text-mist/45"
role="status"
aria-label={`${item.name} сейчас недоступна для онлайн-заказа`}
>
Нельзя заказать
</div>
) : cartQuantity === 0 ? (
<button
type="button"
onClick={onAdd}
className="mt-3 inline-flex min-h-9 w-fit items-center gap-1.5 rounded-lg border border-neon-cyan/35 bg-neon-cyan/8 px-3 text-[11px] font-bold uppercase tracking-wider text-cyan-100 transition-all hover:border-neon-cyan/60 hover:bg-neon-cyan/14 hover:shadow-[0_0_16px_rgba(0,240,255,0.14)]"
aria-label={`Добавить ${item.name} в корзину`}
>
<Plus size={14} />
В корзину
</button>
) : (
<div
className="mt-3 inline-flex min-h-9 w-fit self-start items-center rounded-lg border border-neon-cyan/35 bg-neon-cyan/8 text-cyan-100 shadow-[0_0_16px_rgba(0,240,255,0.08)]"
aria-label={`Количество ${item.name} в корзине: ${cartQuantity}`}
>
<button
type="button"
onClick={onRemove}
className="inline-flex h-9 w-9 items-center justify-center rounded-l-lg text-cyan-100/75 transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
aria-label={`Уменьшить количество ${item.name}`}
>
<Minus size={14} />
</button>
<span className="min-w-8 px-1 text-center text-xs font-extrabold tabular-nums">{cartQuantity}</span>
<button
type="button"
onClick={onAdd}
className="inline-flex h-9 w-9 items-center justify-center rounded-r-lg text-cyan-100/75 transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
aria-label={`Увеличить количество ${item.name}`}
>
<Plus size={14} />
</button>
</div>
)}
</div>
</article>
);
}
function CategoryHeader({
icon: Icon,
label,
tagline,
count,
accent,
}: {
icon: MenuIcon;
label: string;
tagline: string;
count: number;
accent: string;
}) {
return (
<div className="flex items-center gap-3 border-b border-white/10 pb-3">
<div
className="neon-icon neon-frame flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border transition-all duration-300 hover:scale-105"
style={{ borderColor: `${accent}55`, backgroundColor: `${accent}1f`, color: accent }}
>
<Icon size={20} className="drop-shadow-[0_0_6px_currentColor]" />
</div>
<div className="min-w-0">
<h2 className="font-display text-lg font-bold uppercase tracking-wider text-white sm:text-2xl">
{label}
<span className="ml-2.5 align-middle text-xs font-bold text-mist/45">({count})</span>
</h2>
<span className="block truncate text-xs text-mist/50">{tagline}</span>
</div>
</div>
);
}
function readStoredValue<T>(key: string, fallback: T): T {
if (typeof localStorage === "undefined") return fallback;
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function readStoredCart(): MenuCartItem[] {
const stored = readStoredValue<Array<MenuCartItem & { guest?: number }>>(CART_STORAGE_KEY, []);
const merged = new Map<string, MenuCartItem>();
stored.forEach((item) => {
const existing = merged.get(item.itemId);
if (existing) {
existing.quantity += item.quantity;
return;
}
merged.set(item.itemId, {
cartId: item.itemId,
itemId: item.itemId,
name: item.name,
category: item.category,
portion: item.portion,
price: item.price,
quantity: item.quantity,
});
});
return [...merged.values()];
}
export default function MenuPage() {
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<MenuTab>("ALL");
const [cart, setCart] = useState<MenuCartItem[]>(readStoredCart);
const [cartOpen, setCartOpen] = useState(false);
// стоп-лист приходит из CRM и обновляется раз в минуту
const stoplist = useStoplist();
// позиции, добавленные владельцем через CRM («новинки»)
const customCatalog = useCustomCatalog();
// кастомные позиции владельца, разложенные по форматам секций сайта
const customItems = useMemo(
() => ({
teas: customCatalog.menu.filter((item) => item.category === TEA_CATEGORY).map(customToTea),
beers: customCatalog.menu.filter((item) => item.category === BEER_CATEGORY).map(customToBeer),
bar: customCatalog.menu
.filter((item) => item.category !== TEA_CATEGORY && item.category !== BEER_CATEGORY)
.map(customToBar),
}),
[customCatalog],
);
// фото: загруженные владельцем перекрывают фото из каталога сайта
const photoMap = useMemo(() => {
const map: Record<string, string> = {};
for (const item of customCatalog.menu) {
if (item.image) map[item.id] = crmUrl(item.image);
}
return map;
}, [customCatalog]);
const photoFor = (id: string) => photoMap[id] ?? menuPhotos[id];
// подсветка карточек, заданная владельцем в CRM, перекрывает цвет секции
const crmAccents = customCatalog.accents;
const accentFor = (id: string, fallback: string) => crmAccents[id] ?? fallback;
// id позиций-«новинок» (добавлены владельцем за последние 7 дней)
const noveltyIds = useMemo(
() =>
new Set(
customCatalog.menu.filter((item) => isNovelty(item.created_at)).map((item) => item.id),
),
[customCatalog],
);
// позиции, удалённые владельцем через CRM, скрываются с сайта
const deletedMenuIds = useMemo(
() => new Set(customCatalog.deleted_menu),
[customCatalog],
);
// счётчики вкладок включают все позиции: из стоп-листа CRM помечаются «нет в наличии»
const menuTabsWithCounts = useMemo(
() =>
menuTabs.map((tab) => {
const teas = [...authorTeas, ...customItems.teas].filter((t) => !deletedMenuIds.has(t.id));
const beers = [...beerList, ...customItems.beers].filter((b) => !deletedMenuIds.has(b.id));
const bar = [...barMenuItems, ...customItems.bar].filter((i) => !deletedMenuIds.has(i.id));
const count =
tab.id === "ALL"
? teas.length + beers.length + bar.length
: teas.filter((t) => t.category === tab.id).length +
beers.filter((b) => b.category === tab.id).length +
bar.filter((i) => i.category === tab.id).length;
return { ...tab, count };
}),
[customItems, deletedMenuIds],
);
useEffect(() => {
try {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
} catch {
// Корзина остаётся рабочей в памяти, даже если хранилище недоступно.
}
}, [cart]);
const { filteredTeas, filteredBeers, snacks, drinks, sweets, lemonades } = useMemo(() => {
const normalizedQuery = query.toLocaleLowerCase("ru-RU").trim();
const matches = (text: string) => !normalizedQuery || text.toLocaleLowerCase("ru-RU").includes(normalizedQuery);
const isVisible = (category: MenuTab) => activeTab === "ALL" || activeTab === category;
const notDeleted = (id: string) => !deletedMenuIds.has(id);
// новинки всегда вверху списка
const novelFirst = <T extends { id: string }>(list: T[]) =>
[...list].sort((a, b) => (noveltyIds.has(b.id) ? 1 : 0) - (noveltyIds.has(a.id) ? 1 : 0));
const teas = isVisible(TEA_CATEGORY)
? novelFirst([...authorTeas, ...customItems.teas].filter((tea) => notDeleted(tea.id) && matches(`${tea.name} ${tea.desc} ${tea.ingredients.join(" ")}`)))
: [];
const beers = isVisible(BEER_CATEGORY)
? novelFirst([...beerList, ...customItems.beers].filter((beer) => notDeleted(beer.id) && matches(`${beer.name} ${beer.type} ${beer.desc} ${beer.volume}`)))
: [];
const barItems = novelFirst([...barMenuItems, ...customItems.bar].filter((item) => notDeleted(item.id) && matches(`${item.name} ${item.desc} ${item.category}`)));
return {
filteredTeas: teas,
filteredBeers: beers,
snacks: isVisible(SNACKS_CATEGORY) ? barItems.filter((item) => item.category === SNACKS_CATEGORY) : [],
drinks: isVisible(DRINKS_DESSERTS_CATEGORY) ? barItems.filter((item) => item.category === DRINKS_DESSERTS_CATEGORY) : [],
sweets: isVisible(SWEETS_CATEGORY) ? barItems.filter((item) => item.category === SWEETS_CATEGORY) : [],
lemonades: isVisible(LEMONADES_CATEGORY) ? barItems.filter((item) => item.category === LEMONADES_CATEGORY) : [],
};
}, [activeTab, query, customItems, deletedMenuIds, noveltyIds]);
const totalResults = filteredTeas.length + filteredBeers.length + snacks.length + drinks.length + sweets.length + lemonades.length;
const meta = (id: string) => barMetaById.get(id)!;
// «Повторить прошлый заказ»: снимок последней отправленной корзины.
// Показываем, пока корзина пуста; стоп-позиции из повторяемого набора убираем.
// NF-15: снимок — тоже PII гостя (стол, состав), храним не дольше 30 дней.
const readLastOrder = () => {
const v = readStoredValue<{ at?: number; table: string; items: Array<MenuCartItem> } | null>(
"neon-last-order",
null,
);
if (v && (!v.at || Date.now() - v.at > 30 * 24 * 60 * 60 * 1000)) {
try {
localStorage.removeItem("neon-last-order");
} catch {
// приватный режим браузера — удалять нечем
}
return null;
}
return v;
};
const [lastOrder, setLastOrder] = useState(readLastOrder);
// перечитываем снимок, когда корзина опустела (заказ отправлен или очищен):
// сразу после первого заказа кнопка «Повторить» тоже появляется
useEffect(() => {
if (cart.length === 0) {
setLastOrder(readLastOrder());
}
}, [cart.length]);
const repeatable = useMemo(() => {
if (!lastOrder || cart.length > 0 || !lastOrder.items?.length) return null;
const items = lastOrder.items
.filter((item) => !stoplist.menu.has(item.itemId))
.slice(0, 8);
return items.length > 0 ? items : null;
}, [lastOrder, cart.length, stoplist.menu]);
const repeatLastOrder = () => {
if (!repeatable) return;
setCart(
repeatable.map((item) => ({
cartId: item.itemId,
itemId: item.itemId,
name: item.name,
category: item.category ?? "",
portion: item.portion,
price: item.price,
quantity: Math.min(50, Math.max(1, item.quantity)),
})),
);
setCartOpen(true);
};
const addToCart = (item: MenuCardItem) => {
setCart((current) => {
const existing = current.find((cartItem) => cartItem.itemId === item.id);
if (existing) {
// сервер принимает максимум 50 шт позиции — не обещаем гостю больше
return current.map((cartItem) =>
cartItem.cartId === existing.cartId
? { ...cartItem, quantity: Math.min(50, cartItem.quantity + 1) }
: cartItem,
);
}
return [
...current,
{
cartId: item.id,
itemId: item.id,
name: item.name,
category: item.category,
portion: item.portion,
price: item.price,
quantity: 1,
},
];
});
};
const changeQuantity = (cartId: string, delta: number) => {
setCart((current) =>
current
.map((item) => (item.cartId === cartId ? { ...item, quantity: item.quantity + delta } : item))
.filter((item) => item.quantity > 0),
);
};
const changeMenuQuantity = (item: MenuCardItem, delta: number) => {
if (delta > 0) {
addToCart(item);
return;
}
changeQuantity(item.id, delta);
};
const removeCartItem = (cartId: string) => {
setCart((current) => current.filter((item) => item.cartId !== cartId));
};
const renderCard = (item: MenuCardItem, options: { icon: MenuIcon; accent: string; image?: string; portion?: string; badge?: string; ingredients?: string[] }) => (
<MenuItemCard
key={item.id}
item={item}
priceFormatted={item.priceFormatted}
portion={options.portion}
badge={options.badge}
ingredients={options.ingredients}
icon={options.icon}
accent={options.accent}
image={options.image}
cartQuantity={cart.find((cartItem) => cartItem.itemId === item.id)?.quantity ?? 0}
available={!stoplist.menu.has(item.id)}
novelty={noveltyIds.has(item.id)}
onAdd={() => changeMenuQuantity(item, 1)}
onRemove={() => changeMenuQuantity(item, -1)}
/>
);
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[36rem] overflow-hidden" aria-hidden="true">
<img src="./images/menu-bg.jpg" alt="" className="h-full w-full object-cover opacity-25" />
<div className="absolute inset-0 bg-gradient-to-b from-void/70 via-void/60 to-void" />
<div className="absolute -left-32 top-10 h-96 w-96 rounded-full bg-neon-violet/20 blur-[140px]" />
<div className="absolute -right-32 top-20 h-96 w-96 rounded-full bg-neon-cyan/20 blur-[140px]" />
</div>
<div className="relative mx-auto w-full max-w-6xl px-4 pt-24 sm:px-6 sm:pt-28 lg:px-8">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<div className="mt-6 flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-neon-cyan/50 bg-neon-cyan/10 px-3.5 py-1 text-xs font-bold uppercase tracking-widest text-cyan-200 shadow-[0_0_18px_rgba(0,240,255,0.3)]">
<Sparkles size={14} className="text-neon-cyan" />
Барное меню НЕОН
</span>
<span className="text-xs text-mist/40">• г. Нягань</span>
</div>
<h1 id="menu-page-title" tabIndex={-1} className="mt-4 font-display text-[clamp(1.85rem,6vw,3.5rem)] font-extrabold leading-[1.12] text-white outline-none">
Меню <span className="text-glow-cyan text-cyan-300">бара</span>
</h1>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-mist/75">
Авторские чаи 900 мл, охлаждённая пивная карта, горячие закуски, десерты и энергетики — всё, что нужно для отличного вечера.
</p>
<div className="mt-9 flex gap-2 overflow-x-auto pb-2 sm:flex-wrap sm:overflow-visible">
{menuTabsWithCounts.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 rounded-2xl border px-4 py-2.5 font-display text-xs font-bold uppercase tracking-wider transition-all duration-300 sm:text-sm",
activeTab === tab.id
? tab.id === "ALL"
? "border-white/40 bg-white/15 text-white shadow-[0_0_20px_rgba(255,255,255,0.2)] ring-1 ring-white/40"
: tab.id === TEA_CATEGORY
? "border-neon-cyan bg-neon-cyan/15 text-white shadow-[0_0_24px_rgba(0,240,255,0.35)] ring-1 ring-neon-cyan/60"
: tab.id === BEER_CATEGORY
? "border-neon-violet bg-neon-violet/20 text-white shadow-[0_0_24px_rgba(176,38,255,0.45)] ring-1 ring-neon-violet/60"
: "border-white/40 bg-white/10 text-white shadow-[0_0_18px_rgba(255,255,255,0.15)] ring-1 ring-white/30"
: "border-white/10 bg-white/[0.03] text-mist/60 hover:border-white/20 hover:text-white",
)}
>
<span>{tab.label}</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/75">{tab.count}</span>
</button>
))}
</div>
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full max-w-md">
<Search size={17} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45" aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск по всему меню…"
className="catalog-search h-12 w-full rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white"
aria-label="Очистить поиск"
>
<X size={17} />
</button>
)}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<div className="glass flex items-center gap-3 rounded-2xl border border-white/10 px-4 py-3 text-xs text-mist/75">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-neon-cyan/15 text-neon-cyan">
<Flame size={14} />
</span>
<span>
Показано позиций: <strong className="text-white">{totalResults}</strong>
</span>
</div>
</div>
</div>
{repeatable && (
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-neon-cyan/25 bg-neon-cyan/[0.05] px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-bold text-white">Повторить прошлый заказ?</p>
<p className="mt-0.5 truncate text-xs text-mist/60">
{repeatable.map((item) => `${item.quantity}× ${item.name}`).join(" · ")}
</p>
</div>
<button
type="button"
onClick={repeatLastOrder}
className="inline-flex min-h-10 shrink-0 cursor-pointer items-center gap-2 rounded-full border border-neon-cyan/50 bg-neon-cyan/10 px-5 text-xs font-bold uppercase tracking-wider text-cyan-100 transition-all hover:bg-neon-cyan/20 hover:shadow-[0_0_18px_rgba(0,240,255,0.35)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<History size={14} aria-hidden="true" />
Повторить
</button>
</div>
)}
{filteredTeas.length > 0 && (
<section className="mt-12" aria-label={TEA_CATEGORY}>
<CategoryHeader
icon={Coffee}
label={TEA_CATEGORY}
tagline="Все чайники 900 мл по единой цене 350 ₽ на натуральных ягодах и травах"
count={filteredTeas.length}
accent="#00f0ff"
/>
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{filteredTeas.map((tea) =>
renderCard(tea, {
icon: menuIcons.leaf,
accent: accentFor(tea.id, tea.accentColor),
image: photoFor(tea.id),
portion: tea.volume,
ingredients: tea.ingredients,
}),
)}
</div>
</section>
)}
{filteredBeers.length > 0 && (
<section className="mt-16" aria-label={BEER_CATEGORY}>
<CategoryHeader
icon={Beer}
label={BEER_CATEGORY}
tagline="14 охлаждённых сортов: светлые и тёмные лагеры, пшеничные бланши, эли и фруктовые"
count={filteredBeers.length}
accent="#b026ff"
/>
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{filteredBeers.map((beer) =>
renderCard(beer, {
icon: menuIcons.beer,
accent: accentFor(beer.id, beer.accentColor),
image: photoFor(beer.id),
portion: beer.volume,
badge: beer.type,
}),
)}
</div>
</section>
)}
{snacks.length > 0 && (
<section className="mt-16" aria-label={SNACKS_CATEGORY}>
<CategoryHeader icon={Drumstick} label={SNACKS_CATEGORY} tagline={meta(SNACKS_CATEGORY).tagline} count={snacks.length} accent={meta(SNACKS_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{snacks.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.utensils, accent: accentFor(item.id, meta(SNACKS_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{drinks.length > 0 && (
<section className="mt-16" aria-label={DRINKS_DESSERTS_CATEGORY}>
<CategoryHeader icon={Cake} label={DRINKS_DESSERTS_CATEGORY} tagline={meta(DRINKS_DESSERTS_CATEGORY).tagline} count={drinks.length} accent={meta(DRINKS_DESSERTS_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{drinks.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.cake, accent: accentFor(item.id, meta(DRINKS_DESSERTS_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{sweets.length > 0 && (
<section className="mt-16" aria-label={SWEETS_CATEGORY}>
<CategoryHeader icon={Candy} label={SWEETS_CATEGORY} tagline={meta(SWEETS_CATEGORY).tagline} count={sweets.length} accent={meta(SWEETS_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{sweets.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.candy, accent: accentFor(item.id, meta(SWEETS_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{lemonades.length > 0 && (
<section className="mt-16" aria-label={LEMONADES_CATEGORY}>
<CategoryHeader icon={Zap} label={LEMONADES_CATEGORY} tagline={meta(LEMONADES_CATEGORY).tagline} count={lemonades.length} accent={meta(LEMONADES_CATEGORY).accent} />
<div className="mt-7 grid gap-3.5 sm:grid-cols-2">
{lemonades.map((item) => renderCard(item, { icon: menuIcons[item.icon] ?? menuIcons.zap, accent: accentFor(item.id, meta(LEMONADES_CATEGORY).accent), image: photoFor(item.id), portion: item.portion }))}
</div>
</section>
)}
{totalResults === 0 && (
<div className="py-20 text-center">
<h3 className="font-display text-lg font-bold text-white">Ничего не найдено</h3>
<p className="mt-2 text-sm text-mist/60">Попробуйте другой запрос — например, «чай», «козел», «чизкейк» или «BURN».</p>
<button
type="button"
onClick={() => {
setQuery("");
setActiveTab("ALL");
}}
className="mt-5 inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-6 py-2.5 text-xs font-bold uppercase tracking-wider text-cyan-200 transition-all hover:bg-neon-cyan/20"
>
Сбросить фильтры
</button>
</div>
)}
<div className="mt-16 rounded-3xl border border-neon-violet/30 bg-gradient-to-r from-neon-violet/15 via-panel/80 to-neon-cyan/10 p-6 sm:p-8">
<div className="flex flex-col items-center justify-between gap-6 sm:flex-row">
<div>
<span className="text-xs font-bold uppercase tracking-widest text-fuchsia-300">Бар лаунджа НЕОН</span>
<h2 className="mt-1 font-display text-xl font-bold text-white sm:text-2xl">Хотите забронировать столик?</h2>
<p className="mt-2 max-w-xl text-xs leading-relaxed text-mist/70 sm:text-sm">
Бронируйте основной зал, VIP с PS5 или кинозал — мы охладим напитки и приготовим чашу к вашему приходу.
</p>
</div>
<div className="flex flex-wrap gap-3">
<a
href="#contacts"
className="inline-flex min-h-12 items-center gap-2 rounded-full bg-gradient-to-r from-neon-violet to-[#7a1fd6] px-6 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(176,38,255,0.45)] transition-all hover:-translate-y-0.5 hover:shadow-[0_0_38px_rgba(176,38,255,0.7)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Забронировать стол
<ArrowUpRight size={16} />
</a>
<a
href="#/mixes"
className="inline-flex min-h-12 items-center gap-2 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-6 py-3 text-sm font-bold text-cyan-200 transition-all hover:bg-neon-cyan/20 hover:shadow-[0_0_24px_rgba(0,240,255,0.3)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Карта кальянов
</a>
</div>
</div>
</div>
</div>
<MenuCart
items={cart}
open={cartOpen}
stoppedItems={stoplist.menu}
onToggle={() => setCartOpen((open) => !open)}
onQuantityChange={changeQuantity}
onRemoveItem={removeCartItem}
onClear={() => setCart([])}
/>
</div>
);
}
+661
View File
@@ -0,0 +1,661 @@
import { useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
Bot,
Check,
Copy,
Search,
Snowflake,
Sparkles,
X,
} from "lucide-react";
import {
hookahMixes,
mixCoolingLabels,
mixFlavorTags,
mixStrengthLabels,
mixesCatalogNotice,
type CoolingLevel,
type HookahMix,
type MixStrength,
} from "../data/catalog";
import { FLAVORS } from "../data/flavors";
import { flavorTranslation, getFlavor, isColdFlavor } from "../lib/mixEngine";
import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";
import { cn } from "../utils/cn";
const normalize = (value: string) =>
value.toLocaleLowerCase("ru-RU").replace(/ё/g, "е").trim();
/* ------------------------------------------------------------------ */
/* Единая карточка микса: ИИ-витрина и миксы мастера в одной ленте, */
/* различие — только бейдж («Микс от ИИ» / «Микс от мастера»). */
/* Формат карточки — как в выдаче AI-сомелье: подробный состав */
/* с ролями, сверху — крепость, холодок и цена, снизу — копирование. */
/* ------------------------------------------------------------------ */
interface UnifiedItem {
/** название вкуса для строки состава */
title: string;
/** уточнение: русский перевод вкуса или бренд табака */
sub: string | null;
/** вкус-холодок (синяя точка и роль «Холодок») */
cold: boolean;
/** табак в каталоге сайта — по нему сверяется стоп-лист CRM */
catalogId: string | null;
/** этот вкус ушёл в стоп-лист */
stopped: boolean;
}
interface UnifiedMix {
key: string;
source: "ai" | "master";
name: string;
desc: string;
strength: MixStrength | null;
cooling: CoolingLevel | null;
price: string | null;
tags: string[];
items: UnifiedItem[];
/** хотя бы один вкус состава в стоп-листе CRM — карточка гасится */
stopped: boolean;
}
function itemsFromAi(mix: HookahMix): UnifiedItem[] {
return mix.items.map((item) => {
const flavor = getFlavor(item.flavorId);
const translation = flavor ? flavorTranslation(item.flavorId) : null;
return {
title: flavor?.name ?? item.flavorId,
sub: flavor && flavor.name !== translation ? translation : null,
cold: !!flavor && isColdFlavor(flavor),
catalogId: flavor ? flavor.catalogId ?? flavor.id : null,
stopped: false,
};
});
}
function itemsFromMaster(mix: CustomMix): UnifiedItem[] {
return mix.flavors.map((f) => {
// id вкуса CRM = catalogId в справочнике сайта — по нему понимаем холодок
const siteFlavor = FLAVORS.find((x) => x.catalogId === f.id || x.id === f.id);
return {
title: f.flavor,
sub: f.brand,
cold: !!siteFlavor && isColdFlavor(siteFlavor),
catalogId: f.id,
stopped: false,
};
});
}
/** Состав витринного микса, переопределённый в CRM: вкусы справочника CRM
* (id = catalogId сайта) в ролях «основа → добавка → оттенок». */
function itemsFromSiteEdit(flavors: { id: string; brand: string; flavor: string }[]): UnifiedItem[] {
return flavors.map((f) => {
const siteFlavor = FLAVORS.find((x) => x.catalogId === f.id || x.id === f.id);
return {
title: f.flavor,
sub: f.brand,
cold: !!siteFlavor && isColdFlavor(siteFlavor),
catalogId: f.id,
stopped: false,
};
});
}
const formatPrice = (rub: number) => `${rub.toLocaleString("ru-RU")} ₽`;
const roleLabel = (index: number, cold: boolean): string => {
if (cold) return "Холодок";
if (index === 0) return "Основа";
if (index === 1) return "Добавка";
return "Оттенок";
};
function segmentGradient(index: number, cold: boolean): string {
if (cold) return "linear-gradient(90deg,#67e8f9,#00f0ff)";
if (index === 0) return "linear-gradient(90deg,#7c3aed,#d946ef)";
if (index === 1) return "linear-gradient(90deg,#0891b2,#00f0ff)";
return "linear-gradient(90deg,#f59e0b,#f43f5e)";
}
function copyText(card: UnifiedMix): string {
return [
`Микс: ${card.name}`,
...card.items.map((i) => `• ${i.title}${i.sub ? ` (${i.sub})` : ""}`),
"",
card.desc,
"",
"Состав — только сочетание вкусов. Полный разбор и точные пропорции под вашу компанию сделает кальянщик, назовите название микса при заказе.",
"Кальянный лаунж «НЕОН», Нягань",
].join("\n");
}
function StrengthBar({ level }: { level: MixStrength }) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{[1, 2, 3, 4, 5].map((d) => (
<span
key={d}
className={cn(
"h-1.5 w-3 rounded-full transition-all duration-300",
d <= level
? "bg-neon-cyan shadow-[0_0_8px_rgba(0,240,255,0.85)]"
: "bg-white/12"
)}
/>
))}
</span>
);
}
function CoolingPill({ level }: { level: CoolingLevel }) {
if (level === 0) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-mist/40">
Без холодка
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-cyan-200">
<Snowflake size={11} aria-hidden="true" />
{mixCoolingLabels[level]}
</span>
);
}
function UnifiedMixCard({
card,
copied,
onCopy,
}: {
card: UnifiedMix;
copied: boolean;
onCopy: (card: UnifiedMix) => void;
}) {
const isMaster = card.source === "master";
return (
<article
className={cn(
"catalog-result glass flex h-full min-w-0 flex-col rounded-2xl p-5 transition-all duration-300 hover:-translate-y-1",
isMaster
? "hover:border-neon-cyan/40 hover:shadow-[0_18px_50px_-24px_rgba(0,240,255,0.4)]"
: "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",
card.stopped && "opacity-70 saturate-[0.65]",
)}
>
{/* бейдж источника + теги */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full border border-neon-cyan/50 bg-neon-cyan/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-cyan-100">
{isMaster ? <Sparkles size={10} aria-hidden="true" /> : <Bot size={10} aria-hidden="true" />}
{isMaster ? "Микс от мастера" : "Автоподбор"}
</span>
{card.stopped && (
<span className="inline-flex items-center gap-1 rounded-full border border-red-400/50 bg-red-400/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-red-200">
В стоп-листе
</span>
)}
{card.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-neon-violet/30 bg-neon-violet/8 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-fuchsia-200/85"
>
{tag}
</span>
))}
</div>
{/* название и описание */}
<h2 className="mt-3 font-display text-base font-bold leading-snug text-white sm:text-lg">
{card.name}
</h2>
{card.desc && (
<p className="mt-1.5 text-xs leading-relaxed text-mist/65">{card.desc}</p>
)}
{/* подробный состав с ролями — как в выдаче AI-сомелье */}
<div className="mt-4 flex-1">
<ul className="space-y-2">
{card.items.map((item, i) => (
<li key={`${card.key}-${i}`} className="flex items-start justify-between gap-3 text-xs">
<span className="flex min-w-0 items-start gap-2">
<span
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
style={{
background: item.stopped
? "linear-gradient(90deg,#f87171,#ef4444)"
: segmentGradient(i, item.cold),
}}
aria-hidden="true"
/>
<span className="min-w-0">
<span className={cn("block truncate font-semibold text-white/90", item.stopped && "text-red-300")}>{item.title}</span>
{item.sub && (
<span className="block truncate text-[11px] text-mist/45">{item.sub}</span>
)}
</span>
</span>
<span className="shrink-0 pt-0.5 text-[10px] uppercase tracking-wider text-mist/45">
{roleLabel(i, item.cold)}
</span>
</li>
))}
</ul>
<p className="mt-2 text-[11px] leading-relaxed text-mist/40">
Пропорции подберёт кальянщик под вашу чашу.
</p>
</div>
{card.stopped && (
<p className="mt-3 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-[11px] leading-relaxed text-red-100">
Часть табаков из состава сейчас в стоп-листе — кальянщик подберёт замену.
</p>
)}
{/* крепость · холодок · цена */}
<div className="mt-4 space-y-2.5 border-t border-white/10 pt-3">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-mist/55">Крепость</span>
<span className="flex items-center gap-2 text-mist/80">
{card.strength != null ? (
<>
<StrengthBar level={card.strength} />
<span className="text-[11px] font-semibold whitespace-nowrap">
{mixStrengthLabels[card.strength]}
</span>
</>
) : (
<span className="text-[11px] text-mist/40">—</span>
)}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-mist/55">Холодок</span>
{card.cooling != null ? (
<CoolingPill level={card.cooling} />
) : (
<span className="text-[11px] text-mist/40">—</span>
)}
</div>
<div className="flex items-center justify-between gap-2 pt-1 text-sm">
<span className="text-mist/55">Цена</span>
<span className="whitespace-nowrap font-display font-bold text-cyan-200">
{card.price ?? "—"}
</span>
</div>
</div>
{/* копирование рецепта */}
<button
type="button"
onClick={() => onCopy(card)}
aria-label={`Скопировать рецепт «${card.name}»`}
className={cn(
"mt-4 flex min-h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border text-xs font-bold transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neon-cyan",
copied
? "border-emerald-400/60 bg-emerald-400/15 text-emerald-200"
: "border-white/15 bg-white/5 text-white/85 hover:border-neon-cyan/50 hover:bg-neon-cyan/10 hover:text-white",
)}
>
{copied ? (
<>
<Check size={14} aria-hidden="true" /> Скопировано — покажите кальянщику
</>
) : (
<>
<Copy size={14} aria-hidden="true" /> Скопировать микс
</>
)}
</button>
</article>
);
}
export default function MixesPage() {
const [query, setQuery] = useState("");
const [strength, setStrength] = useState("all");
const [cooling, setCooling] = useState("all");
const [flavor, setFlavor] = useState("all");
const searchRef = useRef<HTMLInputElement>(null);
const catalog = useCustomCatalog();
const stoplist = useStoplist();
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const copiedTimer = useRef<number | null>(null);
// витрина ИИ: генерации из каталога сайта, поверх — правки из CRM
// («Авторские миксы сайта»): владелец мог поменять название, описание,
// крепость/холодок/цену, теги и состав; null правки = исходное значение
const aiCards: UnifiedMix[] = useMemo(
() =>
hookahMixes.map((mix) => {
const ed = catalog.site_edits.find((e) => e.id === mix.id);
return {
key: mix.id,
source: "ai" as const,
name: ed?.name ?? mix.name,
desc: ed?.note ?? mix.desc,
strength: (ed?.strength ?? mix.strength) as MixStrength,
cooling: (ed?.cooling ?? mix.cooling) as CoolingLevel,
price: ed?.price != null ? formatPrice(ed.price) : mix.price,
tags: ed?.tags?.length ? ed.tags : mix.flavorTags,
items: ed?.flavors?.length ? itemsFromSiteEdit(ed.flavors) : itemsFromAi(mix),
stopped: false,
};
}),
[catalog.site_edits],
);
// единая лента: миксы мастера (фирменные, из CRM) — первыми, затем витрина ИИ
const masterCards: UnifiedMix[] = useMemo(
() =>
catalog.mixes.map((mix) => ({
key: mix.id,
source: "master" as const,
name: mix.name,
desc: mix.note ?? "",
// незаданные величины не фильтруются: владелец мог их не указать
strength:
mix.strength != null && mix.strength >= 1 && mix.strength <= 5
? (mix.strength as MixStrength)
: null,
cooling:
mix.cooling != null && mix.cooling >= 0 && mix.cooling <= 3
? (mix.cooling as CoolingLevel)
: null,
price: mix.price != null ? `${mix.price.toLocaleString("ru-RU")} ₽` : null,
tags: (mix.tags ?? []).filter(Boolean),
items: itemsFromMaster(mix),
stopped: false,
})),
[catalog.mixes],
);
const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards, aiCards]);
// стоп-лист CRM: микс с остановленным табаком уходит в стоп-лист —
// карточка гасится, стоп-вкусы в составе подсвечиваются красным
const markedCards = useMemo(
() =>
allCards.map((card) => {
const items = card.items.map((item) => ({
...item,
stopped: item.catalogId != null && stoplist.tobacco.has(item.catalogId),
}));
return { ...card, items, stopped: items.some((item) => item.stopped) };
}),
[allCards, stoplist.tobacco],
);
const filtered = useMemo(() => {
const words = normalize(query).split(/\s+/).filter(Boolean);
return markedCards.filter((m) => {
// крепость/холодок: фильтруем только миксы, у которых величина задана
if (strength !== "all" && !(m.strength != null && String(m.strength) === strength))
return false;
if (cooling !== "all" && !(m.cooling != null && String(m.cooling) === cooling))
return false;
if (flavor !== "all" && !m.tags.some((t) => normalize(t) === normalize(flavor)))
return false;
const text = normalize(
`${m.name} ${m.desc} ${m.tags.join(" ")} ${m.items.map((i) => `${i.title} ${i.sub ?? ""}`).join(" ")}`,
);
return words.every((word) => text.includes(word));
});
}, [markedCards, query, strength, cooling, flavor]);
const hasFilters =
query.length > 0 || strength !== "all" || cooling !== "all" || flavor !== "all";
const resetFilters = () => {
setQuery("");
setStrength("all");
setCooling("all");
setFlavor("all");
searchRef.current?.focus();
};
const handleCopy = (card: UnifiedMix) => {
navigator.clipboard?.writeText(copyText(card)).then(
() => {
setCopiedKey(card.key);
if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);
copiedTimer.current = window.setTimeout(() => setCopiedKey(null), 2500);
},
() => {
/* копирование недоступно — кнопка просто не подсветится */
},
);
};
const selectClass =
"h-12 w-full min-w-0 cursor-pointer rounded-xl border border-white/15 bg-panel px-3 text-sm text-white outline-none transition-all hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]";
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden"
aria-hidden="true"
>
<img src="./images/menu-bg.jpg" alt="" className="h-full w-full object-cover opacity-20" />
<div className="absolute inset-0 bg-gradient-to-b from-void/70 via-void/55 to-void" />
</div>
<div className="relative mx-auto max-w-6xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="mixes-title"
tabIndex={-1}
className="mt-6 font-display text-[clamp(1.75rem,5.5vw,3.25rem)] font-bold leading-tight text-white outline-none"
>
Миксы <span className="text-glow-violet text-fuchsia-200">НЕОНа</span>
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-mist/70">
Автоподбор миксов и фирменные сочетания мастера — в одной ленте.
Фильтруй по крепости, холодку и вкусу — или собери свой уникальный.
</p>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-mist/50">
{mixesCatalogNotice}
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<a
href="#/ai-mix"
className="inline-flex min-h-12 items-center gap-2.5 rounded-2xl border border-neon-cyan/50 bg-gradient-to-r from-neon-violet/25 via-neon-cyan/20 to-neon-violet/15 px-5 py-3 text-sm font-bold text-white shadow-[0_0_24px_rgba(0,240,255,0.3)] transition-all hover:-translate-y-0.5 hover:border-neon-cyan hover:shadow-[0_0_36px_rgba(0,240,255,0.55)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<Bot size={18} className="text-neon-cyan" />
<span>Уникальный автоподбор микса</span>
<ArrowUpRight size={15} className="text-cyan-200" />
</a>
</div>
{/* фильтры */}
<form
role="search"
aria-label="Поиск по каталогу миксов"
onSubmit={(event) => event.preventDefault()}
className="mt-9 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.4fr)_repeat(3,minmax(0,1fr))]"
>
<div className="min-w-0 sm:col-span-2 lg:col-span-1">
<label htmlFor="mix-search" className="mb-2 block text-xs font-semibold text-mist/70">
Название, описание или вкус
</label>
<div className="relative">
<Search
size={17}
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-mist/45"
aria-hidden="true"
/>
<input
ref={searchRef}
id="mix-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Например, мята или Berry"
autoComplete="off"
aria-controls="mix-results"
className="catalog-search h-12 w-full min-w-0 rounded-xl border border-white/15 bg-panel/85 pl-11 pr-11 text-sm text-white outline-none transition-all placeholder:text-mist/40 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)]"
/>
{query && (
<button
type="button"
aria-label="Очистить поиск"
onClick={() => {
setQuery("");
searchRef.current?.focus();
}}
className="absolute right-1 top-1/2 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-mist/55 transition-all hover:bg-neon-cyan/10 hover:text-white focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="min-w-0">
<label htmlFor="mix-strength" className="mb-2 block text-xs font-semibold text-mist/70">
Крепость
</label>
<select
id="mix-strength"
value={strength}
onChange={(event) => setStrength(event.target.value)}
aria-controls="mix-results"
className={selectClass}
>
<option value="all">Любая</option>
<option value="1">Лёгкий</option>
<option value="2">Ниже среднего</option>
<option value="3">Средняя</option>
<option value="4">Выше средней</option>
<option value="5">Крепкий</option>
</select>
</div>
<div className="min-w-0">
<label
htmlFor="mix-cooling"
className="mb-2 block text-xs font-semibold text-mist/70"
>
Холодок
</label>
<select
id="mix-cooling"
value={cooling}
onChange={(event) => setCooling(event.target.value)}
aria-controls="mix-results"
className={selectClass}
>
<option value="all">Неважно</option>
<option value="0">Без холодка</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сильный</option>
</select>
</div>
<div className="min-w-0">
<label
htmlFor="mix-flavor"
className="mb-2 block text-xs font-semibold text-mist/70"
>
Вкус
</label>
<select
id="mix-flavor"
value={flavor}
onChange={(event) => setFlavor(event.target.value)}
aria-controls="mix-results"
className={selectClass}
>
<option value="all">Все вкусы</option>
{mixFlavorTags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</div>
</form>
<div className="mt-5 flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-white/15 pb-3">
<p
role="status"
aria-live="polite"
aria-atomic="true"
className="text-xs text-mist/55"
>
Показано {filtered.length} из {markedCards.length}
</p>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="min-h-11 cursor-pointer text-xs font-semibold text-neon-cyan transition-all hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.65)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Сбросить фильтры
</button>
)}
</div>
<div id="mix-results" className="min-w-0">
{filtered.length > 0 ? (
<ul
className="mt-6 grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="Миксы для кальяна"
>
{filtered.map((card) => (
<li key={card.key} className="min-w-0">
<UnifiedMixCard card={card} copied={copiedKey === card.key} onCopy={handleCopy} />
</li>
))}
</ul>
) : (
<div className="py-14 text-center">
<h2 className="font-display text-lg font-bold text-white">
Ничего не нашлось
</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-mist/65">
Попробуй другой вкус или крепость. Например, «мята» или «ягоды».
</p>
<button
type="button"
onClick={resetFilters}
className="mt-6 min-h-11 cursor-pointer rounded-full border border-neon-violet/45 bg-neon-violet/10 px-6 py-3 text-sm font-semibold text-white transition-all hover:border-neon-cyan/50 hover:shadow-[0_0_22px_rgba(176,38,255,0.25)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
Показать все миксы
</button>
</div>
)}
</div>
<div className="mt-10 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="max-w-lg text-sm leading-relaxed text-mist/60">
Не нашёл любимый микс? Уточни полный ассортимент у мастера.
</p>
<a
href="https://vk.ru/shelby_nyagan"
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-neon-cyan/35 bg-neon-cyan/5 px-5 py-3 text-sm font-semibold text-neon-cyan transition-all hover:border-neon-cyan/60 hover:shadow-[0_0_22px_rgba(0,240,255,0.2)] focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
Спросить в VK <ArrowUpRight size={16} aria-hidden="true" />
</a>
</div>
</div>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
Check,
ClipboardList,
Clock,
Loader2,
PackageSearch,
Search,
XCircle,
} from "lucide-react";
import {
TRACK_CODE_RE,
cancelOrder,
fetchOrderStatus,
normalizeTrackCode,
type TrackInfo,
type TrackResult,
} from "../lib/crm";
import { cn } from "../utils/cn";
const LAST_CODE_KEY = "neon-last-track-code";
const POLL_MS = 15_000;
/** сколько минут гость может отменить случайный заказ (синхронно с CRM) */
const CANCEL_WINDOW_MS = 10 * 60_000;
const STEPS: { key: TrackInfo["status"]; label: string; hint: string }[] = [
{ key: "new", label: "Принят", hint: "заказ у бара" },
{ key: "active", label: "Готовится", hint: "официант взял в работу" },
{ key: "done", label: "Выдан", hint: "приятного аппетита" },
];
const STEP_INDEX: Record<TrackInfo["status"], number> = {
new: 0,
active: 1,
done: 2,
cancelled: 0,
};
const fmtTime = (iso: string | null | undefined) => {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime())
? null
: `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
function Steps({ info }: { info: TrackInfo }) {
const current = STEP_INDEX[info.status];
return (
<ol className="mt-6 flex items-stretch gap-2" aria-label="Этапы заказа">
{STEPS.map((step, i) => {
const done = i < current;
const active = i === current;
return (
<li key={step.key} className="flex min-w-0 flex-1 flex-col items-center text-center">
<div className="flex w-full items-center">
<span
className={cn(
"h-px flex-1",
i === 0 ? "bg-transparent" : done || active ? "bg-neon-cyan/60" : "bg-white/12"
)}
aria-hidden="true"
/>
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full border text-sm font-bold transition-all",
done && "border-emerald-400/50 bg-emerald-400/10 text-emerald-300",
active &&
"border-neon-cyan bg-neon-cyan/15 text-cyan-200 shadow-[0_0_22px_rgba(0,240,255,0.4)]",
!done && !active && "border-white/12 bg-white/[0.03] text-mist/40"
)}
>
{done ? <Check size={17} aria-hidden="true" /> : i + 1}
</span>
<span
className={cn(
"h-px flex-1",
i === STEPS.length - 1 ? "bg-transparent" : done ? "bg-neon-cyan/60" : "bg-white/12"
)}
aria-hidden="true"
/>
</div>
<span
className={cn(
"mt-2 text-xs font-bold",
done ? "text-emerald-300/80" : active ? "text-white" : "text-mist/40"
)}
>
{step.label}
</span>
<span className="mt-0.5 text-[10px] leading-snug text-mist/40">{step.hint}</span>
</li>
);
})}
</ol>
);
}
function StatusCard({ info, code, onRefresh }: { info: TrackInfo; code: string; onRefresh: () => void }) {
const created = fmtTime(info.created_at);
const accepted = fmtTime(info.accepted_at);
const delivered = fmtTime(info.delivered_at);
const [cancelState, setCancelState] = useState<{ phase: "idle" | "busy" | "done"; message?: string }>({
phase: "idle",
});
const cancelAllowed =
info.status === "new" &&
info.created_at != null &&
Date.now() - new Date(info.created_at).getTime() <= CANCEL_WINDOW_MS;
const doCancel = async () => {
if (cancelState.phase === "busy") return;
if (!window.confirm("Отменить этот заказ? Бар его ещё не начал готовить.")) return;
setCancelState({ phase: "busy" });
const res = await cancelOrder(code);
if (res.phase === "cancelled") {
setCancelState({ phase: "done" });
onRefresh();
} else {
setCancelState({ phase: "idle", message: res.message });
}
};
return (
<div className="glass mt-6 rounded-2xl p-5 sm:p-6" role="status" aria-live="polite">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-neon-cyan">
Заказ №{info.number}
</p>
<p className="mt-1 font-display text-lg font-bold text-white">{info.table}</p>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/5 px-3.5 py-1.5 text-sm font-bold text-mist/80">
<ClipboardList size={14} aria-hidden="true" />
{info.price.toLocaleString("ru-RU")} ₽
</span>
</div>
{info.status === "cancelled" ? (
<div className="mt-5 flex items-start gap-3 rounded-xl border border-red-400/30 bg-red-400/[0.07] px-4 py-3.5 text-sm text-red-200">
<XCircle size={17} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
Заказ отменён. Если это ошибка — позовите официанта или создайте новый заказ в корзине.
</span>
</div>
) : (
<>
<Steps info={info} />
{cancelAllowed && cancelState.phase !== "done" && (
<div className="mt-4">
<button
type="button"
onClick={() => void doCancel()}
disabled={cancelState.phase === "busy"}
className="inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-xl border border-red-400/40 px-4 text-xs font-bold uppercase tracking-wider text-red-200 transition-colors hover:bg-red-400/10 disabled:cursor-wait disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-neon-cyan"
>
{cancelState.phase === "busy" ? (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
) : (
<XCircle size={14} aria-hidden="true" />
)}
Отменить заказ
</button>
<p className="mt-1.5 text-[11px] text-mist/40">
Случайно отправили? Отмените сами — кнопка активна первые 10 минут, пока бар не начал
готовить.
</p>
{cancelState.message && (
<p className="mt-2 text-xs text-amber-200" role="alert">
{cancelState.message}
</p>
)}
</div>
)}
{cancelState.phase === "done" && (
<p className="mt-4 text-sm font-semibold text-red-200" role="status">
Заказ отменён — это видно и персоналу бара.
</p>
)}
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-2 border-t border-white/10 pt-4 text-xs text-mist/55">
<span className="inline-flex items-center gap-1.5">
<Clock size={12} aria-hidden="true" />
Создан{created ? ` в ${created}` : ""}
</span>
{accepted && <span>Взяли в работу в {accepted}</span>}
{delivered && <span className="text-emerald-300/80">Выдан в {delivered}</span>}
</div>
</>
)}
<p className="mt-4 text-[11px] text-mist/40">
Статус обновляется сам каждые 15 секунд, пока открыта эта страница.
</p>
</div>
);
}
export default function TrackOrderPage() {
// код приходит ссылкой из корзины: #/track?code=ABC123
const initial = useMemo(() => {
const qs = new URLSearchParams(window.location.hash.split("?")[1] ?? "");
const fromUrl = normalizeTrackCode(qs.get("code") ?? "");
if (fromUrl) return fromUrl;
const stored = localStorage.getItem(LAST_CODE_KEY) ?? "";
return TRACK_CODE_RE.test(stored) ? stored : "";
}, []);
const [code, setCode] = useState(initial);
const [checked, setChecked] = useState<string | null>(null);
const [result, setResult] = useState<TrackResult | null>(null);
const [busy, setBusy] = useState(false);
const lookup = useCallback(async (raw: string) => {
const c = normalizeTrackCode(raw);
if (!TRACK_CODE_RE.test(c)) return;
setBusy(true);
const res = await fetchOrderStatus(c);
setBusy(false);
setChecked(c);
setResult(res);
if (res.phase === "found") {
localStorage.setItem(LAST_CODE_KEY, c);
document.title = `Заказ №${res.info.number} | НЕОН, Нягань`;
} else {
document.title = "Статус заказа | НЕОН, Нягань";
}
}, []);
useEffect(() => {
if (initial) void lookup(initial);
}, [initial, lookup]);
// живой опрос, пока заказ найден; несуществующий код не долбим каждые 15 с
useEffect(() => {
if (!checked || result?.phase !== "found") return;
const timer = window.setInterval(() => void lookup(checked), POLL_MS);
return () => window.clearInterval(timer);
}, [checked, result?.phase, lookup]);
const valid = TRACK_CODE_RE.test(normalizeTrackCode(code));
return (
<div className="flavors-page relative min-h-screen w-full max-w-full overflow-x-hidden pb-16 sm:pb-24">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30rem] overflow-hidden" aria-hidden="true">
<img src="./images/hero-bg.jpg" alt="" className="h-full w-full object-cover opacity-15" />
<div className="absolute inset-0 bg-gradient-to-b from-void/75 via-void/60 to-void" />
</div>
<div className="relative mx-auto max-w-2xl px-5 pt-28 sm:px-8 sm:pt-36">
<a
href="#home"
className="inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-mist/70 transition-all hover:text-neon-cyan hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neon-cyan"
>
<ArrowLeft size={16} aria-hidden="true" />
На главную
</a>
<h1
id="track-title"
tabIndex={-1}
className="mt-6 flex items-center gap-3 font-display text-[clamp(1.75rem,5.5vw,3rem)] font-bold leading-tight text-white outline-none"
>
<PackageSearch className="text-glow-cyan text-cyan-200" aria-hidden="true" />
Статус <span className="text-glow-violet text-fuchsia-200">заказа</span>
</h1>
<p className="mt-4 max-w-xl text-base leading-relaxed text-mist/70">
Введите код из подтверждения заказа — увидите, на каком этапе он находится:
принят, готовится или уже выдан.
</p>
<form
role="search"
aria-label="Проверка статуса заказа по коду"
onSubmit={(e) => {
e.preventDefault();
if (valid && !busy) void lookup(code);
}}
className="mt-8 flex flex-col gap-3 sm:flex-row"
>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="Код заказа, например AB12CD"
maxLength={6}
autoComplete="off"
spellCheck={false}
aria-label="Код заказа"
className="h-13 min-w-0 flex-1 rounded-xl border border-white/15 bg-panel/85 px-4 text-center font-display text-lg font-bold tracking-[0.35em] text-white outline-none transition-all placeholder:tracking-[0.12em] placeholder:text-mist/35 hover:border-neon-violet/40 focus:border-neon-cyan/60 focus:shadow-[0_0_18px_rgba(0,240,255,0.12)] sm:text-left"
/>
<button
type="submit"
disabled={!valid || busy}
className="inline-flex min-h-13 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-neon-cyan to-[#00b8d4] px-7 text-xs font-bold uppercase tracking-wider text-[#04252b] transition-all hover:shadow-[0_0_24px_rgba(0,240,255,0.4)] disabled:cursor-not-allowed disabled:opacity-35"
>
{busy ? <Loader2 size={16} className="animate-spin" aria-hidden="true" /> : <Search size={16} aria-hidden="true" />}
Проверить
</button>
</form>
{result?.phase === "found" && (
<StatusCard
info={result.info}
code={checked || code}
onRefresh={() => {
const c = checked || code;
if (c) void lookup(c);
}}
/>
)}
{result?.phase === "not-found" && (
<div
className="mt-6 flex items-start gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/[0.07] px-5 py-4 text-sm text-amber-200"
role="alert"
>
<XCircle size={17} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
Заказ с таким кодом не найден. Проверьте код — он из подтверждения после отправки
корзины. Заказы, созданные до появления кодов, не отслеживаются.
</span>
</div>
)}
{result?.phase === "error" && (
<div
className="mt-6 flex items-start gap-3 rounded-2xl border border-red-400/30 bg-red-400/[0.07] px-5 py-4 text-sm text-red-200"
role="alert"
>
<XCircle size={17} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{result.message}</span>
</div>
)}
<div className="mt-10 rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-sm leading-relaxed text-mist/60">
Где взять код? После отправки заказа из корзины он появляется на экране подтверждения.
Покажите код официанту — он найдёт заказ быстрее.
</div>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />