62 lines
10 KiB
TypeScript
62 lines
10 KiB
TypeScript
import { useEffect, useState, type Ref } from 'react';
|
||
import { NavLink } from 'react-router-dom';
|
||
import { ArrowUpRight, Bookmark, Check, Clock3, Plus, RefreshCw, WifiOff } from './Icons';
|
||
import type { UseQueryResult } from '@tanstack/react-query';
|
||
import type { Board, City, Snapshot, Stop } from '../types';
|
||
import { countLabel, directionLabel, type StopGroup } from '../lib/stopGroups';
|
||
import { formatDate, formatTime, formatWait, localDate, useNow } from '../lib/time';
|
||
import { journeyLink } from '../lib/links';
|
||
import { QuickTripForm } from './QuickTrips';
|
||
import { HourlyTimetable } from './HourlyTimetable';
|
||
import { Alerts } from './ServiceAlerts';
|
||
import { ShareDeparture } from './ShareDeparture';
|
||
|
||
export function Departures({ stop, group, onDirection, query, favorite, onFavorite, online, view, date, onView, routeId, onRoute, city, mode, onTripSaved, onNotice, boardRef }: {
|
||
stop?: Stop; group?: StopGroup; onDirection: (stop: Stop) => void;
|
||
query: UseQueryResult<Snapshot<Board>, Error>; favorite: boolean; onFavorite: () => void; online: boolean;
|
||
view: 'next' | 'day'; date: string; onView: (view: 'next' | 'day', date?: string) => void;
|
||
routeId: number | null; onRoute: (route: number | null) => void; city?: City; mode: 'live' | 'demo'; onTripSaved: () => void;
|
||
onNotice: (notice: string) => void; boardRef: Ref<HTMLElement>;
|
||
}) {
|
||
const now = useNow();
|
||
const [expanded, setExpanded] = useState(false);
|
||
const [saveTrip, setSaveTrip] = useState(false);
|
||
useEffect(() => { setExpanded(false); }, [stop?.id, view, date, routeId]);
|
||
const fullDay = view === 'day';
|
||
const board = query.data?.data;
|
||
const stale = !online || !!query.data?.saved || query.isError;
|
||
const departures = (board?.departures ?? []).filter(departure => (fullDay || Date.parse(departure.scheduledAt) >= now - 60000) && (!routeId || departure.routeId === routeId));
|
||
const visible = expanded ? departures : departures.slice(0, 8);
|
||
return <section ref={boardRef} id="departures" tabIndex={-1} className="boardCard" aria-labelledby="board-title">
|
||
<div className="boardHeading"><span className="eyebrow"><Clock3 size={20} />{fullDay ? 'Расписание на дату' : 'Ближайшие отправления'}</span>{stop && <button className={`iconButton favoriteToggle ${favorite ? 'isFavorite' : ''}`} aria-label={favorite ? 'Убрать остановку из избранного' : 'Добавить остановку в избранное'} aria-pressed={favorite} onClick={onFavorite}><span className="bookmarkMotion" key={favorite ? 'saved' : 'unsaved'}><Bookmark size={24} fill={favorite ? 'currentColor' : 'none'} /></span></button>}</div>
|
||
<h2 id="board-title">{group?.name ?? stop?.name ?? 'Ваша остановка'}</h2>
|
||
{group && group.stops.length > 1 ? <div className="directionFilters" role="group" aria-label="Направления"><span className="directionLabel">Направление</span><div className="directionChips">{group.stops.map((item, index) => <button key={item.id} className={item.id === stop?.id ? 'selected' : ''} aria-pressed={item.id === stop?.id} onClick={() => onDirection(item)}>{directionLabel(group, item, index)}</button>)}</div></div>
|
||
: <p className="boardDirection">{stop?.direction || (stop ? '' : 'Выберите остановку — покажем расписание')}</p>}
|
||
{stop && city && !!board?.routes.length && <button className="textButton saveTripButton" onClick={() => setSaveTrip(true)}><Plus size={18} />Сохранить поездку</button>}
|
||
{stop && <>
|
||
<div className="scheduleControls"><div className="scheduleViews" role="group" aria-label="Режим расписания"><button className={!fullDay ? 'selected' : ''} aria-pressed={!fullDay} onClick={() => onView('next')}>Ближайшие</button><button className={fullDay ? 'selected' : ''} aria-pressed={fullDay} onClick={() => onView('day')}>На дату</button></div>{fullDay && <div className="scheduleDate"><label htmlFor="schedule-date">Дата поездки</label><input id="schedule-date" type="date" value={date} min="2000-01-02" max="2100-12-31" onChange={event => { if (event.target.value && event.target.validity.valid) onView('day', event.target.value); }} /><button className="textButton" onClick={() => onView('day', localDate(now))}>Сегодня</button></div>}</div>
|
||
<div className="boardMeta"><span className={`scheduleBadge ${stale ? 'savedBadge' : ''}`}>{stale ? <WifiOff size={14} /> : <Check size={14} />}{stale ? 'Сохранённые данные' : 'По расписанию'}</span><span>{board ? `${formatDate(board.updatedAt)} · обновлено в ${formatTime(board.updatedAt)}` : 'Сегодня'}</span></div>
|
||
{fullDay && <p className="timetableSummary">Все отправления за {formatDate(`${date}T12:00:00+03:00`)}{board ? ` · ${countLabel(departures.length, ['рейс', 'рейса', 'рейсов'])}` : ''}{board?.isHoliday && ` · ${board.holidayName || 'праздничный день'}`}</p>}
|
||
{board && board.routes.length > 0 && <div className="routeFilters" aria-label="Фильтр по маршруту"><button className={!routeId ? 'selected' : ''} onClick={() => onRoute(null)} aria-pressed={!routeId}>Все маршруты</button>{board.routes.map(route => <button key={route.id} title={route.name} className={routeId === route.id ? 'selected' : ''} aria-label={`Маршрут ${route.number}`} aria-pressed={routeId === route.id} onClick={() => onRoute(route.id)}>{route.number}</button>)}</div>}
|
||
{board && city && <Alerts mode={mode} cityId={city.id} online={online} routes={board.routes.filter(route => !routeId || route.id === routeId)} />}
|
||
</>}
|
||
{stale && board && <p className="savedNotice" role="status">{online ? 'Не удалось обновить расписание.' : 'Вы офлайн.'} Показываем данные, сохранённые {formatDate(board.updatedAt)} в {formatTime(board.updatedAt)}. Они могут быть устаревшими.</p>}
|
||
{!stop ? <div className="boardEmpty"><span className="emptyIcon"><ArrowUpRight size={36} /></span><h3>Начнём с остановки</h3><p>Найдите нужную остановку<br />и выберите направление.</p></div>
|
||
: query.isPending ? <div className="skeletonRows" aria-label="Загрузка расписания" role="status">{[1, 2, 3].map(i => <div className="skeletonRow" key={i}><i /><span /><i /></div>)}</div>
|
||
: !board ? <div className="boardEmpty"><span className="emptyIcon"><WifiOff size={30} /></span><h3>{online ? 'Расписание пока недоступно' : 'Нет сохранённого расписания'}</h3><p>{online ? 'Попробуйте обновить данные чуть позже.' : 'Для этой остановки данные ещё не загружались. Подключитесь к интернету.'}</p><button className="secondaryButton" onClick={() => void query.refetch()} disabled={query.isFetching}>Попробовать снова</button></div>
|
||
: departures.length === 0 ? <div className="boardEmpty"><span className="emptyIcon"><Clock3 size={30} /></span><h3>{fullDay ? 'На эту дату нет отправлений' : routeId ? 'Нет ближайших отправлений' : 'Пока нет отправлений'}</h3><p>{fullDay ? 'Попробуйте другую дату, маршрут или направление.' : stale ? 'В сохранённых данных не осталось предстоящих отправлений. Обновите расписание при подключении к сети.' : 'Для этой остановки сейчас нет ближайших отправлений. Попробуйте другое направление.'}</p>{!fullDay && <button className="secondaryButton" onClick={() => onView('day')}>Посмотреть весь день</button>}</div>
|
||
: fullDay && city ? <HourlyTimetable key={`${stop.id}:${date}:${routeId ?? 'all'}`} departures={departures} now={now} mode={mode} cityId={city.id} stopId={stop.id} date={date} routeId={routeId} />
|
||
: <ul className="departureList" key={`${stop.id}:${view}:${date}:${routeId ?? 'all'}`}>{visible.map((departure, i) => {
|
||
const departed = Date.parse(departure.scheduledAt) < now - 60000;
|
||
const wait = fullDay && localDate(Date.parse(departure.scheduledAt)) !== localDate(now) ? '' : departed ? 'отправился' : formatWait((Date.parse(departure.scheduledAt) - now) / 60000);
|
||
return <li className="departureEntry" key={`${departure.routeId}:${departure.scheduledAt}:${i}`}><NavLink className={`departureRow ${fullDay && departed ? 'pastDeparture' : ''}`} to={journeyLink(mode, city!.id, stop.id, departure, view, date, routeId)} aria-label={`Маршрут ${departure.routeNumber}, отправление ${formatTime(departure.scheduledAt)}, посмотреть рейс`} style={{ animationDelay: `${Math.min(i, 7) * 18}ms` }}>
|
||
<span className="routeNumber">{departure.routeNumber}</span><div className="departureName"><span className="transportLabel">Маршрут</span><strong>{departure.direction}</strong>{departure.note && <span className="tripNote">{departure.note}</span>}</div><div className="departureTime"><strong>{formatTime(departure.scheduledAt)}</strong>{wait && <span className={!departed && i === 0 ? 'soon' : ''}>{wait}</span>}</div>
|
||
</NavLink><ShareDeparture departure={departure} cityId={city!.id} stopId={stop.id} stopName={stop.name} mode={mode} onNotice={onNotice} /></li>;
|
||
})}</ul>}
|
||
{board?.partial && <p className="partialNotice">Часть маршрутов временно недоступна. Показаны доступные отправления.</p>}
|
||
{!fullDay && departures.length > 8 && !expanded && <button className="showMore" onClick={() => setExpanded(true)}>Показать ещё {departures.length - 8}</button>}
|
||
{saveTrip && city && stop && board && <QuickTripForm city={city} stop={stop} routes={board.routes} routeId={routeId} mode={mode} onClose={() => setSaveTrip(false)} onSaved={onTripSaved} />}
|
||
{stop && <div className="boardFooter"><span>Время по расписанию, без учёта задержек</span><button className="refreshButton" disabled={query.isFetching || !online} onClick={() => void query.refetch()}><RefreshCw size={15} className={query.isFetching ? 'spinning' : ''} />{query.isFetching ? 'Обновляем' : 'Обновить'}</button></div>}
|
||
</section>;
|
||
}
|