Files
ridebus-website/modules/home/departuresCard/departuresCard.tsx
2026-10-02 20:31:49 +03:00

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