Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
'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>
|
||
);
|
||
}
|