313 lines
15 KiB
JavaScript
313 lines
15 KiB
JavaScript
import { useMemo } from 'react';
|
||
import { Link, useParams } from 'react-router-dom';
|
||
import urgentImage from '../assets/figma/service-urgent-3d.webp';
|
||
import { api } from '../api.js';
|
||
import BookingSection from '../components/BookingSection.jsx';
|
||
import Breadcrumbs from '../components/Breadcrumbs.jsx';
|
||
import ContactsSection from '../components/ContactsSection.jsx';
|
||
import { DoctorRow } from '../components/DoctorCard.jsx';
|
||
import { useLeadModal } from '../context/LeadModalContext.jsx';
|
||
import { useSiteData } from '../context/SiteData.jsx';
|
||
import { priceLabel, telHref } from '../lib/format.js';
|
||
import { servicePath, specialtyPath, priceSectionPath } from '../lib/links.js';
|
||
import { servicePriceSection } from '../lib/serviceNavigation.js';
|
||
import { DIRECTION_MEDIA, SERVICE_IMAGES, directionOf, serviceSpecialty } from '../lib/media.js';
|
||
import { URGENT_TITLE, parseBlocks } from '../lib/serviceContent.js';
|
||
import { cleanText } from '../lib/text.js';
|
||
import { useApi } from '../lib/useApi.js';
|
||
import { usePageTitle } from '../lib/usePageTitle.js';
|
||
import { serviceMetadata, serviceSchema, jsonForHTML } from '../lib/seo.js';
|
||
import { useInitialApiData } from '../context/ApiData.jsx';
|
||
import StubPage from './StubPage.jsx';
|
||
import './ServicePage.css';
|
||
|
||
const noData = () => Promise.resolve({ data: null, meta: {} });
|
||
|
||
// /services/<направление>/ — список услуг направления;
|
||
// /services/<направление>/<услуга>/ и направления без вложенных услуг — страница услуги.
|
||
export default function ServicePage() {
|
||
const params = useParams();
|
||
const path = (params['*'] || '').replace(/^\/+|\/+$/g, '');
|
||
const nested = path.includes('/');
|
||
const direction = directionOf(path);
|
||
|
||
const service = useApi((signal) => api.service(path, { signal }), [path], `service:${path}`);
|
||
const parent = useApi((signal) => (nested ? api.service(direction, { signal }) : noData()), [direction, nested], `service:${direction}`);
|
||
const siteURL = useInitialApiData('siteURL') || (typeof window !== 'undefined' ? window.location.origin : '');
|
||
usePageTitle(service.data?.name, service.data ? serviceMetadata(service.data, siteURL) : undefined);
|
||
|
||
if (!path) return <StubPage notFound />;
|
||
if (service.error?.status === 404) return <StubPage notFound />;
|
||
if (service.error) return <p className="state">Не удалось загрузить услугу. Обновите страницу.</p>;
|
||
if (!service.data || service.data.path !== path) return <p className="state service-loading">Загружаем…</p>;
|
||
|
||
const s = service.data;
|
||
if (s.children?.length) return <DirectionView direction={s} />;
|
||
return <ServiceView service={s} parent={nested ? parent.data : null} />;
|
||
}
|
||
|
||
function DirectionView({ direction }) {
|
||
const { open } = useLeadModal();
|
||
const site = useSiteData();
|
||
const siteURL = useInitialApiData('siteURL') || (typeof window !== 'undefined' ? window.location.origin : '');
|
||
|
||
return (
|
||
<div className="direction-page">
|
||
<div className="container direction-page__inner">
|
||
<Breadcrumbs
|
||
items={[{ to: '/', label: 'Главная' }, { to: '/services/', label: 'Услуги' }, { label: direction.name }]}
|
||
className="page-crumbs"
|
||
/>
|
||
<h1 className="direction-page__title">{direction.seo?.h1 || direction.name}</h1>
|
||
|
||
<div className="direction-cards">
|
||
{direction.children.map((c) => (
|
||
<article key={c.id} className="direction-card">
|
||
<h2 className="direction-card__name">
|
||
<Link to={servicePath(c.path)}>{c.name}</Link>
|
||
</h2>
|
||
{c.lead && <p className="direction-card__lead">{cleanText(c.lead)}</p>}
|
||
<div className="direction-card__actions">
|
||
<Link to={servicePath(c.path)} className="btn btn--outline direction-card__btn">
|
||
Подробнее
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className="btn btn--coral direction-card__btn"
|
||
onClick={() => open({
|
||
form: 'appointment', service: c, message: `Услуга: ${c.name}`,
|
||
specialty: serviceSpecialty(c.path),
|
||
})}
|
||
>
|
||
Записаться
|
||
</button>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<ContactsSection />
|
||
<script id="service-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonForHTML(serviceSchema(direction, null, site, siteURL, [])) }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ServiceView({ service, parent }) {
|
||
const { open } = useLeadModal();
|
||
const { phone } = useSiteData();
|
||
const sections = useMemo(() => service.content_sections || parseBlocks(service.blocks), [service.blocks, service.content_sections]);
|
||
const site = useSiteData();
|
||
const siteURL = useInitialApiData('siteURL') || (typeof window !== 'undefined' ? window.location.origin : '');
|
||
const metadata = serviceMetadata(service, siteURL);
|
||
const prices = useApi((signal) => api.prices({}, { signal }), [], 'prices');
|
||
const priceSection = servicePriceSection(service.path, prices.data);
|
||
|
||
const media = DIRECTION_MEDIA[directionOf(service.path)];
|
||
const image = SERVICE_IMAGES[service.path] || service.image || (!parent ? media?.image : '');
|
||
const lead = cleanText(service.lead);
|
||
const book = () => open({ form: 'appointment', service, specialty: serviceSpecialty(service.path), message: `Услуга: ${service.name}` });
|
||
|
||
const crumbs = [
|
||
{ to: '/', label: 'Главная' },
|
||
{ to: '/services/', label: 'Услуги' },
|
||
];
|
||
if (parent) crumbs.push({ to: servicePath(parent.path), label: parent.name });
|
||
crumbs.push({ label: service.name });
|
||
|
||
return (
|
||
<div className="service-page">
|
||
<section className="service-hero">
|
||
<div className="container service-hero__row">
|
||
<div className="service-hero__copy">
|
||
<Breadcrumbs items={crumbs} className="service-hero__crumbs" />
|
||
<h1 className="service-hero__title">{metadata.h1}</h1>
|
||
{lead && <p className="service-hero__lead">{lead}</p>}
|
||
{(parent || service.price_from) && (
|
||
<div className="service-hero__badges">
|
||
{parent && <Link className="service-hero__badge" to={servicePath(parent.path)}>{parent.name}</Link>}
|
||
{service.price_from ? (
|
||
<span className="service-hero__badge">{priceLabel({ price: service.price_from, from: true })}</span>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
<div className="service-hero__actions">
|
||
<button type="button" className="btn btn--coral service-hero__btn" onClick={book}>
|
||
Записаться на приём
|
||
</button>
|
||
<a className="btn btn--outline service-hero__btn service-hero__btn--phone" href={telHref(phone)}>
|
||
{phone}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{image && (
|
||
<div className="service-hero__image">
|
||
<img src={image} alt={service.name} fetchpriority="high" decoding="async" />
|
||
{parent && (
|
||
<p className="service-hero__info">
|
||
<span>Направление</span>
|
||
<b>{parent.short_name || parent.name}</b>
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{sections.map((section, i) =>
|
||
URGENT_TITLE.test(section.title) ? (
|
||
<UrgentSection key={i} section={section} onBook={book} />
|
||
) : (
|
||
<ContentSection key={i} section={section} fallbackTitle={i === 0 ? 'Об услуге' : ''} />
|
||
),
|
||
)}
|
||
|
||
{(service.prices?.length > 0 || service.path.startsWith('estetic/') || priceSection) && <PricesSection prices={service.prices || []} service={service} priceSection={priceSection} />}
|
||
|
||
<ServiceDoctors service={service} specialtySlug={serviceSpecialty(service.path)} />
|
||
<BookingSection service={service} />
|
||
<script id="service-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonForHTML(serviceSchema(service, parent, site, siteURL,
|
||
(service.doctors || []).filter((d) => !d.absent).sort((a, b) => Number(Boolean(b.photo)) - Number(Boolean(a.photo))).slice(0, 4))) }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ContentSection({ section, fallbackTitle }) {
|
||
const title = section.title || fallbackTitle;
|
||
const { items } = section;
|
||
// короткие пункты — оранжевыми карточками «01…», длинный перечень — обычным списком
|
||
const asCards = items.length > 0 && items.length <= 8 && items.every((t) => t.length <= 160);
|
||
|
||
return (
|
||
<section className="service-section">
|
||
<div className="container">
|
||
{title && <h2 className="service-section__title">{title}</h2>}
|
||
{section.paragraphs.map((p, i) => (
|
||
<p key={i} className="service-section__text">
|
||
{p}
|
||
</p>
|
||
))}
|
||
{items.length > 0 &&
|
||
(asCards ? (
|
||
<ol className="service-steps">
|
||
{items.map((t, i) => (
|
||
<li key={i} className="service-step">
|
||
<span className="service-step__num">{String(i + 1).padStart(2, '0')}</span>
|
||
<span className="service-step__text">{t}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : (
|
||
<ul className="service-list">
|
||
{items.map((t, i) => (
|
||
<li key={i}>{t}</li>
|
||
))}
|
||
</ul>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function UrgentSection({ section, onBook }) {
|
||
return (
|
||
<section className="service-urgent">
|
||
<div className="container service-urgent__row">
|
||
<div className="service-urgent__intro">
|
||
<h2 className="service-urgent__title">{section.title}</h2>
|
||
{section.paragraphs.map((p, i) => (
|
||
<p key={i} className="service-urgent__text">
|
||
{p}
|
||
</p>
|
||
))}
|
||
<p className="service-urgent__text">
|
||
Не откладывайте консультацию, если состояние ухудшается или появляются необычные симптомы.
|
||
</p>
|
||
<button type="button" className="btn btn--coral service-urgent__btn" onClick={onBook}>
|
||
Записаться срочно
|
||
</button>
|
||
</div>
|
||
{section.items.length > 0 && (
|
||
<ul className="service-urgent__list">
|
||
{section.items.map((t, i) => (
|
||
<li key={i}>{t}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<img className="service-urgent__image" src={urgentImage} alt="" loading="lazy" />
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function PricesSection({ prices, service, priceSection }) {
|
||
const { open } = useLeadModal();
|
||
const headings = {
|
||
'estetic/rinoplastika': 'Стоимость ринопластики в Махачкале',
|
||
'estetic/blefaroplastika': 'Стоимость блефаропластики в Махачкале',
|
||
'estetic/abdominoplastika': 'Стоимость абдоминопластики в Махачкале',
|
||
'estetic/liposaktsiya': 'Стоимость липосакции в Махачкале',
|
||
'estetic/mammoplastika': 'Стоимость маммопластики в Махачкале',
|
||
'estetic/ottoplastika': 'Стоимость отопластики в Махачкале',
|
||
};
|
||
return (
|
||
<section className="service-section service-section--prices">
|
||
<div className="container">
|
||
<h2 className="service-section__title">{headings[service.path] || 'Стоимость'}</h2>
|
||
{prices.length > 0 && <ul className="service-prices">
|
||
{prices.map((p) => (
|
||
<li key={p.id} className="service-price">
|
||
<span className="service-price__name">{p.name}</span>
|
||
<b className="service-price__value">{priceLabel(p)}</b>
|
||
</li>
|
||
))}
|
||
</ul>}
|
||
<p className="service-section__note">Актуальную стоимость уточняйте у администратора клиники.{priceSection && <> <Link to={priceSectionPath(priceSection.slug)}>Прайс направления «{priceSection.name}»</Link>.</>}</p>
|
||
{!prices.length && <button type="button" className="btn btn--outline service-section__ask" onClick={() => open({ form: 'service_order', service, message: `Стоимость услуги: ${service.name}` })}>Уточнить стоимость</button>}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ServiceDoctors({ service, specialtySlug }) {
|
||
const own = service.doctors || [];
|
||
// привязанные к услуге врачи — первыми, свободные места добирают врачи той же специальности
|
||
// (раньше один привязанный врач закрывал весь блок: у невролога показывалась только одна)
|
||
const fallback = own.length < 4 && Boolean(specialtySlug);
|
||
const bySpecialty = useApi(
|
||
(signal) => (fallback ? api.doctors({ specialty: specialtySlug }, { signal }) : noData()),
|
||
[fallback, specialtySlug], `doctors:${specialtySlug}`,
|
||
);
|
||
const specialties = useApi((signal) => (specialtySlug ? api.specialties({ signal }) : noData()), [specialtySlug], 'specialties');
|
||
|
||
const merged = [...own, ...(bySpecialty.data || []).filter((d) => !own.some((o) => o.id === d.id))];
|
||
// врачи с фото — первыми: карточка без фото в этом блоке выглядит пустой
|
||
const doctors = merged
|
||
.filter((d) => !d.absent)
|
||
.sort((a, b) => Number(Boolean(b.photo)) - Number(Boolean(a.photo)))
|
||
.slice(0, 4);
|
||
if (!doctors.length) return null;
|
||
|
||
const specialty = (specialties.data || []).find((sp) => sp.slug === specialtySlug);
|
||
|
||
return (
|
||
<section className="service-doctors">
|
||
<div className="container">
|
||
<h2 className="service-section__title">{specialty ? `${specialty.name} DANA medical` : 'Специалисты DANA medical'}</h2>
|
||
<p className="service-section__text">{fallback ? 'Специалисты направления. Подходящего для этой услуги врача и доступное время уточнит администратор.' : 'Выберите специалиста и запишитесь на удобное время.'}</p>
|
||
<div className="service-doctors__grid">
|
||
{doctors.map((d) => (
|
||
<DoctorRow key={d.id} doctor={d} />
|
||
))}
|
||
</div>
|
||
{specialtySlug && (
|
||
<Link to={specialtyPath(specialtySlug)} className="btn btn--outline service-doctors__all">
|
||
Все специалисты
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|