302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import clsx from 'clsx';
|
|
import { Card } from '@/components/card';
|
|
import { CursorEffect } from '@/components/cursor';
|
|
import Star from '@/assets/images/thick-round-star-5.svg';
|
|
import ContributeArrow from '@/assets/images/contribute-arrow.svg';
|
|
import ContributeArrowInvert from '@/assets/images/contribute-arrow-invert.svg';
|
|
import useLocale from '@/utils/useLocale';
|
|
import type { City, Stop, Departures } from '@/lib/ridebus';
|
|
import { locales } from './locales';
|
|
import { useResource } from './useResource';
|
|
import { SelectMenu } from './selectMenu';
|
|
import { formatRemainingMinutes, groupStops } from './stopGroups';
|
|
import styles from './departuresCard.module.scss';
|
|
|
|
export function DeparturesCard() {
|
|
const t = useLocale(locales);
|
|
const { locale } = useRouter();
|
|
const [revision, setRevision] = useState(0);
|
|
const [chosenCity, setChosenCity] = useState('');
|
|
const [chosenStop, setChosenStop] = useState({ cityId: '', stopId: '' });
|
|
const [now, setNow] = useState(Date.now);
|
|
const cities = useResource<City[]>(
|
|
'/api/departures?resource=cities',
|
|
revision
|
|
);
|
|
const cityId = String(
|
|
cities.data?.find((city) => String(city.id) === chosenCity)?.id ??
|
|
cities.data?.find((city) => city.slug === 'polotsk')?.id ??
|
|
cities.data?.[0]?.id ??
|
|
''
|
|
);
|
|
const stops = useResource<Stop[]>(
|
|
cityId ? `/api/departures?resource=stops&cityId=${cityId}` : null,
|
|
revision
|
|
);
|
|
const stopGroups = useMemo(() => groupStops(stops.data ?? []), [stops.data]);
|
|
const selectedGroup =
|
|
stopGroups.find((group) =>
|
|
group.stops.some(
|
|
(stop) =>
|
|
chosenStop.cityId === cityId && String(stop.id) === chosenStop.stopId
|
|
)
|
|
) ??
|
|
stopGroups.find((group) =>
|
|
group.stops.some((stop) => stop.routesCount > 0)
|
|
) ??
|
|
stopGroups[0];
|
|
const selectedStop =
|
|
selectedGroup?.stops.find(
|
|
(stop) =>
|
|
chosenStop.cityId === cityId && String(stop.id) === chosenStop.stopId
|
|
) ??
|
|
selectedGroup?.stops.find((stop) => stop.routesCount > 0) ??
|
|
selectedGroup?.stops[0];
|
|
const stopId = String(selectedStop?.id ?? '');
|
|
const timetable = useResource<Departures>(
|
|
cityId && stopId
|
|
? `/api/departures?resource=departures&cityId=${cityId}&stopId=${stopId}`
|
|
: null,
|
|
revision,
|
|
true
|
|
);
|
|
|
|
useEffect(() => {
|
|
const interval = window.setInterval(() => setNow(Date.now()), 15000);
|
|
return () => window.clearInterval(interval);
|
|
}, []);
|
|
|
|
const elapsed = timetable.data
|
|
? Math.max(
|
|
0,
|
|
Math.floor((now - Date.parse(timetable.data.updatedAt)) / 60000)
|
|
)
|
|
: 0;
|
|
const departures =
|
|
timetable.data?.departures.filter(
|
|
(departure) => departure.minutesUntil >= elapsed
|
|
) ?? [];
|
|
const failed = [cities, stops, timetable].some(
|
|
(resource) => resource.status === 'error'
|
|
);
|
|
const loading = [cities, stops, timetable].some(
|
|
(resource) => resource.status === 'loading'
|
|
);
|
|
const emptyMessage = !cities.data?.length
|
|
? t('noCities')
|
|
: !stops.data?.length
|
|
? t('noStops')
|
|
: t('empty');
|
|
const updateTime = timetable.data
|
|
? new Intl.DateTimeFormat(locale === 'ru' ? 'ru-BY' : 'en-GB', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
timeZone: 'Europe/Minsk',
|
|
}).format(new Date(timetable.data.updatedAt))
|
|
: '';
|
|
const tryHere = String(t('tryHere'));
|
|
const lastWordStart = tryHere.lastIndexOf(' ') + 1;
|
|
|
|
return (
|
|
<section aria-label={t('title')}>
|
|
<Card
|
|
className={styles.card}
|
|
title={t('title')}
|
|
subtitle={t('description')}
|
|
classes={{ content: styles.content }}
|
|
>
|
|
<Star className={styles.star} aria-hidden="true" />
|
|
<div className={styles.layout}>
|
|
<div className={styles.controls}>
|
|
<div className={styles.tryHere}>
|
|
{tryHere.slice(0, lastWordStart)}
|
|
<span className={styles.tryHereEnd}>
|
|
{tryHere.slice(lastWordStart)}
|
|
<ContributeArrow
|
|
className={styles.tryArrow}
|
|
aria-hidden="true"
|
|
/>
|
|
<ContributeArrowInvert
|
|
className={styles.tryArrowCompact}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
</div>
|
|
<SelectMenu
|
|
label={t('city')}
|
|
value={cityId}
|
|
options={
|
|
cities.data?.map((city) => ({
|
|
value: String(city.id),
|
|
label: city.name,
|
|
})) ?? []
|
|
}
|
|
placeholder={
|
|
cities.status === 'loading'
|
|
? t('loadingCities')
|
|
: t('selectCity')
|
|
}
|
|
searchLabel={t('searchCity')}
|
|
noResults={t('noResults')}
|
|
onChange={setChosenCity}
|
|
/>
|
|
<SelectMenu
|
|
label={t('stop')}
|
|
value={selectedGroup?.key ?? ''}
|
|
options={stopGroups.map((group) => ({
|
|
value: group.key,
|
|
label: group.name,
|
|
}))}
|
|
placeholder={
|
|
stops.status === 'loading' ? t('loadingStops') : t('selectStop')
|
|
}
|
|
searchLabel={t('searchStop')}
|
|
noResults={t('noResults')}
|
|
onChange={(key) => {
|
|
const group = stopGroups.find((item) => item.key === key);
|
|
const stop =
|
|
group?.stops.find((item) => item.routesCount > 0) ??
|
|
group?.stops[0];
|
|
if (stop) setChosenStop({ cityId, stopId: String(stop.id) });
|
|
}}
|
|
/>
|
|
<p className={styles.note}>{t('note')}</p>
|
|
</div>
|
|
<div className={styles.results}>
|
|
{selectedGroup && (
|
|
<div
|
|
className={styles.directions}
|
|
role="group"
|
|
aria-label={t('stopDirection')}
|
|
>
|
|
<span className={styles.directionLabel}>
|
|
{t('stopDirection')}
|
|
</span>
|
|
<div className={styles.chips}>
|
|
{selectedGroup.stops.map((stop, index) => {
|
|
const direction =
|
|
stop.direction || `${t('direction')} ${index + 1}`;
|
|
const repeated =
|
|
stop.direction &&
|
|
selectedGroup.stops.filter(
|
|
(item) => item.direction === stop.direction
|
|
).length > 1;
|
|
return (
|
|
<CursorEffect
|
|
key={stop.id}
|
|
effectForce={3}
|
|
effectDistance={48}
|
|
cursorBorderRadius={40}
|
|
cursorPadding={4}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-pressed={String(stop.id) === stopId}
|
|
className={clsx(
|
|
styles.chip,
|
|
String(stop.id) === stopId && styles.selectedChip
|
|
)}
|
|
onClick={() =>
|
|
setChosenStop({ cityId, stopId: String(stop.id) })
|
|
}
|
|
>
|
|
{String(stop.id) === stopId && (
|
|
<span
|
|
className={styles.selectedDot}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
{direction}
|
|
{repeated ? ` · ${index + 1}` : ''}
|
|
</button>
|
|
</CursorEffect>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className={styles.board} aria-busy={loading}>
|
|
<div className={styles.boardHeader}>
|
|
<span className={styles.schedule}>{t('schedule')}</span>
|
|
{updateTime && (
|
|
<span>
|
|
{t('updated')} {updateTime}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
key={`${cityId}:${stopId}:${failed ? 'error' : loading ? 'loading' : 'ready'}`}
|
|
className={styles.boardContent}
|
|
>
|
|
{failed ? (
|
|
<div className={styles.message}>
|
|
<p>{t('unavailable')}</p>
|
|
<CursorEffect cursorBorderRadius={16} cursorPadding={4}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setRevision((value) => value + 1)}
|
|
>
|
|
{t('retry')}
|
|
</button>
|
|
</CursorEffect>
|
|
</div>
|
|
) : loading ? (
|
|
<div className={styles.message}>
|
|
<span className={styles.loadingDots} aria-hidden="true">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
<p>{t('loading')}</p>
|
|
</div>
|
|
) : departures.length ? (
|
|
<ul className={styles.departures}>
|
|
{departures.map((departure, index) => (
|
|
<li
|
|
key={`${departure.routeId}:${departure.scheduledTime}`}
|
|
className={styles.departure}
|
|
style={{ animationDelay: `${index * 45}ms` }}
|
|
>
|
|
<span className={styles.routeNumber}>
|
|
{departure.routeNumber}
|
|
</span>
|
|
<span className={styles.direction}>
|
|
{departure.direction}
|
|
</span>
|
|
<span className={styles.time}>
|
|
<strong>{departure.scheduledTime}</strong>
|
|
<span>
|
|
{formatRemainingMinutes(
|
|
departure.minutesUntil - elapsed,
|
|
{
|
|
now: t('now'),
|
|
hours: t('hours'),
|
|
minutes: t('minutes'),
|
|
}
|
|
)}
|
|
</span>
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div className={styles.message}>
|
|
<p>{emptyMessage}</p>
|
|
</div>
|
|
)}
|
|
{timetable.data?.partial && (
|
|
<p className={styles.partial}>{t('partial')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
);
|
|
}
|