initial commit

This commit is contained in:
2026-10-07 01:48:32 +03:00
commit 664421662c
70 changed files with 11333 additions and 0 deletions

189
src/App.tsx Normal file
View 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
View 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
View 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
View 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
View 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>;
}

View 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
View 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>;
}

View 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>;
}

View 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
View 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" />;
}

View 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>;
}

View 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>}
</>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>}
</>;
}

View 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>;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 };