feat: add Open Graph image generation and SEO metadata

- Introduced `PageSeo` component for managing page metadata for social sharing.
- Created Open Graph image templates using SVG and static fonts for better visual representation.
- Implemented service alert management with functions to check active alerts and format route numbers.
- Added utility functions for generating and watching alert validity.
- Developed a script to generate Open Graph images dynamically based on page content.
- Included tests for service alert functionalities to ensure correctness and reliability.
This commit is contained in:
2026-10-05 13:02:06 +03:00
parent 6460846134
commit 142ec97959
35 changed files with 8226 additions and 4690 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
# next.js # next.js
/.next/ /.next/
/out/ /out/
/public/images/og/
# production # production
/dist /dist

View File

@@ -1,21 +1,31 @@
# stage 1 as builder
FROM node:24-bookworm-slim AS builder FROM node:24-bookworm-slim AS builder
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Copy in the package file as well as other yarn COPY package.json yarn.lock .yarnrc.yml ./
# dependencies in the local directory, assuming the COPY .yarn/releases ./.yarn/releases
# yarn berry release module is inside .yarn/releases
# already
COPY . .
# Install the dependencies and make the folder
RUN corepack enable && yarn install --immutable RUN corepack enable && yarn install --immutable
# Build the project and copy the files COPY . .
RUN yarn build RUN yarn build && mkdir -p public
FROM node:24-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
HOSTNAME=0.0.0.0 \
PORT=3000
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
USER node
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["yarn", "start"] HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/', { signal: AbortSignal.timeout(4000) }).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "server.js"]

View File

