import { cookies } from "next/headers"; import { demoBanners, demoBookings, demoBranches, demoDoctors, demoGallery, demoNews, demoPromotions, demoReviews, demoServices, generateDemoSlots, type Booking, type Branch, type Doctor, type GalleryItem, type HeroBanner, type NewsItem, type Promotion, type Review, type Service, type Slot, } from "./demo-data"; import { PLACEHOLDER_DOCTOR_PHOTO } from "./real-doctors"; import { matchesAllTokens } from "./search"; import { sitePageHref, type SiteNavItem, type SitePage, type SiteSection, } from "./site-pages"; import { stripPriceListIndex } from "./utils"; const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL || "http://localhost:3001"; const AUTH_COOKIE = "moniki_token"; const SITE_LOCALES = ["ru", "en", "be", "tg", "ky", "zh"] as const; export function cmsLocale(locale?: string | null) { return locale && SITE_LOCALES.includes(locale as (typeof SITE_LOCALES)[number]) ? locale : "ru"; } function withLocale(query: URLSearchParams, locale?: string | null) { const value = cmsLocale(locale); query.set("locale", value); query.set("fallbackLocale", "ru"); return query; } type FetchOptions = RequestInit & { token?: string | null; fallback?: unknown }; async function cmsFetch(path: string, options: FetchOptions = {}): Promise { const { token, fallback, ...init } = options; const headers = new Headers(init.headers); headers.set("Content-Type", "application/json"); if (token) headers.set("Authorization", `Bearer ${token}`); const timeout = AbortSignal.timeout(5000); const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; try { const res = await fetch(`${CMS_URL}${path}`, { ...init, headers, signal, next: init.cache === "no-store" ? undefined : { revalidate: 60 }, }); if (!res.ok) throw new Error(`CMS ${res.status}`); return (await res.json()) as T; } catch { if (fallback !== undefined) return fallback as T; throw new Error("CMS unavailable"); } } export async function getAuthToken(): Promise { try { const store = await cookies(); return store.get(AUTH_COOKIE)?.value ?? null; } catch { return null; } } export { AUTH_COOKIE, CMS_URL }; const DEFAULT_BANNER_BACKGROUND = "/hero/building-blur.jpg"; function resolveAssetUrl(url: string): string { if (!url) return ""; if (url.startsWith("http://") || url.startsWith("https://")) return url; if ( url.startsWith("/hero/") || url.startsWith("/promotions/") || url.startsWith("/doctors/") || url.startsWith("/logo") || url.startsWith("/organization/") || url.startsWith("/documents/") ) { return url; } return `${CMS_URL}${url}`; } function mediaSrc(value: unknown, fallback = ""): string { if (typeof value === "string" && value) return resolveAssetUrl(value); if (value && typeof value === "object") { const url = asText((value as Record).url); if (url) return resolveAssetUrl(url); } return fallback; } export const DOCTORS_PAGE_SIZE = 9; export const REVIEWS_PAGE_SIZE = 12; export const DOCTOR_REVIEWS_PAGE_SIZE = 6; export type Paginated = { items: T[]; page: number; limit: number; total: number; totalPages: number; hasNextPage: boolean; nextPage: number | null; }; type PayloadList = { docs: T[]; totalDocs?: number; totalPages?: number; page?: number; hasNextPage?: boolean; nextPage?: number | null; limit?: number; }; function unwrapList(data: PayloadList | T[]): T[] { return Array.isArray(data) ? data : data.docs ?? []; } function lexicalToPlain(value: unknown): string { if (!value || typeof value !== "object") return ""; const walk = (node: unknown): string => { if (!node || typeof node !== "object") return ""; const rec = node as Record; if (typeof rec.text === "string") return rec.text; const children = Array.isArray(rec.children) ? rec.children.map(walk).join("") : ""; if (rec.type === "paragraph" || rec.type === "heading" || rec.type === "listitem") { return `${children}\n\n`; } if (rec.type === "linebreak") return "\n"; return children; }; return walk((value as Record).root ?? value) .replace(/\n{3,}/g, "\n\n") .trim(); } function asText(value: unknown): string { if (value == null) return ""; if (typeof value === "string") return value; if (typeof value === "number") return String(value); if (typeof value === "object") { const obj = value as Record; if (typeof obj.ru === "string") return obj.ru; if (typeof obj.title === "string") return obj.title; if (typeof obj.fullName === "string") return obj.fullName; if (typeof obj.url === "string") return obj.url; } return ""; } function mapIds(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((item) => { if (item && typeof item === "object" && "id" in item) return String((item as { id: unknown }).id); return String(item); }); } function mapDoctor(raw: Record): Doctor { const specialty = asText(raw.specialty) || [asText(raw.position), asText(raw.degree)].filter(Boolean).join(", "); const specialtyTitles = Array.isArray(raw.specialties) ? raw.specialties .map((item) => item && typeof item === "object" ? asText((item as Record).title) : "", ) .filter(Boolean) .join(" ") : ""; const mediaUrl = typeof raw.photo === "string" ? raw.photo : raw.photo && typeof raw.photo === "object" ? asText((raw.photo as Record).url) : ""; const photoUrl = asText(raw.photoUrl) || mediaUrl; const photoPending = Boolean(raw.photoPending) || !photoUrl; const name = asText(raw.name) || asText(raw.fullName); return { id: String(raw.id ?? ""), slug: asText(raw.slug), name, specialty, experienceYears: Number(raw.experienceYears) || 0, photo: photoPending ? PLACEHOLDER_DOCTOR_PHOTO : photoUrl, photoPending, bio: asText(raw.bio), rating: Number(raw.rating) || 0, reviewsCount: Number(raw.reviewsCount) || 0, services: mapIds(raw.services), branchIds: mapIds(raw.branches).length ? mapIds(raw.branches) : mapIds(raw.branchIds), searchText: `${name} ${specialty} ${specialtyTitles}`.trim(), }; } function mapService(raw: Record): Service { return { id: String(raw.id ?? ""), slug: String(raw.slug ?? ""), title: stripPriceListIndex(String(raw.title ?? "")), category: String(raw.category ?? ""), groupTitle: raw.groupTitle ? stripPriceListIndex(String(raw.groupTitle)) : undefined, code: raw.code ? String(raw.code) : undefined, nmuCode: raw.nmuCode ? String(raw.nmuCode) : undefined, price: Number(raw.price) || 0, durationMin: Number(raw.durationMin ?? raw.durationMinutes ?? 0), description: String(raw.description ?? ""), featured: Boolean(raw.featured ?? raw.isPopular), }; } export async function getServices(params?: { category?: string; q?: string; locale?: string; }): Promise { const locale = cmsLocale(params?.locale); try { const catalog = await cmsFetch<{ services: Record[] }>( `/api/custom/catalog?locale=${encodeURIComponent(locale)}&fallbackLocale=ru`, { fallback: { services: demoServices } }, ); let list = (catalog.services || []).map(mapService); if (!list.length) { list = unwrapList( await cmsFetch>>( `/api/services?limit=10000&depth=0&where[isPublished][equals]=true&locale=${encodeURIComponent(locale)}&fallbackLocale=ru`, { fallback: { docs: demoServices as unknown as Record[] } }, ), ).map(mapService); } if (params?.category) list = list.filter((s) => s.category === params.category); if (params?.q) { const q = params.q.toLowerCase(); list = list.filter((s) => [s.title, s.groupTitle, s.code, s.nmuCode, s.description] .filter(Boolean) .some((v) => String(v).toLowerCase().includes(q)), ); } return list; } catch { return demoServices.filter((s) => { if (params?.category && s.category !== params.category) return false; if (params?.q && !s.title.toLowerCase().includes(params.q.toLowerCase())) return false; return true; }); } } export async function getServiceBySlug(slug: string, locale = "ru"): Promise { try { const data = await cmsFetch>>( `/api/services?where[slug][equals]=${encodeURIComponent(slug)}&limit=1&locale=${encodeURIComponent(cmsLocale(locale))}&fallbackLocale=ru`, { fallback: { docs: demoServices.filter((s) => s.slug === slug) as unknown as Record[] } }, ); const doc = unwrapList(data)[0]; return doc ? mapService(doc) : null; } catch { return demoServices.find((s) => s.slug === slug) ?? null; } } function paginateLocal(all: T[], page: number, limit: number): Paginated { const safePage = Math.max(1, page); const start = (safePage - 1) * limit; const items = all.slice(start, start + limit); const total = all.length; const totalPages = Math.max(1, Math.ceil(total / limit) || 1); return { items, page: safePage, limit, total, totalPages, hasNextPage: safePage < totalPages, nextPage: safePage < totalPages ? safePage + 1 : null, }; } function fromPayloadList( data: PayloadList | T[], map: (item: T) => R, page: number, limit: number, fallbackTotal = 0, ): Paginated { if (Array.isArray(data)) return paginateLocal(data.map(map), page, limit); if (data.totalDocs == null && data.page == null) { return paginateLocal(unwrapList(data).map(map), page, limit); } const items = unwrapList(data).map(map); const total = Number(data.totalDocs ?? fallbackTotal); const totalPages = Number(data.totalPages ?? Math.max(1, Math.ceil(total / limit) || 1)); const currentPage = Number(data.page ?? page); const hasNextPage = Boolean(data.hasNextPage ?? currentPage < totalPages); return { items, page: currentPage, limit: Number(data.limit ?? limit), total, totalPages, hasNextPage, nextPage: hasNextPage ? Number(data.nextPage ?? currentPage + 1) : null, }; } export async function getDoctors(params?: { specialty?: string; q?: string; locale?: string; }): Promise { const locale = cmsLocale(params?.locale); const page = await getDoctorsPage({ page: 1, limit: 100, q: params?.q, specialty: params?.specialty, locale, }); return page.items.sort((a, b) => a.name.localeCompare(b.name, locale)); } function doctorSearchText(doctor: Doctor): string { return doctor.searchText || `${doctor.name} ${doctor.specialty}`; } export async function getDoctorsPage(params?: { page?: number; limit?: number; specialty?: string; q?: string; locale?: string; }): Promise> { const page = Math.max(1, params?.page ?? 1); const limit = params?.limit ?? DOCTORS_PAGE_SIZE; const q = (params?.q || params?.specialty || "").trim(); const locale = cmsLocale(params?.locale); try { const query = withLocale( new URLSearchParams({ limit: q ? "200" : String(limit), page: q ? "1" : String(page), depth: "1", sort: "slug", "where[isPublished][equals]": "true", }), locale, ); const data = await cmsFetch>>(`/api/doctors?${query}`, { cache: "no-store", fallback: { docs: demoDoctors as unknown as Record[] }, }); if (q) { const filtered = unwrapList(data) .map(mapDoctor) .filter((doctor) => matchesAllTokens(doctorSearchText(doctor), q)) .sort((a, b) => a.name.localeCompare(b.name, locale)); return paginateLocal(filtered, page, limit); } return fromPayloadList(data, mapDoctor, page, limit, demoDoctors.length); } catch { const all = demoDoctors .filter((doctor) => matchesAllTokens(doctorSearchText(doctor), q)) .sort((a, b) => a.name.localeCompare(b.name, locale)); return paginateLocal(all, page, limit); } } export async function getDoctorBySlug(slug: string, locale = "ru"): Promise { try { const data = await cmsFetch>>( `/api/doctors?where[slug][equals]=${encodeURIComponent(slug)}&limit=1&depth=1&locale=${encodeURIComponent(cmsLocale(locale))}&fallbackLocale=ru`, { cache: "no-store", fallback: { docs: demoDoctors.filter((d) => d.slug === slug) as unknown as Record[], }, }, ); const doc = unwrapList(data)[0]; return doc ? mapDoctor(doc) : null; } catch { return demoDoctors.find((d) => d.slug === slug) ?? null; } } function mapBranch(raw: Record): Branch { const workHours = Array.isArray(raw.workHours) ? raw.workHours .map((item) => { if (!item || typeof item !== "object") return ""; const rec = item as Record; return [asText(rec.day), asText(rec.hours)].filter(Boolean).join(" "); }) .filter(Boolean) .join(", ") : ""; const lat = Number(raw.lat); const lng = Number(raw.lng); return { id: String(raw.id ?? raw.slug ?? ""), slug: asText(raw.slug), name: asText(raw.name), address: asText(raw.address), city: asText(raw.city) || undefined, metro: asText(raw.metro) || undefined, phone: asText(raw.phone), hours: workHours || asText(raw.hours), howToGet: asText(raw.howToGet) || undefined, lat: Number.isFinite(lat) ? lat : undefined, lng: Number.isFinite(lng) ? lng : undefined, isMain: Boolean(raw.isMain), sortOrder: Number(raw.sortOrder) || (raw.isMain ? 10 : 100), }; } function sortBranches(items: Branch[]): Branch[] { return [...items].sort((a, b) => { const order = (a.sortOrder ?? 100) - (b.sortOrder ?? 100); if (order !== 0) return order; if (Boolean(a.isMain) !== Boolean(b.isMain)) return a.isMain ? -1 : 1; return a.name.localeCompare(b.name, "ru"); }); } export async function getBranches(locale = "ru"): Promise { try { const query = new URLSearchParams({ limit: "50", depth: "1", locale, sort: "sortOrder", }); const data = await cmsFetch> | Record[]>( `/api/branches?${query}`, { fallback: demoBranches as unknown as Record[] }, ); const items = sortBranches(unwrapList(data as PayloadList>).map(mapBranch)); return items.length ? items : demoBranches; } catch { return demoBranches; } } export async function getBranchBySlug(slug: string, locale = "ru"): Promise { try { const data = await cmsFetch>>( `/api/branches?where[slug][equals]=${encodeURIComponent(slug)}&limit=1&depth=1&locale=${encodeURIComponent(locale)}`, { cache: "no-store", fallback: { docs: demoBranches.filter((b) => b.slug === slug) as unknown as Record[], }, }, ); const doc = unwrapList(data)[0]; return doc ? mapBranch(doc) : demoBranches.find((b) => b.slug === slug) ?? null; } catch { return demoBranches.find((b) => b.slug === slug) ?? null; } } export async function getBanners(locale = "ru"): Promise { const query = new URLSearchParams({ limit: "20", depth: "1", locale, sort: "sortOrder", "where[isPublished][equals]": "true", }); try { const data = await cmsFetch>>(`/api/banners?${query}`, { cache: "no-store", fallback: { docs: [] }, }); const items = unwrapList(data).map(mapBanner).filter((banner) => banner.title); return items.length ? items : demoBanners; } catch { return demoBanners; } } function mapBanner(raw: Record): HeroBanner { return { id: String(raw.id ?? raw.slug ?? ""), slug: asText(raw.slug), eyebrow: asText(raw.eyebrow), title: asText(raw.title), subtitle: asText(raw.subtitle), phone: asText(raw.phone), address: asText(raw.address), image: mediaSrc(raw.image, resolveAssetUrl(asText(raw.imageUrl))) || "/hero/moscow-doctors.jpg", imageAlt: asText(raw.imageAlt), video: mediaSrc(raw.video, resolveAssetUrl(asText(raw.videoUrl))), background: mediaSrc(raw.background, resolveAssetUrl(asText(raw.backgroundUrl))) || DEFAULT_BANNER_BACKGROUND, ctaPrimaryLabel: asText(raw.ctaPrimaryLabel), ctaPrimaryHref: asText(raw.ctaPrimaryHref) || "/booking", ctaSecondaryLabel: asText(raw.ctaSecondaryLabel), ctaSecondaryHref: asText(raw.ctaSecondaryHref) || "/services", durationMs: Number(raw.durationMs) || 7000, sortOrder: Number(raw.sortOrder) || 0, }; } function relationId(value: unknown): string { if (value == null || value === "") return ""; if (typeof value === "number" || typeof value === "string") return String(value); if (typeof value === "object" && "id" in value) return String((value as { id: unknown }).id); return ""; } function isPromotionVisible(promo: Promotion, now = Date.now()): boolean { if (promo.startsAt) { const start = new Date(promo.startsAt).getTime(); if (!Number.isNaN(start) && start > now) return false; } if (!promo.hideWhenExpired) return true; const end = new Date(promo.endsAt).getTime(); if (Number.isNaN(end)) return true; return end > now; } function mapPromotion(raw: Record): Promotion { const serviceId = relationId(raw.service); const bookingHref = asText(raw.bookingHref) || (serviceId ? `/booking?service=${serviceId}` : "/booking"); const oldPriceRaw = raw.oldPrice; const oldPrice = oldPriceRaw == null || oldPriceRaw === "" ? null : Number(oldPriceRaw) || null; return { id: String(raw.id ?? raw.slug ?? ""), slug: asText(raw.slug), title: asText(raw.title), badge: asText(raw.badge), description: asText(raw.description), conditions: asText(raw.conditions), price: Number(raw.price) || 0, oldPrice: oldPrice && oldPrice > 0 ? oldPrice : null, startsAt: asText(raw.startsAt) || null, endsAt: asText(raw.endsAt), showCountdown: raw.showCountdown !== false, hideWhenExpired: raw.hideWhenExpired !== false, bookingEnabled: raw.bookingEnabled !== false, ctaLabel: asText(raw.ctaLabel), bookingHref, image: mediaSrc(raw.image, resolveAssetUrl(asText(raw.imageUrl))) || "/hero/diagnostics.jpg", sortOrder: Number(raw.sortOrder) || 0, }; } export async function getPromotions(locale = "ru"): Promise { const query = new URLSearchParams({ limit: "50", depth: "1", locale, sort: "sortOrder", "where[isPublished][equals]": "true", }); try { const data = await cmsFetch>>(`/api/promotions?${query}`, { cache: "no-store", fallback: { docs: [] }, }); const items = unwrapList(data) .map(mapPromotion) .filter((promo) => promo.title && isPromotionVisible(promo)); return items.length ? items : demoPromotions.filter((promo) => isPromotionVisible(promo)); } catch { return demoPromotions.filter((promo) => isPromotionVisible(promo)); } } function newsExcerpt(title: string, excerpt: string, body: string): string { const trimmed = excerpt.trim(); if (trimmed && trimmed !== title) return trimmed; const snippet = body.replace(/\s+/g, " ").trim(); return snippet.length > 180 ? `${snippet.slice(0, 177).trimEnd()}…` : snippet; } function mapNews(raw: Record): NewsItem { const title = asText(raw.title); const body = asText(raw.body) || lexicalToPlain(raw.content) || asText(raw.content) || asText(raw.excerpt); return { id: String(raw.id ?? raw.slug ?? ""), slug: asText(raw.slug), title, excerpt: newsExcerpt(title, asText(raw.excerpt), body), body, cover: mediaSrc(raw.cover) || asText(raw.imageUrl), publishedAt: asText(raw.publishedAt) || asText(raw.createdAt) || new Date().toISOString(), }; } export async function getNews(locale = "ru"): Promise { try { const query = withLocale( new URLSearchParams({ limit: "20", depth: "1", sort: "-publishedAt", "where[isPublished][equals]": "true", }), locale, ); const data = await cmsFetch>>( `/api/posts?${query}`, { cache: "no-store", fallback: { docs: demoNews as unknown as Record[] } }, ); const items = unwrapList(data).map(mapNews).filter((item) => item.title); return items.length ? items : demoNews; } catch { return demoNews; } } export async function getNewsBySlug(slug: string, locale = "ru"): Promise { try { const data = await cmsFetch>>( `/api/posts?where[slug][equals]=${encodeURIComponent(slug)}&limit=1&depth=1&locale=${encodeURIComponent(cmsLocale(locale))}&fallbackLocale=ru`, { cache: "no-store", fallback: { docs: demoNews.filter((n) => n.slug === slug) as unknown as Record[], }, }, ); const doc = unwrapList(data)[0]; return doc ? mapNews(doc) : demoNews.find((n) => n.slug === slug) ?? null; } catch { return demoNews.find((n) => n.slug === slug) ?? null; } } export async function getGallery(locale = "ru"): Promise { const query = withLocale(new URLSearchParams({ limit: "100", depth: "1" }), locale); return cmsFetch(`/api/gallery?${query}`, { fallback: demoGallery }).then((data) => unwrapList(data as PayloadList | GalleryItem[]), ); } export async function getReviews( doctorId?: string, limit?: number, locale = "ru", ): Promise { const page = await getReviewsPage({ doctorId, page: 1, limit: limit ?? (doctorId ? 100 : 12), locale, }); return page.items; } export async function getReviewsPage(params?: { doctorId?: string; page?: number; limit?: number; locale?: string; }): Promise> { const page = Math.max(1, params?.page ?? 1); const limit = params?.limit ?? REVIEWS_PAGE_SIZE; const doctorId = params?.doctorId; const query = withLocale( new URLSearchParams({ limit: String(limit), page: String(page), depth: "1", sort: "-createdAt", }), params?.locale, ); if (doctorId) query.set("where[doctor][equals]", doctorId); try { const data = await cmsFetch>>(`/api/reviews?${query}`, { cache: "no-store", fallback: { docs: demoReviews as unknown as Record[] }, }); return fromPayloadList(data, mapReview, page, limit, demoReviews.length); } catch { const all = doctorId ? demoReviews.filter((review) => review.doctorName && review.doctorName.length > 0) : demoReviews; return paginateLocal(all, page, limit); } } function mapReview(raw: Record): Review { const doctor = raw.doctor; const doctorRecord = doctor && typeof doctor === "object" ? (doctor as Record) : null; const doctorName = asText(raw.doctorName) || (doctorRecord ? asText(doctorRecord.fullName) : ""); const doctorSlug = doctorRecord ? asText(doctorRecord.slug) : ""; return { id: String(raw.id ?? ""), author: asText(raw.authorName) || asText(raw.author), rating: Number(raw.rating) || 0, text: asText(raw.comment) || asText(raw.text), doctorName: doctorName || undefined, doctorSlug: doctorSlug || undefined, date: String(raw.createdAt ?? raw.date ?? new Date().toISOString()), }; } export async function getSlots(params: { date: string; doctorId?: string; serviceId?: string; branchId?: string; }): Promise { const q = new URLSearchParams(params as Record); return cmsFetch(`/api/slots?${q}`, { fallback: generateDemoSlots(params.date), }).then((data) => (Array.isArray(data) ? data : generateDemoSlots(params.date))); } export async function createBooking(payload: { serviceId?: string; doctorId?: string; branchId: string; slotId: string; datetime: string; patientName?: string; phone?: string; }): Promise<{ id: string }> { const token = await getAuthToken(); return cmsFetch(`/api/bookings`, { method: "POST", body: JSON.stringify(payload), token, cache: "no-store", fallback: { id: `demo-${Date.now()}` }, }); } export async function cancelBooking(id: string): Promise { const token = await getAuthToken(); await cmsFetch(`/api/bookings/${id}`, { method: "PATCH", body: JSON.stringify({ status: "cancelled" }), token, cache: "no-store", fallback: {}, }); } export async function rescheduleBooking( id: string, payload: { slotId: string; datetime: string }, ): Promise { const token = await getAuthToken(); await cmsFetch(`/api/bookings/${id}`, { method: "PATCH", body: JSON.stringify(payload), token, cache: "no-store", fallback: {}, }); } export async function createPayment(bookingId: string): Promise<{ paymentUrl: string }> { const token = await getAuthToken(); return cmsFetch(`/api/payments`, { method: "POST", body: JSON.stringify({ bookingId, method: "sbp" }), token, cache: "no-store", fallback: { paymentUrl: `#sbp-demo-${bookingId}` }, }); } export async function createReview(payload: { doctorId: string; rating: number; text: string; bookingId?: string; }): Promise { const token = await getAuthToken(); return cmsFetch(`/api/reviews`, { method: "POST", body: JSON.stringify(payload), token, cache: "no-store", fallback: { id: `r-${Date.now()}`, author: "Вы", rating: payload.rating, text: payload.text, date: new Date().toISOString().slice(0, 10), }, }); } export async function getFavorites(): Promise<(Service | Doctor)[]> { const token = await getAuthToken(); return cmsFetch(`/api/favorites`, { token, cache: "no-store", fallback: [], }).then((data) => unwrapList(data as PayloadList | (Service | Doctor)[])); } export async function addFavorite(payload: { type: "service" | "doctor"; id: string; }): Promise { const token = await getAuthToken(); await cmsFetch(`/api/favorites`, { method: "POST", body: JSON.stringify(payload), token, cache: "no-store", fallback: {}, }); } export async function removeFavorite(id: string): Promise { const token = await getAuthToken(); await cmsFetch(`/api/favorites/${id}`, { method: "DELETE", token, cache: "no-store", fallback: {}, }); } export async function getMyBookings(): Promise { const token = await getAuthToken(); return cmsFetch(`/api/bookings?mine=true`, { token, cache: "no-store", fallback: demoBookings, }).then((data) => unwrapList(data as PayloadList | Booking[])); } export async function me(): Promise<{ id: string; email: string; name?: string; phone?: string; } | null> { const token = await getAuthToken(); if (!token) return null; try { return await cmsFetch(`/api/users/me`, { token, cache: "no-store", fallback: { id: "demo", email: "patient@example.com", name: "Пациент", phone: "+7" }, }); } catch { return null; } } export async function login(email: string, password: string) { const res = await fetch(`${CMS_URL}/api/users/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || "Login failed"); } return res.json() as Promise<{ token: string; user: { id: string; email: string } }>; } export async function register(payload: { email: string; password: string; name?: string; phone?: string; }) { const res = await fetch(`${CMS_URL}/api/users`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || "Register failed"); } return res.json(); } function asRecords(value: unknown): Record[] { if (!Array.isArray(value)) return []; return value.filter((item): item is Record => Boolean(item) && typeof item === "object"); } function mapSiteNavItem(raw: Record): SiteNavItem | null { const slug = asText(raw.slug); const section = asText(raw.section) === "useful" ? "useful" : "organization"; const title = asText(raw.title); if (!slug || !title) return null; return { slug, section, title, href: sitePageHref(section, slug) }; } function mapSitePage(raw: Record): SitePage | null { const nav = mapSiteNavItem(raw); if (!nav) return null; const heading = asText(raw.heading) || nav.title; return { ...nav, heading, subtitle: asText(raw.subtitle), sourceUrl: asText(raw.sourceUrl) || undefined, timelineTitle: asText(raw.timelineTitle) || undefined, notice: asText(raw.notice) || undefined, ctaHint: asText(raw.ctaHint) || undefined, ctaLabel: asText(raw.ctaLabel) || undefined, ctaHref: asText(raw.ctaHref) || undefined, content: raw.content, tableCaption: asText(raw.tableCaption) || undefined, tableRows: asRecords(raw.tableRows) .map((item) => ({ category: asText(item.category), condition: asText(item.condition) || undefined, discount: asText(item.discount), documents: asText(item.documents), })) .filter((item) => item.category && item.discount), facts: asRecords(raw.facts) .map((item) => ({ label: asText(item.label), value: asText(item.value) })) .filter((item) => item.label && item.value), timeline: asRecords(raw.timeline) .map((item) => ({ year: asText(item.year), text: asText(item.text) })) .filter((item) => item.year || item.text), people: asRecords(raw.people) .map((item) => ({ name: asText(item.name), position: asText(item.position), degree: asText(item.degree) || undefined, photo: mediaSrc(item.photo, asText(item.photoUrl)) || undefined, hours: asText(item.hours) || undefined, address: asText(item.address) || undefined, })) .filter((item) => item.name), cards: asRecords(raw.cards) .map((item) => ({ title: asText(item.title), status: asText(item.status) || undefined, role: asText(item.role) || undefined, number: asText(item.number) || undefined, issuedAt: asText(item.issuedAt) || undefined, authority: asText(item.authority) || undefined, activity: asText(item.activity) || undefined, extract: asText(item.extract) || undefined, phone: asText(item.phone) || undefined, email: asText(item.email) || undefined, address: asText(item.address) || undefined, href: asText(item.href) || undefined, linkLabel: asText(item.linkLabel) || undefined, })) .filter((item) => item.title), faq: asRecords(raw.faq) .map((item) => ({ question: asText(item.question), answer: asText(item.answer) })) .filter((item) => item.question && item.answer), }; } function pagesApiPath(params: Record) { const search = new URLSearchParams({ depth: "1", limit: "50", sort: "sortOrder", ...params, }); return `/api/pages?${search.toString()}`; } export async function getSiteNav( section: SiteSection, options: { locale?: string; footer?: boolean } = {}, ): Promise { const { locale = "ru", footer = false } = options; try { const data = await cmsFetch>>( pagesApiPath({ locale, fallbackLocale: "ru", "where[section][equals]": section, "where[isPublished][equals]": "true", ...(footer ? { "where[showInFooter][equals]": "true" } : {}), }), { cache: "no-store" }, ); return unwrapList(data) .map(mapSiteNavItem) .filter((item): item is SiteNavItem => Boolean(item)); } catch { return []; } } export async function getSitePage( section: SiteSection, slug: string, locale = "ru", ): Promise { try { const data = await cmsFetch>>( pagesApiPath({ locale, fallbackLocale: "ru", "where[section][equals]": section, "where[slug][equals]": slug, "where[isPublished][equals]": "true", limit: "1", }), { cache: "no-store" }, ); const doc = unwrapList(data)[0]; return doc ? mapSitePage(doc) : null; } catch { return null; } } export type PublicDocument = { id: number; title: string; href: string; filename?: string; issuedAt?: string; }; export const FALLBACK_PUBLIC_CONTRACT_HREF = "/documents/Dogovor-platnie-2026.docx"; export async function getPublicDocuments(locale = "ru"): Promise { try { const search = new URLSearchParams({ depth: "1", limit: "50", sort: "-issuedAt", locale, fallbackLocale: "ru", "where[isPublic][equals]": "true", }); const data = await cmsFetch>>( `/api/documents?${search.toString()}`, { cache: "no-store" }, ); return unwrapList(data) .map((raw) => { const file = raw.file && typeof raw.file === "object" ? (raw.file as Record) : null; const href = mediaSrc(file); const title = asText(raw.title); if (!title || !href) return null; return { id: Number(raw.id) || 0, title, href, filename: asText(file?.filename) || undefined, issuedAt: asText(raw.issuedAt) || undefined, }; }) .filter((item): item is PublicDocument => Boolean(item)); } catch { return []; } }