Files
2026-10-25 22:55:29 +04:00

426 lines
16 KiB
TypeScript
Raw Permalink 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.
'use client';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { Flat } from '../lib/flats';
// Страница /ru/apartments: квартиры AREV из CRM, галерея фото, описание и запись на просмотр.
// Страница только на русском (решение владельца), поэтому тексты здесь, а не в словарях i18n.
const TIMES = [
{ id: 'morning', label: 'Утро', hint: '10:00–13:00' },
{ id: 'day', label: 'День', hint: '13:00–17:00' },
{ id: 'evening', label: 'Вечер', hint: '17:00–20:00' },
] as const;
type TimeId = (typeof TIMES)[number]['id'];
const ERRORS: Record<string, string> = {
CONTACT_REQUIRED: 'Укажите телефон или Telegram — так агент сможет с вами связаться.',
BAD_PHONE: 'Похоже, в телефоне ошибка. Пример: +374 91 123456.',
BAD_TELEGRAM: 'Ник в Telegram — латиницей, например @anna_ru.',
BAD_DAY: 'Этот день уже недоступен — выберите другой.',
OBJECT_NOT_FOUND: 'Эту квартиру только что сняли с показа. Выберите другую.',
NOT_FOUND: 'Эту квартиру только что сняли с показа. Выберите другую.',
TOO_MANY: 'Слишком много заявок подряд. Попробуйте через несколько минут.',
};
const FALLBACK_ERROR = 'Не получилось отправить заявку. Попробуйте ещё раз или напишите нам в Telegram.';
const SYMBOL: Record<string, string> = {
USD: '$',
AMD: '֏',
RUB: '₽',
EUR: '€',
};
function price(f: Flat) {
if (!f.price) return 'Цена по запросу';
const n = Math.round(f.price).toLocaleString('ru-RU');
const c = f.currency ?? 'AMD';
const sum = c === 'USD' || c === 'EUR' ? `${SYMBOL[c]}${n}` : `${n} ${SYMBOL[c] ?? c}`;
return f.dealType === 'rent' ? `${sum} / мес` : sum;
}
const KIND: Record<string, string> = {
apartment: 'квартира',
house: 'дом',
commercial: 'помещение',
};
function title(f: Flat) {
const kind = KIND[f.propertyType ?? 'apartment'] ?? 'объект';
return f.rooms ? `${f.rooms}-комн. ${kind}` : kind[0]!.toUpperCase() + kind.slice(1);
}
function facts(f: Flat) {
return [f.area ? `${Math.round(f.area)} м²` : null, f.floor ? (f.floorsTotal ? `этаж ${f.floor}/${f.floorsTotal}` : `этаж ${f.floor}`) : null].filter(
Boolean,
) as string[];
}
const photo = (f: Flat, i: number) => `/api/flats/photo/${f.id}/${i}`;
/** Ближайшие 7 дней, начиная с завтра, по Еревану */
function nextDays() {
const ymd = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Yerevan' });
const week = new Intl.DateTimeFormat('ru-RU', {
weekday: 'short',
timeZone: 'Asia/Yerevan',
});
const date = new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
timeZone: 'Asia/Yerevan',
});
return Array.from({ length: 7 }, (_, k) => {
const d = new Date(Date.now() + (k + 1) * 86_400_000);
return {
id: ymd.format(d),
week: k === 0 ? 'завтра' : week.format(d),
date: date.format(d).replace('.', ''),
};
});
}
const ROOMS = [
{ id: 0, label: 'Все' },
{ id: 1, label: '1 комн.' },
{ id: 2, label: '2 комн.' },
{ id: 3, label: '3 комн.' },
{ id: 4, label: '4+' },
];
export function Flats({ flats, telegramUrl }: { flats: Flat[]; telegramUrl: string }) {
const [rooms, setRooms] = useState(0);
const [booking, setBooking] = useState<Flat | null>(null);
const [viewer, setViewer] = useState<{ flat: Flat; index: number } | null>(null);
const shown = useMemo(() => flats.filter((f) => !rooms || (rooms === 4 ? (f.rooms ?? 0) >= 4 : f.rooms === rooms)), [flats, rooms]);
const available = useMemo(() => new Set(flats.map((f) => Math.min(f.rooms ?? 0, 4))), [flats]);
return (
<>
<div className="fl-filter" role="group" aria-label="Комнаты">
{ROOMS.filter((r) => !r.id || available.has(r.id)).map((r) => (
<button key={r.id} type="button" className="fl-chip" aria-pressed={rooms === r.id} onClick={() => setRooms(r.id)}>
{r.label}
</button>
))}
<span className="fl-count">{countLabel(shown.length)}</span>
</div>
<ul className="fl-list">
{shown.map((f) => (
<FlatCard key={f.id} flat={f} onBook={() => setBooking(f)} onOpen={(index) => setViewer({ flat: f, index })} />
))}
</ul>
{viewer && (
<Viewer
flat={viewer.flat}
start={viewer.index}
onClose={() => setViewer(null)}
onBook={() => {
setBooking(viewer.flat);
setViewer(null);
}}
/>
)}
{booking && <Booking flat={booking} telegramUrl={telegramUrl} onClose={() => setBooking(null)} />}
</>
);
}
function countLabel(n: number) {
const m10 = n % 10;
const m100 = n % 100;
const word = m10 === 1 && m100 !== 11 ? 'вариант' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'варианта' : 'вариантов';
return `${n} ${word}`;
}
function FlatCard({ flat: f, onBook, onOpen }: { flat: Flat; onBook: () => void; onOpen: (index: number) => void }) {
const [open, setOpen] = useState(false);
return (
<li className="fl-card">
<Gallery flat={f} onOpen={onOpen} />
<div className="fl-card__body">
<div className="fl-card__head">
<div>
<h2 className="fl-card__title">{title(f)}</h2>
<p className="fl-card__facts">{[f.district, ...facts(f)].filter(Boolean).join(' · ')}</p>
</div>
<p className="fl-card__price">{price(f)}</p>
</div>
<div className="fl-text" data-open={open}>
<p>{f.text}</p>
</div>
<button type="button" className="fl-more" onClick={() => setOpen(!open)} aria-expanded={open}>
{open ? 'Свернуть' : 'Читать описание полностью'}
</button>
<button type="button" className="bc-btn fl-book" onClick={onBook}>
Записаться на просмотр
</button>
</div>
</li>
);
}
/** Лента фото: листается пальцем, стрелками и колесом; по нажатию — на весь экран */
function Gallery({ flat: f, onOpen, start = 0, full = false }: { flat: Flat; onOpen?: (index: number) => void; start?: number; full?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(start);
const count = f.photos;
useEffect(() => {
const el = ref.current;
if (el && start)
el.scrollTo({
left: start * el.clientWidth,
behavior: 'instant' as ScrollBehavior,
});
}, [start]);
const go = (to: number) => {
const el = ref.current;
if (!el) return;
const next = Math.max(0, Math.min(count - 1, to));
el.scrollTo({ left: next * el.clientWidth, behavior: 'smooth' });
};
useEffect(() => {
if (!full) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') go(index + 1);
if (e.key === 'ArrowLeft') go(index - 1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
if (!count) return <div className="fl-gallery fl-gallery--empty">Фото скоро появятся</div>;
return (
<div className={full ? 'fl-gallery fl-gallery--full' : 'fl-gallery'}>
<div
ref={ref}
className="fl-gallery__track"
onScroll={(e) => {
const el = e.currentTarget;
setIndex(Math.round(el.scrollLeft / el.clientWidth));
}}
>
{Array.from({ length: count }, (_, i) => (
<button
key={i}
type="button"
className="fl-gallery__slide"
onClick={() => onOpen?.(i)}
tabIndex={onOpen ? 0 : -1}
aria-label={onOpen ? `Открыть фото ${i + 1} из ${count}` : undefined}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo(f, i)} alt={`${title(f)}, фото ${i + 1}`} loading={i === start ? 'eager' : 'lazy'} decoding="async" />
</button>
))}
</div>
{count > 1 && (
<>
<button
type="button"
className="fl-gallery__nav fl-gallery__nav--prev"
onClick={() => go(index - 1)}
disabled={index === 0}
aria-label="Предыдущее фото"
>
‹
</button>
<button
type="button"
className="fl-gallery__nav fl-gallery__nav--next"
onClick={() => go(index + 1)}
disabled={index >= count - 1}
aria-label="Следующее фото"
>
›
</button>
</>
)}
<span className="fl-gallery__count">
{index + 1} / {count}
</span>
</div>
);
}
function useDialog(onClose: () => void) {
const ref = useRef<HTMLDialogElement>(null);
const latest = useRef(onClose);
latest.current = onClose;
useEffect(() => {
const d = ref.current;
if (!d) return;
if (!d.open) d.showModal();
document.body.style.overflow = 'hidden';
// Esc и закрытие формой — тот же выход, что и крестик
const close = () => latest.current();
d.addEventListener('close', close);
return () => {
d.removeEventListener('close', close);
document.body.style.overflow = '';
};
}, []);
return ref;
}
function Viewer({ flat, start, onClose, onBook }: { flat: Flat; start: number; onClose: () => void; onBook: () => void }) {
const ref = useDialog(onClose);
return (
<dialog ref={ref} className="fl-viewer" aria-label={`Фото: ${title(flat)}`} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="fl-viewer__bar">
<span>
{title(flat)} · {price(flat)}
</span>
<button type="button" className="fl-close" onClick={onClose} aria-label="Закрыть">
×
</button>
</div>
<Gallery flat={flat} start={start} full />
<button type="button" className="bc-btn fl-book fl-viewer__book" onClick={onBook}>
Записаться на просмотр
</button>
</dialog>
);
}
function Booking({ flat, telegramUrl, onClose }: { flat: Flat; telegramUrl: string; onClose: () => void }) {
const ref = useDialog(onClose);
const days = useMemo(nextDays, []);
const [day, setDay] = useState(days[0]!.id);
const [time, setTime] = useState<TimeId>('evening');
const [state, setState] = useState<'form' | 'sending' | 'done'>('form');
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const phone = String(data.get('phone') ?? '').trim();
const telegram = String(data.get('telegram') ?? '').trim();
if (!phone && !telegram) return setError(ERRORS.CONTACT_REQUIRED!);
setError(null);
setState('sending');
try {
const res = await fetch('/api/flats/request', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
leadId: flat.id,
day,
time,
name: data.get('name'),
phone,
telegram,
website: data.get('website'),
}),
});
const body = (await res.json().catch(() => ({}))) as { code?: string };
if (!res.ok) {
setError(ERRORS[body.code ?? ''] ?? FALLBACK_ERROR);
return setState('form');
}
setState('done');
} catch {
setError(FALLBACK_ERROR);
setState('form');
}
};
const chosen = days.find((d) => d.id === day)!;
const slot = TIMES.find((t) => t.id === time)!;
return (
<dialog ref={ref} className="fl-modal" aria-labelledby="fl-modal-title" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="fl-modal__box">
<button type="button" className="fl-close fl-modal__close" onClick={onClose} aria-label="Закрыть">
×
</button>
{state === 'done' ? (
<div className="fl-done">
<h2 id="fl-modal-title">Заявка принята</h2>
<p>
Агент свяжется с вами, чтобы подтвердить просмотр: {chosen.week}, {chosen.date}, {slot.label.toLowerCase()} ({slot.hint}).
</p>
<button type="button" className="bc-btn fl-book" onClick={onClose}>
Смотреть другие квартиры
</button>
</div>
) : (
<form onSubmit={submit} noValidate>
<h2 id="fl-modal-title">Запись на просмотр</h2>
<p className="fl-modal__flat">
{title(flat)}
{flat.district ? `, ${flat.district}` : ''} · {price(flat)}
</p>
<fieldset className="fl-field">
<legend>День</legend>
<div className="fl-days">
{days.map((d) => (
<label key={d.id} className="fl-day">
<input type="radio" name="day" value={d.id} checked={day === d.id} onChange={() => setDay(d.id)} />
<span>
<small>{d.week}</small>
<b>{d.date}</b>
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="fl-field">
<legend>Время</legend>
<div className="fl-times">
{TIMES.map((t) => (
<label key={t.id} className="fl-day">
<input type="radio" name="time" value={t.id} checked={time === t.id} onChange={() => setTime(t.id)} />
<span>
<b>{t.label}</b>
<small>{t.hint}</small>
</span>
</label>
))}
</div>
</fieldset>
<label className="fl-input">
<span>Как к вам обращаться</span>
<input name="name" required maxLength={80} autoComplete="name" placeholder="Имя" />
</label>
<div className="fl-contacts">
<label className="fl-input">
<span>Телефон</span>
<input name="phone" type="tel" maxLength={40} autoComplete="tel" placeholder="+374 …" inputMode="tel" />
</label>
<label className="fl-input">
<span>или Telegram</span>
<input name="telegram" maxLength={80} placeholder="@username" autoCapitalize="none" autoCorrect="off" />
</label>
</div>
<input name="website" tabIndex={-1} autoComplete="off" className="fl-hp" aria-hidden="true" />
{error && (
<p className="fl-error" role="alert">
{error}
{error === FALLBACK_ERROR && (
<>
{' '}
<a href={telegramUrl}>Написать в Telegram</a>
</>
)}
</p>
)}
<button type="submit" className="bc-btn fl-book" disabled={state === 'sending'}>
{state === 'sending' ? 'Отправляем…' : 'Записаться'}
</button>
<p className="fl-note">
Нажимая «Записаться», вы соглашаетесь с <a href="/ru/privacy">политикой конфиденциальности</a>. Мы передадим контакты только агенту, который ведёт
эту квартиру.
</p>
</form>
)}
</div>
</dialog>
);
}