dana-medical-site/web/src/pages/ServicePage.jsx

313 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}