feat: split big file and update agents.md
This commit is contained in:
66
AGENTS.md
66
AGENTS.md
@@ -5,7 +5,7 @@ Juniper Table — seasonal restaurant template for booking-driven dining: keep m
|
||||
## Project Specifics
|
||||
|
||||
- Source content lives in \`src/entities/site-content.ts\`; keep visible copy there or directly in route JSX.
|
||||
- \`src/app\` is routing only; page composition belongs to \`src/widgets/template-ui.tsx\`.
|
||||
- \`src/app\` is routing only; композиция каждой страницы живёт в отдельном widget (\`src/widgets/<page>-page.tsx\`). См. File Map.
|
||||
- Keep cards at 8px radius or less and preserve the restaurant palette in \`src/app/globals.css\`.
|
||||
- This is a static frontend template: do not add real payments, deposits, table-management API, delivery, auth, persistence, external API calls, or backend contracts without an explicit product request.
|
||||
- Keep restaurant specificity: seating, service windows, host confirmation, allergies/dietary requests, pairing, supplier story and private dining packages.
|
||||
@@ -15,3 +15,67 @@ Juniper Table — seasonal restaurant template for booking-driven dining: keep m
|
||||
|
||||
- Кириллица обязательна для видимого текста и выбранных Google Fonts.
|
||||
- Do not flatten this into a generic cafe landing page. Every page should answer a real restaurant business question.
|
||||
|
||||
## Design System
|
||||
|
||||
Источник токенов — `src/app/globals.css` (`@theme inline` + `:root`/`.dark`). Шрифты: заголовки (`h1/h2/h3`, `.font-display`) — **Cormorant Garamond** (`--font-display`, элегантный serif), текст/UI — **Manrope** (`--font-sans`). Работай через семантические классы Tailwind (`bg-primary`, `text-foreground`, `border-border`, `text-muted-foreground`, `font-display`), никогда не хардкодь hex/oklch.
|
||||
|
||||
Личность: **тёплый сезонный fine-dining** — кремово-пергаментный фон (тёплый, hue ~75–92), глубокий приглушённый «можжевеловый» зелёный primary (hue ~145) как фирменный цвет, тёплый терракот/обожжённый оранжевый accent (hue ~34). Контраст элегантного serif-заголовка и спокойного гротеска в тексте. Тон ресторанный, тёплый, не «технологичный».
|
||||
|
||||
| Роль | Light | Характер |
|
||||
|---|---|---|
|
||||
| `background` | тёплый кремовый пергамент | основной фон страниц |
|
||||
| `foreground` | тёмный тёплый коричнево-чёрный | текст |
|
||||
| `primary` | глубокий можжевеловый зелёный | бренд: лого JT, CTA, цены, акценты; footer/CTA-панели `bg-primary text-primary-foreground` |
|
||||
| `accent` | терракот/обожжённый оранжевый | редкие тёплые точечные акценты (charts) |
|
||||
| `secondary` | тёплый светлый песочный | мягкие бейджи, плашки |
|
||||
| `muted` / `muted-foreground` | тёплый бежевый / приглушённый коричневый | вторичный текст, фон секций, hover |
|
||||
| `card` | чистый белый | панели `RestaurantPanel`, формы, таблицы |
|
||||
| `border` | тёплый бежевый | тонкие границы (1px) везде |
|
||||
|
||||
Узнаваемые приёмы (держи их — это «лицо» шаблона):
|
||||
- **Мягкие углы:** `--radius` = 0.5rem; всё на `rounded-md` (карточки, кнопки, фото-панели). Не используй острые `rounded-none` или сильно скруглённые pill-формы.
|
||||
- **`RestaurantPanel`** (`src/shared/ui/restaurant-panel.tsx`) — фирменная панель: `rounded-md border border-border bg-card` + едва заметная тёплая тень `shadow-[0_1px_0_rgba(67,44,17,0.05)]`. Используй её для секционных карточек вместо голого `div`.
|
||||
- **`Container`** (`src/shared/ui/container.tsx`) — `max-w-[1320px]` + `px-5 sm:px-8`; единый горизонтальный ритм страниц.
|
||||
- **Hero-фон:** утилита `.restaurant-tablecloth` (тёплая «скатерть» — зелёно-охровая сетка-льняная фактура 42px с mask-fade) для главного hero.
|
||||
- **Serif-дисплей:** заголовки через `font-display` (`text-4xl`…`text-[6.2rem]`, плотный `leading-[0.94]`); eyebrow-метки `text-xs font-semibold uppercase tracking-[0.18em] text-primary`.
|
||||
- **Тонкие разделители:** `Separator` и `border-b border-border` в списках блюд/пакетов; структура на линиях, не на тенях.
|
||||
|
||||
Do / Don't:
|
||||
- **Do:** держи кремово-зелёную палитру и терракотовый акцент; serif-заголовки + Manrope-текст; оборачивай секции в `Container`/`RestaurantPanel`; продавай ресторанную специфику (посадка, pairing, аллергены, тайминг вечера).
|
||||
- **Don't:** не вводи холодный синий/чёрный primary, неоновые градиенты, тяжёлые drop-shadow, острые углы или generic-SaaS hero — это ломает тёплый fine-dining тон.
|
||||
|
||||
## File Map
|
||||
|
||||
| Route | Widget |
|
||||
|---|---|
|
||||
| `/` | `src/widgets/home-page.tsx` (`HomePage`) |
|
||||
| `/menu` | `src/widgets/menu-page.tsx` (`MenuPage`) |
|
||||
| `/reservations` | `src/widgets/reservations-page.tsx` (`ReservationsPage`) |
|
||||
| `/private-events` | `src/widgets/private-events-page.tsx` (`PrivateEventsPage`) |
|
||||
| `/about` | `src/widgets/about-page.tsx` (`AboutPage`) |
|
||||
| `/contact` | `src/widgets/contact-page.tsx` (`ContactPage`) |
|
||||
|
||||
Переиспользуемые блоки:
|
||||
- `src/widgets/site-shell.tsx` — `SiteHeader` + `SiteFooter` (обёртка из `src/app/layout.tsx`).
|
||||
- `src/widgets/menu-section-board.tsx` — `MenuSectionBoard` (Home, Menu).
|
||||
- `src/widgets/dish-showcase.tsx` — `DishShowcase` (Home, Menu; локальный `DishRow` внутри).
|
||||
- `src/widgets/seating-guide.tsx` — `SeatingGuide` (Home, Reservations).
|
||||
- `src/widgets/restaurant-evening-plan.tsx` — `RestaurantEveningPlan` (Home, Reservations).
|
||||
- `src/widgets/private-events-grid.tsx` — `PrivateEventsGrid` (Home, Private events).
|
||||
- `src/widgets/testimonial-band.tsx` — `TestimonialBand` (Home, About).
|
||||
- `src/shared/ui/container.tsx` — `Container` (горизонтальный wrapper всех секций).
|
||||
- `src/shared/ui/restaurant-panel.tsx` — `RestaurantPanel` (фирменная карточка-панель).
|
||||
- `src/shared/ui/section-header.tsx` — `SectionHeader` (eyebrow + заголовок секции).
|
||||
- `src/shared/ui/inner-hero.tsx` — `InnerHero` (хедер всех внутренних страниц).
|
||||
- `src/shared/ui/info-columns.tsx` — `InfoColumns` (правила/dietary notes; используется в Menu и Reservations).
|
||||
- `src/shared/ui/cta-panel.tsx` — `CtaPanel` (Menu, Private events).
|
||||
- `src/shared/ui/split-story.tsx` — `SplitStory` (готовый props-блок image+points, сейчас не подключён ни одной страницей).
|
||||
|
||||
Одноразовые блоки колоцированы со своей страницей:
|
||||
- `PageHero` — внутри `home-page.tsx`.
|
||||
- `FeaturedGrid`, `PairingBoard` (+ `WineIcon`), `PricingTiles`, `DietaryNotes` — внутри `menu-page.tsx`.
|
||||
- `ReservationForm`, `ReservationRules` — внутри `reservations-page.tsx`.
|
||||
- `ChefStory`, `SupplierLedger`, `MetricStrip` — внутри `about-page.tsx`.
|
||||
- `IconCards`, `HoursBoard`, `ContactForm` — внутри `contact-page.tsx`.
|
||||
- `EventPlanner` — внутри `private-events-page.tsx`.
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ChefStory, InnerHero, MetricStrip, SupplierLedger, TestimonialBand } from "@/widgets/template-ui";
|
||||
import { AboutPage } from "@/widgets/about-page";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Kitchen"
|
||||
title="Juniper Table держится на продукте, сезоне и спокойном сервисе"
|
||||
text="About page объясняет кухню, поставщиков, открытый формат и почему меню меняется каждые несколько недель."
|
||||
/>
|
||||
<ChefStory />
|
||||
<SupplierLedger />
|
||||
<MetricStrip />
|
||||
<TestimonialBand />
|
||||
</>
|
||||
);
|
||||
return <AboutPage />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ContactForm, HoursBoard, IconCards, InnerHero } from "@/widgets/template-ui";
|
||||
import { contactCards } from "@/entities/site-content";
|
||||
import { ContactPage } from "@/widgets/contact-page";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Contact"
|
||||
title="Адрес, часы кухни, хост и детали подтверждения"
|
||||
text="Контакты ресторана должны отвечать на вопросы до звонка: как добраться, когда открыта кухня, как подтвердить бронь и где указать ограничения."
|
||||
/>
|
||||
<IconCards items={contactCards} />
|
||||
<HoursBoard />
|
||||
<ContactForm />
|
||||
</>
|
||||
);
|
||||
return <ContactPage />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/shared/hooks/theme-provider";
|
||||
import { ThemeMessageListener } from "@/shared/hooks/theme-message-listener";
|
||||
import { SiteHeader, SiteFooter } from "@/widgets/template-ui";
|
||||
import { SiteHeader, SiteFooter } from "@/widgets/site-shell";
|
||||
|
||||
const display = Cormorant_Garamond({
|
||||
variable: "--font-display",
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CtaPanel,
|
||||
DietaryNotes,
|
||||
DishShowcase,
|
||||
FeaturedGrid,
|
||||
InnerHero,
|
||||
MenuSectionBoard,
|
||||
PairingBoard,
|
||||
PricingTiles,
|
||||
} from "@/widgets/template-ui";
|
||||
import { MenuPage } from "@/widgets/menu-page";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Seasonal menu"
|
||||
title="Меню показывает сценарий ужина, аллергены и pairing"
|
||||
text="Страница меню продает не набор карточек, а путь гостя: секции, блюда, ограничения, напитки и дегустационные форматы."
|
||||
/>
|
||||
<MenuSectionBoard />
|
||||
<DishShowcase />
|
||||
<FeaturedGrid
|
||||
eyebrow="Menu ledger"
|
||||
title="A la carte с полезными деталями"
|
||||
text="У каждого блюда есть секция, цена, краткое описание и pairing, чтобы гость принимал решение до бронирования."
|
||||
/>
|
||||
<PairingBoard />
|
||||
<DietaryNotes />
|
||||
<PricingTiles title="Дегустационные форматы" />
|
||||
<CtaPanel title="Нужен стол у открытой кухни?" text="Укажите дату, время, гостей и ограничения. Хост подтвердит доступный формат посадки." href="/reservations" label="Перейти к брони" />
|
||||
</>
|
||||
);
|
||||
return <MenuPage />;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DishShowcase,
|
||||
MenuSectionBoard,
|
||||
PageHero,
|
||||
PrivateEventsGrid,
|
||||
RestaurantEveningPlan,
|
||||
SeatingGuide,
|
||||
TestimonialBand,
|
||||
} from "@/widgets/template-ui";
|
||||
import { HomePage } from "@/widgets/home-page";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<PageHero />
|
||||
<MenuSectionBoard />
|
||||
<DishShowcase />
|
||||
<SeatingGuide />
|
||||
<RestaurantEveningPlan />
|
||||
<PrivateEventsGrid />
|
||||
<TestimonialBand />
|
||||
</>
|
||||
);
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { CtaPanel, EventPlanner, InnerHero, PrivateEventsGrid } from "@/widgets/template-ui";
|
||||
import { PrivateEventsPage } from "@/widgets/private-events-page";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Private dining"
|
||||
title="Камерные ужины, wedding welcome и corporate dinner как отдельные продукты"
|
||||
text="Страница продает высокомаржинальные сценарии: гости, формат, меню, депозит, тайминг и команда зала."
|
||||
/>
|
||||
<PrivateEventsGrid />
|
||||
<EventPlanner />
|
||||
<CtaPanel title="Собрать вечер под вашу дату?" text="Оставьте формат, гостей и желаемый тайминг. Команда предложит меню и условия." href="/reservations" label="Оставить запрос" />
|
||||
</>
|
||||
);
|
||||
return <PrivateEventsPage />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { InnerHero, ReservationForm, ReservationRules, RestaurantEveningPlan, SeatingGuide } from "@/widgets/template-ui";
|
||||
import { ReservationsPage } from "@/widgets/reservations-page";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Reservations"
|
||||
title="Бронь уточняет формат вечера, посадку и ограничения"
|
||||
text="Production-ready booking UI для ресторана: гости, время, посадка, событие, аллергии и правила подтверждения."
|
||||
/>
|
||||
<SeatingGuide />
|
||||
<ReservationForm />
|
||||
<ReservationRules />
|
||||
<RestaurantEveningPlan />
|
||||
</>
|
||||
);
|
||||
return <ReservationsPage />;
|
||||
}
|
||||
|
||||
5
src/shared/ui/container.tsx
Normal file
5
src/shared/ui/container.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Container({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <section className={"mx-auto w-full max-w-[1320px] px-5 sm:px-8 " + className}>{children}</section>;
|
||||
}
|
||||
19
src/shared/ui/cta-panel.tsx
Normal file
19
src/shared/ui/cta-panel.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
|
||||
export function CtaPanel({ title, text, href, label }: { title: string; text: string; href: string; label: string }) {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<div className="rounded-md bg-primary p-8 text-primary-foreground">
|
||||
<h2 className="font-display text-4xl font-semibold">{title}</h2>
|
||||
<p className="mt-4 max-w-[720px] text-sm leading-7 text-primary-foreground/72">{text}</p>
|
||||
<Button asChild variant="secondary" className="mt-6 rounded-md">
|
||||
<Link href={href}>{label}<ArrowRightIcon className="size-4" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
23
src/shared/ui/info-columns.tsx
Normal file
23
src/shared/ui/info-columns.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { StarIcon } from "lucide-react";
|
||||
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
|
||||
type InfoItem = { title: string; text: string };
|
||||
|
||||
export function InfoColumns({ title, items }: { title: string; items: readonly InfoItem[] }) {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<h2 className="mb-6 font-display text-4xl font-semibold">{title}</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<StarIcon className="mb-8 size-5 text-primary" />
|
||||
<h3 className="font-display text-2xl font-semibold">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
14
src/shared/ui/inner-hero.tsx
Normal file
14
src/shared/ui/inner-hero.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
|
||||
export function InnerHero({ eyebrow, title, text }: { eyebrow: string; title: string; text: string }) {
|
||||
return (
|
||||
<section className="border-b border-border bg-card">
|
||||
<Container className="py-14">
|
||||
<Badge variant="outline" className="mb-6 rounded-md border-primary/30 text-primary">{eyebrow}</Badge>
|
||||
<h1 className="max-w-[980px] font-display text-4xl font-semibold leading-[1.02] sm:text-6xl">{title}</h1>
|
||||
<p className="mt-5 max-w-[760px] text-base leading-7 text-muted-foreground">{text}</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
5
src/shared/ui/restaurant-panel.tsx
Normal file
5
src/shared/ui/restaurant-panel.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function RestaurantPanel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <div className={"rounded-md border border-border bg-card shadow-[0_1px_0_rgba(67,44,17,0.05)] " + className}>{children}</div>;
|
||||
}
|
||||
11
src/shared/ui/section-header.tsx
Normal file
11
src/shared/ui/section-header.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export function SectionHeader({ eyebrow, title, text }: { eyebrow: string; title: string; text?: string }) {
|
||||
return (
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-[0.8fr_1fr] md:items-end">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</div>
|
||||
<h2 className="mt-2 font-display text-4xl font-semibold leading-tight sm:text-6xl">{title}</h2>
|
||||
</div>
|
||||
{text ? <p className="max-w-[620px] text-sm leading-7 text-muted-foreground sm:text-base">{text}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/shared/ui/split-story.tsx
Normal file
28
src/shared/ui/split-story.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Image from "next/image";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
|
||||
export function SplitStory({ image, eyebrow, title, text, points }: { image: string; eyebrow: string; title: string; text: string; points: readonly string[] }) {
|
||||
return (
|
||||
<Container className="grid gap-10 py-14 lg:grid-cols-2">
|
||||
<RestaurantPanel className="relative min-h-[500px] overflow-hidden bg-muted">
|
||||
<Image src={image} alt={title} fill className="object-cover" sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</RestaurantPanel>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-primary">{eyebrow}</div>
|
||||
<h2 className="font-display text-4xl font-semibold leading-tight sm:text-5xl">{title}</h2>
|
||||
<p className="mt-5 text-base leading-7 text-muted-foreground">{text}</p>
|
||||
<div className="mt-7 grid gap-3">
|
||||
{points.map((point) => (
|
||||
<div key={point} className="flex items-center gap-3 text-sm font-semibold">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{point}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
85
src/widgets/about-page.tsx
Normal file
85
src/widgets/about-page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import Image from "next/image";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { highlights, suppliers } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { InnerHero } from "@/shared/ui/inner-hero";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { SectionHeader } from "@/shared/ui/section-header";
|
||||
import { TestimonialBand } from "@/widgets/testimonial-band";
|
||||
|
||||
function ChefStory() {
|
||||
return (
|
||||
<Container className="grid gap-10 py-14 lg:grid-cols-2">
|
||||
<RestaurantPanel className="relative min-h-[520px] overflow-hidden bg-muted">
|
||||
<Image src="https://images.unsplash.com/photo-1577219491135-ce391730fb2c?auto=format&fit=crop&w=1200&q=82" alt="Kitchen team" fill className="object-cover" sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</RestaurantPanel>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-primary">Kitchen story</div>
|
||||
<h2 className="font-display text-4xl font-semibold leading-tight sm:text-5xl">Точная кухня и тихая уверенность сервиса</h2>
|
||||
<p className="mt-5 text-base leading-7 text-muted-foreground">
|
||||
Juniper Table строит меню вокруг короткого сезонного окна. Шеф-команда не обещает шоу: она держит продукт, соус, свет и темп вечера.
|
||||
</p>
|
||||
<div className="mt-7 grid gap-3">
|
||||
{["поставки под меню недели", "открытая кухня без театральности", "зал держит ритм, а не давит на гостя"].map((point) => (
|
||||
<div key={point} className="flex items-center gap-3 text-sm font-semibold">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{point}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function SupplierLedger() {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<SectionHeader eyebrow="Suppliers" title="Поставщики объясняют сезонность меню" text="Ресторанный сайт становится убедительнее, когда показывает, откуда берутся продукты и почему меню меняется." />
|
||||
<div className="overflow-hidden rounded-md border border-border bg-card">
|
||||
{suppliers.map((supplier) => (
|
||||
<div key={supplier.name} className="grid gap-3 border-b border-border p-5 last:border-b-0 md:grid-cols-[1fr_1fr_180px]">
|
||||
<div className="font-display text-2xl font-semibold">{supplier.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{supplier.product}</div>
|
||||
<div className="text-sm font-semibold text-primary md:text-right">{supplier.cadence}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricStrip() {
|
||||
return (
|
||||
<Container className="grid gap-3 py-8 md:grid-cols-3">
|
||||
{highlights.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-5">
|
||||
<Icon className="mb-6 size-5 text-primary" />
|
||||
<div className="font-display text-4xl font-semibold">{item.value}</div>
|
||||
<h3 className="mt-2 font-semibold">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Kitchen"
|
||||
title="Juniper Table держится на продукте, сезоне и спокойном сервисе"
|
||||
text="About page объясняет кухню, поставщиков, открытый формат и почему меню меняется каждые несколько недель."
|
||||
/>
|
||||
<ChefStory />
|
||||
<SupplierLedger />
|
||||
<MetricStrip />
|
||||
<TestimonialBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
src/widgets/contact-page.tsx
Normal file
82
src/widgets/contact-page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { ClockIcon } from "lucide-react";
|
||||
|
||||
import { contactCards, serviceWindows } from "@/entities/site-content";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { InnerHero } from "@/shared/ui/inner-hero";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type IconComponent = ComponentType<{ className?: string }>;
|
||||
type IconItem = { title: string; text: string; icon: IconComponent };
|
||||
|
||||
function IconCards({ items }: { items: readonly IconItem[] }) {
|
||||
return (
|
||||
<Container className="grid gap-4 py-12 md:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<Icon className="mb-8 size-6 text-primary" />
|
||||
<h3 className="font-display text-2xl font-semibold">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function HoursBoard() {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{serviceWindows.map((item) => (
|
||||
<RestaurantPanel key={item.label} className="p-6">
|
||||
<ClockIcon className="mb-8 size-6 text-primary" />
|
||||
<h3 className="font-display text-3xl font-semibold">{item.label}</h3>
|
||||
<div className="mt-3 font-display text-3xl font-semibold text-primary">{item.time}</div>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.note}</p>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactForm() {
|
||||
return (
|
||||
<Container className="grid gap-6 py-14 lg:grid-cols-2">
|
||||
<RestaurantPanel className="p-6">
|
||||
<ClockIcon className="mb-8 size-6 text-primary" />
|
||||
<h2 className="font-display text-4xl font-semibold">Связаться с хостом</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||
Контактная страница закрывает практику: как добраться, когда работает кухня, как подтвердить бронь и где указать ограничения.
|
||||
</p>
|
||||
</RestaurantPanel>
|
||||
<form className="grid gap-3 rounded-md border border-border bg-card p-5">
|
||||
<Input placeholder="Ваше имя" />
|
||||
<Input placeholder="Email или телефон" />
|
||||
<Textarea placeholder="Дата, гости, событие, аллергии или вопрос хосту" />
|
||||
<Button type="button" className="h-11 rounded-md">Отправить</Button>
|
||||
</form>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactPage() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Contact"
|
||||
title="Адрес, часы кухни, хост и детали подтверждения"
|
||||
text="Контакты ресторана должны отвечать на вопросы до звонка: как добраться, когда открыта кухня, как подтвердить бронь и где указать ограничения."
|
||||
/>
|
||||
<IconCards items={contactCards} />
|
||||
<HoursBoard />
|
||||
<ContactForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/widgets/dish-showcase.tsx
Normal file
58
src/widgets/dish-showcase.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { dishes } from "@/entities/site-content";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { SectionHeader } from "@/shared/ui/section-header";
|
||||
|
||||
type DishItem = (typeof dishes)[number];
|
||||
|
||||
function DishRow({ dish }: { dish: DishItem }) {
|
||||
return (
|
||||
<article className="grid grid-cols-[132px_1fr] overflow-hidden rounded-md border border-border bg-card sm:grid-cols-[170px_1fr]">
|
||||
<div className="relative min-h-[168px] bg-muted">
|
||||
<Image src={dish.image} alt={dish.name} fill className="object-cover" sizes="180px" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="rounded-md">{dish.section}</Badge>
|
||||
<span className="text-sm font-semibold">{dish.price}</span>
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-semibold">{dish.name}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{dish.text}</p>
|
||||
<div className="mt-4 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<div>allergens: {dish.allergens}</div>
|
||||
<div>pairing: {dish.pairing}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DishShowcase() {
|
||||
const heroDish = dishes[1];
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="A la carte" title="Блюда продают вкус, ограничения и pairing сразу" text="Для ресторанного шаблона важно показывать аллергенные подсказки, секцию меню и сочетание напитков, а не только фото и цену." />
|
||||
<div className="grid gap-4 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<RestaurantPanel className="relative min-h-[560px] overflow-hidden bg-muted">
|
||||
<Image src={heroDish.image} alt={heroDish.name} fill className="object-cover" sizes="(min-width: 1024px) 54vw, 100vw" />
|
||||
<div className="absolute bottom-5 left-5 right-5 rounded-md bg-background/92 p-5 backdrop-blur">
|
||||
<Badge variant="secondary" className="mb-3 rounded-md">{heroDish.tag}</Badge>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h3 className="font-display text-3xl font-semibold">{heroDish.name}</h3>
|
||||
<div className="font-display text-3xl font-semibold">{heroDish.price}</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{heroDish.text}</p>
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
<div className="grid gap-3">
|
||||
{dishes.filter((dish) => dish.name !== heroDish.name).map((dish) => (
|
||||
<DishRow key={dish.name} dish={dish} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
91
src/widgets/home-page.tsx
Normal file
91
src/widgets/home-page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
|
||||
import { highlights, serviceWindows, site } from "@/entities/site-content";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { DishShowcase } from "@/widgets/dish-showcase";
|
||||
import { MenuSectionBoard } from "@/widgets/menu-section-board";
|
||||
import { PrivateEventsGrid } from "@/widgets/private-events-grid";
|
||||
import { RestaurantEveningPlan } from "@/widgets/restaurant-evening-plan";
|
||||
import { SeatingGuide } from "@/widgets/seating-guide";
|
||||
import { TestimonialBand } from "@/widgets/testimonial-band";
|
||||
|
||||
function PageHero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-border">
|
||||
<div className="restaurant-tablecloth absolute inset-0" />
|
||||
<Container className="relative grid min-h-[760px] gap-10 py-14 lg:grid-cols-[0.72fr_1.28fr]">
|
||||
<div className="flex flex-col justify-center">
|
||||
<Badge variant="outline" className="mb-6 w-fit rounded-md border-primary/30 bg-background/80 text-primary">service opens at 18:00</Badge>
|
||||
<h1 className="font-display text-5xl font-semibold leading-[0.94] tracking-normal sm:text-7xl lg:text-[6.2rem]">
|
||||
Вечер начинается до первого блюда
|
||||
</h1>
|
||||
<p className="mt-6 max-w-[580px] text-lg leading-8 text-muted-foreground">{site.tagline}</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Button asChild className="h-12 rounded-md px-6">
|
||||
<Link href="/reservations">{site.cta}<ArrowRightIcon className="size-4" /></Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="h-12 rounded-md bg-background/65 px-6">
|
||||
<Link href="/menu">{site.secondaryCta}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-9 grid gap-3 sm:grid-cols-3">
|
||||
{serviceWindows.map((window) => (
|
||||
<div key={window.label} className="rounded-md border border-border bg-card/88 p-4">
|
||||
<div className="font-display text-2xl font-semibold">{window.time}</div>
|
||||
<div className="mt-1 text-sm font-semibold">{window.label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{window.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_300px]">
|
||||
<RestaurantPanel className="relative min-h-[620px] overflow-hidden bg-muted">
|
||||
<Image src={site.heroImage} alt="Вечерняя посадка в Juniper Table" fill priority className="object-cover" sizes="(min-width: 1280px) 680px, 100vw" />
|
||||
<div className="absolute bottom-5 left-5 right-5 rounded-md bg-background/92 p-5 shadow-xl backdrop-blur">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-3 rounded-md">tonight</Badge>
|
||||
<h2 className="font-display text-3xl font-semibold">Chef counter: 4 места на 20:30</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">Подходит для дегустационного сета и pairing.</p>
|
||||
</div>
|
||||
<div className="font-display text-2xl font-semibold">18</div>
|
||||
</div>
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
<div className="grid content-center gap-4">
|
||||
{highlights.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-5">
|
||||
<Icon className="mb-7 size-5 text-primary" />
|
||||
<div className="font-display text-3xl font-semibold">{item.value}</div>
|
||||
<h3 className="mt-2 font-semibold">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero />
|
||||
<MenuSectionBoard />
|
||||
<DishShowcase />
|
||||
<SeatingGuide />
|
||||
<RestaurantEveningPlan />
|
||||
<PrivateEventsGrid />
|
||||
<TestimonialBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
src/widgets/menu-page.tsx
Normal file
110
src/widgets/menu-page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { CheckIcon, StarIcon } from "lucide-react";
|
||||
|
||||
import { dietaryNotes, dishes, pairingFlights, tastingSets } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { CtaPanel } from "@/shared/ui/cta-panel";
|
||||
import { InfoColumns } from "@/shared/ui/info-columns";
|
||||
import { InnerHero } from "@/shared/ui/inner-hero";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { SectionHeader } from "@/shared/ui/section-header";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { DishShowcase } from "@/widgets/dish-showcase";
|
||||
import { MenuSectionBoard } from "@/widgets/menu-section-board";
|
||||
|
||||
type DishItem = (typeof dishes)[number];
|
||||
type TileItem = { title: string; price: string; items: readonly string[] };
|
||||
|
||||
function FeaturedGrid({ eyebrow, title, text, items = dishes }: { eyebrow: string; title: string; text: string; items?: readonly DishItem[] }) {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow={eyebrow} title={title} text={text} />
|
||||
<div className="overflow-hidden rounded-md border border-border bg-card">
|
||||
{items.map((item) => (
|
||||
<div key={item.name} className="grid gap-4 border-b border-border p-5 last:border-b-0 md:grid-cols-[1fr_140px_180px] md:items-center">
|
||||
<div>
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.14em] text-primary">{item.tag}</div>
|
||||
<h3 className="font-display text-2xl font-semibold">{item.name}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</div>
|
||||
<div className="font-display text-2xl font-semibold md:text-right">{item.price}</div>
|
||||
<div className="text-sm leading-6 text-muted-foreground">pairing: {item.pairing}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function WineIcon({ className }: { className?: string }) {
|
||||
return <StarIcon className={className} />;
|
||||
}
|
||||
|
||||
function PairingBoard() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Pairing" title="Напитки продают темп вечера" text="Pairing вынесен отдельным блоком: вино, non-alcohol и спокойные альтернативы без давления на гостя." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{pairingFlights.map((flight) => (
|
||||
<RestaurantPanel key={flight.title} className="p-6">
|
||||
<WineIcon className="mb-8 size-6 text-primary" />
|
||||
<h3 className="font-display text-3xl font-semibold">{flight.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{flight.text}</p>
|
||||
<div className="mt-5 font-display text-2xl font-semibold">{flight.price}</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingTiles({ title, items = tastingSets }: { title: string; items?: readonly TileItem[] }) {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<h2 className="mb-7 font-display text-4xl font-semibold sm:text-5xl">{title}</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<h3 className="font-display text-2xl font-semibold">{item.title}</h3>
|
||||
<div className="mt-3 font-display text-3xl font-semibold text-primary">{item.price}</div>
|
||||
<Separator className="my-5" />
|
||||
<div className="grid gap-2">
|
||||
{item.items.map((line) => (
|
||||
<div key={line} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function DietaryNotes() {
|
||||
return <InfoColumns title="Allergies and dietary notes" items={dietaryNotes} />;
|
||||
}
|
||||
|
||||
export function MenuPage() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Seasonal menu"
|
||||
title="Меню показывает сценарий ужина, аллергены и pairing"
|
||||
text="Страница меню продает не набор карточек, а путь гостя: секции, блюда, ограничения, напитки и дегустационные форматы."
|
||||
/>
|
||||
<MenuSectionBoard />
|
||||
<DishShowcase />
|
||||
<FeaturedGrid
|
||||
eyebrow="Menu ledger"
|
||||
title="A la carte с полезными деталями"
|
||||
text="У каждого блюда есть секция, цена, краткое описание и pairing, чтобы гость принимал решение до бронирования."
|
||||
/>
|
||||
<PairingBoard />
|
||||
<DietaryNotes />
|
||||
<PricingTiles title="Дегустационные форматы" />
|
||||
<CtaPanel title="Нужен стол у открытой кухни?" text="Укажите дату, время, гостей и ограничения. Хост подтвердит доступный формат посадки." href="/reservations" label="Перейти к брони" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
src/widgets/menu-section-board.tsx
Normal file
32
src/widgets/menu-section-board.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { menuSections } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { SectionHeader } from "@/shared/ui/section-header";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
|
||||
export function MenuSectionBoard() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Menu architecture" title="Меню собрано по сценарию вечера" text="Гость видит не хаотичный список блюд, а путь: легкий старт, огонь и основные блюда, затем десерт и pairing." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{menuSections.map((section) => (
|
||||
<RestaurantPanel key={section.title} className="p-6">
|
||||
<h3 className="font-display text-3xl font-semibold">{section.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{section.note}</p>
|
||||
<Separator className="my-5" />
|
||||
<div className="grid gap-2">
|
||||
{section.items.map((item) => (
|
||||
<div key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
33
src/widgets/private-events-grid.tsx
Normal file
33
src/widgets/private-events-grid.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { privatePackages } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { SectionHeader } from "@/shared/ui/section-header";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
|
||||
export function PrivateEventsGrid() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Private dining" title="События продаются пакетами, а не одной формой обратной связи" text="Для ресторана private events - отдельная выручка: гости, формат, депозит, меню и тайминг должны быть видны заранее." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{privatePackages.map((item) => (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<h3 className="font-display text-3xl font-semibold">{item.title}</h3>
|
||||
<div className="mt-3 text-sm font-semibold text-primary">{item.guests}</div>
|
||||
<div className="mt-5 font-display text-3xl font-semibold">{item.price}</div>
|
||||
<Separator className="my-5" />
|
||||
<div className="grid gap-2">
|
||||
{item.details.map((detail) => (
|
||||
<div key={detail} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
45
src/widgets/private-events-page.tsx
Normal file
45
src/widgets/private-events-page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { eventTypes } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { CtaPanel } from "@/shared/ui/cta-panel";
|
||||
import { InnerHero } from "@/shared/ui/inner-hero";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { PrivateEventsGrid } from "@/widgets/private-events-grid";
|
||||
|
||||
function EventPlanner() {
|
||||
return (
|
||||
<Container className="grid gap-4 py-12 lg:grid-cols-[1fr_1.05fr]">
|
||||
<RestaurantPanel className="relative min-h-[460px] overflow-hidden bg-muted">
|
||||
<Image src="https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?auto=format&fit=crop&w=1200&q=82" alt="Private dining room" fill className="object-cover" sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</RestaurantPanel>
|
||||
<div className="grid gap-4">
|
||||
{eventTypes.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<Icon className="mb-7 size-6 text-primary" />
|
||||
<h3 className="font-display text-3xl font-semibold">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateEventsPage() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Private dining"
|
||||
title="Камерные ужины, wedding welcome и corporate dinner как отдельные продукты"
|
||||
text="Страница продает высокомаржинальные сценарии: гости, формат, меню, депозит, тайминг и команда зала."
|
||||
/>
|
||||
<PrivateEventsGrid />
|
||||
<EventPlanner />
|
||||
<CtaPanel title="Собрать вечер под вашу дату?" text="Оставьте формат, гостей и желаемый тайминг. Команда предложит меню и условия." href="/reservations" label="Оставить запрос" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
src/widgets/reservations-page.tsx
Normal file
68
src/widgets/reservations-page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { CalendarDaysIcon } from "lucide-react";
|
||||
|
||||
import { bookingRules } from "@/entities/site-content";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { InfoColumns } from "@/shared/ui/info-columns";
|
||||
import { InnerHero } from "@/shared/ui/inner-hero";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { RestaurantEveningPlan } from "@/widgets/restaurant-evening-plan";
|
||||
import { SeatingGuide } from "@/widgets/seating-guide";
|
||||
|
||||
function ReservationForm() {
|
||||
return (
|
||||
<Container className="grid gap-8 py-14 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<div>
|
||||
<Badge variant="outline" className="mb-5 rounded-md">
|
||||
<CalendarDaysIcon className="size-4" />
|
||||
booking flow
|
||||
</Badge>
|
||||
<h2 className="font-display text-4xl font-semibold sm:text-5xl">Бронь уточняет сценарий вечера</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-muted-foreground">
|
||||
Форма статическая, но показывает production-ready сценарий: дата, время, гости, посадка, аллергии, событие и пожелания к темпу.
|
||||
</p>
|
||||
</div>
|
||||
<form className="grid gap-4 rounded-md border border-border bg-card p-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input placeholder="Имя" />
|
||||
<Input placeholder="Телефон" />
|
||||
<Input placeholder="Дата" />
|
||||
<Input placeholder="Время" />
|
||||
<Input placeholder="Количество гостей" />
|
||||
<Input placeholder="Формат посадки" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["chef counter", "dining room", "private room", "tasting", "birthday"].map((tag) => (
|
||||
<button key={tag} type="button" className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-muted-foreground">
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Textarea placeholder="Аллергии, dietary requests, детский стул, событие или пожелания к темпу вечера" />
|
||||
<Button type="button" className="h-11 rounded-md">Отправить заявку хосту</Button>
|
||||
</form>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function ReservationRules() {
|
||||
return <InfoColumns title="Правила бронирования" items={bookingRules} />;
|
||||
}
|
||||
|
||||
export function ReservationsPage() {
|
||||
return (
|
||||
<>
|
||||
<InnerHero
|
||||
eyebrow="Reservations"
|
||||
title="Бронь уточняет формат вечера, посадку и ограничения"
|
||||
text="Production-ready booking UI для ресторана: гости, время, посадка, событие, аллергии и правила подтверждения."
|
||||
/>
|
||||
<SeatingGuide />
|
||||
<ReservationForm />
|
||||
<ReservationRules />
|
||||
<RestaurantEveningPlan />
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
src/widgets/restaurant-evening-plan.tsx
Normal file
21
src/widgets/restaurant-evening-plan.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { eveningTimeline } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
|
||||
export function RestaurantEveningPlan() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<div className="rounded-md bg-primary p-6 text-primary-foreground sm:p-8">
|
||||
<h2 className="font-display text-4xl font-semibold">Маршрут вечера</h2>
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||
{eveningTimeline.map((step) => (
|
||||
<div key={step.time} className="rounded-md border border-primary-foreground/18 p-5">
|
||||
<div className="font-display text-3xl font-semibold">{step.time}</div>
|
||||
<h3 className="mt-5 font-semibold">{step.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-primary-foreground/72">{step.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
22
src/widgets/seating-guide.tsx
Normal file
22
src/widgets/seating-guide.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { seatingOptions } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
import { RestaurantPanel } from "@/shared/ui/restaurant-panel";
|
||||
import { SectionHeader } from "@/shared/ui/section-header";
|
||||
|
||||
export function SeatingGuide() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Seating" title="Посадка - это отдельный продукт ресторана" text="Гость выбирает не просто время, а формат: зал, chef counter или приватная комната с депозитом и ожиданиями." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{seatingOptions.map((option) => (
|
||||
<RestaurantPanel key={option.title} className="p-6">
|
||||
<h3 className="font-display text-3xl font-semibold">{option.title}</h3>
|
||||
<div className="mt-3 font-display text-2xl font-semibold text-primary">{option.seats}</div>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{option.text}</p>
|
||||
<div className="mt-5 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">{option.deposit}</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
59
src/widgets/site-shell.tsx
Normal file
59
src/widgets/site-shell.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import { MenuIcon } from "lucide-react";
|
||||
|
||||
import { site } from "@/entities/site-content";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/92 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-[72px] w-full max-w-[1320px] items-center justify-between px-5 sm:px-8">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">JT</span>
|
||||
<span>
|
||||
<span className="block font-display text-2xl font-semibold leading-none">{site.name}</span>
|
||||
<span className="hidden text-[11px] uppercase tracking-[0.18em] text-muted-foreground sm:block">seasonal dining</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{site.nav.map((item) => (
|
||||
<Link key={item.href} href={item.href} className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="hidden rounded-md border-primary/30 bg-card text-primary md:inline-flex">кухня до 23:00</Badge>
|
||||
<Button asChild className="hidden h-11 rounded-md px-6 sm:inline-flex">
|
||||
<Link href="/reservations">{site.cta}</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="rounded-md lg:hidden" aria-label="Открыть меню">
|
||||
<MenuIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-primary text-primary-foreground">
|
||||
<div className="mx-auto grid w-full max-w-[1320px] gap-8 px-5 py-12 sm:px-8 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div>
|
||||
<div className="font-display text-4xl font-semibold">{site.name}</div>
|
||||
<p className="mt-4 max-w-[520px] text-sm leading-6 text-primary-foreground/72">{site.tagline}</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-primary-foreground/75">
|
||||
<div className="mb-2 font-semibold text-primary-foreground">Страницы</div>
|
||||
{site.nav.map((item) => <Link key={item.href} href={item.href}>{item.label}</Link>)}
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-primary-foreground/75">
|
||||
<div className="mb-2 font-semibold text-primary-foreground">Сегодня</div>
|
||||
Chef counter бронируется заранее. Аллергии и dietary requests лучше указать до визита.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,643 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CalendarDaysIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
MenuIcon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
bookingRules,
|
||||
dietaryNotes,
|
||||
dishes,
|
||||
eveningTimeline,
|
||||
eventTypes,
|
||||
highlights,
|
||||
menuSections,
|
||||
pairingFlights,
|
||||
privatePackages,
|
||||
seatingOptions,
|
||||
serviceWindows,
|
||||
site,
|
||||
suppliers,
|
||||
tastingSets,
|
||||
testimonials,
|
||||
} from "@/entities/site-content";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type IconComponent = ComponentType<{ className?: string }>;
|
||||
type IconItem = { title: string; text: string; icon: IconComponent };
|
||||
type DishItem = (typeof dishes)[number];
|
||||
type TileItem = { title: string; price: string; items: readonly string[] };
|
||||
type InfoItem = { title: string; text: string };
|
||||
type TestimonialItem = { name: string; text: string; rating: string };
|
||||
|
||||
function Container({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <section className={"mx-auto w-full max-w-[1320px] px-5 sm:px-8 " + className}>{children}</section>;
|
||||
}
|
||||
|
||||
function RestaurantPanel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <div className={"rounded-md border border-border bg-card shadow-[0_1px_0_rgba(67,44,17,0.05)] " + className}>{children}</div>;
|
||||
}
|
||||
|
||||
function SectionHeader({ eyebrow, title, text }: { eyebrow: string; title: string; text?: string }) {
|
||||
return (
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-[0.8fr_1fr] md:items-end">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</div>
|
||||
<h2 className="mt-2 font-display text-4xl font-semibold leading-tight sm:text-6xl">{title}</h2>
|
||||
</div>
|
||||
{text ? <p className="max-w-[620px] text-sm leading-7 text-muted-foreground sm:text-base">{text}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/92 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-[72px] w-full max-w-[1320px] items-center justify-between px-5 sm:px-8">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">JT</span>
|
||||
<span>
|
||||
<span className="block font-display text-2xl font-semibold leading-none">{site.name}</span>
|
||||
<span className="hidden text-[11px] uppercase tracking-[0.18em] text-muted-foreground sm:block">seasonal dining</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{site.nav.map((item) => (
|
||||
<Link key={item.href} href={item.href} className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="hidden rounded-md border-primary/30 bg-card text-primary md:inline-flex">кухня до 23:00</Badge>
|
||||
<Button asChild className="hidden h-11 rounded-md px-6 sm:inline-flex">
|
||||
<Link href="/reservations">{site.cta}</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="rounded-md lg:hidden" aria-label="Открыть меню">
|
||||
<MenuIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-primary text-primary-foreground">
|
||||
<div className="mx-auto grid w-full max-w-[1320px] gap-8 px-5 py-12 sm:px-8 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div>
|
||||
<div className="font-display text-4xl font-semibold">{site.name}</div>
|
||||
<p className="mt-4 max-w-[520px] text-sm leading-6 text-primary-foreground/72">{site.tagline}</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-primary-foreground/75">
|
||||
<div className="mb-2 font-semibold text-primary-foreground">Страницы</div>
|
||||
{site.nav.map((item) => <Link key={item.href} href={item.href}>{item.label}</Link>)}
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-primary-foreground/75">
|
||||
<div className="mb-2 font-semibold text-primary-foreground">Сегодня</div>
|
||||
Chef counter бронируется заранее. Аллергии и dietary requests лучше указать до визита.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-border">
|
||||
<div className="restaurant-tablecloth absolute inset-0" />
|
||||
<Container className="relative grid min-h-[760px] gap-10 py-14 lg:grid-cols-[0.72fr_1.28fr]">
|
||||
<div className="flex flex-col justify-center">
|
||||
<Badge variant="outline" className="mb-6 w-fit rounded-md border-primary/30 bg-background/80 text-primary">service opens at 18:00</Badge>
|
||||
<h1 className="font-display text-5xl font-semibold leading-[0.94] tracking-normal sm:text-7xl lg:text-[6.2rem]">
|
||||
Вечер начинается до первого блюда
|
||||
</h1>
|
||||
<p className="mt-6 max-w-[580px] text-lg leading-8 text-muted-foreground">{site.tagline}</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Button asChild className="h-12 rounded-md px-6">
|
||||
<Link href="/reservations">{site.cta}<ArrowRightIcon className="size-4" /></Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="h-12 rounded-md bg-background/65 px-6">
|
||||
<Link href="/menu">{site.secondaryCta}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-9 grid gap-3 sm:grid-cols-3">
|
||||
{serviceWindows.map((window) => (
|
||||
<div key={window.label} className="rounded-md border border-border bg-card/88 p-4">
|
||||
<div className="font-display text-2xl font-semibold">{window.time}</div>
|
||||
<div className="mt-1 text-sm font-semibold">{window.label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{window.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_300px]">
|
||||
<RestaurantPanel className="relative min-h-[620px] overflow-hidden bg-muted">
|
||||
<Image src={site.heroImage} alt="Вечерняя посадка в Juniper Table" fill priority className="object-cover" sizes="(min-width: 1280px) 680px, 100vw" />
|
||||
<div className="absolute bottom-5 left-5 right-5 rounded-md bg-background/92 p-5 shadow-xl backdrop-blur">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-3 rounded-md">tonight</Badge>
|
||||
<h2 className="font-display text-3xl font-semibold">Chef counter: 4 места на 20:30</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">Подходит для дегустационного сета и pairing.</p>
|
||||
</div>
|
||||
<div className="font-display text-2xl font-semibold">18</div>
|
||||
</div>
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
<div className="grid content-center gap-4">
|
||||
{highlights.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-5">
|
||||
<Icon className="mb-7 size-5 text-primary" />
|
||||
<div className="font-display text-3xl font-semibold">{item.value}</div>
|
||||
<h3 className="mt-2 font-semibold">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InnerHero({ eyebrow, title, text }: { eyebrow: string; title: string; text: string }) {
|
||||
return (
|
||||
<section className="border-b border-border bg-card">
|
||||
<Container className="py-14">
|
||||
<Badge variant="outline" className="mb-6 rounded-md border-primary/30 text-primary">{eyebrow}</Badge>
|
||||
<h1 className="max-w-[980px] font-display text-4xl font-semibold leading-[1.02] sm:text-6xl">{title}</h1>
|
||||
<p className="mt-5 max-w-[760px] text-base leading-7 text-muted-foreground">{text}</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSectionBoard() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Menu architecture" title="Меню собрано по сценарию вечера" text="Гость видит не хаотичный список блюд, а путь: легкий старт, огонь и основные блюда, затем десерт и pairing." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{menuSections.map((section) => (
|
||||
<RestaurantPanel key={section.title} className="p-6">
|
||||
<h3 className="font-display text-3xl font-semibold">{section.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{section.note}</p>
|
||||
<Separator className="my-5" />
|
||||
<div className="grid gap-2">
|
||||
{section.items.map((item) => (
|
||||
<div key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function DishShowcase() {
|
||||
const heroDish = dishes[1];
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="A la carte" title="Блюда продают вкус, ограничения и pairing сразу" text="Для ресторанного шаблона важно показывать аллергенные подсказки, секцию меню и сочетание напитков, а не только фото и цену." />
|
||||
<div className="grid gap-4 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<RestaurantPanel className="relative min-h-[560px] overflow-hidden bg-muted">
|
||||
<Image src={heroDish.image} alt={heroDish.name} fill className="object-cover" sizes="(min-width: 1024px) 54vw, 100vw" />
|
||||
<div className="absolute bottom-5 left-5 right-5 rounded-md bg-background/92 p-5 backdrop-blur">
|
||||
<Badge variant="secondary" className="mb-3 rounded-md">{heroDish.tag}</Badge>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h3 className="font-display text-3xl font-semibold">{heroDish.name}</h3>
|
||||
<div className="font-display text-3xl font-semibold">{heroDish.price}</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{heroDish.text}</p>
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
<div className="grid gap-3">
|
||||
{dishes.filter((dish) => dish.name !== heroDish.name).map((dish) => (
|
||||
<DishRow key={dish.name} dish={dish} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function DishRow({ dish }: { dish: DishItem }) {
|
||||
return (
|
||||
<article className="grid grid-cols-[132px_1fr] overflow-hidden rounded-md border border-border bg-card sm:grid-cols-[170px_1fr]">
|
||||
<div className="relative min-h-[168px] bg-muted">
|
||||
<Image src={dish.image} alt={dish.name} fill className="object-cover" sizes="180px" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="rounded-md">{dish.section}</Badge>
|
||||
<span className="text-sm font-semibold">{dish.price}</span>
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-semibold">{dish.name}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{dish.text}</p>
|
||||
<div className="mt-4 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<div>allergens: {dish.allergens}</div>
|
||||
<div>pairing: {dish.pairing}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturedGrid({ eyebrow, title, text, items = dishes }: { eyebrow: string; title: string; text: string; items?: readonly DishItem[] }) {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow={eyebrow} title={title} text={text} />
|
||||
<div className="overflow-hidden rounded-md border border-border bg-card">
|
||||
{items.map((item) => (
|
||||
<div key={item.name} className="grid gap-4 border-b border-border p-5 last:border-b-0 md:grid-cols-[1fr_140px_180px] md:items-center">
|
||||
<div>
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.14em] text-primary">{item.tag}</div>
|
||||
<h3 className="font-display text-2xl font-semibold">{item.name}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</div>
|
||||
<div className="font-display text-2xl font-semibold md:text-right">{item.price}</div>
|
||||
<div className="text-sm leading-6 text-muted-foreground">pairing: {item.pairing}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricStrip() {
|
||||
return (
|
||||
<Container className="grid gap-3 py-8 md:grid-cols-3">
|
||||
{highlights.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-5">
|
||||
<Icon className="mb-6 size-5 text-primary" />
|
||||
<div className="font-display text-4xl font-semibold">{item.value}</div>
|
||||
<h3 className="mt-2 font-semibold">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function PairingBoard() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Pairing" title="Напитки продают темп вечера" text="Pairing вынесен отдельным блоком: вино, non-alcohol и спокойные альтернативы без давления на гостя." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{pairingFlights.map((flight) => (
|
||||
<RestaurantPanel key={flight.title} className="p-6">
|
||||
<WineIcon className="mb-8 size-6 text-primary" />
|
||||
<h3 className="font-display text-3xl font-semibold">{flight.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{flight.text}</p>
|
||||
<div className="mt-5 font-display text-2xl font-semibold">{flight.price}</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function WineIcon({ className }: { className?: string }) {
|
||||
return <StarIcon className={className} />;
|
||||
}
|
||||
|
||||
export function PricingTiles({ title, items = tastingSets }: { title: string; items?: readonly TileItem[] }) {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<h2 className="mb-7 font-display text-4xl font-semibold sm:text-5xl">{title}</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<h3 className="font-display text-2xl font-semibold">{item.title}</h3>
|
||||
<div className="mt-3 font-display text-3xl font-semibold text-primary">{item.price}</div>
|
||||
<Separator className="my-5" />
|
||||
<div className="grid gap-2">
|
||||
{item.items.map((line) => (
|
||||
<div key={line} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeatingGuide() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Seating" title="Посадка - это отдельный продукт ресторана" text="Гость выбирает не просто время, а формат: зал, chef counter или приватная комната с депозитом и ожиданиями." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{seatingOptions.map((option) => (
|
||||
<RestaurantPanel key={option.title} className="p-6">
|
||||
<h3 className="font-display text-3xl font-semibold">{option.title}</h3>
|
||||
<div className="mt-3 font-display text-2xl font-semibold text-primary">{option.seats}</div>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{option.text}</p>
|
||||
<div className="mt-5 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">{option.deposit}</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestaurantEveningPlan() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<div className="rounded-md bg-primary p-6 text-primary-foreground sm:p-8">
|
||||
<h2 className="font-display text-4xl font-semibold">Маршрут вечера</h2>
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||
{eveningTimeline.map((step) => (
|
||||
<div key={step.time} className="rounded-md border border-primary-foreground/18 p-5">
|
||||
<div className="font-display text-3xl font-semibold">{step.time}</div>
|
||||
<h3 className="mt-5 font-semibold">{step.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-primary-foreground/72">{step.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReservationForm() {
|
||||
return (
|
||||
<Container className="grid gap-8 py-14 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<div>
|
||||
<Badge variant="outline" className="mb-5 rounded-md">
|
||||
<CalendarDaysIcon className="size-4" />
|
||||
booking flow
|
||||
</Badge>
|
||||
<h2 className="font-display text-4xl font-semibold sm:text-5xl">Бронь уточняет сценарий вечера</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-muted-foreground">
|
||||
Форма статическая, но показывает production-ready сценарий: дата, время, гости, посадка, аллергии, событие и пожелания к темпу.
|
||||
</p>
|
||||
</div>
|
||||
<form className="grid gap-4 rounded-md border border-border bg-card p-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Input placeholder="Имя" />
|
||||
<Input placeholder="Телефон" />
|
||||
<Input placeholder="Дата" />
|
||||
<Input placeholder="Время" />
|
||||
<Input placeholder="Количество гостей" />
|
||||
<Input placeholder="Формат посадки" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["chef counter", "dining room", "private room", "tasting", "birthday"].map((tag) => (
|
||||
<button key={tag} type="button" className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-muted-foreground">
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Textarea placeholder="Аллергии, dietary requests, детский стул, событие или пожелания к темпу вечера" />
|
||||
<Button type="button" className="h-11 rounded-md">Отправить заявку хосту</Button>
|
||||
</form>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReservationRules() {
|
||||
return <InfoColumns title="Правила бронирования" items={bookingRules} />;
|
||||
}
|
||||
|
||||
export function DietaryNotes() {
|
||||
return <InfoColumns title="Allergies and dietary notes" items={dietaryNotes} />;
|
||||
}
|
||||
|
||||
export function PrivateEventsGrid() {
|
||||
return (
|
||||
<Container className="py-14">
|
||||
<SectionHeader eyebrow="Private dining" title="События продаются пакетами, а не одной формой обратной связи" text="Для ресторана private events - отдельная выручка: гости, формат, депозит, меню и тайминг должны быть видны заранее." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{privatePackages.map((item) => (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<h3 className="font-display text-3xl font-semibold">{item.title}</h3>
|
||||
<div className="mt-3 text-sm font-semibold text-primary">{item.guests}</div>
|
||||
<div className="mt-5 font-display text-3xl font-semibold">{item.price}</div>
|
||||
<Separator className="my-5" />
|
||||
<div className="grid gap-2">
|
||||
{item.details.map((detail) => (
|
||||
<div key={detail} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventPlanner() {
|
||||
return (
|
||||
<Container className="grid gap-4 py-12 lg:grid-cols-[1fr_1.05fr]">
|
||||
<RestaurantPanel className="relative min-h-[460px] overflow-hidden bg-muted">
|
||||
<Image src="https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?auto=format&fit=crop&w=1200&q=82" alt="Private dining room" fill className="object-cover" sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</RestaurantPanel>
|
||||
<div className="grid gap-4">
|
||||
{eventTypes.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<Icon className="mb-7 size-6 text-primary" />
|
||||
<h3 className="font-display text-3xl font-semibold">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCards({ items }: { items: readonly IconItem[] }) {
|
||||
return (
|
||||
<Container className="grid gap-4 py-12 md:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<Icon className="mb-8 size-6 text-primary" />
|
||||
<h3 className="font-display text-2xl font-semibold">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChefStory() {
|
||||
return (
|
||||
<Container className="grid gap-10 py-14 lg:grid-cols-2">
|
||||
<RestaurantPanel className="relative min-h-[520px] overflow-hidden bg-muted">
|
||||
<Image src="https://images.unsplash.com/photo-1577219491135-ce391730fb2c?auto=format&fit=crop&w=1200&q=82" alt="Kitchen team" fill className="object-cover" sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</RestaurantPanel>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-primary">Kitchen story</div>
|
||||
<h2 className="font-display text-4xl font-semibold leading-tight sm:text-5xl">Точная кухня и тихая уверенность сервиса</h2>
|
||||
<p className="mt-5 text-base leading-7 text-muted-foreground">
|
||||
Juniper Table строит меню вокруг короткого сезонного окна. Шеф-команда не обещает шоу: она держит продукт, соус, свет и темп вечера.
|
||||
</p>
|
||||
<div className="mt-7 grid gap-3">
|
||||
{["поставки под меню недели", "открытая кухня без театральности", "зал держит ритм, а не давит на гостя"].map((point) => (
|
||||
<div key={point} className="flex items-center gap-3 text-sm font-semibold">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{point}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupplierLedger() {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<SectionHeader eyebrow="Suppliers" title="Поставщики объясняют сезонность меню" text="Ресторанный сайт становится убедительнее, когда показывает, откуда берутся продукты и почему меню меняется." />
|
||||
<div className="overflow-hidden rounded-md border border-border bg-card">
|
||||
{suppliers.map((supplier) => (
|
||||
<div key={supplier.name} className="grid gap-3 border-b border-border p-5 last:border-b-0 md:grid-cols-[1fr_1fr_180px]">
|
||||
<div className="font-display text-2xl font-semibold">{supplier.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{supplier.product}</div>
|
||||
<div className="text-sm font-semibold text-primary md:text-right">{supplier.cadence}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function SplitStory({ image, eyebrow, title, text, points }: { image: string; eyebrow: string; title: string; text: string; points: readonly string[] }) {
|
||||
return (
|
||||
<Container className="grid gap-10 py-14 lg:grid-cols-2">
|
||||
<RestaurantPanel className="relative min-h-[500px] overflow-hidden bg-muted">
|
||||
<Image src={image} alt={title} fill className="object-cover" sizes="(min-width: 1024px) 50vw, 100vw" />
|
||||
</RestaurantPanel>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-primary">{eyebrow}</div>
|
||||
<h2 className="font-display text-4xl font-semibold leading-tight sm:text-5xl">{title}</h2>
|
||||
<p className="mt-5 text-base leading-7 text-muted-foreground">{text}</p>
|
||||
<div className="mt-7 grid gap-3">
|
||||
{points.map((point) => (
|
||||
<div key={point} className="flex items-center gap-3 text-sm font-semibold">
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
{point}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestimonialBand({ items = testimonials }: { items?: readonly TestimonialItem[] }) {
|
||||
return (
|
||||
<Container className="grid gap-4 py-12 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<blockquote key={item.name} className="rounded-md border border-border bg-card p-7">
|
||||
<div className="text-sm font-semibold text-primary">{item.rating}</div>
|
||||
<p className="mt-5 font-display text-3xl leading-tight">"{item.text}"</p>
|
||||
<footer className="mt-5 text-sm text-muted-foreground">{item.name}</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoColumns({ title, items }: { title: string; items: readonly InfoItem[] }) {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<h2 className="mb-6 font-display text-4xl font-semibold">{title}</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<RestaurantPanel key={item.title} className="p-6">
|
||||
<StarIcon className="mb-8 size-5 text-primary" />
|
||||
<h3 className="font-display text-2xl font-semibold">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.text}</p>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoursBoard() {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{serviceWindows.map((item) => (
|
||||
<RestaurantPanel key={item.label} className="p-6">
|
||||
<ClockIcon className="mb-8 size-6 text-primary" />
|
||||
<h3 className="font-display text-3xl font-semibold">{item.label}</h3>
|
||||
<div className="mt-3 font-display text-3xl font-semibold text-primary">{item.time}</div>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{item.note}</p>
|
||||
</RestaurantPanel>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactForm() {
|
||||
return (
|
||||
<Container className="grid gap-6 py-14 lg:grid-cols-2">
|
||||
<RestaurantPanel className="p-6">
|
||||
<ClockIcon className="mb-8 size-6 text-primary" />
|
||||
<h2 className="font-display text-4xl font-semibold">Связаться с хостом</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||
Контактная страница закрывает практику: как добраться, когда работает кухня, как подтвердить бронь и где указать ограничения.
|
||||
</p>
|
||||
</RestaurantPanel>
|
||||
<form className="grid gap-3 rounded-md border border-border bg-card p-5">
|
||||
<Input placeholder="Ваше имя" />
|
||||
<Input placeholder="Email или телефон" />
|
||||
<Textarea placeholder="Дата, гости, событие, аллергии или вопрос хосту" />
|
||||
<Button type="button" className="h-11 rounded-md">Отправить</Button>
|
||||
</form>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function CtaPanel({ title, text, href, label }: { title: string; text: string; href: string; label: string }) {
|
||||
return (
|
||||
<Container className="py-12">
|
||||
<div className="rounded-md bg-primary p-8 text-primary-foreground">
|
||||
<h2 className="font-display text-4xl font-semibold">{title}</h2>
|
||||
<p className="mt-4 max-w-[720px] text-sm leading-7 text-primary-foreground/72">{text}</p>
|
||||
<Button asChild variant="secondary" className="mt-6 rounded-md">
|
||||
<Link href={href}>{label}<ArrowRightIcon className="size-4" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
18
src/widgets/testimonial-band.tsx
Normal file
18
src/widgets/testimonial-band.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { testimonials } from "@/entities/site-content";
|
||||
import { Container } from "@/shared/ui/container";
|
||||
|
||||
type TestimonialItem = { name: string; text: string; rating: string };
|
||||
|
||||
export function TestimonialBand({ items = testimonials }: { items?: readonly TestimonialItem[] }) {
|
||||
return (
|
||||
<Container className="grid gap-4 py-12 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<blockquote key={item.name} className="rounded-md border border-border bg-card p-7">
|
||||
<div className="text-sm font-semibold text-primary">{item.rating}</div>
|
||||
<p className="mt-5 font-display text-3xl leading-tight">"{item.text}"</p>
|
||||
<footer className="mt-5 text-sm text-muted-foreground">{item.name}</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user