Files
Moniki_uslugi/apps/web/src/lib/api.ts
T

1096 lines
34 KiB
TypeScript

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<T>(path: string, options: FetchOptions = {}): Promise<T> {
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<string | null> {
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<string, unknown>).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<T> = {
items: T[];
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
nextPage: number | null;
};
type PayloadList<T> = {
docs: T[];
totalDocs?: number;
totalPages?: number;
page?: number;
hasNextPage?: boolean;
nextPage?: number | null;
limit?: number;
};
function unwrapList<T>(data: PayloadList<T> | 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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>): 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<string, unknown>).title) : "",
)
.filter(Boolean)
.join(" ")
: "";
const mediaUrl =
typeof raw.photo === "string"
? raw.photo
: raw.photo && typeof raw.photo === "object"
? asText((raw.photo as Record<string, unknown>).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<string, unknown>): 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<Service[]> {
const locale = cmsLocale(params?.locale);
try {
const catalog = await cmsFetch<{ services: Record<string, unknown>[] }>(
`/api/custom/catalog?locale=${encodeURIComponent(locale)}&fallbackLocale=ru`,
{ fallback: { services: demoServices } },
);
let list = (catalog.services || []).map(mapService);
if (!list.length) {
list = unwrapList(
await cmsFetch<PayloadList<Record<string, unknown>>>(
`/api/services?limit=10000&depth=0&where[isPublished][equals]=true&locale=${encodeURIComponent(locale)}&fallbackLocale=ru`,
{ fallback: { docs: demoServices as unknown as Record<string, unknown>[] } },
),
).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<Service | null> {
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/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<string, unknown>[] } },
);
const doc = unwrapList(data)[0];
return doc ? mapService(doc) : null;
} catch {
return demoServices.find((s) => s.slug === slug) ?? null;
}
}
function paginateLocal<T>(all: T[], page: number, limit: number): Paginated<T> {
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<T, R>(
data: PayloadList<T> | T[],
map: (item: T) => R,
page: number,
limit: number,
fallbackTotal = 0,
): Paginated<R> {
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<Doctor[]> {
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<Paginated<Doctor>> {
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<PayloadList<Record<string, unknown>>>(`/api/doctors?${query}`, {
cache: "no-store",
fallback: { docs: demoDoctors as unknown as Record<string, unknown>[] },
});
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<Doctor | null> {
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/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<string, unknown>[],
},
},
);
const doc = unwrapList(data)[0];
return doc ? mapDoctor(doc) : null;
} catch {
return demoDoctors.find((d) => d.slug === slug) ?? null;
}
}
function mapBranch(raw: Record<string, unknown>): Branch {
const workHours = Array.isArray(raw.workHours)
? raw.workHours
.map((item) => {
if (!item || typeof item !== "object") return "";
const rec = item as Record<string, unknown>;
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<Branch[]> {
try {
const query = new URLSearchParams({
limit: "50",
depth: "1",
locale,
sort: "sortOrder",
});
const data = await cmsFetch<PayloadList<Record<string, unknown>> | Record<string, unknown>[]>(
`/api/branches?${query}`,
{ fallback: demoBranches as unknown as Record<string, unknown>[] },
);
const items = sortBranches(unwrapList(data as PayloadList<Record<string, unknown>>).map(mapBranch));
return items.length ? items : demoBranches;
} catch {
return demoBranches;
}
}
export async function getBranchBySlug(slug: string, locale = "ru"): Promise<Branch | null> {
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/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<string, unknown>[],
},
},
);
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<HeroBanner[]> {
const query = new URLSearchParams({
limit: "20",
depth: "1",
locale,
sort: "sortOrder",
"where[isPublished][equals]": "true",
});
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(`/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<string, unknown>): 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<string, unknown>): 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<Promotion[]> {
const query = new URLSearchParams({
limit: "50",
depth: "1",
locale,
sort: "sortOrder",
"where[isPublished][equals]": "true",
});
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(`/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<string, unknown>): 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<NewsItem[]> {
try {
const query = withLocale(
new URLSearchParams({
limit: "20",
depth: "1",
sort: "-publishedAt",
"where[isPublished][equals]": "true",
}),
locale,
);
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/api/posts?${query}`,
{ cache: "no-store", fallback: { docs: demoNews as unknown as Record<string, unknown>[] } },
);
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<NewsItem | null> {
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/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<string, unknown>[],
},
},
);
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<GalleryItem[]> {
const query = withLocale(new URLSearchParams({ limit: "100", depth: "1" }), locale);
return cmsFetch<GalleryItem[]>(`/api/gallery?${query}`, { fallback: demoGallery }).then((data) =>
unwrapList(data as PayloadList<GalleryItem> | GalleryItem[]),
);
}
export async function getReviews(
doctorId?: string,
limit?: number,
locale = "ru",
): Promise<Review[]> {
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<Paginated<Review>> {
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<PayloadList<Record<string, unknown>>>(`/api/reviews?${query}`, {
cache: "no-store",
fallback: { docs: demoReviews as unknown as Record<string, unknown>[] },
});
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<string, unknown>): Review {
const doctor = raw.doctor;
const doctorRecord = doctor && typeof doctor === "object" ? (doctor as Record<string, unknown>) : 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<Slot[]> {
const q = new URLSearchParams(params as Record<string, string>);
return cmsFetch<Slot[]>(`/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<void> {
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<void> {
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<Review> {
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> | (Service | Doctor)[]));
}
export async function addFavorite(payload: {
type: "service" | "doctor";
id: string;
}): Promise<void> {
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<void> {
const token = await getAuthToken();
await cmsFetch(`/api/favorites/${id}`, {
method: "DELETE",
token,
cache: "no-store",
fallback: {},
});
}
export async function getMyBookings(): Promise<Booking[]> {
const token = await getAuthToken();
return cmsFetch<Booking[]>(`/api/bookings?mine=true`, {
token,
cache: "no-store",
fallback: demoBookings,
}).then((data) => unwrapList(data as PayloadList<Booking> | 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<string, unknown>[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
}
function mapSiteNavItem(raw: Record<string, unknown>): 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<string, unknown>): 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<string, string>) {
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<SiteNavItem[]> {
const { locale = "ru", footer = false } = options;
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
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<SitePage | null> {
try {
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
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<PublicDocument[]> {
try {
const search = new URLSearchParams({
depth: "1",
limit: "50",
sort: "-issuedAt",
locale,
fallbackLocale: "ru",
"where[isPublic][equals]": "true",
});
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/api/documents?${search.toString()}`,
{ cache: "no-store" },
);
return unwrapList(data)
.map((raw) => {
const file = raw.file && typeof raw.file === "object" ? (raw.file as Record<string, unknown>) : 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 [];
}
}