@@ -14,8 +14,39 @@ Departure times use `scheduledTime`. Historical delay is removed from the ETA co
The city and stop menus use the landing’s custom menu style. Long catalogues have search and a bounded scroll area. Stops with the same name share one menu entry; direction chips retain their separate stop IDs and use the API’s stop `description`. Countdown labels show hours and minutes. Menus support keyboard navigation and Escape, and interface animations respect reduced-motion preferences. The homepage FAQ explains the timetable and links to the contribution page. The city and stop menus use the landing’s custom menu style. Long catalogues have search and a bounded scroll area. Stops with the same name share one menu entry; direction chips retain their separate stop IDs and use the API’s stop `description`. Countdown labels show hours and minutes. Menus support keyboard navigation and Escape, and interface animations respect reduced-motion preferences. The homepage FAQ explains the timetable and links to the contribution page.
On touchscreens, opening a menu does not automatically focus search or bring up the keyboard. Tapping search keeps the menu open as the visible viewport changes. Keyboard navigation scrolls only the options, and search text is at least 16px to avoid input zoom on iOS.
For Docker Compose, supply `RIDEBUS_API_KEY` and optionally `RIDEBUS_API_BASE_URL` through the shell environment or a local `.env` file. Environment files are excluded from the image build context. Do not commit credentials. For Docker Compose, supply `RIDEBUS_API_KEY` and optionally `RIDEBUS_API_BASE_URL` through the shell environment or a local `.env` file. Environment files are excluded from the image build context. Do not commit credentials.
## Server deployment
Run the landing as a separate Compose project alongside the API and admin panel:
```sh
cp .env.example .env
# Set RIDEBUS_API_KEY in .env without committing the file.
chmod 600 .env
docker compose config --quiet
docker compose up -d --build --wait
docker compose ps
```
The service publishes port 3000 by default. Point the existing reverse proxy for `ridebus.by` at the server or LXC container's IP and port 3000. TLS is handled by that proxy. Compose does not require Traefik or an existing Docker network.
Set `RIDEBUS_PORT` in `.env` to use a different port. Set `RIDEBUS_BIND_ADDRESS` to restrict the listening address; its default is `0.0.0.0` so a reverse proxy in a separate LXC container can reach the site.
The image runs the Next.js standalone server as the `node` user, includes an HTTP health check, and receives the API key only at runtime. Updates use `docker compose up -d --build --wait` from the landing's directory. This only updates the `ridebus-landing` project.
## Open Graph images
`yarn dev` and `yarn build` generate eight 1200 × 630 PNG images before starting Next.js: one for each of the home, contribution, privacy and terms pages in English and Russian. To regenerate only the images, run `yarn generate:og` (or `npm run generate:og`).
Output is written to `public/images/og/{page}-{locale}.png` and is excluded from Git. The Docker build generates and copies it into the standalone image. Each page supplies its own canonical URL, localized Open Graph tags and Twitter large-image card; the image URLs use the production origin `https://ridebus.by`.
Edit the page copy and routes in `lib/social.mjs`, and the layout in `lib/og-template.mjs`. The template reuses the landing's bus logo, stars, rounded cards, blue and purple palette, and Manrope/Caveat typography. Local fonts include Latin and Cyrillic characters, so image generation needs no external requests or browser. Font sources and licenses are in `assets/fonts/og/`.
In-page anchors such as `/#download` use the homepage preview: crawlers do not request the fragment, so separate previews apply to actual page URLs.
## Checks ## Checks
```sh ```sh

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2014 The Caveat Project Authors (https://github.com/googlefonts/caveat)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@@ -0,0 +1,8 @@
# Fonts for Open Graph images
These static TrueType fonts are derived from the variable fonts in the Google Fonts repository. Both families cover Latin and Cyrillic characters and use the SIL Open Font License 1.1; their license texts are included here.
- [Manrope source](https://github.com/google/fonts/tree/main/ofl/manrope): instances at weights 600 (SemiBold) and 800 (ExtraBold).
- [Caveat source](https://github.com/google/fonts/tree/main/ofl/caveat): instance at weight 700 (Bold).
Static instances were prepared with FontTools `varLib.instancer`. FontTools is only needed when replacing the font files, not to run or build the site.

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1136" height="566" viewBox="0 0 1136 566">
<defs>
<radialGradient id="blue" cx="1" cy="0" r="1">
<stop stop-color="#cfddf8" />
<stop offset="1" stop-color="#cfddf8" stop-opacity="0" />
</radialGradient>
<radialGradient id="purple" cx="0" cy="1" r="0.9">
<stop stop-color="#eadcee" />
<stop offset="1" stop-color="#eadcee" stop-opacity="0" />
</radialGradient>
</defs>
<path fill="#f7f9fd" d="M0 0h1136v566H0z" />
<path fill="url(#blue)" d="M0 0h1136v566H0z" />
<path fill="url(#purple)" d="M0 0h1136v566H0z" />
</svg>

After

Width:  |  Height:  |  Size: 615 B

View File

@@ -0,0 +1,69 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { getSocialMetadata, ogImageSize, socialPages } from '@/lib/social.mjs';
export function PageSeo({ page }: { page: keyof typeof socialPages }) {
const { locale } = useRouter();
const metadata = getSocialMetadata(page, locale || 'en');
return (
<Head>
<title>{metadata.title}</title>
<meta
name="description"
content={metadata.description}
key="description"
/>
<link rel="canonical" href={metadata.url} key="canonical" />
<meta property="og:type" content="website" key="og:type" />
<meta property="og:site_name" content="RideBus" key="og:site_name" />
<meta property="og:url" content={metadata.url} key="og:url" />
<meta property="og:title" content={metadata.title} key="og:title" />
<meta
property="og:description"
content={metadata.description}
key="og:description"
/>
<meta property="og:locale" content={metadata.locale} key="og:locale" />
<meta
property="og:locale:alternate"
content={metadata.alternateLocale}
key="og:locale:alternate"
/>
<meta property="og:image" content={metadata.image} key="og:image" />
<meta property="og:image:type" content="image/png" key="og:image:type" />
<meta
property="og:image:width"
content={String(ogImageSize.width)}
key="og:image:width"
/>
<meta
property="og:image:height"
content={String(ogImageSize.height)}
key="og:image:height"
/>
<meta
property="og:image:alt"
content={metadata.heading.join(' ')}
key="og:image:alt"
/>
<meta
name="twitter:card"
content="summary_large_image"
key="twitter:card"
/>
<meta name="twitter:title" content={metadata.title} key="twitter:title" />
<meta
name="twitter:description"
content={metadata.description}
key="twitter:description"
/>
<meta name="twitter:image" content={metadata.image} key="twitter:image" />
<meta
name="twitter:image:alt"
content={metadata.heading.join(' ')}
key="twitter:image:alt"
/>
</Head>
);
}

View File

@@ -1,20 +1,18 @@
version: "3.3" name: ridebus-landing
networks:
web:
external: true
services: services:
ridebus-landing: ridebus-landing:
build: . build: .
restart: unless-stopped image: ridebus-landing:latest
environment: init: true
RIDEBUS_API_BASE_URL: ${RIDEBUS_API_BASE_URL:-https://api.ridebus.by/} restart: unless-stopped
RIDEBUS_API_KEY: ${RIDEBUS_API_KEY:-} ports:
networks: - '${RIDEBUS_BIND_ADDRESS:-0.0.0.0}:${RIDEBUS_PORT:-3000}:3000'
- web environment:
labels: RIDEBUS_API_BASE_URL: ${RIDEBUS_API_BASE_URL:-https://api.ridebus.by/}
- "traefik.enable=true" RIDEBUS_API_KEY: ${RIDEBUS_API_KEY:-}
- "traefik.http.routers.ridebus-landing.rule=Host(`ridebus.by`)" logging:
- "traefik.http.routers.ridebus-landing.entrypoints=web-http,web-https" driver: json-file
- "traefik.http.routers.ridebus-landing.tls=true" options:
- "traefik.http.routers.ridebus-landing.tls.certresolver=letsEncrypt" max-size: '10m'
- "traefik.http.services.ridebus-landing-service.loadbalancer.server.port=3000" max-file: '3'

179
lib/og-template.mjs Normal file
View File

@@ -0,0 +1,179 @@
import { createElement as h } from 'react';
// ImageResponse uses flex and inline styles, independently of browser CSS,
// animation and the landing's Webpack loaders.
const box = (style, ...children) =>
h('div', { style: { display: 'flex', ...style } }, ...children);
const picture = (src, width, height, style = {}) =>
h('img', { src, width, height, style });
export function createOgImage(page, locale, assets) {
const copy = page[locale];
const handwritten = ['schedule', 'community'].includes(page.illustration);
const privacy = page.illustration === 'privacy';
const accent = page.illustration === 'terms' ? '#715573' : '#1d4fac';
const headingSize = locale === 'ru' && privacy ? 58 : 76;
const secondSize = locale === 'ru' && privacy ? 52 : handwritten ? 100 : 76;
const illustrations = {
schedule: assets.logo,
community: assets.love,
privacy: assets.shield,
terms: assets.note,
};
return box(
{
position: 'relative',
width: '100%',
height: '100%',
padding: 32,
color: '#242424',
backgroundColor: '#fff',
fontFamily: 'Manrope',
fontWeight: 600,
},
box(
{
position: 'relative',
width: '100%',
height: '100%',
borderRadius: 36,
overflow: 'hidden',
backgroundColor: '#f5f7fc',
},
picture(assets.background, 1136, 566, {
position: 'absolute',
left: 0,
top: 0,
}),
picture(assets.star6, 350, 365, {
position: 'absolute',
right: -60,
top: 122,
opacity: 0.72,
transform: 'rotate(16deg)',
}),
picture(assets.star5, 170, 170, {
position: 'absolute',
right: 237,
bottom: 21,
opacity: 0.48,
transform: 'rotate(-15deg)',
}),
picture(assets.thinStar, 148, 148, {
position: 'absolute',
left: -71,
bottom: -60,
opacity: 0.32,
transform: 'rotate(10deg)',
}),
box(
{
position: 'relative',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
padding: '38px 44px',
},
box(
{ alignItems: 'center', justifyContent: 'space-between' },
box(
{ alignItems: 'center', gap: 12 },
picture(assets.logo, 48, 48),
h('span', { style: { fontSize: 32, fontWeight: 800 } }, 'ridebus')
),
box(
{
padding: '12px 22px',
borderRadius: 40,
fontSize: 18,
color: accent,
backgroundColor: '#ffffffb3',
},
copy.label
)
),
box(
{ flexDirection: 'column', width: 730, marginTop: 10 },
h(
'div',
{
style: {
fontSize: headingSize,
lineHeight: 1.16,
fontWeight: 800,
letterSpacing: '-2.5px',
},
},
copy.heading[0]
),
h(
'div',
{
style: {
fontFamily: handwritten ? 'Caveat' : 'Manrope',
fontSize: secondSize,
fontWeight: handwritten ? 700 : 800,
lineHeight: handwritten ? 1.1 : 1.2,
letterSpacing: handwritten ? '-1px' : '-2px',
color: handwritten ? '#715573' : accent,
marginTop: handwritten ? -2 : 4,
},
},
copy.heading[1]
),
box(
{
marginTop: 22,
maxWidth: 665,
fontSize: 24,
lineHeight: 1.5,
color: '#526079',
},
copy.caption
)
),
box(
{ alignItems: 'center', justifyContent: 'space-between' },
box(
{ gap: 10 },
...copy.tags.map((tag) =>
box(
{
padding: '10px 18px',
borderRadius: 18,
backgroundColor: '#ffffffb3',
fontSize: 17,
color: '#526079',
},
tag
)
)
),
h('span', { style: { fontSize: 20, color: accent } }, 'ridebus.by')
)
),
box(
{
position: 'absolute',
right: 44,
top: 197,
width: 236,
height: 236,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 60,
backgroundColor: '#ffffffdb',
border: '2px solid #ffffff',
transform: 'rotate(-9deg)',
},
picture(
illustrations[page.illustration],
page.illustration === 'schedule' ? 164 : 126,
page.illustration === 'schedule' ? 164 : 126
)
)
)
);
}

View File

@@ -1,3 +1,5 @@
import { getActiveAlerts } from './serviceAlerts.ts';
export type City = { export type City = {
id: number; id: number;
name: string; name: string;
@@ -7,9 +9,14 @@ export type City = {
}; };
export type ServiceAlert = { export type ServiceAlert = {
id: number; id: number;
cityId: number;
type: 'delay' | 'cancellation' | 'detour' | 'info'; type: 'delay' | 'cancellation' | 'detour' | 'info';
title: string; title: string;
message: string; message: string;
routeNumbers: string[];
routeIds: number[];
isCityWide: boolean;
routeId: number | null;
routeNumber: string | null; routeNumber: string | null;
routeName: string | null; routeName: string | null;
startTime: string; startTime: string;
@@ -119,7 +126,7 @@ function cityAvatar(value: unknown, baseUrl: string): string | undefined {
type CacheEntry = { expiresAt: number; data: unknown }; type CacheEntry = { expiresAt: number; data: unknown };
// This module is only imported at runtime by the API route. UI imports are type-only. // Credentials and the upstream client are only used by the server API route.
export function createRideBusClient( export function createRideBusClient(
options: { options: {
baseUrl?: string; baseUrl?: string;
@@ -176,25 +183,54 @@ export function createRideBusClient(
}); });
} }
if (input.resource === 'alerts') { if (input.resource === 'alerts') {
const cityId = entityId(input.cityId);
const alerts = list(await request('api/v1/alerts/active', input.cityId)) const alerts = list(await request('api/v1/alerts/active', input.cityId))
.map(record) .map(record)
.filter((alert) => alert.is_active !== false) .filter(
(alert) =>
alert.is_active !== false &&
(alert.city_id == null || entityId(alert.city_id) === cityId)
)
.map((alert): ServiceAlert => { .map((alert): ServiceAlert => {
const startTime = date(alert.start_time); const startTime = date(alert.start_time);
const type = alert.alert_type; const type = alert.alert_type;
const routeNumber =
typeof alert.route_number === 'string' && alert.route_number.trim()
? alert.route_number.trim()
: null;
const routeId =
alert.route_id == null ? null : entityId(alert.route_id);
const routeNumbers = [
...new Set(
alert.route_numbers == null
? []
: list(alert.route_numbers).map(text)
),
];
const routeIds = [
...new Set(
alert.route_ids == null ? [] : list(alert.route_ids).map(entityId)
),
];
// Older responses only carry a single route. Check both formats before
// classifying a notice as citywide, including notices with IDs only.
if (!routeNumbers.length && routeNumber)
routeNumbers.push(routeNumber);
if (!routeIds.length && routeId) routeIds.push(routeId);
return { return {
id: entityId(alert.id), id: entityId(alert.id),
cityId,
type: type:
type === 'delay' || type === 'cancellation' || type === 'detour' type === 'delay' || type === 'cancellation' || type === 'detour'
? type ? type
: 'info', : 'info',
title: text(alert.title), title: text(alert.title),
message: text(alert.message), message: text(alert.message),
routeNumber: routeNumbers,
typeof alert.route_number === 'string' && routeIds,
alert.route_number.trim() isCityWide: !routeNumbers.length && !routeIds.length,
? alert.route_number.trim() routeId,
: null, routeNumber,
routeName: routeName:
typeof alert.route_name === 'string' && alert.route_name.trim() typeof alert.route_name === 'string' && alert.route_name.trim()
? alert.route_name.trim() ? alert.route_name.trim()
@@ -282,14 +318,9 @@ export function createRideBusClient(
const current = (data: unknown) => { const current = (data: unknown) => {
if (input.resource !== 'alerts') return data; if (input.resource !== 'alerts') return data;
const result = data as Alerts; const result = data as Alerts;
const timestamp = now();
return { return {
...result, ...result,
alerts: result.alerts.filter( alerts: getActiveAlerts(result.alerts, input.cityId!, now()),
(alert) =>
Date.parse(alert.startTime) <= timestamp &&
(!alert.endTime || Date.parse(alert.endTime) >= timestamp)
),
} satisfies Alerts; } satisfies Alerts;
}; };
const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}`; const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}`;

57
lib/serviceAlerts.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { ServiceAlert } from './ridebus';
export function isAlertActive(alert: ServiceAlert, now: number): boolean {
return (
Date.parse(alert.startTime) <= now &&
(alert.endTime === null || now < Date.parse(alert.endTime))
);
}
export function getActiveAlerts(
alerts: ServiceAlert[],
cityId: number,
now: number
): ServiceAlert[] {
const seen = new Set<number>();
return alerts.filter((alert) => {
if (
alert.cityId !== cityId ||
seen.has(alert.id) ||
!isAlertActive(alert, now)
)
return false;
seen.add(alert.id);
return true;
});
}
export function formatAlertRouteNumbers(alert: ServiceAlert): string {
return alert.routeNumbers.map((number) => `№${number}`).join(' · ');
}
// Recheck the current data at each start/end boundary, independently of polling.
// Long timers are capped because browsers store their delay as a signed int32.
export function watchAlertValidity(
alerts: ServiceAlert[],
onChange: () => void
) {
let timer: ReturnType<typeof setTimeout>;
function schedule() {
const now = Date.now();
const next = alerts
.flatMap((alert) => [alert.startTime, alert.endTime])
.filter((time): time is string => time !== null)
.map(Date.parse)
.filter((time) => time > now)
.reduce((earliest, time) => Math.min(earliest, time), Infinity);
if (Number.isFinite(next))
timer = setTimeout(refresh, Math.min(next - now, 2147483647));
}
function refresh() {
onChange();
schedule();
}
// Also cover a boundary crossed between render and effect subscription.
timer = setTimeout(refresh, 0);
return () => clearTimeout(timer);
}

114
lib/social.mjs Normal file
View File

@@ -0,0 +1,114 @@
import { legalCopy } from '../modules/legal/content.ts';
export const siteUrl = 'https://ridebus.by';
export const socialLocales = ['en', 'ru'];
export const ogImageSize = { width: 1200, height: 630 };
// Shared by the page metadata and the build-time image generator.
export const socialPages = {
home: {
path: '/',
illustration: 'schedule',
en: {
title: 'RideBus',
description:
'RideBus — An app that helps you conveniently view public transportation schedules',
label: 'Public transport, at hand',
heading: ['Public transport', 'made simple'],
caption: 'Schedules, stops and routes. All in one small app.',
tags: ['Schedules', 'Routes', 'Favourites'],
},
ru: {
title: 'RideBus',
description:
'RideBus — Приложение, которое поможет вам удобно смотреть расписание общественного транспорта',
label: 'Транспорт под рукой',
heading: ['Расписание', 'транспорта'],
caption: 'Расписания, остановки и маршруты. В одном приложении.',
tags: ['Расписания', 'Маршруты', 'Избранное'],
},
},
contribute: {
path: '/contribute',
illustration: 'community',
en: {
title: 'RideBus - Contribute',
description:
'Help RideBus grow: add your city, share the app or leave a review.',
label: 'Made with love',
heading: ['Help RideBus', 'grow with you'],
caption: 'Add your city, share the app or leave a review.',
tags: ['Open source', 'Community'],
},
ru: {
title: 'RideBus - Вклад',
description:
'Помогите развитию RideBus: добавьте свой город, расскажите о приложении или оставьте отзыв.',
label: 'Сделано с любовью',
heading: ['Помогите RideBus', 'стать лучше'],
caption:
'Добавьте свой город, расскажите о приложении или оставьте отзыв.',
tags: ['Открытый код', 'Сообщество'],
},
},
privacy: {
path: '/privacy',
illustration: 'privacy',
en: {
title: `${legalCopy.en.documents.privacy.title} — RideBus`,
description: legalCopy.en.documents.privacy.description,
label: legalCopy.en.label,
heading: ['Privacy', 'policy'],
caption: legalCopy.en.documents.privacy.description,
tags: ['Data', 'Permissions'],
},
ru: {
title: `${legalCopy.ru.documents.privacy.title} — RideBus`,
description: legalCopy.ru.documents.privacy.description,
label: legalCopy.ru.label,
heading: ['Политика', 'конфиденциальности'],
caption: legalCopy.ru.documents.privacy.description,
tags: ['Данные', 'Разрешения'],
},
},
terms: {
path: '/terms',
illustration: 'terms',
en: {
title: `${legalCopy.en.documents.terms.title} — RideBus`,
description: legalCopy.en.documents.terms.description,
label: legalCopy.en.label,
heading: ['Terms', 'of use'],
caption: legalCopy.en.documents.terms.description,
tags: ['The service', 'Guidelines'],
},
ru: {
title: `${legalCopy.ru.documents.terms.title} — RideBus`,
description: legalCopy.ru.documents.terms.description,
label: legalCopy.ru.label,
heading: ['Условия', 'использования'],
caption: legalCopy.ru.documents.terms.description,
tags: ['О сервисе', 'Правила'],
},
},
};
/** @param {keyof typeof socialPages} page @param {string} locale */
export function getSocialMetadata(page, locale) {
const language = locale === 'ru' ? 'ru' : 'en';
const definition = socialPages[page];
const localizedPath =
language === 'ru'
? definition.path === '/'
? '/ru'
: `/ru${definition.path}`
: definition.path;
return {
...definition[language],
url: `${siteUrl}${localizedPath}`,
image: `${siteUrl}/images/og/${page}-${language}.png`,
locale: language === 'ru' ? 'ru_RU' : 'en_US',
alternateLocale: language === 'ru' ? 'en_US' : 'ru_RU',
};
}

View File

@@ -83,6 +83,9 @@
} }
.type, .type,
.route { .route {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
padding: 8px 12px; padding: 8px 12px;
border-radius: 40px; border-radius: 40px;
font-size: 0.75rem; font-size: 0.75rem;

View File

@@ -5,13 +5,17 @@ import { Card } from '@/components/card';
import { CursorEffect } from '@/components/cursor'; import { CursorEffect } from '@/components/cursor';
import Star from '@/assets/images/thick-round-star-6.svg'; import Star from '@/assets/images/thick-round-star-6.svg';
import NoteIcon from '@/assets/icons/note.svg'; import NoteIcon from '@/assets/icons/note.svg';
import type { Alerts, City } from '@/lib/ridebus'; import type { Alerts, City, ServiceAlert } from '@/lib/ridebus';
import { formatAlertRouteNumbers } from '@/lib/serviceAlerts';
import useLocale from '@/utils/useLocale'; import useLocale from '@/utils/useLocale';
import { SelectMenu } from '../departuresCard/selectMenu'; import { SelectMenu } from '../departuresCard/selectMenu';
import { useResource } from '../departuresCard/useResource'; import { useResource } from '../departuresCard/useResource';
import { locales } from './locales'; import { locales } from './locales';
import { useActiveAlerts } from './useActiveAlerts';
import styles from './changesCard.module.scss'; import styles from './changesCard.module.scss';
const noAlerts: ServiceAlert[] = [];
export function ChangesCard() { export function ChangesCard() {
const t = useLocale(locales); const t = useLocale(locales);
const { locale } = useRouter(); const { locale } = useRouter();
@@ -35,7 +39,10 @@ export function ChangesCard() {
); );
const failed = cities.status === 'error' || notices.status === 'error'; const failed = cities.status === 'error' || notices.status === 'error';
const loading = cities.status === 'loading' || notices.status === 'loading'; const loading = cities.status === 'loading' || notices.status === 'loading';
const alerts = notices.data?.alerts ?? []; const alerts = useActiveAlerts(
notices.data?.alerts ?? noAlerts,
Number(cityId)
);
const dateFormat = new Intl.DateTimeFormat( const dateFormat = new Intl.DateTimeFormat(
locale === 'ru' ? 'ru-BY' : 'en-GB', locale === 'ru' ? 'ru-BY' : 'en-GB',
{ {
@@ -132,11 +139,13 @@ export function ChangesCard() {
<span className={clsx(styles.type, styles[alert.type])}> <span className={clsx(styles.type, styles[alert.type])}>
{t(alert.type)} {t(alert.type)}
</span> </span>
{alert.routeNumber && ( <span className={styles.route}>
<span className={styles.route}> {alert.isCityWide
{t('route')} №{alert.routeNumber} ? t('citywide')
</span> : alert.routeNumbers.length
)} ? `${t(alert.routeNumbers.length > 1 ? 'routes' : 'route')} ${formatAlertRouteNumbers(alert)}`
: alert.routeName || t('routes')}
</span>
</div> </div>
<h4>{alert.title}</h4> <h4>{alert.title}</h4>
<p className={styles.message}>{alert.message}</p> <p className={styles.message}>{alert.message}</p>

View File

@@ -23,6 +23,8 @@ export const locales: LocalesMap = {
detour: 'Объезд', detour: 'Объезд',
info: 'Информация', info: 'Информация',
route: 'Маршрут', route: 'Маршрут',
routes: 'Маршруты',
citywide: 'Весь город',
published: 'Опубликовано', published: 'Опубликовано',
until: 'Действует до', until: 'Действует до',
updated: 'Обновлено', updated: 'Обновлено',
@@ -51,6 +53,8 @@ export const locales: LocalesMap = {
detour: 'Detour', detour: 'Detour',
info: 'Information', info: 'Information',
route: 'Route', route: 'Route',
routes: 'Routes',
citywide: 'Citywide',
published: 'Published', published: 'Published',
until: 'Valid until', until: 'Valid until',
updated: 'Updated', updated: 'Updated',

View File

@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import type { ServiceAlert } from '@/lib/ridebus';
import { getActiveAlerts, watchAlertValidity } from '@/lib/serviceAlerts';
export function useActiveAlerts(alerts: ServiceAlert[], cityId: number) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const refresh = () => setNow(Date.now());
const refreshVisible = () => {
if (document.visibilityState === 'visible') refresh();
};
const stopWatching = watchAlertValidity(alerts, refresh);
document.addEventListener('visibilitychange', refreshVisible);
window.addEventListener('focus', refresh);
return () => {
stopWatching();
document.removeEventListener('visibilitychange', refreshVisible);
window.removeEventListener('focus', refresh);
};
}, [alerts]);
return getActiveAlerts(alerts, cityId, now);
}

View File

@@ -79,7 +79,7 @@
background: #f3f3fa; background: #f3f3fa;
color: #2c4678; color: #2c4678;
font: inherit; font: inherit;
font-size: 0.9rem; font-size: max(1rem, 16px);
user-select: text; user-select: text;
-webkit-user-select: text; -webkit-user-select: text;
cursor: text; cursor: text;

View File

@@ -1,4 +1,11 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'; import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import clsx from 'clsx'; import clsx from 'clsx';
import { CursorEffect } from '@/components/cursor'; import { CursorEffect } from '@/components/cursor';
@@ -29,6 +36,9 @@ export function SelectMenu({
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const pointerTypeRef = useRef('');
const focusSearchRef = useRef(true);
const [position, setPosition] = useState<{ const [position, setPosition] = useState<{
left: number; left: number;
top?: number; top?: number;
@@ -36,6 +46,7 @@ export function SelectMenu({
width: number; width: number;
maxHeight: number; maxHeight: number;
} | null>(null); } | null>(null);
const isOpen = position !== null;
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [active, setActive] = useState(value); const [active, setActive] = useState(value);
const searchable = options.length > 8; const searchable = options.length > 8;
@@ -54,33 +65,61 @@ export function SelectMenu({
function close(restoreFocus = false) { function close(restoreFocus = false) {
setPosition(null); setPosition(null);
if (restoreFocus) triggerRef.current?.focus(); if (restoreFocus) triggerRef.current?.focus({ preventScroll: true });
} }
function open() { const updatePosition = useCallback(() => {
const rect = triggerRef.current?.getBoundingClientRect(); const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
const below = window.innerHeight - rect.bottom - 20; const viewport = window.visualViewport;
const above = rect.top - 20; const viewportTop = viewport?.offsetTop ?? 0;
const viewportHeight = viewport?.height ?? window.innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const below = Math.max(0, viewportBottom - rect.bottom - 20);
const above = Math.max(0, rect.top - viewportTop - 20);
const upwards = below < 280 && above > below; const upwards = below < 280 && above > below;
const maxHeight = Math.min(340, upwards ? above : below); const maxHeight = Math.min(
setQuery(''); 340,
setActive(value); Math.max(0, viewportHeight - 40),
upwards ? above : below
);
setPosition({ setPosition({
left: rect.left, left: rect.left,
...(upwards ...(upwards
? { bottom: window.innerHeight - rect.top + 8 } ? {
: { top: rect.bottom + 8 }), bottom:
window.innerHeight -
Math.min(
Math.max(viewportTop + maxHeight + 20, rect.top - 8),
viewportBottom - 20
),
}
: {
top: Math.min(
Math.max(viewportTop + 20, rect.bottom + 8),
viewportBottom - maxHeight - 20
),
}),
width: rect.width, width: rect.width,
maxHeight, maxHeight,
}); });
}, []);
function open(focusSearch = true) {
focusSearchRef.current = focusSearch;
setQuery('');
setActive(value);
updatePosition();
} }
useEffect(() => { useEffect(() => {
if (!position) return; if (!isOpen) return;
if (searchable) searchRef.current?.focus(); // A touch opens the list without bringing up the software keyboard.
else const focusTarget =
panelRef.current?.querySelector<HTMLElement>('[role="listbox"]')?.focus(); searchable && focusSearchRef.current
? searchRef.current
: listRef.current;
focusTarget?.focus({ preventScroll: true });
const dismiss = (event: PointerEvent) => { const dismiss = (event: PointerEvent) => {
const target = event.target as Node; const target = event.target as Node;
if ( if (
@@ -89,27 +128,40 @@ export function SelectMenu({
) )
setPosition(null); setPosition(null);
}; };
const dismissOnScroll = (event: Event) => { const repositionOnScroll = (event: Event) => {
if (!panelRef.current?.contains(event.target as Node)) setPosition(null); if (!panelRef.current?.contains(event.target as Node)) updatePosition();
}; };
const dismissOnResize = () => setPosition(null); const viewport = window.visualViewport;
document.addEventListener('pointerdown', dismiss); document.addEventListener('pointerdown', dismiss);
window.addEventListener('scroll', dismissOnScroll, true); window.addEventListener('scroll', repositionOnScroll, true);
window.addEventListener('resize', dismissOnResize); window.addEventListener('resize', updatePosition);
viewport?.addEventListener('resize', updatePosition);
viewport?.addEventListener('scroll', updatePosition);
return () => { return () => {
document.removeEventListener('pointerdown', dismiss); document.removeEventListener('pointerdown', dismiss);
window.removeEventListener('scroll', dismissOnScroll, true); window.removeEventListener('scroll', repositionOnScroll, true);
window.removeEventListener('resize', dismissOnResize); window.removeEventListener('resize', updatePosition);
viewport?.removeEventListener('resize', updatePosition);
viewport?.removeEventListener('scroll', updatePosition);
}; };
}, [position, searchable]); }, [isOpen, searchable, updatePosition]);
useEffect(() => { useEffect(() => {
if (position && activeValue) { const list = listRef.current;
document const option = activeValue
.getElementById(`${id}-option-${encodeURIComponent(activeValue)}`) ? document.getElementById(
?.scrollIntoView({ block: 'nearest' }); `${id}-option-${encodeURIComponent(activeValue)}`
} )
}, [id, position, activeValue]); : null;
if (!isOpen || !list || !option) return;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
// Scroll the options only; scrollIntoView can also move the page on iOS.
if (optionRect.top < listRect.top)
list.scrollTop -= listRect.top - optionRect.top;
else if (optionRect.bottom > listRect.bottom)
list.scrollTop += optionRect.bottom - listRect.bottom;
}, [id, isOpen, activeValue]);
function choose(nextValue: string) { function choose(nextValue: string) {
onChange(nextValue); onChange(nextValue);
@@ -136,8 +188,16 @@ export function SelectMenu({
aria-expanded={Boolean(position)} aria-expanded={Boolean(position)}
aria-controls={position ? `${id}-list` : undefined} aria-controls={position ? `${id}-list` : undefined}
disabled={!options.length} disabled={!options.length}
onClick={() => (position ? close() : open())} onPointerDown={(event) => {
pointerTypeRef.current = event.pointerType;
}}
onClick={() =>
position
? close()
: open(!['touch', 'pen'].includes(pointerTypeRef.current))
}
onKeyDown={(event) => { onKeyDown={(event) => {
pointerTypeRef.current = 'keyboard';
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault(); event.preventDefault();
open(); open();
@@ -167,7 +227,8 @@ export function SelectMenu({
onBlur={(event) => { onBlur={(event) => {
if ( if (
event.relatedTarget && event.relatedTarget &&
!event.currentTarget.contains(event.relatedTarget) !event.currentTarget.contains(event.relatedTarget) &&
!triggerRef.current?.contains(event.relatedTarget)
) )
close(); close();
}} }}
@@ -217,13 +278,14 @@ export function SelectMenu({
<p className={styles.menuLabel}>{placeholder}</p> <p className={styles.menuLabel}>{placeholder}</p>
)} )}
<ul <ul
ref={listRef}
id={`${id}-list`} id={`${id}-list`}
role="listbox" role="listbox"
aria-label={label} aria-label={label}
className={styles.options} className={styles.options}
tabIndex={searchable ? -1 : 0} tabIndex={searchable ? -1 : 0}
aria-activedescendant={ aria-activedescendant={
!searchable && activeValue activeValue
? `${id}-option-${encodeURIComponent(activeValue)}` ? `${id}-option-${encodeURIComponent(activeValue)}`
: undefined : undefined
} }
@@ -244,7 +306,10 @@ export function SelectMenu({
data-cursor-effect data-cursor-effect
data-cursor-padding="2" data-cursor-padding="2"
data-cursor-border-radius="8" data-cursor-border-radius="8"
onPointerMove={() => setActive(option.value)} onPointerMove={(event) => {
if (event.pointerType === 'mouse')
setActive(option.value);
}}
onClick={() => choose(option.value)} onClick={() => choose(option.value)}
> >
<span>{option.label}</span> <span>{option.label}</span>

View File

@@ -1,4 +1,4 @@
import { Locales } from '@/utils/useLocale'; import type { Locales } from '@/utils/useLocale';
export type LegalDocumentId = 'privacy' | 'terms'; export type LegalDocumentId = 'privacy' | 'terms';

View File

@@ -1,4 +1,4 @@
import Head from 'next/head'; import { PageSeo } from '@/components/pageSeo/pageSeo';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Footer } from '@/components/footer'; import { Footer } from '@/components/footer';
@@ -23,10 +23,7 @@ export function LegalPage({ documentId }: { documentId: LegalDocumentId }) {
return ( return (
<> <>
<Head> <PageSeo page={documentId} />
<title>{document.title} — RideBus</title>
<meta name="description" content={document.description} />
</Head>
<header className={`${styles.header} ${styles[documentId]}`}> <header className={`${styles.header} ${styles[documentId]}`}>
<TopBar notHide /> <TopBar notHide />
<div className={styles.headerContent}> <div className={styles.headerContent}>

View File

@@ -3,6 +3,7 @@ const plugins = [];
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = plugins.reduce((acc, next) => next(acc), { const nextConfig = plugins.reduce((acc, next) => next(acc), {
reactStrictMode: true, reactStrictMode: true,
output: 'standalone',
i18n: { i18n: {
locales: ['en', 'ru'], locales: ['en', 'ru'],
defaultLocale: 'en', defaultLocale: 'en',

View File

@@ -3,8 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "npm run generate:og && next dev --webpack",
"build": "next build --webpack", "build": "npm run generate:og && next build --webpack",
"generate:og": "node scripts/generate-og-images.mjs",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",

View File

@@ -1,55 +1,10 @@
import useLocale, { Locales } from '@/utils/useLocale';
import { Html, Head, Main, NextScript, DocumentProps } from 'next/document'; import { Html, Head, Main, NextScript, DocumentProps } from 'next/document';
import { useRouter } from 'next/router';
import { LocalesMap } from '@/utils/useLocale';
export const locales: LocalesMap = {
ru: {
title: 'ridebus - расписание транспорта',
description: 'Приложение, которое поможет вам удобно смотреть расписание общественного транспорта',
},
en: {
title: 'ridebus - public transport schedule',
description: 'An app that helps you conveniently view public transportation schedules',
},
};
export default function Document(props: DocumentProps) { export default function Document(props: DocumentProps) {
const locale = props.locale || 'en';
const t = (key: string) => locales[locale as Locales][key] || key;
return ( return (
<Html> <Html lang={props.locale || 'en'}>
<Head> <Head>
<link rel="shortcut icon" type="image/ico" href="/favicon.ico" /> <link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
{/* Primary Meta Tags */}
<meta name="title" content="ridebus - budget manager" />
<meta
name="description"
content="An app that helps you conveniently view public transportation schedules"
/>
{/* Open Graph */}
<meta property="og:type" content="website" />
<meta property="og:url" content="https://ridebus.by/" />
<meta property="og:title" content={t('title')} />
<meta property="og:description" content={t('description')} />
<meta
property="og:image"
content={`https://ridebus.by/images/share-banner-${locale}.png`}
/>
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://ridebus.by/" />
<meta property="twitter:title" content={t('title')} />
<meta property="twitter:description" content={t('description')} />
<meta
property="twitter:image"
content={`https://ridebus.by/images/share-banner-${locale}.png`}
/>
</Head> </Head>
<body> <body>
<Main /> <Main />

View File

@@ -1,22 +1,21 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { createRideBusClient, parseRequest, RideBusError } from '@/lib/ridebus'; import { createRideBusClient, parseRequest, RideBusError } from '@/lib/ridebus';
const ridebus = createRideBusClient(); export function createDeparturesHandler(ridebus = createRideBusClient()) {
return async function departures(req: NextApiRequest, res: NextApiResponse) {
export default async function departures( res.setHeader('Cache-Control', 'no-store');
req: NextApiRequest, if (req.method !== 'GET') {
res: NextApiResponse res.setHeader('Allow', 'GET');
) { return res.status(405).json({ error: 'method_not_allowed' });
res.setHeader('Cache-Control', 'no-store'); }
if (req.method !== 'GET') { try {
res.setHeader('Allow', 'GET'); return res.status(200).json(await ridebus.get(parseRequest(req.query)));
return res.status(405).json({ error: 'method_not_allowed' }); } catch (error) {
} const status = error instanceof RideBusError ? error.status : 502;
try { // Never forward upstream responses or configuration details to the browser.
return res.status(200).json(await ridebus.get(parseRequest(req.query))); return res.status(status).json({ error: 'departures_unavailable' });
} catch (error) { }
const status = error instanceof RideBusError ? error.status : 502; };
// Never forward upstream responses or configuration details to the browser.
return res.status(status).json({ error: 'departures_unavailable' });
}
} }
export default createDeparturesHandler();

View File

@@ -1,4 +1,4 @@
import Head from 'next/head'; import { PageSeo } from '@/components/pageSeo/pageSeo';
import styles from '@/modules/contribute/contribute.module.scss'; import styles from '@/modules/contribute/contribute.module.scss';
import { GooglePlayButton } from '@/components/googlePlayButton'; import { GooglePlayButton } from '@/components/googlePlayButton';
import { TelegramCardButton } from '@/components/telegramCardButton'; import { TelegramCardButton } from '@/components/telegramCardButton';
@@ -7,7 +7,6 @@ import { Card } from '@/components/card';
import { Footer } from '@/components/footer'; import { Footer } from '@/components/footer';
import clsx from 'clsx'; import clsx from 'clsx';
import useLocale from '@/utils/useLocale'; import useLocale from '@/utils/useLocale';
import { useRouter } from 'next/router';
import { locales } from '@/modules/contribute/locales'; import { locales } from '@/modules/contribute/locales';
import { ShareCard } from '@/modules/contribute/shareCard'; import { ShareCard } from '@/modules/contribute/shareCard';
import { Fragment } from 'react'; import { Fragment } from 'react';
@@ -15,15 +14,11 @@ import { Header } from '@/modules/contribute/header';
import { AddCityCard } from '@/modules/contribute/addCityCard/addCityCard'; import { AddCityCard } from '@/modules/contribute/addCityCard/addCityCard';
export default function ContributePage() { export default function ContributePage() {
const router = useRouter();
const t = useLocale(locales); const t = useLocale(locales);
return ( return (
<Fragment> <Fragment>
<Head> <PageSeo page="contribute" />
<title>{t('pageTitle')}</title>
<meta name="description" content={t('pageDescription')} />
</Head>
<Header /> <Header />
<main className={styles.main}> <main className={styles.main}>
<AddCityCard /> <AddCityCard />

View File

@@ -14,20 +14,13 @@ import { DownloadCard } from '@/modules/home/downloadCard/downloadCard';
import { ReportCard } from '@/modules/home/reportCard/reportCard'; import { ReportCard } from '@/modules/home/reportCard/reportCard';
import { FeaturesCards } from '@/modules/home/featuresCards/featuresCards'; import { FeaturesCards } from '@/modules/home/featuresCards/featuresCards';
import { LegalCards } from '@/modules/home/legalCards/legalCards'; import { LegalCards } from '@/modules/home/legalCards/legalCards';
import { locales } from '@/modules/home/locales'; import { PageSeo } from '@/components/pageSeo/pageSeo';
import useLocale from '@/utils/useLocale';
import Head from 'next/head';
import { Fragment } from 'react'; import { Fragment } from 'react';
export default function HomePage() { export default function HomePage() {
const t = useLocale(locales);
return ( return (
<Fragment> <Fragment>
<Head> <PageSeo page="home" />
<title>{t('pageTitle')}</title>
<meta name="description" content={t('pageDescription')} />
</Head>
<Header /> <Header />
<main className={styles.main}> <main className={styles.main}>
<DownloadCard /> <DownloadCard />

View File

@@ -0,0 +1,65 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { ImageResponse } from 'next/og.js';
import { ogImageSize, socialLocales, socialPages } from '../lib/social.mjs';
import { createOgImage } from '../lib/og-template.mjs';
const projectRoot = new URL('../', import.meta.url);
const outputDirectory = new URL('public/images/og/', projectRoot);
async function svgAsset(path, color) {
const svg = (await readFile(new URL(path, projectRoot), 'utf8'))
.replaceAll('currentColor', color)
.replace(/<svg\b([^>]*)>/, (tag, attributes) =>
/\sfill=/.test(attributes) ? tag : `<svg${attributes} fill="${color}">`
);
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
}
async function font(name, file, weight) {
return {
name,
data: await readFile(new URL(`assets/fonts/og/${file}`, projectRoot)),
weight,
style: 'normal',
};
}
const fonts = await Promise.all([
font('Manrope', 'Manrope-SemiBold.ttf', 600),
font('Manrope', 'Manrope-ExtraBold.ttf', 800),
font('Caveat', 'Caveat-Bold.ttf', 700),
]);
const [logo, star5, star6, thinStar, shield, note, love, background] =
await Promise.all([
readFile(new URL('assets/images/logo-alt.png', projectRoot)).then(
(data) => `data:image/png;base64,${data.toString('base64')}`
),
svgAsset('assets/images/thick-round-star-5.svg', '#9d7ba0'),
svgAsset('assets/images/thick-round-star-6.svg', '#5780cc'),
svgAsset('assets/images/thin-star-big.svg', '#0058ff'),
svgAsset('assets/icons/shield.svg', '#1d4fac'),
svgAsset('assets/icons/note.svg', '#715573'),
svgAsset('assets/icons/love.svg', '#ff003d'),
svgAsset('assets/images/og-background.svg', '#f7f9fd'),
]);
const assets = { logo, star5, star6, thinStar, shield, note, love, background };
await mkdir(outputDirectory, { recursive: true });
for (const [id, page] of Object.entries(socialPages)) {
for (const locale of socialLocales) {
const response = new ImageResponse(createOgImage(page, locale, assets), {
...ogImageSize,
fonts,
});
const png = Buffer.from(await response.arrayBuffer());
const destination = new URL(`${id}-${locale}.png`, outputDirectory);
const temporary = new URL(`${id}-${locale}.png.tmp`, outputDirectory);
await writeFile(temporary, png);
await rename(temporary, destination);
console.log(
`Generated ${fileURLToPath(destination)} (${png.length} bytes)`
);
}
}

View File

@@ -102,6 +102,7 @@ test('loads active alerts for the requested city without exposing notification f
id: 2, id: 2,
alert_type: 'detour', alert_type: 'detour',
route_number: ' 21тк ', route_number: ' 21тк ',
route_id: 19,
route_name: ' Route ', route_name: ' Route ',
start_time: '2026-10-01T12:00:00Z', start_time: '2026-10-01T12:00:00Z',
created_at: '2026-09-30T12:00:00Z', created_at: '2026-09-30T12:00:00Z',
@@ -118,9 +119,14 @@ test('loads active alerts for the requested city without exposing notification f
alerts: [ alerts: [
{ {
id: 2, id: 2,
cityId: 2,
type: 'detour', type: 'detour',
title: 'Notice', title: 'Notice',
message: 'Public message', message: 'Public message',
routeNumbers: ['21тк'],
routeIds: [19],
isCityWide: false,
routeId: 19,
routeNumber: '21тк', routeNumber: '21тк',
routeName: 'Route', routeName: 'Route',
startTime: '2026-10-01T12:00:00.000Z', startTime: '2026-10-01T12:00:00.000Z',
@@ -129,9 +135,14 @@ test('loads active alerts for the requested city without exposing notification f
}, },
{ {
id: 1, id: 1,
cityId: 2,
type: 'info', type: 'info',
title: 'Notice', title: 'Notice',
message: 'Public message', message: 'Public message',
routeNumbers: [],
routeIds: [],
isCityWide: true,
routeId: null,
routeNumber: null, routeNumber: null,
routeName: null, routeName: null,
startTime: '2026-09-25T12:00:00.000Z', startTime: '2026-09-25T12:00:00.000Z',
@@ -170,7 +181,9 @@ test('expires alerts within a cached response and keeps different cities separat
const initial = await Promise.all([client.get(query), client.get(query)]); const initial = await Promise.all([client.get(query), client.get(query)]);
assert.equal(initial[0].alerts.length, 1); assert.equal(initial[0].alerts.length, 1);
assert.equal(calls, 1); assert.equal(calls, 1);
clock += 11000; clock += 9999;
assert.equal((await client.get(query)).alerts.length, 1);
clock += 1;
assert.equal((await client.get(query)).alerts.length, 0); assert.equal((await client.get(query)).alerts.length, 0);
assert.equal(calls, 1); assert.equal(calls, 1);
assert.equal( assert.equal(
@@ -268,7 +281,7 @@ test('shows schedule times, removes historical delay, sorts and limits departure
route_number: '1', route_number: '1',
name: 'Centre', name: 'Centre',
arrivals: [ arrivals: [
{ scheduledTime: '9:05:00', minutesUntil: 7, delay: 2 }, { scheduledTime: '9:05:00', minutesUntil: 7, delay: 7 },
{ scheduledTime: '09:15', minutesUntil: 15 }, { scheduledTime: '09:15', minutesUntil: 15 },
{ scheduledTime: '09:25', minutesUntil: 25 }, { scheduledTime: '09:25', minutesUntil: 25 },
], ],
@@ -292,12 +305,12 @@ test('shows schedule times, removes historical delay, sorts and limits departure
cityId: 1, cityId: 1,
stopId: 5, stopId: 5,
}); });
assert.equal(data.departures.length, 6); assert.equal(data.departures.length, 4);
assert.deepEqual( assert.deepEqual(
data.departures.map((item) => item.minutesUntil), data.departures.map((item) => item.minutesUntil),
[1, 2, 3, 4, 5, 15] [0, 1, 2, 3]
); );
assert.equal(data.departures[4].scheduledTime, '09:05'); assert.equal(data.departures[0].scheduledTime, '09:05');
assert.equal(data.updatedAt, '1970-01-01T00:00:00.000Z'); assert.equal(data.updatedAt, '1970-01-01T00:00:00.000Z');
assert.equal(data.partial, false); assert.equal(data.partial, false);
}); });

View File

@@ -0,0 +1,305 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import ts from 'typescript';
import { createRideBusClient, RideBusError } from '../lib/ridebus.ts';
import {
formatAlertRouteNumbers,
getActiveAlerts,
isAlertActive,
watchAlertValidity,
} from '../lib/serviceAlerts.ts';
const now = Date.parse('2026-10-05T09:00:00Z');
const notice = {
id: 42,
city_id: 1,
alert_type: 'cancellation',
title: 'Изменение рейсов',
message: 'Отменены отправления:\n№2: Вокзал — 12:10.\n№27: Больница — 13:25.',
start_time: '2026-10-05T08:00:00Z',
end_time: '2026-10-05T10:00:00Z',
route_numbers: ['2', '27'],
route_ids: [1, 2, 3, 4],
route_id: null,
route_number: null,
};
const json = (data) => new Response(JSON.stringify(data));
const query = { resource: 'alerts', cityId: 1 };
function clientFor(alerts) {
return createRideBusClient({
apiKey: 'test-only-key',
now: () => now,
fetch: async () => json(alerts),
});
}
test('keeps several numbers and all direction IDs in one notice, preserving cancellation details', async () => {
const { alerts } = await clientFor([notice]).get(query);
assert.equal(alerts.length, 1);
assert.deepEqual(alerts[0].routeNumbers, ['2', '27']);
assert.deepEqual(alerts[0].routeIds, [1, 2, 3, 4]);
assert.equal(alerts[0].cityId, 1);
assert.equal(alerts[0].isCityWide, false);
assert.equal(alerts[0].routeId, null);
assert.equal(alerts[0].routeNumber, null);
assert.equal(alerts[0].message, notice.message);
assert.equal(formatAlertRouteNumbers(alerts[0]), '№2 · №27');
});
test('normalizes old single fields and distinguishes general notices from ID-only and number-only notices', async () => {
const { route_numbers, route_ids, ...old } = notice;
const { alerts } = await clientFor([
{
...old,
id: 1,
route_id: 5,
route_number: ' 21тк ',
route_name: 'Centre',
},
{ ...notice, id: 2, route_numbers: [], route_ids: [] },
{ ...old, id: 3, route_id: 6 },
{ ...notice, id: 4, route_numbers: [], route_ids: [7, 8] },
{ ...notice, id: 5, route_numbers: ['27'], route_ids: [] },
{ ...old, id: 6 },
// Empty new fields cannot erase a still-present old association.
{
...notice,
id: 7,
route_numbers: [],
route_ids: [],
route_id: 9,
route_number: '2',
},
]).get(query);
assert.deepEqual(
alerts.map(({ routeNumbers, routeIds, isCityWide }) => ({
routeNumbers,
routeIds,
isCityWide,
})),
[
{ routeNumbers: ['21тк'], routeIds: [5], isCityWide: false },
{ routeNumbers: [], routeIds: [], isCityWide: true },
{ routeNumbers: [], routeIds: [6], isCityWide: false },
{ routeNumbers: [], routeIds: [7, 8], isCityWide: false },
{ routeNumbers: ['27'], routeIds: [], isCityWide: false },
{ routeNumbers: [], routeIds: [], isCityWide: true },
{ routeNumbers: ['2'], routeIds: [9], isCityWide: false },
]
);
assert.equal(alerts[0].routeName, 'Centre');
});
test('deduplicates repeated numbers, IDs and notice rows without multiplying by direction', async () => {
const duplicate = {
...notice,
route_numbers: ['2', ' 2 ', '27', '27'],
route_ids: [1, 2, 2, 3, 4],
};
const { alerts } = await clientFor([
duplicate,
duplicate,
{ ...notice, id: 43 },
]).get(query);
assert.equal(alerts.length, 2);
assert.deepEqual(
alerts.map((alert) => alert.id),
[42, 43]
);
assert.deepEqual(alerts[0].routeNumbers, ['2', '27']);
assert.deepEqual(alerts[0].routeIds, [1, 2, 3, 4]);
});
test('isolates city caches and ignores another city even when route numbers and notice IDs coincide', async () => {
const calls = [];
const client = createRideBusClient({
apiKey: 'test-only-key',
now: () => now,
fetch: async (url, options) => {
const cityId = Number(options.headers['X-City-Id']);
calls.push(cityId);
return json([
{
...notice,
city_id: cityId,
route_ids: [cityId * 10],
message: `City ${cityId}`,
},
{ ...notice, id: 999, city_id: cityId === 1 ? 2 : 1 },
]);
},
});
const first = await client.get(query);
const second = await client.get({ ...query, cityId: 2 });
const back = await client.get(query);
assert.deepEqual(calls, [1, 2]);
assert.equal(first.alerts.length, 1);
assert.equal(second.alerts.length, 1);
assert.equal(first.alerts[0].message, 'City 1');
assert.equal(second.alerts[0].message, 'City 2');
assert.deepEqual(back, first);
assert.deepEqual(getActiveAlerts(first.alerts, 2, now), []);
assert.deepEqual(getActiveAlerts(second.alerts, 1, now), []);
});
test('uses inclusive start and exclusive end, including future notices inside the server cache', async () => {
let clock = now - 1;
let calls = 0;
const client = createRideBusClient({
apiKey: 'test-only-key',
now: () => clock,
fetch: async () => {
calls++;
return json([
{
...notice,
start_time: new Date(now).toISOString(),
end_time: new Date(now + 1000).toISOString(),
},
]);
},
});
assert.equal((await client.get(query)).alerts.length, 0);
clock = now;
const { alerts } = await client.get(query);
assert.equal(alerts.length, 1);
assert.equal(isAlertActive(alerts[0], now - 1), false);
assert.equal(isAlertActive(alerts[0], now), true);
assert.equal(isAlertActive(alerts[0], now + 999), true);
assert.equal(isAlertActive(alerts[0], now + 1000), false);
clock += 999;
assert.equal((await client.get(query)).alerts.length, 1);
clock++;
assert.equal((await client.get(query)).alerts.length, 0);
assert.equal(calls, 1);
});
test('removes expired notices on the open page without another request and cancels timers on city change', async (t) => {
const { alerts } = await clientFor([notice]).get(query);
const alert = {
...alerts[0],
startTime: new Date(now + 100).toISOString(),
endTime: new Date(now + 1000).toISOString(),
};
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now });
const visible = [];
const stop = watchAlertValidity([alert], () => {
visible.push(getActiveAlerts([alert], 1, Date.now()).length);
});
t.mock.timers.tick(0);
assert.deepEqual(visible, [0]);
t.mock.timers.tick(100);
assert.deepEqual(visible, [0, 1]);
t.mock.timers.tick(899);
assert.deepEqual(visible, [0, 1]);
t.mock.timers.tick(1);
assert.deepEqual(visible, [0, 1, 0]);
stop();
const changed = t.mock.fn();
const cancel = watchAlertValidity(
[{ ...alert, endTime: new Date(now + 5000).toISOString() }],
changed
);
t.mock.timers.tick(0);
assert.equal(changed.mock.callCount(), 1);
cancel();
t.mock.timers.tick(30000);
assert.equal(changed.mock.callCount(), 1);
});
test('reschedules capped long timers instead of expiring distant notices immediately', async (t) => {
const { alerts } = await clientFor([notice]).get(query);
const duration = 2147483647 + 1000;
const alert = {
...alerts[0],
endTime: new Date(now + duration).toISOString(),
};
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now });
const visible = [];
const stop = watchAlertValidity([alert], () =>
visible.push(isAlertActive(alert, Date.now()))
);
t.mock.timers.tick(0);
t.mock.timers.tick(2147483647);
assert.deepEqual(visible, [true, true]);
t.mock.timers.tick(1000);
assert.deepEqual(visible, [true, true, false]);
stop();
});
// Exercise the real Next handler with Node's test runner; erase Next's type-only
// imports and resolve the project alias without adding another test framework.
const source = await readFile(
new URL('../pages/api/departures.ts', import.meta.url),
'utf8'
);
const compiled = ts
.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
})
.outputText.replace(
'@/lib/ridebus',
new URL('../lib/ridebus.ts', import.meta.url).href
);
const { createDeparturesHandler } = await import(
`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`
);
function apiResponse() {
return {
headers: {},
statusCode: 0,
body: null,
setHeader(name, value) {
this.headers[name] = value;
},
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
},
};
}
test('API proxy passes normalized lists and uses no-store without leaking server credentials', async () => {
const res = apiResponse();
await createDeparturesHandler(clientFor([notice]))(
{ method: 'GET', query: { resource: 'alerts', cityId: '1' } },
res
);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Cache-Control'], 'no-store');
assert.deepEqual(res.body.alerts[0].routeNumbers, ['2', '27']);
assert.deepEqual(res.body.alerts[0].routeIds, [1, 2, 3, 4]);
assert.equal(res.body.alerts.length, 1);
assert.equal(JSON.stringify(res.body).includes('test-only-key'), false);
});
test('API proxy rejects missing cities and non-GET methods and sanitizes upstream errors', async () => {
const handler = createDeparturesHandler({
get: async () => {
throw new RideBusError(502);
},
});
const missingCity = apiResponse();
await handler({ method: 'GET', query: { resource: 'alerts' } }, missingCity);
assert.equal(missingCity.statusCode, 400);
const wrongMethod = apiResponse();
await handler({ method: 'POST', query: {} }, wrongMethod);
assert.equal(wrongMethod.statusCode, 405);
assert.equal(wrongMethod.headers.Allow, 'GET');
const failed = apiResponse();
await handler(
{ method: 'GET', query: { resource: 'alerts', cityId: '1' } },
failed
);
assert.equal(failed.statusCode, 502);
assert.deepEqual(failed.body, { error: 'departures_unavailable' });
});

View File

@@ -7,6 +7,7 @@
"esnext" "esnext"
], ],
"allowJs": true, "allowJs": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,

11433
yarn.lock

File diff suppressed because it is too large Load Diff