initial commit
This commit is contained in:
189
src/App.tsx
Normal file
189
src/App.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowDown, ArrowRight, ArrowUpRight, Bookmark, BusFront, ChevronDown, MapPin, Search, WifiOff, X } from './components/Icons';
|
||||
import { DataError, loadResource } from './lib/api';
|
||||
import { db, readPreference, savePreference, stopKey } from './lib/storage';
|
||||
import { Departures } from './components/Departures';
|
||||
import { PwaControls } from './components/PwaControls';
|
||||
import { FavoriteCard } from './components/FavoriteCard';
|
||||
import { About } from './components/About';
|
||||
import { QuickTrips } from './components/QuickTrips';
|
||||
import { NearbyStops } from './components/NearbyStops';
|
||||
import { RoutePage } from './components/RoutePage';
|
||||
import { StopList, StopListItem } from './components/StopList';
|
||||
import { positiveParam } from './lib/links';
|
||||
import { isTravelDate, localDate } from './lib/time';
|
||||
import { countLabel, groupStops, type StopGroup } from './lib/stopGroups';
|
||||
import type { Board, City, Stop } from './types';
|
||||
|
||||
function useOnline() {
|
||||
const [online, setOnline] = useState(navigator.onLine);
|
||||
useEffect(() => {
|
||||
const update = () => setOnline(navigator.onLine);
|
||||
window.addEventListener('online', update); window.addEventListener('offline', update);
|
||||
return () => { window.removeEventListener('online', update); window.removeEventListener('offline', update); };
|
||||
}, []);
|
||||
return online;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const online = useOnline();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const mode = params.get('demo') === '1' ? 'demo' : 'live';
|
||||
const demoPreviewAvailable = import.meta.env.DEV && mode === 'live' && online;
|
||||
const favoritesPage = location.pathname === '/favorites';
|
||||
const aboutPage = location.pathname === '/about';
|
||||
const routePage = location.pathname === '/route';
|
||||
const schedulePage = !favoritesPage && !aboutPage && !routePage;
|
||||
const routeId = positiveParam(params.get('route')) ?? null;
|
||||
const [view, setView] = useState<'next' | 'day'>(() => params.get('view') === 'day' ? 'day' : 'next');
|
||||
const [date, setDate] = useState(() => isTravelDate(params.get('date')) ? params.get('date')! : localDate());
|
||||
const [cityChoice, setCityChoice] = useState(() => params.get('city') || readPreference(`${mode}:city`) || '');
|
||||
const [stopChoice, setStopChoice] = useState(() => params.get('stop') || readPreference(`${mode}:stop`) || '');
|
||||
const [search, setSearch] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [nearbySelected, setNearbySelected] = useState<Stop | null>(null);
|
||||
const boardRef = useRef<HTMLElement>(null);
|
||||
const scrolledLocation = useRef<string | null>(null);
|
||||
const favorites = useLiveQuery(() => db.favorites.where('mode').equals(mode).toArray(), [mode], []);
|
||||
const trips = useLiveQuery(() => db.quickTrips.where('mode').equals(mode).toArray(), [mode], []);
|
||||
const savedCount = favorites.length + trips.length;
|
||||
const cities = useQuery({ queryKey: ['cities', mode], queryFn: () => loadResource<City[]>('cities', mode), staleTime: 300000, enabled: schedulePage });
|
||||
const city = cities.data?.data.find(item => String(item.id) === cityChoice) ?? cities.data?.data.find(item => item.slug === 'polotsk') ?? cities.data?.data[0];
|
||||
const cityId = city?.id;
|
||||
const stops = useQuery({ queryKey: ['stops', mode, cityId], queryFn: () => loadResource<Stop[]>('stops', mode, cityId), enabled: !!cityId && schedulePage, staleTime: 60000 });
|
||||
const allStops = useMemo(() => [...(stops.data?.data ?? [])].sort((a, b) => a.name.localeCompare(b.name, 'ru')), [stops.data]);
|
||||
const stopGroups = useMemo(() => groupStops(allStops), [allStops]);
|
||||
const stop = allStops.find(item => String(item.id) === stopChoice)
|
||||
?? (nearbySelected && String(nearbySelected.id) === stopChoice ? nearbySelected : undefined)
|
||||
?? favorites.find(item => item.cityId === cityId && String(item.id) === stopChoice)
|
||||
?? trips.find(item => item.cityId === cityId && String(item.stop.id) === stopChoice)?.stop;
|
||||
const selectedGroup = stopGroups.find(group => group.stops.some(item => item.id === stop?.id)) ?? (stop ? groupStops([stop])[0] : undefined);
|
||||
const board = useQuery({
|
||||
queryKey: ['departures', mode, cityId, stop?.id],
|
||||
queryFn: () => loadResource<Board>('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && schedulePage && view === 'next',
|
||||
staleTime: 30000, refetchInterval: online ? 60000 : false,
|
||||
});
|
||||
const timetable = useQuery({
|
||||
queryKey: ['timetable', mode, cityId, stop?.id, date],
|
||||
queryFn: () => loadResource<Board>('timetable', mode, cityId, stop?.id, date),
|
||||
enabled: !!cityId && !!stop && schedulePage && view === 'day', staleTime: 300000,
|
||||
});
|
||||
const key = cityId && stop ? stopKey(mode, cityId, stop.id) : '';
|
||||
const isFavorite = favorites.some(item => item.key === key);
|
||||
const filtered = stopGroups.filter(group => `${group.name} ${group.stops.map(item => item.direction).join(' ')}`.toLocaleLowerCase('ru').includes(search.trim().toLocaleLowerCase('ru')));
|
||||
const urlSuffix = mode === 'demo' ? '?demo=1' : '';
|
||||
|
||||
// Browser history and links to favorites restore their exact city/stop context.
|
||||
useEffect(() => {
|
||||
const nextCity = params.get('city'); const nextStop = params.get('stop');
|
||||
if (nextCity) { setCityChoice(nextCity); setStopChoice(nextStop ?? ''); }
|
||||
else if (nextStop) setStopChoice(nextStop);
|
||||
setView(params.get('view') === 'day' ? 'day' : 'next');
|
||||
if (isTravelDate(params.get('date'))) setDate(params.get('date')!);
|
||||
}, [params]);
|
||||
useEffect(() => { if (notice) { const timer = setTimeout(() => setNotice(''), 4500); return () => clearTimeout(timer); } }, [notice]);
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [location.pathname]);
|
||||
useEffect(() => { setNearbySelected(null); }, [mode, cityId]);
|
||||
const boardTarget = location.state?.boardTarget as { cityId: number; stopId: number; routeId: number } | undefined;
|
||||
const boardPending = (view === 'day' ? timetable : board).isPending;
|
||||
useEffect(() => {
|
||||
if (!schedulePage || !boardTarget || scrolledLocation.current === location.key || stops.isPending || boardPending
|
||||
|| cityId !== boardTarget.cityId || stop?.id !== boardTarget.stopId || routeId !== boardTarget.routeId || view !== 'next') return;
|
||||
const target = boardRef.current;
|
||||
if (!target) return;
|
||||
scrolledLocation.current = location.key;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
}, [schedulePage, boardTarget, location.key, stops.isPending, boardPending, cityId, stop?.id, routeId, view]);
|
||||
|
||||
function selectCity(value: string) {
|
||||
setCityChoice(value); setStopChoice(''); setSearch('');
|
||||
savePreference(`${mode}:city`, value); savePreference(`${mode}:stop`, '');
|
||||
setParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: value, ...(view === 'day' ? { view, date } : {}) });
|
||||
}
|
||||
function selectStop(value: Stop) {
|
||||
setStopChoice(String(value.id));
|
||||
savePreference(`${mode}:city`, String(cityId)); savePreference(`${mode}:stop`, String(value.id));
|
||||
setParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: String(cityId), stop: String(value.id), ...(view === 'day' ? { view, date } : {}) });
|
||||
}
|
||||
function selectView(nextView: 'next' | 'day', nextDate = date) {
|
||||
setView(nextView); setDate(nextDate);
|
||||
const nextParams = new URLSearchParams(params);
|
||||
nextParams.delete('view'); nextParams.delete('date');
|
||||
if (nextView === 'day') { nextParams.set('view', 'day'); nextParams.set('date', nextDate); }
|
||||
setParams(nextParams);
|
||||
}
|
||||
function selectRoute(route: number | null) {
|
||||
const nextParams = new URLSearchParams(params);
|
||||
if (route) nextParams.set('route', String(route)); else nextParams.delete('route');
|
||||
setParams(nextParams);
|
||||
}
|
||||
function selectGroup(group: StopGroup) {
|
||||
const selected = group.stops.find(item => String(item.id) === stopChoice);
|
||||
selectStop(selected ?? group.stops.find(item => item.routesCount > 0) ?? group.stops[0]);
|
||||
}
|
||||
async function toggleFavorite() {
|
||||
if (!city || !stop) return;
|
||||
try {
|
||||
if (isFavorite) await db.favorites.delete(key);
|
||||
else await db.favorites.put({ ...stop, key, mode, cityId: city.id, cityName: city.name, addedAt: Date.now() });
|
||||
setNotice(isFavorite ? 'Остановка удалена из избранного' : 'Остановка сохранена в избранное');
|
||||
} catch { setNotice('Не удалось сохранить избранное. Проверьте доступ к хранилищу браузера.'); }
|
||||
}
|
||||
function switchMode(demo: boolean) {
|
||||
setCityChoice(readPreference(`${demo ? 'demo' : 'live'}:city`) || '');
|
||||
setStopChoice(readPreference(`${demo ? 'demo' : 'live'}:stop`) || '');
|
||||
setSearch(''); navigate(`/${demo ? '?demo=1' : ''}`);
|
||||
}
|
||||
|
||||
return <div className="app">
|
||||
<a className="skipLink" href="#main">Перейти к расписанию</a>
|
||||
<header className="header">
|
||||
<NavLink to={`/${urlSuffix}`} className="brand" aria-label="RideBus, главная"><img src="/logo.svg" width="45" height="45" alt="" /><span>ridebus</span><span className="webTag">web</span></NavLink>
|
||||
<nav className="desktopNav" aria-label="Основная навигация"><NavLink end to={`/${urlSuffix}`}><BusFront size={18} />Расписание</NavLink><NavLink to={`/favorites${urlSuffix}`}><Bookmark size={18} />Избранное{savedCount > 0 && <span className="navCount" key={savedCount}>{savedCount}</span>}</NavLink></nav>
|
||||
<div className="headerActions"><PwaControls /></div>
|
||||
</header>
|
||||
<main id="main">
|
||||
{mode === 'demo' && <div className="demoBanner" role="status"><span>Демонстрация · остановки и отправления — примеры</span><button onClick={() => switchMode(false)}>К реальному расписанию <ArrowRight size={15} /></button></div>}
|
||||
{!online && <div className="offlineBanner" role="status"><WifiOff size={17} />Вы офлайн. Ранее просмотренные остановки и отправления доступны из сохранённых данных.</div>}
|
||||
{routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <>
|
||||
<section className="favoritesHero"><span className="eyebrow">ВСЕГДА ПОД РУКОЙ</span><h1>Любимые <span>остановки.</span></h1><p>Сохраните те, с которых начинается ваш день.</p></section>
|
||||
<QuickTrips trips={trips} online={online} onNotice={setNotice} />
|
||||
{favorites.length === 0 && trips.length === 0 ? <section className="favoritesEmpty"><span className="emptyIcon"><Bookmark size={32} /></span><h2>Здесь будут ваши остановки</h2><p>Нажмите на закладку рядом с названием остановки<br />или сохраните поездку с нужным маршрутом.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>Найти остановку <ArrowRight size={18} /></NavLink></section>
|
||||
: <div className="favoritesGrid">{[...favorites].sort((a, b) => b.addedAt - a.addedAt).map(item => <FavoriteCard key={item.key} stop={item} online={online} onRemove={() => void db.favorites.delete(item.key).catch(() => setNotice('Не удалось удалить остановку'))} />)}</div>}
|
||||
</> : <>
|
||||
<section className="hero">
|
||||
<div className="heroCopy"><h1>Ваш автобус.<br /><span>Всё по расписанию.</span></h1><p>Выберите остановку и направление,<br className="desktopBreak" /> чтобы узнать время отправления.</p></div>
|
||||
<div className="heroNote" aria-hidden="true"><img src="/star.svg" className="heroStar" alt="" /><div className="noteTop"><span className="noteDot" />RideBus</div><BusFront size={36} /><strong>Расписание<br />в вашем кармане.</strong><span className="noteBottom">Выберите остановку <ArrowDown size={20} /></span></div>
|
||||
</section>
|
||||
<QuickTrips trips={trips} online={online} onNotice={setNotice} />
|
||||
<div className="workspace">
|
||||
<section className="stopsCard" aria-labelledby="stops-title">
|
||||
<div className="sectionHeading"><h2 id="stops-title">Куда едем?</h2><MapPin size={22} /></div><p className="sectionDescription">Выберите город и остановку</p>
|
||||
<label className="fieldLabel" htmlFor="city">Ваш город</label><div className="citySelect"><MapPin size={18} /><select id="city" value={cityId ?? ''} disabled={!cities.data?.data.length} onChange={event => selectCity(event.target.value)}>{!city && <option value="">{cities.isPending ? 'Загружаем города…' : 'Выберите город'}</option>}{cities.data?.data.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select><ChevronDown size={17} /></div>
|
||||
{cityId && <NearbyStops key={`${mode}:${cityId}`} cityId={cityId} mode={mode} online={online} selectedGroupKey={selectedGroup?.key} favoriteStopIds={favorites.filter(item => item.cityId === cityId).map(item => item.id)} onSelect={value => { setNearbySelected(value); selectStop(value); }} />}
|
||||
<label className="fieldLabel" htmlFor="stop-search">Ваша остановка</label><div className="searchField"><Search size={18} /><input id="stop-search" type="search" placeholder="Название остановки" value={search} onChange={event => setSearch(event.target.value)} autoComplete="off" /></div>
|
||||
<StopList label="Остановки" busy={stops.isFetching || cities.isFetching}>
|
||||
{cities.isPending || (cityId && stops.isPending) ? <div className="listMessage" role="status">Загружаем остановки…</div>
|
||||
: cities.isError && !city ? <div className="listMessage"><WifiOff size={25} /><h3>{cities.error instanceof DataError && cities.error.code === 'api_not_configured' ? 'Подключаем расписание' : 'Города пока недоступны'}</h3><p>{online ? demoPreviewAvailable ? 'Попробуйте обновить данные или посмотрите, как работает RideBus, на примере.' : 'Попробуйте обновить данные немного позже.' : 'Подключитесь к интернету, чтобы загрузить города.'}</p><button className="secondaryButton" onClick={() => void cities.refetch()}>Повторить</button>{demoPreviewAvailable && <button className="textButton" onClick={() => switchMode(true)}>Посмотреть пример <ArrowRight size={15} /></button>}</div>
|
||||
: stops.isError && !stops.data ? <div className="listMessage"><h3>Нет доступных остановок</h3><p>{online ? 'Не удалось загрузить список.' : 'Для этого города ещё нет сохранённых данных.'}</p><button className="secondaryButton" onClick={() => void stops.refetch()}>Повторить</button></div>
|
||||
: filtered.length === 0 ? <div className="listMessage"><Search size={25} /><h3>{search ? 'Ничего не нашлось' : 'Пока нет остановок'}</h3><p>{search ? 'Попробуйте другое название.' : 'Выберите другой город или обновите данные.'}</p></div>
|
||||
: filtered.map(group => <StopListItem key={group.key} name={group.name} selected={selectedGroup?.key === group.key} favorite={favorites.some(f => group.stops.some(item => f.key === stopKey(mode, cityId!, item.id)))} onSelect={() => selectGroup(group)} />)}
|
||||
</StopList>
|
||||
<div className="stopsFooter">{countLabel(filtered.length, ['остановка', 'остановки', 'остановок'])}{(stops.data?.saved || cities.data?.saved || !online) && <span><WifiOff size={16} />Сохранено</span>}</div>
|
||||
</section>
|
||||
<Departures stop={stop} group={selectedGroup} onDirection={selectStop} query={view === 'day' ? timetable : board} favorite={isFavorite} onFavorite={() => void toggleFavorite()} online={online} view={view} date={date} onView={selectView} routeId={routeId} onRoute={selectRoute} city={city} mode={mode} onTripSaved={() => setNotice('Поездка сохранена')} onNotice={setNotice} boardRef={boardRef} />
|
||||
</div>
|
||||
<section className="benefitStrip"><span className="benefitIcon"><Bookmark size={21} /></span><div><strong>Ваша остановка — в один тап</strong><p>Добавьте её в избранное, чтобы в следующий раз найти быстрее.</p></div><NavLink to={`/favorites${urlSuffix}`}>В избранное <ArrowRight size={18} /></NavLink></section>
|
||||
</>}
|
||||
</main>
|
||||
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
|
||||
<nav className="mobileNav" aria-label="Навигация на телефоне"><NavLink end to={`/${urlSuffix}`}><BusFront size={28} /><span>Расписание</span></NavLink><NavLink to={`/favorites${urlSuffix}`} aria-label={`Избранное${savedCount ? `, сохранено: ${savedCount}` : ''}`}><span className="navIcon"><Bookmark size={28} />{savedCount > 0 && <span className="mobileBadge" key={savedCount} aria-hidden="true">{savedCount > 99 ? '99+' : savedCount}</span>}</span><span>Избранное</span></NavLink></nav>
|
||||
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}
|
||||
</div>;
|
||||
}
|
||||
152
src/_features.scss
Normal file
152
src/_features.scss
Normal file
@@ -0,0 +1,152 @@
|
||||
@mixin components {
|
||||
.appScrollArea { scrollbar-width: thin; scrollbar-color: #c7d1e8 transparent; }
|
||||
.appScrollArea::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.appScrollArea::-webkit-scrollbar-track { background: transparent; }
|
||||
.appScrollArea::-webkit-scrollbar-thumb { background: #c7d1e8; border-radius: 8px; }
|
||||
.appScrollArea::-webkit-scrollbar-thumb:hover { background: #aebedc; }
|
||||
.stopDistance { flex-shrink: 0; white-space: nowrap; font-size: 14px; color: var(--blue-muted); }
|
||||
.boardCard { scroll-margin-top: 24px; }
|
||||
.boardCard:focus-visible, .tripsCarouselTrack:focus-visible { outline: 3px solid #638ce1; outline-offset: 4px; }
|
||||
.departureEntry { display: flex; gap: 8px; align-items: stretch; min-width: 0; }
|
||||
.departureEntry .departureRow { flex: 1; min-width: 0; }
|
||||
.departureEntry > .shareDepartureButton { flex-shrink: 0; align-self: flex-start; margin-top: 21px; background: var(--lavender); color: var(--blue); }
|
||||
.departureEntry + .departureEntry { border-top: 1px solid var(--line); }
|
||||
.departureRow:hover .departureName strong { color: var(--blue); }
|
||||
.saveTripButton { margin: 12px 0; min-height: 44px; }
|
||||
|
||||
.quickTrips { margin: 0 0 30px; }
|
||||
.tripsTitle { margin-bottom: 16px; }
|
||||
.tripsTitle h2 { font-size: 25px; letter-spacing: -.3px; }
|
||||
.tripsTitle p { margin-top: 5px; color: var(--muted); font-size: 15px; }
|
||||
.quickTripsGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.quickTripsGrid.tripsCarouselTrack { position: relative; grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: calc((100% - 64px) / 2); align-items: start; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scroll-padding-inline: 4px; overscroll-behavior-inline: contain; padding: 4px 4px 12px; margin: -4px -4px 0; }
|
||||
.tripsCarouselTrack {
|
||||
--fade-start: #000;
|
||||
--fade-end: #000;
|
||||
-webkit-mask-image: linear-gradient(to right, var(--fade-start), #000 32px, #000 calc(100% - 32px), var(--fade-end));
|
||||
mask-image: linear-gradient(to right, var(--fade-start), #000 32px, #000 calc(100% - 32px), var(--fade-end));
|
||||
}
|
||||
.tripsCarouselTrack[data-fade-start='true'] { --fade-start: transparent; }
|
||||
.tripsCarouselTrack[data-fade-end='true'] { --fade-end: transparent; }
|
||||
.tripsCarouselTrack > .quickTripCard { scroll-snap-align: start; }
|
||||
.quickTripCard { display: flex; flex-direction: column; padding: 24px; border-radius: 28px; background: var(--green); color: var(--green-ink); min-width: 0; }
|
||||
.quickTripHeading { display: flex; align-items: center; gap: 12px; }
|
||||
.quickTripHeading h3 { font-size: 23px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.quickTripHeading > button { margin-left: auto; flex-shrink: 0; }
|
||||
.tripSymbol { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: #ffffff70; flex-shrink: 0; }
|
||||
.tripPlace { margin-top: 15px; font-size: 16px; font-weight: 750; overflow-wrap: anywhere; }
|
||||
.tripDirection { margin-top: 5px; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.tripNext { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
|
||||
.tripNext .routeNumber { background: #ffffff90; color: var(--green-ink); min-width: 44px; height: 44px; }
|
||||
.tripNext > strong { font-size: 29px; font-variant-numeric: tabular-nums; }
|
||||
.tripNext > span:last-child { font-size: 14px; }
|
||||
.tripFollowing, .tripSaved { margin-top: 10px; font-size: 14px; line-height: 1.7; }
|
||||
.tripSaved { padding: 10px 12px; border-radius: 12px; background: #ffffff65; }
|
||||
.tripActions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 20px; padding-top: 12px; border-top: 1px solid #bdd4b6; }
|
||||
.tripActions > a { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: 15px; font-weight: 800; }
|
||||
.tripActions .textButton { color: inherit; min-height: 44px; }
|
||||
.carouselControls { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 8px; }
|
||||
.carouselControls > span { font-size: 14px; color: var(--muted); }
|
||||
.carouselControls > div { display: flex; gap: 8px; }
|
||||
.carouselControls .iconButton { background: var(--lavender); color: var(--blue); }
|
||||
.carouselPrevious > .vkuiIcon { transform: rotate(180deg); }
|
||||
|
||||
.appDialog { border: 0; padding: 0; margin: auto; width: min(460px, calc(100% - 32px)); max-height: calc(100dvh - 32px); overflow: auto; border-radius: 28px; background: white; color: var(--ink); box-shadow: 0 20px 80px #14274b35; }
|
||||
.appDialog::backdrop { background: #14274b70; backdrop-filter: blur(5px); }
|
||||
.dialogBody { padding: 26px; }
|
||||
.dialogHeading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
||||
.dialogHeading h2 { font-size: 25px; line-height: 1.4; }
|
||||
.dialogHeading .iconButton { margin-top: -4px; margin-right: -8px; }
|
||||
.dialogDescription { color: var(--muted); font-size: 15px; line-height: 1.8; }
|
||||
.tripForm input, .tripForm select, .shareLinkForm input { width: 100%; min-width: 0; border: 1px solid #c3d3f5; border-radius: 13px; min-height: 48px; padding: 11px 12px; font-size: 16px; background: var(--lavender); color: var(--blue); }
|
||||
.namePresets { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.namePresets .secondaryButton { padding: 10px 14px; min-height: 44px; }
|
||||
.tripForm > .primaryButton { margin-top: 24px; min-height: 48px; }
|
||||
.formError { margin-top: 16px; font-size: 15px; color: #a34239; }
|
||||
.shareLinkForm p { font-size: 14px; color: var(--muted); line-height: 1.8; margin: 12px 0 20px; }
|
||||
.routeHero .shareDepartureButton { margin-top: 16px; min-height: 44px; max-width: 100%; }
|
||||
|
||||
.hourlyHeading { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.hourlyHeading p { font-size: 14px; color: var(--muted); }
|
||||
.hourlyHeading .textButton { min-height: 44px; }
|
||||
.hourRow { display: grid; grid-template-columns: 45px minmax(0, 1fr); gap: 12px; padding: 14px 0; border-top: 1px solid var(--line); }
|
||||
.hourRow h3 { font-size: 22px; font-weight: 800; color: var(--blue); padding-top: 7px; font-variant-numeric: tabular-nums; }
|
||||
.hourRow h3 span { display: block; font-size: 13px; font-weight: 600; color: var(--muted); margin-top: -3px; }
|
||||
.hourDepartures { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 8px; align-content: start; }
|
||||
.hourDeparture { display: flex; justify-content: center; align-items: center; gap: 7px; min-height: 44px; padding: 8px 7px; border: 1px solid transparent; border-radius: 12px; background: var(--lavender); color: var(--blue); position: relative; }
|
||||
.hourDeparture strong { font-size: 16px; font-variant-numeric: tabular-nums; }
|
||||
.hourRoute { font-size: 13px; font-weight: 800; }
|
||||
.hourDeparture.pastDeparture { background: #f4f4f1; color: #69758c; }
|
||||
.hourDeparture.nextDeparture { background: var(--green); border-color: #9db994; color: var(--green-ink); }
|
||||
.hourDeparture:hover { border-color: var(--blue-muted); }
|
||||
.hasTripNote { font-size: 14px; position: absolute; right: 4px; top: -2px; }
|
||||
|
||||
.serviceAlerts { padding: 18px; border-radius: 18px; background: #fff7e9; color: #735421; margin: 18px 0; min-width: 0; }
|
||||
.alertsHeading { display: flex; align-items: center; gap: 8px; font-size: 15px; }
|
||||
.serviceAlert { padding-top: 14px; }
|
||||
.serviceAlert + .serviceAlert { margin-top: 14px; border-top: 1px solid #eadcc6; }
|
||||
.alertRoutes { display: block; font-size: 14px; font-weight: 800; margin-bottom: 5px; }
|
||||
.serviceAlert h3 { font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.serviceAlert p { margin: 7px 0 10px; font-size: 15px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.serviceAlert small, .alertSaved { display: block; font-size: 13px; line-height: 1.7; }
|
||||
.alertSaved { margin-top: 8px; }
|
||||
.alertsUnavailable { display: flex; align-items: flex-start; gap: 7px; font-size: 14px; color: var(--muted); line-height: 1.7; margin-top: 16px; }
|
||||
.alertsUnavailable .vkuiIcon { margin-top: 4px; }
|
||||
.compactAlerts { padding: 14px; }
|
||||
|
||||
.nearbySection { margin-top: 14px; }
|
||||
.nearbyTrigger { width: 100%; min-height: 44px; }
|
||||
.nearbyMessage, .nearbyResults > p { font-size: 14px; color: var(--blue-muted); line-height: 1.7; margin-top: 10px; }
|
||||
|
||||
.routePage { padding: 32px 0 48px; }
|
||||
.backToSchedule { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 750; color: var(--blue); min-height: 44px; }
|
||||
.backToSchedule > .vkuiIcon { transform: rotate(180deg); }
|
||||
.routeHero { display: flex; align-items: flex-start; gap: 22px; padding: 30px 0; }
|
||||
.routeHero > div { min-width: 0; }
|
||||
.routeHero > .routeNumber { min-width: 64px; height: 64px; font-size: 25px; border-radius: 20px; margin-top: 5px; }
|
||||
.routeHero h1 { font-size: clamp(26px, 3.2vw, 40px); line-height: 1.3; margin: 10px 0; letter-spacing: -.3px; overflow-wrap: anywhere; }
|
||||
.routeHero p { font-size: 15px; line-height: 1.7; color: var(--muted); }
|
||||
.routeBody { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
.routeBody:has(> .serviceAlerts) { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||
.routeBody > .serviceAlerts { margin: 0; }
|
||||
.routeTimelineCard { padding: 28px; border: 1px solid var(--line); border-radius: 32px; background: white; min-width: 0; }
|
||||
.routeTimelineHeading { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.routeTimelineHeading h2 { font-size: 24px; }
|
||||
.routeTimelineHeading > span { font-size: 14px; color: var(--blue); background: var(--lavender); padding: 4px 9px; border-radius: 20px; }
|
||||
.journeyTimeline { list-style: none; margin: 20px 0; padding: 0; }
|
||||
.journeyTimeline li { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 12px; min-height: 72px; position: relative; }
|
||||
.journeyTimeline li:not(:last-child)::before { content: ''; position: absolute; top: 50%; bottom: -50%; left: 26px; width: 2px; background: #dce0ee; }
|
||||
.journeyPoint { position: relative; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; color: var(--blue); background: var(--lavender); border: 2px solid white; font-size: 13px; font-weight: 800; }
|
||||
.journeyTimeline a { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-height: 44px; }
|
||||
.journeyTimeline a strong { font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.journeyTimeline a span { font-size: 13px; color: var(--blue-muted); }
|
||||
.journeyTimeline a:hover strong { color: var(--blue); }
|
||||
.journeyTime { text-align: right; }
|
||||
.journeyTime strong { font-size: 21px; font-variant-numeric: tabular-nums; }
|
||||
.journeyTime small { display: block; max-width: 85px; font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||
.journeyTimeline .selectedJourneyStop { border-radius: 15px; background: var(--blue-soft); }
|
||||
.selectedJourneyStop .journeyPoint { background: var(--blue); color: white; }
|
||||
.journeyTimeline .passedJourneyStop:not(.selectedJourneyStop) { color: var(--muted); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.quickTripsGrid { grid-template-columns: 1fr; gap: 14px; }
|
||||
.quickTripsGrid.tripsCarouselTrack { grid-auto-columns: calc(100% - 30px); }
|
||||
.quickTripCard { padding: 20px; border-radius: 26px; }
|
||||
.quickTripHeading { gap: 8px; }
|
||||
.quickTripHeading h3 { font-size: 21px; flex: 1; min-width: 0; }
|
||||
.quickTripHeading > button { margin-left: 0; }
|
||||
.tripSymbol { width: 36px; height: 36px; border-radius: 12px; }
|
||||
.tripsTitle h2 { font-size: 23px; }
|
||||
.routeBody:has(> .serviceAlerts) { grid-template-columns: 1fr; }
|
||||
.routeHero { gap: 14px; padding: 24px 0; }
|
||||
.routeHero > .routeNumber { min-width: 48px; height: 48px; font-size: 20px; border-radius: 14px; }
|
||||
.routeTimelineCard { padding: 20px 12px; border-radius: 26px; }
|
||||
.routeTimelineHeading, .routeTimelineCard .boardFooter, .routeTimelineCard > .savedNotice { margin-left: 6px; margin-right: 6px; }
|
||||
.journeyTimeline li { gap: 9px; padding: 12px 6px; grid-template-columns: 30px minmax(0, 1fr) auto; }
|
||||
.journeyTimeline li:not(:last-child)::before { left: 20px; }
|
||||
.journeyTime strong { font-size: 19px; }
|
||||
.hourRow { grid-template-columns: 34px minmax(0, 1fr); gap: 8px; }
|
||||
.hourDepartures { grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 6px; }
|
||||
.dialogBody { padding: 22px; }
|
||||
}
|
||||
}
|
||||
120
src/_motion.scss
Normal file
120
src/_motion.scss
Normal file
@@ -0,0 +1,120 @@
|
||||
@mixin components {
|
||||
:root {
|
||||
--motion-fast: 160ms;
|
||||
--motion-enter: 280ms;
|
||||
--motion-ease: cubic-bezier(.2, .75, .25, 1);
|
||||
}
|
||||
|
||||
.heroCopy, .favoritesHero, .aboutIntro,
|
||||
.stopsCard, .boardCard, .favoritesEmpty,
|
||||
.favoriteCard, .quickTripCard, .routeHero, .routeTimelineCard,
|
||||
.aboutGrid > article, .benefitStrip {
|
||||
animation: surfaceEnter var(--motion-enter) var(--motion-ease) backwards;
|
||||
}
|
||||
.stopsCard { animation-delay: 35ms; }
|
||||
.boardCard, .aboutGrid > article:nth-child(2) { animation-delay: 75ms; }
|
||||
.heroNote { animation: noteEnter 360ms var(--motion-ease) 60ms backwards; }
|
||||
.favoriteCard {
|
||||
transition: transform var(--motion-fast) var(--motion-ease), box-shadow var(--motion-fast) ease;
|
||||
@for $index from 2 through 6 {
|
||||
&:nth-child(#{$index}) { animation-delay: ($index - 1) * 30ms; }
|
||||
}
|
||||
&:nth-child(n + 13) { animation: none; }
|
||||
}
|
||||
|
||||
.primaryButton, .secondaryButton, .installButton, .iconButton,
|
||||
.textButton, .showMore, .refreshButton, .stopItem,
|
||||
.directionChips button, .routeFilters button, .scheduleViews button,
|
||||
.desktopNav a, .mobileNav a, .hourDeparture, .nearbyResults button {
|
||||
transition: background-color var(--motion-fast) ease, color var(--motion-fast) ease,
|
||||
border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease,
|
||||
transform var(--motion-fast) var(--motion-ease);
|
||||
}
|
||||
.primaryButton:active, .secondaryButton:active, .installButton:active,
|
||||
.iconButton:not(:disabled):active, .textButton:not(:disabled):active,
|
||||
.showMore:active, .refreshButton:not(:disabled):active,
|
||||
.stopItem:active, .directionChips button:active, .routeFilters button:active,
|
||||
.scheduleViews button:active, .desktopNav a:active, .mobileNav a:active,
|
||||
.hourDeparture:active, .nearbyResults button:active {
|
||||
transform: scale(.97);
|
||||
}
|
||||
.stopEnd, .favoriteLink > .vkuiIcon, .benefitStrip > a > .vkuiIcon,
|
||||
.primaryButton > .vkuiIcon, .secondaryButton > .vkuiIcon,
|
||||
.footer a > .vkuiIcon {
|
||||
transition: transform var(--motion-fast) var(--motion-ease), opacity var(--motion-fast) ease;
|
||||
}
|
||||
.activeStop .stopEnd { opacity: 1; }
|
||||
.citySelect, .searchField, .scheduleDate input {
|
||||
transition: box-shadow var(--motion-fast) ease, border-color var(--motion-fast) ease;
|
||||
}
|
||||
.bookmarkMotion { display: inline-flex; animation: bookmarkPop 250ms var(--motion-ease); }
|
||||
.navCount, .mobileBadge { animation: badgePop 250ms var(--motion-ease); }
|
||||
.scheduleDate, .boardEmpty, .listMessage, .favoriteDepartures ul {
|
||||
animation: surfaceEnter 220ms var(--motion-ease) backwards;
|
||||
}
|
||||
.departureList { animation: contentEnter 180ms ease-out; }
|
||||
.departureRow { animation: rowEnter 220ms var(--motion-ease) backwards; }
|
||||
.departureEntry:nth-child(n + 17) .departureRow { animation: none; }
|
||||
.hourRow, .serviceAlerts, .nearbyResults { animation: rowEnter 220ms var(--motion-ease) backwards; }
|
||||
.hourRow:nth-child(n + 7) { animation: none; }
|
||||
.demoBanner, .offlineBanner, .savedNotice, .partialNotice { animation: contentEnter 200ms ease-out; }
|
||||
.toast, .updateToast { animation: noticeEnter 240ms var(--motion-ease) backwards; }
|
||||
.modalBackdrop { animation: contentEnter 160ms ease-out; }
|
||||
.installDialog, .appDialog { animation: dialogEnter 240ms var(--motion-ease) backwards; }
|
||||
.skeletonRow span { animation: pulse 1.2s ease-in-out infinite alternate; }
|
||||
.spinning { animation: spin 1s linear infinite; }
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.primaryButton:hover, .secondaryButton:hover, .installButton:hover { transform: translateY(-2px); }
|
||||
.favoriteCard:hover { transform: translateY(-3px); box-shadow: 0 10px 24px #2c46780d; }
|
||||
.stopItem:hover .stopEnd, .favoriteLink:hover > .vkuiIcon,
|
||||
.benefitStrip > a:hover > .vkuiIcon, .footer a:hover > .vkuiIcon,
|
||||
.primaryButton:hover > .vkuiIcon, .secondaryButton:hover > .vkuiIcon { transform: translateX(3px); }
|
||||
.primaryButton:active, .secondaryButton:active, .installButton:active { transform: scale(.97); }
|
||||
}
|
||||
|
||||
@keyframes surfaceEnter {
|
||||
from { opacity: 0; transform: translateY(7px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes noteEnter {
|
||||
from { opacity: 0; transform: translateY(7px) rotate(1deg); }
|
||||
to { opacity: 1; transform: translateY(0) rotate(3deg); }
|
||||
}
|
||||
@keyframes rowEnter {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes contentEnter { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes bookmarkPop {
|
||||
0% { transform: scale(.88); }
|
||||
55% { transform: scale(1.12); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes badgePop {
|
||||
0% { opacity: 0; transform: scale(.75); }
|
||||
65% { opacity: 1; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes noticeEnter {
|
||||
from { opacity: 0; transform: translate(-50%, 8px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
@keyframes dialogEnter {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes pulse { to { opacity: .4; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
|
||||
.primaryButton, .secondaryButton, .installButton, .iconButton,
|
||||
.textButton, .showMore, .refreshButton, .stopItem, .favoriteCard,
|
||||
.directionChips button, .routeFilters button, .scheduleViews button,
|
||||
.desktopNav a, .mobileNav a, .stopEnd, .favoriteLink > .vkuiIcon,
|
||||
.benefitStrip > a > .vkuiIcon, .primaryButton > .vkuiIcon,
|
||||
.secondaryButton > .vkuiIcon, .footer a > .vkuiIcon,
|
||||
.hourDeparture, .nearbyResults button { transform: none !important; }
|
||||
}
|
||||
}
|
||||
14
src/_tokens.scss
Normal file
14
src/_tokens.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
// RideBus landing palette, card shapes and typography.
|
||||
:root {
|
||||
--page: #faf9f6;
|
||||
--ink: #242424;
|
||||
--muted: #6f737c;
|
||||
--line: #e9e9e3;
|
||||
--blue: #2c4678;
|
||||
--blue-muted: #455e91;
|
||||
--blue-soft: #d8e2ff;
|
||||
--lavender: #f3f3fa;
|
||||
--green: #ddf9d0;
|
||||
--green-ink: #254817;
|
||||
--card-radius: 32px;
|
||||
}
|
||||
12
src/components/About.tsx
Normal file
12
src/components/About.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ArrowRight, BusFront } from './Icons';
|
||||
|
||||
export function About({ urlSuffix }: { urlSuffix: string }) {
|
||||
return <section className="aboutPage" aria-labelledby="about-title">
|
||||
<div className="aboutIntro"><span className="eyebrow">О ПРОЕКТЕ</span><h1 id="about-title">Привет, <span>я Захар.</span></h1><p>И я делаю RideBus, чтобы расписание было под рукой.</p></div>
|
||||
<div className="aboutGrid">
|
||||
<article className="aboutStory"><span className="emptyIcon"><BusFront size={32} /></span><h2>От своей остановки — к вашему маршруту</h2><p>Я создал этот сайт, чтобы жителям было проще планировать поездки на общественном транспорте. Найти остановку, посмотреть время автобуса и спокойно выйти из дома — без поисков расписания по разным сайтам.</p><p>RideBus можно добавить на главный экран телефона, а любимые остановки сохранить в избранном. Надеюсь, он сделает ваши ежедневные поездки немного удобнее.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>К расписанию <ArrowRight size={18} /></NavLink></article>
|
||||
<article className="aboutSupport"><img src="/star.svg" width="64" height="64" alt="" /><h2>Нравится RideBus?</h2><p>Проект живёт благодаря моему времени и интересу. Если он помогает вам в дороге, буду рад добровольной поддержке: она пойдёт на хостинг, домен и дальнейшее развитие.</p><p>А иногда — на напиток с пеной, чтобы придумывать новые функции было веселее 🙂</p><div className="supportNote">Ссылку для поддержки добавлю чуть позже. А пока — спасибо, что пользуетесь RideBus!</div></article>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
61
src/components/Departures.tsx
Normal file
61
src/components/Departures.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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>;
|
||||
}
|
||||
19
src/components/Dialog.tsx
Normal file
19
src/components/Dialog.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useLayoutEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { X } from './Icons';
|
||||
|
||||
export function Dialog({ title, children, onClose }: { title: string; children: ReactNode; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
useLayoutEffect(() => {
|
||||
const dialog = ref.current!;
|
||||
const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
dialog.showModal();
|
||||
dialog.querySelector<HTMLElement>('input, select, textarea')?.focus({ preventScroll: true });
|
||||
return () => { dialog.close(); if (trigger?.isConnected) trigger.focus({ preventScroll: true }); };
|
||||
}, []);
|
||||
return <dialog ref={ref} className="appDialog" aria-labelledby={titleId} onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose();
|
||||
}}><div className="dialogBody"><div className="dialogHeading"><h2 id={titleId}>{title}</h2><button className="iconButton" aria-label="Закрыть окно" onClick={onClose}><X size={22} /></button></div>{children}</div></dialog>;
|
||||
}
|
||||
34
src/components/FavoriteCard.tsx
Normal file
34
src/components/FavoriteCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ArrowUpRight, MapPin, WifiOff, X } from './Icons';
|
||||
import { loadResource } from '../lib/api';
|
||||
import { formatDate, formatTime, formatWait, useNow } from '../lib/time';
|
||||
import type { Board, SavedStop } from '../types';
|
||||
import { Alerts } from './ServiceAlerts';
|
||||
|
||||
export function FavoriteCard({ stop, online, onRemove }: { stop: SavedStop; online: boolean; onRemove: () => void }) {
|
||||
const now = useNow();
|
||||
const query = useQuery({
|
||||
queryKey: ['departures', stop.mode, stop.cityId, stop.id],
|
||||
queryFn: () => loadResource<Board>('departures', stop.mode, stop.cityId, stop.id),
|
||||
staleTime: 30000, refetchInterval: online ? 60000 : false,
|
||||
});
|
||||
const board = query.data?.data;
|
||||
const stale = !online || !!query.data?.saved || query.isError;
|
||||
const departures = (board?.departures ?? []).filter(item => Date.parse(item.scheduledAt) >= now - 60000).slice(0, 3);
|
||||
const params = new URLSearchParams({ ...(stop.mode === 'demo' ? { demo: '1' } : {}), city: String(stop.cityId), stop: String(stop.id) });
|
||||
return <article className="favoriteCard">
|
||||
<div className="favoriteCity"><MapPin size={15} />{stop.cityName}<button className="iconButton" aria-label={`Удалить ${stop.name} из избранного`} onClick={onRemove}><X size={17} /></button></div>
|
||||
<h2>{stop.name}</h2><p>{stop.direction || 'Все направления'}</p>
|
||||
<div className="favoriteDepartures" aria-label="Ближайшие отправления">
|
||||
{query.isPending ? <p role="status">Загружаем отправления…</p>
|
||||
: !board ? <p>{online ? 'Не удалось загрузить отправления' : 'Нет сохранённых отправлений'}</p>
|
||||
: departures.length === 0 ? <p>{stale ? 'В сохранённых данных нет ближайших рейсов' : 'Ближайших отправлений пока нет'}</p>
|
||||
: <ul>{departures.map(item => <li key={`${item.routeId}:${item.scheduledAt}`} title={item.direction}><span className="favoriteRoute">{item.routeNumber}</span><strong>{formatTime(item.scheduledAt)}</strong><span>{formatWait((Date.parse(item.scheduledAt) - now) / 60000)}</span></li>)}</ul>}
|
||||
</div>
|
||||
{stale && board ? <div className="favoriteStatus"><WifiOff size={15} /><span>Сохранено {formatDate(board.updatedAt)} в {formatTime(board.updatedAt)} · данные могут быть устаревшими</span></div>
|
||||
: board?.partial && <div className="favoriteStatus">Доступна часть маршрутов</div>}
|
||||
{board && <Alerts mode={stop.mode} cityId={stop.cityId} online={online} routes={board.routes} compact />}
|
||||
<NavLink className="favoriteLink" to={`/?${params}`}>Посмотреть расписание <ArrowUpRight size={20} /></NavLink>
|
||||
</article>;
|
||||
}
|
||||
23
src/components/HourlyTimetable.tsx
Normal file
23
src/components/HourlyTimetable.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useRef } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { groupByHour } from '../lib/timetable';
|
||||
import { journeyLink } from '../lib/links';
|
||||
import { formatTime } from '../lib/time';
|
||||
import { ArrowDown } from './Icons';
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function HourlyTimetable({ departures, now, mode, cityId, stopId, date, routeId }: { departures: Departure[]; now: number; mode: 'live' | 'demo'; cityId: number; stopId: number; date: string; routeId: number | null }) {
|
||||
const nearest = departures.find(departure => Date.parse(departure.scheduledAt) >= now - 60000);
|
||||
const nextRef = useRef<HTMLAnchorElement>(null);
|
||||
const groups = groupByHour(departures);
|
||||
return <div className="hourlyTimetable"><div className="hourlyHeading"><p>Нажмите на время, чтобы посмотреть рейс.</p>{nearest && <button className="textButton" onClick={() => {
|
||||
nextRef.current?.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
nextRef.current?.focus({ preventScroll: true });
|
||||
}}>К ближайшему <ArrowDown size={17} /></button>}</div><div className="hourGroups" aria-label="Расписание по часам">{groups.map(({ hour, departures }, groupIndex) => <section className="hourRow" key={hour} aria-label={`${hour} часов`} style={{ animationDelay: `${Math.min(groupIndex, 5) * 20}ms` }}><h3>{hour}</h3><div className="hourDepartures">{departures.map((departure, index) => {
|
||||
const next = departure === nearest;
|
||||
const past = Date.parse(departure.scheduledAt) < now - 60000;
|
||||
return <NavLink key={`${departure.routeId}:${departure.scheduledAt}:${index}`} ref={next ? nextRef : undefined} className={`hourDeparture ${past ? 'pastDeparture' : ''} ${next ? 'nextDeparture' : ''}`} to={journeyLink(mode, cityId, stopId, departure, 'day', date, routeId)} title={`${departure.direction}${departure.note ? ` · ${departure.note}` : ''}`} aria-label={`Маршрут ${departure.routeNumber}, отправление ${formatTime(departure.scheduledAt)}${next ? ', ближайший рейс' : ''}`}>
|
||||
{!routeId && <span className="hourRoute">{departure.routeNumber}</span>}<strong>{formatTime(departure.scheduledAt)}</strong>{departure.note && <span className="hasTripNote" aria-label="Есть примечание">•</span>}
|
||||
</NavLink>;
|
||||
})}</div></section>)}</div></div>;
|
||||
}
|
||||
42
src/components/Icons.tsx
Normal file
42
src/components/Icons.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Icon20ArrowDownOutline, Icon20ArrowRightOutline, Icon20ArrowUpRightOutline,
|
||||
Icon20BusOutline, Icon20Check, Icon20ClockOutline, Icon20DownloadOutline,
|
||||
Icon20GlobeCrossOutline, Icon20RefreshOutline, Icon20ShareOutline,
|
||||
Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel,
|
||||
Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline,
|
||||
Icon24Add, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline,
|
||||
} from '@vkontakte/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
type IconProps = { size?: number; className?: string };
|
||||
type VkIcon = ComponentType<{ width?: number; height?: number; className?: string }>;
|
||||
|
||||
function adapt(Icon: VkIcon) {
|
||||
return function AppIcon({ size = 20, className }: IconProps) {
|
||||
return <Icon width={size} height={size} className={className} aria-hidden="true" />;
|
||||
};
|
||||
}
|
||||
|
||||
export const ArrowDown = adapt(Icon20ArrowDownOutline);
|
||||
export const ArrowRight = adapt(Icon20ArrowRightOutline);
|
||||
export const ArrowUpRight = adapt(Icon20ArrowUpRightOutline);
|
||||
export const BusFront = adapt(Icon20BusOutline);
|
||||
export const Check = adapt(Icon20Check);
|
||||
export const Clock3 = adapt(Icon20ClockOutline);
|
||||
export const Download = adapt(Icon20DownloadOutline);
|
||||
export const WifiOff = adapt(Icon20GlobeCrossOutline);
|
||||
export const RefreshCw = adapt(Icon20RefreshOutline);
|
||||
export const X = adapt(Icon24Cancel);
|
||||
export const ChevronDown = adapt(Icon24ChevronDown);
|
||||
export const MapPin = adapt(Icon24LocationOutline);
|
||||
export const Search = adapt(Icon24SearchOutline);
|
||||
export const Plus = adapt(Icon24Add);
|
||||
export const Home = adapt(Icon24HomeOutline);
|
||||
export const Work = adapt(Icon24WorkOutline);
|
||||
export const Warning = adapt(Icon24WarningTriangleOutline);
|
||||
export const Share = adapt(Icon20ShareOutline);
|
||||
|
||||
export function Bookmark({ size = 24, className, fill }: IconProps & { fill?: string }) {
|
||||
const Icon = fill === 'currentColor' ? Icon24Bookmark : Icon24BookmarkOutline;
|
||||
return <Icon width={size} height={size} className={className} aria-hidden="true" />;
|
||||
}
|
||||
40
src/components/NearbyStops.tsx
Normal file
40
src/components/NearbyStops.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { loadNearby } from '../lib/api';
|
||||
import { groupStops } from '../lib/stopGroups';
|
||||
import { MapPin, RefreshCw } from './Icons';
|
||||
import { StopList, StopListItem } from './StopList';
|
||||
import type { NearbyStop, Stop } from '../types';
|
||||
|
||||
export function NearbyStops({ cityId, mode, online, onSelect, selectedGroupKey, favoriteStopIds }: { cityId: number; mode: 'live' | 'demo'; online: boolean; onSelect: (stop: Stop) => void; selectedGroupKey?: string; favoriteStopIds: number[] }) {
|
||||
const [results, setResults] = useState<NearbyStop[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [approximate, setApproximate] = useState(false);
|
||||
const request = useRef(0);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => { request.current++; controller.current?.abort(); }, []);
|
||||
async function searchNearby() {
|
||||
if (!online || busy) return;
|
||||
if (!navigator.geolocation || !window.isSecureContext) { setMessage('Поиск по местоположению недоступен в этом браузере. Найдите остановку по названию.'); return; }
|
||||
const current = ++request.current;
|
||||
controller.current?.abort(); controller.current = new AbortController();
|
||||
const signal = controller.current.signal;
|
||||
setBusy(true); setMessage('');
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 }));
|
||||
if (current !== request.current) return;
|
||||
const stops = await loadNearby(mode, cityId, position.coords.latitude, position.coords.longitude, signal);
|
||||
if (current !== request.current) return;
|
||||
setResults(stops); setApproximate(position.coords.accuracy > 300);
|
||||
} catch (error) {
|
||||
if (current !== request.current) return;
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
||||
setMessage(code === 1 ? 'Доступ к местоположению закрыт. Можно выбрать остановку по названию.' : code === 3 ? 'Не удалось определить местоположение вовремя. Попробуйте ещё раз или найдите остановку по названию.' : 'Не удалось найти остановки рядом. Попробуйте ещё раз или воспользуйтесь поиском.');
|
||||
} finally { if (current === request.current) setBusy(false); }
|
||||
}
|
||||
const groups = groupStops(results ?? []).map(group => ({ group, nearest: (results ?? []).filter(stop => group.stops.some(item => item.id === stop.id)).sort((a, b) => a.distance - b.distance)[0] })).sort((a, b) => a.nearest.distance - b.nearest.distance);
|
||||
return <div className="nearbySection"><button className="secondaryButton nearbyTrigger" disabled={!online || busy} onClick={() => void searchNearby()}>{busy ? <RefreshCw size={18} className="spinning" /> : <MapPin size={18} />}{busy ? 'Ищем рядом…' : results ? 'Обновить поиск рядом' : 'Остановки рядом'}</button>
|
||||
{message && <p className="nearbyMessage" role="status">{message}</p>}
|
||||
{results && <div className="nearbyResults"><p>{mode === 'demo' ? 'Примеры остановок рядом' : 'В радиусе 1 км'}{approximate && ' · местоположение приблизительное'}</p>{!online && <p>Последний результат поиска · вы офлайн</p>}<StopList label="Остановки рядом">{!groups.length ? <div className="listMessage" role="status">Рядом нет остановок выбранного города. Попробуйте выбрать другой город.</div> : groups.map(({ group, nearest }) => <StopListItem key={group.key} name={group.name} selected={selectedGroupKey === group.key} favorite={group.stops.some(stop => favoriteStopIds.includes(stop.id))} distance={nearest.distance} onSelect={() => onSelect(nearest)} />)}</StopList></div>}
|
||||
</div>;
|
||||
}
|
||||
43
src/components/PwaControls.tsx
Normal file
43
src/components/PwaControls.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, RefreshCw, X } from './Icons';
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||
|
||||
type InstallEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> };
|
||||
|
||||
export function PwaControls() {
|
||||
const [prompt, setPrompt] = useState<InstallEvent | null>(null);
|
||||
const [help, setHelp] = useState(false);
|
||||
const [installed, setInstalled] = useState(() => window.matchMedia('(display-mode: standalone)').matches);
|
||||
const { needRefresh: [refresh, setRefresh], updateServiceWorker } = useRegisterSW();
|
||||
useEffect(() => {
|
||||
const available = (event: Event) => { event.preventDefault(); setPrompt(event as InstallEvent); };
|
||||
const complete = () => { setInstalled(true); setPrompt(null); setHelp(false); };
|
||||
window.addEventListener('beforeinstallprompt', available);
|
||||
window.addEventListener('appinstalled', complete);
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', available);
|
||||
window.removeEventListener('appinstalled', complete);
|
||||
};
|
||||
}, []);
|
||||
async function install() {
|
||||
if (!prompt) { setHelp(true); return; }
|
||||
await prompt.prompt();
|
||||
await prompt.userChoice;
|
||||
setPrompt(null);
|
||||
}
|
||||
return <>
|
||||
{!installed && <button className="installButton" onClick={() => void install()}><Download size={17} /><span>Установить</span></button>}
|
||||
{help && <div className="modalBackdrop" onClick={() => setHelp(false)}>
|
||||
<section className="installDialog" role="dialog" aria-modal="true" aria-labelledby="install-title" onClick={event => event.stopPropagation()}>
|
||||
<button autoFocus className="iconButton closeDialog" aria-label="Закрыть" onClick={() => setHelp(false)}><X size={20} /></button>
|
||||
<img src="/logo.svg" width="58" height="58" alt="" />
|
||||
<h2 id="install-title">RideBus на главном экране</h2>
|
||||
<p>На iPhone откройте меню «Поделиться» в Safari и выберите «На экран Домой».</p>
|
||||
<p>На Android откройте меню браузера и выберите «Установить приложение» или «Добавить на главный экран».</p>
|
||||
<p className="muted">Если пункта установки нет, продолжайте пользоваться RideBus в браузере.</p>
|
||||
<button className="primaryButton" onClick={() => setHelp(false)}>Понятно</button>
|
||||
</section>
|
||||
</div>}
|
||||
{refresh && <div className="updateToast" role="status"><RefreshCw size={18} /><span>Доступна новая версия RideBus</span><button onClick={() => void updateServiceWorker(true)}>Обновить</button><button aria-label="Позже" onClick={() => setRefresh(false)}><X size={17} /></button></div>}
|
||||
</>;
|
||||
}
|
||||
74
src/components/QuickTrips.tsx
Normal file
74
src/components/QuickTrips.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Dialog } from './Dialog';
|
||||
import { ArrowRight, Home, Work, X } from './Icons';
|
||||
import { db } from '../lib/storage';
|
||||
import { loadResource } from '../lib/api';
|
||||
import { formatTime, formatWait, useNow } from '../lib/time';
|
||||
import { Alerts } from './ServiceAlerts';
|
||||
import { TripCarousel } from './TripCarousel';
|
||||
import type { Board, City, QuickTrip, Route, Stop } from '../types';
|
||||
|
||||
export function QuickTripForm({ city, stop, routes, routeId, mode, onClose, onSaved }: {
|
||||
city: City; stop: Stop; routes: Route[]; routeId: number | null; mode: 'live' | 'demo'; onClose: () => void; onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('Домой');
|
||||
const [selected, setSelected] = useState(routeId ?? routes[0]?.id);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const route = routes.find(route => route.id === selected);
|
||||
if (!name.trim() || !route || saving) return;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await db.quickTrips.put({ id: crypto.randomUUID(), name: name.trim(), mode, cityId: city.id, cityName: city.name, stop, route, createdAt: Date.now() });
|
||||
onSaved(); onClose();
|
||||
} catch { setError('Не удалось сохранить поездку. Проверьте доступ к хранилищу браузера.'); setSaving(false); }
|
||||
}
|
||||
return <Dialog title="Сохранить поездку" onClose={onClose}><form onSubmit={event => void submit(event)} className="tripForm">
|
||||
<p className="dialogDescription">{city.name} · {stop.name}<br />{stop.direction}</p>
|
||||
<label className="fieldLabel" htmlFor="trip-name">Название поездки</label><input id="trip-name" autoFocus required maxLength={40} value={name} onChange={event => setName(event.target.value)} />
|
||||
<div className="namePresets"><button type="button" className="secondaryButton" onClick={() => setName('Домой')}><Home size={18} />Домой</button><button type="button" className="secondaryButton" onClick={() => setName('На работу')}><Work size={18} />На работу</button></div>
|
||||
<label className="fieldLabel" htmlFor="trip-route">Маршрут поездки</label><select id="trip-route" value={selected} onChange={event => setSelected(Number(event.target.value))}>{routes.map(route => <option key={route.id} value={route.id}>№{route.number} · {route.name}</option>)}</select>
|
||||
{error && <p className="formError" role="alert">{error}</p>}<button className="primaryButton" disabled={saving || !name.trim()} type="submit">{saving ? 'Сохраняем…' : 'Сохранить поездку'} <ArrowRight size={18} /></button>
|
||||
</form></Dialog>;
|
||||
}
|
||||
|
||||
function TripCard({ trip, online, onNotice, onEdit }: { trip: QuickTrip; online: boolean; onNotice: (notice: string) => void; onEdit: () => void }) {
|
||||
const now = useNow();
|
||||
const query = useQuery({ queryKey: ['departures', trip.mode, trip.cityId, trip.stop.id], queryFn: () => loadResource<Board>('departures', trip.mode, trip.cityId, trip.stop.id), staleTime: 30000, refetchInterval: online ? 60000 : false });
|
||||
const departures = query.data?.data.departures.filter(item => item.routeId === trip.route.id && Date.parse(item.scheduledAt) >= now - 60000).slice(0, 3) ?? [];
|
||||
const first = departures[0];
|
||||
const saved = !online || !!query.data?.saved || query.isError;
|
||||
const suffix = new URLSearchParams({ ...(trip.mode === 'demo' ? { demo: '1' } : {}), city: String(trip.cityId), stop: String(trip.stop.id), route: String(trip.route.id) });
|
||||
return <article className="quickTripCard"><div className="quickTripHeading"><span className="tripSymbol">{trip.name === 'На работу' ? <Work size={23} /> : <Home size={23} />}</span><h3>{trip.name}</h3><button className="iconButton" aria-label={`Удалить поездку ${trip.name}`} onClick={() => void db.quickTrips.delete(trip.id).then(() => onNotice('Поездка удалена')).catch(() => onNotice('Не удалось удалить поездку'))}><X size={18} /></button></div>
|
||||
<p className="tripPlace">{trip.cityName} · {trip.stop.name}</p><p className="tripDirection">{trip.stop.direction}</p>
|
||||
<div className="tripNext"><span className="routeNumber">{trip.route.number}</span>{first ? <><strong>{formatTime(first.scheduledAt)}</strong><span>{formatWait((Date.parse(first.scheduledAt) - now) / 60000)}</span></> : <span>{query.isPending ? 'Загружаем…' : !query.data ? 'Отправления недоступны' : 'Ближайших рейсов пока нет'}</span>}</div>
|
||||
{departures.length > 1 && <p className="tripFollowing">Затем: {departures.slice(1).map(item => formatTime(item.scheduledAt)).join(' · ')}</p>}
|
||||
{saved && <p className="tripSaved">{query.data ? 'Сохранённые данные · могут быть устаревшими' : 'Подключитесь к интернету для загрузки'}</p>}
|
||||
<Alerts mode={trip.mode} cityId={trip.cityId} online={online} routes={[trip.route]} compact />
|
||||
<div className="tripActions"><NavLink to={`/?${suffix}`} state={{ boardTarget: { cityId: trip.cityId, stopId: trip.stop.id, routeId: trip.route.id } }}>Открыть поездку <ArrowRight size={18} /></NavLink><button className="textButton" onClick={onEdit}>Изменить</button></div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
export function QuickTrips({ trips, online, onNotice }: { trips: QuickTrip[]; online: boolean; onNotice: (notice: string) => void }) {
|
||||
const [editing, setEditing] = useState<QuickTrip | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
if (!trips.length) return null;
|
||||
async function rename(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!editing || !name.trim() || saving) return;
|
||||
setSaving(true);
|
||||
try { await db.quickTrips.update(editing.id, { name: name.trim() }); setEditing(null); onNotice('Поездка обновлена'); }
|
||||
catch { setError('Не удалось изменить поездку'); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
const ordered = [...trips].sort((a, b) => a.createdAt - b.createdAt);
|
||||
return <section className="quickTrips" aria-labelledby="trips-title"><div className="tripsTitle"><h2 id="trips-title">Мои поездки</h2><p>Нужная остановка, направление и маршрут — сразу вместе.</p></div><TripCarousel count={ordered.length} itemKeys={ordered.map(trip => trip.id).join(':')}>{ordered.map(trip => <TripCard key={trip.id} trip={trip} online={online} onNotice={onNotice} onEdit={() => { setEditing(trip); setName(trip.name); setError(''); }} />)}</TripCarousel>
|
||||
{editing && <Dialog title="Изменить поездку" onClose={() => setEditing(null)}><form className="tripForm" onSubmit={event => void rename(event)}><label className="fieldLabel" htmlFor="rename-trip">Название поездки</label><input id="rename-trip" autoFocus required maxLength={40} value={name} onChange={event => setName(event.target.value)} />{error && <p className="formError" role="alert">{error}</p>}<button className="primaryButton" type="submit" disabled={saving || !name.trim()}>Сохранить</button></form></Dialog>}
|
||||
</section>;
|
||||
}
|
||||
46
src/components/RoutePage.tsx
Normal file
46
src/components/RoutePage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NavLink, useSearchParams } from 'react-router-dom';
|
||||
import { DataError, loadResource } from '../lib/api';
|
||||
import { positiveParam } from '../lib/links';
|
||||
import { formatDate, formatTime, isTravelDate, localDate, useNow } from '../lib/time';
|
||||
import { ArrowRight, Clock3, RefreshCw, WifiOff } from './Icons';
|
||||
import { Alerts } from './ServiceAlerts';
|
||||
import { ShareDeparture } from './ShareDeparture';
|
||||
import type { Journey } from '../types';
|
||||
|
||||
export function RoutePage({ online, onNotice }: { online: boolean; onNotice: (notice: string) => void }) {
|
||||
const [params] = useSearchParams();
|
||||
const now = useNow();
|
||||
const mode = params.get('demo') === '1' ? 'demo' : 'live';
|
||||
const cityId = positiveParam(params.get('city'));
|
||||
const stopId = positiveParam(params.get('stop'));
|
||||
const routeId = positiveParam(params.get('route'));
|
||||
const at = params.get('at') || '';
|
||||
const time = Date.parse(at);
|
||||
const valid = !!cityId && !!stopId && !!routeId && Number.isFinite(time) && isTravelDate(localDate(time)) && new Date(time).toISOString() === at;
|
||||
const tripKey = params.get('tripKey') || undefined;
|
||||
const query = useQuery({ queryKey: ['journey', mode, cityId, stopId, routeId, at, tripKey], queryFn: () => loadResource<Journey>('journey', mode, cityId, stopId, undefined, { routeId, at, tripKey }), enabled: valid, staleTime: 300000 });
|
||||
const journey = query.data?.data;
|
||||
const selected = journey?.stops.find(stop => stop.id === stopId);
|
||||
const stale = !online || !!query.data?.saved || query.isError;
|
||||
function scheduleLink(nextStop = stopId, nextDate?: string) {
|
||||
const back = new URLSearchParams(mode === 'demo' ? { demo: '1' } : {});
|
||||
if (cityId) back.set('city', String(cityId));
|
||||
if (nextStop) back.set('stop', String(nextStop));
|
||||
const filter = nextStop === stopId ? positiveParam(params.get('filter')) : routeId;
|
||||
if (filter) back.set('route', String(filter));
|
||||
if (params.get('view') === 'day' && isTravelDate(nextDate || params.get('date'))) { back.set('view', 'day'); back.set('date', nextDate || params.get('date')!); }
|
||||
return `/?${back}`;
|
||||
}
|
||||
return <section className="routePage"><NavLink className="backToSchedule" to={scheduleLink()}><ArrowRight size={18} />К расписанию остановки</NavLink>
|
||||
{!valid ? <div className="boardEmpty"><h1>Не удалось открыть рейс</h1><p>Выберите отправление в расписании остановки.</p></div>
|
||||
: query.isPending ? <div className="boardEmpty" role="status"><Clock3 size={32} /><p>Загружаем маршрут…</p></div>
|
||||
: !journey ? <div className="boardEmpty"><WifiOff size={30} /><h1>{online ? 'Рейс пока недоступен' : 'Нет сохранённого маршрута'}</h1><p>{query.error instanceof DataError && query.error.code === 'journey_ambiguous' ? 'На это время есть несколько вариантов рейса. Выберите отправление в полном расписании на дату.' : online ? 'Расписание могло измениться. Вернитесь к остановке и выберите рейс заново.' : 'Откройте этот рейс при подключении к интернету, чтобы сохранить его.'}</p><button className="secondaryButton" disabled={!online || query.isFetching} onClick={() => void query.refetch()}>Попробовать снова</button></div>
|
||||
: <><div className="routeHero"><span className="routeNumber">{journey.route.number}</span><div><span className="eyebrow">ВАШ РЕЙС</span><h1>{journey.route.name}</h1><p>{selected ? `${formatDate(selected.scheduledAt)} · ${formatTime(selected.scheduledAt)} на вашей остановке` : 'Остановки маршрута'}</p>{selected && <ShareDeparture departure={{ ...selected, routeId: journey.route.id, routeNumber: journey.route.number, direction: journey.route.name, tripKey: journey.tripKey }} cityId={cityId!} stopId={selected.id} stopName={selected.name} mode={mode} onNotice={onNotice} withLabel />}</div></div>
|
||||
<div className="routeBody"><div className="routeTimelineCard"><div className="routeTimelineHeading"><h2>Остановки рейса</h2><span>{journey.stops.length}</span></div>{journey.note && <p className="savedNotice">{journey.note}</p>}{stale && <p className="savedNotice">{online ? 'Не удалось обновить маршрут.' : 'Вы офлайн.'} Сохранено {formatDate(journey.updatedAt)} в {formatTime(journey.updatedAt)} · данные могут быть устаревшими.</p>}
|
||||
<ol className="journeyTimeline">{journey.stops.map((stop, index) => <li className={`${stop.id === stopId ? 'selectedJourneyStop' : ''} ${Date.parse(stop.scheduledAt) < now - 60000 ? 'passedJourneyStop' : ''}`} key={`${stop.sequence}:${stop.id}`}><span className="journeyPoint" aria-hidden="true">{index + 1}</span><NavLink to={scheduleLink(stop.id, localDate(Date.parse(stop.scheduledAt)))}><strong>{stop.name}</strong>{stop.id === stopId && <span>Ваша остановка</span>}</NavLink><div className="journeyTime"><strong>{formatTime(stop.scheduledAt)}</strong>{selected && localDate(Date.parse(stop.scheduledAt)) !== localDate(Date.parse(selected.scheduledAt)) && <small>{formatDate(stop.scheduledAt)}</small>}</div></li>)}</ol>
|
||||
<div className="boardFooter"><span>Время по расписанию, без учёта задержек</span><button className="refreshButton" disabled={!online || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={16} className={query.isFetching ? 'spinning' : ''} />Обновить</button></div>
|
||||
</div><Alerts mode={mode} cityId={cityId!} online={online} routes={[journey.route]} /></div>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
26
src/components/ServiceAlerts.tsx
Normal file
26
src/components/ServiceAlerts.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { loadResource } from '../lib/api';
|
||||
import { relevantAlerts } from '../lib/alerts';
|
||||
import { formatDate, formatTime } from '../lib/time';
|
||||
import { Warning, WifiOff } from './Icons';
|
||||
import type { Route, ServiceAlert } from '../types';
|
||||
|
||||
export function Alerts({ mode, cityId, online, routes, compact = false }: { mode: 'live' | 'demo'; cityId: number; online: boolean; routes?: Route[]; compact?: boolean }) {
|
||||
const query = useQuery({ queryKey: ['alerts', mode, cityId], queryFn: () => loadResource<ServiceAlert[]>('alerts', mode, cityId), staleTime: 30000, refetchInterval: online ? 60000 : false });
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const update = () => {
|
||||
const time = Date.now(); setNow(time); clearTimeout(timer);
|
||||
const boundary = (query.data?.data ?? []).flatMap(alert => [alert.startTime, alert.endTime]).filter((value): value is string => !!value).map(Date.parse).filter(value => value > time).sort((a, b) => a - b)[0];
|
||||
if (boundary) timer = setTimeout(update, Math.min(boundary - time + 1, 2147483647));
|
||||
};
|
||||
update(); document.addEventListener('visibilitychange', update); window.addEventListener('focus', update);
|
||||
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', update); window.removeEventListener('focus', update); };
|
||||
}, [query.data]);
|
||||
const alerts = relevantAlerts(query.data?.data ?? [], cityId, now, routes);
|
||||
const stale = !online || !!query.data?.saved || query.isError;
|
||||
if (!alerts.length) return query.isError || stale ? <p className="alertsUnavailable"><WifiOff size={15} />{stale ? 'Изменения движения сейчас не обновляются' : 'Не удалось проверить изменения движения'}</p> : null;
|
||||
return <aside className={`serviceAlerts ${compact ? 'compactAlerts' : ''}`} aria-label="Изменения движения"><div className="alertsHeading"><Warning size={19} /><strong>Изменения движения</strong></div>{stale && <p className="alertSaved">Сохранённые сообщения · возможны новые изменения</p>}{alerts.map(alert => <article key={alert.id} className={`serviceAlert alert-${alert.type}`}><span className="alertRoutes">{alert.isCityWide ? 'По всему городу' : alert.routeNumbers.length ? alert.routeNumbers.map(number => `№${number}`).join(' · ') : routes?.filter(route => alert.routeIds.includes(route.id)).map(route => `№${route.number}`).join(' · ') || 'Изменение маршрута'}</span><h3>{alert.title}</h3><p>{alert.message}</p><small>{formatDate(alert.startTime)} · с {formatTime(alert.startTime)}{alert.endTime && ` до ${formatDate(alert.endTime)}, ${formatTime(alert.endTime)}`}</small></article>)}</aside>;
|
||||
}
|
||||
40
src/components/ShareDeparture.tsx
Normal file
40
src/components/ShareDeparture.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Dialog } from './Dialog';
|
||||
import { RefreshCw, Share } from './Icons';
|
||||
import { journeyLink } from '../lib/links';
|
||||
import { formatTime, localDate } from '../lib/time';
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function ShareDeparture({ departure, cityId, stopId, stopName, mode, onNotice, withLabel = false }: {
|
||||
departure: Departure; cityId: number; stopId: number; stopName: string; mode: 'live' | 'demo'; onNotice: (notice: string) => void; withLabel?: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [manual, setManual] = useState(false);
|
||||
const inFlight = useRef(false);
|
||||
const date = localDate(Date.parse(departure.scheduledAt));
|
||||
const url = new URL(journeyLink(mode, cityId, stopId, departure, 'day', date, departure.routeId), window.location.origin).href;
|
||||
const day = new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(departure.scheduledAt));
|
||||
const data = { title: `RideBus · №${departure.routeNumber} · ${formatTime(departure.scheduledAt)}`,
|
||||
text: `${stopName} · ${day}, ${formatTime(departure.scheduledAt)}\nМаршрут №${departure.routeNumber}: ${departure.direction}\nВремя по расписанию.${mode === 'demo' ? '\nДемонстрационный рейс.' : ''}`, url };
|
||||
async function copy() {
|
||||
try { await navigator.clipboard.writeText(url); onNotice('Ссылка на отправление скопирована'); setManual(false); }
|
||||
catch { setManual(true); }
|
||||
}
|
||||
async function share() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true);
|
||||
try {
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
|
||||
try { await navigator.share(data); return; }
|
||||
catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; }
|
||||
}
|
||||
await copy();
|
||||
} finally { inFlight.current = false; setBusy(false); }
|
||||
}
|
||||
return <>
|
||||
<button className={withLabel ? 'secondaryButton shareDepartureButton' : 'iconButton shareDepartureButton'} disabled={busy} title="Поделиться отправлением" aria-label={withLabel ? 'Поделиться отправлением' : `Поделиться отправлением ${formatTime(departure.scheduledAt)}, маршрут ${departure.routeNumber}`} onClick={() => void share()}>
|
||||
{busy ? <RefreshCw size={20} className="spinning" /> : <Share size={20} />}{withLabel && 'Поделиться отправлением'}
|
||||
</button>
|
||||
{manual && <Dialog title="Поделиться отправлением" onClose={() => setManual(false)}><p className="dialogDescription">{stopName} · №{departure.routeNumber}<br />{day}, {formatTime(departure.scheduledAt)}</p><div className="shareLinkForm"><label className="fieldLabel" htmlFor="departure-link">Ссылка на отправление</label><input id="departure-link" readOnly autoFocus value={url} onFocus={event => event.currentTarget.select()} /><p>Если копирование недоступно, скопируйте выделенную ссылку вручную.</p><button className="primaryButton" onClick={() => void copy()}>Скопировать ссылку</button></div></Dialog>}
|
||||
</>;
|
||||
}
|
||||
16
src/components/StopList.tsx
Normal file
16
src/components/StopList.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowRight, Bookmark } from './Icons';
|
||||
|
||||
export function StopList({ label, busy = false, children }: { label: string; busy?: boolean; children: ReactNode }) {
|
||||
return <div className="stopsList appScrollArea" aria-label={label} aria-busy={busy}>{children}</div>;
|
||||
}
|
||||
|
||||
export function StopListItem({ name, selected, favorite, distance, onSelect }: {
|
||||
name: string; selected: boolean; favorite?: boolean; distance?: number; onSelect: () => void;
|
||||
}) {
|
||||
return <button className={`stopItem ${selected ? 'activeStop' : ''}`} onClick={onSelect} aria-pressed={selected}>
|
||||
<span className="stopCopy"><strong>{name}</strong></span>
|
||||
{distance !== undefined && <span className="stopDistance">{distance < 1000 ? `${distance} м` : `${(distance / 1000).toFixed(1)} км`}</span>}
|
||||
<span className="stopEnd">{favorite ? <Bookmark size={20} fill="currentColor" /> : <ArrowRight size={20} />}</span>
|
||||
</button>;
|
||||
}
|
||||
49
src/components/TripCarousel.tsx
Normal file
49
src/components/TripCarousel.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowRight } from './Icons';
|
||||
|
||||
export function TripCarousel({ count, itemKeys, children }: { count: number; itemKeys: string; children: ReactNode }) {
|
||||
const track = useRef<HTMLDivElement>(null);
|
||||
const [mobile, setMobile] = useState(() => matchMedia('(max-width: 760px)').matches);
|
||||
const [position, setPosition] = useState<{ first: number; previous: boolean; next: boolean; height?: number }>({ first: 0, previous: false, next: false });
|
||||
const carousel = count > 1 && (mobile || count >= 3);
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(max-width: 760px)');
|
||||
const update = () => setMobile(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const element = track.current!;
|
||||
function update() {
|
||||
const cards = [...element.children] as HTMLElement[];
|
||||
const offset = cards[0]?.offsetLeft ?? 0;
|
||||
const first = cards.reduce((closest, card, index) => Math.abs(card.offsetLeft - offset - element.scrollLeft) < Math.abs(cards[closest].offsetLeft - offset - element.scrollLeft) ? index : closest, 0);
|
||||
const previous = element.scrollLeft > 2, next = element.scrollWidth - element.clientWidth - element.scrollLeft > 2;
|
||||
const visible = cards.slice(first, first + (mobile ? 1 : 2));
|
||||
const height = carousel && visible.length ? Math.max(...visible.map(card => card.offsetHeight)) + 24 : undefined;
|
||||
if (element.scrollTop !== 0) element.scrollTop = 0;
|
||||
setPosition(current => current.first === first && current.previous === previous && current.next === next && current.height === height ? current : { first, previous, next, height });
|
||||
}
|
||||
if (!carousel) element.scrollLeft = 0;
|
||||
update(); element.addEventListener('scroll', update, { passive: true });
|
||||
const observer = new ResizeObserver(update); observer.observe(element);
|
||||
for (const card of element.children) observer.observe(card);
|
||||
return () => { observer.disconnect(); element.removeEventListener('scroll', update); };
|
||||
}, [carousel, itemKeys, mobile]);
|
||||
function move(index: number) {
|
||||
const element = track.current!;
|
||||
const first = element.children[0] as HTMLElement;
|
||||
const card = element.children[Math.max(0, Math.min(index, count - 1))] as HTMLElement;
|
||||
element.scrollTo({ left: card.offsetLeft - first.offsetLeft, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
}
|
||||
const visibleEnd = Math.min(count, position.first + (mobile ? 1 : 2));
|
||||
return <div className="tripsCollection">
|
||||
<div ref={track} className={`quickTripsGrid ${carousel ? 'tripsCarouselTrack appScrollArea' : ''}`} data-fade-start={carousel && position.previous} data-fade-end={carousel && position.next} style={carousel && position.height ? { height: position.height } : undefined} tabIndex={carousel ? 0 : undefined} role={carousel ? 'region' : undefined} aria-roledescription={carousel ? 'карусель' : undefined} aria-label={carousel ? 'Сохранённые поездки' : undefined} onKeyDown={event => {
|
||||
if (!carousel || event.target !== event.currentTarget) return;
|
||||
if (['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault(); move(event.key === 'Home' ? 0 : event.key === 'End' ? count - 1 : position.first + (event.key === 'ArrowRight' ? 1 : -1));
|
||||
}
|
||||
}}>{children}</div>
|
||||
{carousel && <div className="carouselControls"><span aria-live="polite" aria-atomic="true">{mobile ? position.first + 1 : `${position.first + 1}–${visibleEnd}`} из {count}</span><div><button className="iconButton carouselPrevious" disabled={!position.previous} aria-label="Предыдущая поездка" onClick={() => move(position.first - 1)}><ArrowRight size={21} /></button><button className="iconButton" disabled={!position.next} aria-label="Следующая поездка" onClick={() => move(position.first + 1)}><ArrowRight size={21} /></button></div></div>}
|
||||
</div>;
|
||||
}
|
||||
12
src/lib/alerts.ts
Normal file
12
src/lib/alerts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Route, ServiceAlert } from '../types';
|
||||
|
||||
const routeNumber = (value: string) => value.replace(/[\s№]/g, '').toLocaleUpperCase('ru');
|
||||
export function relevantAlerts(alerts: ServiceAlert[], cityId: number, now: number, routes?: Route[]) {
|
||||
const seen = new Set<number>();
|
||||
return alerts.filter(alert => {
|
||||
const start = Date.parse(alert.startTime), end = alert.endTime === null ? null : Date.parse(alert.endTime);
|
||||
if (alert.cityId !== cityId || seen.has(alert.id) || !Number.isFinite(start) || start > now || (end !== null && (!Number.isFinite(end) || end <= now))) return false;
|
||||
seen.add(alert.id);
|
||||
return alert.isCityWide || !routes || routes.some(route => alert.routeIds.includes(route.id) || alert.routeNumbers.some(number => routeNumber(number) === routeNumber(route.number)));
|
||||
});
|
||||
}
|
||||
51
src/lib/api.ts
Normal file
51
src/lib/api.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { db } from './storage';
|
||||
import { demoData } from './demo';
|
||||
import type { NearbyStop, Snapshot } from '../types';
|
||||
|
||||
export type ResourceOptions = { routeId?: number; at?: string; tripKey?: string };
|
||||
|
||||
export class DataError extends Error {
|
||||
constructor(public code: string) { super(code); }
|
||||
}
|
||||
|
||||
export async function loadResource<T>(
|
||||
resource: string, mode: 'live' | 'demo', cityId?: number, stopId?: number, date?: string, options: ResourceOptions = {},
|
||||
): Promise<Snapshot<T>> {
|
||||
const key = `${mode}:${resource}:${cityId ?? ''}:${stopId ?? ''}${date ? `:${date}` : ''}${options.routeId ? `:${options.routeId}:${options.at ?? ''}:${options.tripKey ?? ''}` : ''}`;
|
||||
let data: T;
|
||||
try {
|
||||
if (!navigator.onLine) throw new DataError('offline');
|
||||
if (mode === 'demo') data = demoData(resource, cityId, stopId, date, options) as T;
|
||||
else {
|
||||
const params = new URLSearchParams({ resource });
|
||||
if (cityId) params.set('cityId', String(cityId));
|
||||
if (stopId) params.set('stopId', String(stopId));
|
||||
if (date) params.set('date', date);
|
||||
if (options.routeId) params.set('routeId', String(options.routeId));
|
||||
if (options.at) params.set('at', options.at);
|
||||
if (options.tripKey) params.set('tripKey', options.tripKey);
|
||||
const response = await fetch(`/api/data?${params}`, { signal: AbortSignal.timeout(15000), cache: 'no-store' });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new DataError(body.error || 'data_unavailable');
|
||||
data = body as T;
|
||||
}
|
||||
} catch (error) {
|
||||
const snapshot = await db.snapshots.get(key).catch(() => undefined);
|
||||
if (snapshot) return { data: snapshot.data as T, savedAt: snapshot.savedAt, saved: true };
|
||||
throw error;
|
||||
}
|
||||
const savedAt = new Date().toISOString();
|
||||
// Storage failure must not hide otherwise available API data.
|
||||
await db.snapshots.put({ key, data, savedAt }).catch(() => undefined);
|
||||
return { data, savedAt, saved: false };
|
||||
}
|
||||
|
||||
export async function loadNearby(mode: 'live' | 'demo', cityId: number, lat: number, lon: number, signal: AbortSignal): Promise<NearbyStop[]> {
|
||||
if (mode === 'demo') return (demoData('stops', cityId) as NearbyStop[]).slice(0, 4).map((stop, index) => ({ ...stop, distance: 120 + index * 90 }));
|
||||
const params = new URLSearchParams({ resource: 'nearby', cityId: String(cityId), lat: lat.toFixed(5), lon: lon.toFixed(5), radius: '1000' });
|
||||
const response = await fetch(`/api/data?${params}`, { cache: 'no-store', signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new DataError(data.error || 'data_unavailable');
|
||||
// Nearby coordinates and results stay in memory, outside persistent snapshots.
|
||||
return data as NearbyStop[];
|
||||
}
|
||||
66
src/lib/demo.ts
Normal file
66
src/lib/demo.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Board, City, Journey, ServiceAlert, Stop, Timetable } from '../types';
|
||||
import type { ResourceOptions } from './api';
|
||||
|
||||
const cities: City[] = [
|
||||
{ id: 1, name: 'Полоцк', slug: 'polotsk' },
|
||||
{ id: 2, name: 'Новополоцк', slug: 'novopolotsk' },
|
||||
];
|
||||
const stops: Stop[] = [
|
||||
{ id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4 },
|
||||
{ id: 102, name: 'Площадь Свободы', direction: 'В сторону центра', routesCount: 3 },
|
||||
{ id: 103, name: 'Автовокзал', direction: 'В сторону микрорайона Аэропорт', routesCount: 5 },
|
||||
{ id: 104, name: 'Больница', direction: 'В сторону центра', routesCount: 2 },
|
||||
{ id: 105, name: 'Железнодорожный вокзал', direction: 'В сторону площади Свободы', routesCount: 3 },
|
||||
{ id: 106, name: 'Университет', direction: 'В сторону автовокзала', routesCount: 2 },
|
||||
];
|
||||
|
||||
export function demoData(resource: string, cityId?: number, stopId?: number, date?: string, options: ResourceOptions = {}): City[] | Stop[] | Board | Timetable | ServiceAlert[] | Journey {
|
||||
if (resource === 'cities') return cities;
|
||||
if (resource === 'stops') return cityId === 2
|
||||
? stops.map(stop => ({ ...stop, id: stop.id + 100, name: stop.id === 101 ? 'Площадь Строителей' : stop.name })) : stops;
|
||||
const now = Date.now();
|
||||
const routes = [
|
||||
{ id: 1, number: '4', name: 'Автовокзал → Больница' },
|
||||
{ id: 2, number: '10', name: 'Вокзал → Аэропорт' },
|
||||
{ id: 3, number: '12', name: 'Боровуха → Автовокзал' },
|
||||
{ id: 4, number: '24', name: 'Центр → Университет' },
|
||||
];
|
||||
if (resource === 'alerts') return cityId === 2 ? [] : [{
|
||||
id: 1, cityId: 1, type: 'detour', title: 'Изменение маршрутов 4 и 12',
|
||||
message: 'Пример сообщения: автобусы следуют через улицу Гагарина. Остановка «Больница» временно перенесена к соседнему перекрёстку.',
|
||||
routeNumbers: ['4', '12'], routeIds: [1, 3], isCityWide: false,
|
||||
startTime: new Date(now - 3600000).toISOString(), endTime: new Date(now + 14400000).toISOString(),
|
||||
}];
|
||||
if (resource === 'journey') {
|
||||
const cityStops = (demoData('stops', cityId) as Stop[]);
|
||||
const selected = cityStops.findIndex(stop => stop.id === stopId);
|
||||
const at = Date.parse(options.at || new Date(now).toISOString());
|
||||
return { route: routes.find(route => route.id === options.routeId) || routes[0], selectedStopId: stopId || cityStops[0].id,
|
||||
note: '', updatedAt: new Date(now).toISOString(), stops: cityStops.map((stop, index) => {
|
||||
const scheduledAt = new Date(at + (index - Math.max(selected, 0)) * 300000).toISOString();
|
||||
return { id: stop.id, sequence: index + 1, name: stop.name, scheduledAt,
|
||||
scheduledTime: new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(new Date(scheduledAt)) };
|
||||
}) };
|
||||
}
|
||||
if (resource === 'timetable' && date) return {
|
||||
date, routes, partial: false, updatedAt: new Date(now).toISOString(), isHoliday: false, holidayName: null,
|
||||
departures: Array.from({ length: 32 }, (_, i) => {
|
||||
const route = routes[(i + (stopId ?? 0)) % routes.length];
|
||||
const minutes = 360 + i * 30;
|
||||
const scheduledTime = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
||||
return { routeId: route.id, routeNumber: route.number, direction: route.name, scheduledTime,
|
||||
scheduledAt: new Date(`${date}T${scheduledTime}:00+03:00`).toISOString() };
|
||||
}),
|
||||
};
|
||||
return {
|
||||
routes, partial: false, updatedAt: new Date(now).toISOString(),
|
||||
departures: [4, 9, 15, 22, 31, 38, 46, 57].map((minutes, i) => {
|
||||
const route = routes[(i + (stopId ?? 0)) % routes.length];
|
||||
const scheduledAt = new Date(now + minutes * 60000).toISOString();
|
||||
return {
|
||||
routeId: route.id, routeNumber: route.number, direction: route.name, scheduledAt,
|
||||
scheduledTime: new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(new Date(scheduledAt)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
10
src/lib/links.ts
Normal file
10
src/lib/links.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function positiveParam(value: string | null) {
|
||||
const number = Number(value);
|
||||
return value && /^[1-9]\d*$/.test(value) && Number.isSafeInteger(number) ? number : undefined;
|
||||
}
|
||||
export function journeyLink(mode: 'live' | 'demo', cityId: number, stopId: number, departure: Departure, view: 'next' | 'day', date: string, filter: number | null) {
|
||||
return `/route?${new URLSearchParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: String(cityId), stop: String(stopId), route: String(departure.routeId), at: departure.scheduledAt,
|
||||
...(departure.tripKey ? { tripKey: departure.tripKey } : {}), ...(view === 'day' ? { view, date } : {}), ...(filter ? { filter: String(filter) } : {}) })}`;
|
||||
}
|
||||
32
src/lib/stopGroups.ts
Normal file
32
src/lib/stopGroups.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Stop } from '../types';
|
||||
|
||||
export type StopGroup = { key: string; name: string; stops: Stop[] };
|
||||
|
||||
// Match the landing: preserve directional stop IDs while listing each name once.
|
||||
export function groupStops(stops: Stop[]): StopGroup[] {
|
||||
const groups = new Map<string, StopGroup>();
|
||||
const seen = new Set<number>();
|
||||
for (const stop of stops) {
|
||||
if (seen.has(stop.id)) continue;
|
||||
seen.add(stop.id);
|
||||
const name = stop.name.trim().replace(/\s+/g, ' ');
|
||||
const key = name.normalize('NFKC').toLocaleLowerCase('ru');
|
||||
const group = groups.get(key) ?? { key, name, stops: [] };
|
||||
group.stops.push(stop);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name, 'ru', { numeric: true }));
|
||||
}
|
||||
|
||||
export function directionLabel(group: StopGroup, stop: Stop, index: number): string {
|
||||
if (!stop.direction) return group.stops.length === 1 ? 'Отправления с этой остановки' : `Направление ${index + 1}`;
|
||||
const repeated = group.stops.filter(item => item.direction === stop.direction).length > 1;
|
||||
return `${stop.direction}${repeated ? ` · ${index + 1}` : ''}`;
|
||||
}
|
||||
|
||||
export function countLabel(count: number, forms: [string, string, string]): string {
|
||||
const lastTwo = count % 100;
|
||||
const last = count % 10;
|
||||
const form = lastTwo >= 11 && lastTwo <= 14 ? forms[2] : last === 1 ? forms[0] : last >= 2 && last <= 4 ? forms[1] : forms[2];
|
||||
return `${count} ${form}`;
|
||||
}
|
||||
19
src/lib/storage.ts
Normal file
19
src/lib/storage.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
import type { QuickTrip, SavedStop } from '../types';
|
||||
|
||||
export const db = new Dexie('ridebus-pwa') as Dexie & {
|
||||
snapshots: EntityTable<{ key: string; data: unknown; savedAt: string }, 'key'>;
|
||||
favorites: EntityTable<SavedStop, 'key'>;
|
||||
quickTrips: EntityTable<QuickTrip, 'id'>;
|
||||
};
|
||||
db.version(1).stores({ snapshots: 'key', favorites: 'key, cityId, mode' });
|
||||
db.version(2).stores({ snapshots: 'key', favorites: 'key, cityId, mode', quickTrips: 'id, mode, cityId' });
|
||||
|
||||
export const stopKey = (mode: string, cityId: number, stopId: number) => `${mode}:${cityId}:${stopId}`;
|
||||
|
||||
export function readPreference(key: string) {
|
||||
try { return localStorage.getItem(`ridebus:${key}`); } catch { return null; }
|
||||
}
|
||||
export function savePreference(key: string, value: string) {
|
||||
try { localStorage.setItem(`ridebus:${key}`, value); } catch { /* browsing still works without storage */ }
|
||||
}
|
||||
33
src/lib/time.ts
Normal file
33
src/lib/time.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||||
}
|
||||
export function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-BY', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long' }).format(new Date(value));
|
||||
}
|
||||
export function localDate(time = Date.now()) {
|
||||
return new Date(time + 10800000).toISOString().slice(0, 10);
|
||||
}
|
||||
export function isTravelDate(value: string | null): value is string {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value) || value < '2000-01-02' || value > '2100-12-31') return false;
|
||||
const time = Date.parse(`${value}T12:00:00Z`);
|
||||
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value;
|
||||
}
|
||||
export function formatWait(minutes: number) {
|
||||
const total = Math.max(0, Math.ceil(minutes));
|
||||
if (total === 0) return 'сейчас';
|
||||
const hours = Math.floor(total / 60);
|
||||
const remainder = total % 60;
|
||||
return `через ${hours ? `${hours} ч${remainder ? ' ' : ''}` : ''}${remainder ? `${remainder} мин` : ''}`;
|
||||
}
|
||||
export function useNow() {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const update = () => setNow(Date.now());
|
||||
const timer = window.setInterval(update, 15000);
|
||||
document.addEventListener('visibilitychange', update);
|
||||
return () => { clearInterval(timer); document.removeEventListener('visibilitychange', update); };
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
10
src/lib/timetable.ts
Normal file
10
src/lib/timetable.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Departure } from '../types';
|
||||
|
||||
export function groupByHour(departures: Departure[]) {
|
||||
const hours = new Map<string, Departure[]>();
|
||||
for (const departure of [...departures].sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt))) {
|
||||
const hour = new Date(Date.parse(departure.scheduledAt) + 10800000).toISOString().slice(11, 13);
|
||||
hours.set(hour, [...(hours.get(hour) ?? []), departure]);
|
||||
}
|
||||
return [...hours].map(([hour, departures]) => ({ hour, departures }));
|
||||
}
|
||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import '@fontsource-variable/manrope';
|
||||
import App from './App';
|
||||
import './styles.scss';
|
||||
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: 1, networkMode: 'always', refetchOnWindowFocus: true } } });
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={client}><BrowserRouter><App /></BrowserRouter></QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
260
src/styles.scss
Normal file
260
src/styles.scss
Normal file
@@ -0,0 +1,260 @@
|
||||
@use './tokens';
|
||||
@use './motion';
|
||||
@use './features';
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Manrope Variable', 'Manrope', sans-serif; color: var(--ink); background: var(--page); font-size: 16px; line-height: 1.5; font-weight: 500; -webkit-font-smoothing: antialiased; }
|
||||
button, input, select { font: inherit; }
|
||||
button, a, select { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: default; opacity: .5; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, a { touch-action: manipulation; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #638ce1; outline-offset: 4px; }
|
||||
button { color: inherit; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
svg, .vkuiIcon { flex-shrink: 0; }
|
||||
.app { max-width: 1240px; margin: auto; padding: 0 48px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; height: 112px; gap: 24px; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; }
|
||||
.brand > span:first-of-type { font-size: 28px; letter-spacing: -.5px; font-weight: 800; }
|
||||
.brandDot { color: var(--blue); }
|
||||
.webTag { font-size: 14px; letter-spacing: .5px; background: var(--lavender); color: var(--blue); padding: 5px 8px; border-radius: 7px; font-weight: 750; }
|
||||
.desktopNav { display: flex; gap: 6px; background: #eeeee9; border-radius: 40px; padding: 5px; }
|
||||
.desktopNav a { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 750; padding: 11px 17px; border-radius: 30px; color: var(--muted); }
|
||||
.desktopNav a.active { background: white; color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
|
||||
.navCount { background: var(--lavender); border-radius: 20px; padding: 1px 7px; font-size: 13px; font-weight: 750; }
|
||||
.headerActions { display: flex; gap: 19px; align-items: center; }
|
||||
.noteDot { width: 6px; height: 6px; border-radius: 50%; background: #6e955f; }
|
||||
.installButton { display: flex; align-items: center; gap: 8px; border: 0; border-radius: 30px; padding: 11px 17px; color: var(--blue); background: var(--blue-soft); font-size: 15px; font-weight: 750; }
|
||||
.hero { display: flex; gap: 45px; align-items: center; justify-content: space-between; padding: 56px 0 42px; }
|
||||
.eyebrow { display: flex; align-items: center; gap: 8px; color: var(--blue); font-size: 14px; letter-spacing: .2px; font-weight: 800; }
|
||||
.tinyStar { font-size: 19px; line-height: 1; }
|
||||
.hero h1, .favoritesHero h1 { font-size: clamp(36px, 4.6vw, 58px); letter-spacing: -.5px; line-height: 1.13; font-weight: 780; margin: 18px 0; }
|
||||
.hero h1 span, .favoritesHero h1 span { color: var(--blue); }
|
||||
.heroCopy > p, .favoritesHero > p { font-size: 16px; line-height: 1.8; color: var(--muted); font-weight: 500; }
|
||||
.heroNote { position: relative; isolation: isolate; flex-shrink: 0; width: 280px; min-height: 252px; padding: 25px 26px; border-radius: 30px; background: var(--green); color: var(--green-ink); transform: rotate(3deg); overflow: hidden; }
|
||||
.noteTop { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 750; margin-bottom: 24px; }
|
||||
.heroNote > .vkuiIcon { position: absolute; top: 44px; right: 26px; transform: rotate(-12deg); }
|
||||
.heroNote strong { font-size: 25px; line-height: 1.2; letter-spacing: -.5px; }
|
||||
.noteBottom { display: flex; align-items: center; justify-content: space-between; margin-top: 23px; font-size: 14px; font-weight: 650; }
|
||||
.heroStar { position: absolute; z-index: -1; opacity: .16; width: 145px; height: 145px; right: -36px; bottom: -34px; filter: sepia(1) saturate(.4); }
|
||||
.workspace { display: grid; grid-template-columns: 340px minmax(0, 1fr); align-items: start; gap: 24px; }
|
||||
.stopsCard { background: var(--lavender); border-radius: var(--card-radius); padding: 26px 22px 0; overflow: hidden; color: var(--blue); }
|
||||
.sectionHeading { display: flex; align-items: center; justify-content: space-between; }
|
||||
.sectionHeading h2 { font-size: 25px; font-weight: 800; letter-spacing: -.5px; }
|
||||
.sectionDescription { margin: 6px 0 25px; font-size: 14px; color: var(--blue-muted); }
|
||||
.fieldLabel { display: block; font-size: 14px; font-weight: 750; margin: 16px 0 8px; }
|
||||
.citySelect, .searchField { display: flex; align-items: center; gap: 9px; border-radius: 14px; padding: 0 14px; height: 49px; background: white; }
|
||||
.citySelect select { appearance: none; width: 100%; border: 0; background: transparent; font-size: 16px; color: var(--blue); font-weight: 700; cursor: pointer; padding: 8px 0; }
|
||||
.searchField input { min-width: 0; width: 100%; border: 0; background: transparent; font-size: 16px; height: 100%; color: var(--blue); }
|
||||
.searchField input::placeholder { color: #69758e; font-size: 16px; }
|
||||
.citySelect:focus-within, .searchField:focus-within { box-shadow: 0 0 0 2px #aec6ff; }
|
||||
.citySelect select:focus-visible, .searchField input:focus-visible { outline: 0; }
|
||||
.stopsList { margin: 18px -8px 8px; padding: 0 8px; max-height: 398px; overflow-y: auto; }
|
||||
.stopItem { width: 100%; min-height: 54px; text-align: left; display: flex; gap: 12px; align-items: center; padding: 14px 16px; border: 1px solid transparent; border-radius: 15px; background: transparent; margin-bottom: 4px; transition: background .18s; }
|
||||
.stopItem:hover { background: #ffffff90; }
|
||||
.stopItem.activeStop { background: white; border-color: #c3d3f5; box-shadow: 0 3px 8px #2c467809; }
|
||||
.stopCopy { flex: 1; min-width: 0; }
|
||||
.stopCopy strong { font-size: 16px; line-height: 1.4; font-weight: 750; overflow-wrap: anywhere; }
|
||||
.stopEnd { margin-left: auto; opacity: .6; }
|
||||
.stopsFooter { border-top: 1px solid #dce0ee; display: flex; justify-content: space-between; font-size: 13px; color: var(--blue-muted); padding: 14px 0 18px; }
|
||||
.stopsFooter span { display: flex; align-items: center; gap: 4px; }
|
||||
.boardCard { background: white; border: 1px solid #eeeee9; border-radius: var(--card-radius); min-height: 590px; padding: 29px 30px 20px; min-width: 0; }
|
||||
.boardHeading { display: flex; align-items: center; justify-content: space-between; min-height: 44px; gap: 10px; }
|
||||
.boardHeading .eyebrow { font-size: 15px; letter-spacing: 0; font-weight: 650; }
|
||||
.iconButton { background: transparent; border: 0; min-width: 44px; min-height: 44px; padding: 10px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.iconButton:hover { background: var(--lavender); }
|
||||
.favoriteToggle { color: #8390a9; background: var(--lavender); }
|
||||
.favoriteToggle.isFavorite { color: var(--blue); background: var(--blue-soft); }
|
||||
.boardCard h2 { font-size: 28px; line-height: 1.25; letter-spacing: -.5px; margin: 11px 0 7px; overflow-wrap: anywhere; }
|
||||
.boardDirection { font-size: 15px; color: var(--muted); line-height: 1.6; }
|
||||
.directionFilters { margin: 20px 0 8px; }
|
||||
.directionLabel { display: block; color: var(--blue-muted); font-size: 14px; font-weight: 650; margin-bottom: 10px; }
|
||||
.directionChips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.directionChips button { min-height: 44px; padding: 10px 16px; border: 0; border-radius: 24px; color: var(--blue); background: var(--blue-soft); font-size: 15px; font-weight: 650; line-height: 1.5; text-align: left; overflow-wrap: anywhere; }
|
||||
.directionChips button.selected { background: var(--blue); color: white; }
|
||||
.directionChips button:hover { background: #aec6ff; }
|
||||
.directionChips button.selected:hover { background: var(--blue-muted); }
|
||||
.navIcon { position: relative; display: inline-flex; width: 28px; height: 28px; }
|
||||
.mobileBadge { position: absolute; top: -8px; right: -13px; display: grid; place-items: center; min-width: 22px; height: 22px; padding: 0 5px; border: 2px solid white; border-radius: 20px; color: white; background: var(--blue); font-size: 12px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.boardMeta { margin: 19px 0 21px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); }
|
||||
.scheduleBadge { display: flex; align-items: center; gap: 5px; padding: 6px 10px; border-radius: 30px; background: var(--green); color: var(--green-ink); font-size: 13px; font-weight: 750; }
|
||||
.savedBadge { background: #fff1d8; color: #876022; }
|
||||
.routeFilters { display: flex; flex-wrap: wrap; gap: 7px; padding-bottom: 17px; border-bottom: 1px solid var(--line); }
|
||||
.routeFilters button { border: 0; border-radius: 20px; min-height: 40px; min-width: 40px; background: var(--lavender); color: var(--blue); padding: 9px 13px; font-size: 15px; font-weight: 750; }
|
||||
.routeFilters button.selected { background: var(--blue); color: white; }
|
||||
.scheduleControls { margin-top: 22px; }
|
||||
.scheduleViews { display: inline-flex; gap: 4px; padding: 4px; background: var(--lavender); border-radius: 16px; }
|
||||
.scheduleViews button { border: 0; background: transparent; padding: 10px 16px; min-height: 44px; border-radius: 12px; font-size: 15px; font-weight: 750; color: var(--blue-muted); }
|
||||
.scheduleViews button.selected { background: white; color: var(--blue); box-shadow: 0 2px 6px #172d6010; }
|
||||
.scheduleDate { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-top: 16px; }
|
||||
.scheduleDate label { width: 100%; font-size: 14px; font-weight: 700; color: var(--muted); }
|
||||
.scheduleDate input { border: 1px solid #c3d3f5; border-radius: 12px; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 44px; color: var(--blue); background: var(--lavender); font-size: 16px; }
|
||||
.timetableSummary { margin: -5px 0 18px; color: var(--muted); font-size: 14px; line-height: 1.7; }
|
||||
.departureList { list-style: none; margin: 0; padding: 0; }
|
||||
.departureRow { display: flex; gap: 15px; align-items: center; min-height: 94px; padding: 17px 0; }
|
||||
.departureRow + .departureRow { border-top: 1px solid var(--line); }
|
||||
.routeNumber { display: grid; place-items: center; background: var(--blue-soft); color: var(--blue); min-width: 50px; height: 50px; padding: 6px; border-radius: 14px; font-size: 18px; letter-spacing: -.5px; font-weight: 800; }
|
||||
.departureName { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.transportLabel { font-size: 14px; color: var(--muted); }
|
||||
.departureName strong { font-size: 16px; font-weight: 700; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.tripNote { font-size: 14px; color: var(--muted); line-height: 1.6; }
|
||||
.pastDeparture .departureTime { color: var(--muted); }
|
||||
.departureTime { display: flex; align-items: flex-end; flex-direction: column; gap: 4px; margin-left: auto; flex-shrink: 0; padding-left: 5px; }
|
||||
.departureTime strong { font-size: 26px; letter-spacing: -.5px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.departureTime span { font-size: 14px; color: var(--muted); white-space: nowrap; }
|
||||
.departureTime .soon { color: var(--green-ink); background: var(--green); padding: 3px 7px; border-radius: 20px; }
|
||||
.boardFooter { display: flex; align-items: center; justify-content: space-between; gap: 10px; border-top: 1px solid var(--line); margin-top: 16px; padding-top: 17px; }
|
||||
.boardFooter > span { font-size: 13px; color: var(--muted); }
|
||||
.refreshButton { display: flex; align-items: center; gap: 5px; background: none; border: 0; font-size: 14px; font-weight: 750; color: var(--blue); padding: 8px 0 8px 8px; }
|
||||
.boardEmpty { min-height: 340px; padding: 50px 12px; display: flex; align-items: center; flex-direction: column; justify-content: center; text-align: center; }
|
||||
.emptyIcon { display: grid; place-items: center; width: 76px; height: 76px; background: var(--lavender); color: var(--blue); border-radius: 24px; transform: rotate(-6deg); margin-bottom: 22px; }
|
||||
.boardEmpty h3 { font-size: 18px; letter-spacing: -.5px; }
|
||||
.boardEmpty p { font-size: 15px; color: var(--muted); line-height: 1.7; max-width: 340px; margin: 10px auto 20px; }
|
||||
.listMessage { display: flex; align-items: center; flex-direction: column; gap: 12px; text-align: center; padding: 28px 8px; font-size: 15px; line-height: 1.6; }
|
||||
.listMessage h3 { font-size: 16px; }
|
||||
.listMessage p { color: var(--blue-muted); }
|
||||
.primaryButton, .secondaryButton { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 30px; padding: 13px 20px; font-size: 15px; font-weight: 750; background: var(--blue); color: white; }
|
||||
.secondaryButton { background: var(--blue-soft); color: var(--blue); }
|
||||
.textButton { border: 0; background: transparent; color: var(--blue); display: flex; align-items: center; gap: 5px; padding: 8px; font-size: 14px; font-weight: 750; }
|
||||
.showMore { background: var(--lavender); border: 0; border-radius: 13px; padding: 11px; width: 100%; font-weight: 750; font-size: 14px; color: var(--blue); }
|
||||
.savedNotice, .partialNotice { margin-top: 12px; padding: 12px 14px; border-radius: 13px; background: #fff7e9; color: #866529; font-size: 14px; line-height: 1.6; }
|
||||
.benefitStrip { display: flex; align-items: center; gap: 18px; padding: 22px 26px; background: #eeede6; border-radius: 22px; margin: 24px 0 42px; }
|
||||
.benefitIcon { width: 43px; height: 43px; border-radius: 15px; display: grid; place-items: center; background: #faf9f6; color: var(--blue); }
|
||||
.benefitStrip strong { font-size: 15px; font-weight: 800; }
|
||||
.benefitStrip p { margin-top: 4px; font-size: 14px; color: var(--muted); line-height: 1.6; }
|
||||
.benefitStrip > a { display: flex; align-items: center; gap: 9px; margin-left: auto; font-size: 14px; white-space: nowrap; font-weight: 750; color: var(--blue); }
|
||||
.footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 25px 0 34px; border-top: 1px solid var(--line); }
|
||||
.footer > span { font-weight: 800; font-size: 20px; letter-spacing: -.5px; }
|
||||
.footer > span small { display: block; font-weight: 500; font-size: 13px; letter-spacing: 0; color: var(--muted); margin-top: 3px; }
|
||||
.footer > div { display: flex; align-items: center; gap: 22px; font-size: 14px; color: var(--muted); }
|
||||
.footer a { display: flex; gap: 4px; align-items: center; }
|
||||
.footer button { border: 0; background: transparent; font-size: inherit; color: inherit; padding: 7px 0; }
|
||||
.footer > small { font-size: 13px; color: var(--muted); }
|
||||
.mobileNav { display: none; }
|
||||
.demoBanner, .offlineBanner { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: #fff1d8; color: #75561f; font-size: 14px; line-height: 1.5; border-radius: 15px; padding: 13px 18px; margin-top: 20px; }
|
||||
.demoBanner button { display: flex; align-items: center; gap: 5px; background: transparent; border: 0; font-size: 14px; font-weight: 750; color: inherit; }
|
||||
.offlineBanner { justify-content: flex-start; background: var(--lavender); color: var(--blue); }
|
||||
.favoritesHero { padding: 55px 0 40px; }
|
||||
.favoritesEmpty { border-radius: 32px; padding: 70px 20px; text-align: center; background: white; border: 1px solid var(--line); margin-bottom: 50px; }
|
||||
.favoritesEmpty .emptyIcon { margin: 0 auto 25px; }
|
||||
.favoritesEmpty h2 { font-size: 23px; }
|
||||
.favoritesEmpty p { font-size: 15px; line-height: 1.8; color: var(--muted); margin: 13px 0 24px; }
|
||||
.favoritesGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-bottom: 50px; }
|
||||
.favoriteCard { background: var(--lavender); border-radius: 28px; padding: 22px; }
|
||||
.favoriteCity { display: flex; align-items: center; gap: 5px; font-size: 14px; color: var(--blue-muted); }
|
||||
.favoriteCity button { margin-left: auto; }
|
||||
.favoriteCard h2 { font-size: 23px; letter-spacing: -.5px; line-height: 1.3; margin: 15px 0 8px; color: var(--blue); }
|
||||
.favoriteCard > p { font-size: 15px; line-height: 1.6; color: var(--blue-muted); }
|
||||
.favoriteDepartures { margin-top: 20px; padding-top: 12px; border-top: 1px solid #dce0ee; min-height: 156px; }
|
||||
.favoriteDepartures ul { list-style: none; margin: 0; padding: 0; }
|
||||
.favoriteDepartures li { display: flex; align-items: center; gap: 12px; padding: 8px 0; font-size: 14px; }
|
||||
.favoriteDepartures li > strong { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--blue); }
|
||||
.favoriteDepartures li > span:last-child { margin-left: auto; color: var(--blue-muted); text-align: right; }
|
||||
.favoriteDepartures p { color: var(--blue-muted); font-size: 15px; padding: 18px 0; line-height: 1.7; }
|
||||
.favoriteRoute { display: grid; place-items: center; min-width: 42px; min-height: 36px; padding: 4px 8px; font-weight: 800; border-radius: 10px; color: var(--blue); background: white; }
|
||||
.favoriteStatus { display: flex; align-items: flex-start; gap: 7px; font-size: 13px; color: var(--blue-muted); margin-top: 12px; line-height: 1.7; }
|
||||
.favoriteStatus > .vkuiIcon { margin-top: 3px; }
|
||||
.favoriteLink { display: flex; align-items: center; justify-content: space-between; font-size: 14px; font-weight: 750; color: var(--blue); padding-top: 20px; border-top: 1px solid #dce0ee; margin-top: 22px; }
|
||||
.aboutPage { padding: 55px 0 50px; }
|
||||
.aboutIntro h1 { font-size: clamp(36px, 4.6vw, 58px); line-height: 1.2; letter-spacing: -.5px; margin: 16px 0; }
|
||||
.aboutIntro h1 span { color: var(--blue); }
|
||||
.aboutIntro > p { color: var(--muted); line-height: 1.8; }
|
||||
.aboutGrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 24px; margin-top: 36px; align-items: start; }
|
||||
.aboutGrid article { padding: 32px; border-radius: var(--card-radius); }
|
||||
.aboutStory { background: white; border: 1px solid var(--line); }
|
||||
.aboutGrid h2 { font-size: 25px; line-height: 1.35; letter-spacing: -.3px; margin: 20px 0 16px; }
|
||||
.aboutGrid p { font-size: 16px; line-height: 1.9; margin: 16px 0; }
|
||||
.aboutStory p { color: var(--muted); }
|
||||
.aboutStory .primaryButton { margin-top: 14px; }
|
||||
.aboutSupport { background: var(--green); color: var(--green-ink); }
|
||||
.aboutSupport > img { transform: rotate(12deg); opacity: .3; }
|
||||
.supportNote { font-size: 14px; line-height: 1.8; padding-top: 20px; margin-top: 24px; border-top: 1px solid #bdd4b6; }
|
||||
.toast, .updateToast { position: fixed; z-index: 50; left: 50%; bottom: 30px; transform: translateX(-50%); max-width: calc(100vw - 32px); background: var(--blue); color: white; border-radius: 18px; padding: 16px 20px; display: flex; align-items: center; gap: 15px; box-shadow: 0 10px 30px #2c467830; font-size: 15px; }
|
||||
.toast button, .updateToast button { border: 0; background: transparent; color: inherit; display: flex; padding: 4px; }
|
||||
.updateToast { bottom: 90px; }
|
||||
.updateToast button { font-weight: 750; font-size: 15px; }
|
||||
.modalBackdrop { position: fixed; inset: 0; z-index: 100; background: #14274b70; backdrop-filter: blur(5px); display: grid; place-items: center; padding: 20px; }
|
||||
.installDialog { position: relative; padding: 34px; border-radius: 30px; max-width: 430px; background: white; }
|
||||
.closeDialog { position: absolute; top: 14px; right: 14px; }
|
||||
.installDialog h2 { font-size: 25px; letter-spacing: -.5px; margin: 20px 0 15px; }
|
||||
.installDialog p { font-size: 16px; line-height: 1.8; margin: 10px 0; }
|
||||
.installDialog .muted { color: var(--muted); font-size: 14px; }
|
||||
.installDialog .primaryButton { margin-top: 14px; }
|
||||
.skipLink { position: fixed; left: 20px; top: 20px; z-index: 200; padding: 15px; border-radius: 10px; background: white; clip-path: inset(100%); }
|
||||
.skipLink:focus { clip-path: none; }
|
||||
.skeletonRows { padding-top: 15px; }
|
||||
.skeletonRow { display: flex; gap: 15px; align-items: center; height: 84px; }
|
||||
.skeletonRow i { width: 46px; height: 46px; border-radius: 14px; background: var(--lavender); }
|
||||
.skeletonRow span { height: 18px; flex: 1; background: var(--lavender); border-radius: 10px; }
|
||||
@media (min-width: 1400px) { .hero h1 { font-size: 62px; } }
|
||||
@media (max-width: 1050px) { .app { padding: 0 30px; } .workspace { grid-template-columns: 300px minmax(0, 1fr); gap: 18px; } .boardCard { padding: 24px; } .heroNote { width: 250px; } .heroNote strong { font-size: 23px; } .header { gap: 12px; } .desktopNav a { padding: 10px 12px; font-size: 15px; } .webTag { display: none; } }
|
||||
@media (max-width: 760px) {
|
||||
.app { padding: 0 20px; padding-bottom: 105px; }
|
||||
.header { height: 83px; }
|
||||
.brand img { width: 38px; height: 38px; }
|
||||
.brand > span:first-of-type { font-size: 25px; }
|
||||
.desktopNav { display: none; }
|
||||
.installButton { padding: 10px 14px; font-size: 14px; }
|
||||
.hero { padding: 36px 0 29px; }
|
||||
.hero .eyebrow { font-size: 13px; letter-spacing: 1px; }
|
||||
.hero h1 { font-size: clamp(32px, 6.7vw, 48px); letter-spacing: -.3px; line-height: 1.2; margin: 0 0 16px; }
|
||||
.heroCopy > p { font-size: 15px; line-height: 1.8; }
|
||||
.heroNote { display: none; }
|
||||
.desktopBreak { display: none; }
|
||||
.workspace { grid-template-columns: 1fr; gap: 18px; }
|
||||
.stopsCard { padding: 22px 18px 0; border-radius: 26px; }
|
||||
.sectionHeading h2 { font-size: 24px; }
|
||||
.sectionDescription { margin-bottom: 18px; }
|
||||
.stopsList { max-height: 244px; }
|
||||
.stopCopy strong { font-size: 16px; }
|
||||
.stopsFooter { font-size: 14px; }
|
||||
.boardCard { border-radius: 26px; padding: 22px 20px; min-height: 400px; }
|
||||
.boardCard h2 { font-size: 27px; }
|
||||
.boardHeading .eyebrow { font-size: 14px; letter-spacing: 0; }
|
||||
.boardDirection { font-size: 15px; }
|
||||
.boardMeta { font-size: 14px; gap: 8px; }
|
||||
.routeFilters button { font-size: 14px; min-height: 40px; }
|
||||
.departureRow { gap: 12px; min-height: 88px; }
|
||||
.departureName strong { font-size: 15px; }
|
||||
.transportLabel, .departureTime span { font-size: 14px; }
|
||||
.departureTime strong { font-size: 26px; }
|
||||
.routeNumber { min-width: 48px; height: 48px; }
|
||||
.boardFooter { align-items: flex-start; }
|
||||
.boardFooter > span { font-size: 13px; line-height: 1.6; max-width: 210px; }
|
||||
.boardEmpty { min-height: 265px; }
|
||||
.benefitStrip { margin: 18px 0 30px; padding: 20px; gap: 13px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.benefitStrip > div { flex: 1; flex-basis: calc(100% - 56px); }
|
||||
.benefitStrip > a { margin-left: 56px; font-size: 14px; }
|
||||
.benefitStrip strong { font-size: 15px; }
|
||||
.benefitStrip p { font-size: 14px; }
|
||||
.footer { flex-wrap: wrap; padding: 24px 0 20px; }
|
||||
.footer > small { display: none; }
|
||||
.footer > div { gap: 18px; flex-wrap: wrap; }
|
||||
.mobileNav { position: fixed; z-index: 30; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-evenly; gap: 16px; border-top: 1px solid #e0e3ed; background: #fffffff0; backdrop-filter: blur(14px); padding: 11px 20px max(11px, env(safe-area-inset-bottom)); }
|
||||
.mobileNav a { display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 14px; font-weight: 700; color: var(--muted); padding: 0 12px; }
|
||||
.mobileNav a.active { color: var(--blue); }
|
||||
.toast { bottom: 110px; font-size: 14px; width: max-content; padding: 14px 16px; }
|
||||
.updateToast { bottom: 145px; flex-wrap: wrap; font-size: 14px; width: max-content; }
|
||||
.favoritesHero { padding: 38px 0 30px; }
|
||||
.favoritesHero h1 { font-size: 37px; letter-spacing: -.5px; }
|
||||
.favoritesGrid { grid-template-columns: 1fr; gap: 14px; }
|
||||
.favoritesEmpty { padding: 45px 20px; }
|
||||
.favoritesEmpty h2 { font-size: 21px; }
|
||||
.aboutPage { padding: 38px 0 32px; }
|
||||
.aboutGrid { grid-template-columns: 1fr; gap: 18px; margin-top: 28px; }
|
||||
.aboutGrid article { padding: 24px; border-radius: 26px; }
|
||||
.aboutGrid h2 { font-size: 23px; }
|
||||
.demoBanner { flex-wrap: wrap; font-size: 14px; }
|
||||
.offlineBanner { font-size: 14px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.departureRow { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 7px 12px; padding: 18px 0; }
|
||||
.departureRow .routeNumber { grid-column: 1; grid-row: 1 / 3; align-self: start; margin-top: 4px; }
|
||||
.departureName { grid-column: 2; }
|
||||
.departureTime { grid-column: 2; flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 4px 8px; margin-left: 0; padding-left: 0; }
|
||||
}
|
||||
@media (max-width: 360px) { .app { padding: 0 14px 105px; } .hero h1 { font-size: 31px; } .boardCard { padding: 20px 16px; } .departureRow { gap: 9px; } .departureName strong { font-size: 14px; } .departureTime strong { font-size: 24px; } .brand { gap: 5px; } .footer > div { gap: 12px; } }
|
||||
@include features.components;
|
||||
@include motion.components;
|
||||
12
src/types.ts
Normal file
12
src/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type City = { id: number; name: string; slug: string };
|
||||
export type Stop = { id: number; name: string; direction: string; routesCount: number };
|
||||
export type Route = { id: number; number: string; name: string };
|
||||
export type Departure = { routeId: number; routeNumber: string; direction: string; scheduledTime: string; scheduledAt: string; note?: string; tripKey?: string };
|
||||
export type Board = { departures: Departure[]; routes: { id: number; number: string; name: string }[]; updatedAt: string; partial: boolean; date?: string; isHoliday?: boolean; holidayName?: string | null };
|
||||
export type Timetable = Board & { date: string; isHoliday: boolean; holidayName: string | null };
|
||||
export type SavedStop = Stop & { key: string; cityId: number; cityName: string; mode: 'live' | 'demo'; addedAt: number };
|
||||
export type Snapshot<T> = { data: T; savedAt: string; saved: boolean };
|
||||
export type QuickTrip = { id: string; name: string; mode: 'live' | 'demo'; cityId: number; cityName: string; stop: Stop; route: Route; createdAt: number };
|
||||
export type NearbyStop = Stop & { distance: number };
|
||||
export type ServiceAlert = { id: number; cityId: number; title: string; message: string; type: 'info' | 'delay' | 'detour' | 'cancellation'; routeNumbers: string[]; routeIds: number[]; isCityWide: boolean; startTime: string; endTime: string | null };
|
||||
export type Journey = { route: Route; selectedStopId: number; stops: { id: number; sequence: number; name: string; scheduledTime: string; scheduledAt: string }[]; note: string; tripKey?: string; updatedAt: string };
|
||||
Reference in New Issue
Block a user