Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+425
View File
@@ -0,0 +1,425 @@
'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>
);
}