Initial release of Moniki paid services site and CMS (v1.0.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexey Novikov
2026-09-16 13:10:25 +03:00
co-authored by Cursor
commit 8abd8d06dd
263 changed files with 24973 additions and 0 deletions
@@ -0,0 +1,44 @@
import { BookingWizard } from "@/components/booking/BookingWizard";
import { getBranches, getDoctors } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "booking");
}
export default async function BookingPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ service?: string; doctor?: string; branch?: string }>;
}) {
const { locale } = await params;
const sp = await searchParams;
setRequestLocale(locale);
const t = await getTranslations("booking");
const [doctors, branches] = await Promise.all([getDoctors({ locale }), getBranches(locale)]);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-center text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mx-auto mt-2 max-w-xl text-center text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-10">
<BookingWizard
doctors={doctors}
branches={branches}
initialServiceId={sp.service}
initialDoctorId={sp.doctor}
initialBranchId={sp.branch}
/>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
import { YandexBranchMap } from "@/components/branches/YandexBranchMap";
import { Badge } from "@/components/ui/Badge";
import { Link } from "@/i18n/routing";
import { getBranchBySlug } from "@/lib/api";
import { hasCoordinates } from "@/lib/yandex-maps";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const branch = await getBranchBySlug(slug, locale);
if (!branch) return {};
return {
title: branch.name,
description: branch.address,
};
}
export default async function BranchDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("branches");
const branch = await getBranchBySlug(slug, locale);
if (!branch) notFound();
return (
<div className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<Link href="/branches" className="text-sm font-medium text-[var(--brand-700)]">
← {t("back")}
</Link>
<div className="mt-6 flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold text-[var(--text)]">{branch.name}</h1>
{branch.isMain ? <Badge variant="brand">{t("main")}</Badge> : null}
</div>
<p className="mt-3 text-lg text-[var(--text-muted)]">{branch.address}</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
{branch.metro ? (
<p className="text-sm text-[var(--text)]">
<span className="font-medium text-[var(--text-muted)]">{t("metro")}: </span>
{branch.metro}
</p>
) : null}
<p className="text-sm text-[var(--text)]">
<span className="font-medium text-[var(--text-muted)]">{t("phone")}: </span>
<a href={`tel:${branch.phone.replace(/[^\d+]/g, "")}`} className="font-semibold text-[var(--brand-700)]">
{branch.phone}
</a>
</p>
{branch.hours ? (
<p className="text-sm text-[var(--text)] sm:col-span-2">
<span className="font-medium text-[var(--text-muted)]">{t("hours")}: </span>
{branch.hours}
</p>
) : null}
</div>
{branch.howToGet ? (
<section className="mt-10">
<h2 className="text-xl font-semibold text-[var(--text)]">{t("howToGet")}</h2>
<p className="mt-3 whitespace-pre-line leading-relaxed text-[var(--text-muted)]">{branch.howToGet}</p>
</section>
) : null}
{typeof branch.lat === "number" &&
typeof branch.lng === "number" &&
hasCoordinates(branch.lat, branch.lng) ? (
<section className="mt-10">
<h2 className="text-xl font-semibold text-[var(--text)]">{t("map")}</h2>
<div className="mt-4">
<YandexBranchMap
lat={branch.lat}
lng={branch.lng}
title={branch.name}
mapLabel={t("map")}
navigatorLabel={t("openNavigator")}
coordinatesLabel={t("coordinates")}
/>
</div>
</section>
) : null}
<div className="mt-10">
<Link href={`/booking?branch=${branch.id}`}>
<span className="inline-flex h-12 items-center justify-center rounded-[10px] bg-[var(--brand-600)] px-6 text-base font-semibold text-white shadow-sm shadow-slate-900/10 hover:bg-[var(--brand-700)]">
{t("book")}
</span>
</Link>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { BranchCard } from "@/components/branches/BranchCard";
import { getBranches } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "branches");
}
export default async function BranchesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("branches");
const branches = await getBranches(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--text)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-8 grid gap-6 md:grid-cols-2">
{branches.map((branch) => (
<BranchCard
key={branch.id}
branch={branch}
metroLabel={t("metro")}
hoursLabel={t("hours")}
phoneLabel={t("phone")}
mainLabel={t("main")}
detailsLabel={t("details")}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { BookingActions } from "@/components/cabinet/BookingActions";
import { getMyBookings } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetBookingsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const bookings = (await getMyBookings()).filter((b) => b.status === "upcoming");
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("bookings")}</h2>
{bookings.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyBookings")}</p>
) : (
<div className="space-y-4">
{bookings.map((b) => (
<BookingActions key={b.id} booking={b} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,18 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetDocumentsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("documents")}</h2>
<p className="text-[var(--text-muted)]">{t("emptyDocuments")}</p>
</div>
);
}
@@ -0,0 +1,33 @@
import { Card } from "@/components/ui/Card";
import { getFavorites } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetFavoritesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const favorites = await getFavorites();
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("favorites")}</h2>
{favorites.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyFavorites")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{favorites.map((item, i) => (
<Card key={"id" in item ? item.id : i}>
<p className="font-semibold">
{"title" in item ? item.title : "name" in item ? item.name : "—"}
</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,29 @@
import { BookingActions } from "@/components/cabinet/BookingActions";
import { getMyBookings } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetHistoryPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const bookings = (await getMyBookings()).filter((b) => b.status !== "upcoming");
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("history")}</h2>
{bookings.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyBookings")}</p>
) : (
<div className="space-y-4">
{bookings.map((b) => (
<BookingActions key={b.id} booking={b} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { Card } from "@/components/ui/Card";
import { getMyBookings } from "@/lib/api";
import { formatPrice } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetInvoicesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const bookings = await getMyBookings();
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("invoices")}</h2>
{bookings.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyInvoices")}</p>
) : (
<div className="space-y-3">
{bookings.map((b) => (
<Card key={b.id} className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold">{b.serviceTitle}</p>
<p className="text-sm text-[var(--text-muted)]">{b.datetime}</p>
</div>
<div className="text-right">
<p className="font-bold text-[var(--brand-700)]">{formatPrice(b.price, locale)}</p>
<p className="text-xs text-[var(--neutral-500)]">{b.paid ? t("paid") : t("unpaid")}</p>
</div>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,37 @@
import { CabinetNav } from "@/components/cabinet/CabinetNav";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "cabinet");
}
export default async function CabinetLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<div className="mt-8 grid gap-8 md:grid-cols-[220px_1fr]">
<aside>
<CabinetNav />
</aside>
<div>{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { ProfileForm } from "@/components/cabinet/ProfileForm";
import { me } from "@/lib/api";
import { Link } from "@/i18n/routing";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetProfilePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const auth = await getTranslations("auth");
const user = await me();
if (!user) {
return (
<div className="rounded-[12px] border border-[var(--border)] bg-white p-6">
<p className="text-[var(--text-muted)]">{auth("loginTitle")}</p>
<Link href="/login" className="mt-3 inline-block font-semibold text-[var(--brand-700)]">
{auth("login")}
</Link>
</div>
);
}
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("profile")}</h2>
<ProfileForm user={user} />
</div>
);
}
@@ -0,0 +1,88 @@
import { DoctorPhoto } from "@/components/doctors/DoctorPhoto";
import { ReviewsInfinite } from "@/components/reviews/ReviewsInfinite";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import { DOCTOR_REVIEWS_PAGE_SIZE, getDoctorBySlug, getReviewsPage } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const doctor = await getDoctorBySlug(slug, locale);
if (!doctor) return {};
return {
title: doctor.name,
description: doctor.specialty,
};
}
export default async function DoctorDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("doctors");
const tr = await getTranslations("reviews");
const loadingLabel = (await getTranslations("common"))("loading");
const doctor = await getDoctorBySlug(slug, locale);
if (!doctor) notFound();
const [initial, prefetched] = await Promise.all([
getReviewsPage({ doctorId: doctor.id, page: 1, limit: DOCTOR_REVIEWS_PAGE_SIZE, locale }),
getReviewsPage({ doctorId: doctor.id, page: 2, limit: DOCTOR_REVIEWS_PAGE_SIZE, locale }),
]);
return (
<div className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<div className="grid gap-8 md:grid-cols-[280px_1fr]">
<DoctorPhoto
src={doctor.photo}
alt={doctor.name}
pending={doctor.photoPending}
pendingLabel={t("photoPending")}
className="rounded-[20px] aspect-[3/4]"
/>
<div>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{doctor.name}</h1>
<p className="mt-2 text-lg text-[var(--text-muted)]">{doctor.specialty}</p>
<div className="mt-4 flex flex-wrap gap-2">
<Badge variant="brand">
{doctor.experienceYears} {t("experience")}
</Badge>
<Badge variant="neutral">
★ {doctor.rating} · {doctor.reviewsCount} {t("reviews")}
</Badge>
</div>
<h2 className="mt-8 text-xl font-semibold">{t("bio")}</h2>
<p className="mt-3 leading-relaxed text-[var(--neutral-700)]">{doctor.bio}</p>
<div className="mt-8">
<Link href={`/booking?doctor=${doctor.id}`}>
<Button size="lg">{t("book")}</Button>
</Link>
</div>
</div>
</div>
<section className="mt-14">
<h2 className="text-2xl font-bold">{tr("title")}</h2>
{initial.items.length === 0 ? (
<p className="mt-4 text-[var(--text-muted)]">{tr("empty")}</p>
) : (
<ReviewsInfinite
initial={initial}
prefetched={initial.hasNextPage ? prefetched : null}
endpoint={`/api/reviews?limit=${DOCTOR_REVIEWS_PAGE_SIZE}&doctorId=${encodeURIComponent(doctor.id)}&locale=${encodeURIComponent(locale)}`}
className="mt-6 grid gap-4 md:grid-cols-2"
loadingLabel={loadingLabel}
/>
)}
</section>
</div>
);
}
@@ -0,0 +1,53 @@
import { DoctorsInfinite } from "@/components/doctors/DoctorsInfinite";
import { DOCTORS_PAGE_SIZE, getDoctorsPage } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "doctors");
}
export default async function DoctorsPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ q?: string }>;
}) {
const { locale } = await params;
const { q = "" } = await searchParams;
setRequestLocale(locale);
const t = await getTranslations("doctors");
const loadingLabel = (await getTranslations("common"))("loading");
const query = q.trim();
const [initial, prefetched] = await Promise.all([
getDoctorsPage({ page: 1, limit: DOCTORS_PAGE_SIZE, q: query, locale }),
getDoctorsPage({ page: 2, limit: DOCTORS_PAGE_SIZE, q: query, locale }),
]);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
<DoctorsInfinite
initial={initial}
prefetched={initial.hasNextPage ? prefetched : null}
initialQuery={query}
loadingLabel={loadingLabel}
labels={{
photoPending: t("photoPending"),
experience: t("experience"),
reviews: t("reviews"),
bio: t("bio"),
book: t("book"),
}}
/>
</div>
);
}
@@ -0,0 +1,46 @@
import { getGallery } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "gallery");
}
export default async function GalleryPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("gallery");
const items = await getGallery(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
{items.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-8 columns-1 gap-4 sm:columns-2 lg:columns-3">
{items.map((item) => (
<figure key={item.id} className="mb-4 break-inside-avoid overflow-hidden rounded-[16px]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.image} alt={item.title} className="mx-auto block w-full object-cover object-center" />
<figcaption className="bg-white px-3 py-2 text-sm text-[var(--neutral-600)]">
{item.title} · {item.category}
</figcaption>
</figure>
))}
</div>
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { AccessibilityProvider } from "@/components/a11y/AccessibilityProvider";
import { RouteFocus } from "@/components/a11y/RouteFocus";
import { SkipLink } from "@/components/a11y/SkipLink";
import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { ScrollToTop } from "@/components/layout/ScrollToTop";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { CookieBanner } from "@/components/legal/CookieBanner";
import { A11Y_BOOTSTRAP } from "@/lib/a11y";
import { THEME_BOOTSTRAP } from "@/lib/theme";
import { routing, type Locale } from "@/i18n/routing";
import { Manrope } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import "../globals.css";
const manrope = Manrope({
subsets: ["latin", "cyrillic"],
variable: "--font-manrope",
display: "swap",
});
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
return {
title: {
default: `${t("siteName")} — ${t("tagline")}`,
template: `%s · ${t("siteName")}`,
},
description: t("fullName"),
icons: {
icon: "/logo.png",
apple: "/logo.png",
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) notFound();
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale} className={manrope.variable} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: `${A11Y_BOOTSTRAP}${THEME_BOOTSTRAP}` }} />
</head>
<body className="flex min-h-screen flex-col antialiased">
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<AccessibilityProvider>
<SkipLink />
<RouteFocus />
<Header />
<main id="main-content" tabIndex={-1} className="flex-1 outline-none">
{children}
</main>
<Footer />
<CookieBanner />
<ScrollToTop />
</AccessibilityProvider>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { LoginForm } from "@/components/auth/LoginForm";
import { namedPageMetadata } from "@/lib/page-metadata";
import { setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "auth", "loginTitle");
}
export default async function LoginPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex max-w-6xl justify-center px-4 py-16 sm:px-6">
<LoginForm />
</div>
);
}
@@ -0,0 +1,50 @@
import { Link } from "@/i18n/routing";
import { getNewsBySlug } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const item = await getNewsBySlug(slug, locale);
if (!item) return {};
return {
title: item.title,
description: item.body.slice(0, 160),
};
}
export default async function NewsDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("news");
const item = await getNewsBySlug(slug, locale);
if (!item) notFound();
return (
<article className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<Link href="/news" className="text-sm font-medium text-[var(--brand-700)]">
← {t("back")}
</Link>
<p className="mt-6 text-sm text-[var(--neutral-500)]">{formatDate(item.publishedAt, locale)}</p>
<h1 className="mt-2 text-3xl font-bold text-[var(--neutral-900)]">{item.title}</h1>
{item.cover ? (
<div className="relative mt-8 aspect-[16/9] overflow-hidden rounded-[20px] bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.cover} alt="" className="absolute inset-0 h-full w-full max-w-none object-cover object-center" />
</div>
) : null}
<div className="mt-8 whitespace-pre-line text-lg leading-relaxed text-[var(--neutral-700)]">
{item.body}
</div>
</article>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import { getNews } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { formatDate } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "news");
}
export default async function NewsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("news");
const news = await getNews(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
{news.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{news.map((item) => (
<Link key={item.id} href={`/news/${item.slug}`}>
<Card className="h-full overflow-hidden p-0 transition hover:border-[var(--brand-300)]">
{item.cover ? (
<div className="relative aspect-[16/10] overflow-hidden bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.cover} alt="" className="absolute inset-0 h-full w-full max-w-none object-cover object-center" />
</div>
) : null}
<div className="p-5">
<p className="text-xs text-[var(--neutral-500)]">{formatDate(item.publishedAt, locale)}</p>
<h2 className="mt-2 text-lg font-semibold">{item.title}</h2>
<p className="mt-2 line-clamp-3 text-sm text-[var(--text-muted)]">{item.excerpt}</p>
<p className="mt-3 text-sm font-medium text-[var(--brand-700)]">{t("readMore")}</p>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Link } from "@/i18n/routing";
export default function NotFound() {
return (
<div className="mx-auto flex max-w-lg flex-col items-center px-4 py-24 text-center">
<p className="text-5xl font-bold text-[var(--brand-600)]">404</p>
<p className="mt-4 text-lg text-[var(--neutral-700)]">Страница не найдена</p>
<Link
href="/"
className="mt-6 rounded-[10px] bg-[var(--brand-600)] px-5 py-2.5 text-sm font-semibold text-white"
>
МОНИКИ
</Link>
</div>
);
}
@@ -0,0 +1,362 @@
import {
ComingSoon,
InfoList,
OrganizationLayout,
SourceNote,
} from "@/components/organization/OrganizationLayout";
import { CmsPageBody } from "@/components/pages/CmsPageBody";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import {
faqItems,
instituteFacts,
instituteTimeline,
isOrganizationSlug,
leadership,
licenses,
officialLinks,
organizationPages,
receptionSchedule,
requisites,
supervisors,
type OrganizationSlug,
} from "@/lib/organization";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getSitePage } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export function generateStaticParams() {
return organizationPages.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const page = await getSitePage("organization", slug, locale);
if (page) {
return { title: page.heading || page.title, description: page.subtitle || undefined };
}
if (!isOrganizationSlug(slug)) return {};
return namedPageMetadata(locale, "organization", `${slug}.title`);
}
export default async function OrganizationPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const cmsPage = await getSitePage("organization", slug, locale);
if (cmsPage) {
return (
<OrganizationLayout slug={slug}>
<CmsPageBody page={cmsPage} />
</OrganizationLayout>
);
}
if (!isOrganizationSlug(slug)) notFound();
return (
<OrganizationLayout slug={slug}>
<OrganizationBody slug={slug} />
</OrganizationLayout>
);
}
async function OrganizationBody({ slug }: { slug: OrganizationSlug }) {
const t = await getTranslations("organization");
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t(`${slug}.title`)}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t(`${slug}.subtitle`)}</p>
<div className="mt-8">
{slug === "institute" ? <InstituteContent /> : null}
{slug === "leaders" ? <LeadersContent /> : null}
{slug === "license" ? <LicenseContent /> : null}
{slug === "requisites" ? <RequisitesContent /> : null}
{slug === "supervision" ? <SupervisionContent /> : null}
{slug === "reception" ? <ReceptionContent /> : null}
{slug === "faq" ? <FaqContent /> : null}
</div>
</>
);
}
async function InstituteContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-8">
<InfoList items={instituteFacts} />
<div>
<h2 className="text-xl font-semibold text-[var(--neutral-900)]">{t("history")}</h2>
<ol className="mt-4 space-y-3">
{instituteTimeline.map((item) => (
<li key={item.year} className="flex gap-4">
<span className="w-16 shrink-0 font-semibold text-[var(--brand-700)]">{item.year}</span>
<span className="text-[var(--neutral-700)]">{item.text}</span>
</li>
))}
</ol>
</div>
<div className="space-y-4 text-[var(--neutral-700)] leading-relaxed">
<p>
Первым предшественником института был противочумный карантин, созданный в 1772 году на 3-й
Мещанской улице и преобразованный по указу Екатерины II в больницу. С 1835 года она называлась
Старо-Екатерининской и служила клинической базой медицинского факультета Московского
университета.
</p>
<p>
Сегодня МОНИКИ — ведущий клинический, научный и образовательный комплекс Московской области:
стационар на 871 койку, консультации по 40 профилям, около 200 тысяч консультаций и около
10 тысяч операций в год. Здесь сочетаются клиническая помощь, научные исследования и подготовка
кадров, включая факультет усовершенствования врачей.
</p>
</div>
<SourceNote href={officialLinks.history} label={t("source")} />
</div>
);
}
async function LeadersContent() {
const t = await getTranslations("organization");
return (
<div className="min-w-0">
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{leadership.map((person) => (
<li
key={person.slug}
className="min-w-0 overflow-hidden rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)]"
>
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={person.photo}
alt={person.name}
width={720}
height={960}
className="absolute inset-0 h-full w-full max-w-none object-cover object-center"
/>
</div>
<div className="p-4">
<p className="break-words text-sm text-[var(--neutral-500)]">{person.position}</p>
<h2 className="mt-1 break-words text-base font-semibold text-[var(--neutral-900)]">
{person.name}
</h2>
{person.degree ? (
<p className="mt-1 break-words text-sm text-[var(--neutral-700)]">{person.degree}</p>
) : null}
</div>
</li>
))}
</ul>
<SourceNote href={officialLinks.leaders} label={t("source")} />
</div>
);
}
async function LicenseContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-6">
{licenses.map((item) => (
<Card key={item.title}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.title}</h2>
<p className="text-sm font-medium text-[var(--brand-700)]">{item.status}</p>
</div>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
{item.number ? (
<div>
<dt className="text-[var(--neutral-500)]">{t("licenseNumber")}</dt>
<dd className="mt-1 font-medium text-[var(--neutral-800)]">{item.number}</dd>
</div>
) : null}
{item.issuedAt ? (
<div>
<dt className="text-[var(--neutral-500)]">{t("issuedAt")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.issuedAt}</dd>
</div>
) : null}
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("authority")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.authority}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("activity")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.activity}</dd>
</div>
</dl>
<p className="mt-4 text-sm text-[var(--text-muted)]">{item.extract}</p>
<a href={item.href} target="_blank" rel="noopener noreferrer" className="mt-4 inline-flex">
<Button variant="outline" size="sm">
{t("openDocument")}
</Button>
</a>
</Card>
))}
<ComingSoon text={t("licenseSoon")} />
<SourceNote href={officialLinks.license} label={t("source")} />
</div>
);
}
async function RequisitesContent() {
const t = await getTranslations("organization");
return (
<div>
<InfoList items={requisites} />
<a
href={officialLinks.requisitesPdf}
target="_blank"
rel="noopener noreferrer"
className="mt-6 inline-flex"
>
<Button variant="outline">{t("downloadPdf")}</Button>
</a>
<SourceNote href={officialLinks.officialInfo} label={t("source")} />
</div>
);
}
async function SupervisionContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-6">
{supervisors.map((item) => (
<Card key={item.title}>
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.title}</h2>
<p className="mt-2 text-sm text-[var(--text-muted)]">{item.role}</p>
<dl className="mt-4 space-y-2 text-sm">
<div>
<dt className="text-[var(--neutral-500)]">{t("phone")}</dt>
<dd className="text-[var(--neutral-800)]">{item.phone}</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
<dd className="text-[var(--neutral-800)]">{item.address}</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("email")}</dt>
<dd>
<a href={`mailto:${item.email}`} className="text-[var(--brand-700)] hover:underline">
{item.email}
</a>
</dd>
</div>
</dl>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block text-sm font-medium text-[var(--brand-700)] hover:underline"
>
{t("officialSite")}
</a>
</Card>
))}
<SourceNote href={officialLinks.officialInfo} label={t("source")} />
</div>
);
}
async function ReceptionContent() {
const t = await getTranslations("organization");
return (
<div className="min-w-0">
<ul className="space-y-3 md:hidden">
{receptionSchedule.map((row) => (
<li
key={row.name}
className="rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-4"
>
<p className="whitespace-pre-line font-semibold text-[var(--neutral-900)]">{row.position}</p>
<p className="mt-1 text-[var(--neutral-800)]">{row.name}</p>
<dl className="mt-3 space-y-2 text-sm">
<div>
<dt className="text-[var(--neutral-500)]">{t("reception.hours")}</dt>
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">
{row.hours || "—"}
</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">{row.address}</dd>
</div>
</dl>
</li>
))}
</ul>
<div className="hidden overflow-x-auto rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] md:block">
<table className="w-full min-w-[36rem] border-collapse text-left text-sm">
<thead className="bg-[var(--neutral-50)]">
<tr>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("reception.position")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("reception.hours")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("address")}
</th>
</tr>
</thead>
<tbody>
{receptionSchedule.map((row) => (
<tr key={row.name} className="border-t border-[var(--border)] align-top">
<td className="px-4 py-3 text-[var(--neutral-800)]">
<p className="whitespace-pre-line font-medium">{row.position}</p>
<p className="mt-1 whitespace-pre-line">{row.name}</p>
</td>
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
{row.hours || "—"}
</td>
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
{row.address}
</td>
</tr>
))}
</tbody>
</table>
</div>
<SourceNote href={officialLinks.contacts} label={t("source")} />
</div>
);
}
async function FaqContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-4">
{faqItems.map((item) => (
<Card key={item.question}>
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.question}</h2>
<p className="mt-2 leading-relaxed text-[var(--neutral-700)]">{item.answer}</p>
</Card>
))}
<ComingSoon text={t("faq.comingSoon")} />
<p className="text-sm text-[var(--text-muted)]">
{t("faq.bookHint")}{" "}
<Link href="/booking" className="font-medium text-[var(--brand-700)] hover:underline">
{t("faq.bookLink")}
</Link>
</p>
<SourceNote href={officialLinks.site} label={t("source")} />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { BranchesPreview } from "@/components/home/BranchesPreview";
import { DoctorsPreview } from "@/components/home/DoctorsPreview";
import { Hero } from "@/components/home/Hero";
import { HomeCta } from "@/components/home/HomeCta";
import { NewsPreview } from "@/components/home/NewsPreview";
import { PromotionsPreview } from "@/components/home/PromotionsPreview";
import { Reviews } from "@/components/home/Reviews";
import { Specialties } from "@/components/home/Specialties";
import { getBanners, getBranches, getDoctors, getNews, getPromotions, getReviews } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "hero");
}
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const [doctors, reviews, branches, news, banners, promotions] = await Promise.all([
getDoctors({ locale }).catch(() => []),
getReviews(undefined, 12, locale).catch(() => []),
getBranches(locale).catch(() => []),
getNews(locale).catch(() => []),
getBanners(locale).catch(() => []),
getPromotions(locale).catch(() => []),
]);
return (
<>
<Hero banners={banners} />
<Specialties />
<PromotionsPreview promotions={promotions} />
<DoctorsPreview doctors={doctors} />
<Reviews reviews={reviews} />
<HomeCta />
<BranchesPreview branches={branches} />
<NewsPreview news={news} />
</>
);
}
@@ -0,0 +1,41 @@
import { PromotionCard } from "@/components/promotions/PromotionCard";
import { getPromotions } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "promotions");
}
export default async function PromotionsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("promotions");
const promotions = await getPromotions(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 max-w-2xl text-[var(--text-muted)]">{t("subtitle")}</p>
{promotions.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-8 grid gap-6 md:grid-cols-2">
{promotions.map((promo) => (
<PromotionCard key={promo.id} promo={promo} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,27 @@
import { RegisterForm } from "@/components/auth/RegisterForm";
import { namedPageMetadata } from "@/lib/page-metadata";
import { setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "auth", "registerTitle");
}
export default async function RegisterPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex max-w-6xl justify-center px-4 py-16 sm:px-6">
<RegisterForm />
</div>
);
}
@@ -0,0 +1,47 @@
import { ReviewsInfinite } from "@/components/reviews/ReviewsInfinite";
import { getReviewsPage, REVIEWS_PAGE_SIZE } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "reviews");
}
export default async function ReviewsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("reviews");
const loadingLabel = (await getTranslations("common"))("loading");
const [initial, prefetched] = await Promise.all([
getReviewsPage({ page: 1, limit: REVIEWS_PAGE_SIZE, locale }),
getReviewsPage({ page: 2, limit: REVIEWS_PAGE_SIZE, locale }),
]);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
{initial.items.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<ReviewsInfinite
initial={initial}
prefetched={initial.hasNextPage ? prefetched : null}
endpoint={`/api/reviews?limit=${REVIEWS_PAGE_SIZE}&locale=${encodeURIComponent(locale)}`}
className="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3"
loadingLabel={loadingLabel}
/>
)}
</div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Link, usePathname, useRouter } from "@/i18n/routing";
import type { Service } from "@/lib/demo-data";
import { demoServices } from "@/lib/demo-data";
import { matchesQuery } from "@/lib/search";
import { SERVICE_CATEGORY_ORDER } from "@/lib/service-categories";
import { formatPrice, stripPriceListIndex, cn } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type Grouped = {
category: string;
groups: { title: string; items: Service[] }[];
count: number;
};
function EllipsisText({ text, className }: { text: string; className?: string }) {
return (
<span className={cn("group/ell relative block min-w-0", className)}>
<span className="block truncate">{text}</span>
<span
aria-hidden="true"
className="pointer-events-none absolute left-0 top-full z-30 mt-1 hidden max-w-[min(28rem,70vw)] whitespace-normal rounded-lg bg-[var(--neutral-900)] px-3 py-2 text-left text-xs font-normal leading-snug text-white shadow-lg group-hover/ell:block"
>
{text}
</span>
</span>
);
}
export function ServicesCatalog({
initialCategory,
initialQ,
}: {
initialCategory?: string;
initialQ?: string;
}) {
const t = useTranslations("services");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState(initialQ || "");
const [category, setCategory] = useState(initialCategory || "");
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
useEffect(() => {
let cancelled = false;
fetch(`/api/catalog?locale=${encodeURIComponent(locale)}`)
.then((res) => res.json())
.then((data: { services?: Service[] }) => {
if (cancelled) return;
setServices(Array.isArray(data.services) && data.services.length ? data.services : demoServices);
})
.catch(() => {
if (!cancelled) setServices(demoServices);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [locale]);
function syncUrl(nextQ: string, nextCategory: string) {
const params = new URLSearchParams();
if (nextCategory) params.set("category", nextCategory);
if (nextQ) params.set("q", nextQ);
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}
const filtered = useMemo(() => {
return services.filter((s) => {
if (category && s.category !== category) return false;
if (!q.trim()) return true;
return matchesQuery(
[s.title, s.groupTitle, s.code, s.nmuCode, s.description].filter(Boolean).join(" "),
q,
);
});
}, [services, category, q]);
const grouped = useMemo<Grouped[]>(() => {
const byCategory = new Map<string, Map<string, Service[]>>();
for (const s of filtered) {
const cat = s.category || "procedure";
const group = stripPriceListIndex(s.groupTitle || t(`categories.${cat}` as "categories.consultation") || cat);
if (!byCategory.has(cat)) byCategory.set(cat, new Map());
const groups = byCategory.get(cat)!;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(s);
}
const order = [...SERVICE_CATEGORY_ORDER];
const extra = [...byCategory.keys()].filter((k) => !order.includes(k as (typeof order)[number]));
return [...order, ...extra]
.filter((cat) => byCategory.has(cat))
.map((cat) => {
const groups = [...byCategory.get(cat)!.entries()].map(([title, items]) => ({
title,
items,
}));
return {
category: cat,
groups,
count: groups.reduce((n, g) => n + g.items.length, 0),
};
});
}, [filtered, t]);
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of services) {
counts[s.category] = (counts[s.category] || 0) + 1;
}
return counts;
}, [services]);
return (
<div className="mt-8 grid gap-8 lg:grid-cols-[260px_1fr]">
<aside className="h-fit rounded-2xl border border-[var(--border)] bg-white p-4 lg:sticky lg:top-24">
<p className="text-sm font-semibold text-[var(--neutral-800)]">{t("groups")}</p>
<div className="mt-3 flex flex-col gap-1">
<button
type="button"
onClick={() => {
setCategory("");
syncUrl(q, "");
}}
className={`rounded-xl px-3 py-2 text-left text-sm ${
!category
? "bg-[var(--brand-600)] font-medium text-white"
: "text-[var(--neutral-700)] hover:bg-[var(--neutral-50)]"
}`}
>
{t("allCategories")}
<span className="ml-2 opacity-70">{services.length}</span>
</button>
{SERVICE_CATEGORY_ORDER.filter((id) => categoryCounts[id]).map((id) => (
<button
key={id}
type="button"
onClick={() => {
setCategory(id);
syncUrl(q, id);
}}
className={`rounded-xl px-3 py-2 text-left text-sm ${
category === id
? "bg-[var(--brand-600)] font-medium text-white"
: "text-[var(--neutral-700)] hover:bg-[var(--neutral-50)]"
}`}
>
{t(`categories.${id}` as "categories.consultation")}
<span className="ml-2 opacity-70">{categoryCounts[id]}</span>
</button>
))}
</div>
</aside>
<div>
<Input
label={t("search")}
value={q}
placeholder={t("searchPlaceholder")}
autoComplete="off"
onChange={(e) => {
const value = e.target.value;
setQ(value);
syncUrl(value, category);
}}
/>
<p className="mt-2 text-sm text-[var(--text-muted)]">
{loading ? t("loading") : t("found", { count: filtered.length })}
</p>
{filtered.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-6 space-y-10">
{grouped.map((section) => (
<section key={section.category} id={`cat-${section.category}`}>
<h2 className="text-xl font-bold text-[var(--neutral-900)]">
{t(`categories.${section.category}` as "categories.consultation") || section.category}
<span className="ml-2 text-sm font-normal text-[var(--neutral-500)]">
{section.count}
</span>
</h2>
<div className="mt-4 space-y-3">
{section.groups.map((group) => {
const searching = Boolean(q.trim());
const isOpen = searching || Boolean(openGroups[group.title]);
return (
<details
key={group.title}
open={isOpen}
onToggle={(e) => {
if (searching) return;
setOpenGroups((prev) => ({
...prev,
[group.title]: (e.target as HTMLDetailsElement).open,
}));
}}
className="rounded-2xl border border-[var(--border)] bg-white"
>
<summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-semibold text-[var(--brand-700)] marker:content-none">
<EllipsisText text={group.title} className="flex-1" />
<span className="shrink-0 font-normal text-[var(--neutral-500)]">{group.items.length}</span>
</summary>
<div className="border-t border-[var(--border)]">
{isOpen
? group.items.map((service, i) => (
<div
key={service.id}
className={`grid grid-cols-1 items-center gap-x-4 gap-y-2 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] ${
i ? "border-t border-[var(--border)]" : ""
}`}
>
<div className="min-w-0">
<EllipsisText
text={stripPriceListIndex(service.title)}
className="font-medium text-[var(--neutral-900)]"
/>
<p className="mt-0.5 truncate text-xs text-[var(--neutral-500)]">
{service.code ? `${t("code")} ${service.code}` : null}
{service.nmuCode ? ` · НМУ ${service.nmuCode}` : null}
</p>
</div>
<div className="flex shrink-0 flex-nowrap items-center gap-2">
<p className="w-[5.75rem] shrink-0 text-right text-lg font-bold tabular-nums text-[var(--brand-700)]">
{formatPrice(service.price, locale)}
</p>
<Link href={`/services/${service.slug}`} className="shrink-0">
<Button variant="outline" size="sm">
{t("details")}
</Button>
</Link>
<Link href={`/booking?service=${service.id}`} className="shrink-0">
<Button size="sm">{t("book")}</Button>
</Link>
</div>
</div>
))
: null}
</div>
</details>
);
})}
</div>
</section>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import { getServiceBySlug } from "@/lib/api";
import { formatPrice, stripPriceListIndex } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const service = await getServiceBySlug(slug, locale);
if (!service) return {};
return {
title: stripPriceListIndex(service.title),
description: service.description,
};
}
export default async function ServiceDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("services");
const service = await getServiceBySlug(slug, locale);
if (!service) notFound();
return (
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<Badge variant="brand">
{t(`categories.${service.category}` as "categories.consultation")}
</Badge>
{service.groupTitle ? (
<p className="mt-3 text-sm font-medium text-[var(--brand-700)]">
{stripPriceListIndex(service.groupTitle)}
</p>
) : null}
<h1 className="mt-2 text-3xl font-bold text-[var(--neutral-900)]">
{stripPriceListIndex(service.title)}
</h1>
<p className="mt-4 text-lg leading-relaxed text-[var(--text-muted)]">{service.description}</p>
<div className="mt-6 flex flex-wrap items-center gap-4">
<p className="text-2xl font-bold text-[var(--brand-700)]">{formatPrice(service.price, locale)}</p>
{service.durationMin ? (
<p className="text-sm text-[var(--neutral-500)]">
{t("duration")}: {service.durationMin} {t("minutes")}
</p>
) : null}
{service.code ? (
<p className="text-sm text-[var(--neutral-500)]">
{t("code")}: {service.code}
</p>
) : null}
</div>
<div className="mt-8">
<Link href={`/booking?service=${service.id}`}>
<Button size="lg">{t("book")}</Button>
</Link>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { ServicesCatalog } from "./ServicesCatalog";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "services");
}
export default async function ServicesPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ category?: string; q?: string }>;
}) {
const { locale } = await params;
const sp = await searchParams;
setRequestLocale(locale);
const t = await getTranslations("services");
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
<ServicesCatalog initialCategory={sp.category} initialQ={sp.q} />
</div>
);
}
@@ -0,0 +1,187 @@
import { LegalLayout } from "@/components/legal/LegalLayout";
import { CmsPageBody } from "@/components/pages/CmsPageBody";
import { PublicDocumentsList } from "@/components/pages/PublicDocumentsList";
import { InfoList } from "@/components/organization/OrganizationLayout";
import {
isLegalSlug,
legalPages,
pdOperatorFields,
rknOperatorUrl,
type LegalSlug,
} from "@/lib/legal";
import { discountNotes, discountRows } from "@/lib/discounts";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getSitePage } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export function generateStaticParams() {
return legalPages.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const page = await getSitePage("useful", slug, locale);
if (page) {
return { title: page.heading || page.title, description: page.subtitle || undefined };
}
if (!isLegalSlug(slug)) return {};
const navKey = legalPages.find((item) => item.slug === slug)?.navKey;
if (!navKey) return {};
return namedPageMetadata(locale, "legal", `${navKey}.title`);
}
export default async function UsefulPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const cmsPage = await getSitePage("useful", slug, locale);
if (cmsPage) {
return (
<LegalLayout slug={slug}>
<CmsPageBody page={cmsPage} />
{slug === "documents" ? (
<div className="mt-8">
<PublicDocumentsList locale={locale} />
</div>
) : null}
</LegalLayout>
);
}
if (!isLegalSlug(slug)) notFound();
return (
<LegalLayout slug={slug}>
<LegalBody slug={slug} locale={locale} />
</LegalLayout>
);
}
async function LegalBody({ slug, locale }: { slug: LegalSlug; locale: string }) {
if (slug === "documents") return <DocumentsContent locale={locale} />;
if (slug === "discounts") return <DiscountsContent />;
if (slug === "cookies") return <CookiesContent />;
if (slug === "privacy") return <PrivacyContent />;
return <PdOperatorContent />;
}
async function DocumentsContent({ locale }: { locale: string }) {
const t = await getTranslations("legal.documentsPage");
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<p className="mt-8 leading-relaxed text-[var(--neutral-700)]">{t("intro")}</p>
<div className="mt-8">
<PublicDocumentsList locale={locale} />
</div>
</>
);
}
async function DiscountsContent() {
const t = await getTranslations("legal.discounts");
const page = {
heading: t("heading"),
subtitle: t("subtitle"),
tableCaption: t("caption"),
tableRows: discountRows.map((row) => ({
category: row.category,
condition: row.condition || undefined,
discount: row.discount,
documents: row.documents,
})),
sourceUrl: "https://www.monikiweb.ru/skidki",
ctaHint: t("phoneHint"),
ctaLabel: t("phone"),
ctaHref: "tel:+74956810303",
content: {
root: {
type: "root",
children: discountNotes.map((paragraph) => ({
type: "paragraph",
version: 1,
children: [{ type: "text", text: paragraph, version: 1 }],
})),
},
},
facts: [],
timeline: [],
people: [],
cards: [],
faq: [],
slug: "discounts",
href: "/useful/discounts",
title: t("title"),
section: "useful" as const,
};
return <CmsPageBody page={page} />;
}
async function CookiesContent() {
const t = await getTranslations("legal.cookies");
const paragraphs = t.raw("paragraphs") as string[];
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-8 space-y-4 text-lg leading-relaxed text-[var(--neutral-700)]">
{paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</>
);
}
async function PrivacyContent() {
const t = await getTranslations("legal.privacy");
const paragraphs = t.raw("paragraphs") as string[];
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-8 space-y-4 text-lg leading-relaxed text-[var(--neutral-700)]">
{paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</>
);
}
async function PdOperatorContent() {
const t = await getTranslations("legal.pdOperator");
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<p className="mt-8 leading-relaxed text-[var(--neutral-700)]">{t("intro")}</p>
<p className="mt-4">
<a
href={rknOperatorUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-[var(--brand-700)] hover:underline"
>
{t("rknLink")}
</a>
</p>
<div className="mt-8">
<InfoList items={pdOperatorFields} />
</div>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { AUTH_COOKIE, CMS_URL } from "@/lib/api";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.json();
try {
const res = await fetch(`${CMS_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(data, { status: res.status });
}
const response = NextResponse.json({ user: data.user });
response.cookies.set(AUTH_COOKIE, data.token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 7,
});
return response;
} catch {
// Demo login fallback when CMS is down
if (body.email && body.password) {
const response = NextResponse.json({
user: { id: "demo", email: body.email, name: "Пациент" },
});
response.cookies.set(AUTH_COOKIE, "demo-token", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
}
return NextResponse.json({ message: "Login failed" }, { status: 500 });
}
}
@@ -0,0 +1,8 @@
import { AUTH_COOKIE } from "@/lib/api";
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(AUTH_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
return response;
}
+54
View File
@@ -0,0 +1,54 @@
import { AUTH_COOKIE, CMS_URL } from "@/lib/api";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function GET() {
const store = await cookies();
const token = store.get(AUTH_COOKIE)?.value;
if (!token) return NextResponse.json({ user: null }, { status: 401 });
if (token === "demo-token") {
return NextResponse.json({
user: { id: "demo", email: "patient@example.com", name: "Пациент", phone: "+7" },
});
}
try {
const res = await fetch(`${CMS_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch {
return NextResponse.json({
user: { id: "demo", email: "patient@example.com", name: "Пациент" },
});
}
}
export async function PATCH(request: Request) {
const store = await cookies();
const token = store.get(AUTH_COOKIE)?.value;
if (!token) return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
const body = await request.json();
if (token === "demo-token") {
return NextResponse.json({ user: { id: "demo", ...body } });
}
try {
const res = await fetch(`${CMS_URL}/api/users/me`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch {
return NextResponse.json({ user: { id: "demo", ...body } });
}
}
@@ -0,0 +1,47 @@
import { AUTH_COOKIE, CMS_URL } from "@/lib/api";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.json();
try {
const res = await fetch(`${CMS_URL}/api/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(data, { status: res.status });
}
// Auto-login after register
const loginRes = await fetch(`${CMS_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: body.email, password: body.password }),
});
const loginData = await loginRes.json().catch(() => ({}));
const token = loginData.token || data.token || "demo-token";
const response = NextResponse.json({ user: data.doc || data.user || { email: body.email } });
response.cookies.set(AUTH_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 7,
});
return response;
} catch {
const response = NextResponse.json({
user: { id: "demo", email: body.email, name: body.name },
});
response.cookies.set(AUTH_COOKIE, "demo-token", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
}
}
@@ -0,0 +1,19 @@
import { cancelBooking, rescheduleBooking } from "@/lib/api";
import { NextResponse } from "next/server";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const { id } = await params;
const body = await request.json();
try {
if (body.status === "cancelled") {
await cancelBooking(id);
} else {
await rescheduleBooking(id, body);
}
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: true });
}
}
+12
View File
@@ -0,0 +1,12 @@
import { createBooking } from "@/lib/api";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.json();
try {
const booking = await createBooking(body);
return NextResponse.json(booking);
} catch {
return NextResponse.json({ id: `demo-${Date.now()}` });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { stripPriceListIndex } from "@/lib/utils";
const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL || "http://localhost:3001";
const LOCALES = ["ru", "en", "be", "tg", "ky", "zh"] as const;
type CatalogService = {
title?: string;
groupTitle?: string;
[key: string]: unknown;
};
export async function GET(request: Request) {
const localeParam = new URL(request.url).searchParams.get("locale") || "ru";
const locale = LOCALES.includes(localeParam as (typeof LOCALES)[number]) ? localeParam : "ru";
const res = await fetch(
`${CMS_URL}/api/custom/catalog?locale=${encodeURIComponent(locale)}&fallbackLocale=ru`,
{ cache: "no-store" },
);
if (!res.ok) {
return NextResponse.json({ services: [], total: 0 }, { status: 502 });
}
const data = (await res.json()) as { services?: CatalogService[]; total?: number };
const services = (data.services || []).map((service) => ({
...service,
title: stripPriceListIndex(String(service.title || "")),
groupTitle: service.groupTitle ? stripPriceListIndex(String(service.groupTitle)) : service.groupTitle,
}));
return NextResponse.json({ services, total: services.length });
}
+13
View File
@@ -0,0 +1,13 @@
import { DOCTORS_PAGE_SIZE, getDoctorsPage } from "@/lib/api";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get("page") || 1);
const limit = Number(searchParams.get("limit") || DOCTORS_PAGE_SIZE);
const specialty = searchParams.get("specialty") || undefined;
const q = searchParams.get("q") || undefined;
const locale = searchParams.get("locale") || "ru";
const data = await getDoctorsPage({ page, limit, specialty, q, locale });
return NextResponse.json(data);
}
+33
View File
@@ -0,0 +1,33 @@
import { addFavorite, getFavorites, removeFavorite } from "@/lib/api";
import { NextResponse } from "next/server";
export async function GET() {
try {
const items = await getFavorites();
return NextResponse.json(items);
} catch {
return NextResponse.json([]);
}
}
export async function POST(request: Request) {
const body = await request.json();
try {
await addFavorite(body);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: true });
}
}
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ message: "id required" }, { status: 400 });
try {
await removeFavorite(id);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: true });
}
}
+12
View File
@@ -0,0 +1,12 @@
import { createPayment } from "@/lib/api";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.json();
try {
const payment = await createPayment(body.bookingId);
return NextResponse.json(payment);
} catch {
return NextResponse.json({ paymentUrl: `#sbp-demo-${body.bookingId}` });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { createReview, getReviewsPage, REVIEWS_PAGE_SIZE } from "@/lib/api";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get("page") || 1);
const limit = Number(searchParams.get("limit") || REVIEWS_PAGE_SIZE);
const doctorId = searchParams.get("doctorId") || undefined;
const locale = searchParams.get("locale") || "ru";
const data = await getReviewsPage({ page, limit, doctorId, locale });
return NextResponse.json(data);
}
export async function POST(request: Request) {
const body = await request.json();
try {
const review = await createReview(body);
return NextResponse.json(review);
} catch {
return NextResponse.json({
id: `r-${Date.now()}`,
author: "Вы",
rating: body.rating,
text: body.text,
date: new Date().toISOString().slice(0, 10),
});
}
}
+29
View File
@@ -0,0 +1,29 @@
import { getDoctorsPage, getServices } from "@/lib/api";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const q = params.get("q")?.trim() || "";
const locale = params.get("locale") || "ru";
if (q.length < 2) {
return NextResponse.json({ doctors: [], services: [] });
}
const [doctorsPage, services] = await Promise.all([
getDoctorsPage({ page: 1, limit: 5, q, locale }),
getServices({ q, locale }),
]);
return NextResponse.json({
doctors: doctorsPage.items.slice(0, 5).map((doctor) => ({
slug: doctor.slug,
name: doctor.name,
specialty: doctor.specialty,
})),
services: services.slice(0, 5).map((service) => ({
slug: service.slug,
title: service.title,
price: service.price,
})),
});
}
+19
View File
@@ -0,0 +1,19 @@
import { generateDemoSlots } from "@/lib/demo-data";
import { getSlots } from "@/lib/api";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const date = searchParams.get("date") || new Date().toISOString().slice(0, 10);
try {
const slots = await getSlots({
date,
doctorId: searchParams.get("doctorId") || undefined,
serviceId: searchParams.get("serviceId") || undefined,
branchId: searchParams.get("branchId") || undefined,
});
return NextResponse.json(slots);
} catch {
return NextResponse.json(generateDemoSlots(date));
}
}
+320
View File
@@ -0,0 +1,320 @@
@import "tailwindcss";
:root {
--brand-50: #f4f6fb;
--brand-100: #e8eef7;
--brand-200: #cbd7ec;
--brand-300: #a1b5d9;
--brand-400: #6989bf;
--brand-500: #44659c;
--brand-600: #334b74;
--brand-700: #263b5e;
--brand-800: #1d2d49;
--brand-900: #131f35;
--neutral-25: #fcfcfd;
--neutral-50: #f8fafc;
--neutral-100: #f1f5f9;
--neutral-200: #e2e8f0;
--neutral-300: #cbd5e1;
--neutral-400: #94a3b8;
--neutral-500: #64748b;
--neutral-600: #475569;
--neutral-700: #334155;
--neutral-800: #1e293b;
--neutral-900: #0f172a;
--surface: #ffffff;
--surface-muted: var(--neutral-50);
--text: var(--neutral-900);
--text-muted: var(--neutral-600);
--border: var(--neutral-200);
--ring: var(--brand-500);
--success: #059669;
--warning: #d97706;
--danger: #dc2626;
--font-ui: var(--font-manrope), system-ui, sans-serif;
--radius: 12px;
--header-h: 72px;
}
html {
scroll-behavior: smooth;
}
html[data-motion="reduce"] {
scroll-behavior: auto;
}
body {
font-family: var(--font-ui);
color: var(--text);
background: var(--surface-muted);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
@layer base {
img {
max-width: 100%;
height: auto;
}
}
/* Subtle motions */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes soft-pulse {
0%,
100% {
opacity: 0.55;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.04);
}
}
@keyframes float-y {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
.animate-fade-up {
animation: fade-up 0.7s ease-out both;
}
.animate-fade-up-delay {
animation: fade-up 0.8s ease-out 0.15s both;
}
.animate-soft-pulse {
animation: soft-pulse 6s ease-in-out infinite;
}
.animate-float {
animation: float-y 5s ease-in-out infinite;
}
.hero-gradient {
background: var(--surface-muted);
}
.hero-scrim {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--surface-muted) 38%, transparent) 0%,
color-mix(in srgb, var(--surface-muted) 10%, transparent) 48%,
transparent 72%
);
}
.hero-copy {
background: color-mix(in srgb, var(--surface) 92%, transparent);
border: 1px solid var(--border);
color: var(--text);
backdrop-filter: blur(12px);
box-shadow: 0 18px 50px -28px color-mix(in srgb, var(--text) 28%, transparent);
}
.skip-link {
position: absolute;
left: 1rem;
top: -4.5rem;
z-index: 100;
border-radius: 10px;
background: var(--surface);
padding: 0.65rem 1rem;
font-weight: 600;
color: var(--text);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.skip-link:focus {
top: 1rem;
}
html[data-font-scale="125"] {
font-size: 125%;
}
html[data-font-scale="150"] {
font-size: 150%;
}
html[data-contrast="high"] {
--surface: #ffffff;
--surface-muted: #ffffff;
--text: #000000;
--text-muted: #111111;
--border: #111111;
--brand-50: #f4f4f4;
--brand-100: #e7e7e7;
--brand-500: #000000;
--brand-600: #000000;
--brand-700: #000000;
--brand-800: #000000;
--brand-900: #000000;
--ring: #000000;
}
html[data-contrast="high"] a {
text-decoration: underline;
text-underline-offset: 0.18em;
}
html[data-contrast="high"] .hero-gradient,
html[data-contrast="high"] [data-decorative] {
background: none !important;
filter: none !important;
}
html[data-contrast="high"] button,
html[data-contrast="high"] a,
html[data-contrast="high"] input,
html[data-contrast="high"] select,
html[data-contrast="high"] textarea {
min-height: 44px;
}
html[data-contrast="high"] header {
background: var(--surface);
backdrop-filter: none;
}
html[data-theme="dark"] {
color-scheme: dark;
--brand-50: #101b2d;
--brand-100: #17243b;
--brand-200: #213250;
--brand-300: #2b4064;
--brand-400: #3d5b8f;
--brand-500: #5c80bc;
--brand-600: #7a98cd;
--brand-700: #9db4dd;
--brand-800: #c9d7ed;
--brand-900: #e4ebf6;
--neutral-25: #020617;
--neutral-50: #0f172a;
--neutral-100: #1e293b;
--neutral-200: #334155;
--neutral-300: #475569;
--neutral-400: #64748b;
--neutral-500: #94a3b8;
--neutral-600: #cbd5e1;
--neutral-700: #e2e8f0;
--neutral-800: #f1f5f9;
--neutral-900: #f8fafc;
--surface: #111827;
--surface-muted: #020617;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
--ring: #7a98cd;
}
html[data-theme="dark"] .hero-gradient {
background: var(--surface-muted);
}
html[data-theme="dark"] .hero-copy {
background: color-mix(in srgb, var(--surface) 88%, transparent);
}
.footer-social-icon {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
object-fit: contain;
overflow: hidden;
}
html[data-contrast="high"] .hero-copy,
html[data-contrast="high"][data-theme="dark"] .hero-copy {
background: var(--surface);
backdrop-filter: none;
box-shadow: none;
}
html[data-theme="dark"] .bg-white {
background-color: var(--surface);
}
html[data-theme="dark"] .bg-white\/90 {
background-color: color-mix(in srgb, var(--surface) 90%, transparent);
}
html[data-contrast="high"][data-theme="dark"] {
--surface: #000000;
--surface-muted: #000000;
--text: #ffffff;
--text-muted: #f4f4f4;
--border: #ffffff;
--brand-50: #111111;
--brand-100: #1a1a1a;
--brand-500: #ffffff;
--brand-600: #ffffff;
--brand-700: #ffffff;
--brand-800: #ffffff;
--brand-900: #ffffff;
--ring: #ffffff;
}
html[data-motion="reduce"] .animate-fade-up,
html[data-motion="reduce"] .animate-fade-up-delay,
html[data-motion="reduce"] .animate-soft-pulse,
html[data-motion="reduce"] .animate-float {
animation: none !important;
}
html[data-motion="reduce"] *,
html[data-motion="reduce"] *::before,
html[data-motion="reduce"] *::after {
animation: none !important;
transition: none !important;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
html[data-motion="system"] .animate-fade-up,
html[data-motion="system"] .animate-fade-up-delay,
html[data-motion="system"] .animate-soft-pulse,
html[data-motion="system"] .animate-float,
html:not([data-motion]) .animate-fade-up,
html:not([data-motion]) .animate-fade-up-delay,
html:not([data-motion]) .animate-soft-pulse,
html:not([data-motion]) .animate-float {
animation: none !important;
}
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return children;
}
+6
View File
@@ -0,0 +1,6 @@
import { routing } from "@/i18n/routing";
import { redirect } from "next/navigation";
export default function RootPage() {
redirect(`/${routing.defaultLocale}`);
}
@@ -0,0 +1,102 @@
"use client";
import { useAccessibility } from "@/components/a11y/AccessibilityProvider";
import { cn } from "@/lib/utils";
import { CONTRASTS, FONT_SCALES, MOTIONS } from "@/lib/a11y";
import { useTranslations } from "next-intl";
export function AccessibilityMenu() {
const t = useTranslations("a11y");
const { prefs, setPrefs, reset } = useAccessibility();
const panelId = "a11y-settings";
return (
<div
id={panelId}
role="dialog"
aria-modal="false"
aria-labelledby={`${panelId}-title`}
className="w-[min(20rem,calc(100vw-2rem))] rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-4 shadow-lg"
>
<p id={`${panelId}-title`} className="text-sm font-semibold text-[var(--text)]">
{t("title")}
</p>
<fieldset className="mt-4">
<legend className="text-xs font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{t("font")}
</legend>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{FONT_SCALES.map((scale) => (
<button
key={scale}
type="button"
aria-pressed={prefs.fontScale === scale}
onClick={() => setPrefs({ fontScale: scale })}
className={cn(
"h-10 rounded-[10px] border text-sm font-semibold",
prefs.fontScale === scale
? "border-[var(--brand-600)] bg-[var(--brand-50)] text-[var(--brand-800)]"
: "border-[var(--border)] text-[var(--neutral-700)] hover:border-[var(--brand-300)]",
)}
>
{scale === "100" ? t("fontDefault") : scale === "125" ? t("fontLarge") : t("fontXLarge")}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-4">
<legend className="text-xs font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{t("contrast")}
</legend>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{CONTRASTS.map((contrast) => (
<button
key={contrast}
type="button"
aria-pressed={prefs.contrast === contrast}
onClick={() => setPrefs({ contrast })}
className={cn(
"h-10 rounded-[10px] border text-sm font-semibold",
prefs.contrast === contrast
? "border-[var(--brand-600)] bg-[var(--brand-50)] text-[var(--brand-800)]"
: "border-[var(--border)] text-[var(--neutral-700)] hover:border-[var(--brand-300)]",
)}
>
{contrast === "high" ? t("contrastHigh") : t("contrastDefault")}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-4">
<legend className="text-xs font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{t("motion")}
</legend>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{MOTIONS.map((motion) => (
<button
key={motion}
type="button"
aria-pressed={prefs.motion === motion}
onClick={() => setPrefs({ motion })}
className={cn(
"h-10 rounded-[10px] border text-sm font-semibold",
prefs.motion === motion
? "border-[var(--brand-600)] bg-[var(--brand-50)] text-[var(--brand-800)]"
: "border-[var(--border)] text-[var(--neutral-700)] hover:border-[var(--brand-300)]",
)}
>
{motion === "reduce" ? t("motionReduce") : t("motionSystem")}
</button>
))}
</div>
</fieldset>
<button
type="button"
className="mt-4 w-full rounded-[10px] px-3 py-2 text-sm font-medium text-[var(--neutral-600)] hover:bg-[var(--neutral-50)] hover:text-[var(--neutral-900)]"
onClick={reset}
>
{t("reset")}
</button>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import {
A11Y_STORAGE_KEY,
applyA11yPrefs,
DEFAULT_A11Y_PREFS,
parseA11yPrefs,
type A11yPrefs,
} from "@/lib/a11y";
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useState,
type ReactNode,
} from "react";
type AccessibilityContextValue = {
prefs: A11yPrefs;
setPrefs: (next: Partial<A11yPrefs>) => void;
reset: () => void;
};
const AccessibilityContext = createContext<AccessibilityContextValue | null>(null);
export function AccessibilityProvider({ children }: { children: ReactNode }) {
const [prefs, setPrefsState] = useState<A11yPrefs>(DEFAULT_A11Y_PREFS);
useLayoutEffect(() => {
const next = parseA11yPrefs(localStorage.getItem(A11Y_STORAGE_KEY));
setPrefsState(next);
applyA11yPrefs(next);
}, []);
const persist = useCallback((next: A11yPrefs) => {
localStorage.setItem(A11Y_STORAGE_KEY, JSON.stringify(next));
applyA11yPrefs(next);
return next;
}, []);
const setPrefs = useCallback((partial: Partial<A11yPrefs>) => {
setPrefsState((current) => persist({ ...current, ...partial }));
}, [persist]);
const reset = useCallback(() => {
setPrefsState(persist(DEFAULT_A11Y_PREFS));
}, [persist]);
const value = useMemo(() => ({ prefs, setPrefs, reset }), [prefs, setPrefs, reset]);
return <AccessibilityContext.Provider value={value}>{children}</AccessibilityContext.Provider>;
}
export function useAccessibility() {
const ctx = useContext(AccessibilityContext);
if (!ctx) throw new Error("useAccessibility must be used within AccessibilityProvider");
return ctx;
}
@@ -0,0 +1,20 @@
"use client";
import { usePathname } from "@/i18n/routing";
import { useEffect, useRef } from "react";
export function RouteFocus() {
const pathname = usePathname();
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const main = document.getElementById("main-content");
main?.focus({ preventScroll: true });
}, [pathname]);
return null;
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { useTranslations } from "next-intl";
export function SkipLink() {
const t = useTranslations("a11y");
return (
<a
href="#main-content"
className="skip-link"
onClick={(event) => {
const main = document.getElementById("main-content");
if (!main) return;
event.preventDefault();
main.focus({ preventScroll: true });
main.scrollIntoView({ behavior: "auto", block: "start" });
}}
>
{t("skip")}
</a>
);
}
@@ -0,0 +1,68 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Link, useRouter } from "@/i18n/routing";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
export function LoginForm() {
const t = useTranslations("auth");
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
return (
<Card className="mx-auto w-full max-w-md space-y-4">
<h1 className="text-2xl font-bold text-[var(--neutral-900)]">{t("loginTitle")}</h1>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
setError("");
startTransition(async () => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
setError(t("error"));
return;
}
router.push("/cabinet");
router.refresh();
});
}}
>
<Input
type="email"
label={t("email")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
label={t("password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error ? <p className="text-sm text-[var(--danger)]">{error}</p> : null}
<Button type="submit" className="w-full" disabled={pending}>
{t("login")}
</Button>
</form>
<p className="text-sm text-[var(--text-muted)]">
{t("noAccount")}{" "}
<Link href="/register" className="font-semibold text-[var(--brand-700)]">
{t("register")}
</Link>
</p>
</Card>
);
}
@@ -0,0 +1,72 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Link, useRouter } from "@/i18n/routing";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
export function RegisterForm() {
const t = useTranslations("auth");
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
return (
<Card className="mx-auto w-full max-w-md space-y-4">
<h1 className="text-2xl font-bold text-[var(--neutral-900)]">{t("registerTitle")}</h1>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
setError("");
startTransition(async () => {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name, phone }),
});
if (!res.ok) {
setError(t("error"));
return;
}
router.push("/cabinet");
router.refresh();
});
}}
>
<Input label={t("name")} value={name} onChange={(e) => setName(e.target.value)} />
<Input label={t("phone")} value={phone} onChange={(e) => setPhone(e.target.value)} />
<Input
type="email"
label={t("email")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
label={t("password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error ? <p className="text-sm text-[var(--danger)]">{error}</p> : null}
<Button type="submit" className="w-full" disabled={pending}>
{t("register")}
</Button>
</form>
<p className="text-sm text-[var(--text-muted)]">
{t("hasAccount")}{" "}
<Link href="/login" className="font-semibold text-[var(--brand-700)]">
{t("login")}
</Link>
</p>
</Card>
);
}
@@ -0,0 +1,316 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import type { Branch, Doctor, Service, Slot } from "@/lib/demo-data";
import { matchesQuery } from "@/lib/search";
import { cn, formatPrice, stripPriceListIndex } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl";
import { useEffect, useMemo, useState, useTransition } from "react";
type Props = {
doctors: Doctor[];
branches: Branch[];
initialServiceId?: string;
initialDoctorId?: string;
initialBranchId?: string;
};
const steps = [
{ id: "service", labelKey: "stepService" },
{ id: "branch", labelKey: "stepBranch" },
{ id: "slot", labelKey: "stepSlot" },
{ id: "confirm", labelKey: "stepConfirm" },
] as const;
export function BookingWizard({
doctors,
branches,
initialServiceId,
initialDoctorId,
initialBranchId,
}: Props) {
const t = useTranslations("booking");
const locale = useLocale();
const [step, setStep] = useState(0);
const [serviceId, setServiceId] = useState(initialServiceId || "");
const [doctorId, setDoctorId] = useState(initialDoctorId || "");
const [services, setServices] = useState<Service[]>([]);
const [serviceQuery, setServiceQuery] = useState("");
const [branchId, setBranchId] = useState(initialBranchId || "");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [slots, setSlots] = useState<Slot[]>([]);
const [slotId, setSlotId] = useState("");
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
useEffect(() => {
fetch(`/api/catalog?locale=${encodeURIComponent(locale)}`)
.then((res) => res.json())
.then((data: { services?: Service[] }) => {
if (Array.isArray(data.services)) setServices(data.services);
})
.catch(() => undefined);
}, [locale]);
const service = services.find((s) => s.id === serviceId);
const doctor = doctors.find((d) => d.id === doctorId);
const branch = branches.find((b) => b.id === branchId);
const slot = slots.find((s) => s.id === slotId);
const visibleServices = useMemo(() => {
const list = !serviceQuery.trim()
? services.slice(0, 80)
: services
.filter((s) =>
matchesQuery([s.title, s.groupTitle, s.code].filter(Boolean).join(" "), serviceQuery),
)
.slice(0, 80);
const selected = services.find((s) => s.id === serviceId);
if (selected && !list.some((s) => s.id === selected.id)) return [selected, ...list];
return list;
}, [services, serviceQuery, serviceId]);
const filteredDoctors = useMemo(() => {
if (!serviceId) return doctors;
return doctors.filter((d) => d.services.includes(serviceId));
}, [doctors, serviceId]);
async function loadSlots(nextDate: string, nextBranch: string) {
const params = new URLSearchParams({
date: nextDate,
branchId: nextBranch,
});
if (doctorId) params.set("doctorId", doctorId);
if (serviceId) params.set("serviceId", serviceId);
const res = await fetch(`/api/slots?${params}`);
const data = (await res.json()) as Slot[];
setSlots(data);
setSlotId("");
}
function canNext() {
if (step === 0) return Boolean(serviceId || doctorId);
if (step === 1) return Boolean(branchId);
if (step === 2) return Boolean(slotId);
return Boolean(name && phone);
}
async function onNext() {
setError("");
if (step === 1 && branchId) {
await loadSlots(date, branchId);
}
if (step < steps.length - 1) {
setStep((s) => s + 1);
return;
}
startTransition(async () => {
try {
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serviceId: serviceId || undefined,
doctorId: doctorId || undefined,
branchId,
slotId,
datetime: slot?.start,
patientName: name,
phone,
}),
});
if (!res.ok) throw new Error("fail");
setDone(true);
} catch {
setError(t("confirm"));
}
});
}
if (done) {
return (
<Card className="mx-auto max-w-lg text-center">
<p className="text-2xl font-bold text-[var(--brand-700)]">{t("success")}</p>
<p className="mt-3 text-[var(--text-muted)]">{t("successHint")}</p>
</Card>
);
}
return (
<div className="mx-auto max-w-2xl">
<ol className="mb-8 flex flex-wrap gap-2">
{steps.map((s, i) => (
<li
key={s.id}
className={cn(
"rounded-full px-3 py-1 text-xs font-semibold",
i === step
? "bg-[var(--brand-600)] text-white"
: i < step
? "bg-[var(--brand-100)] text-[var(--brand-800)]"
: "bg-[var(--neutral-100)] text-[var(--neutral-500)]",
)}
>
{i + 1}. {t(s.labelKey)}
</li>
))}
</ol>
<Card className="space-y-5">
{step === 0 ? (
<>
<Input
label={t("selectService")}
placeholder={t("searchService")}
value={serviceQuery}
onChange={(e) => setServiceQuery(e.target.value)}
autoComplete="off"
/>
<div className="max-h-64 overflow-y-auto rounded-[10px] border border-[var(--border)]">
{visibleServices.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setServiceId(s.id)}
className={cn(
"flex w-full items-center justify-between gap-3 border-b border-[var(--border)] px-3 py-2 text-left text-sm last:border-b-0",
serviceId === s.id
? "bg-[var(--brand-50)] text-[var(--brand-800)]"
: "hover:bg-[var(--neutral-50)]",
)}
>
<span className="min-w-0">
<span className="block truncate" title={stripPriceListIndex(s.title)}>
{stripPriceListIndex(s.title)}
</span>
{s.code ? (
<span className="mt-0.5 block truncate text-xs text-[var(--neutral-500)]">{s.code}</span>
) : null}
</span>
<span className="shrink-0 font-semibold tabular-nums">{formatPrice(s.price, locale)}</span>
</button>
))}
</div>
<Select
label={t("selectDoctor")}
placeholder="—"
value={doctorId}
onChange={(e) => setDoctorId(e.target.value)}
options={filteredDoctors.map((d) => ({
value: d.id,
label: `${d.name} — ${d.specialty}`,
}))}
/>
</>
) : null}
{step === 1 ? (
<div className="space-y-3">
<p className="text-sm font-medium text-[var(--neutral-700)]">{t("selectBranch")}</p>
{branches.map((b) => (
<button
key={b.id}
type="button"
onClick={() => setBranchId(b.id)}
className={cn(
"w-full rounded-[10px] border px-4 py-3 text-left transition",
branchId === b.id
? "border-[var(--brand-500)] bg-[var(--brand-50)]"
: "border-[var(--border)] hover:border-[var(--brand-300)]",
)}
>
<p className="font-semibold">{b.name}</p>
<p className="text-sm text-[var(--text-muted)]">{b.address}</p>
</button>
))}
</div>
) : null}
{step === 2 ? (
<>
<Input
type="date"
label={t("selectDate")}
value={date}
onChange={async (e) => {
const v = e.target.value;
setDate(v);
if (branchId) await loadSlots(v, branchId);
}}
/>
<div>
<p className="mb-2 text-sm font-medium text-[var(--neutral-700)]">{t("selectTime")}</p>
{slots.filter((s) => s.available).length === 0 ? (
<p className="text-sm text-[var(--text-muted)]">{t("noSlots")}</p>
) : (
<div className="flex flex-wrap gap-2">
{slots
.filter((s) => s.available)
.map((s) => {
const label = new Date(s.start).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={s.id}
type="button"
onClick={() => setSlotId(s.id)}
className={cn(
"rounded-[10px] border px-3 py-2 text-sm font-medium",
slotId === s.id
? "border-[var(--brand-500)] bg-[var(--brand-600)] text-white"
: "border-[var(--border)] hover:border-[var(--brand-300)]",
)}
>
{label}
</button>
);
})}
</div>
)}
</div>
</>
) : null}
{step === 3 ? (
<>
<div className="rounded-[10px] bg-[var(--neutral-50)] p-4 text-sm">
<p className="font-semibold">{t("summary")}</p>
<ul className="mt-2 space-y-1 text-[var(--text-muted)]">
{service ? <li>{stripPriceListIndex(service.title)}</li> : null}
{doctor ? <li>{doctor.name}</li> : null}
{branch ? <li>{branch.name}</li> : null}
{slot ? <li>{new Date(slot.start).toLocaleString()}</li> : null}
{service ? <li>{formatPrice(service.price, locale)}</li> : null}
</ul>
</div>
<Input label={t("patientName")} value={name} onChange={(e) => setName(e.target.value)} required />
<Input label={t("phone")} value={phone} onChange={(e) => setPhone(e.target.value)} required />
</>
) : null}
{error ? <p className="text-sm text-[var(--danger)]">{error}</p> : null}
<div className="flex justify-between gap-3 pt-2">
<Button
variant="ghost"
disabled={step === 0 || pending}
onClick={() => setStep((s) => Math.max(0, s - 1))}
>
{t("back")}
</Button>
<Button disabled={!canNext() || pending} onClick={onNext}>
{step === steps.length - 1 ? t("confirm") : t("next")}
</Button>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,46 @@
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import type { Branch } from "@/lib/demo-data";
export function BranchCard({
branch,
metroLabel,
hoursLabel,
phoneLabel,
mainLabel,
detailsLabel,
}: {
branch: Branch;
metroLabel: string;
hoursLabel: string;
phoneLabel: string;
mainLabel: string;
detailsLabel: string;
}) {
return (
<Link href={`/branches/${branch.slug}`} className="block h-full">
<Card className="h-full transition hover:border-[var(--brand-300)] hover:bg-[var(--brand-50)]/40">
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="text-lg font-semibold text-[var(--text)]">{branch.name}</p>
{branch.isMain ? <Badge variant="brand">{mainLabel}</Badge> : null}
</div>
<p className="mt-2 text-sm text-[var(--text-muted)]">{branch.address}</p>
{branch.metro ? (
<p className="mt-1 text-sm text-[var(--brand-700)]">
{metroLabel}: {branch.metro}
</p>
) : null}
<p className="mt-3 text-sm font-medium text-[var(--text)]">
{phoneLabel}: {branch.phone}
</p>
{branch.hours ? (
<p className="mt-1 text-sm text-[var(--text-muted)]">
{hoursLabel}: {branch.hours}
</p>
) : null}
<p className="mt-4 text-sm font-semibold text-[var(--brand-700)]">{detailsLabel}</p>
</Card>
</Link>
);
}
@@ -0,0 +1,62 @@
"use client";
import {
formatCoordinates,
yandexNavigatorUrl,
yandexWidgetUrl,
} from "@/lib/yandex-maps";
export function YandexBranchMap({
lat,
lng,
title,
mapLabel,
navigatorLabel,
coordinatesLabel,
}: {
lat: number;
lng: number;
title: string;
mapLabel: string;
navigatorLabel: string;
coordinatesLabel: string;
}) {
const navigatorHref = yandexNavigatorUrl(lat, lng);
return (
<div className="space-y-4">
<iframe
title={`${mapLabel}: ${title}`}
src={yandexWidgetUrl(lat, lng)}
className="h-[380px] w-full rounded-[20px] border border-[var(--border)] bg-[var(--surface)]"
loading="lazy"
allowFullScreen
/>
<div>
<p className="text-sm font-medium text-[var(--text-muted)]">{coordinatesLabel}</p>
<p className="mt-1 font-mono text-[var(--text)]">{formatCoordinates(lat, lng)}</p>
</div>
<a
href={navigatorHref}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex max-w-full items-center gap-3 rounded-[16px] border border-[var(--border)] bg-[var(--surface)] px-4 py-3 transition hover:border-[var(--brand-400)] hover:bg-[var(--brand-50)]/40"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/icons/yandex-navigator.png"
alt=""
width={67}
height={67}
className="h-[67px] w-[67px] shrink-0"
/>
<span className="text-sm font-semibold text-[var(--brand-700)]">
{navigatorLabel}
<span aria-hidden className="ml-1 inline-block transition group-hover:translate-x-0.5">
→
</span>
</span>
</a>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import type { Booking } from "@/lib/demo-data";
import { formatDate, formatPrice } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl";
import { useState, useTransition } from "react";
import { Link } from "@/i18n/routing";
export function BookingActions({ booking }: { booking: Booking }) {
const t = useTranslations("cabinet");
const tr = useTranslations("reviews");
const locale = useLocale();
const [pending, startTransition] = useTransition();
const [status, setStatus] = useState(booking.status);
const [paid, setPaid] = useState(booking.paid);
const [showReview, setShowReview] = useState(false);
const [rating, setRating] = useState(5);
const [text, setText] = useState("");
const [reviewDone, setReviewDone] = useState(false);
const [rescheduleDate, setRescheduleDate] = useState("");
function cancel() {
startTransition(async () => {
await fetch(`/api/bookings/${booking.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "cancelled" }),
});
setStatus("cancelled");
});
}
function pay() {
startTransition(async () => {
const res = await fetch("/api/payments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bookingId: booking.id }),
});
const data = await res.json();
if (data.paymentUrl?.startsWith("#")) {
setPaid(true);
} else if (data.paymentUrl) {
window.location.href = data.paymentUrl;
}
});
}
function reschedule() {
if (!rescheduleDate) return;
startTransition(async () => {
await fetch(`/api/bookings/${booking.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slotId: `slot-${rescheduleDate}-10`,
datetime: `${rescheduleDate}T10:00:00`,
}),
});
});
}
function submitReview() {
startTransition(async () => {
await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bookingId: booking.id,
rating,
text,
doctorId: "1",
}),
});
setReviewDone(true);
setShowReview(false);
});
}
const statusLabel =
status === "upcoming"
? t("statusUpcoming")
: status === "completed"
? t("statusCompleted")
: t("statusCancelled");
return (
<Card>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold text-[var(--neutral-900)]">{booking.serviceTitle}</p>
<p className="mt-1 text-sm text-[var(--text-muted)]">{booking.doctorName}</p>
<p className="text-sm text-[var(--text-muted)]">{booking.branchName}</p>
<p className="mt-2 text-sm font-medium">{formatDate(booking.datetime, locale)}</p>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-[var(--brand-700)]">{statusLabel}</p>
<p className="mt-1 text-sm">{formatPrice(booking.price, locale)}</p>
<p className="text-xs text-[var(--neutral-500)]">{paid ? t("paid") : t("unpaid")}</p>
</div>
</div>
{status === "upcoming" ? (
<div className="mt-4 flex flex-wrap gap-2">
{!paid ? (
<Button size="sm" onClick={pay} disabled={pending}>
{t("paySbp")}
</Button>
) : null}
<Button size="sm" variant="outline" onClick={cancel} disabled={pending}>
{t("cancel")}
</Button>
<div className="flex flex-wrap items-end gap-2">
<Input
type="date"
label={t("reschedule")}
value={rescheduleDate}
onChange={(e) => setRescheduleDate(e.target.value)}
className="w-auto"
/>
<Button size="sm" variant="secondary" onClick={reschedule} disabled={pending || !rescheduleDate}>
{t("reschedule")}
</Button>
</div>
</div>
) : null}
{status === "completed" && !reviewDone ? (
<div className="mt-4">
{!showReview ? (
<Button size="sm" variant="secondary" onClick={() => setShowReview(true)}>
{t("leaveReview")}
</Button>
) : (
<div className="space-y-3 rounded-[10px] border border-[var(--border)] p-3">
<label className="block text-sm font-medium">
{tr("rating")}
<select
className="mt-1 h-10 w-full rounded-[10px] border border-[var(--border)] px-3"
value={rating}
onChange={(e) => setRating(Number(e.target.value))}
>
{[5, 4, 3, 2, 1].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<Input label={tr("text")} value={text} onChange={(e) => setText(e.target.value)} />
<Button size="sm" onClick={submitReview} disabled={pending || !text}>
{tr("submit")}
</Button>
</div>
)}
</div>
) : null}
{reviewDone ? <p className="mt-3 text-sm text-[var(--success)]">{tr("thanks")}</p> : null}
{status === "upcoming" ? (
<p className="mt-3 text-xs text-[var(--neutral-500)]">
<Link href="/booking" className="underline">
{t("reschedule")}
</Link>
</p>
) : null}
</Card>
);
}
@@ -0,0 +1,44 @@
"use client";
import { Link, usePathname } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
const items = [
{ href: "/cabinet" as const, key: "profile" },
{ href: "/cabinet/bookings" as const, key: "bookings" },
{ href: "/cabinet/history" as const, key: "history" },
{ href: "/cabinet/favorites" as const, key: "favorites" },
{ href: "/cabinet/documents" as const, key: "documents" },
{ href: "/cabinet/invoices" as const, key: "invoices" },
];
export function CabinetNav() {
const t = useTranslations("cabinet");
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1" aria-label={t("title")}>
{items.map((item) => {
const active =
item.href === "/cabinet"
? pathname === "/cabinet"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"rounded-[10px] px-3 py-2.5 text-sm font-medium transition",
active
? "bg-[var(--brand-600)] text-white"
: "text-[var(--neutral-700)] hover:bg-[var(--brand-50)]",
)}
>
{t(item.key)}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,44 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
type Props = {
user: { email: string; name?: string; phone?: string };
};
export function ProfileForm({ user }: Props) {
const t = useTranslations("cabinet");
const auth = useTranslations("auth");
const [name, setName] = useState(user.name || "");
const [phone, setPhone] = useState(user.phone || "");
const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition();
return (
<Card className="max-w-lg space-y-4">
<Input label={auth("email")} value={user.email} disabled />
<Input label={auth("name")} value={name} onChange={(e) => setName(e.target.value)} />
<Input label={auth("phone")} value={phone} onChange={(e) => setPhone(e.target.value)} />
<Button
disabled={pending}
onClick={() => {
startTransition(async () => {
await fetch("/api/auth/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, phone }),
});
setSaved(true);
});
}}
>
{t("save")}
</Button>
{saved ? <p className="text-sm text-[var(--success)]">{t("saved")}</p> : null}
</Card>
);
}
@@ -0,0 +1,54 @@
import { DoctorPhoto } from "@/components/doctors/DoctorPhoto";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import type { Doctor } from "@/lib/demo-data";
export function DoctorCard({
doctor,
labels,
}: {
doctor: Doctor;
labels: {
photoPending: string;
experience: string;
reviews: string;
bio: string;
book: string;
};
}) {
return (
<Card className="overflow-hidden p-0">
<DoctorPhoto
src={doctor.photo}
alt={doctor.name}
pending={doctor.photoPending}
pendingLabel={labels.photoPending}
className="aspect-[3/4]"
/>
<div className="p-5">
<h2 className="text-lg font-semibold">{doctor.name}</h2>
<p className="mt-1 text-sm text-[var(--text-muted)]">{doctor.specialty}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="brand">
{doctor.experienceYears} {labels.experience}
</Badge>
<Badge variant="neutral">
★ {doctor.rating} · {doctor.reviewsCount} {labels.reviews}
</Badge>
</div>
<div className="mt-5 flex gap-2">
<Link href={`/doctors/${doctor.slug}`}>
<Button variant="outline" size="sm">
{labels.bio}
</Button>
</Link>
<Link href={`/booking?doctor=${doctor.id}`}>
<Button size="sm">{labels.book}</Button>
</Link>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,41 @@
import { PLACEHOLDER_DOCTOR_PHOTO } from "@/lib/real-doctors";
import { cn } from "@/lib/utils";
export function DoctorPhoto({
src,
alt,
pending,
pendingLabel,
className,
imgClassName,
}: {
src?: string;
alt: string;
pending?: boolean;
pendingLabel: string;
className?: string;
imgClassName?: string;
}) {
const isPending = Boolean(pending) || !src;
const photo = isPending ? PLACEHOLDER_DOCTOR_PHOTO : src;
return (
<div className={cn("relative overflow-hidden bg-[var(--neutral-100)]", className)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo}
alt={isPending ? `${alt}. ${pendingLabel}` : alt}
className={cn(
"absolute inset-0 h-full w-full max-w-none object-center",
isPending ? "object-contain p-8 grayscale" : "object-cover",
imgClassName,
)}
/>
{isPending ? (
<p className="absolute inset-x-0 bottom-0 bg-white/90 px-3 py-2 text-center text-xs font-medium text-[var(--neutral-600)]">
{pendingLabel}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import { DoctorCard } from "@/components/doctors/DoctorCard";
import { InfiniteScroll, type InfinitePage } from "@/components/ui/InfiniteScroll";
import { Input } from "@/components/ui/Input";
import { usePathname, useRouter } from "@/i18n/routing";
import type { Doctor } from "@/lib/demo-data";
import { useTranslations, useLocale } from "next-intl";
import { useEffect, useRef, useState } from "react";
const PAGE_LIMIT = 9;
function prefetchPhotos(doctors: Doctor[]) {
if (typeof window === "undefined") return;
for (const doctor of doctors) {
if (!doctor.photo) continue;
const image = new Image();
image.src = doctor.photo;
}
}
function doctorsEndpoint(query: string, locale: string) {
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), locale });
if (query) params.set("q", query);
return `/api/doctors?${params.toString()}`;
}
export function DoctorsInfinite({
initial,
prefetched,
initialQuery = "",
labels,
loadingLabel,
}: {
initial: InfinitePage<Doctor> & { total?: number };
prefetched?: InfinitePage<Doctor> | null;
initialQuery?: string;
loadingLabel: string;
labels: {
photoPending: string;
experience: string;
reviews: string;
bio: string;
book: string;
};
}) {
const t = useTranslations("doctors");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const ssrQuery = initialQuery.trim();
const ssrRef = useRef({ initial, prefetched: prefetched ?? null, query: ssrQuery });
const [input, setInput] = useState(initialQuery);
const [query, setQuery] = useState(ssrQuery);
const [pages, setPages] = useState({
initial,
prefetched: prefetched ?? null,
total: initial.total,
});
const [searching, setSearching] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => setQuery(input.trim()), 250);
return () => window.clearTimeout(timer);
}, [input]);
useEffect(() => {
const params = new URLSearchParams();
if (query) params.set("q", query);
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [pathname, query, router]);
useEffect(() => {
if (query === ssrRef.current.query) {
setPages({
initial: ssrRef.current.initial,
prefetched: ssrRef.current.prefetched,
total: ssrRef.current.initial.total,
});
return;
}
let cancelled = false;
setSearching(true);
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), page: "1", locale });
if (query) params.set("q", query);
(async () => {
try {
const firstRes = await fetch(`/api/doctors?${params}`, { cache: "no-store" });
if (!firstRes.ok) throw new Error("search failed");
const first = (await firstRes.json()) as InfinitePage<Doctor> & { total?: number };
let next: InfinitePage<Doctor> | null = null;
if (first.hasNextPage) {
params.set("page", "2");
const secondRes = await fetch(`/api/doctors?${params}`, { cache: "no-store" });
if (secondRes.ok) next = (await secondRes.json()) as InfinitePage<Doctor>;
}
if (!cancelled) {
setPages({ initial: first, prefetched: next, total: first.total });
}
} catch {
if (!cancelled) {
setPages({
initial: { items: [], page: 1, hasNextPage: false, nextPage: null, total: 0 },
prefetched: null,
total: 0,
});
}
} finally {
if (!cancelled) setSearching(false);
}
})();
return () => {
cancelled = true;
};
}, [query, locale]);
const endpoint = doctorsEndpoint(query, locale);
return (
<>
<div className="mt-8 max-w-xl">
<Input
label={t("search")}
value={input}
placeholder={t("searchPlaceholder")}
autoComplete="off"
name="q"
inputMode="search"
onChange={(event) => setInput(event.target.value)}
/>
<p className="mt-2 text-sm text-[var(--text-muted)]">
{searching ? t("searching") : t("found", { count: pages.total ?? pages.initial.items.length })}
</p>
</div>
{pages.initial.items.length === 0 && !searching ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<InfiniteScroll
key={endpoint}
initial={pages.initial}
prefetched={pages.prefetched}
endpoint={endpoint}
className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
loadingLabel={loadingLabel}
onPrefetchItems={prefetchPhotos}
renderItem={(doctor) => <DoctorCard doctor={doctor} labels={labels} />}
/>
)}
</>
);
}
@@ -0,0 +1,41 @@
import { BranchCard } from "@/components/branches/BranchCard";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import type { Branch } from "@/lib/demo-data";
import { getTranslations } from "next-intl/server";
export async function BranchesPreview({ branches }: { branches: Branch[] }) {
const t = await getTranslations("home");
const b = await getTranslations("branches");
return (
<section className="bg-[var(--brand-50)]/50">
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[var(--text)] sm:text-3xl">
{t("branchesTitle")}
</h2>
<p className="mt-2 text-[var(--text-muted)]">{t("branchesSubtitle")}</p>
</div>
<Link href="/branches">
<Button variant="outline">{b("title")}</Button>
</Link>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-2">
{branches.map((branch) => (
<BranchCard
key={branch.id}
branch={branch}
metroLabel={b("metro")}
hoursLabel={b("hours")}
phoneLabel={b("phone")}
mainLabel={b("main")}
detailsLabel={b("details")}
/>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,59 @@
import { DoctorPhoto } from "@/components/doctors/DoctorPhoto";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import type { Doctor } from "@/lib/demo-data";
import { getTranslations } from "next-intl/server";
export async function DoctorsPreview({ doctors }: { doctors: Doctor[] }) {
const t = await getTranslations("home");
const d = await getTranslations("doctors");
return (
<section className="bg-white">
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[var(--neutral-900)] sm:text-3xl">
{t("doctorsTitle")}
</h2>
<p className="mt-2 text-[var(--text-muted)]">{t("doctorsSubtitle")}</p>
</div>
<Link href="/doctors">
<Button variant="outline">{t("allDoctors")}</Button>
</Link>
</div>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{[...doctors]
.sort((a, b) => Number(a.photoPending) - Number(b.photoPending))
.slice(0, 4)
.map((doc) => (
<Link key={doc.id} href={`/doctors/${doc.slug}`}>
<Card className="h-full overflow-hidden p-0 transition hover:border-[var(--brand-300)]">
<DoctorPhoto
src={doc.photo}
alt={doc.name}
pending={doc.photoPending}
pendingLabel={d("photoPending")}
className="aspect-[3/4]"
/>
<div className="p-4">
<p className="font-semibold text-[var(--neutral-900)]">{doc.name}</p>
<p className="mt-1 text-sm text-[var(--text-muted)]">{doc.specialty}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="brand">
{doc.experienceYears} {d("experience")}
</Badge>
<Badge variant="neutral">★ {doc.rating}</Badge>
</div>
</div>
</Card>
</Link>
))}
</div>
</div>
</section>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { HeroCarousel } from "@/components/home/HeroCarousel";
import type { HeroBanner } from "@/lib/demo-data";
export function Hero({ banners }: { banners: HeroBanner[] }) {
return <HeroCarousel banners={banners} />;
}
@@ -0,0 +1,274 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import type { HeroBanner } from "@/lib/demo-data";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
function isInternalHref(href: string) {
return href.startsWith("/") && !href.startsWith("//");
}
function BannerLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
if (!href) return null;
if (isInternalHref(href)) {
return <Link href={href}>{children}</Link>;
}
return (
<a href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noreferrer">
{children}
</a>
);
}
export function HeroCarousel({ banners }: { banners: HeroBanner[] }) {
const t = useTranslations("hero");
const meta = useTranslations("meta");
const slides = banners.length ? banners : [];
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [playingId, setPlayingId] = useState<string | null>(null);
const videoRefs = useRef<Record<string, HTMLVideoElement | null>>({});
const current = slides[index] ?? slides[0];
const count = slides.length;
const autoplayBlocked = paused || Boolean(playingId);
const stopVideos = useCallback(() => {
for (const video of Object.values(videoRefs.current)) {
if (!video) continue;
video.pause();
video.currentTime = 0;
}
setPlayingId(null);
}, []);
const go = useCallback(
(next: number) => {
if (!count) return;
stopVideos();
setIndex((next + count) % count);
},
[count, stopVideos],
);
useEffect(() => {
if (count < 2 || autoplayBlocked) return;
const reduce =
document.documentElement.dataset.motion === "reduce" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const duration = Math.max(4000, current?.durationMs || 7000);
const timer = window.setTimeout(() => go(index + 1), duration);
return () => window.clearTimeout(timer);
}, [count, autoplayBlocked, current?.durationMs, go, index]);
const toggleVideo = useCallback(
(slide: HeroBanner) => {
if (!slide.video) return;
const video = videoRefs.current[slide.id];
if (!video) return;
if (playingId === slide.id && !video.paused) {
video.pause();
setPlayingId(null);
return;
}
void video.play().then(() => setPlayingId(slide.id)).catch(() => setPlayingId(null));
},
[playingId],
);
if (!current) return null;
return (
<section
className="hero-gradient relative overflow-hidden pb-8 lg:pb-16"
aria-roledescription="carousel"
aria-label={t("carousel")}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPaused(false);
}
}}
>
{slides.map((slide, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`bg-${slide.id}`}
src={(slide.background || "/hero/building-blur.jpg").replace(
/building-blur\.jpg$/,
"building-blur.jpg?v=30",
)}
alt=""
aria-hidden
data-decorative
className={cn(
"pointer-events-none absolute inset-0 h-full w-full max-w-none scale-105 object-cover object-center transition-opacity duration-700",
i === index ? "opacity-100" : "opacity-0",
)}
/>
))}
<div data-decorative className="hero-scrim pointer-events-none absolute inset-0" aria-hidden />
<div className="relative mx-auto grid min-h-[calc(100vh-var(--header-h))] max-w-6xl items-center gap-10 px-4 py-16 sm:px-6 lg:grid-cols-2 lg:gap-14 lg:py-20">
<div className="hero-copy max-w-xl rounded-[28px] p-6 sm:p-8">
<p className="text-4xl font-extrabold tracking-tight text-[var(--brand-700)] sm:text-5xl lg:text-6xl">
{current.eyebrow || t("brand")}
</p>
<p className="mt-2 text-sm font-medium text-[var(--text-muted)]">{meta("fullName")}</p>
<h1
key={`title-${current.id}`}
className="mt-6 text-2xl font-semibold leading-snug text-[var(--text)] sm:text-3xl"
>
{current.title}
</h1>
{current.subtitle ? (
<p
key={`subtitle-${current.id}`}
className="mt-4 text-base leading-relaxed text-[var(--text-muted)] sm:text-lg"
>
{current.subtitle}
</p>
) : null}
<div className="mt-8 flex flex-wrap gap-3">
{current.ctaPrimaryLabel ? (
<BannerLink href={current.ctaPrimaryHref}>
<Button size="lg">{current.ctaPrimaryLabel}</Button>
</BannerLink>
) : null}
{current.ctaSecondaryLabel ? (
<BannerLink href={current.ctaSecondaryHref}>
<Button size="lg" variant="outline">
{current.ctaSecondaryLabel}
</Button>
</BannerLink>
) : null}
</div>
{(current.phone || current.address) && (
<p className="mt-6 text-sm text-[var(--text-muted)]">
{current.phone ? (
<a href={`tel:${current.phone.replace(/[^\d+]/g, "")}`} className="font-semibold text-[var(--brand-700)]">
{current.phone}
</a>
) : null}
{current.phone && current.address ? (
<span className="mx-2 text-[var(--border)]">·</span>
) : null}
{current.address}
</p>
)}
</div>
<div className="relative">
<div className="relative aspect-[4/5] overflow-hidden rounded-[28px] shadow-[0_24px_80px_-32px_rgba(15,118,110,0.45)] sm:aspect-[5/6]">
{slides.map((slide, i) => {
const active = i === index;
const playing = playingId === slide.id && active;
return (
<div
key={slide.id}
className={cn(
"absolute inset-0 transition-opacity duration-700",
active ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
{slide.video ? (
<video
ref={(node) => {
videoRefs.current[slide.id] = node;
}}
src={slide.video}
poster={slide.image}
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full max-w-none object-cover object-center"
onEnded={() => setPlayingId(null)}
/>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={slide.image}
alt={active ? slide.imageAlt || current.title : ""}
className={cn(
"absolute inset-0 h-full w-full max-w-none object-cover object-center transition-opacity duration-300",
playing ? "opacity-0" : "opacity-100",
)}
/>
{slide.video ? (
<button
type="button"
className="absolute inset-0 z-10 flex items-center justify-center"
aria-label={playing ? t("pauseVideo") : t("playVideo")}
onClick={() => toggleVideo(slide)}
>
{playing ? null : (
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--brand-700)]/90 text-3xl text-white shadow-lg ring-4 ring-white/40">
▶
</span>
)}
</button>
) : null}
</div>
);
})}
<div
data-decorative
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-t from-[var(--brand-900)]/35 via-transparent to-transparent transition-opacity duration-300",
playingId === current.id ? "opacity-0" : "opacity-100",
)}
/>
</div>
{count > 1 ? (
<div className="mt-5 flex items-center justify-center gap-3 lg:absolute lg:-bottom-2 lg:left-1/2 lg:mt-0 lg:-translate-x-1/2 lg:translate-y-full">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface)] text-[var(--text)] shadow-sm hover:border-[var(--brand-400)] hover:text-[var(--brand-700)]"
aria-label={t("prev")}
onClick={() => go(index - 1)}
>
‹
</button>
<div className="flex items-center gap-2" role="tablist" aria-label={t("carousel")}>
{slides.map((slide, i) => (
<button
key={slide.id}
type="button"
role="tab"
aria-selected={i === index}
aria-label={t("slide", { current: i + 1, total: count })}
className={cn(
"h-2.5 rounded-full transition-all",
i === index ? "w-7 bg-[var(--brand-600)]" : "w-2.5 bg-[var(--neutral-300)] hover:bg-[var(--brand-300)]",
)}
onClick={() => go(i)}
/>
))}
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface)] text-[var(--text)] shadow-sm hover:border-[var(--brand-400)] hover:text-[var(--brand-700)]"
aria-label={t("next")}
onClick={() => go(index + 1)}
>
›
</button>
</div>
) : null}
</div>
</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import { getTranslations } from "next-intl/server";
export async function HomeCta() {
const t = await getTranslations("home");
const hero = await getTranslations("hero");
return (
<section className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="overflow-hidden rounded-[24px] bg-[var(--brand-700)] px-6 py-12 text-white sm:px-10">
<div className="max-w-xl">
<h2 className="text-2xl font-bold sm:text-3xl">{t("ctaTitle")}</h2>
<p className="mt-3 text-white/85">{t("ctaSubtitle")}</p>
<div className="mt-6">
<Link href="/booking">
<Button
size="lg"
className="bg-white text-[var(--brand-800)] hover:bg-[var(--brand-50)]"
>
{hero("ctaBook")}
</Button>
</Link>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,46 @@
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import type { NewsItem } from "@/lib/demo-data";
import { formatDate } from "@/lib/utils";
import { getLocale, getTranslations } from "next-intl/server";
export async function NewsPreview({ news }: { news: NewsItem[] }) {
const t = await getTranslations("home");
const n = await getTranslations("news");
const locale = await getLocale();
return (
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[var(--neutral-900)] sm:text-3xl">{t("newsTitle")}</h2>
<p className="mt-2 text-[var(--text-muted)]">{t("newsSubtitle")}</p>
</div>
<Link href="/news">
<Button variant="outline">{t("allNews")}</Button>
</Link>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-3">
{news.slice(0, 3).map((item) => (
<Link key={item.id} href={`/news/${item.slug}`}>
<Card className="h-full overflow-hidden p-0 transition hover:border-[var(--brand-300)]">
{item.cover ? (
<div className="relative aspect-[16/10] overflow-hidden bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.cover} alt="" className="absolute inset-0 h-full w-full max-w-none object-cover object-center" />
</div>
) : null}
<div className="p-4">
<p className="text-xs text-[var(--neutral-500)]">{formatDate(item.publishedAt, locale)}</p>
<p className="mt-2 font-semibold text-[var(--neutral-900)]">{item.title}</p>
<p className="mt-2 line-clamp-2 text-sm text-[var(--text-muted)]">{item.excerpt}</p>
<p className="mt-3 text-sm font-medium text-[var(--brand-700)]">{n("readMore")}</p>
</div>
</Card>
</Link>
))}
</div>
</section>
);
}
@@ -0,0 +1,29 @@
import { PromotionCard } from "@/components/promotions/PromotionCard";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import type { Promotion } from "@/lib/demo-data";
import { getTranslations } from "next-intl/server";
export async function PromotionsPreview({ promotions }: { promotions: Promotion[] }) {
const t = await getTranslations("home");
if (!promotions.length) return null;
return (
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[var(--neutral-900)] sm:text-3xl">{t("promotionsTitle")}</h2>
<p className="mt-2 text-[var(--text-muted)]">{t("promotionsSubtitle")}</p>
</div>
<Link href="/promotions">
<Button variant="outline">{t("allPromotions")}</Button>
</Link>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2">
{promotions.slice(0, 4).map((promo) => (
<PromotionCard key={promo.id} promo={promo} />
))}
</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { ReviewCard } from "@/components/reviews/ReviewCard";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import type { Review } from "@/lib/demo-data";
import { getTranslations } from "next-intl/server";
export async function Reviews({ reviews }: { reviews: Review[] }) {
const t = await getTranslations("reviews");
const home = await getTranslations("home");
return (
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="max-w-2xl">
<h2 className="text-2xl font-bold text-[var(--neutral-900)] sm:text-3xl">{t("title")}</h2>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
</div>
<Link href="/reviews">
<Button variant="outline">{home("allReviews")}</Button>
</Link>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-3">
{reviews.slice(0, 3).map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
</section>
);
}
@@ -0,0 +1,43 @@
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import { specialties } from "@/lib/demo-data";
import { getTranslations } from "next-intl/server";
const icons: Record<string, string> = {
stethoscope: "🩺",
heart: "♥",
brain: "◎",
scan: "▣",
flask: "⚗",
activity: "⌁",
};
export async function Specialties() {
const t = await getTranslations("home");
const categories = await getTranslations("services.categories");
return (
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<div className="max-w-2xl">
<h2 className="text-2xl font-bold text-[var(--neutral-900)] sm:text-3xl">
{t("specialtiesTitle")}
</h2>
<p className="mt-2 text-[var(--text-muted)]">{t("specialtiesSubtitle")}</p>
</div>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{specialties.map((s) => (
<Link key={s.slug} href={`/services?category=${s.slug}`}>
<Card className="h-full transition hover:border-[var(--brand-300)] hover:bg-[var(--brand-50)]/40">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--brand-50)] text-lg text-[var(--brand-700)]">
{icons[s.icon] || "•"}
</div>
<p className="mt-4 text-lg font-semibold text-[var(--neutral-900)]">
{categories(s.slug as "consultation")}
</p>
</Card>
</Link>
))}
</div>
</section>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { Logo } from "@/components/layout/Logo";
import { Link } from "@/i18n/routing";
import { getBranches, getSiteNav } from "@/lib/api";
import { legalPages } from "@/lib/legal";
import { organizationPages } from "@/lib/organization";
import { getLocale, getTranslations } from "next-intl/server";
const socials = [
{
href: "https://vk.ru/institutmoniki",
key: "vk" as const,
icon: "/icons/vk.png",
},
{
href: "https://max.ru/id7702066157_gos",
key: "max" as const,
icon: "/icons/max.png",
},
];
const linkKeys = [
{ href: "/services" as const, key: "services" },
{ href: "/doctors" as const, key: "doctors" },
{ href: "/booking" as const, key: "booking" },
{ href: "/promotions" as const, key: "promotions" },
{ href: "/branches" as const, key: "branches" },
{ href: "/news" as const, key: "news" },
{ href: "/gallery" as const, key: "gallery" },
];
function telHref(phone: string) {
const value = phone.replace(/[^\d+]/g, "");
return value ? `tel:${value}` : undefined;
}
function phoneDigits(phone: string) {
return phone.replace(/\D/g, "");
}
export async function Footer() {
const t = await getTranslations();
const nav = await getTranslations("nav");
const footer = await getTranslations("footer");
const hero = await getTranslations("hero");
const organization = await getTranslations("organization");
const legal = await getTranslations("legal");
const locale = await getLocale();
const [cmsOrg, cmsUseful, branches] = await Promise.all([
getSiteNav("organization", { locale, footer: true }),
getSiteNav("useful", { locale, footer: true }),
getBranches(locale),
]);
const extraBranches = branches.filter((branch) => !branch.isMain);
const orgLinks = cmsOrg.length
? cmsOrg
: organizationPages.map((page) => ({
slug: page.slug,
href: page.href,
title: organization(`${page.navKey}.title`),
}));
const usefulLinks = cmsUseful.length
? cmsUseful
: legalPages.map((page) => ({
slug: page.slug,
href: page.href,
title: legal(`${page.navKey}.title`),
}));
return (
<footer className="mt-auto border-t border-[var(--border)] bg-[var(--surface)]">
<div className="mx-auto grid max-w-6xl gap-10 px-4 py-12 sm:px-6 md:grid-cols-2 lg:grid-cols-5">
<div>
<Logo imgClassName="h-16 w-16" showTagline={false} />
<p className="mt-2 text-sm leading-relaxed text-[var(--text-muted)]">{footer("about")}</p>
<p className="mt-4 text-sm text-[var(--neutral-700)]">{hero("address")}</p>
<p className="mt-1 text-sm font-semibold text-[var(--brand-700)]">
<a href="tel:+74996740709">{hero("phone")}</a>
{" · "}
<a href="tel:122">{hero("phoneAlt")}</a>
</p>
{extraBranches.map((branch) => {
const phone = branch.phone.trim();
const href = telHref(phone);
const duplicatePhone =
Boolean(phone) && phoneDigits(phone) === phoneDigits(hero("phone"));
return (
<div key={branch.id} className="mt-4">
<p className="text-sm text-[var(--neutral-700)]">{branch.address}</p>
{phone && !duplicatePhone ? (
<p className="mt-1 text-sm font-semibold text-[var(--brand-700)]">
{href ? <a href={href}>{phone}</a> : phone}
</p>
) : null}
</div>
);
})}
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{footer("links")}
</p>
<nav aria-label={nav("menu")}>
<ul className="mt-3 space-y-2">
{linkKeys.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="text-sm text-[var(--neutral-700)] hover:text-[var(--brand-700)]"
>
{nav(l.key)}
</Link>
</li>
))}
</ul>
</nav>
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{footer("organization")}
</p>
<nav aria-label={footer("organization")}>
<ul className="mt-3 space-y-2">
{orgLinks.map((page) => (
<li key={page.slug}>
<Link
href={page.href}
className="text-sm text-[var(--neutral-700)] hover:text-[var(--brand-700)]"
>
{page.title}
</Link>
</li>
))}
</ul>
</nav>
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{footer("useful")}
</p>
<nav aria-label={footer("useful")}>
<ul className="mt-3 space-y-2">
{usefulLinks.map((page) => (
<li key={page.slug}>
<Link
href={page.href}
className="text-sm text-[var(--neutral-700)] hover:text-[var(--brand-700)]"
>
{page.title}
</Link>
</li>
))}
</ul>
</nav>
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{footer("social")}
</p>
<ul className="mt-3 space-y-2.5 text-sm">
{socials.map((item) => (
<li key={item.href}>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2.5 text-sm text-[var(--text)] transition hover:text-[var(--brand-700)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.icon}
alt=""
width={20}
height={20}
className="footer-social-icon"
/>
{footer(item.key)}
</a>
</li>
))}
</ul>
<p className="mt-6 text-xs text-[var(--neutral-500)]">{footer("rights")}</p>
<p className="mt-1 text-xs text-[var(--neutral-400)]">{t("meta.fullName")}</p>
</div>
</div>
</footer>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { HeaderTools } from "@/components/layout/HeaderTools";
import { LocaleSwitcher } from "@/components/layout/LocaleSwitcher";
import { Logo } from "@/components/layout/Logo";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useEffect, useId, useState } from "react";
const links = [
{ href: "/services" as const, key: "services" },
{ href: "/doctors" as const, key: "doctors" },
{ href: "/booking" as const, key: "booking" },
{ href: "/promotions" as const, key: "promotions" },
{ href: "/branches" as const, key: "branches" },
{ href: "/news" as const, key: "news" },
{ href: "/gallery" as const, key: "gallery" },
];
export function Header() {
const t = useTranslations("nav");
const a11y = useTranslations("a11y");
const meta = useTranslations("meta");
const [open, setOpen] = useState(false);
const menuId = useId();
useEffect(() => {
if (!open) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<header className="sticky top-0 z-50 border-b border-[var(--border)] bg-[var(--surface)]">
<div className="mx-auto flex h-[var(--header-h)] max-w-7xl items-center gap-3 px-4 sm:px-6">
<Logo tagline={meta("tagline")} className="min-w-0 shrink-0" />
<nav
className="hidden min-w-0 flex-1 items-center justify-end gap-0 lg:flex"
aria-label={a11y("mainNav")}
>
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className="whitespace-nowrap rounded-lg px-2.5 py-2 text-base font-medium text-[var(--neutral-700)] transition hover:bg-[var(--brand-50)] hover:text-[var(--brand-700)]"
>
{t(l.key)}
</Link>
))}
</nav>
<div className="ml-auto flex shrink-0 items-center gap-2">
<HeaderTools />
<Link href="/cabinet" className="hidden md:inline-flex">
<Button size="sm">{t("cabinet")}</Button>
</Link>
<LocaleSwitcher />
<button
type="button"
className="relative inline-flex h-10 w-10 flex-col items-center justify-center gap-1.5 rounded-lg border border-[var(--border)] lg:hidden"
aria-expanded={open}
aria-controls={menuId}
aria-label={open ? t("close") : t("menu")}
onClick={() => setOpen((v) => !v)}
>
<span
className={cn(
"block h-0.5 w-5 bg-[var(--neutral-800)] transition",
open && "translate-y-2 rotate-45",
)}
/>
<span
className={cn(
"block h-0.5 w-5 bg-[var(--neutral-800)] transition",
open && "opacity-0",
)}
/>
<span
className={cn(
"block h-0.5 w-5 bg-[var(--neutral-800)] transition",
open && "-translate-y-2 -rotate-45",
)}
/>
</button>
</div>
</div>
{open ? (
<div id={menuId} className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-4 lg:hidden">
<nav className="flex flex-col gap-1" aria-label={a11y("mainNav")}>
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className="rounded-lg px-3 py-3 text-base font-medium text-[var(--neutral-700)] hover:bg-[var(--brand-50)]"
onClick={() => setOpen(false)}
>
{t(l.key)}
</Link>
))}
<div className="mt-3 flex flex-wrap items-center gap-2 md:hidden">
<Link href="/cabinet" onClick={() => setOpen(false)}>
<Button size="sm">{t("cabinet")}</Button>
</Link>
</div>
</nav>
</div>
) : null}
</header>
);
}
@@ -0,0 +1,246 @@
"use client";
import { AccessibilityMenu } from "@/components/a11y/AccessibilityMenu";
import { useTheme } from "@/components/layout/ThemeProvider";
import { Link } from "@/i18n/routing";
import { cn, formatPrice } from "@/lib/utils";
import { Glasses02, Moon01, SearchMd, Sun } from "@untitledui/icons";
import { useTranslations, useLocale } from "next-intl";
import { useEffect, useId, useRef, useState } from "react";
type SearchDoctor = { slug: string; name: string; specialty: string };
type SearchService = { slug: string; title: string; price: number };
function ToolbarButton({
label,
pressed,
expanded,
controls,
onClick,
children,
}: {
label: string;
pressed?: boolean;
expanded?: boolean;
controls?: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={pressed}
aria-expanded={expanded}
aria-controls={controls}
onClick={onClick}
className={cn(
"inline-flex h-10 w-10 items-center justify-center rounded-[12px] text-[var(--neutral-700)] transition-colors hover:bg-[var(--neutral-100)]",
(pressed || expanded) && "bg-[var(--neutral-100)] text-[var(--neutral-900)]",
)}
>
{children}
</button>
);
}
export function HeaderTools() {
const t = useTranslations("search");
const locale = useLocale();
const a11y = useTranslations("a11y");
const themeT = useTranslations("theme");
const { theme, toggle } = useTheme();
const [panel, setPanel] = useState<"search" | "a11y" | null>(null);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [doctors, setDoctors] = useState<SearchDoctor[]>([]);
const [services, setServices] = useState<SearchService[]>([]);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchId = useId();
useEffect(() => {
if (!panel) return;
function onPointer(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) setPanel(null);
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setPanel(null);
}
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [panel]);
useEffect(() => {
if (panel === "search") {
const id = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(id);
}
}, [panel]);
useEffect(() => {
const q = query.trim();
if (panel !== "search" || q.length < 2) {
setDoctors([]);
setServices([]);
setLoading(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&locale=${encodeURIComponent(locale)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error("search failed");
const data = (await res.json()) as { doctors?: SearchDoctor[]; services?: SearchService[] };
if (controller.signal.aborted) return;
setDoctors(data.doctors ?? []);
setServices(data.services ?? []);
} catch (error) {
if ((error as { name?: string }).name === "AbortError") return;
setDoctors([]);
setServices([]);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, 220);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [panel, query, locale]);
const hasQuery = query.trim().length >= 2;
const empty = hasQuery && !loading && doctors.length === 0 && services.length === 0;
return (
<div ref={rootRef} className="relative shrink-0">
<div className="flex items-center rounded-[22px] border border-[var(--border)] bg-[var(--surface)] p-1 shadow-[0_1px_2px_rgba(15,23,42,0.06)]">
<ToolbarButton
label={t("open")}
expanded={panel === "search"}
controls={searchId}
onClick={() => setPanel((value) => (value === "search" ? null : "search"))}
>
<SearchMd size={20} />
</ToolbarButton>
<ToolbarButton
label={a11y("open")}
expanded={panel === "a11y"}
controls="a11y-settings"
onClick={() => setPanel((value) => (value === "a11y" ? null : "a11y"))}
>
<Glasses02 size={20} />
</ToolbarButton>
<ToolbarButton
label={theme === "dark" ? themeT("light") : themeT("dark")}
pressed={theme === "dark"}
onClick={toggle}
>
{theme === "dark" ? <Sun size={20} /> : <Moon01 size={20} />}
</ToolbarButton>
</div>
{panel === "search" ? (
<div
id={searchId}
className="absolute right-0 z-50 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-3 shadow-lg"
>
<label className="sr-only" htmlFor={`${searchId}-input`}>
{t("open")}
</label>
<input
ref={inputRef}
id={`${searchId}-input`}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("placeholder")}
className="h-11 w-full rounded-[10px] border border-[var(--border)] bg-[var(--surface-muted)] px-3.5 text-sm text-[var(--text)] outline-none placeholder:text-[var(--neutral-400)] focus:border-[var(--brand-500)] focus:ring-2 focus:ring-[var(--brand-200)]"
/>
<div className="mt-3 max-h-80 overflow-auto">
{!hasQuery ? (
<p className="px-1 py-2 text-sm text-[var(--text-muted)]">{t("hint")}</p>
) : null}
{loading ? <p className="px-1 py-2 text-sm text-[var(--text-muted)]">{t("loading")}</p> : null}
{empty ? <p className="px-1 py-2 text-sm text-[var(--text-muted)]">{t("empty")}</p> : null}
{doctors.length ? (
<div>
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{t("doctors")}
</p>
<ul className="mt-1">
{doctors.map((doctor) => (
<li key={doctor.slug}>
<Link
href={`/doctors/${doctor.slug}`}
className="block rounded-lg px-2 py-2 hover:bg-[var(--neutral-50)]"
onClick={() => setPanel(null)}
>
<span className="block text-sm font-medium text-[var(--text)]">{doctor.name}</span>
<span className="block text-xs text-[var(--text-muted)]">{doctor.specialty}</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
{services.length ? (
<div className={doctors.length ? "mt-3" : ""}>
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{t("services")}
</p>
<ul className="mt-1">
{services.map((service) => (
<li key={service.slug}>
<Link
href={`/services/${service.slug}`}
className="flex items-start justify-between gap-3 rounded-lg px-2 py-2 hover:bg-[var(--neutral-50)]"
onClick={() => setPanel(null)}
>
<span className="text-sm font-medium text-[var(--text)]">{service.title}</span>
<span className="shrink-0 text-sm text-[var(--brand-700)]">
{formatPrice(service.price, locale)}
</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
{hasQuery ? (
<div className="mt-2 flex gap-2 border-t border-[var(--border)] pt-2">
<Link
href={`/doctors?q=${encodeURIComponent(query.trim())}`}
className="rounded-lg px-2 py-1.5 text-xs font-semibold text-[var(--brand-700)] hover:bg-[var(--brand-50)]"
onClick={() => setPanel(null)}
>
{t("allDoctors")}
</Link>
<Link
href={`/services?q=${encodeURIComponent(query.trim())}`}
className="rounded-lg px-2 py-1.5 text-xs font-semibold text-[var(--brand-700)] hover:bg-[var(--brand-50)]"
onClick={() => setPanel(null)}
>
{t("allServices")}
</Link>
</div>
) : null}
</div>
</div>
) : null}
{panel === "a11y" ? (
<div className="absolute right-0 z-50 mt-2">
<AccessibilityMenu />
</div>
) : null}
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter, locales, type Locale } from "@/i18n/routing";
const labels: Record<Locale, string> = {
ru: "RU",
en: "EN",
be: "BE",
tg: "TG",
ky: "KY",
zh: "中文",
};
export function LocaleSwitcher() {
const locale = useLocale() as Locale;
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("a11y");
return (
<label className="inline-flex items-center gap-1.5 text-sm">
<span className="sr-only">{t("language")}</span>
<select
className="h-9 rounded-[10px] border border-[var(--border)] bg-[var(--surface)] px-2 text-sm font-medium text-[var(--neutral-700)]"
value={locale}
aria-label={t("language")}
onChange={(e) => {
router.replace(pathname, { locale: e.target.value as Locale });
}}
>
{locales.map((l) => (
<option key={l} value={l}>
{labels[l]}
</option>
))}
</select>
</label>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
export function Logo({
className,
imgClassName,
showTagline = true,
tagline,
}: {
className?: string;
imgClassName?: string;
showTagline?: boolean;
tagline?: string;
}) {
return (
<Link href="/" className={cn("group flex items-center gap-2.5", className)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="МОНИКИ"
className={cn("h-12 w-12 shrink-0 rounded-full object-contain", imgClassName)}
/>
{showTagline && tagline ? (
<span className="hidden max-w-[7.5rem] text-[11px] leading-tight text-[var(--neutral-500)] xl:block">
{tagline}
</span>
) : null}
</Link>
);
}
@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export function ScrollToTop() {
const t = useTranslations("a11y");
const [visible, setVisible] = useState(false);
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 320);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
function scrollUp() {
const reduce =
document.documentElement.dataset.motion === "reduce" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
}
return (
<button
type="button"
onClick={scrollUp}
aria-label={t("backToTop")}
aria-hidden={!visible}
tabIndex={visible ? 0 : -1}
className={cn(
"fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--brand-600)] text-white shadow-lg shadow-slate-900/20 transition hover:bg-[var(--brand-700)]",
visible ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0",
)}
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
<path
d="M6 14.5 12 8.5l6 6"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
);
}
@@ -0,0 +1,24 @@
import { Link } from "@/i18n/routing";
import { getTranslations } from "next-intl/server";
export async function SectionBackHome() {
const t = await getTranslations("nav");
return (
<Link
href="/"
className="inline-flex items-center gap-1.5 text-sm font-medium uppercase tracking-wide text-[var(--brand-700)] transition hover:opacity-80"
>
<svg viewBox="0 0 24 24" className="size-3.5 shrink-0" fill="none" aria-hidden="true">
<path
d="M4.5 11.2 12 4.8l7.5 6.4M6.2 10.4V19a1 1 0 0 0 1 1h3.3v-5.2h3V20h3.3a1 1 0 0 0 1-1v-8.6"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{t("home")}
</Link>
);
}
@@ -0,0 +1,60 @@
"use client";
import {
THEME_STORAGE_KEY,
applyTheme,
parseTheme,
type ThemePref,
} from "@/lib/theme";
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useState,
type ReactNode,
} from "react";
type ThemeContextValue = {
theme: ThemePref;
setTheme: (theme: ThemePref) => void;
toggle: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemePref>("light");
useLayoutEffect(() => {
const next = parseTheme(localStorage.getItem(THEME_STORAGE_KEY));
setThemeState(next);
applyTheme(next);
}, []);
const setTheme = useCallback((next: ThemePref) => {
setThemeState(next);
localStorage.setItem(THEME_STORAGE_KEY, next);
applyTheme(next);
}, []);
const toggle = useCallback(() => {
setThemeState((current) => {
const next = current === "dark" ? "light" : "dark";
localStorage.setItem(THEME_STORAGE_KEY, next);
applyTheme(next);
return next;
});
}, []);
const value = useMemo(() => ({ theme, setTheme, toggle }), [theme, setTheme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
@@ -0,0 +1,79 @@
"use client";
import { Link } from "@/i18n/routing";
import { COOKIE_CONSENT_KEY } from "@/lib/legal";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
export function CookieBanner() {
const t = useTranslations("cookies");
const [visible, setVisible] = useState(false);
useEffect(() => {
try {
setVisible(localStorage.getItem(COOKIE_CONSENT_KEY) !== "1");
} catch {
setVisible(true);
}
}, []);
function accept() {
try {
localStorage.setItem(COOKIE_CONSENT_KEY, "1");
} catch {
/* ignore quota / private mode */
}
setVisible(false);
}
if (!visible) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 p-4 md:p-6">
<div
role="region"
aria-label={t("aria")}
className="pointer-events-auto mx-auto flex max-w-6xl flex-col gap-4 rounded-2xl bg-[var(--brand-800)] p-4 text-white shadow-xl ring-1 ring-white/15 sm:flex-row sm:items-center sm:gap-5 sm:p-5"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-white/10 text-white ring-1 ring-white/15">
<svg viewBox="0 0 24 24" className="size-5" fill="none" aria-hidden="true">
<path
d="M11.302 21.615c.221.129.332.194.488.227.122.026.298.026.42 0 .156-.034.267-.098.488-.227C14.646 20.478 20 16.908 20 12V7.217c0-.799 0-1.199-.13-1.542a2 2 0 0 0-.548-.79c-.275-.243-.65-.383-1.398-.664l-5.362-2.01c-.208-.078-.312-.117-.419-.133a1 1 0 0 0-.286 0c-.107.016-.21.055-.419.133L6.076 4.22c-.748.28-1.122.421-1.398.664a2 2 0 0 0-.547.79C4 6.018 4 6.418 4 7.217V12c0 4.908 5.354 8.478 7.302 9.615Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<p className="min-w-0 flex-1 text-sm leading-6 text-white/85">
{t.rich("text", {
cookie: (chunks) => (
<Link
href="/useful/cookies"
className="whitespace-nowrap font-semibold text-white underline underline-offset-2 hover:text-white/90"
>
{chunks}
</Link>
),
privacy: (chunks) => (
<Link
href="/useful/privacy"
className="whitespace-nowrap font-semibold text-white underline underline-offset-2 hover:text-white/90"
>
{chunks}
</Link>
),
})}
</p>
<button
type="button"
onClick={accept}
className="inline-flex h-max shrink-0 items-center justify-center rounded-lg bg-white px-3.5 py-2.5 text-sm font-semibold text-[var(--brand-800)] transition hover:bg-white/90"
>
{t("accept")}
</button>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import { SectionBackHome } from "@/components/layout/SectionBackHome";
import { Link } from "@/i18n/routing";
import { getSiteNav } from "@/lib/api";
import { legalPages } from "@/lib/legal";
import { cn } from "@/lib/utils";
import { getLocale, getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
export async function LegalLayout({
slug,
children,
}: {
slug: string;
children: ReactNode;
}) {
const t = await getTranslations("legal");
const locale = await getLocale();
const cmsNav = await getSiteNav("useful", { locale });
const nav = cmsNav.length
? cmsNav
: legalPages.map((page) => ({
slug: page.slug,
href: page.href,
title: t(`${page.navKey}.title`),
}));
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<div className="flex items-center gap-3">
<SectionBackHome />
<p className="text-sm font-medium uppercase tracking-wide text-[var(--brand-700)]">
{t("section")}
</p>
</div>
<div className="mt-6 grid gap-8 lg:grid-cols-[240px_1fr]">
<nav aria-label={t("section")} className="lg:sticky lg:top-24 lg:self-start">
<ul className="space-y-1">
{nav.map((page) => (
<li key={page.slug}>
<Link
href={page.href}
className={cn(
"block rounded-lg px-3 py-2 text-sm font-medium transition",
page.slug === slug
? "bg-[var(--brand-50)] text-[var(--brand-800)]"
: "text-[var(--neutral-700)] hover:bg-[var(--neutral-50)] hover:text-[var(--brand-700)]",
)}
aria-current={page.slug === slug ? "page" : undefined}
>
{page.title}
</Link>
</li>
))}
</ul>
</nav>
<div className="min-w-0">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import { SectionBackHome } from "@/components/layout/SectionBackHome";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import { getSiteNav } from "@/lib/api";
import { organizationPages } from "@/lib/organization";
import { cn } from "@/lib/utils";
import { getLocale, getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
export async function OrganizationLayout({
slug,
children,
}: {
slug: string;
children: ReactNode;
}) {
const t = await getTranslations("organization");
const locale = await getLocale();
const cmsNav = await getSiteNav("organization", { locale });
const nav = cmsNav.length
? cmsNav
: organizationPages.map((page) => ({
slug: page.slug,
href: page.href,
title: t(`${page.navKey}.title`),
}));
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<div className="flex items-center gap-3">
<SectionBackHome />
<p className="text-sm font-medium uppercase tracking-wide text-[var(--brand-700)]">
{t("section")}
</p>
</div>
<div className="mt-6 grid gap-8 lg:grid-cols-[240px_1fr]">
<nav aria-label={t("section")} className="lg:sticky lg:top-24 lg:self-start">
<ul className="space-y-1">
{nav.map((page) => (
<li key={page.slug}>
<Link
href={page.href}
className={cn(
"block rounded-lg px-3 py-2 text-sm font-medium transition",
page.slug === slug
? "bg-[var(--brand-50)] text-[var(--brand-800)]"
: "text-[var(--neutral-700)] hover:bg-[var(--neutral-50)] hover:text-[var(--brand-700)]",
)}
aria-current={page.slug === slug ? "page" : undefined}
>
{page.title}
</Link>
</li>
))}
</ul>
</nav>
<div className="min-w-0">{children}</div>
</div>
</div>
);
}
export function InfoList({ items }: { items: { label: string; value: string }[] }) {
return (
<dl className="divide-y divide-[var(--border)] overflow-hidden rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)]">
{items.map((item) => (
<div key={item.label} className="grid gap-1 px-4 py-3 sm:grid-cols-[220px_1fr] sm:gap-4">
<dt className="text-sm font-medium text-[var(--neutral-500)]">{item.label}</dt>
<dd className="whitespace-pre-line break-words text-[var(--neutral-800)]">{item.value}</dd>
</div>
))}
</dl>
);
}
export function SourceNote({ href, label }: { href: string; label: string }) {
return (
<p className="mt-8 text-sm text-[var(--text-muted)]">
{label}{" "}
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-[var(--brand-700)] hover:underline"
>
monikiweb.ru
</a>
</p>
);
}
export function ComingSoon({ text }: { text: string }) {
return (
<Card className="mt-8 border-dashed bg-[var(--neutral-50)]">
<p className="text-[var(--neutral-700)]">{text}</p>
</Card>
);
}
@@ -0,0 +1,398 @@
import {
ComingSoon,
InfoList,
SourceNote,
} from "@/components/organization/OrganizationLayout";
import { RichText } from "@/components/pages/RichText";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import type { SitePage } from "@/lib/site-pages";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
function PageAnchor({
href,
children,
className,
}: {
href: string;
children: ReactNode;
className?: string;
}) {
if (href.startsWith("/")) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
{children}
</a>
);
}
function PeopleBlock({ page }: { page: SitePage }) {
const hasPhotos = page.people.some((person) => person.photo);
if (hasPhotos) {
return (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{page.people.map((person) => (
<li
key={`${person.name}-${person.position}`}
className="min-w-0 overflow-hidden rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)]"
>
{person.photo ? (
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={person.photo}
alt={person.name}
width={720}
height={960}
className="absolute inset-0 h-full w-full max-w-none object-cover object-center"
/>
</div>
) : null}
<div className="p-4">
{person.position ? (
<p className="break-words text-sm text-[var(--neutral-500)]">{person.position}</p>
) : null}
<h2 className="mt-1 break-words text-base font-semibold text-[var(--neutral-900)]">
{person.name}
</h2>
{person.degree ? (
<p className="mt-1 break-words text-sm text-[var(--neutral-700)]">{person.degree}</p>
) : null}
</div>
</li>
))}
</ul>
);
}
const hasSchedule = page.people.some((person) => person.hours || person.address);
if (!hasSchedule) {
return (
<ul className="space-y-3">
{page.people.map((person) => (
<li key={`${person.name}-${person.position}`}>
<p className="font-medium text-[var(--neutral-900)]">{person.name}</p>
{person.position ? (
<p className="text-sm text-[var(--neutral-600)]">{person.position}</p>
) : null}
</li>
))}
</ul>
);
}
return <ReceptionPeople page={page} />;
}
async function ReceptionPeople({ page }: { page: SitePage }) {
const t = await getTranslations("organization");
return (
<div className="min-w-0">
<ul className="space-y-3 md:hidden">
{page.people.map((row) => (
<li
key={`${row.name}-${row.position}`}
className="rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-4"
>
<p className="whitespace-pre-line font-semibold text-[var(--neutral-900)]">
{row.position}
</p>
<p className="mt-1 text-[var(--neutral-800)]">{row.name}</p>
<dl className="mt-3 space-y-2 text-sm">
<div>
<dt className="text-[var(--neutral-500)]">{t("reception.hours")}</dt>
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">
{row.hours || "—"}
</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">
{row.address || "—"}
</dd>
</div>
</dl>
</li>
))}
</ul>
<div className="hidden overflow-x-auto rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] md:block">
<table className="w-full min-w-[36rem] border-collapse text-left text-sm">
<thead className="bg-[var(--neutral-50)]">
<tr>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("reception.position")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("reception.hours")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("address")}
</th>
</tr>
</thead>
<tbody>
{page.people.map((row) => (
<tr key={`${row.name}-${row.position}`} className="border-t border-[var(--border)] align-top">
<td className="px-4 py-3 text-[var(--neutral-800)]">
<p className="whitespace-pre-line font-medium">{row.position}</p>
<p className="mt-1 whitespace-pre-line">{row.name}</p>
</td>
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
{row.hours || "—"}
</td>
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
{row.address || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
async function CardsBlock({ page }: { page: SitePage }) {
const t = await getTranslations("organization");
return (
<div className="space-y-6">
{page.cards.map((item) => (
<Card key={item.title}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.title}</h2>
{item.status ? (
<p className="text-sm font-medium text-[var(--brand-700)]">{item.status}</p>
) : null}
</div>
{item.role ? <p className="mt-2 text-sm text-[var(--text-muted)]">{item.role}</p> : null}
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
{item.number ? (
<div>
<dt className="text-[var(--neutral-500)]">{t("licenseNumber")}</dt>
<dd className="mt-1 font-medium text-[var(--neutral-800)]">{item.number}</dd>
</div>
) : null}
{item.issuedAt ? (
<div>
<dt className="text-[var(--neutral-500)]">{t("issuedAt")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.issuedAt}</dd>
</div>
) : null}
{item.authority ? (
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("authority")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.authority}</dd>
</div>
) : null}
{item.activity ? (
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("activity")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.activity}</dd>
</div>
) : null}
{item.phone ? (
<div>
<dt className="text-[var(--neutral-500)]">{t("phone")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.phone}</dd>
</div>
) : null}
{item.address ? (
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.address}</dd>
</div>
) : null}
{item.email ? (
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("email")}</dt>
<dd className="mt-1">
<a href={`mailto:${item.email}`} className="text-[var(--brand-700)] hover:underline">
{item.email}
</a>
</dd>
</div>
) : null}
</dl>
{item.extract ? <p className="mt-4 text-sm text-[var(--text-muted)]">{item.extract}</p> : null}
{item.href ? (
<PageAnchor href={item.href} className="mt-4 inline-flex">
{item.linkLabel && !item.number && !item.authority ? (
<span className="text-sm font-medium text-[var(--brand-700)] hover:underline">
{item.linkLabel}
</span>
) : (
<Button variant="outline" size="sm">
{item.linkLabel || item.href}
</Button>
)}
</PageAnchor>
) : null}
</Card>
))}
</div>
);
}
async function TableBlock({ page }: { page: SitePage }) {
const t = await getTranslations("legal.discounts");
return (
<div className="min-w-0 space-y-4">
{page.tableCaption ? (
<p className="text-[var(--neutral-700)]">{page.tableCaption}</p>
) : null}
<ul className="space-y-3 md:hidden">
{page.tableRows.map((row) => (
<li
key={`${row.category}-${row.condition}-${row.discount}`}
className="rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-4"
>
<p className="font-semibold text-[var(--neutral-900)]">{row.category}</p>
{row.condition ? (
<p className="mt-1 text-sm text-[var(--neutral-700)]">{row.condition}</p>
) : null}
<dl className="mt-3 space-y-2 text-sm">
<div>
<dt className="text-[var(--neutral-500)]">{t("discount")}</dt>
<dd className="mt-0.5 font-medium text-[var(--brand-700)]">{row.discount}</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("documents")}</dt>
<dd className="mt-0.5 text-[var(--neutral-800)]">{row.documents}</dd>
</div>
</dl>
</li>
))}
</ul>
<div className="hidden overflow-x-auto rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] md:block">
<table className="w-full min-w-[40rem] border-collapse text-left text-sm">
<caption className="sr-only">{page.tableCaption || t("heading")}</caption>
<thead className="bg-[var(--neutral-50)]">
<tr>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("category")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("discount")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("documents")}
</th>
</tr>
</thead>
<tbody>
{page.tableRows.map((row) => (
<tr
key={`${row.category}-${row.condition}-${row.discount}`}
className="border-t border-[var(--border)] align-top"
>
<th scope="row" className="px-4 py-3 font-medium text-[var(--neutral-800)]">
<p>{row.category}</p>
{row.condition ? (
<p className="mt-1 font-normal text-[var(--neutral-600)]">{row.condition}</p>
) : null}
</th>
<td className="whitespace-nowrap px-4 py-3 font-semibold text-[var(--brand-700)]">
{row.discount}
</td>
<td className="px-4 py-3 text-[var(--neutral-800)]">{row.documents}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export async function CmsPageBody({ page }: { page: SitePage }) {
const t = await getTranslations("organization");
const ctaLabel = page.ctaLabel || page.ctaHref;
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{page.heading}</h1>
{page.subtitle ? (
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{page.subtitle}</p>
) : null}
<div className="mt-8 space-y-8">
{page.tableRows.length === 0 && page.timeline.length === 0 ? (
<RichText value={page.content} />
) : null}
{page.tableRows.length ? <TableBlock page={page} /> : null}
{page.facts.length ? <InfoList items={page.facts} /> : null}
{page.timeline.length ? (
<div>
<h2 className="text-xl font-semibold text-[var(--neutral-900)]">
{page.timelineTitle || t("history")}
</h2>
<ol className="mt-4 space-y-3">
{page.timeline.map((item) => (
<li key={`${item.year}-${item.text}`} className="flex gap-4">
<span className="w-16 shrink-0 font-semibold text-[var(--brand-700)]">{item.year}</span>
<span className="text-[var(--neutral-700)]">{item.text}</span>
</li>
))}
</ol>
</div>
) : null}
{page.timeline.length || page.tableRows.length ? <RichText value={page.content} /> : null}
{page.people.length ? <PeopleBlock page={page} /> : null}
{page.cards.length ? <CardsBlock page={page} /> : null}
{page.faq.length ? (
<div className="space-y-4">
{page.faq.map((item) => (
<Card key={item.question}>
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.question}</h2>
<p className="mt-2 leading-relaxed text-[var(--neutral-700)]">{item.answer}</p>
</Card>
))}
</div>
) : null}
{page.notice ? <ComingSoon text={page.notice} /> : null}
{page.ctaHref && ctaLabel ? (
page.ctaHint ? (
<p className="text-sm text-[var(--text-muted)]">
{page.ctaHint}{" "}
<PageAnchor href={page.ctaHref} className="font-medium text-[var(--brand-700)] hover:underline">
{ctaLabel}
</PageAnchor>
</p>
) : (
<PageAnchor href={page.ctaHref} className="inline-flex">
{page.ctaHref.startsWith("http") && !page.facts.length ? (
<span className="font-medium text-[var(--brand-700)] hover:underline">{ctaLabel}</span>
) : (
<Button variant="outline">{ctaLabel}</Button>
)}
</PageAnchor>
)
) : null}
{page.sourceUrl ? <SourceNote href={page.sourceUrl} label={t("source")} /> : null}
</div>
</>
);
}
@@ -0,0 +1,54 @@
import { Card } from "@/components/ui/Card";
import {
FALLBACK_PUBLIC_CONTRACT_HREF,
getPublicDocuments,
type PublicDocument,
} from "@/lib/api";
import { getTranslations } from "next-intl/server";
function fileLabel(item: PublicDocument) {
if (item.filename) return item.filename;
try {
const path = item.href.split("?")[0];
return decodeURIComponent(path.split("/").pop() || "");
} catch {
return item.href;
}
}
export async function PublicDocumentsList({ locale }: { locale: string }) {
const t = await getTranslations("legal.documentsPage");
const docs = await getPublicDocuments(locale);
const items: PublicDocument[] = docs.length
? docs
: [
{
id: 0,
title: t("contractTitle"),
href: FALLBACK_PUBLIC_CONTRACT_HREF,
filename: "Dogovor-platnie-2026.docx",
},
];
return (
<ul className="space-y-4">
{items.map((item) => (
<li key={`${item.id}-${item.href}`}>
<Card className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.title}</h2>
<p className="mt-1 truncate text-sm text-[var(--text-muted)]">{fileLabel(item)}</p>
</div>
<a
href={item.href}
download={fileLabel(item)}
className="inline-flex h-11 shrink-0 items-center justify-center rounded-[10px] border border-[var(--border)] bg-[var(--surface)] px-4 text-sm font-semibold text-[var(--neutral-800)] transition-colors hover:border-[var(--brand-400)] hover:text-[var(--brand-700)]"
>
{t("download")}
</a>
</Card>
</li>
))}
</ul>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { type ReactNode } from "react";
type LexicalNode = {
type?: string;
tag?: string;
text?: string;
format?: number | string;
url?: string;
listType?: string;
children?: LexicalNode[];
fields?: { url?: string; newTab?: boolean; linkType?: string };
};
const IS_BOLD = 1;
const IS_ITALIC = 2;
const IS_STRIKETHROUGH = 4;
const IS_UNDERLINE = 8;
const IS_CODE = 16;
function formatNumber(format: unknown): number {
return typeof format === "number" ? format : 0;
}
function TextLeaf({ node }: { node: LexicalNode }) {
const text = node.text ?? "";
if (!text) return null;
const format = formatNumber(node.format);
let content: ReactNode = text;
if (format & IS_CODE) content = <code>{content}</code>;
if (format & IS_BOLD) content = <strong>{content}</strong>;
if (format & IS_ITALIC) content = <em>{content}</em>;
if (format & IS_UNDERLINE) content = <u>{content}</u>;
if (format & IS_STRIKETHROUGH) content = <s>{content}</s>;
return content;
}
function linkHref(node: LexicalNode): string {
return node.fields?.url || node.url || "";
}
function Nodes({ nodes }: { nodes?: LexicalNode[] }) {
if (!nodes?.length) return null;
return (
<>
{nodes.map((node, index) => (
<Node key={`${node.type ?? "n"}-${index}`} node={node} />
))}
</>
);
}
function Node({ node }: { node: LexicalNode }) {
if (!node) return null;
if (node.type === "text") return <TextLeaf node={node} />;
if (node.type === "linebreak") return <br />;
if (node.type === "link" || node.type === "autolink") {
const href = linkHref(node);
if (!href) return <Nodes nodes={node.children} />;
const external = href.startsWith("http");
return (
<a
href={href}
className="font-medium text-[var(--brand-700)] hover:underline"
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
<Nodes nodes={node.children} />
</a>
);
}
if (node.type === "listitem") return <li><Nodes nodes={node.children} /></li>;
if (node.type === "list") {
const Tag = node.listType === "number" || node.tag === "ol" ? "ol" : "ul";
return (
<Tag className="list-inside space-y-1 pl-1">
<Nodes nodes={node.children} />
</Tag>
);
}
if (node.type === "heading") {
const tag = node.tag === "h1" || node.tag === "h2" || node.tag === "h3" ? node.tag : "h2";
const className =
tag === "h1"
? "text-2xl font-semibold text-[var(--neutral-900)]"
: "text-xl font-semibold text-[var(--neutral-900)]";
const HeadingTag = tag;
return (
<HeadingTag className={className}>
<Nodes nodes={node.children} />
</HeadingTag>
);
}
if (node.type === "quote") {
return (
<blockquote className="border-l-2 border-[var(--brand-300)] pl-4 text-[var(--neutral-700)]">
<Nodes nodes={node.children} />
</blockquote>
);
}
if (node.type === "paragraph") {
return (
<p>
<Nodes nodes={node.children} />
</p>
);
}
if (node.type === "root") return <Nodes nodes={node.children} />;
return <Nodes nodes={node.children} />;
}
export function RichText({ value }: { value: unknown }) {
if (!value || typeof value !== "object") return null;
const root = (value as { root?: LexicalNode }).root ?? (value as LexicalNode);
if (!root?.children?.length) return null;
return (
<div className="space-y-4 text-lg leading-relaxed text-[var(--neutral-700)]">
<Node node={root} />
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type Parts = {
days: number;
hours: number;
minutes: number;
seconds: number;
};
function split(ms: number): Parts {
const total = Math.max(0, Math.floor(ms / 1000));
return {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
};
}
export function PromoCountdown({
endsAt,
show = true,
}: {
endsAt: string;
show?: boolean;
}) {
const t = useTranslations("promotions");
const end = useMemo(() => new Date(endsAt).getTime(), [endsAt]);
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
if (!show || Number.isNaN(end)) return;
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [end, show]);
if (!show || Number.isNaN(end)) return null;
if (now == null) return <div className="h-[5.5rem]" aria-hidden />;
const remaining = end - now;
if (remaining <= 0) {
return (
<p className="rounded-xl bg-[var(--neutral-100)] px-3 py-2 text-sm font-medium text-[var(--neutral-600)]">
{t("ended")}
</p>
);
}
const parts = split(remaining);
const units: { key: keyof Parts; label: string }[] = [
{ key: "days", label: t("days") },
{ key: "hours", label: t("hours") },
{ key: "minutes", label: t("minutes") },
{ key: "seconds", label: t("seconds") },
];
return (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-[var(--neutral-500)]">{t("until")}</p>
<div className="mt-2 grid grid-cols-4 gap-2" aria-live="polite">
{units.map((unit) => (
<div
key={unit.key}
className={cn(
"rounded-xl border border-[var(--border)] bg-[var(--neutral-50)] px-1 py-2 text-center",
)}
>
<p className="font-mono text-lg font-bold tabular-nums text-[var(--neutral-900)]">
{String(parts[unit.key]).padStart(2, "0")}
</p>
<p className="text-[10px] uppercase tracking-wide text-[var(--neutral-500)]">{unit.label}</p>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { PromoCountdown } from "@/components/promotions/PromoCountdown";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import type { Promotion } from "@/lib/demo-data";
import { formatPrice } from "@/lib/utils";
import { getLocale, getTranslations } from "next-intl/server";
export async function PromotionCard({ promo }: { promo: Promotion }) {
const t = await getTranslations("promotions");
const locale = await getLocale();
const ended = Boolean(promo.endsAt) && new Date(promo.endsAt).getTime() <= Date.now();
const canBook = promo.bookingEnabled && !ended;
const href = (promo.bookingHref || "/booking").startsWith("/") ? promo.bookingHref || "/booking" : "/booking";
return (
<Card className="flex h-full flex-col overflow-hidden p-0">
<div className="relative aspect-[16/9] overflow-hidden bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={promo.image} alt="" className="absolute inset-0 h-full w-full max-w-none object-cover object-center" />
{promo.badge ? (
<Badge variant="brand" className="absolute left-3 top-3 bg-white/95 shadow-sm">
{promo.badge}
</Badge>
) : null}
</div>
<div className="flex flex-1 flex-col p-5">
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{promo.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-[var(--text-muted)]">{promo.description}</p>
<div className="mt-4 rounded-xl bg-[var(--neutral-50)] p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--neutral-500)]">
{t("conditions")}
</p>
<p className="mt-1 whitespace-pre-line text-sm text-[var(--neutral-700)]">{promo.conditions}</p>
</div>
<div className="mt-4 flex flex-wrap items-end gap-3">
<div>
<p className="text-xs text-[var(--neutral-500)]">{t("price")}</p>
<p className="text-2xl font-bold text-[var(--brand-700)]">{formatPrice(promo.price, locale)}</p>
</div>
{promo.oldPrice ? (
<p className="pb-1 text-sm text-[var(--neutral-500)] line-through">
{formatPrice(promo.oldPrice, locale)}
</p>
) : null}
</div>
<div className="mt-4">
<PromoCountdown endsAt={promo.endsAt} show={promo.showCountdown} />
</div>
{promo.bookingEnabled ? (
<div className="mt-5">
{canBook ? (
<Link href={href as "/booking"} className="block">
<Button className="w-full">{promo.ctaLabel || t("book")}</Button>
</Link>
) : (
<Button className="w-full" disabled>
{ended ? t("ended") : promo.ctaLabel || t("book")}
</Button>
)}
</div>
) : null}
</div>
</Card>
);
}
@@ -0,0 +1,37 @@
"use client";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import type { Review } from "@/lib/demo-data";
import { formatDate } from "@/lib/utils";
import { useLocale } from "next-intl";
export function ReviewCard({ review }: { review: Review }) {
const locale = useLocale();
const rating = Math.min(5, Math.max(0, Math.round(Number(review.rating) || 0)));
return (
<Card className="h-full">
<div className="flex items-center gap-1 text-[var(--brand-600)]" aria-label={`${rating}/5`}>
{"★".repeat(rating)}
<span className="text-[var(--neutral-300)]">{"★".repeat(5 - rating)}</span>
</div>
<p className="mt-3 text-[var(--neutral-800)] leading-relaxed">&ldquo;{review.text}&rdquo;</p>
<p className="mt-4 text-sm font-semibold text-[var(--neutral-900)]">{review.author}</p>
<p className="text-xs text-[var(--neutral-500)]">
{review.doctorName ? (
<>
{review.doctorSlug ? (
<Link href={`/doctors/${review.doctorSlug}`} className="hover:text-[var(--brand-700)]">
{review.doctorName}
</Link>
) : (
review.doctorName
)}
{" · "}
</>
) : null}
{formatDate(review.date, locale)}
</p>
</Card>
);
}
@@ -0,0 +1,30 @@
"use client";
import { ReviewCard } from "@/components/reviews/ReviewCard";
import { InfiniteScroll, type InfinitePage } from "@/components/ui/InfiniteScroll";
import type { Review } from "@/lib/demo-data";
export function ReviewsInfinite({
initial,
prefetched,
endpoint,
className,
loadingLabel,
}: {
initial: InfinitePage<Review>;
prefetched?: InfinitePage<Review> | null;
endpoint: string;
className: string;
loadingLabel: string;
}) {
return (
<InfiniteScroll
initial={initial}
prefetched={prefetched}
endpoint={endpoint}
className={className}
loadingLabel={loadingLabel}
renderItem={(review) => <ReviewCard review={review} />}
/>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
type BadgeVariant = "default" | "brand" | "success" | "warning" | "neutral";
const variants: Record<BadgeVariant, string> = {
default: "bg-[var(--neutral-100)] text-[var(--neutral-700)]",
brand: "bg-[var(--brand-50)] text-[var(--brand-700)]",
success: "bg-emerald-50 text-emerald-700",
warning: "bg-amber-50 text-amber-700",
neutral: "bg-[var(--neutral-100)] text-[var(--neutral-600)]",
};
export function Badge({
className,
variant = "default",
...props
}: HTMLAttributes<HTMLSpanElement> & { variant?: BadgeVariant }) {
return (
<span
className={cn(
"inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold",
variants[variant],
className,
)}
{...props}
/>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { cn } from "@/lib/utils";
import { type ButtonHTMLAttributes, forwardRef } from "react";
type Variant = "primary" | "secondary" | "ghost" | "outline" | "danger";
type Size = "sm" | "md" | "lg";
const variants: Record<Variant, string> = {
primary:
"bg-[var(--brand-600)] text-white hover:bg-[var(--brand-700)] shadow-sm shadow-slate-900/10",
secondary: "bg-[var(--brand-50)] text-[var(--brand-800)] hover:bg-[var(--brand-100)]",
ghost: "bg-transparent text-[var(--neutral-700)] hover:bg-[var(--neutral-100)]",
outline:
"border border-[var(--border)] bg-[var(--surface)] text-[var(--neutral-800)] hover:border-[var(--brand-400)] hover:text-[var(--brand-700)]",
danger: "bg-[var(--danger)] text-white hover:bg-red-700",
};
const sizes: Record<Size, string> = {
sm: "h-9 px-3 text-sm gap-1.5",
md: "h-11 px-4 text-sm gap-2",
lg: "h-12 px-6 text-base gap-2",
};
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
size?: Size;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", type = "button", ...props }, ref) => (
<button
ref={ref}
type={type}
className={cn(
"inline-flex items-center justify-center rounded-[10px] font-semibold transition-colors disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
),
);
Button.displayName = "Button";
+22
View File
@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-5",
className,
)}
{...props}
/>
);
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("text-lg font-semibold text-[var(--neutral-900)]", className)} {...props} />;
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("mt-1 text-sm text-[var(--text-muted)]", className)} {...props} />;
}
@@ -0,0 +1,169 @@
"use client";
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
export type InfinitePage<T> = {
items: T[];
page: number;
hasNextPage: boolean;
nextPage: number | null;
};
type Props<T extends { id: string }> = {
initial: InfinitePage<T>;
prefetched?: InfinitePage<T> | null;
endpoint: string;
className: string;
renderItem: (item: T) => ReactNode;
loadingLabel: string;
onPrefetchItems?: (items: T[]) => void;
};
const PREFETCH_MARGIN_PX = 1200;
async function fetchPage<T>(endpoint: string, page: number): Promise<InfinitePage<T>> {
const url = new URL(endpoint, window.location.origin);
url.searchParams.set("page", String(page));
const res = await fetch(`${url.pathname}${url.search}`, { cache: "no-store" });
if (!res.ok) throw new Error("Failed to load page");
const data = (await res.json()) as InfinitePage<T>;
return {
items: data.items ?? [],
page: data.page ?? page,
hasNextPage: Boolean(data.hasNextPage),
nextPage: data.nextPage ?? null,
};
}
function sentinelInRange(node: HTMLElement | null) {
if (!node) return false;
const rect = node.getBoundingClientRect();
return rect.top <= window.innerHeight + PREFETCH_MARGIN_PX;
}
export function InfiniteScroll<T extends { id: string }>({
initial,
prefetched = null,
endpoint,
className,
renderItem,
loadingLabel,
onPrefetchItems,
}: Props<T>) {
const [items, setItems] = useState(initial.items);
const [hasMore, setHasMore] = useState(initial.hasNextPage);
const [loading, setLoading] = useState(false);
const hasMoreRef = useRef(initial.hasNextPage);
const nextPageRef = useRef(initial.nextPage);
const bufferRef = useRef<InfinitePage<T> | null>(
prefetched && prefetched.items.length ? prefetched : null,
);
const prefetchingRef = useRef(false);
const appendingRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const setMore = (value: boolean) => {
hasMoreRef.current = value;
setHasMore(value);
};
const prefetch = useCallback(async () => {
const page = nextPageRef.current;
if (!page || bufferRef.current || prefetchingRef.current) return;
prefetchingRef.current = true;
try {
const data = await fetchPage<T>(endpoint, page);
bufferRef.current = data;
onPrefetchItems?.(data.items);
} catch {
bufferRef.current = null;
} finally {
prefetchingRef.current = false;
}
}, [endpoint, onPrefetchItems]);
const appendNext = useCallback(async () => {
if (appendingRef.current || !hasMoreRef.current) return;
appendingRef.current = true;
try {
if (!bufferRef.current) {
const page = nextPageRef.current;
if (!page) {
setMore(false);
return;
}
setLoading(true);
try {
bufferRef.current = await fetchPage<T>(endpoint, page);
} catch {
setMore(false);
return;
} finally {
setLoading(false);
}
}
const buffered = bufferRef.current;
if (!buffered) return;
bufferRef.current = null;
setItems((current) => {
const seen = new Set(current.map((item) => item.id));
return [...current, ...buffered.items.filter((item) => !seen.has(item.id))];
});
setMore(buffered.hasNextPage);
nextPageRef.current = buffered.nextPage;
} finally {
appendingRef.current = false;
await prefetch();
if (hasMoreRef.current && sentinelInRange(sentinelRef.current)) {
void appendNext();
}
}
}, [endpoint, prefetch]);
const seed = `${endpoint}|${initial.items.map((item) => item.id).join(",")}|${initial.nextPage ?? ""}`;
useEffect(() => {
setItems(initial.items);
hasMoreRef.current = initial.hasNextPage;
setHasMore(initial.hasNextPage);
nextPageRef.current = initial.nextPage;
bufferRef.current = prefetched && prefetched.items.length ? prefetched : null;
prefetchingRef.current = false;
appendingRef.current = false;
if (bufferRef.current) onPrefetchItems?.(bufferRef.current.items);
else void prefetch();
// initial/prefetched are applied when seed changes (query or first-page ids).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seed, onPrefetchItems, prefetch]);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) void appendNext();
},
{ rootMargin: `${PREFETCH_MARGIN_PX}px 0px`, threshold: 0 },
);
observer.observe(node);
if (sentinelInRange(node)) void appendNext();
return () => observer.disconnect();
}, [appendNext]);
return (
<>
<div className={className}>
{items.map((item) => (
<Fragment key={item.id}>{renderItem(item)}</Fragment>
))}
</div>
{hasMore ? (
<div ref={sentinelRef} className="mt-8 flex h-12 items-center justify-center" aria-live="polite">
{loading ? <p className="text-sm text-[var(--text-muted)]">{loadingLabel}</p> : null}
</div>
) : null}
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
import { type InputHTMLAttributes, forwardRef } from "react";
export type InputProps = InputHTMLAttributes<HTMLInputElement> & {
label?: string;
error?: string;
};
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
const inputId = id || props.name;
return (
<label className="flex w-full flex-col gap-1.5 text-sm">
{label ? (
<span className="font-medium text-[var(--neutral-700)]">{label}</span>
) : null}
<input
ref={ref}
id={inputId}
className={cn(
"h-11 w-full rounded-[10px] border border-[var(--border)] bg-[var(--surface)] px-3.5 text-[var(--neutral-900)] outline-none transition placeholder:text-[var(--neutral-400)] focus:border-[var(--brand-500)] focus:ring-2 focus:ring-[var(--brand-200)]",
error && "border-[var(--danger)]",
className,
)}
{...props}
/>
{error ? <span className="text-xs text-[var(--danger)]">{error}</span> : null}
</label>
);
},
);
Input.displayName = "Input";
+43
View File
@@ -0,0 +1,43 @@
import { cn } from "@/lib/utils";
import { type SelectHTMLAttributes, forwardRef } from "react";
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
label?: string;
error?: string;
options: { value: string; label: string }[];
placeholder?: string;
};
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, error, options, placeholder, id, ...props }, ref) => {
const selectId = id || props.name;
return (
<label className="flex w-full flex-col gap-1.5 text-sm">
{label ? (
<span className="font-medium text-[var(--neutral-700)]">{label}</span>
) : null}
<select
ref={ref}
id={selectId}
className={cn(
"h-11 w-full rounded-[10px] border border-[var(--border)] bg-[var(--surface)] px-3.5 text-[var(--neutral-900)] outline-none transition focus:border-[var(--brand-500)] focus:ring-2 focus:ring-[var(--brand-200)]",
error && "border-[var(--danger)]",
className,
)}
{...props}
>
{placeholder ? (
<option value="">{placeholder}</option>
) : null}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error ? <span className="text-xs text-[var(--danger)]">{error}</span> : null}
</label>
);
},
);
Select.displayName = "Select";
+15
View File
@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing, type Locale } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as Locale)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
+14
View File
@@ -0,0 +1,14 @@
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";
export const locales = ["ru", "en", "be", "tg", "ky", "zh"] as const;
export type Locale = (typeof locales)[number];
export const routing = defineRouting({
locales,
defaultLocale: "ru",
localePrefix: "always",
});
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
+59
View File
@@ -0,0 +1,59 @@
export const A11Y_STORAGE_KEY = "moniki-a11y";
export const FONT_SCALES = ["100", "125", "150"] as const;
export const CONTRASTS = ["default", "high"] as const;
export const MOTIONS = ["system", "reduce"] as const;
export type FontScale = (typeof FONT_SCALES)[number];
export type Contrast = (typeof CONTRASTS)[number];
export type MotionPref = (typeof MOTIONS)[number];
export type A11yPrefs = {
fontScale: FontScale;
contrast: Contrast;
motion: MotionPref;
};
export const DEFAULT_A11Y_PREFS: A11yPrefs = {
fontScale: "100",
contrast: "default",
motion: "system",
};
export function isFontScale(value: unknown): value is FontScale {
return FONT_SCALES.includes(value as FontScale);
}
export function isContrast(value: unknown): value is Contrast {
return CONTRASTS.includes(value as Contrast);
}
export function isMotionPref(value: unknown): value is MotionPref {
return MOTIONS.includes(value as MotionPref);
}
export function parseA11yPrefs(raw: string | null): A11yPrefs {
if (!raw) return DEFAULT_A11Y_PREFS;
if (raw === "true") {
return { fontScale: "125", contrast: "high", motion: "reduce" };
}
if (raw === "false") return DEFAULT_A11Y_PREFS;
try {
const parsed = JSON.parse(raw) as Partial<A11yPrefs>;
return {
fontScale: isFontScale(parsed.fontScale) ? parsed.fontScale : DEFAULT_A11Y_PREFS.fontScale,
contrast: isContrast(parsed.contrast) ? parsed.contrast : DEFAULT_A11Y_PREFS.contrast,
motion: isMotionPref(parsed.motion) ? parsed.motion : DEFAULT_A11Y_PREFS.motion,
};
} catch {
return DEFAULT_A11Y_PREFS;
}
}
export function applyA11yPrefs(prefs: A11yPrefs, root: HTMLElement = document.documentElement) {
root.dataset.fontScale = prefs.fontScale;
root.dataset.contrast = prefs.contrast;
root.dataset.motion = prefs.motion;
}
export const A11Y_BOOTSTRAP = `(function(){try{var k=${JSON.stringify(A11Y_STORAGE_KEY)};var raw=localStorage.getItem(k);var p={fontScale:"100",contrast:"default",motion:"system"};if(raw==="true"){p={fontScale:"125",contrast:"high",motion:"reduce"};}else if(raw&&raw!=="false"){var j=JSON.parse(raw);if(j.fontScale)p.fontScale=j.fontScale;if(j.contrast)p.contrast=j.contrast;if(j.motion)p.motion=j.motion;}var r=document.documentElement;r.setAttribute("data-font-scale",p.fontScale);r.setAttribute("data-contrast",p.contrast);r.setAttribute("data-motion",p.motion);}catch(e){}})();`;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { cn, formatDate, formatPrice } from "./utils";
+568
View File
@@ -0,0 +1,568 @@
import importedNews from "./imported-news.json";
import { realDoctors } from "./real-doctors";
export type Service = {
id: string;
slug: string;
title: string;
titleEn?: string;
category: string;
groupTitle?: string;
code?: string;
nmuCode?: string;
price: number;
durationMin: number;
description: string;
featured?: boolean;
};
export type Doctor = {
id: string;
slug: string;
name: string;
specialty: string;
experienceYears: number;
photo: string;
photoPending?: boolean;
bio: string;
rating: number;
reviewsCount: number;
services: string[];
branchIds: string[];
searchText?: string;
};
export type Branch = {
id: string;
slug: string;
name: string;
address: string;
city?: string;
metro?: string;
phone: string;
hours: string;
howToGet?: string;
lat?: number;
lng?: number;
isMain?: boolean;
sortOrder: number;
};
export type Review = {
id: string;
author: string;
rating: number;
text: string;
doctorName?: string;
doctorSlug?: string;
date: string;
};
export type NewsItem = {
id: string;
slug: string;
title: string;
excerpt: string;
body: string;
cover: string;
publishedAt: string;
};
export type GalleryItem = {
id: string;
title: string;
image: string;
category: string;
};
export type HeroBanner = {
id: string;
slug: string;
eyebrow: string;
title: string;
subtitle: string;
phone: string;
address: string;
image: string;
imageAlt: string;
video?: string;
background: string;
ctaPrimaryLabel: string;
ctaPrimaryHref: string;
ctaSecondaryLabel: string;
ctaSecondaryHref: string;
durationMs: number;
sortOrder: number;
};
export type Promotion = {
id: string;
slug: string;
title: string;
badge: string;
description: string;
conditions: string;
price: number;
oldPrice: number | null;
startsAt: string | null;
endsAt: string;
showCountdown: boolean;
hideWhenExpired: boolean;
bookingEnabled: boolean;
ctaLabel: string;
bookingHref: string;
image: string;
sortOrder: number;
};
export type Slot = {
id: string;
start: string;
end: string;
available: boolean;
};
export type Booking = {
id: string;
serviceTitle: string;
doctorName: string;
branchName: string;
datetime: string;
status: "upcoming" | "completed" | "cancelled";
price: number;
paid: boolean;
};
export const demoServices: Service[] = [
{
id: "1",
slug: "konsultatsiya-terapevta",
title: "Консультация терапевта",
titleEn: "Therapist consultation",
category: "Терапия",
price: 3500,
durationMin: 40,
description:
"Первичный приём врача-терапевта: сбор анамнеза, осмотр, рекомендации по обследованиям и лечению.",
featured: true,
},
{
id: "2",
slug: "konsultatsiya-kardiologa",
title: "Консультация кардиолога",
category: "Кардиология",
price: 4500,
durationMin: 45,
description:
"Приём кардиолога с оценкой сердечно-сосудистых рисков и назначением диагностических процедур.",
featured: true,
},
{
id: "3",
slug: "uzi-bryushnoj-polosti",
title: "УЗИ брюшной полости",
category: "Диагностика",
price: 2800,
durationMin: 30,
description: "Ультразвуковое исследование органов брюшной полости на современном оборудовании.",
featured: true,
},
{
id: "4",
slug: "mrt-golovnogo-mozga",
title: "МРТ головного мозга",
category: "Диагностика",
price: 8900,
durationMin: 40,
description: "Магнитно-резонансная томография головного мозга с расшифровкой результата.",
},
{
id: "5",
slug: "analiz-krovi-obshchij",
title: "Общий анализ крови",
category: "Лаборатория",
price: 650,
durationMin: 15,
description: "Клинический анализ крови с лейкоцитарной формулой.",
},
{
id: "6",
slug: "konsultatsiya-nevrologa",
title: "Консультация невролога",
category: "Неврология",
price: 4200,
durationMin: 45,
description: "Приём невролога при головных болях, головокружении, нарушениях чувствительности.",
featured: true,
},
{
id: "7",
slug: "gastroskopiya",
title: "Гастроскопия",
category: "Эндоскопия",
price: 7500,
durationMin: 30,
description: "Эзофагогастродуоденоскопия с возможностью биопсии.",
},
{
id: "8",
slug: "konsultatsiya-endokrinologa",
title: "Консультация эндокринолога",
category: "Эндокринология",
price: 4000,
durationMin: 40,
description: "Диагностика и коррекция заболеваний щитовидной железы, диабета и метаболических нарушений.",
},
];
export const demoDoctors: Doctor[] = realDoctors.map((d, i) => ({
id: String(i + 1),
slug: d.slug,
name: d.fullName,
specialty: [d.specialty, d.degree].filter(Boolean).join(", "),
experienceYears: d.experienceYears,
photo: d.photo,
photoPending: d.photoPending,
bio: d.bio,
rating: 0,
reviewsCount: 0,
services: [],
branchIds: ["main"],
}));
export const demoBranches: Branch[] = [
{
id: "main",
slug: "kdc-moniki",
name: "КДЦ МОНИКИ (основной)",
address:
"г. Москва, ул. Щепкина, 61/2. Станция метро «Проспект Мира», выход к спорткомплексу «Олимпийский».",
city: "Москва",
metro: "Проспект Мира",
phone: "+7 (499) 674-07-09",
hours: "Пн–Сб 08:00–20:00, Вс 09:00–16:00",
howToGet: "От метро «Проспект Мира» пешком по улице Щепкина до корпуса института.",
lat: 55.786637,
lng: 37.627265,
isMain: true,
sortOrder: 10,
},
{
id: "reutov",
slug: "kdc-reutov",
name: "Филиал КДЦ МОНИКИ в г. Реутов",
address: "Московская область, г. Реутов, проспект Мира, д. 53",
city: "Реутов",
phone: "+7 (499) 674-07-09",
hours: "Пн–Сб 08:00–20:00",
howToGet:
"Московская область, г. Реутов, проспект Мира, д. 53. Удобно доехать наземным транспортом от метро «Новокосино» и от станции Реутов.",
lat: 55.771049,
lng: 37.862507,
isMain: false,
sortOrder: 20,
},
];
export const demoReviews: Review[] = [
{
id: "1",
author: "Анна К.",
rating: 5,
text: "Очень внимательный приём, всё объяснили понятно. Записалась онлайн без очередей.",
doctorName: "Камынина О. Д.",
date: "2026-08-12",
},
{
id: "2",
author: "Сергей М.",
rating: 5,
text: "Кардиолог провёл тщательный осмотр, назначил только необходимые обследования.",
doctorName: "Кокотов И. Ю.",
date: "2026-07-28",
},
{
id: "3",
author: "Ольга В.",
rating: 4,
text: "Удобная запись и оплата через СБП. Рекомендую филиал в Реутове.",
date: "2026-07-05",
},
];
export const demoNews: NewsItem[] = importedNews.map((item) => {
const snippet = item.body.replace(/\s+/g, " ").trim();
const excerpt =
item.excerpt && item.excerpt !== item.title
? item.excerpt
: snippet.length > 180
? `${snippet.slice(0, 177).trimEnd()}…`
: snippet;
return {
id: item.id,
slug: item.slug,
title: item.title,
excerpt,
body: item.body,
cover: item.cover,
publishedAt: item.publishedAt,
};
});
export const demoGallery: GalleryItem[] = [
{
id: "1",
title: "Холл клиники",
image:
"https://images.unsplash.com/photo-1519494026892-80bbd2d6fd0d?w=800&h=600&fit=crop",
category: "Интерьер",
},
{
id: "2",
title: "Диагностический кабинет",
image:
"https://images.unsplash.com/photo-1581595220892-b24562b08b6e?w=800&h=600&fit=crop",
category: "Диагностика",
},
{
id: "3",
title: "Лаборатория",
image:
"https://images.unsplash.com/photo-1579154204601-01588f351e67?w=800&h=600&fit=crop",
category: "Лаборатория",
},
{
id: "4",
title: "Кабинет врача",
image:
"https://images.unsplash.com/photo-1666214280557-f1b5022eb634?w=800&h=600&fit=crop",
category: "Приём",
},
{
id: "5",
title: "Зона ожидания",
image:
"https://images.unsplash.com/photo-1516549655169-df83a0774514?w=800&h=600&fit=crop",
category: "Интерьер",
},
{
id: "6",
title: "Оборудование УЗИ",
image:
"https://images.unsplash.com/photo-1551076805-e1869033fa41?w=800&h=600&fit=crop",
category: "Диагностика",
},
];
export const demoBanners: HeroBanner[] = [
{
id: "banner-paid-services",
slug: "paid-services",
eyebrow: "МОНИКИ",
title: "Платные услуги ведущего клинического центра",
subtitle:
"Запишитесь к специалистам ГБУЗ МО МОНИКИ им. М. Ф. Владимирского онлайн — без очередей, с прозрачными ценами.",
phone: "+7 (499) 674-07-09",
address: "ул. Щепкина, 61/2, метро Проспект Мира",
image: "/hero/moscow-doctors.jpg",
imageAlt: "Врач консультирует пациента в московской поликлинике",
video: "/hero/250-white.mp4",
background: "/hero/building-blur.jpg",
ctaPrimaryLabel: "Записаться на приём",
ctaPrimaryHref: "/booking",
ctaSecondaryLabel: "Смотреть услуги",
ctaSecondaryHref: "/services",
durationMs: 7000,
sortOrder: 10,
},
{
id: "banner-diagnostics",
slug: "diagnostics",
eyebrow: "Диагностика",
title: "Комплексное обследование за один день",
subtitle:
"УЗИ, ЭКГ, лабораторные исследования и консультации специалистов КДЦ МОНИКИ — с понятным планом и фиксированными тарифами.",
phone: "+7 (499) 674-07-09",
address: "ул. Щепкина, 61/2, метро Проспект Мира",
image: "/hero/diagnostics.jpg",
imageAlt: "Врач проводит УЗИ-диагностику в клиническом центре МОНИКИ",
background: "/hero/building-blur.jpg",
ctaPrimaryLabel: "Записаться на диагностику",
ctaPrimaryHref: "/booking",
ctaSecondaryLabel: "Прайс услуг",
ctaSecondaryHref: "/services",
durationMs: 7000,
sortOrder: 20,
},
{
id: "banner-specialists",
slug: "specialists",
eyebrow: "Врачи",
title: "Ведущие специалисты института",
subtitle:
"Приём профессоров и врачей высшей категории. Научная школа, клиническая практика и отзывы пациентов МОНИКИ.",
phone: "+7 (499) 674-07-09",
address: "ул. Щепкина, 61/2, метро Проспект Мира",
image: "/hero/specialists.jpg",
imageAlt: "Врачи МОНИКИ в коридоре клинического института",
background: "/hero/building-blur.jpg",
ctaPrimaryLabel: "Смотреть врачей",
ctaPrimaryHref: "/doctors",
ctaSecondaryLabel: "Записаться",
ctaSecondaryHref: "/booking",
durationMs: 7000,
sortOrder: 30,
},
{
id: "banner-branches",
slug: "branches",
eyebrow: "Филиалы",
title: "Два филиала — Москва и Реутов",
subtitle:
"КДЦ на Щепкина, 61/2 у метро «Проспект Мира» и филиал в Реутове. Онлайн-запись и оплата через СБП.",
phone: "+7 (499) 674-07-09",
address: "Москва и Реутов",
image: "/hero/reception.jpg",
imageAlt: "Регистратура консультативно-диагностического центра МОНИКИ",
background: "/hero/building-blur.jpg",
ctaPrimaryLabel: "Как добраться",
ctaPrimaryHref: "/branches",
ctaSecondaryLabel: "Записаться онлайн",
ctaSecondaryHref: "/booking",
durationMs: 7000,
sortOrder: 40,
},
];
export const demoPromotions: Promotion[] = [
{
id: "promo-checkup-health",
slug: "checkup-health",
title: "Чек-ап «Здоровье»",
badge: "−32%",
description:
"Комплексное обследование за один визит: приём терапевта, лабораторный минимум, ЭКГ и заключение с рекомендациями.",
conditions:
"Для новых пациентов КДЦ МОНИКИ. Пакет нельзя разбить на отдельные услуги. Запись только онлайн до окончания акции. Действует в КДЦ на ул. Щепкина, 61/2.",
price: 12900,
oldPrice: 18900,
startsAt: null,
endsAt: "2026-10-12T20:59:00.000Z",
showCountdown: true,
hideWhenExpired: true,
bookingEnabled: true,
ctaLabel: "Записаться на чек-ап",
bookingHref: "/booking",
image: "/promotions/checkup.jpg",
sortOrder: 10,
},
{
id: "promo-mri-brain",
slug: "mri-brain",
title: "МРТ головного мозга",
badge: "Скидка",
description:
"Исследование на экспертном томографе со скидкой при записи через сайт. Результат и описание врача-рентгенолога.",
conditions:
"Без контрастного усиления. Будние дни, 08:00–16:00. Нужно направление или заполненная анкета противопоказаний. Не суммируется с другими скидками.",
price: 7500,
oldPrice: 9900,
startsAt: null,
endsAt: "2026-10-20T20:59:00.000Z",
showCountdown: true,
hideWhenExpired: true,
bookingEnabled: true,
ctaLabel: "Записаться на МРТ",
bookingHref: "/booking",
image: "/promotions/mri.jpg",
sortOrder: 20,
},
{
id: "promo-dental-hygiene",
slug: "dental-hygiene",
title: "Профессиональная гигиена полости рта",
badge: "−29%",
description:
"Снятие налёта, полировка и рекомендации стоматолога. Подходит как самостоятельная процедура и как подготовка к лечению.",
conditions:
"Один пациент — одна процедура по акции. Первичный приём в стоматологии КДЦ. Не распространяется на отбеливание и лечение кариеса.",
price: 3900,
oldPrice: 5500,
startsAt: null,
endsAt: "2026-11-01T20:59:00.000Z",
showCountdown: true,
hideWhenExpired: true,
bookingEnabled: true,
ctaLabel: "Записаться к стоматологу",
bookingHref: "/booking",
image: "/promotions/dental.jpg",
sortOrder: 30,
},
{
id: "promo-cardio-package",
slug: "cardio-package",
title: "Кардиолог + ЭКГ + холтер",
badge: "Пакет",
description:
"Консультация кардиолога, электрокардиограмма и суточное мониторирование в одном пакете — со скидкой при записи онлайн.",
conditions:
"Пакет оказывается целиком, услуги по отдельности по акции не продаются. Холтер выдаётся в день приёма. Действует в КДЦ МОНИКИ (Москва).",
price: 6400,
oldPrice: 8800,
startsAt: null,
endsAt: "2026-11-15T20:59:00.000Z",
showCountdown: true,
hideWhenExpired: true,
bookingEnabled: true,
ctaLabel: "Записаться к кардиологу",
bookingHref: "/booking",
image: "/promotions/cardio.jpg",
sortOrder: 40,
},
];
export const demoBookings: Booking[] = [
{
id: "b1",
serviceTitle: "Консультация терапевта",
doctorName: "Иванова Елена Сергеевна",
branchName: "ул. Щепкина, 61/2",
datetime: "2026-09-20T10:00:00",
status: "upcoming",
price: 3500,
paid: false,
},
{
id: "b2",
serviceTitle: "УЗИ брюшной полости",
doctorName: "—",
branchName: "Филиал КДЦ в Реутове",
datetime: "2026-08-01T14:30:00",
status: "completed",
price: 2800,
paid: true,
},
];
export function generateDemoSlots(date: string): Slot[] {
const hours = [9, 10, 11, 12, 14, 15, 16, 17, 18];
return hours.map((h, i) => {
const start = `${date}T${String(h).padStart(2, "0")}:00:00`;
const end = `${date}T${String(h).padStart(2, "0")}:40:00`;
return {
id: `slot-${date}-${h}`,
start,
end,
available: i % 4 !== 2,
};
});
}
export const specialties = [
{ slug: "consultation", title: "Приёмы и консультации", icon: "stethoscope" },
{ slug: "diagnostics", title: "Инструментальные исследования", icon: "scan" },
{ slug: "lab", title: "Лабораторные исследования", icon: "flask" },
{ slug: "procedure", title: "Манипуляции и процедуры", icon: "activity" },
{ slug: "surgery", title: "Операции", icon: "heart" },
{ slug: "dentistry", title: "Стоматология", icon: "brain" },
];
+71
View File
@@ -0,0 +1,71 @@
export const discountRows = [
{
category: "Ветеранам Великой Отечественной Войны и ветеранам боевых действий",
condition: "",
discount: "15%",
documents: "Удостоверение участника ВОВ или удостоверение участника боевых действий, заявление",
},
{
category: "Инвалидам I и II группы, детям инвалидам до 18 лет",
condition: "",
discount: "15%",
documents: "Справка об инвалидности, заявление",
},
{
category: "Многодетным семьям",
condition: "",
discount: "15%",
documents: "Удостоверение многодетной семьи, заявление",
},
{
category: "Ветеранам труда",
condition: "",
discount: "15%",
documents: "Удостоверение ветерана труда, заявление",
},
{
category:
"Гражданам, получившим или перенесшим лучевую болезнь и другие заболевания, связанные с радиационным воздействием вследствие чернобыльской катастрофы или с работами по ликвидации последствий катастрофы на Чернобыльской АЭС, а также инвалидам вследствие чернобыльской катастрофы",
condition: "",
discount: "10%",
documents:
"Удостоверение пострадавшего от катастрофы на Чернобыльской АЭС, удостоверение участника ликвидации последствий Чернобыльской АЭС, заявление",
},
{
category: "Гражданам, награжденным знаком «Почетный донор»",
condition: "",
discount: "10%",
documents: "Удостоверение о награждении знаком «Почетный донор», заявление",
},
{
category:
"Сотрудникам института, для которых работа в институте является основной, а также их близким родственникам (супругам, родителям, детям, усыновленным, подопечным опекунов, попечителям)",
condition: "",
discount: "15%",
documents:
"Справка с места работы, документ, подтверждающий родство сотрудника (свидетельство о браке, свидетельство о рождении, удостоверение опекуна), заявление",
},
{
category: "Физическим лицам (пациентам, заказчикам)",
condition: "Накопленная сумма оплат медицинских услуг превышает 200 000 руб.",
discount: "7%",
documents: "Договор на оказание платных медицинских услуг, чек об оплате, заявление",
},
{
category: "Физическим лицам (пациентам, заказчикам)",
condition: "Накопленная сумма оплат медицинских услуг превышает 400 000 руб.",
discount: "10%",
documents: "Договор на оказание платных медицинских услуг, чек об оплате, заявление",
},
{
category: "Физическим лицам (пациентам, заказчикам)",
condition: "Накопленная сумма оплат медицинских услуг превышает 500 000 руб.",
discount: "15%",
documents: "Договор на оказание платных медицинских услуг, чек об оплате, заявление",
},
] as const;
export const discountNotes = [
"При наличии двух и более оснований для предоставления скидки возможно применение только одной скидки с максимальным процентом. Скидки не суммируются.",
"Скидки не распространяются на высокотехнологичную медицинскую помощь, дорогостоящие расходные материалы, дорогостоящие лекарственные средства (входящие в перечень жизненно необходимых и важнейших лекарственных препаратов для медицинского применения).",
];
File diff suppressed because one or more lines are too long
+76
View File
@@ -0,0 +1,76 @@
export const COOKIE_CONSENT_KEY = "moniki-cookie-consent";
export const rknOperatorUrl =
"https://pd.rkn.gov.ru/operators-registry/operators-list/?id=10-0090548";
export const legalSlugs = ["documents", "discounts", "cookies", "privacy", "pd-operator"] as const;
export type LegalSlug = (typeof legalSlugs)[number];
export function isLegalSlug(value: string): value is LegalSlug {
return (legalSlugs as readonly string[]).includes(value);
}
export const legalPages: {
slug: LegalSlug;
href: `/useful/${LegalSlug}`;
navKey: "documentsPage" | "discounts" | "cookies" | "privacy" | "pdOperator";
}[] = [
{ slug: "documents", href: "/useful/documents", navKey: "documentsPage" },
{ slug: "discounts", href: "/useful/discounts", navKey: "discounts" },
{ slug: "cookies", href: "/useful/cookies", navKey: "cookies" },
{ slug: "privacy", href: "/useful/privacy", navKey: "privacy" },
{ slug: "pd-operator", href: "/useful/pd-operator", navKey: "pdOperator" },
];
export const pdOperatorFields = [
{ label: "Регистрационный номер", value: "10-0090548" },
{
label: "Дата и основание внесения оператора в реестр",
value: "Приказ № 128 от 11.03.2010",
},
{
label: "Наименование оператора",
value:
'Государственное бюджетное учреждение здравоохранения Московской области "Московский областной научно-исследовательский клинический институт им. М.Ф. Владимирского"',
},
{ label: "ИНН", value: "7702066157" },
{ label: "Юридический адрес", value: "Щепкина ул., 61/2, Москва, 129100" },
{ label: "Дата регистрации уведомления", value: "15.02.2010" },
{
label: "Субъекты РФ, на территории которых происходит обработка персональных данных",
value: "Московская область",
},
{
label: "Цель обработки персональных данных",
value:
"установления медицинского диагноза субъекта ПДн, оказания медицинских, медико-профилактических и медико-социальных услуг (пп. 3, 4 части 2 ст. 10 Закона 152-ФЗ) субъекту ПДн, ведение и актуализация локального сегмента единой государственной информационной системы здравоохранения - Единой медицинской информационно-аналитической системы Московской области, в том числе: формирование и ведение медицинских регистров; оказание услуг населению Московской области по записи на прием к врачу в электронном виде; ведение электронной медицинской карты и электронной истории болезни; сбор, обработка и предоставление статистической информации; исполнения обязательств по хозяйственным договорам с субъектом ПДн (договоры гражданско-правового характера); обработки персональных данных сотрудников (работников) ЛПУ, сведений об их профессиональной служебной деятельности, в том числе: трудовые договоры, повышение квалификации работников; расчет, начисление и выплата заработной платы, премий и иных видов выплат; ведение кадрового учета и личных дел работников ЛПУ в соответствии с ТК РФ; охрана труда и здоровья работников; проверка профессиональной пригодности; воинский учет; реализация социальной политики; рассмотрения обращений граждан.",
},
{
label: "Правовое основание обработки персональных данных",
value:
"ст. 23-24 Конституции Российской Федерации; ч. 4 Гражданского кодекса Российской Федерации от 18.12.2006 г. № 230-ФЗ; ст. 85-90 Трудового кодекса Российской Федерации (Федерального закона от 30.12.2001г. №197-ФЗ), ст. 5-6, ст. 15 Федерального закона от 02.05.2006 г. № 59-ФЗ «О порядке рассмотрения обращений граждан Российской Федерации»; Федеральный закон от 27.07.2010 № 210-ФЗ «Об организации предоставления государственных и муниципальных услуг»; Федеральным законом от 27.07.2006 г. № 152-ФЗ «О персональных данных»; ст. 6-9, ст. 16-17 Федерального закона от 27.07.2006 г. № 149-ФЗ «Об информатизации, информационных технологиях и о защите информации»; ст. 28, ст. 32 Федерального закона от 24.07.2009 г. № 212-ФЗ «О страховых взносах в Пенсионный фонд Российской Федерации, Фонд социального страхования Российской Федерации, Федеральный фонд обязательного медицинского страхования»; ст. 4 главы 1, ст. 13 главы 2, ст. 14-16 главы 3, глава 4, ст. 55 главы 5, ст. 51, ст. 53-54, ст. 59 главы 6, ст. 91-94, ст. 97 Федерального закона от 21.11.2011 г. № 323-ФЗ «Об основах охраны здоровья граждан в Российской Федерации»; Федеральным законом от 05.04.2013 N 44-ФЗ \"О контрактной системе в сфере закупок товаров, работ, услуг для обеспечения государственных и муниципальных нужд\"; Постановлением Правительства Российской Федерации от 16.04.2003 г. № 225 «О трудовых книжках»; Постановлением Правительства Российской Федерации от 27.11.2006 г. № 719 «Об утверждении Положения о воинском учете»; Постановлением Правительства Российской Федерации от 15.09. 2008 г. № 687 «Об утверждении Положения об особенностях обработки персональных данных, осуществляемой без использования средств автоматизации»; Постановлением Правительства Российской Федерации от 01.11. 2012 г. № 1119 «Об утверждении требований к защите персональных данных при их обработке в информационных системах персональных данных»; Приказом Минздравсоцразвития России от 28.02.2011 N 158н (ред. от 06.08.2015)\"Об утверждении Правил обязательного медицинского страхования\"; Приказом Министерства здравоохранения Московской области от 01.02.2008 № 30 \"О ведении Московского областного регистра лиц, имеющих право на получение государственной социальной поддержки в части лекарственного обеспечения за счет средств бюджета Московской области\" (с изменениями, внесенными приказом Министерства здравоохранения Московской области от 09.03.2011 N 180);",
},
{
label: "Описание мер, предусмотренных ст. 18.1 и 19 Закона",
value:
"1. Меры, направленные на обеспечение выполнения обязанностей, предусмотренных ФЗ «О персональных данных» 1.1. Назначен ответственный за организацию обработки персональных данных; 1.2. Разработаны и утверждены документы, определяющие политику УЗМО в отношении обработки персональных данных, а также локальные акты по вопросам обработки персональных данных, а также локальные акты, устанавливающие процедуры, направленные на предотвращение и выявление нарушений законодательства Российской Федерации, устранение последствий таких нарушений; 1.3. Применяются организационные и технические меры, обеспечивающие уставленные уровни защищённости персональных данных при их обработке в информационных системах персональных данных УЗМО в соответствии с требованиями, утверждёнными постановлением Правительства Российской Федерации от 1 ноября 2012 г. N 1119; 1.4. Осуществляется внутренний контроль соответствия обработки персональных данных, требованиям ФЗ «О персональных данных», принятым в соответствии с ним нормативным правовым актам, локальным актам УЗМО, требованиям к защите персональных данных и политике УЗМО в отношении обработки персональных данных; 1.5. Проведена оценка вреда, который может быть причинён субъектам персональных данных в случае нарушения ФЗ «О персональных данных»; 1.6. Работники УЗМО, осуществляющие обработку персональных данных, ознакомлены с положениями законодательства Российской Федерации о персональных данных, в том числе требованиями к защите персональных данных, документами, определяющими политику УЗМО в отношении обработки персональных данных, локальными актами по вопросам обработки персональных данных; 1.7. Документ определяющий политику УЗМО в отношении обработки персональных данных опубликован на официальном сайте УЗМО. 2. Меры по обеспечению безопасности персональных данных: 2.1. Определены угрозы безопасности персональных данных при их обработке в информационных системах персональных данных УЗМО; 2.2. Применяются организационные и технические меры, обеспечивающие уставленные уровни защищённости персональных данных при их обработке в информационных системах персональных данных УЗМО в соответствии с требованиями, утверждёнными постановлением Правительства Российской Федерации от 1 ноября 2012 г. N 1119; 2.3. Для обеспечения безопасности персональных данных применяются средства защиты информации, прошедшие в установленном порядке процедуру оценки соответствия;",
},
{
label:
"ФИО физического лица или наименование юридического лица, ответственных за организацию обработки персональных данных",
value: "Подойницын Алексей Алексеевич",
},
{
label: "номера их контактных телефонов, почтовые адреса и адреса электронной почты",
value: "(495)-631-72-37\n129110, г. Москва, ул. Щепкина, д. 61/2\na.podoinitsyn@monikiweb.ru",
},
{ label: "Дата начала обработки персональных данных", value: "30.08.2001" },
{
label: "Срок или условие прекращения обработки персональных данных",
value: "прекращение полномочий, ликвидация юридического лица",
},
{
label: "Дата и основание внесения записи в реестр",
value: "Приказ № 111 от 19.07.2016",
},
];

Some files were not shown because too many files have changed in this diff Show More