improvements
This commit is contained in:
114
modules/contribute/addCityCard/addCityCard.module.scss
Normal file
114
modules/contribute/addCityCard/addCityCard.module.scss
Normal file
@@ -0,0 +1,114 @@
|
||||
.card {
|
||||
--on-background: #2c4678;
|
||||
--on-background-secondary: #455e91;
|
||||
--background: #d8e2ff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.star {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
position: absolute;
|
||||
right: -140px;
|
||||
bottom: -130px;
|
||||
color: #aec6ff;
|
||||
transform: rotate(-15deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.steps {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 32px;
|
||||
position: relative;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f3fa;
|
||||
border-radius: 16px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.contact {
|
||||
position: relative;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.handwritten {
|
||||
display: inline-block;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
--background: #fff;
|
||||
flex: 1 1 260px;
|
||||
max-width: 380px;
|
||||
|
||||
a:focus-visible {
|
||||
display: block;
|
||||
border-radius: 16px;
|
||||
outline: 3px solid #2c4678;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.steps {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.links {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
97
modules/contribute/addCityCard/addCityCard.tsx
Normal file
97
modules/contribute/addCityCard/addCityCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import clsx from 'clsx';
|
||||
import { Card, CardLink } from '@/components/card';
|
||||
import Star from '@/assets/images/thick-round-star-6.svg';
|
||||
import useLocale, { LocalesMap } from '@/utils/useLocale';
|
||||
import { caveat } from '@/styles/fonts';
|
||||
import styles from './addCityCard.module.scss';
|
||||
|
||||
const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Помогите добавить свой город',
|
||||
description:
|
||||
'Знаете, где найти расписание транспорта в вашем городе? Поделитесь источником — это поможет расширить RideBus.',
|
||||
city: 'Название города',
|
||||
cityDescription: 'Расскажите, для какого города нужно расписание.',
|
||||
source: 'Источник расписания',
|
||||
sourceDescription:
|
||||
'Пришлите ссылку на сайт перевозчика или актуальное расписание.',
|
||||
details: 'Полезные подробности',
|
||||
detailsDescription:
|
||||
'Если знаете, добавьте номера маршрутов и названия остановок.',
|
||||
handwritten: 'Начнём с вашего города',
|
||||
contact: 'Напишите в Telegram',
|
||||
contactDescription: 'Пришлите название города и источник расписания',
|
||||
email: 'Или напишите на почту',
|
||||
emailSubject: 'Новый город для RideBus',
|
||||
emailBody:
|
||||
'Город: \nИсточник расписания: \nМаршруты и остановки (если известны): \n',
|
||||
},
|
||||
en: {
|
||||
title: 'Help add your city',
|
||||
description:
|
||||
'Know where to find your city’s transport timetable? Share a source to help bring RideBus to more cities.',
|
||||
city: 'Your city',
|
||||
cityDescription: 'Tell us which city needs a timetable.',
|
||||
source: 'A timetable source',
|
||||
sourceDescription:
|
||||
'Send a link to the transport operator’s website or a current timetable.',
|
||||
details: 'Helpful details',
|
||||
detailsDescription:
|
||||
'Include route numbers and stop names if you know them.',
|
||||
handwritten: 'Let’s start with your city',
|
||||
contact: 'Get in touch on Telegram',
|
||||
contactDescription: 'Send your city’s name and a timetable source',
|
||||
email: 'Or send an email',
|
||||
emailSubject: 'A new city for RideBus',
|
||||
emailBody: 'City: \nTimetable source: \nRoutes and stops (if known): \n',
|
||||
},
|
||||
};
|
||||
|
||||
export function AddCityCard() {
|
||||
const t = useLocale(locales);
|
||||
const email = `mailto:xtimms@live.com?subject=${encodeURIComponent(t('emailSubject'))}&body=${encodeURIComponent(t('emailBody'))}`;
|
||||
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" />
|
||||
<ol className={styles.steps}>
|
||||
{['city', 'source', 'details'].map((step, index) => (
|
||||
<li key={step}>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t(step)}</h4>
|
||||
<p>{t(`${step}Description`)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className={styles.contact}>
|
||||
{/* <span className={clsx(styles.handwritten, caveat.className)}>
|
||||
{t('handwritten')}
|
||||
</span> */}
|
||||
<div className={styles.links}>
|
||||
<CardLink
|
||||
title={t('contact')}
|
||||
hrefTitle={t('contactDescription')}
|
||||
href="https://t.me/xtimms"
|
||||
className={styles.link}
|
||||
/>
|
||||
<CardLink
|
||||
title={t('email')}
|
||||
hrefTitle="xtimms@live.com"
|
||||
href={email}
|
||||
className={styles.link}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
position: relative;
|
||||
display: inline;
|
||||
|
||||
div {
|
||||
.arrowImage {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
left: 26px;
|
||||
@@ -179,7 +179,7 @@
|
||||
line-height: 170%;
|
||||
|
||||
.contributeArrow {
|
||||
div {
|
||||
.arrowImage {
|
||||
top: 8px;
|
||||
}
|
||||
svg {
|
||||
@@ -203,7 +203,7 @@
|
||||
|
||||
.description {
|
||||
.contributeArrow {
|
||||
div {
|
||||
.arrowImage {
|
||||
top: 10px;
|
||||
}
|
||||
svg {
|
||||
@@ -213,4 +213,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const locales: LocalesMap = {
|
||||
};
|
||||
|
||||
function Arrow() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLSpanElement>(null);
|
||||
const [isInvertArrow, setIsInvertArrow] = useState(false);
|
||||
const breakpoint640 = useMediaQuery('(max-width: 640px)');
|
||||
const breakpoint800 = useMediaQuery('(max-width: 800px)');
|
||||
@@ -79,9 +79,9 @@ function Arrow() {
|
||||
return (
|
||||
<span ref={rootRef} className={styles.contributeArrow}>
|
||||
<span style={{ width: '140px', display: 'inline-block' }} />
|
||||
<div style={{ left }}>
|
||||
<span className={styles.arrowImage} style={{ left }}>
|
||||
{isInvertArrow ? <ContributeArrowInvert /> : <ContributeArrow />}
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -123,4 +123,4 @@ export function Header() {
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,13 +48,9 @@ export function ShareCard() {
|
||||
>
|
||||
</Card>
|
||||
);
|
||||
if (typeof window !== 'undefined' && (navigator.share as any)) {
|
||||
return (
|
||||
<CursorEffect cursorPadding={8} cursorBorderRadius={36}>
|
||||
{ShareCard}
|
||||
</CursorEffect>
|
||||
);
|
||||
} else {
|
||||
return ShareCard;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<CursorEffect cursorPadding={8} cursorBorderRadius={36}>
|
||||
{ShareCard}
|
||||
</CursorEffect>
|
||||
);
|
||||
}
|
||||
|
||||
235
modules/home/changesCard/changesCard.module.scss
Normal file
235
modules/home/changesCard/changesCard.module.scss
Normal file
@@ -0,0 +1,235 @@
|
||||
.card {
|
||||
--on-background: #2c4678;
|
||||
--on-background-secondary: #455e91;
|
||||
--background: #f3f3fa;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
margin-top: 32px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.star {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: -130px;
|
||||
left: -170px;
|
||||
color: #d8e2ff;
|
||||
transform: rotate(-12deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.layout {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.note {
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.updated {
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.notices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.notice,
|
||||
.state {
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
color: #2c4678;
|
||||
animation: noticeIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.notice {
|
||||
h4 {
|
||||
margin-top: 16px;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.45;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.type,
|
||||
.route {
|
||||
padding: 8px 12px;
|
||||
border-radius: 40px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.info,
|
||||
.route {
|
||||
color: #2c4678;
|
||||
background: #d8e2ff;
|
||||
}
|
||||
.delay,
|
||||
.cancellation {
|
||||
color: #861118;
|
||||
background: #ffeef2;
|
||||
}
|
||||
.detour {
|
||||
color: #254817;
|
||||
background: #ddf9d0;
|
||||
}
|
||||
.message {
|
||||
margin-top: 10px;
|
||||
color: #455e91;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.65;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin-top: 20px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
color: #455e91;
|
||||
}
|
||||
.state {
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
p {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.65;
|
||||
color: #455e91;
|
||||
}
|
||||
}
|
||||
.emptyIcon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ddf9d0;
|
||||
color: #254817;
|
||||
transform: rotate(-6deg);
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
.action {
|
||||
padding: 14px 18px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #d8e2ff;
|
||||
color: #2c4678;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.action:focus-visible {
|
||||
outline: 3px solid #2c4678;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.loadingDots {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #aec6ff;
|
||||
animation: dotBounce 1.1s ease-in-out infinite;
|
||||
}
|
||||
i:nth-child(2) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
i:nth-child(3) {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
}
|
||||
@keyframes noticeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes dotBounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
.notice,
|
||||
.state {
|
||||
padding: 20px;
|
||||
}
|
||||
.notice h4,
|
||||
.state h4 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.message,
|
||||
.state p,
|
||||
.note {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notice,
|
||||
.state,
|
||||
.loadingDots i {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
189
modules/home/changesCard/changesCard.tsx
Normal file
189
modules/home/changesCard/changesCard.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { 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-6.svg';
|
||||
import NoteIcon from '@/assets/icons/note.svg';
|
||||
import type { Alerts, City } from '@/lib/ridebus';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
import { SelectMenu } from '../departuresCard/selectMenu';
|
||||
import { useResource } from '../departuresCard/useResource';
|
||||
import { locales } from './locales';
|
||||
import styles from './changesCard.module.scss';
|
||||
|
||||
export function ChangesCard() {
|
||||
const t = useLocale(locales);
|
||||
const { locale } = useRouter();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [chosenCity, setChosenCity] = useState('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
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 notices = useResource<Alerts>(
|
||||
cityId ? `/api/departures?resource=alerts&cityId=${cityId}` : null,
|
||||
revision,
|
||||
true
|
||||
);
|
||||
const failed = cities.status === 'error' || notices.status === 'error';
|
||||
const loading = cities.status === 'loading' || notices.status === 'loading';
|
||||
const alerts = notices.data?.alerts ?? [];
|
||||
const dateFormat = new Intl.DateTimeFormat(
|
||||
locale === 'ru' ? 'ru-BY' : 'en-GB',
|
||||
{
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/Minsk',
|
||||
}
|
||||
);
|
||||
const updateTime = notices.data
|
||||
? new Intl.DateTimeFormat(locale === 'ru' ? 'ru-BY' : 'en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Minsk',
|
||||
}).format(new Date(notices.data.updatedAt))
|
||||
: '';
|
||||
|
||||
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}>
|
||||
<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={(value) => {
|
||||
setChosenCity(value);
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.note}>{t('note')}</p>
|
||||
{updateTime && !failed && !loading && (
|
||||
<p className={styles.updated}>
|
||||
{t('updated')} {updateTime}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={styles.notices}
|
||||
aria-live="polite"
|
||||
aria-busy={loading}
|
||||
>
|
||||
{failed || loading || !cities.data?.length ? (
|
||||
<div className={styles.state}>
|
||||
{loading && (
|
||||
<div className={styles.loadingDots} aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
{t(failed ? 'unavailable' : loading ? 'loading' : 'noCities')}
|
||||
</p>
|
||||
{failed && (
|
||||
<CursorEffect cursorBorderRadius={16} cursorPadding={4}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={() => setRevision((r) => r + 1)}
|
||||
>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</CursorEffect>
|
||||
)}
|
||||
</div>
|
||||
) : alerts.length ? (
|
||||
<>
|
||||
{(expanded ? alerts : alerts.slice(0, 3)).map((alert) => (
|
||||
<article
|
||||
className={styles.notice}
|
||||
key={`${cityId}:${alert.id}`}
|
||||
>
|
||||
<div className={styles.badges}>
|
||||
<span className={clsx(styles.type, styles[alert.type])}>
|
||||
{t(alert.type)}
|
||||
</span>
|
||||
{alert.routeNumber && (
|
||||
<span className={styles.route}>
|
||||
{t('route')} №{alert.routeNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4>{alert.title}</h4>
|
||||
<p className={styles.message}>{alert.message}</p>
|
||||
<div className={styles.dates}>
|
||||
<span>
|
||||
{t('published')}{' '}
|
||||
<time dateTime={alert.createdAt}>
|
||||
{dateFormat.format(new Date(alert.createdAt))}
|
||||
</time>
|
||||
</span>
|
||||
{alert.endTime && (
|
||||
<span>
|
||||
{t('until')}{' '}
|
||||
<time dateTime={alert.endTime}>
|
||||
{dateFormat.format(new Date(alert.endTime))}
|
||||
</time>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{alerts.length > 3 && (
|
||||
<CursorEffect cursorBorderRadius={16} cursorPadding={4}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{t(expanded ? 'showLess' : 'showAll')}
|
||||
{!expanded && ` (${alerts.length})`}
|
||||
</button>
|
||||
</CursorEffect>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.state} key={cityId}>
|
||||
<span className={styles.emptyIcon} aria-hidden="true">
|
||||
<NoteIcon />
|
||||
</span>
|
||||
<h4>{t('emptyTitle')}</h4>
|
||||
<p>{t('emptyDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
60
modules/home/changesCard/locales.ts
Normal file
60
modules/home/changesCard/locales.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { LocalesMap } from '@/utils/useLocale';
|
||||
|
||||
export const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Что изменилось в движении',
|
||||
description:
|
||||
'Изменения маршрутов, задержки и другие сообщения — перед поездкой загляните сюда.',
|
||||
city: 'Город',
|
||||
selectCity: 'Выберите город',
|
||||
searchCity: 'Найти город',
|
||||
noResults: 'Ничего не найдено. Попробуйте другое название.',
|
||||
loadingCities: 'Загружаем города…',
|
||||
loading: 'Загружаем сообщения…',
|
||||
unavailable: 'Не удалось загрузить сообщения. Попробуйте ещё раз.',
|
||||
noCities: 'Список городов пока недоступен.',
|
||||
emptyTitle: 'Активных сообщений нет',
|
||||
emptyDescription:
|
||||
'Для этого города пока нет сообщений об изменениях. Перед поездкой можно проверить ближайшие отправления на этой странице.',
|
||||
note: 'Здесь собраны действующие сообщения для выбранного города.',
|
||||
retry: 'Попробовать ещё раз',
|
||||
delay: 'Задержка',
|
||||
cancellation: 'Отмена',
|
||||
detour: 'Объезд',
|
||||
info: 'Информация',
|
||||
route: 'Маршрут',
|
||||
published: 'Опубликовано',
|
||||
until: 'Действует до',
|
||||
updated: 'Обновлено',
|
||||
showAll: 'Показать все сообщения',
|
||||
showLess: 'Свернуть список',
|
||||
},
|
||||
en: {
|
||||
title: 'What has changed on the road',
|
||||
description:
|
||||
'Route changes, delays and other notices. Check here before your journey.',
|
||||
city: 'City',
|
||||
selectCity: 'Choose a city',
|
||||
searchCity: 'Find a city',
|
||||
noResults: 'No matches. Try another name.',
|
||||
loadingCities: 'Loading cities…',
|
||||
loading: 'Loading notices…',
|
||||
unavailable: 'Notices could not be loaded. Please try again.',
|
||||
noCities: 'The city list is not available yet.',
|
||||
emptyTitle: 'No active notices',
|
||||
emptyDescription:
|
||||
'There are no notices about changes for this city at the moment. You can check the next departures on this page before your journey.',
|
||||
note: 'Current notices for your selected city appear here.',
|
||||
retry: 'Try again',
|
||||
delay: 'Delay',
|
||||
cancellation: 'Cancellation',
|
||||
detour: 'Detour',
|
||||
info: 'Information',
|
||||
route: 'Route',
|
||||
published: 'Published',
|
||||
until: 'Valid until',
|
||||
updated: 'Updated',
|
||||
showAll: 'Show all notices',
|
||||
showLess: 'Show fewer notices',
|
||||
},
|
||||
};
|
||||
@@ -1,148 +1,213 @@
|
||||
.card {
|
||||
--on-background: #122F60;
|
||||
--on-background-secondary: #2C4678;
|
||||
--background: #D8E2FF;
|
||||
--on-background: #122f60;
|
||||
--on-background-secondary: #2c4678;
|
||||
--background: #d8e2ff;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 580px;
|
||||
position: relative;
|
||||
}
|
||||
.content {
|
||||
margin-top: 32px;
|
||||
justify-content: center;
|
||||
}
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
bottom: -140px;
|
||||
right: -140px;
|
||||
pointer-events: none;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 580px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mockupPhoneContainer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 0;
|
||||
.catalog {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
align-items: flex-start;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.backdropImage {
|
||||
z-index: 1;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
width: auto;
|
||||
height: 650px;
|
||||
.count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
color: var(--on-background);
|
||||
transform: rotate(-4deg);
|
||||
strong {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
span {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.ridebusImageMask {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: 50%;
|
||||
bottom: -140px;
|
||||
right: -140px;
|
||||
.marquee {
|
||||
width: calc(100% + 72px);
|
||||
margin-left: -36px;
|
||||
padding: 20px 0;
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
|
||||
.ridebusImageAnchor {
|
||||
bottom: 320px;
|
||||
left: 320px;
|
||||
position: absolute;
|
||||
.marqueeTrack {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: citiesScroll 32s linear infinite;
|
||||
}
|
||||
|
||||
.ridebusImage {
|
||||
z-index: 1;
|
||||
bottom: -400px;
|
||||
left: -400px;
|
||||
position: absolute;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
.cities {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
padding: 0 12px 0 0;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
z-index: 1;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
width: auto;
|
||||
height: 650px;
|
||||
.cityBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 120px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 40px;
|
||||
background: #fff;
|
||||
color: #122f60;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
&:nth-child(even) {
|
||||
background: #122f60;
|
||||
color: #fff;
|
||||
}
|
||||
&:hover {
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
.smallCitiesLine {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
@keyframes citiesScroll {
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
.contribute {
|
||||
max-width: 100%;
|
||||
margin-top: 4px;
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
right: -50px;
|
||||
bottom: 260px;
|
||||
transform-origin: right center;
|
||||
transform: rotate(4deg);
|
||||
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: #d8e2ff;
|
||||
color: #122f60;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
svg {
|
||||
width: 220px;
|
||||
height: auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bigCitiesLine {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
right: -280px;
|
||||
bottom: 300px;
|
||||
transform-origin: right center;
|
||||
transform: rotate(356deg);
|
||||
gap: 9px;
|
||||
|
||||
svg {
|
||||
width: 260px;
|
||||
height: auto;
|
||||
}
|
||||
.status {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
color: #2c4678;
|
||||
button {
|
||||
padding: 14px 18px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: #122f60;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
.contribute a:focus-visible,
|
||||
.status button:focus-visible {
|
||||
outline: 3px solid #122f60;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.card {
|
||||
height: 490px;
|
||||
}
|
||||
.ridebusImageMask {
|
||||
width: 440px;
|
||||
height: 440px;
|
||||
bottom: -70px;
|
||||
right: -110px;
|
||||
}
|
||||
.ridebusImageAnchor {
|
||||
bottom: 220px;
|
||||
left: 220px;
|
||||
}
|
||||
.smallCitiesLine {
|
||||
bottom: 200px;
|
||||
}
|
||||
.bigCitiesLine {
|
||||
bottom: 150px;
|
||||
}
|
||||
.card {
|
||||
min-height: 490px;
|
||||
}
|
||||
.backdrop {
|
||||
width: 440px;
|
||||
height: 440px;
|
||||
bottom: -70px;
|
||||
right: -110px;
|
||||
}
|
||||
.catalog {
|
||||
gap: 24px;
|
||||
}
|
||||
.cityBadge {
|
||||
font-size: 1.05rem;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.card {
|
||||
height: 390px;
|
||||
}
|
||||
.smallCitiesLine {
|
||||
bottom: 160px;
|
||||
|
||||
svg {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
.bigCitiesLine svg {
|
||||
width: 200px;
|
||||
}
|
||||
.card {
|
||||
min-height: 430px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.card {
|
||||
height: 390px;
|
||||
}
|
||||
.smallCitiesLine {
|
||||
bottom: 160px;
|
||||
|
||||
svg {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
.bigCitiesLine {
|
||||
bottom: 75px;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
.content {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.count strong {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.cityBadge {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.marquee {
|
||||
width: calc(100% + 48px);
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop {
|
||||
transform: none !important;
|
||||
}
|
||||
.cityBadge {
|
||||
transition: none;
|
||||
}
|
||||
.marquee {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
transform: none;
|
||||
}
|
||||
.marqueeTrack {
|
||||
width: 100%;
|
||||
animation: none;
|
||||
}
|
||||
.cities {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
}
|
||||
.cities[aria-hidden='true'],
|
||||
.cityBadge[aria-hidden='true'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +1,50 @@
|
||||
import { useRef } from 'react';
|
||||
import styles from './citiesCard.module.scss';
|
||||
import { Card } from '@/components/card';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
import { LocalesMap } from '@/utils/useLocale';
|
||||
import CityPolotskSmallEn from '@/assets/images/city-polotsk-small-en.svg';
|
||||
import CityNovopolotskSmallEn from '@/assets/images/city-novopolotsk-small-en.svg';
|
||||
import CityMiorySmallEn from '@/assets/images/city-miory-small-en.svg';
|
||||
import CityBigEn from '@/assets/images/city-big-en.svg';
|
||||
import CityPolotskSmallRu from '@/assets/images/city-polotsk-small-ru.svg';
|
||||
import CityNovopolotskSmallRu from '@/assets/images/city-novopolotsk-small-ru.svg';
|
||||
import CityMiorySmallRu from '@/assets/images/city-miory-small-ru.svg';
|
||||
import CityBigRu from '@/assets/images/city-big-ru.svg';
|
||||
import Mask from '@/assets/images/cities-star-mask.svg';
|
||||
import ridebusImage from '@/assets/images/cities-backdrop.jpg';
|
||||
import Image from 'next/image';
|
||||
import useFrame from '@/utils/useFrame';
|
||||
import useScroll from '@/utils/useScroll';
|
||||
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';
|
||||
|
||||
const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Поддержка нескольких городов',
|
||||
description:
|
||||
'Поддерживаются города Полоцк и Новополоцк, но мы планируем добавлять расписание общественного транспорта и для других городов Беларуси',
|
||||
},
|
||||
en: {
|
||||
title: 'Multi-city support',
|
||||
description:
|
||||
'The cities of Polotsk and Novopolotsk are supported, but we plan to add public transportation schedules for other cities of Belarus as well',
|
||||
},
|
||||
};
|
||||
|
||||
function SmallCitiesLine() {
|
||||
const router = useRouter();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const breakpoint800 = useMediaQuery('(max-width: 800px)');
|
||||
|
||||
const cityWidth = breakpoint800 ? 170 : 220;
|
||||
const swapWidth = cityWidth * 2;
|
||||
|
||||
useScroll((scrollY, delta, time) => {
|
||||
|
||||
const offset = -(scrollY / 6) - (time / 30);
|
||||
|
||||
if (rootRef.current) {
|
||||
rootRef.current.style.transform = `rotate(3deg) translate3D(${offset % swapWidth + cityWidth * 2}px, 0px, 0px)`;
|
||||
}
|
||||
}, {}, [cityWidth]);
|
||||
|
||||
const CityPolotsk = router.locale === 'ru' ? CityPolotskSmallRu : CityPolotskSmallEn;
|
||||
const CityNovopolotsk = router.locale === 'ru' ? CityNovopolotskSmallRu : CityNovopolotskSmallEn;
|
||||
const CityMiory = router.locale === 'ru' ? CityMiorySmallRu : CityMiorySmallEn;
|
||||
|
||||
function CityBadge({ city, hidden = false }: { city: City; hidden?: boolean }) {
|
||||
return (
|
||||
<div ref={rootRef} className={styles.smallCitiesLine}>
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigCitiesLine() {
|
||||
const router = useRouter();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const breakpoint800 = useMediaQuery('(max-width: 800px)');
|
||||
|
||||
const cityWidth = (breakpoint800 ? 200 : 260) + 9;
|
||||
const swapWidth = cityWidth * 1;
|
||||
|
||||
useScroll((scrollY, delta, time) => {
|
||||
|
||||
const offset = (scrollY / 6) + (time / 30);
|
||||
|
||||
if (rootRef.current) {
|
||||
rootRef.current.style.transform = `rotate(356deg) translate3D(${offset % swapWidth * 3}px, 0px, 0px)`;
|
||||
}
|
||||
}, {}, [cityWidth]);
|
||||
|
||||
const CityPolotsk = router.locale === 'ru' ? CityPolotskSmallRu : CityPolotskSmallEn;
|
||||
const CityNovopolotsk = router.locale === 'ru' ? CityNovopolotskSmallRu : CityNovopolotskSmallEn;
|
||||
const CityMiory = router.locale === 'ru' ? CityMiorySmallRu : CityMiorySmallEn;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={styles.bigCitiesLine}>
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
<CityPolotsk />
|
||||
<CityNovopolotsk />
|
||||
<CityMiory />
|
||||
</div>
|
||||
<li className={styles.cityBadge} aria-hidden={hidden || undefined}>
|
||||
<span>{city.name}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CitiesCard() {
|
||||
const t = useLocale(locales);
|
||||
const ridebusImageMask = useRef<HTMLDivElement>(null);
|
||||
const ridebusImageRef = useRef<HTMLDivElement>(null);
|
||||
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 (ridebusImageMask.current) {
|
||||
ridebusImageMask.current.style.transform = `rotate(${(time * 0.005) % 360}deg) translate3D(0px, 0px, 0px)`;
|
||||
}
|
||||
if (backdrop.current && !reducedMotion)
|
||||
backdrop.current.style.transform = `rotate(${(time * 0.005) % 360}deg)`;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -132,16 +52,73 @@ export function CitiesCard() {
|
||||
className={styles.card}
|
||||
title={t('title')}
|
||||
subtitle={t('description')}
|
||||
backdropOnText
|
||||
classes={{ subtitle: styles.description, title: styles.title }}
|
||||
classes={{ content: styles.content }}
|
||||
>
|
||||
<div className={styles.mockupPhoneContainer}>
|
||||
<div className={styles.ridebusImageMask}
|
||||
ref={ridebusImageMask}>
|
||||
<Mask />
|
||||
</div>
|
||||
<BigCitiesLine />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './citiesCard';
|
||||
export * from './citiesCard';
|
||||
|
||||
34
modules/home/citiesCard/locales.ts
Normal file
34
modules/home/citiesCard/locales.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { LocalesMap } from '@/utils/useLocale';
|
||||
|
||||
export const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Поддержка нескольких городов',
|
||||
description:
|
||||
'Один RideBus — разные города. Выберите свой, чтобы смотреть его маршруты, остановки и расписание.',
|
||||
cities_one: 'город',
|
||||
cities_few: 'города',
|
||||
cities_many: 'городов',
|
||||
cities_other: 'города',
|
||||
inRideBus: 'уже в RideBus',
|
||||
availableCities: 'Доступные города',
|
||||
helpCity: 'Помочь добавить свой город',
|
||||
loading: 'Загружаем города…',
|
||||
unavailable: 'Не удалось загрузить города. Попробуйте ещё раз.',
|
||||
empty: 'Список городов пока пуст.',
|
||||
retry: 'Попробовать ещё раз',
|
||||
},
|
||||
en: {
|
||||
title: 'Multi-city support',
|
||||
description:
|
||||
'One RideBus, different cities. Choose yours to see its routes, stops and timetables.',
|
||||
cities_one: 'city',
|
||||
cities_other: 'cities',
|
||||
inRideBus: 'in RideBus',
|
||||
availableCities: 'Available cities',
|
||||
helpCity: 'Help add your city',
|
||||
loading: 'Loading cities…',
|
||||
unavailable: 'The city list could not be loaded. Please try again.',
|
||||
empty: 'The city list is empty for now.',
|
||||
retry: 'Try again',
|
||||
},
|
||||
};
|
||||
358
modules/home/departuresCard/departuresCard.module.scss
Normal file
358
modules/home/departuresCard/departuresCard.module.scss
Normal file
@@ -0,0 +1,358 @@
|
||||
.card {
|
||||
--on-background: #2c4678;
|
||||
--on-background-secondary: #455e91;
|
||||
--background: #f3f3fa;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
margin-top: 32px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.star {
|
||||
position: absolute;
|
||||
right: -95px;
|
||||
top: -160px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
color: #d8e2ff;
|
||||
transform: rotate(12deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
> * {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tryHere {
|
||||
--hint-arrow-width: 100px;
|
||||
--hint-arrow-space: 112px;
|
||||
grid-column: 1 / -1;
|
||||
padding-bottom: 52px;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tryHereEnd {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-right: var(--hint-arrow-space);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tryArrow,
|
||||
.tryArrowCompact {
|
||||
position: absolute;
|
||||
width: var(--hint-arrow-width);
|
||||
height: auto;
|
||||
top: 0.35em;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tryArrowCompact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.directions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.directionLabel {
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.chips > div {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 12px 18px;
|
||||
border: 0;
|
||||
border-radius: 40px;
|
||||
background: #d8e2ff;
|
||||
color: #2c4678;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
color 0.25s ease;
|
||||
&:hover {
|
||||
background: #aec6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedChip {
|
||||
background: #2c4678;
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #455e91;
|
||||
}
|
||||
}
|
||||
.selectedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ddf9d0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip:focus-visible,
|
||||
.message button:focus-visible {
|
||||
outline: 3px solid #2c4678;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.note,
|
||||
.partial {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--on-background-secondary);
|
||||
}
|
||||
.note {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.board {
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
align-self: start;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.boardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
background: #ddf9d0;
|
||||
color: #254817;
|
||||
padding: 8px 12px;
|
||||
border-radius: 40px;
|
||||
}
|
||||
|
||||
.departures {
|
||||
list-style: none;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.departure {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px 0;
|
||||
animation: departureIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
}
|
||||
|
||||
.routeNumber {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
background: #d8e2ff;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.direction {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.time {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--on-background-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.boardContent {
|
||||
animation: departureIn 0.3s ease both;
|
||||
}
|
||||
.loadingDots {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #aec6ff;
|
||||
animation: dotBounce 1.1s ease-in-out infinite;
|
||||
}
|
||||
i:nth-child(2) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
i:nth-child(3) {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes departureIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes dotBounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--on-background-secondary);
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 14px 18px;
|
||||
background: #d8e2ff;
|
||||
color: var(--on-background);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 730px) {
|
||||
.controls {
|
||||
gap: 16px;
|
||||
}
|
||||
.tryHere {
|
||||
--hint-arrow-width: 95px;
|
||||
--hint-arrow-space: 43px;
|
||||
min-height: 72px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
.tryArrow {
|
||||
display: none;
|
||||
}
|
||||
.tryArrowCompact {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.controls {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.board {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.departure {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.direction {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.time strong {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.departure,
|
||||
.boardContent,
|
||||
.loadingDots i {
|
||||
animation: none;
|
||||
}
|
||||
.chip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
301
modules/home/departuresCard/departuresCard.tsx
Normal file
301
modules/home/departuresCard/departuresCard.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
68
modules/home/departuresCard/locales.ts
Normal file
68
modules/home/departuresCard/locales.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { LocalesMap } from '@/utils/useLocale';
|
||||
|
||||
export const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Ближайшие отправления',
|
||||
description:
|
||||
'Выберите город и остановку — посмотрите, когда отправляется ваш транспорт.',
|
||||
city: 'Город',
|
||||
stop: 'Остановка',
|
||||
stopDirection: 'Направление остановки',
|
||||
direction: 'Направление',
|
||||
searchCity: 'Найти город',
|
||||
searchStop: 'Найти остановку',
|
||||
noResults: 'Ничего не найдено. Попробуйте другое название.',
|
||||
selectCity: 'Выберите город',
|
||||
selectStop: 'Выберите остановку',
|
||||
loading: 'Загружаем расписание…',
|
||||
loadingCities: 'Загружаем города…',
|
||||
loadingStops: 'Загружаем остановки…',
|
||||
unavailable:
|
||||
'Не удалось загрузить расписание. Попробуйте ещё раз чуть позже.',
|
||||
noCities: 'Список городов пока недоступен.',
|
||||
noStops: 'Для этого города пока нет остановок. Попробуйте выбрать другой.',
|
||||
empty:
|
||||
'Ближайших отправлений нет. Выберите другую остановку или проверьте позже.',
|
||||
retry: 'Попробовать ещё раз',
|
||||
schedule: 'По расписанию',
|
||||
note: 'Время рассчитано по расписанию и может отличаться от фактического прибытия.',
|
||||
partial: 'Часть расписания временно недоступна.',
|
||||
now: 'Сейчас',
|
||||
minutes: 'мин',
|
||||
hours: 'ч',
|
||||
updated: 'Обновлено',
|
||||
tryHere: 'Попробуйте прямо здесь',
|
||||
},
|
||||
en: {
|
||||
title: 'Next departures',
|
||||
description:
|
||||
'Choose your city and stop to see when your transport departs.',
|
||||
city: 'City',
|
||||
stop: 'Stop',
|
||||
stopDirection: 'Stop direction',
|
||||
direction: 'Direction',
|
||||
searchCity: 'Find a city',
|
||||
searchStop: 'Find a stop',
|
||||
noResults: 'No matches. Try another name.',
|
||||
selectCity: 'Choose a city',
|
||||
selectStop: 'Choose a stop',
|
||||
loading: 'Loading the timetable…',
|
||||
loadingCities: 'Loading cities…',
|
||||
loadingStops: 'Loading stops…',
|
||||
unavailable:
|
||||
'The timetable could not be loaded. Please try again in a little while.',
|
||||
noCities: 'The city list is not available yet.',
|
||||
noStops: 'This city has no stops yet. Try choosing another city.',
|
||||
empty:
|
||||
'There are no upcoming departures. Choose another stop or check back later.',
|
||||
retry: 'Try again',
|
||||
schedule: 'Scheduled',
|
||||
note: 'Times are based on the timetable. Actual arrivals may differ.',
|
||||
partial: 'Some timetable information is temporarily unavailable.',
|
||||
now: 'Now',
|
||||
minutes: 'min',
|
||||
hours: 'h',
|
||||
updated: 'Updated',
|
||||
tryHere: 'Try it right here',
|
||||
},
|
||||
};
|
||||
153
modules/home/departuresCard/selectMenu.module.scss
Normal file
153
modules/home/departuresCard/selectMenu.module.scss
Normal file
@@ -0,0 +1,153 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #455e91;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 16px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: #2c4678;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: background 0.25s ease;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
&:disabled {
|
||||
color: #455e91;
|
||||
opacity: 0.65;
|
||||
}
|
||||
&:hover:not(:disabled),
|
||||
&.open {
|
||||
background: #d8e2ff;
|
||||
}
|
||||
&.open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger:focus-visible,
|
||||
.search:focus-visible {
|
||||
outline: 3px solid #2c4678;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
border: 1px solid #efefef;
|
||||
border-radius: 16px;
|
||||
background: #fafafa;
|
||||
color: #2c4678;
|
||||
box-shadow: 0 4px 16px #00000014;
|
||||
animation: menuIn 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex-shrink: 0;
|
||||
width: calc(100% - 8px);
|
||||
margin: 4px 4px 10px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f3f3fa;
|
||||
color: #2c4678;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
&::placeholder {
|
||||
color: #455e91;
|
||||
}
|
||||
}
|
||||
|
||||
.menuLabel,
|
||||
.noResults {
|
||||
padding: 8px 12px;
|
||||
color: #455e91;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #aec6ff transparent;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
transition: background 0.2s ease;
|
||||
&:hover,
|
||||
&.active {
|
||||
background: #efefef;
|
||||
}
|
||||
&.selected {
|
||||
background: #d8e2ff;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel {
|
||||
animation: none;
|
||||
}
|
||||
.trigger,
|
||||
.trigger svg,
|
||||
.option {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
268
modules/home/departuresCard/selectMenu.tsx
Normal file
268
modules/home/departuresCard/selectMenu.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import clsx from 'clsx';
|
||||
import { CursorEffect } from '@/components/cursor';
|
||||
import { manrope } from '@/styles/fonts';
|
||||
import styles from './selectMenu.module.scss';
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
type SelectMenuProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
options: Option[];
|
||||
placeholder: string;
|
||||
searchLabel: string;
|
||||
noResults: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function SelectMenu({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
searchLabel,
|
||||
noResults,
|
||||
onChange,
|
||||
}: SelectMenuProps) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [position, setPosition] = useState<{
|
||||
left: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [active, setActive] = useState(value);
|
||||
const searchable = options.length > 8;
|
||||
const filtered = useMemo(() => {
|
||||
const search = query.trim().toLocaleLowerCase();
|
||||
return options.filter((option) =>
|
||||
option.label.toLocaleLowerCase().includes(search)
|
||||
);
|
||||
}, [query, options]);
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
filtered.findIndex((option) => option.value === active)
|
||||
);
|
||||
const activeValue = filtered[activeIndex]?.value;
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
function close(restoreFocus = false) {
|
||||
setPosition(null);
|
||||
if (restoreFocus) triggerRef.current?.focus();
|
||||
}
|
||||
|
||||
function open() {
|
||||
const rect = triggerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const below = window.innerHeight - rect.bottom - 20;
|
||||
const above = rect.top - 20;
|
||||
const upwards = below < 280 && above > below;
|
||||
const maxHeight = Math.min(340, upwards ? above : below);
|
||||
setQuery('');
|
||||
setActive(value);
|
||||
setPosition({
|
||||
left: rect.left,
|
||||
...(upwards
|
||||
? { bottom: window.innerHeight - rect.top + 8 }
|
||||
: { top: rect.bottom + 8 }),
|
||||
width: rect.width,
|
||||
maxHeight,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!position) return;
|
||||
if (searchable) searchRef.current?.focus();
|
||||
else
|
||||
panelRef.current?.querySelector<HTMLElement>('[role="listbox"]')?.focus();
|
||||
const dismiss = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
!panelRef.current?.contains(target) &&
|
||||
!triggerRef.current?.contains(target)
|
||||
)
|
||||
setPosition(null);
|
||||
};
|
||||
const dismissOnScroll = (event: Event) => {
|
||||
if (!panelRef.current?.contains(event.target as Node)) setPosition(null);
|
||||
};
|
||||
const dismissOnResize = () => setPosition(null);
|
||||
document.addEventListener('pointerdown', dismiss);
|
||||
window.addEventListener('scroll', dismissOnScroll, true);
|
||||
window.addEventListener('resize', dismissOnResize);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', dismiss);
|
||||
window.removeEventListener('scroll', dismissOnScroll, true);
|
||||
window.removeEventListener('resize', dismissOnResize);
|
||||
};
|
||||
}, [position, searchable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (position && activeValue) {
|
||||
document
|
||||
.getElementById(`${id}-option-${encodeURIComponent(activeValue)}`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [id, position, activeValue]);
|
||||
|
||||
function choose(nextValue: string) {
|
||||
onChange(nextValue);
|
||||
close(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span id={`${id}-label`} className={styles.label}>
|
||||
{label}
|
||||
</span>
|
||||
<CursorEffect
|
||||
effectForce={3}
|
||||
effectDistance={48}
|
||||
cursorBorderRadius={16}
|
||||
cursorPadding={4}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={clsx(styles.trigger, position && styles.open)}
|
||||
aria-labelledby={`${id}-label ${id}-value`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={Boolean(position)}
|
||||
aria-controls={position ? `${id}-list` : undefined}
|
||||
disabled={!options.length}
|
||||
onClick={() => (position ? close() : open())}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span id={`${id}-value`}>{selected?.label ?? placeholder}</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
d="m5 7.5 5 5 5-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</CursorEffect>
|
||||
{position &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={clsx(styles.panel, manrope.className)}
|
||||
style={position}
|
||||
tabIndex={-1}
|
||||
onBlur={(event) => {
|
||||
if (
|
||||
event.relatedTarget &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
)
|
||||
close();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close(true);
|
||||
} else if (event.key === 'Tab') {
|
||||
close(true);
|
||||
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const next =
|
||||
(activeIndex +
|
||||
(event.key === 'ArrowDown' ? 1 : -1) +
|
||||
filtered.length) %
|
||||
filtered.length;
|
||||
if (filtered[next]) setActive(filtered[next].value);
|
||||
} else if (event.key === 'Enter' && activeValue) {
|
||||
event.preventDefault();
|
||||
choose(activeValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{searchable ? (
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.search}
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-label={searchLabel}
|
||||
aria-expanded="true"
|
||||
aria-controls={`${id}-list`}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
activeValue
|
||||
? `${id}-option-${encodeURIComponent(activeValue)}`
|
||||
: undefined
|
||||
}
|
||||
placeholder={searchLabel}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setActive('');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.menuLabel}>{placeholder}</p>
|
||||
)}
|
||||
<ul
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
className={styles.options}
|
||||
tabIndex={searchable ? -1 : 0}
|
||||
aria-activedescendant={
|
||||
!searchable && activeValue
|
||||
? `${id}-option-${encodeURIComponent(activeValue)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{filtered.map((option) => (
|
||||
<li key={option.value} role="presentation">
|
||||
<button
|
||||
id={`${id}-option-${encodeURIComponent(option.value)}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
tabIndex={-1}
|
||||
className={clsx(
|
||||
styles.option,
|
||||
option.value === value && styles.selected,
|
||||
option.value === activeValue && styles.active
|
||||
)}
|
||||
data-cursor-effect
|
||||
data-cursor-padding="2"
|
||||
data-cursor-border-radius="8"
|
||||
onPointerMove={() => setActive(option.value)}
|
||||
onClick={() => choose(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.value === value && (
|
||||
<span aria-hidden="true">✓</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!filtered.length && (
|
||||
<p className={styles.noResults} role="status">
|
||||
{noResults}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
modules/home/departuresCard/stopGroups.ts
Normal file
34
modules/home/departuresCard/stopGroups.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Stop } from '../../../lib/ridebus';
|
||||
|
||||
export type StopGroup = { key: string; name: string; stops: Stop[] };
|
||||
|
||||
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 Array.from(groups.values());
|
||||
}
|
||||
|
||||
export function formatRemainingMinutes(
|
||||
value: number,
|
||||
labels: { now: string; hours: string; minutes: string }
|
||||
): string {
|
||||
const minutes = Math.max(0, Math.ceil(value));
|
||||
if (!minutes) return labels.now;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainder = minutes % 60;
|
||||
return [
|
||||
hours ? `${hours} ${labels.hours}` : '',
|
||||
remainder ? `${remainder} ${labels.minutes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
56
modules/home/departuresCard/useResource.ts
Normal file
56
modules/home/departuresCard/useResource.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Snapshot<T> =
|
||||
| { key: string; status: 'ready'; data: T }
|
||||
| { key: string; status: 'error'; data: null };
|
||||
|
||||
export function useResource<T>(
|
||||
url: string | null,
|
||||
revision: number,
|
||||
refresh = false
|
||||
) {
|
||||
const [snapshot, setSnapshot] = useState<Snapshot<T> | null>(null);
|
||||
const key = `${url}:${revision}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
const controller = new AbortController();
|
||||
let fetching = false;
|
||||
async function load() {
|
||||
if (fetching || controller.signal.aborted) return;
|
||||
fetching = true;
|
||||
try {
|
||||
const response = await fetch(url!, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new Error('Resource unavailable');
|
||||
const data = (await response.json()) as T;
|
||||
if (!controller.signal.aborted)
|
||||
setSnapshot({ key, status: 'ready', data });
|
||||
} catch {
|
||||
if (!controller.signal.aborted)
|
||||
setSnapshot({ key, status: 'error', data: null });
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
}
|
||||
void load();
|
||||
const refreshVisible = () => {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
};
|
||||
const interval = refresh
|
||||
? window.setInterval(refreshVisible, 30000)
|
||||
: undefined;
|
||||
if (refresh) document.addEventListener('visibilitychange', refreshVisible);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', refreshVisible);
|
||||
};
|
||||
}, [url, key, refresh]);
|
||||
|
||||
if (!url) return { status: 'idle' as const, data: null };
|
||||
if (snapshot?.key !== key) return { status: 'loading' as const, data: null };
|
||||
return snapshot;
|
||||
}
|
||||
189
modules/home/downloadCard/downloadCard.module.scss
Normal file
189
modules/home/downloadCard/downloadCard.module.scss
Normal file
@@ -0,0 +1,189 @@
|
||||
.card {
|
||||
--background: #d8e2ff;
|
||||
--on-background: #122f60;
|
||||
--on-background-secondary: #2c4678;
|
||||
position: relative;
|
||||
min-height: 390px;
|
||||
}
|
||||
.textWrapper {
|
||||
width: calc(100% - 440px);
|
||||
}
|
||||
.title {
|
||||
max-width: 680px;
|
||||
font-size: 2.75rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.subtitle {
|
||||
max-width: 640px;
|
||||
margin-top: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.content {
|
||||
margin-top: 28px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: calc(100% - 440px);
|
||||
}
|
||||
.download {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 4px;
|
||||
max-width: 100%;
|
||||
a {
|
||||
line-height: 1.5;
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
a:focus-visible {
|
||||
outline: 3px solid #122f60;
|
||||
outline-offset: 4px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
.note {
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.illustration {
|
||||
position: absolute;
|
||||
width: 520px;
|
||||
height: 390px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.star {
|
||||
position: absolute;
|
||||
width: 390px;
|
||||
height: 390px;
|
||||
right: -50px;
|
||||
bottom: -70px;
|
||||
color: #aec6ff;
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
.logo {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
top: 25px;
|
||||
right: 5px;
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.sticker {
|
||||
position: absolute;
|
||||
right: 32px;
|
||||
bottom: 48px;
|
||||
max-width: 320px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 40px;
|
||||
background: #ddf9d0;
|
||||
color: #254817;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
transform: rotate(-6deg);
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.card:hover .phone {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
.card:hover .sticker {
|
||||
transform: rotate(-3deg) translateY(-4px);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.textWrapper,
|
||||
.actions {
|
||||
width: calc(100% - 340px);
|
||||
}
|
||||
.title {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
.illustration {
|
||||
right: -70px;
|
||||
}
|
||||
.sticker {
|
||||
right: 90px;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 730px) {
|
||||
.card {
|
||||
min-height: 0;
|
||||
}
|
||||
.textWrapper,
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.content {
|
||||
gap: 24px;
|
||||
}
|
||||
.illustration {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
right: auto;
|
||||
margin-bottom: -36px;
|
||||
}
|
||||
.phone {
|
||||
width: 300px;
|
||||
left: calc(50% - 250px);
|
||||
right: auto;
|
||||
top: 0;
|
||||
}
|
||||
.star {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
right: calc(50% - 180px);
|
||||
bottom: -70px;
|
||||
}
|
||||
.sticker {
|
||||
right: 0;
|
||||
bottom: 20px;
|
||||
max-width: 100%;
|
||||
font-size: 0.85rem;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.illustration {
|
||||
margin-bottom: -24px;
|
||||
}
|
||||
.download a {
|
||||
font-size: 0.9rem;
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.phone,
|
||||
.sticker {
|
||||
transition: none;
|
||||
}
|
||||
.card:hover .phone {
|
||||
transform: none;
|
||||
}
|
||||
.card:hover .sticker {
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
}
|
||||
45
modules/home/downloadCard/downloadCard.tsx
Normal file
45
modules/home/downloadCard/downloadCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Card } from '@/components/card';
|
||||
import { GooglePlayLink } from '@/components/googlePlayLink';
|
||||
import Star from '@/assets/images/thick-round-star-6.svg';
|
||||
import Logo from '@/assets/images/logo-alt.svg';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
import { locales } from './locales';
|
||||
import styles from './downloadCard.module.scss';
|
||||
|
||||
export function DownloadCard() {
|
||||
const t = useLocale(locales);
|
||||
const { locale } = useRouter();
|
||||
|
||||
return (
|
||||
<section aria-label={t('title')}>
|
||||
<Card
|
||||
className={styles.card}
|
||||
title={t('title')}
|
||||
subtitle={t('description')}
|
||||
classes={{
|
||||
title: styles.title,
|
||||
subtitle: styles.subtitle,
|
||||
textWrapper: styles.textWrapper,
|
||||
content: styles.content,
|
||||
}}
|
||||
>
|
||||
<div className={styles.actions}>
|
||||
<GooglePlayLink className={styles.download} />
|
||||
<p className={styles.note}>{t('note')}</p>
|
||||
</div>
|
||||
<div className={styles.illustration} aria-hidden="true">
|
||||
<Star className={styles.star} />
|
||||
<Logo
|
||||
className={styles.logo}
|
||||
alt=""
|
||||
width={650}
|
||||
sizes="(max-width: 730px) 500px, 650px"
|
||||
/>
|
||||
<span className={styles.sticker}>{t('sticker')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
18
modules/home/downloadCard/locales.ts
Normal file
18
modules/home/downloadCard/locales.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { LocalesMap } from '@/utils/useLocale';
|
||||
|
||||
export const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Расписание — в вашем кармане',
|
||||
description:
|
||||
'Смотрите расписание, сохраняйте любимые остановки и находите маршрут от А до Б. RideBus рядом, куда бы вы ни собирались.',
|
||||
note: 'Для Android. Бесплатно и без рекламы.',
|
||||
sticker: 'С вами на каждой остановке',
|
||||
},
|
||||
en: {
|
||||
title: 'Your timetable, in your pocket',
|
||||
description:
|
||||
'Check timetables, save your favourite stops and find a route from A to B. Take RideBus wherever you’re headed.',
|
||||
note: 'For Android. Free, with no ads.',
|
||||
sticker: 'With you at every stop',
|
||||
},
|
||||
};
|
||||
168
modules/home/faqCard/faqCard.module.scss
Normal file
168
modules/home/faqCard/faqCard.module.scss
Normal file
@@ -0,0 +1,168 @@
|
||||
.card {
|
||||
--on-background: #2c4678;
|
||||
--on-background-secondary: #455e91;
|
||||
--background: #d8e2ff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.star {
|
||||
position: absolute;
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
right: -120px;
|
||||
top: -190px;
|
||||
color: #aec6ff;
|
||||
transform: rotate(-15deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.questions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.item {
|
||||
background: #f3f3fa;
|
||||
border-radius: 20px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
.expanded {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.question {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 32px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 20px 24px;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: var(--on-background);
|
||||
font: inherit;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
&:focus-visible {
|
||||
outline: 3px solid #2c4678;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
font-size: 0.85rem;
|
||||
color: #455e91;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.plus {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #d8e2ff;
|
||||
border-radius: 50%;
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 15px;
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: #2c4678;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
&::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
.expanded .plus::after {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
.answer {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.25s ease,
|
||||
visibility 0.35s;
|
||||
> div {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
p {
|
||||
padding: 0 72px 24px 80px;
|
||||
max-width: 960px;
|
||||
color: #455e91;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
a {
|
||||
color: #2c4678;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
&:focus-visible {
|
||||
outline: 2px solid #2c4678;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.expanded .answer {
|
||||
grid-template-rows: 1fr;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.question {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.answer p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
.question {
|
||||
padding: 16px;
|
||||
gap: 10px;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 28px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.plus {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
&::before,
|
||||
&::after {
|
||||
left: 7px;
|
||||
top: 13px;
|
||||
}
|
||||
}
|
||||
.answer p {
|
||||
padding: 0 16px 20px 50px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.item,
|
||||
.plus::before,
|
||||
.plus::after,
|
||||
.answer {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
73
modules/home/faqCard/faqCard.tsx
Normal file
73
modules/home/faqCard/faqCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
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 useLocale from '@/utils/useLocale';
|
||||
import { locales, questionIds } from './locales';
|
||||
import styles from './faqCard.module.scss';
|
||||
|
||||
export function FaqCard() {
|
||||
const t = useLocale(locales);
|
||||
const [expanded, setExpanded] = useState<string | null>('accuracy');
|
||||
|
||||
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.questions}>
|
||||
{questionIds.map((id, index) => {
|
||||
const open = expanded === id;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={clsx(styles.item, open && styles.expanded)}
|
||||
>
|
||||
<h4>
|
||||
<CursorEffect
|
||||
effectForce={3}
|
||||
effectDistance={48}
|
||||
cursorBorderRadius={16}
|
||||
cursorPadding={4}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id={`faq-${id}-question`}
|
||||
aria-expanded={open}
|
||||
aria-controls={`faq-${id}-answer`}
|
||||
onClick={() => setExpanded(open ? null : id)}
|
||||
className={styles.question}
|
||||
>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
0{index + 1}
|
||||
</span>
|
||||
<span>{t(`${id}Question`)}</span>
|
||||
<span className={styles.plus} aria-hidden="true" />
|
||||
</button>
|
||||
</CursorEffect>
|
||||
</h4>
|
||||
<div
|
||||
id={`faq-${id}-answer`}
|
||||
role="region"
|
||||
aria-labelledby={`faq-${id}-question`}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
className={styles.answer}
|
||||
>
|
||||
<div>
|
||||
<p>{t(`${id}Answer`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
87
modules/home/faqCard/locales.tsx
Normal file
87
modules/home/faqCard/locales.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Link from 'next/link';
|
||||
import type { LocalesMap } from '@/utils/useLocale';
|
||||
|
||||
export const questionIds = [
|
||||
'accuracy',
|
||||
'cities',
|
||||
'empty',
|
||||
'free',
|
||||
'download',
|
||||
] as const;
|
||||
|
||||
export const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Частые вопросы',
|
||||
description: 'Немного о расписании, приложении и том, как помочь RideBus.',
|
||||
accuracyQuestion: 'Это точное время прибытия автобуса?',
|
||||
accuracyAnswer:
|
||||
'Ближайшие отправления на сайте рассчитаны по расписанию. Дорожная ситуация и задержки могут изменить фактическое время прибытия — учитывайте это, когда планируете поездку.',
|
||||
citiesQuestion: 'Что делать, если моего города нет?',
|
||||
citiesAnswer: (
|
||||
<>
|
||||
Помогите нам найти расписание и остановки вашего города. В разделе{' '}
|
||||
<Link href="/contribute">«Вклад»</Link> есть блок «Помогите добавить
|
||||
свой город» с подсказками, какие данные нужны и куда их отправить.
|
||||
</>
|
||||
),
|
||||
emptyQuestion: 'Почему на остановке нет отправлений?',
|
||||
emptyAnswer:
|
||||
'На выбранном направлении может не быть ближайших рейсов, а часть расписания ещё может отсутствовать. Проверьте другой чип направления или выберите соседнюю остановку. Если расписание не загружается, попробуйте ещё раз чуть позже.',
|
||||
freeQuestion: 'RideBus бесплатный?',
|
||||
freeAnswer:
|
||||
'Да, RideBus бесплатный и без рекламы. Это хобби-проект, которому можно помочь данными, идеями или участием в разработке.',
|
||||
downloadQuestion: 'Где скачать приложение?',
|
||||
downloadAnswer: (
|
||||
<>
|
||||
Приложение для Android доступно в{' '}
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=org.xtimms.ridebus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Google Play
|
||||
</a>
|
||||
. Ближайшие отправления можно посмотреть прямо на этом сайте — выберите
|
||||
город, остановку и направление.
|
||||
</>
|
||||
),
|
||||
},
|
||||
en: {
|
||||
title: 'Frequently asked questions',
|
||||
description:
|
||||
'A little about the timetable, the app and how to help RideBus.',
|
||||
accuracyQuestion: 'Is this the exact bus arrival time?',
|
||||
accuracyAnswer:
|
||||
'Departures on this website are calculated from the timetable. Traffic and delays can change the actual arrival time, so allow for this when planning your journey.',
|
||||
citiesQuestion: 'What if my city is missing?',
|
||||
citiesAnswer: (
|
||||
<>
|
||||
Help us find your city’s timetable and stops. The{' '}
|
||||
<Link href="/contribute">Contribute</Link> page includes a “Help add
|
||||
your city” section explaining what information we need and where to send
|
||||
it.
|
||||
</>
|
||||
),
|
||||
emptyQuestion: 'Why are there no departures at my stop?',
|
||||
emptyAnswer:
|
||||
'There may be no upcoming services in the selected direction, or some timetable data may still be missing. Try another direction chip or a nearby stop. If the timetable does not load, try again a little later.',
|
||||
freeQuestion: 'Is RideBus free?',
|
||||
freeAnswer:
|
||||
'Yes, RideBus is free and has no ads. It is a hobby project that you can support with data, ideas or contributions to development.',
|
||||
downloadQuestion: 'Where can I download the app?',
|
||||
downloadAnswer: (
|
||||
<>
|
||||
The Android app is available on{' '}
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=org.xtimms.ridebus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Google Play
|
||||
</a>
|
||||
. You can also check upcoming departures here by choosing your city,
|
||||
stop and direction.
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
468
modules/home/featuresCards/featuresCards.module.scss
Normal file
468
modules/home/featuresCards/featuresCards.module.scss
Normal file
@@ -0,0 +1,468 @@
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.card {
|
||||
--on-background: #2c4678;
|
||||
--on-background-secondary: #455e91;
|
||||
position: relative;
|
||||
min-height: 470px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journey,
|
||||
.timetable {
|
||||
--background: #f3f3fa;
|
||||
}
|
||||
.map {
|
||||
--background: #d8e2ff;
|
||||
}
|
||||
.favorites {
|
||||
--background: #ffeef2;
|
||||
--on-background: #861118;
|
||||
--on-background-secondary: #861118;
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
margin-top: 36px;
|
||||
}
|
||||
.illustration {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
height: 250px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.roundStar,
|
||||
.favoriteStar,
|
||||
.timetableStar {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.roundStar {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
bottom: -130px;
|
||||
left: -130px;
|
||||
color: #d8e2ff;
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
.favoriteStar {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: -155px;
|
||||
right: -130px;
|
||||
color: #ff003d;
|
||||
opacity: 0.12;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
.timetableStar {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
bottom: -130px;
|
||||
right: -130px;
|
||||
color: #d8e2ff;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.journeySheet {
|
||||
position: relative;
|
||||
margin: 20px 14px 0;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
transform: rotate(-4deg);
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.place {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
& + & {
|
||||
margin-top: 28px;
|
||||
}
|
||||
}
|
||||
.place > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.place span:not(.point) {
|
||||
font-size: 0.8rem;
|
||||
color: #455e91;
|
||||
font-weight: 600;
|
||||
}
|
||||
.place strong {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 16px;
|
||||
flex-shrink: 0;
|
||||
background: #d8e2ff;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.destination {
|
||||
background: #ddf9d0;
|
||||
color: #254817;
|
||||
}
|
||||
.journeyLine {
|
||||
position: absolute;
|
||||
left: 45px;
|
||||
top: 60px;
|
||||
bottom: 60px;
|
||||
border-left: 2px dashed #aec6ff;
|
||||
}
|
||||
|
||||
.journeyChip,
|
||||
.mapChip,
|
||||
.favoritesChip {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 40px;
|
||||
padding: 14px 22px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.journeyChip {
|
||||
background: #ddf9d0;
|
||||
color: #254817;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transform: rotate(7deg);
|
||||
span {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mapIllustration {
|
||||
height: 250px;
|
||||
}
|
||||
.mapSheet {
|
||||
margin: 8px 0 0;
|
||||
transform: rotate(4deg);
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.mapDrawing {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.mapChip {
|
||||
background: #fff;
|
||||
color: #2c4678;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(-6deg);
|
||||
}
|
||||
.chipDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #2c4678;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.locationHalo {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: locationPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.favoriteTicket {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: absolute;
|
||||
width: calc(100% - 26px);
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
color: #861118;
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
strong {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
div span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.homeTicket {
|
||||
top: 18px;
|
||||
left: 0;
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
.workTicket {
|
||||
top: 116px;
|
||||
right: 0;
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
.favoriteIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ffeef2;
|
||||
color: #ff003d;
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
.ticketArrow {
|
||||
margin-left: auto;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.favoritesChip {
|
||||
background: #861118;
|
||||
color: #ffeef2;
|
||||
bottom: -4px;
|
||||
left: 16px;
|
||||
transform: rotate(-5deg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.timetableSheet {
|
||||
position: relative;
|
||||
margin: 0 14px;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
transform: rotate(3deg);
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.dayTypes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
span {
|
||||
padding: 7px 12px;
|
||||
border-radius: 40px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: #ddf9d0;
|
||||
color: #254817;
|
||||
}
|
||||
span + span {
|
||||
background: #ffeef2;
|
||||
color: #861118;
|
||||
}
|
||||
}
|
||||
.days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
border-radius: 10px;
|
||||
background: #d8e2ff;
|
||||
}
|
||||
.weekend {
|
||||
background: #ffeef2;
|
||||
color: #861118;
|
||||
}
|
||||
}
|
||||
.hours {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: 42px repeat(3, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
strong {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
span {
|
||||
padding: 7px 0;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
background: #f3f3fa;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.card:hover .journeySheet {
|
||||
transform: rotate(-1deg) translateY(-4px);
|
||||
}
|
||||
.card:hover .journeyChip {
|
||||
transform: rotate(3deg) translateY(-4px);
|
||||
}
|
||||
.card:hover .mapSheet {
|
||||
transform: rotate(1deg) translateY(-4px);
|
||||
}
|
||||
.card:hover .homeTicket {
|
||||
transform: rotate(-3deg) translateY(-4px);
|
||||
}
|
||||
.card:hover .workTicket {
|
||||
transform: rotate(3deg) translateY(4px);
|
||||
}
|
||||
.card:hover .timetableSheet {
|
||||
transform: rotate(0deg) translateY(-4px);
|
||||
}
|
||||
|
||||
@keyframes locationPulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.cards {
|
||||
gap: 36px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.cards {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.card {
|
||||
min-height: 430px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.cards {
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.cards {
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
padding: 24px;
|
||||
min-height: 400px;
|
||||
}
|
||||
.content {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.illustration {
|
||||
height: 240px;
|
||||
}
|
||||
.journeySheet {
|
||||
margin: 18px 4px 0;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.journeyLine {
|
||||
left: 37px;
|
||||
}
|
||||
.place {
|
||||
gap: 12px;
|
||||
}
|
||||
.place strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.journeyChip,
|
||||
.mapChip {
|
||||
font-size: 1rem;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
.mapIllustration {
|
||||
height: 210px;
|
||||
}
|
||||
.mapSheet {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.mapChip {
|
||||
bottom: 0;
|
||||
}
|
||||
.favoriteTicket {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
strong {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
div span {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
.timetableSheet {
|
||||
margin: 0 4px;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
.hours > div {
|
||||
grid-template-columns: 34px repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.journey .illustration,
|
||||
.favorites .illustration {
|
||||
height: 285px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.locationHalo {
|
||||
animation: none;
|
||||
}
|
||||
.journeySheet,
|
||||
.journeyChip,
|
||||
.mapChip,
|
||||
.favoritesChip,
|
||||
.mapSheet,
|
||||
.favoriteTicket,
|
||||
.timetableSheet {
|
||||
transition: none;
|
||||
}
|
||||
.card:hover .journeySheet {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
.card:hover .journeyChip {
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
.card:hover .mapSheet {
|
||||
transform: rotate(4deg);
|
||||
}
|
||||
.card:hover .homeTicket {
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
.card:hover .workTicket {
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
.card:hover .timetableSheet {
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
}
|
||||
181
modules/home/featuresCards/featuresCards.tsx
Normal file
181
modules/home/featuresCards/featuresCards.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import clsx from 'clsx';
|
||||
import { Card } from '@/components/card';
|
||||
import RoundStar from '@/assets/images/thick-round-star-6.svg';
|
||||
import FatStar from '@/assets/images/fat-little-star.svg';
|
||||
import LoveIcon from '@/assets/icons/love.svg';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
import { locales } from './locales';
|
||||
import styles from './featuresCards.module.scss';
|
||||
|
||||
export function FeaturesCards() {
|
||||
const t = useLocale(locales);
|
||||
const days = t('days') as string[];
|
||||
|
||||
return (
|
||||
<section className={styles.cards} aria-label={t('section')}>
|
||||
<Card
|
||||
className={clsx(styles.card, styles.journey)}
|
||||
title={t('journeyTitle')}
|
||||
subtitle={t('journeyDescription')}
|
||||
classes={{ content: styles.content }}
|
||||
>
|
||||
<RoundStar className={styles.roundStar} aria-hidden="true" />
|
||||
<div className={styles.illustration} aria-hidden="true">
|
||||
<div className={styles.journeySheet}>
|
||||
<span className={styles.journeyLine} />
|
||||
<div className={styles.place}>
|
||||
<span className={styles.point}>{t('departureLetter')}</span>
|
||||
<div>
|
||||
<span>{t('from')}</span>
|
||||
<strong>{t('departurePoint')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.place}>
|
||||
<span className={clsx(styles.point, styles.destination)}>
|
||||
{t('destinationLetter')}
|
||||
</span>
|
||||
<div>
|
||||
<span>{t('to')}</span>
|
||||
<strong>{t('destinationPoint')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.journeyChip}>
|
||||
{t('journeyChip')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={clsx(styles.card, styles.map)}
|
||||
title={t('mapTitle')}
|
||||
subtitle={t('mapDescription')}
|
||||
classes={{ content: styles.content }}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.illustration, styles.mapIllustration)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className={styles.mapSheet}>
|
||||
<svg viewBox="0 0 440 270" className={styles.mapDrawing}>
|
||||
<rect width="440" height="270" rx="28" fill="#AEC6FF" />
|
||||
<g fill="#D8E2FF">
|
||||
<rect x="20" y="18" width="99" height="59" rx="16" />
|
||||
<rect x="145" y="18" width="116" height="59" rx="16" />
|
||||
<rect x="286" y="18" width="134" height="59" rx="16" />
|
||||
<rect x="20" y="106" width="99" height="146" rx="16" />
|
||||
<rect x="145" y="106" width="116" height="67" rx="16" />
|
||||
<rect x="286" y="106" width="134" height="67" rx="16" />
|
||||
<rect x="145" y="200" width="116" height="52" rx="16" />
|
||||
<rect x="286" y="200" width="134" height="52" rx="16" />
|
||||
</g>
|
||||
<g stroke="#F3F3FA" strokeWidth="13" strokeLinecap="round">
|
||||
<path d="M132 0v270M274 0v270M0 92h440M132 187h308" />
|
||||
</g>
|
||||
<path
|
||||
d="M52 92h222v95h111"
|
||||
fill="none"
|
||||
stroke="#2C4678"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<g fill="#fff" stroke="#2C4678" strokeWidth="5">
|
||||
<circle cx="52" cy="92" r="10" />
|
||||
<circle cx="274" cy="187" r="10" />
|
||||
<circle cx="385" cy="187" r="10" />
|
||||
</g>
|
||||
<circle
|
||||
className={styles.locationHalo}
|
||||
cx="198"
|
||||
cy="139"
|
||||
r="22"
|
||||
fill="#2C4678"
|
||||
fillOpacity="0.15"
|
||||
/>
|
||||
<circle
|
||||
cx="198"
|
||||
cy="139"
|
||||
r="9"
|
||||
fill="#2C4678"
|
||||
stroke="#fff"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className={styles.mapChip}>
|
||||
<span className={styles.chipDot} />
|
||||
{t('mapChip')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={clsx(styles.card, styles.favorites)}
|
||||
title={t('favoritesTitle')}
|
||||
subtitle={t('favoritesDescription')}
|
||||
classes={{ content: styles.content }}
|
||||
>
|
||||
<FatStar className={styles.favoriteStar} aria-hidden="true" />
|
||||
<div className={styles.illustration} aria-hidden="true">
|
||||
<div className={clsx(styles.favoriteTicket, styles.homeTicket)}>
|
||||
<span className={styles.favoriteIcon}>
|
||||
<LoveIcon />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('stopTitle')}</strong>
|
||||
<span>{t('favoriteStop')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx(styles.favoriteTicket, styles.workTicket)}>
|
||||
<span className={styles.favoriteIcon}>
|
||||
<LoveIcon />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('routeTitle')}</strong>
|
||||
<span>{t('favoriteRoute')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.favoritesChip}>{t('favoritesChip')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={clsx(styles.card, styles.timetable)}
|
||||
title={t('timetableTitle')}
|
||||
subtitle={t('timetableDescription')}
|
||||
classes={{ content: styles.content }}
|
||||
>
|
||||
<RoundStar className={styles.timetableStar} aria-hidden="true" />
|
||||
<div className={styles.illustration} aria-hidden="true">
|
||||
<div className={styles.timetableSheet}>
|
||||
<div className={styles.dayTypes}>
|
||||
<span>{t('weekdays')}</span>
|
||||
<span>{t('weekends')}</span>
|
||||
</div>
|
||||
<div className={styles.days}>
|
||||
{days.map((day, index) => (
|
||||
<span
|
||||
key={day}
|
||||
className={index > 4 ? styles.weekend : undefined}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.hours}>
|
||||
{['07', '08', '09'].map((hour) => (
|
||||
<div key={hour}>
|
||||
<strong>{hour}</strong>
|
||||
<span>10</span>
|
||||
<span>25</span>
|
||||
<span>40</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
65
modules/home/featuresCards/locales.ts
Normal file
65
modules/home/featuresCards/locales.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { LocalesMap } from '@/utils/useLocale';
|
||||
|
||||
export const locales: LocalesMap = {
|
||||
ru: {
|
||||
section: 'Возможности приложения',
|
||||
journeyTitle: 'Маршрут от А до Б',
|
||||
journeyDescription:
|
||||
'Укажите начальную и конечную остановки — RideBus предложит варианты поездки.',
|
||||
from: 'Откуда',
|
||||
to: 'Куда',
|
||||
departurePoint: 'Начальная остановка',
|
||||
destinationPoint: 'Конечная остановка',
|
||||
departureLetter: 'А',
|
||||
destinationLetter: 'Б',
|
||||
journeyChip: 'Поехали?',
|
||||
mapTitle: 'Остановки на карте',
|
||||
mapDescription:
|
||||
'Найдите нужную остановку на карте и посмотрите, какие маршруты через неё проходят.',
|
||||
mapChip: 'Остановки рядом',
|
||||
favoritesTitle: 'Ваши привычные поездки',
|
||||
favoritesDescription:
|
||||
'Сохраняйте остановки и маршруты в избранное, чтобы не искать их заново перед каждой поездкой.',
|
||||
stopTitle: 'Спортклуб',
|
||||
routeTitle: 'Центр - Вокзал',
|
||||
favoriteStop: 'Избранная остановка',
|
||||
favoriteRoute: 'Избранный маршрут',
|
||||
favoritesChip: 'Всегда под рукой',
|
||||
timetableTitle: 'Расписание на нужный день',
|
||||
timetableDescription:
|
||||
'Проверяйте время отправления по будням и выходным — планировать поездки заранее проще.',
|
||||
weekdays: 'Будни',
|
||||
weekends: 'Выходные',
|
||||
days: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
|
||||
},
|
||||
en: {
|
||||
section: 'App features',
|
||||
journeyTitle: 'A route from A to B',
|
||||
journeyDescription:
|
||||
'Choose your starting and destination stops, and RideBus will suggest journey options.',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
departurePoint: 'Starting stop',
|
||||
destinationPoint: 'Destination stop',
|
||||
departureLetter: 'A',
|
||||
destinationLetter: 'B',
|
||||
journeyChip: 'Let’s go?',
|
||||
mapTitle: 'Stops on the map',
|
||||
mapDescription: 'Find your stop on the map and see which routes serve it.',
|
||||
mapChip: 'Stops nearby',
|
||||
favoritesTitle: 'Your everyday journeys',
|
||||
favoritesDescription:
|
||||
'Save your stops and routes to favorites so you do not have to search for them before every trip.',
|
||||
stopTitle: 'Sportclub',
|
||||
routeTitle: 'City center - Train station',
|
||||
favoriteStop: 'Favorite stop',
|
||||
favoriteRoute: 'Favorite route',
|
||||
favoritesChip: 'Always at hand',
|
||||
timetableTitle: 'A timetable for your day',
|
||||
timetableDescription:
|
||||
'Check weekday and weekend departures to plan your journeys ahead.',
|
||||
weekdays: 'Weekdays',
|
||||
weekends: 'Weekends',
|
||||
days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||
},
|
||||
};
|
||||
@@ -20,10 +20,41 @@ import noise from '@/assets/images/noise.png';
|
||||
|
||||
const locales: LocalesMap = {
|
||||
ru: {
|
||||
slogan_line: 'Приложение, которое поможет вам с лёгкостью смотреть расписание транспорта',
|
||||
slogan_line: (
|
||||
<>
|
||||
<span>Приложение,</span>
|
||||
<span>которое</span>
|
||||
<span>поможет</span>
|
||||
<span>вам</span>
|
||||
<span>с</span>
|
||||
<span className={styles.spendWord}>лёгкостью</span>
|
||||
<span>смотреть</span>
|
||||
<span>расписание</span>
|
||||
<span className={clsx(caveat.className, styles.wiselyWord)}>транспорта</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
en: {
|
||||
slogan_line: 'Small app that helps you conveniently view public transpot schedules',
|
||||
slogan_line: (
|
||||
<>
|
||||
<span>Small</span>
|
||||
<span>app</span>
|
||||
<span>that</span>
|
||||
<span>helps</span>
|
||||
<span>you</span>
|
||||
<span className={styles.spendWord}>conveniently</span>
|
||||
<span>view</span>
|
||||
<span className={clsx(caveat.className, styles.wiselyWord)}>
|
||||
public
|
||||
</span>
|
||||
<span className={clsx(caveat.className, styles.wiselyWord)}>
|
||||
transport
|
||||
</span>
|
||||
<span className={clsx(caveat.className, styles.wiselyWord)}>
|
||||
schedules
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,7 +104,11 @@ export default function Header() {
|
||||
ref={thickRoundStar5}>
|
||||
<ThickRoundStar5 />
|
||||
</div>
|
||||
<h1 className={styles.slogan}>{t('slogan_line')}</h1>
|
||||
<h1 className={clsx(styles.slogan, manrope.className)}>
|
||||
<span ref={sloganLineRef} className={styles.line1}>
|
||||
{t('slogan_line')}
|
||||
</span>
|
||||
</h1>
|
||||
<div className={styles.gradientContainer}>
|
||||
<GradientPrimary className={styles.greenGradient} />
|
||||
<GradientTertiary className={styles.orangeGradient} />
|
||||
|
||||
@@ -191,6 +191,24 @@
|
||||
flex-direction: row;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.liveCardsRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 48px;
|
||||
|
||||
> section {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.liveAside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex-basis: 50%;
|
||||
@@ -207,7 +225,9 @@
|
||||
padding: 36px;
|
||||
gap: 36px;
|
||||
}
|
||||
.cardsRow {
|
||||
.cardsRow,
|
||||
.liveCardsRow,
|
||||
.liveAside {
|
||||
gap: 36px;
|
||||
}
|
||||
}
|
||||
@@ -225,6 +245,10 @@
|
||||
.cardsRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.liveCardsRow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.howItsWorkCard {
|
||||
height: 550px;
|
||||
@@ -250,7 +274,9 @@
|
||||
padding: 24px;
|
||||
gap: 24px;
|
||||
}
|
||||
.cardsRow {
|
||||
.cardsRow,
|
||||
.liveCardsRow,
|
||||
.liveAside {
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@@ -294,7 +320,9 @@
|
||||
.wiselyWord {
|
||||
font-size: 3rem !important;
|
||||
}
|
||||
.cardsRow {
|
||||
.cardsRow,
|
||||
.liveCardsRow,
|
||||
.liveAside {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ const locales: LocalesMap = {
|
||||
export function HowItsWorkCard() {
|
||||
const router = useRouter();
|
||||
const t = useLocale(locales);
|
||||
const ref = useRef<any>();
|
||||
const imageRef = useRef<any>();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useScroll((scrollY) => {
|
||||
if (!imageRef.current || !ref.current) return;
|
||||
@@ -77,4 +77,4 @@ export function HowItsWorkCard() {
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
159
modules/home/legalCards/legalCards.module.scss
Normal file
159
modules/home/legalCards/legalCards.module.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 20px;
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 36px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid #0058ff;
|
||||
outline-offset: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
transition: box-shadow 180ms ease;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.privacy {
|
||||
--background: #d8e2ff;
|
||||
--on-background: #2c4678;
|
||||
--on-background-secondary: #455e91;
|
||||
}
|
||||
|
||||
.terms {
|
||||
--background: #f3edf7;
|
||||
--on-background: #715573;
|
||||
--on-background-secondary: #725d7c;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 460px;
|
||||
padding-right: 32px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 460px;
|
||||
line-height: 1.6;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.star {
|
||||
position: absolute;
|
||||
right: -58px;
|
||||
top: -74px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--on-background);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
right: 36px;
|
||||
bottom: 42px;
|
||||
color: var(--on-background-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1240px) {
|
||||
.cards {
|
||||
gap: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 980px) {
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.cards {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.cards {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.number {
|
||||
right: 28px;
|
||||
bottom: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
54
modules/home/legalCards/legalCards.tsx
Normal file
54
modules/home/legalCards/legalCards.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Card } from '@/components/card';
|
||||
import { CursorEffect } from '@/components/cursor';
|
||||
import LinkIcon from '@/assets/icons/link.svg';
|
||||
import ThinStarBig from '@/assets/images/thin-star-big.svg';
|
||||
import { Locales } from '@/utils/useLocale';
|
||||
import { legalCopy, legalDocumentIds } from '@/modules/legal/content';
|
||||
import styles from './legalCards.module.scss';
|
||||
|
||||
export function LegalCards() {
|
||||
const { locale } = useRouter();
|
||||
const copy = legalCopy[(locale || 'en') as Locales];
|
||||
|
||||
return (
|
||||
<section className={styles.root} aria-labelledby="legal-title">
|
||||
<div className={styles.cards}>
|
||||
{legalDocumentIds.map((id, index) => {
|
||||
const document = copy.documents[id];
|
||||
|
||||
return (
|
||||
<CursorEffect key={id} cursorBorderRadius={36} cursorPadding={8}>
|
||||
<Link href={`/${id}`} className={styles.link}>
|
||||
<Card
|
||||
className={`${styles.card} ${styles[id]}`}
|
||||
title={document.title}
|
||||
subtitle={document.description}
|
||||
classes={{
|
||||
title: styles.title,
|
||||
subtitle: styles.description,
|
||||
content: styles.content,
|
||||
}}
|
||||
>
|
||||
<span className={styles.star} aria-hidden="true">
|
||||
<ThinStarBig />
|
||||
</span>
|
||||
{/* <div className={styles.action}>
|
||||
<span>{copy.read}</span>
|
||||
<span className={styles.arrow} aria-hidden="true">
|
||||
<LinkIcon />
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
0{index + 1}
|
||||
</span> */}
|
||||
</Card>
|
||||
</Link>
|
||||
</CursorEffect>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
modules/home/reportCard/locales.ts
Normal file
22
modules/home/reportCard/locales.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { LocalesMap } from '@/utils/useLocale';
|
||||
|
||||
export const locales: LocalesMap = {
|
||||
ru: {
|
||||
title: 'Заметили неточность?',
|
||||
description:
|
||||
'Расписание изменилось, а в RideBus всё по-старому? Помогите нам сделать поездки чуть проще — расскажите об ошибке.',
|
||||
timetable: 'Расписание',
|
||||
routes: 'Маршруты',
|
||||
stops: 'Остановки',
|
||||
action: 'Как помочь RideBus',
|
||||
},
|
||||
en: {
|
||||
title: 'Spotted a mistake?',
|
||||
description:
|
||||
'The timetable has changed, but RideBus hasn’t caught up? Help make journeys a little easier — let us know about the mistake.',
|
||||
timetable: 'Timetables',
|
||||
routes: 'Routes',
|
||||
stops: 'Stops',
|
||||
action: 'How to help RideBus',
|
||||
},
|
||||
};
|
||||
113
modules/home/reportCard/reportCard.module.scss
Normal file
113
modules/home/reportCard/reportCard.module.scss
Normal file
@@ -0,0 +1,113 @@
|
||||
.card {
|
||||
--background: #ffeef2;
|
||||
--on-background: #861118;
|
||||
--on-background-secondary: #861118;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 12px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
margin-top: 28px;
|
||||
gap: 28px;
|
||||
justify-content: space-between;
|
||||
z-index: 1;
|
||||
}
|
||||
.star {
|
||||
position: absolute;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
right: -130px;
|
||||
bottom: -130px;
|
||||
color: #ffd9e2;
|
||||
transform: rotate(12deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.badges {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
span {
|
||||
border-radius: 40px;
|
||||
padding: 12px 18px;
|
||||
background: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
transform: rotate(-3deg);
|
||||
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
&:nth-child(2) {
|
||||
background: #861118;
|
||||
color: #ffeef2;
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
&:nth-child(3) {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
&:hover {
|
||||
transform: rotate(0deg) translateY(-3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
.action {
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: #861118;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 3px solid #861118;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.card {
|
||||
padding: 24px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.badges {
|
||||
gap: 10px;
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.badges span {
|
||||
transition: none;
|
||||
&:hover {
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
&:nth-child(2):hover {
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
&:nth-child(3):hover {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
modules/home/reportCard/reportCard.tsx
Normal file
41
modules/home/reportCard/reportCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import Link from 'next/link';
|
||||
import { Card } from '@/components/card';
|
||||
import { CursorEffect } from '@/components/cursor';
|
||||
import Star from '@/assets/images/thick-round-star-5.svg';
|
||||
import LinkIcon from '@/assets/icons/link.svg';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
import { locales } from './locales';
|
||||
import styles from './reportCard.module.scss';
|
||||
|
||||
export function ReportCard() {
|
||||
const t = useLocale(locales);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.card}
|
||||
role="region"
|
||||
aria-label={t('title')}
|
||||
title={t('title')}
|
||||
subtitle={t('description')}
|
||||
classes={{ content: styles.content, subtitle: styles.subtitle }}
|
||||
>
|
||||
<Star className={styles.star} aria-hidden="true" />
|
||||
<div className={styles.badges} aria-hidden="true">
|
||||
<span>{t('timetable')}</span>
|
||||
<span>{t('routes')}</span>
|
||||
<span>{t('stops')}</span>
|
||||
</div>
|
||||
<CursorEffect
|
||||
className={styles.action}
|
||||
cursorBorderRadius={16}
|
||||
cursorPadding={4}
|
||||
effectForce={3}
|
||||
>
|
||||
<Link href="/contribute">
|
||||
{t('action')}
|
||||
<LinkIcon aria-hidden="true" />
|
||||
</Link>
|
||||
</CursorEffect>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
323
modules/legal/content.ts
Normal file
323
modules/legal/content.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Locales } from '@/utils/useLocale';
|
||||
|
||||
export type LegalDocumentId = 'privacy' | 'terms';
|
||||
|
||||
type LegalSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
export type LegalDocument = {
|
||||
title: string;
|
||||
description: string;
|
||||
introduction: string;
|
||||
sections: LegalSection[];
|
||||
};
|
||||
|
||||
export const legalDocumentIds: LegalDocumentId[] = ['privacy', 'terms'];
|
||||
export const legalUpdatedAt = '2026-10-01';
|
||||
export const supportEmail = 'ztimms73@gmail.com';
|
||||
|
||||
export const legalCopy: Record<
|
||||
Locales,
|
||||
{
|
||||
label: string;
|
||||
read: string;
|
||||
home: string;
|
||||
updated: string;
|
||||
contents: string;
|
||||
contactTitle: string;
|
||||
contactDescription: string;
|
||||
documents: Record<LegalDocumentId, LegalDocument>;
|
||||
}
|
||||
> = {
|
||||
ru: {
|
||||
label: 'Открыто о важном',
|
||||
read: 'Читать документ',
|
||||
home: 'На главную',
|
||||
updated: 'Обновлено 1 октября 2026',
|
||||
contents: 'В этом документе',
|
||||
contactTitle: 'Остались вопросы?',
|
||||
contactDescription:
|
||||
'Напишите разработчику по вопросам данных, работы приложения или этих документов.',
|
||||
documents: {
|
||||
privacy: {
|
||||
title: 'Политика конфиденциальности',
|
||||
description:
|
||||
'Какие данные нужны RideBus, для чего они используются и как управлять разрешениями.',
|
||||
introduction:
|
||||
'Эта политика описывает обработку данных при использовании сайта и приложения RideBus. Доступность отдельных функций и состав данных зависят от версии приложения и включённых вами возможностей.',
|
||||
sections: [
|
||||
{
|
||||
id: 'website',
|
||||
title: 'Сайт и обращения',
|
||||
paragraphs: [
|
||||
'Для просмотра лендинга не требуется регистрация. На сайте нет форм для сбора персональных данных, рекламных трекеров и подключённой системы аналитики посещений.',
|
||||
'При загрузке страниц сервер и инфраструктура доставки получают технические сведения запроса, включая IP-адрес, адрес страницы и сведения о браузере. Они необходимы для доставки контента и могут попадать в технические журналы инфраструктуры.',
|
||||
'Если вы пишете разработчику по электронной почте или в Telegram, для ответа используются переданные вами контактные данные и содержание обращения. Не включайте в сообщение сведения, которые не нужны для решения вопроса.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
title: 'Данные приложения',
|
||||
paragraphs: [
|
||||
'RideBus обрабатывает выбранный город, настройки и сведения, необходимые для запрошенных функций: просмотра расписаний, поиска остановок, построения маршрутов и сохранения избранного. Часть настроек и кеш расписаний сохраняются на устройстве.',
|
||||
'При обращении к серверам расписаний и карт передаются параметры запроса и технические сведения соединения. В зависимости от версии приложения сервисы диагностики могут получать сведения об ошибках, версии приложения, устройстве и его работе для устранения сбоев.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
title: 'Местоположение и разрешения',
|
||||
paragraphs: [
|
||||
'Доступ к местоположению запрашивается через системное разрешение и используется для функций рядом с вами, например поиска ближайших остановок и отображения позиции на карте. При запросе маршрута координаты выбранных точек могут передаваться сервису маршрутизации.',
|
||||
'Вы можете отозвать разрешение в настройках устройства. Ручной выбор города, остановок и просмотр доступных расписаний не требуют разрешения на местоположение. Разрешение на уведомления также управляется в настройках устройства.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'passenger-mode',
|
||||
title: 'Добровольный пассажирский режим',
|
||||
paragraphs: [
|
||||
'В версиях с пассажирским режимом передача геоданных включается отдельным действием пользователя. Одного разрешения на местоположение для включения этого режима недостаточно.',
|
||||
'Когда режим включён и приложение открыто, на сервер RideBus могут передаваться координаты, точность, скорость, направление движения, время наблюдения, выбранные город и маршрут, а также случайный идентификатор сессии. Эти сведения используются для оценки движения общественного транспорта. Идентификатор сессии не является вашим именем, номером телефона или адресом электронной почты.',
|
||||
'Передача прекращается при выключении режима, отзыве разрешения или уходе приложения в фон. Выключение режима останавливает новые отправки и само по себе не удаляет ранее отправленные сведения.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'providers',
|
||||
title: 'Сторонние сервисы',
|
||||
paragraphs: [
|
||||
'Работа отдельных функций может требовать сервисов карт, маршрутизации, авторизации, уведомлений и диагностики, включая Google Firebase. Такие поставщики обрабатывают необходимые им данные по собственным условиям и политикам конфиденциальности.',
|
||||
'Ссылки на Google Play, GitHub и Telegram ведут на внешние сайты. После перехода действуют правила соответствующего сервиса. Ознакомьтесь с ними перед передачей данных.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'control',
|
||||
title: 'Управление данными',
|
||||
paragraphs: [
|
||||
'Вы можете изменить разрешения устройства, отключить добровольные функции и очистить локальные данные приложения средствами операционной системы. Очистка локальных данных не удаляет автоматически данные, которые уже были переданы серверам или сторонним сервисам.',
|
||||
'По вопросам доступа, исправления или удаления переданных данных свяжитесь с разработчиком по адресу ниже. В обращении укажите, какой функцией и версией приложения вы пользовались; не отправляйте пароли и коды доступа. Возможность выполнения запроса зависит от того, можно ли установить, какие данные относятся к вам, и от применимых требований.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
title: 'Изменения политики',
|
||||
paragraphs: [
|
||||
'Актуальная редакция публикуется на этой странице с датой обновления. При изменении функций или обработки данных политика может быть уточнена. Для функций, требующих отдельного согласия, ознакомьтесь с информацией в приложении перед их включением.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
terms: {
|
||||
title: 'Условия использования',
|
||||
description:
|
||||
'Как пользоваться RideBus, откуда берутся расписания и что учитывать при планировании поездки.',
|
||||
introduction:
|
||||
'Эти условия относятся к сайту и приложению RideBus — проекту для просмотра информации об общественном транспорте. Используя сервис, ознакомьтесь с его возможностями, ограничениями и политикой конфиденциальности.',
|
||||
sections: [
|
||||
{
|
||||
id: 'service',
|
||||
title: 'О сервисе',
|
||||
paragraphs: [
|
||||
'RideBus помогает просматривать расписания, остановки и маршруты в поддерживаемых городах. Набор функций зависит от версии приложения и доступности данных. Сервис предоставляется бесплатно; для отдельных функций требуется интернет, который оплачивается по тарифу вашего оператора.',
|
||||
'RideBus — информационный проект. Разработчик не является перевозчиком, не продаёт билеты и не управляет движением транспорта.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
title: 'Расписания и планирование поездок',
|
||||
paragraphs: [
|
||||
'Расписания и другие транспортные сведения поступают из доступных источников и могут обновляться с задержкой. Возможны изменения маршрутов, отмены рейсов, ошибки в данных и отклонения фактического времени прибытия от указанного.',
|
||||
'Используйте информацию RideBus как ориентир. Если точное время поездки критично, проверяйте сведения у перевозчика или на остановке и закладывайте время на возможные изменения. О найденной ошибке можно сообщить разработчику.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'responsible-use',
|
||||
title: 'Правила использования',
|
||||
paragraphs: [
|
||||
'Пользуйтесь сервисом законно и уважайте права других людей. Не вмешивайтесь в работу серверов, не пытайтесь получить несанкционированный доступ к чужим данным и не создавайте нагрузку, мешающую другим пользователям.',
|
||||
'Если вы используете функции передачи транспортных данных, отправляйте достоверные сведения. Не подменяйте координаты и не используйте такие функции для наблюдения за другими людьми без законных оснований.',
|
||||
'Во время движения соблюдайте правила дорожного движения и пользуйтесь приложением только тогда, когда это безопасно.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
title: 'Разрешения и конфиденциальность',
|
||||
paragraphs: [
|
||||
'Отдельные функции требуют разрешения на местоположение, уведомления или самостоятельного включения. Вы можете отказаться от них или отозвать разрешения в настройках устройства; доступность связанных функций при этом изменится.',
|
||||
'Обработка данных описана в политике конфиденциальности. Согласие на добровольную передачу геоданных даётся отдельно в приложении.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
title: 'Материалы и открытый исходный код',
|
||||
paragraphs: [
|
||||
'Права на название, оформление, тексты и другие материалы принадлежат их правообладателям. Доступность материалов в интернете не означает разрешение на любое их использование.',
|
||||
'Если исходный код компонента опубликован с открытой лицензией, его использование и распространение регулируются этой лицензией. Условия соответствующих лицензий также распространяются на сторонние библиотеки и данные.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'availability',
|
||||
title: 'Доступность и ответственность',
|
||||
paragraphs: [
|
||||
'Мы стремимся поддерживать работу RideBus, но не можем гарантировать непрерывную доступность сервиса, отсутствие ошибок или точность всех транспортных сведений. Перерывы возможны из-за обновлений, технических работ и сбоев внешних поставщиков.',
|
||||
'Решения о поездке вы принимаете самостоятельно. Ответственность сторон определяется применимым законодательством; эти условия не ограничивают права пользователя и ответственность, которые нельзя ограничить по закону.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
title: 'Изменения и обратная связь',
|
||||
paragraphs: [
|
||||
'Функции сервиса и эти условия могут изменяться по мере развития проекта. Актуальная редакция условий публикуется на этой странице с датой обновления.',
|
||||
'По вопросам работы RideBus, этих условий и обнаруженных ошибок свяжитесь с разработчиком по адресу ниже.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
label: 'The details that matter',
|
||||
read: 'Read document',
|
||||
home: 'Back to home',
|
||||
updated: 'Updated October 1, 2026',
|
||||
contents: 'In this document',
|
||||
contactTitle: 'Have a question?',
|
||||
contactDescription:
|
||||
'Contact the developer about your data, the app or these documents.',
|
||||
documents: {
|
||||
privacy: {
|
||||
title: 'Privacy policy',
|
||||
description:
|
||||
'What data RideBus needs, how it is used and how to manage your permissions.',
|
||||
introduction:
|
||||
'This policy describes data processing when you use the RideBus website and app. Available features and the data involved depend on your app version and the features you enable.',
|
||||
sections: [
|
||||
{
|
||||
id: 'website',
|
||||
title: 'Website and support requests',
|
||||
paragraphs: [
|
||||
'You do not need an account to view the landing page. The website has no personal data collection forms, advertising trackers or integrated visitor analytics.',
|
||||
'When you load a page, the server and delivery infrastructure receive technical request information, including your IP address, the page address and browser information. This is needed to deliver content and may appear in infrastructure logs.',
|
||||
'If you contact the developer by email or Telegram, the contact details and message you provide are used to respond. Please only include information needed to resolve your question.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
title: 'App data',
|
||||
paragraphs: [
|
||||
'RideBus processes your selected city, preferences and information needed for requested features such as viewing schedules, finding stops, planning routes and saving favourites. Some preferences and cached schedules are stored on your device.',
|
||||
'Requests to schedule and map servers include request parameters and technical connection information. Depending on your app version, diagnostic services may receive error reports, app version, device information and performance data to help fix failures.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
title: 'Location and permissions',
|
||||
paragraphs: [
|
||||
'Location access requires system permission and is used for nearby features, such as finding nearby stops and showing your position on a map. When you request directions, coordinates of selected points may be sent to the routing service.',
|
||||
'You can revoke permission in your device settings. Manually selecting a city or stop and viewing available schedules do not require location permission. Notification permission can also be managed in device settings.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'passenger-mode',
|
||||
title: 'Optional passenger mode',
|
||||
paragraphs: [
|
||||
'In versions with passenger mode, location sharing requires a separate action by the user. Granting location permission alone does not enable this mode.',
|
||||
'While the mode is enabled and the app is in the foreground, coordinates, accuracy, speed, heading, observation time, the selected city and route, and a random session identifier may be sent to the RideBus server. This information is used to estimate public transport movement. The session identifier is not your name, phone number or email address.',
|
||||
'Sending stops when you disable the mode, revoke permission or move the app to the background. Disabling the mode stops new transmissions and does not itself delete information already sent.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'providers',
|
||||
title: 'Third-party services',
|
||||
paragraphs: [
|
||||
'Some features may require map, routing, authentication, notification and diagnostic services, including Google Firebase. These providers process the data they need under their own terms and privacy policies.',
|
||||
'Links to Google Play, GitHub and Telegram lead to external websites. Once you follow a link, that service’s rules apply. Please review them before providing data.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'control',
|
||||
title: 'Managing your data',
|
||||
paragraphs: [
|
||||
'You can change device permissions, disable optional features and clear local app data using your operating system. Clearing local data does not automatically remove data already sent to servers or third-party services.',
|
||||
'Contact the developer at the address below about access, correction or deletion of transmitted data. Specify the feature and app version you used; do not send passwords or access codes. Whether a request can be fulfilled depends on identifying which data relates to you and on applicable requirements.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
title: 'Policy updates',
|
||||
paragraphs: [
|
||||
'The current policy is published on this page with its update date. It may be revised when features or data processing change. For features requiring separate consent, review the information in the app before enabling them.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
terms: {
|
||||
title: 'Terms of use',
|
||||
description:
|
||||
'How to use RideBus, where schedules come from and what to consider when planning a trip.',
|
||||
introduction:
|
||||
'These terms apply to the RideBus website and app, a project for viewing public transport information. When using the service, please review its features, limitations and privacy policy.',
|
||||
sections: [
|
||||
{
|
||||
id: 'service',
|
||||
title: 'About the service',
|
||||
paragraphs: [
|
||||
'RideBus helps you view schedules, stops and routes in supported cities. Features depend on the app version and available data. The service is free; some features require internet access, charged according to your provider’s plan.',
|
||||
'RideBus is an information project. The developer is not a transport operator, does not sell tickets and does not control vehicle operations.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
title: 'Schedules and trip planning',
|
||||
paragraphs: [
|
||||
'Schedules and other transport information come from available sources and updates may be delayed. Routes may change, trips may be cancelled, data may contain errors and actual arrival times may differ from those shown.',
|
||||
'Use RideBus information as a guide. If timing is critical, check with the transport operator or at the stop and allow time for possible changes. You can report data errors to the developer.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'responsible-use',
|
||||
title: 'Responsible use',
|
||||
paragraphs: [
|
||||
'Use the service lawfully and respect other people’s rights. Do not interfere with servers, attempt to access other people’s data without authorisation or create traffic that disrupts other users.',
|
||||
'If you use transport data sharing features, provide accurate information. Do not falsify coordinates or use these features to monitor other people without a lawful basis.',
|
||||
'Follow traffic rules and only use the app when it is safe to do so.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
title: 'Permissions and privacy',
|
||||
paragraphs: [
|
||||
'Some features require location or notification permission, or a separate opt-in. You can decline them or revoke permissions in device settings; this will affect the availability of related features.',
|
||||
'Data processing is described in the privacy policy. Consent to optional location sharing is given separately in the app.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
title: 'Materials and open source',
|
||||
paragraphs: [
|
||||
'Rights to the name, design, text and other materials belong to their respective rights holders. Being available online does not grant permission for every use.',
|
||||
'Where a component’s source code is published under an open-source licence, that licence governs its use and distribution. Third-party libraries and data are also subject to their respective licences.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'availability',
|
||||
title: 'Availability and responsibility',
|
||||
paragraphs: [
|
||||
'We aim to keep RideBus working, but cannot guarantee uninterrupted availability, error-free operation or the accuracy of all transport information. Updates, maintenance and external provider failures may cause interruptions.',
|
||||
'You make your own travel decisions. Each party’s liability is governed by applicable law; these terms do not limit user rights or liability that cannot legally be limited.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
title: 'Changes and feedback',
|
||||
paragraphs: [
|
||||
'Service features and these terms may change as the project develops. The current terms are published on this page with their update date.',
|
||||
'Contact the developer at the address below about RideBus, these terms or errors you have found.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
259
modules/legal/legalPage.module.scss
Normal file
259
modules/legal/legalPage.module.scss
Normal file
@@ -0,0 +1,259 @@
|
||||
.header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 220px 48px 64px;
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff00 55%, #fff 98%),
|
||||
linear-gradient(150deg, var(--legal-accent), #fff 75%);
|
||||
}
|
||||
|
||||
.privacy {
|
||||
--legal-accent: #d8e2ff;
|
||||
--legal-ink: #2c4678;
|
||||
}
|
||||
|
||||
.terms {
|
||||
--legal-accent: #f3edf7;
|
||||
--legal-ink: #715573;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
|
||||
h1 {
|
||||
max-width: 1000px;
|
||||
margin-top: 28px;
|
||||
font-size: clamp(2rem, 4.8vw, 4rem);
|
||||
line-height: 1.15;
|
||||
font-weight: 800;
|
||||
color: var(--legal-ink);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 680px;
|
||||
margin-top: 20px;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.7;
|
||||
color: var(--on-background-secondary);
|
||||
}
|
||||
|
||||
time {
|
||||
display: block;
|
||||
margin-top: 24px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--on-background-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: #0058ff;
|
||||
font-weight: 700;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.star {
|
||||
position: absolute;
|
||||
right: -40px;
|
||||
top: 200px;
|
||||
color: var(--legal-ink);
|
||||
opacity: 0.1;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 64px;
|
||||
width: 100%;
|
||||
max-width: 1296px;
|
||||
padding: 0 48px 64px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.contents {
|
||||
background: #f3f3fa;
|
||||
border-radius: 28px;
|
||||
padding: 28px;
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #2c4678;
|
||||
}
|
||||
|
||||
ol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
padding-left: 20px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: #455e91;
|
||||
}
|
||||
}
|
||||
|
||||
.document {
|
||||
min-width: 0;
|
||||
line-height: 1.85;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: baseline;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 800;
|
||||
color: var(--on-background);
|
||||
}
|
||||
|
||||
.introduction {
|
||||
margin-top: 0;
|
||||
font-size: 1.125rem;
|
||||
color: var(--on-background);
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 36px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--divider);
|
||||
scroll-margin-top: 32px;
|
||||
}
|
||||
|
||||
.sectionNumber {
|
||||
flex-shrink: 0;
|
||||
color: #0058ff;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.contact {
|
||||
padding: 32px;
|
||||
border-radius: 28px;
|
||||
margin-top: 40px;
|
||||
background: #d8e2ff;
|
||||
|
||||
h2,
|
||||
p {
|
||||
color: #2c4678;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
color: #0058ff;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.relatedLink {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
margin-top: 24px;
|
||||
color: #0058ff;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.relatedLinkIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main a:focus-visible,
|
||||
.backLink:focus-visible {
|
||||
outline: 2px solid #0058ff;
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) {
|
||||
.header {
|
||||
padding-top: 180px;
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.header {
|
||||
padding: 160px 24px 40px;
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
padding: 0 24px 48px;
|
||||
}
|
||||
|
||||
.contents ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.header {
|
||||
padding: 125px 24px 32px;
|
||||
}
|
||||
|
||||
.headerContent h1 {
|
||||
font-size: clamp(1.5rem, 7.5vw, 1.75rem);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 0 16px 40px;
|
||||
}
|
||||
|
||||
.document {
|
||||
padding: 0 8px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.contents,
|
||||
.contact {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.contents ol {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.star {
|
||||
right: -130px;
|
||||
top: 160px;
|
||||
}
|
||||
}
|
||||
91
modules/legal/legalPage.tsx
Normal file
91
modules/legal/legalPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Footer } from '@/components/footer';
|
||||
import { TopBar } from '@/components/topBar';
|
||||
import { CursorEffect } from '@/components/cursor';
|
||||
import LinkIcon from '@/assets/icons/link.svg';
|
||||
import ThinStarBig from '@/assets/images/thin-star-big.svg';
|
||||
import { Locales } from '@/utils/useLocale';
|
||||
import {
|
||||
legalCopy,
|
||||
legalUpdatedAt,
|
||||
LegalDocumentId,
|
||||
supportEmail,
|
||||
} from './content';
|
||||
import styles from './legalPage.module.scss';
|
||||
|
||||
export function LegalPage({ documentId }: { documentId: LegalDocumentId }) {
|
||||
const { locale } = useRouter();
|
||||
const copy = legalCopy[(locale || 'en') as Locales];
|
||||
const document = copy.documents[documentId];
|
||||
const relatedId = documentId === 'privacy' ? 'terms' : 'privacy';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{document.title} — RideBus</title>
|
||||
<meta name="description" content={document.description} />
|
||||
</Head>
|
||||
<header className={`${styles.header} ${styles[documentId]}`}>
|
||||
<TopBar notHide />
|
||||
<div className={styles.headerContent}>
|
||||
<h1>{document.title}</h1>
|
||||
<p>{document.description}</p>
|
||||
<time dateTime={legalUpdatedAt}>{copy.updated}</time>
|
||||
</div>
|
||||
<span className={styles.star} aria-hidden="true">
|
||||
<ThinStarBig />
|
||||
</span>
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
<nav className={styles.contents} aria-label={copy.contents}>
|
||||
<h2>{copy.contents}</h2>
|
||||
<ol>
|
||||
{document.sections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<a href={`#${section.id}`}>{section.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<article className={styles.document} aria-label={document.title}>
|
||||
<p className={styles.introduction}>{document.introduction}</p>
|
||||
{document.sections.map((section, index) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className={styles.section}
|
||||
>
|
||||
<h2>
|
||||
<span className={styles.sectionNumber}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
{section.title}
|
||||
</h2>
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
<aside className={styles.contact}>
|
||||
<h2>{copy.contactTitle}</h2>
|
||||
<p>{copy.contactDescription}</p>
|
||||
<a href={`mailto:${supportEmail}`}>{supportEmail}</a>
|
||||
</aside>
|
||||
<CursorEffect cursorPadding={6} cursorBorderRadius={16}>
|
||||
<Link href={`/${relatedId}`} className={styles.relatedLink}>
|
||||
{copy.documents[relatedId].title}
|
||||
<LinkIcon
|
||||
className={styles.relatedLinkIcon}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
</Link>
|
||||
</CursorEffect>
|
||||
</article>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user