1096 lines
34 KiB
TypeScript
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 [];
|
|
}
|
|
}
|