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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexey Novikov
2026-09-16 13:10:25 +03:00
co-authored by Cursor
commit 8abd8d06dd
263 changed files with 24973 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_CMS_URL=http://localhost:3001
+8
View File
@@ -0,0 +1,8 @@
.node_modules
.next
out
dist
.env*.local
!.env.example
*.tsbuildinfo
next-env.d.ts
+17
View File
@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
devIndicators: false,
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
{ protocol: "http", hostname: "localhost", port: "3001" },
{ protocol: "http", hostname: "193.124.91.99", port: "3001" },
],
},
};
export default withNextIntl(nextConfig);
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@moniki/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"@untitledui/icons": "^0.0.19",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"next": "15.3.9",
"next-intl": "^4.1.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.0",
"zod": "^3.25.28"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.7",
"@types/node": "^22.15.21",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"tailwindcss": "^4.1.7",
"typescript": "^5.8.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Download official MONIKI leadership photos and normalize them to 720x960 JPEG."""
from __future__ import annotations
import io
import urllib.request
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "public" / "organization" / "leaders"
TARGET_W, TARGET_H = 720, 960
RATIO = TARGET_W / TARGET_H
LEADERS = [
("sobolev", "https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Sobolev.JPG"),
("gameeva", "https://www.monikiweb.ru/sites/default/files/page_content_files/GameevaEV2026-07.jpg"),
("stepanova", "https://www.monikiweb.ru/sites/default/files/page_content_files/StepanovaAM.jpg"),
("kakorina", "https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Kakorina_E_P.jpg"),
("mozhaev", "https://www.monikiweb.ru/sites/default/files/page_content_files/MozhaevYA.jpg"),
("chernyavskaya", "https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Chernyavskaya_T_K.jpg"),
("kononova", "https://www.monikiweb.ru/sites/default/files/page_content_files/KononovaIV.jpg"),
("bogdan", "https://www.monikiweb.ru/sites/default/files/page_content_files/BogdanAA.png"),
("lopotovsky", "https://www.monikiweb.ru/sites/default/files/page_content_files/LopotovskyP.jpg"),
("varlamova", "https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Varlamova_L_S.JPG"),
("ermolaeva", "https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Ermolaeva_A_V.JPG"),
("shikina", "https://www.monikiweb.ru/sites/default/files/page_content_files/Shikina.JPG"),
("padalko", "https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Zam.dir.po_kadram-small.jpg"),
("simonenkov", "https://www.monikiweb.ru/sites/default/files/simanenkov.JPG"),
("khizgiaev", None),
("lobanova", "https://www.monikiweb.ru/sites/default/files/page_content_files/LobanovaOS.jpg.jfif"),
("zyuzina", "https://www.monikiweb.ru/sites/default/files/page_content_files/photozyzina.jpg"),
("averin", "https://www.monikiweb.ru/sites/default/files/page_content_files/AverinAA.jpg"),
("arkhipova", "https://www.monikiweb.ru/sites/default/files/page_content_files/Arkhipova.jpg"),
("babaeva", "https://www.monikiweb.ru/sites/default/files/page_content_files/BabaevaLA.jpg"),
("doronina", "https://www.monikiweb.ru/sites/default/files/page_content_files/DoroninaOV.jpg"),
("kanina", "https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Kanina_E_V.JPG"),
("kuidin", "https://www.monikiweb.ru/sites/default/files/page_content_files/KuidinDV.jpg"),
]
KHIZGIAEV_CANDIDATES = [
"https://www.monikiweb.ru/sites/default/files/page_content_files/Hizgiaev.jpg",
"https://www.monikiweb.ru/sites/default/files/page_content_files/Hizgiyaev.jpg",
"https://www.monikiweb.ru/sites/default/files/page_content_files/Khizgiaev.jpg",
"https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Hizgiaev.jpg",
"https://www.monikiweb.ru/sites/default/files/page_content_files/!!!Hizgiaev_V_I.jpg",
"https://www.monikiweb.ru/sites/default/files/page_content_files/HizgiaevVI.jpg",
]
def fetch(url: str) -> bytes | None:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
try:
with urllib.request.urlopen(req, timeout=60) as res:
if res.status != 200:
return None
data = res.read()
if not data:
return None
return data
except Exception as exc: # noqa: BLE001
print(f" skip {url}: {exc}")
return None
def normalize(image: Image.Image) -> Image.Image:
image = ImageOps.exif_transpose(image).convert("RGB")
width, height = image.size
if width / height > RATIO:
crop_w = int(height * RATIO)
left = max((width - crop_w) // 2, 0)
image = image.crop((left, 0, left + crop_w, height))
else:
crop_h = int(width / RATIO)
image = image.crop((0, 0, width, min(crop_h, height)))
return image.resize((TARGET_W, TARGET_H), Image.Resampling.LANCZOS)
def save_jpeg(image: Image.Image, path: Path) -> None:
image.save(path, "JPEG", quality=84, optimize=True, progressive=True)
def placeholder(initials: str) -> Image.Image:
image = Image.new("RGB", (TARGET_W, TARGET_H), (232, 240, 232))
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
try:
font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 140)
except OSError:
pass
bbox = draw.textbbox((0, 0), initials, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((TARGET_W - tw) / 2, (TARGET_H - th) / 2), initials, fill=(15, 81, 50), font=font)
return image
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
for slug, url in LEADERS:
out = OUT_DIR / f"{slug}.jpg"
print(slug)
data = None
if slug == "khizgiaev":
for candidate in KHIZGIAEV_CANDIDATES:
data = fetch(candidate)
if data:
print(f" found {candidate}")
break
elif url:
data = fetch(url)
if data:
image = Image.open(io.BytesIO(data))
save_jpeg(normalize(image), out)
print(f" wrote {out.name} {out.stat().st_size} bytes")
else:
save_jpeg(placeholder("ВИХ"), out)
print(f" placeholder {out.name}")
if __name__ == "__main__":
main()
@@ -0,0 +1,44 @@
import { BookingWizard } from "@/components/booking/BookingWizard";
import { getBranches, getDoctors } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "booking");
}
export default async function BookingPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ service?: string; doctor?: string; branch?: string }>;
}) {
const { locale } = await params;
const sp = await searchParams;
setRequestLocale(locale);
const t = await getTranslations("booking");
const [doctors, branches] = await Promise.all([getDoctors({ locale }), getBranches(locale)]);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-center text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mx-auto mt-2 max-w-xl text-center text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-10">
<BookingWizard
doctors={doctors}
branches={branches}
initialServiceId={sp.service}
initialDoctorId={sp.doctor}
initialBranchId={sp.branch}
/>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
import { YandexBranchMap } from "@/components/branches/YandexBranchMap";
import { Badge } from "@/components/ui/Badge";
import { Link } from "@/i18n/routing";
import { getBranchBySlug } from "@/lib/api";
import { hasCoordinates } from "@/lib/yandex-maps";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const branch = await getBranchBySlug(slug, locale);
if (!branch) return {};
return {
title: branch.name,
description: branch.address,
};
}
export default async function BranchDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("branches");
const branch = await getBranchBySlug(slug, locale);
if (!branch) notFound();
return (
<div className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<Link href="/branches" className="text-sm font-medium text-[var(--brand-700)]">
← {t("back")}
</Link>
<div className="mt-6 flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold text-[var(--text)]">{branch.name}</h1>
{branch.isMain ? <Badge variant="brand">{t("main")}</Badge> : null}
</div>
<p className="mt-3 text-lg text-[var(--text-muted)]">{branch.address}</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
{branch.metro ? (
<p className="text-sm text-[var(--text)]">
<span className="font-medium text-[var(--text-muted)]">{t("metro")}: </span>
{branch.metro}
</p>
) : null}
<p className="text-sm text-[var(--text)]">
<span className="font-medium text-[var(--text-muted)]">{t("phone")}: </span>
<a href={`tel:${branch.phone.replace(/[^\d+]/g, "")}`} className="font-semibold text-[var(--brand-700)]">
{branch.phone}
</a>
</p>
{branch.hours ? (
<p className="text-sm text-[var(--text)] sm:col-span-2">
<span className="font-medium text-[var(--text-muted)]">{t("hours")}: </span>
{branch.hours}
</p>
) : null}
</div>
{branch.howToGet ? (
<section className="mt-10">
<h2 className="text-xl font-semibold text-[var(--text)]">{t("howToGet")}</h2>
<p className="mt-3 whitespace-pre-line leading-relaxed text-[var(--text-muted)]">{branch.howToGet}</p>
</section>
) : null}
{typeof branch.lat === "number" &&
typeof branch.lng === "number" &&
hasCoordinates(branch.lat, branch.lng) ? (
<section className="mt-10">
<h2 className="text-xl font-semibold text-[var(--text)]">{t("map")}</h2>
<div className="mt-4">
<YandexBranchMap
lat={branch.lat}
lng={branch.lng}
title={branch.name}
mapLabel={t("map")}
navigatorLabel={t("openNavigator")}
coordinatesLabel={t("coordinates")}
/>
</div>
</section>
) : null}
<div className="mt-10">
<Link href={`/booking?branch=${branch.id}`}>
<span className="inline-flex h-12 items-center justify-center rounded-[10px] bg-[var(--brand-600)] px-6 text-base font-semibold text-white shadow-sm shadow-slate-900/10 hover:bg-[var(--brand-700)]">
{t("book")}
</span>
</Link>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { BranchCard } from "@/components/branches/BranchCard";
import { getBranches } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "branches");
}
export default async function BranchesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("branches");
const branches = await getBranches(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--text)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-8 grid gap-6 md:grid-cols-2">
{branches.map((branch) => (
<BranchCard
key={branch.id}
branch={branch}
metroLabel={t("metro")}
hoursLabel={t("hours")}
phoneLabel={t("phone")}
mainLabel={t("main")}
detailsLabel={t("details")}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { BookingActions } from "@/components/cabinet/BookingActions";
import { getMyBookings } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetBookingsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const bookings = (await getMyBookings()).filter((b) => b.status === "upcoming");
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("bookings")}</h2>
{bookings.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyBookings")}</p>
) : (
<div className="space-y-4">
{bookings.map((b) => (
<BookingActions key={b.id} booking={b} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,18 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetDocumentsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("documents")}</h2>
<p className="text-[var(--text-muted)]">{t("emptyDocuments")}</p>
</div>
);
}
@@ -0,0 +1,33 @@
import { Card } from "@/components/ui/Card";
import { getFavorites } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetFavoritesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const favorites = await getFavorites();
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("favorites")}</h2>
{favorites.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyFavorites")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{favorites.map((item, i) => (
<Card key={"id" in item ? item.id : i}>
<p className="font-semibold">
{"title" in item ? item.title : "name" in item ? item.name : "—"}
</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,29 @@
import { BookingActions } from "@/components/cabinet/BookingActions";
import { getMyBookings } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetHistoryPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const bookings = (await getMyBookings()).filter((b) => b.status !== "upcoming");
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("history")}</h2>
{bookings.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyBookings")}</p>
) : (
<div className="space-y-4">
{bookings.map((b) => (
<BookingActions key={b.id} booking={b} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { Card } from "@/components/ui/Card";
import { getMyBookings } from "@/lib/api";
import { formatPrice } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetInvoicesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const bookings = await getMyBookings();
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("invoices")}</h2>
{bookings.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyInvoices")}</p>
) : (
<div className="space-y-3">
{bookings.map((b) => (
<Card key={b.id} className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold">{b.serviceTitle}</p>
<p className="text-sm text-[var(--text-muted)]">{b.datetime}</p>
</div>
<div className="text-right">
<p className="font-bold text-[var(--brand-700)]">{formatPrice(b.price, locale)}</p>
<p className="text-xs text-[var(--neutral-500)]">{b.paid ? t("paid") : t("unpaid")}</p>
</div>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,37 @@
import { CabinetNav } from "@/components/cabinet/CabinetNav";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "cabinet");
}
export default async function CabinetLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<div className="mt-8 grid gap-8 md:grid-cols-[220px_1fr]">
<aside>
<CabinetNav />
</aside>
<div>{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { ProfileForm } from "@/components/cabinet/ProfileForm";
import { me } from "@/lib/api";
import { Link } from "@/i18n/routing";
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetProfilePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("cabinet");
const auth = await getTranslations("auth");
const user = await me();
if (!user) {
return (
<div className="rounded-[12px] border border-[var(--border)] bg-white p-6">
<p className="text-[var(--text-muted)]">{auth("loginTitle")}</p>
<Link href="/login" className="mt-3 inline-block font-semibold text-[var(--brand-700)]">
{auth("login")}
</Link>
</div>
);
}
return (
<div>
<h2 className="mb-4 text-xl font-semibold">{t("profile")}</h2>
<ProfileForm user={user} />
</div>
);
}
@@ -0,0 +1,88 @@
import { DoctorPhoto } from "@/components/doctors/DoctorPhoto";
import { ReviewsInfinite } from "@/components/reviews/ReviewsInfinite";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import { DOCTOR_REVIEWS_PAGE_SIZE, getDoctorBySlug, getReviewsPage } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const doctor = await getDoctorBySlug(slug, locale);
if (!doctor) return {};
return {
title: doctor.name,
description: doctor.specialty,
};
}
export default async function DoctorDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("doctors");
const tr = await getTranslations("reviews");
const loadingLabel = (await getTranslations("common"))("loading");
const doctor = await getDoctorBySlug(slug, locale);
if (!doctor) notFound();
const [initial, prefetched] = await Promise.all([
getReviewsPage({ doctorId: doctor.id, page: 1, limit: DOCTOR_REVIEWS_PAGE_SIZE, locale }),
getReviewsPage({ doctorId: doctor.id, page: 2, limit: DOCTOR_REVIEWS_PAGE_SIZE, locale }),
]);
return (
<div className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<div className="grid gap-8 md:grid-cols-[280px_1fr]">
<DoctorPhoto
src={doctor.photo}
alt={doctor.name}
pending={doctor.photoPending}
pendingLabel={t("photoPending")}
className="rounded-[20px] aspect-[3/4]"
/>
<div>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{doctor.name}</h1>
<p className="mt-2 text-lg text-[var(--text-muted)]">{doctor.specialty}</p>
<div className="mt-4 flex flex-wrap gap-2">
<Badge variant="brand">
{doctor.experienceYears} {t("experience")}
</Badge>
<Badge variant="neutral">
★ {doctor.rating} · {doctor.reviewsCount} {t("reviews")}
</Badge>
</div>
<h2 className="mt-8 text-xl font-semibold">{t("bio")}</h2>
<p className="mt-3 leading-relaxed text-[var(--neutral-700)]">{doctor.bio}</p>
<div className="mt-8">
<Link href={`/booking?doctor=${doctor.id}`}>
<Button size="lg">{t("book")}</Button>
</Link>
</div>
</div>
</div>
<section className="mt-14">
<h2 className="text-2xl font-bold">{tr("title")}</h2>
{initial.items.length === 0 ? (
<p className="mt-4 text-[var(--text-muted)]">{tr("empty")}</p>
) : (
<ReviewsInfinite
initial={initial}
prefetched={initial.hasNextPage ? prefetched : null}
endpoint={`/api/reviews?limit=${DOCTOR_REVIEWS_PAGE_SIZE}&doctorId=${encodeURIComponent(doctor.id)}&locale=${encodeURIComponent(locale)}`}
className="mt-6 grid gap-4 md:grid-cols-2"
loadingLabel={loadingLabel}
/>
)}
</section>
</div>
);
}
@@ -0,0 +1,53 @@
import { DoctorsInfinite } from "@/components/doctors/DoctorsInfinite";
import { DOCTORS_PAGE_SIZE, getDoctorsPage } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "doctors");
}
export default async function DoctorsPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ q?: string }>;
}) {
const { locale } = await params;
const { q = "" } = await searchParams;
setRequestLocale(locale);
const t = await getTranslations("doctors");
const loadingLabel = (await getTranslations("common"))("loading");
const query = q.trim();
const [initial, prefetched] = await Promise.all([
getDoctorsPage({ page: 1, limit: DOCTORS_PAGE_SIZE, q: query, locale }),
getDoctorsPage({ page: 2, limit: DOCTORS_PAGE_SIZE, q: query, locale }),
]);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
<DoctorsInfinite
initial={initial}
prefetched={initial.hasNextPage ? prefetched : null}
initialQuery={query}
loadingLabel={loadingLabel}
labels={{
photoPending: t("photoPending"),
experience: t("experience"),
reviews: t("reviews"),
bio: t("bio"),
book: t("book"),
}}
/>
</div>
);
}
@@ -0,0 +1,46 @@
import { getGallery } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "gallery");
}
export default async function GalleryPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("gallery");
const items = await getGallery(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
{items.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-8 columns-1 gap-4 sm:columns-2 lg:columns-3">
{items.map((item) => (
<figure key={item.id} className="mb-4 break-inside-avoid overflow-hidden rounded-[16px]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.image} alt={item.title} className="mx-auto block w-full object-cover object-center" />
<figcaption className="bg-white px-3 py-2 text-sm text-[var(--neutral-600)]">
{item.title} · {item.category}
</figcaption>
</figure>
))}
</div>
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { AccessibilityProvider } from "@/components/a11y/AccessibilityProvider";
import { RouteFocus } from "@/components/a11y/RouteFocus";
import { SkipLink } from "@/components/a11y/SkipLink";
import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { ScrollToTop } from "@/components/layout/ScrollToTop";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { CookieBanner } from "@/components/legal/CookieBanner";
import { A11Y_BOOTSTRAP } from "@/lib/a11y";
import { THEME_BOOTSTRAP } from "@/lib/theme";
import { routing, type Locale } from "@/i18n/routing";
import { Manrope } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import "../globals.css";
const manrope = Manrope({
subsets: ["latin", "cyrillic"],
variable: "--font-manrope",
display: "swap",
});
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
return {
title: {
default: `${t("siteName")} — ${t("tagline")}`,
template: `%s · ${t("siteName")}`,
},
description: t("fullName"),
icons: {
icon: "/logo.png",
apple: "/logo.png",
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) notFound();
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale} className={manrope.variable} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: `${A11Y_BOOTSTRAP}${THEME_BOOTSTRAP}` }} />
</head>
<body className="flex min-h-screen flex-col antialiased">
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<AccessibilityProvider>
<SkipLink />
<RouteFocus />
<Header />
<main id="main-content" tabIndex={-1} className="flex-1 outline-none">
{children}
</main>
<Footer />
<CookieBanner />
<ScrollToTop />
</AccessibilityProvider>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { LoginForm } from "@/components/auth/LoginForm";
import { namedPageMetadata } from "@/lib/page-metadata";
import { setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "auth", "loginTitle");
}
export default async function LoginPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex max-w-6xl justify-center px-4 py-16 sm:px-6">
<LoginForm />
</div>
);
}
@@ -0,0 +1,50 @@
import { Link } from "@/i18n/routing";
import { getNewsBySlug } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const item = await getNewsBySlug(slug, locale);
if (!item) return {};
return {
title: item.title,
description: item.body.slice(0, 160),
};
}
export default async function NewsDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("news");
const item = await getNewsBySlug(slug, locale);
if (!item) notFound();
return (
<article className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<Link href="/news" className="text-sm font-medium text-[var(--brand-700)]">
← {t("back")}
</Link>
<p className="mt-6 text-sm text-[var(--neutral-500)]">{formatDate(item.publishedAt, locale)}</p>
<h1 className="mt-2 text-3xl font-bold text-[var(--neutral-900)]">{item.title}</h1>
{item.cover ? (
<div className="relative mt-8 aspect-[16/9] overflow-hidden rounded-[20px] bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.cover} alt="" className="absolute inset-0 h-full w-full max-w-none object-cover object-center" />
</div>
) : null}
<div className="mt-8 whitespace-pre-line text-lg leading-relaxed text-[var(--neutral-700)]">
{item.body}
</div>
</article>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import { getNews } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { formatDate } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "news");
}
export default async function NewsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("news");
const news = await getNews(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
{news.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{news.map((item) => (
<Link key={item.id} href={`/news/${item.slug}`}>
<Card className="h-full overflow-hidden p-0 transition hover:border-[var(--brand-300)]">
{item.cover ? (
<div className="relative aspect-[16/10] overflow-hidden bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.cover} alt="" className="absolute inset-0 h-full w-full max-w-none object-cover object-center" />
</div>
) : null}
<div className="p-5">
<p className="text-xs text-[var(--neutral-500)]">{formatDate(item.publishedAt, locale)}</p>
<h2 className="mt-2 text-lg font-semibold">{item.title}</h2>
<p className="mt-2 line-clamp-3 text-sm text-[var(--text-muted)]">{item.excerpt}</p>
<p className="mt-3 text-sm font-medium text-[var(--brand-700)]">{t("readMore")}</p>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Link } from "@/i18n/routing";
export default function NotFound() {
return (
<div className="mx-auto flex max-w-lg flex-col items-center px-4 py-24 text-center">
<p className="text-5xl font-bold text-[var(--brand-600)]">404</p>
<p className="mt-4 text-lg text-[var(--neutral-700)]">Страница не найдена</p>
<Link
href="/"
className="mt-6 rounded-[10px] bg-[var(--brand-600)] px-5 py-2.5 text-sm font-semibold text-white"
>
МОНИКИ
</Link>
</div>
);
}
@@ -0,0 +1,362 @@
import {
ComingSoon,
InfoList,
OrganizationLayout,
SourceNote,
} from "@/components/organization/OrganizationLayout";
import { CmsPageBody } from "@/components/pages/CmsPageBody";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Link } from "@/i18n/routing";
import {
faqItems,
instituteFacts,
instituteTimeline,
isOrganizationSlug,
leadership,
licenses,
officialLinks,
organizationPages,
receptionSchedule,
requisites,
supervisors,
type OrganizationSlug,
} from "@/lib/organization";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getSitePage } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export function generateStaticParams() {
return organizationPages.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const page = await getSitePage("organization", slug, locale);
if (page) {
return { title: page.heading || page.title, description: page.subtitle || undefined };
}
if (!isOrganizationSlug(slug)) return {};
return namedPageMetadata(locale, "organization", `${slug}.title`);
}
export default async function OrganizationPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const cmsPage = await getSitePage("organization", slug, locale);
if (cmsPage) {
return (
<OrganizationLayout slug={slug}>
<CmsPageBody page={cmsPage} />
</OrganizationLayout>
);
}
if (!isOrganizationSlug(slug)) notFound();
return (
<OrganizationLayout slug={slug}>
<OrganizationBody slug={slug} />
</OrganizationLayout>
);
}
async function OrganizationBody({ slug }: { slug: OrganizationSlug }) {
const t = await getTranslations("organization");
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t(`${slug}.title`)}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t(`${slug}.subtitle`)}</p>
<div className="mt-8">
{slug === "institute" ? <InstituteContent /> : null}
{slug === "leaders" ? <LeadersContent /> : null}
{slug === "license" ? <LicenseContent /> : null}
{slug === "requisites" ? <RequisitesContent /> : null}
{slug === "supervision" ? <SupervisionContent /> : null}
{slug === "reception" ? <ReceptionContent /> : null}
{slug === "faq" ? <FaqContent /> : null}
</div>
</>
);
}
async function InstituteContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-8">
<InfoList items={instituteFacts} />
<div>
<h2 className="text-xl font-semibold text-[var(--neutral-900)]">{t("history")}</h2>
<ol className="mt-4 space-y-3">
{instituteTimeline.map((item) => (
<li key={item.year} className="flex gap-4">
<span className="w-16 shrink-0 font-semibold text-[var(--brand-700)]">{item.year}</span>
<span className="text-[var(--neutral-700)]">{item.text}</span>
</li>
))}
</ol>
</div>
<div className="space-y-4 text-[var(--neutral-700)] leading-relaxed">
<p>
Первым предшественником института был противочумный карантин, созданный в 1772 году на 3-й
Мещанской улице и преобразованный по указу Екатерины II в больницу. С 1835 года она называлась
Старо-Екатерининской и служила клинической базой медицинского факультета Московского
университета.
</p>
<p>
Сегодня МОНИКИ — ведущий клинический, научный и образовательный комплекс Московской области:
стационар на 871 койку, консультации по 40 профилям, около 200 тысяч консультаций и около
10 тысяч операций в год. Здесь сочетаются клиническая помощь, научные исследования и подготовка
кадров, включая факультет усовершенствования врачей.
</p>
</div>
<SourceNote href={officialLinks.history} label={t("source")} />
</div>
);
}
async function LeadersContent() {
const t = await getTranslations("organization");
return (
<div className="min-w-0">
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{leadership.map((person) => (
<li
key={person.slug}
className="min-w-0 overflow-hidden rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)]"
>
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--neutral-100)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={person.photo}
alt={person.name}
width={720}
height={960}
className="absolute inset-0 h-full w-full max-w-none object-cover object-center"
/>
</div>
<div className="p-4">
<p className="break-words text-sm text-[var(--neutral-500)]">{person.position}</p>
<h2 className="mt-1 break-words text-base font-semibold text-[var(--neutral-900)]">
{person.name}
</h2>
{person.degree ? (
<p className="mt-1 break-words text-sm text-[var(--neutral-700)]">{person.degree}</p>
) : null}
</div>
</li>
))}
</ul>
<SourceNote href={officialLinks.leaders} label={t("source")} />
</div>
);
}
async function LicenseContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-6">
{licenses.map((item) => (
<Card key={item.title}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.title}</h2>
<p className="text-sm font-medium text-[var(--brand-700)]">{item.status}</p>
</div>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
{item.number ? (
<div>
<dt className="text-[var(--neutral-500)]">{t("licenseNumber")}</dt>
<dd className="mt-1 font-medium text-[var(--neutral-800)]">{item.number}</dd>
</div>
) : null}
{item.issuedAt ? (
<div>
<dt className="text-[var(--neutral-500)]">{t("issuedAt")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.issuedAt}</dd>
</div>
) : null}
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("authority")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.authority}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-[var(--neutral-500)]">{t("activity")}</dt>
<dd className="mt-1 text-[var(--neutral-800)]">{item.activity}</dd>
</div>
</dl>
<p className="mt-4 text-sm text-[var(--text-muted)]">{item.extract}</p>
<a href={item.href} target="_blank" rel="noopener noreferrer" className="mt-4 inline-flex">
<Button variant="outline" size="sm">
{t("openDocument")}
</Button>
</a>
</Card>
))}
<ComingSoon text={t("licenseSoon")} />
<SourceNote href={officialLinks.license} label={t("source")} />
</div>
);
}
async function RequisitesContent() {
const t = await getTranslations("organization");
return (
<div>
<InfoList items={requisites} />
<a
href={officialLinks.requisitesPdf}
target="_blank"
rel="noopener noreferrer"
className="mt-6 inline-flex"
>
<Button variant="outline">{t("downloadPdf")}</Button>
</a>
<SourceNote href={officialLinks.officialInfo} label={t("source")} />
</div>
);
}
async function SupervisionContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-6">
{supervisors.map((item) => (
<Card key={item.title}>
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.title}</h2>
<p className="mt-2 text-sm text-[var(--text-muted)]">{item.role}</p>
<dl className="mt-4 space-y-2 text-sm">
<div>
<dt className="text-[var(--neutral-500)]">{t("phone")}</dt>
<dd className="text-[var(--neutral-800)]">{item.phone}</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
<dd className="text-[var(--neutral-800)]">{item.address}</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("email")}</dt>
<dd>
<a href={`mailto:${item.email}`} className="text-[var(--brand-700)] hover:underline">
{item.email}
</a>
</dd>
</div>
</dl>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block text-sm font-medium text-[var(--brand-700)] hover:underline"
>
{t("officialSite")}
</a>
</Card>
))}
<SourceNote href={officialLinks.officialInfo} label={t("source")} />
</div>
);
}
async function ReceptionContent() {
const t = await getTranslations("organization");
return (
<div className="min-w-0">
<ul className="space-y-3 md:hidden">
{receptionSchedule.map((row) => (
<li
key={row.name}
className="rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-4"
>
<p className="whitespace-pre-line font-semibold text-[var(--neutral-900)]">{row.position}</p>
<p className="mt-1 text-[var(--neutral-800)]">{row.name}</p>
<dl className="mt-3 space-y-2 text-sm">
<div>
<dt className="text-[var(--neutral-500)]">{t("reception.hours")}</dt>
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">
{row.hours || "—"}
</dd>
</div>
<div>
<dt className="text-[var(--neutral-500)]">{t("address")}</dt>
<dd className="mt-0.5 whitespace-pre-line text-[var(--neutral-800)]">{row.address}</dd>
</div>
</dl>
</li>
))}
</ul>
<div className="hidden overflow-x-auto rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] md:block">
<table className="w-full min-w-[36rem] border-collapse text-left text-sm">
<thead className="bg-[var(--neutral-50)]">
<tr>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("reception.position")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("reception.hours")}
</th>
<th scope="col" className="px-4 py-3 font-semibold text-[var(--neutral-800)]">
{t("address")}
</th>
</tr>
</thead>
<tbody>
{receptionSchedule.map((row) => (
<tr key={row.name} className="border-t border-[var(--border)] align-top">
<td className="px-4 py-3 text-[var(--neutral-800)]">
<p className="whitespace-pre-line font-medium">{row.position}</p>
<p className="mt-1 whitespace-pre-line">{row.name}</p>
</td>
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
{row.hours || "—"}
</td>
<td className="whitespace-pre-line px-4 py-3 text-[var(--neutral-800)]">
{row.address}
</td>
</tr>
))}
</tbody>
</table>
</div>
<SourceNote href={officialLinks.contacts} label={t("source")} />
</div>
);
}
async function FaqContent() {
const t = await getTranslations("organization");
return (
<div className="space-y-4">
{faqItems.map((item) => (
<Card key={item.question}>
<h2 className="text-lg font-semibold text-[var(--neutral-900)]">{item.question}</h2>
<p className="mt-2 leading-relaxed text-[var(--neutral-700)]">{item.answer}</p>
</Card>
))}
<ComingSoon text={t("faq.comingSoon")} />
<p className="text-sm text-[var(--text-muted)]">
{t("faq.bookHint")}{" "}
<Link href="/booking" className="font-medium text-[var(--brand-700)] hover:underline">
{t("faq.bookLink")}
</Link>
</p>
<SourceNote href={officialLinks.site} label={t("source")} />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { BranchesPreview } from "@/components/home/BranchesPreview";
import { DoctorsPreview } from "@/components/home/DoctorsPreview";
import { Hero } from "@/components/home/Hero";
import { HomeCta } from "@/components/home/HomeCta";
import { NewsPreview } from "@/components/home/NewsPreview";
import { PromotionsPreview } from "@/components/home/PromotionsPreview";
import { Reviews } from "@/components/home/Reviews";
import { Specialties } from "@/components/home/Specialties";
import { getBanners, getBranches, getDoctors, getNews, getPromotions, getReviews } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "hero");
}
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const [doctors, reviews, branches, news, banners, promotions] = await Promise.all([
getDoctors({ locale }).catch(() => []),
getReviews(undefined, 12, locale).catch(() => []),
getBranches(locale).catch(() => []),
getNews(locale).catch(() => []),
getBanners(locale).catch(() => []),
getPromotions(locale).catch(() => []),
]);
return (
<>
<Hero banners={banners} />
<Specialties />
<PromotionsPreview promotions={promotions} />
<DoctorsPreview doctors={doctors} />
<Reviews reviews={reviews} />
<HomeCta />
<BranchesPreview branches={branches} />
<NewsPreview news={news} />
</>
);
}
@@ -0,0 +1,41 @@
import { PromotionCard } from "@/components/promotions/PromotionCard";
import { getPromotions } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "promotions");
}
export default async function PromotionsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("promotions");
const promotions = await getPromotions(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 max-w-2xl text-[var(--text-muted)]">{t("subtitle")}</p>
{promotions.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-8 grid gap-6 md:grid-cols-2">
{promotions.map((promo) => (
<PromotionCard key={promo.id} promo={promo} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,27 @@
import { RegisterForm } from "@/components/auth/RegisterForm";
import { namedPageMetadata } from "@/lib/page-metadata";
import { setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "auth", "registerTitle");
}
export default async function RegisterPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex max-w-6xl justify-center px-4 py-16 sm:px-6">
<RegisterForm />
</div>
);
}
@@ -0,0 +1,47 @@
import { ReviewsInfinite } from "@/components/reviews/ReviewsInfinite";
import { getReviewsPage, REVIEWS_PAGE_SIZE } from "@/lib/api";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "reviews");
}
export default async function ReviewsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("reviews");
const loadingLabel = (await getTranslations("common"))("loading");
const [initial, prefetched] = await Promise.all([
getReviewsPage({ page: 1, limit: REVIEWS_PAGE_SIZE, locale }),
getReviewsPage({ page: 2, limit: REVIEWS_PAGE_SIZE, locale }),
]);
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
{initial.items.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<ReviewsInfinite
initial={initial}
prefetched={initial.hasNextPage ? prefetched : null}
endpoint={`/api/reviews?limit=${REVIEWS_PAGE_SIZE}&locale=${encodeURIComponent(locale)}`}
className="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3"
loadingLabel={loadingLabel}
/>
)}
</div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Link, usePathname, useRouter } from "@/i18n/routing";
import type { Service } from "@/lib/demo-data";
import { demoServices } from "@/lib/demo-data";
import { matchesQuery } from "@/lib/search";
import { SERVICE_CATEGORY_ORDER } from "@/lib/service-categories";
import { formatPrice, stripPriceListIndex, cn } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type Grouped = {
category: string;
groups: { title: string; items: Service[] }[];
count: number;
};
function EllipsisText({ text, className }: { text: string; className?: string }) {
return (
<span className={cn("group/ell relative block min-w-0", className)}>
<span className="block truncate">{text}</span>
<span
aria-hidden="true"
className="pointer-events-none absolute left-0 top-full z-30 mt-1 hidden max-w-[min(28rem,70vw)] whitespace-normal rounded-lg bg-[var(--neutral-900)] px-3 py-2 text-left text-xs font-normal leading-snug text-white shadow-lg group-hover/ell:block"
>
{text}
</span>
</span>
);
}
export function ServicesCatalog({
initialCategory,
initialQ,
}: {
initialCategory?: string;
initialQ?: string;
}) {
const t = useTranslations("services");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState(initialQ || "");
const [category, setCategory] = useState(initialCategory || "");
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
useEffect(() => {
let cancelled = false;
fetch(`/api/catalog?locale=${encodeURIComponent(locale)}`)
.then((res) => res.json())
.then((data: { services?: Service[] }) => {
if (cancelled) return;
setServices(Array.isArray(data.services) && data.services.length ? data.services : demoServices);
})
.catch(() => {
if (!cancelled) setServices(demoServices);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [locale]);
function syncUrl(nextQ: string, nextCategory: string) {
const params = new URLSearchParams();
if (nextCategory) params.set("category", nextCategory);
if (nextQ) params.set("q", nextQ);
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}
const filtered = useMemo(() => {
return services.filter((s) => {
if (category && s.category !== category) return false;
if (!q.trim()) return true;
return matchesQuery(
[s.title, s.groupTitle, s.code, s.nmuCode, s.description].filter(Boolean).join(" "),
q,
);
});
}, [services, category, q]);
const grouped = useMemo<Grouped[]>(() => {
const byCategory = new Map<string, Map<string, Service[]>>();
for (const s of filtered) {
const cat = s.category || "procedure";
const group = stripPriceListIndex(s.groupTitle || t(`categories.${cat}` as "categories.consultation") || cat);
if (!byCategory.has(cat)) byCategory.set(cat, new Map());
const groups = byCategory.get(cat)!;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(s);
}
const order = [...SERVICE_CATEGORY_ORDER];
const extra = [...byCategory.keys()].filter((k) => !order.includes(k as (typeof order)[number]));
return [...order, ...extra]
.filter((cat) => byCategory.has(cat))
.map((cat) => {
const groups = [...byCategory.get(cat)!.entries()].map(([title, items]) => ({
title,
items,
}));
return {
category: cat,
groups,
count: groups.reduce((n, g) => n + g.items.length, 0),
};
});
}, [filtered, t]);
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of services) {
counts[s.category] = (counts[s.category] || 0) + 1;
}
return counts;
}, [services]);
return (
<div className="mt-8 grid gap-8 lg:grid-cols-[260px_1fr]">
<aside className="h-fit rounded-2xl border border-[var(--border)] bg-white p-4 lg:sticky lg:top-24">
<p className="text-sm font-semibold text-[var(--neutral-800)]">{t("groups")}</p>
<div className="mt-3 flex flex-col gap-1">
<button
type="button"
onClick={() => {
setCategory("");
syncUrl(q, "");
}}
className={`rounded-xl px-3 py-2 text-left text-sm ${
!category
? "bg-[var(--brand-600)] font-medium text-white"
: "text-[var(--neutral-700)] hover:bg-[var(--neutral-50)]"
}`}
>
{t("allCategories")}
<span className="ml-2 opacity-70">{services.length}</span>
</button>
{SERVICE_CATEGORY_ORDER.filter((id) => categoryCounts[id]).map((id) => (
<button
key={id}
type="button"
onClick={() => {
setCategory(id);
syncUrl(q, id);
}}
className={`rounded-xl px-3 py-2 text-left text-sm ${
category === id
? "bg-[var(--brand-600)] font-medium text-white"
: "text-[var(--neutral-700)] hover:bg-[var(--neutral-50)]"
}`}
>
{t(`categories.${id}` as "categories.consultation")}
<span className="ml-2 opacity-70">{categoryCounts[id]}</span>
</button>
))}
</div>
</aside>
<div>
<Input
label={t("search")}
value={q}
placeholder={t("searchPlaceholder")}
autoComplete="off"
onChange={(e) => {
const value = e.target.value;
setQ(value);
syncUrl(value, category);
}}
/>
<p className="mt-2 text-sm text-[var(--text-muted)]">
{loading ? t("loading") : t("found", { count: filtered.length })}
</p>
{filtered.length === 0 ? (
<p className="mt-10 text-[var(--text-muted)]">{t("empty")}</p>
) : (
<div className="mt-6 space-y-10">
{grouped.map((section) => (
<section key={section.category} id={`cat-${section.category}`}>
<h2 className="text-xl font-bold text-[var(--neutral-900)]">
{t(`categories.${section.category}` as "categories.consultation") || section.category}
<span className="ml-2 text-sm font-normal text-[var(--neutral-500)]">
{section.count}
</span>
</h2>
<div className="mt-4 space-y-3">
{section.groups.map((group) => {
const searching = Boolean(q.trim());
const isOpen = searching || Boolean(openGroups[group.title]);
return (
<details
key={group.title}
open={isOpen}
onToggle={(e) => {
if (searching) return;
setOpenGroups((prev) => ({
...prev,
[group.title]: (e.target as HTMLDetailsElement).open,
}));
}}
className="rounded-2xl border border-[var(--border)] bg-white"
>
<summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-semibold text-[var(--brand-700)] marker:content-none">
<EllipsisText text={group.title} className="flex-1" />
<span className="shrink-0 font-normal text-[var(--neutral-500)]">{group.items.length}</span>
</summary>
<div className="border-t border-[var(--border)]">
{isOpen
? group.items.map((service, i) => (
<div
key={service.id}
className={`grid grid-cols-1 items-center gap-x-4 gap-y-2 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_auto] ${
i ? "border-t border-[var(--border)]" : ""
}`}
>
<div className="min-w-0">
<EllipsisText
text={stripPriceListIndex(service.title)}
className="font-medium text-[var(--neutral-900)]"
/>
<p className="mt-0.5 truncate text-xs text-[var(--neutral-500)]">
{service.code ? `${t("code")} ${service.code}` : null}
{service.nmuCode ? ` · НМУ ${service.nmuCode}` : null}
</p>
</div>
<div className="flex shrink-0 flex-nowrap items-center gap-2">
<p className="w-[5.75rem] shrink-0 text-right text-lg font-bold tabular-nums text-[var(--brand-700)]">
{formatPrice(service.price, locale)}
</p>
<Link href={`/services/${service.slug}`} className="shrink-0">
<Button variant="outline" size="sm">
{t("details")}
</Button>
</Link>
<Link href={`/booking?service=${service.id}`} className="shrink-0">
<Button size="sm">{t("book")}</Button>
</Link>
</div>
</div>
))
: null}
</div>
</details>
);
})}
</div>
</section>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import { getServiceBySlug } from "@/lib/api";
import { formatPrice, stripPriceListIndex } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const service = await getServiceBySlug(slug, locale);
if (!service) return {};
return {
title: stripPriceListIndex(service.title),
description: service.description,
};
}
export default async function ServiceDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const t = await getTranslations("services");
const service = await getServiceBySlug(slug, locale);
if (!service) notFound();
return (
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<Badge variant="brand">
{t(`categories.${service.category}` as "categories.consultation")}
</Badge>
{service.groupTitle ? (
<p className="mt-3 text-sm font-medium text-[var(--brand-700)]">
{stripPriceListIndex(service.groupTitle)}
</p>
) : null}
<h1 className="mt-2 text-3xl font-bold text-[var(--neutral-900)]">
{stripPriceListIndex(service.title)}
</h1>
<p className="mt-4 text-lg leading-relaxed text-[var(--text-muted)]">{service.description}</p>
<div className="mt-6 flex flex-wrap items-center gap-4">
<p className="text-2xl font-bold text-[var(--brand-700)]">{formatPrice(service.price, locale)}</p>
{service.durationMin ? (
<p className="text-sm text-[var(--neutral-500)]">
{t("duration")}: {service.durationMin} {t("minutes")}
</p>
) : null}
{service.code ? (
<p className="text-sm text-[var(--neutral-500)]">
{t("code")}: {service.code}
</p>
) : null}
</div>
<div className="mt-8">
<Link href={`/booking?service=${service.id}`}>
<Button size="lg">{t("book")}</Button>
</Link>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { ServicesCatalog } from "./ServicesCatalog";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getTranslations, setRequestLocale } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return namedPageMetadata(locale, "services");
}
export default async function ServicesPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ category?: string; q?: string }>;
}) {
const { locale } = await params;
const sp = await searchParams;
setRequestLocale(locale);
const t = await getTranslations("services");
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-2 text-[var(--text-muted)]">{t("subtitle")}</p>
<ServicesCatalog initialCategory={sp.category} initialQ={sp.q} />
</div>
);
}
@@ -0,0 +1,187 @@
import { LegalLayout } from "@/components/legal/LegalLayout";
import { CmsPageBody } from "@/components/pages/CmsPageBody";
import { PublicDocumentsList } from "@/components/pages/PublicDocumentsList";
import { InfoList } from "@/components/organization/OrganizationLayout";
import {
isLegalSlug,
legalPages,
pdOperatorFields,
rknOperatorUrl,
type LegalSlug,
} from "@/lib/legal";
import { discountNotes, discountRows } from "@/lib/discounts";
import { namedPageMetadata } from "@/lib/page-metadata";
import { getSitePage } from "@/lib/api";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
export function generateStaticParams() {
return legalPages.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const page = await getSitePage("useful", slug, locale);
if (page) {
return { title: page.heading || page.title, description: page.subtitle || undefined };
}
if (!isLegalSlug(slug)) return {};
const navKey = legalPages.find((item) => item.slug === slug)?.navKey;
if (!navKey) return {};
return namedPageMetadata(locale, "legal", `${navKey}.title`);
}
export default async function UsefulPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const cmsPage = await getSitePage("useful", slug, locale);
if (cmsPage) {
return (
<LegalLayout slug={slug}>
<CmsPageBody page={cmsPage} />
{slug === "documents" ? (
<div className="mt-8">
<PublicDocumentsList locale={locale} />
</div>
) : null}
</LegalLayout>
);
}
if (!isLegalSlug(slug)) notFound();
return (
<LegalLayout slug={slug}>
<LegalBody slug={slug} locale={locale} />
</LegalLayout>
);
}
async function LegalBody({ slug, locale }: { slug: LegalSlug; locale: string }) {
if (slug === "documents") return <DocumentsContent locale={locale} />;
if (slug === "discounts") return <DiscountsContent />;
if (slug === "cookies") return <CookiesContent />;
if (slug === "privacy") return <PrivacyContent />;
return <PdOperatorContent />;
}
async function DocumentsContent({ locale }: { locale: string }) {
const t = await getTranslations("legal.documentsPage");
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<p className="mt-8 leading-relaxed text-[var(--neutral-700)]">{t("intro")}</p>
<div className="mt-8">
<PublicDocumentsList locale={locale} />
</div>
</>
);
}
async function DiscountsContent() {
const t = await getTranslations("legal.discounts");
const page = {
heading: t("heading"),
subtitle: t("subtitle"),
tableCaption: t("caption"),
tableRows: discountRows.map((row) => ({
category: row.category,
condition: row.condition || undefined,
discount: row.discount,
documents: row.documents,
})),
sourceUrl: "https://www.monikiweb.ru/skidki",
ctaHint: t("phoneHint"),
ctaLabel: t("phone"),
ctaHref: "tel:+74956810303",
content: {
root: {
type: "root",
children: discountNotes.map((paragraph) => ({
type: "paragraph",
version: 1,
children: [{ type: "text", text: paragraph, version: 1 }],
})),
},
},
facts: [],
timeline: [],
people: [],
cards: [],
faq: [],
slug: "discounts",
href: "/useful/discounts",
title: t("title"),
section: "useful" as const,
};
return <CmsPageBody page={page} />;
}
async function CookiesContent() {
const t = await getTranslations("legal.cookies");
const paragraphs = t.raw("paragraphs") as string[];
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-8 space-y-4 text-lg leading-relaxed text-[var(--neutral-700)]">
{paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</>
);
}
async function PrivacyContent() {
const t = await getTranslations("legal.privacy");
const paragraphs = t.raw("paragraphs") as string[];
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<div className="mt-8 space-y-4 text-lg leading-relaxed text-[var(--neutral-700)]">
{paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</>
);
}
async function PdOperatorContent() {
const t = await getTranslations("legal.pdOperator");
return (
<>
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("heading")}</h1>
<p className="mt-2 max-w-3xl text-[var(--text-muted)]">{t("subtitle")}</p>
<p className="mt-8 leading-relaxed text-[var(--neutral-700)]">{t("intro")}</p>
<p className="mt-4">
<a
href={rknOperatorUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-[var(--brand-700)] hover:underline"
>
{t("rknLink")}
</a>
</p>
<div className="mt-8">
<InfoList items={pdOperatorFields} />
</div>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { AUTH_COOKIE, CMS_URL } from "@/lib/api";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.json();
try {
const res = await fetch(`${CMS_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(data, { status: res.status });
}
const response = NextResponse.json({ user: data.user });
response.cookies.set(AUTH_COOKIE, data.token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 7,
});
return response;
} catch {
// Demo login fallback when CMS is down
if (body.email && body.password) {
const response = NextResponse.json({
user: { id: "demo", email: body.email, name: "Пациент" },
});
response.cookies.set(AUTH_COOKIE, "demo-token", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
}
return NextResponse.json({ message: "Login failed" }, { status: 500 });
}
}
@@ -0,0 +1,8 @@
import { AUTH_COOKIE } from "@/lib/api";
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(AUTH_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
return response;
}

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