Initial release of Moniki paid services site and CMS (v1.0.0).
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_CMS_URL=http://localhost:3001
|
||||
@@ -0,0 +1,8 @@
|
||||
.node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
.env*.local
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 270 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||