Initial release of Moniki paid services site and CMS (v1.0.0).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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()}` });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">“{review.text}”</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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
export { cn, formatDate, formatPrice } from "./utils";
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user