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

125 lines
4.0 KiB
TypeScript

import { useRef, useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { Card } from '@/components/card';
import { CursorEffect } from '@/components/cursor';
import Mask from '@/assets/images/cities-star-mask.svg';
import LinkIcon from '@/assets/icons/link.svg';
import type { City } from '@/lib/ridebus';
import useLocale from '@/utils/useLocale';
import useFrame from '@/utils/useFrame';
import useMediaQuery from '@/utils/useMediaQuery';
import { useResource } from '../departuresCard/useResource';
import { locales } from './locales';
import styles from './citiesCard.module.scss';
function CityBadge({ city, hidden = false }: { city: City; hidden?: boolean }) {
return (
<li className={styles.cityBadge} aria-hidden={hidden || undefined}>
<span>{city.name}</span>
</li>
);
}
export function CitiesCard() {
const t = useLocale(locales);
const { locale } = useRouter();
const [revision, setRevision] = useState(0);
const cities = useResource<City[]>(
'/api/departures?resource=cities',
revision
);
const backdrop = useRef<HTMLDivElement>(null);
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const cityList = cities.data ?? [];
const count = cityList.length;
// Each half fills the card even with a short city list, so the loop has no gaps.
const ribbonCities = Array.from(
{ length: Math.max(1, Math.ceil(8 / Math.max(1, count))) },
() => cityList
).flat();
const plural = new Intl.PluralRules(locale === 'ru' ? 'ru' : 'en').select(
count
);
useFrame((delta, time) => {
if (backdrop.current && !reducedMotion)
backdrop.current.style.transform = `rotate(${(time * 0.005) % 360}deg)`;
});
return (
<Card
className={styles.card}
title={t('title')}
subtitle={t('description')}
classes={{ content: styles.content }}
>
<div ref={backdrop} className={styles.backdrop} aria-hidden="true">
<Mask />
</div>
<div className={styles.catalog}>
{cities.status === 'ready' && count > 0 ? (
<>
<p className={styles.count}>
<strong>{count}</strong>
<span>
{t(`cities_${plural}`)}
<br />
{t('inRideBus')}
</span>
</p>
<div className={styles.marquee}>
<div className={styles.marqueeTrack}>
<ul className={styles.cities} aria-label={t('availableCities')}>
{ribbonCities.map((city, index) => (
<CityBadge
key={`${city.id}:${index}`}
city={city}
hidden={index >= count}
/>
))}
</ul>
<ul className={styles.cities} aria-hidden="true">
{ribbonCities.map((city, index) => (
<CityBadge key={`${city.id}:${index}`} city={city} />
))}
</ul>
</div>
</div>
</>
) : (
<div className={styles.status} role="status">
<p>
{t(
cities.status === 'error'
? 'unavailable'
: cities.status === 'ready'
? 'empty'
: 'loading'
)}
</p>
{cities.status === 'error' && (
<CursorEffect cursorBorderRadius={16} cursorPadding={4}>
<button type="button" onClick={() => setRevision((r) => r + 1)}>
{t('retry')}
</button>
</CursorEffect>
)}
</div>
)}
<CursorEffect
className={styles.contribute}
cursorBorderRadius={16}
cursorPadding={4}
effectForce={3}
>
<Link href="/contribute">
<span>{t('helpCity')}</span>
<LinkIcon aria-hidden="true" />
</Link>
</CursorEffect>
</div>
</Card>
);
}