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