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

View File

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

View File

@@ -5,13 +5,17 @@ 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 type { Alerts, City, ServiceAlert } from '@/lib/ridebus';
import { formatAlertRouteNumbers } from '@/lib/serviceAlerts';
import useLocale from '@/utils/useLocale';
import { SelectMenu } from '../departuresCard/selectMenu';
import { useResource } from '../departuresCard/useResource';
import { locales } from './locales';
import { useActiveAlerts } from './useActiveAlerts';
import styles from './changesCard.module.scss';
const noAlerts: ServiceAlert[] = [];
export function ChangesCard() {
const t = useLocale(locales);
const { locale } = useRouter();
@@ -35,7 +39,10 @@ export function ChangesCard() {
);
const failed = cities.status === 'error' || notices.status === 'error';
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(
locale === 'ru' ? 'ru-BY' : 'en-GB',
{
@@ -132,11 +139,13 @@ export function ChangesCard() {
<span className={clsx(styles.type, styles[alert.type])}>
{t(alert.type)}
</span>
{alert.routeNumber && (
<span className={styles.route}>
{t('route')} №{alert.routeNumber}
</span>
)}
<span className={styles.route}>
{alert.isCityWide
? t('citywide')
: alert.routeNumbers.length
? `${t(alert.routeNumbers.length > 1 ? 'routes' : 'route')} ${formatAlertRouteNumbers(alert)}`
: alert.routeName || t('routes')}
</span>
</div>
<h4>{alert.title}</h4>
<p className={styles.message}>{alert.message}</p>

View File

@@ -23,6 +23,8 @@ export const locales: LocalesMap = {
detour: 'Объезд',
info: 'Информация',
route: 'Маршрут',
routes: 'Маршруты',
citywide: 'Весь город',
published: 'Опубликовано',
until: 'Действует до',
updated: 'Обновлено',
@@ -51,6 +53,8 @@ export const locales: LocalesMap = {
detour: 'Detour',
info: 'Information',
route: 'Route',
routes: 'Routes',
citywide: 'Citywide',
published: 'Published',
until: 'Valid until',
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;
color: #2c4678;
font: inherit;
font-size: 0.9rem;
font-size: max(1rem, 16px);
user-select: text;
-webkit-user-select: 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 clsx from 'clsx';
import { CursorEffect } from '@/components/cursor';
@@ -29,6 +36,9 @@ export function SelectMenu({
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const pointerTypeRef = useRef('');
const focusSearchRef = useRef(true);
const [position, setPosition] = useState<{
left: number;
top?: number;
@@ -36,6 +46,7 @@ export function SelectMenu({
width: number;
maxHeight: number;
} | null>(null);
const isOpen = position !== null;
const [query, setQuery] = useState('');
const [active, setActive] = useState(value);
const searchable = options.length > 8;
@@ -54,33 +65,61 @@ export function SelectMenu({
function close(restoreFocus = false) {
setPosition(null);
if (restoreFocus) triggerRef.current?.focus();
if (restoreFocus) triggerRef.current?.focus({ preventScroll: true });
}
function open() {
const updatePosition = useCallback(() => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
const below = window.innerHeight - rect.bottom - 20;
const above = rect.top - 20;
const viewport = window.visualViewport;
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 maxHeight = Math.min(340, upwards ? above : below);
setQuery('');
setActive(value);
const maxHeight = Math.min(
340,
Math.max(0, viewportHeight - 40),
upwards ? above : below
);
setPosition({
left: rect.left,
...(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,
maxHeight,
});
}, []);
function open(focusSearch = true) {
focusSearchRef.current = focusSearch;
setQuery('');
setActive(value);
updatePosition();
}
useEffect(() => {
if (!position) return;
if (searchable) searchRef.current?.focus();
else
panelRef.current?.querySelector<HTMLElement>('[role="listbox"]')?.focus();
if (!isOpen) return;
// A touch opens the list without bringing up the software keyboard.
const focusTarget =
searchable && focusSearchRef.current
? searchRef.current
: listRef.current;
focusTarget?.focus({ preventScroll: true });
const dismiss = (event: PointerEvent) => {
const target = event.target as Node;
if (
@@ -89,27 +128,40 @@ export function SelectMenu({
)
setPosition(null);
};
const dismissOnScroll = (event: Event) => {
if (!panelRef.current?.contains(event.target as Node)) setPosition(null);
const repositionOnScroll = (event: Event) => {
if (!panelRef.current?.contains(event.target as Node)) updatePosition();
};
const dismissOnResize = () => setPosition(null);
const viewport = window.visualViewport;
document.addEventListener('pointerdown', dismiss);
window.addEventListener('scroll', dismissOnScroll, true);
window.addEventListener('resize', dismissOnResize);
window.addEventListener('scroll', repositionOnScroll, true);
window.addEventListener('resize', updatePosition);
viewport?.addEventListener('resize', updatePosition);
viewport?.addEventListener('scroll', updatePosition);
return () => {
document.removeEventListener('pointerdown', dismiss);
window.removeEventListener('scroll', dismissOnScroll, true);
window.removeEventListener('resize', dismissOnResize);
window.removeEventListener('scroll', repositionOnScroll, true);
window.removeEventListener('resize', updatePosition);
viewport?.removeEventListener('resize', updatePosition);
viewport?.removeEventListener('scroll', updatePosition);
};
}, [position, searchable]);
}, [isOpen, searchable, updatePosition]);
useEffect(() => {
if (position && activeValue) {
document
.getElementById(`${id}-option-${encodeURIComponent(activeValue)}`)
?.scrollIntoView({ block: 'nearest' });
}
}, [id, position, activeValue]);
const list = listRef.current;
const option = activeValue
? document.getElementById(
`${id}-option-${encodeURIComponent(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) {
onChange(nextValue);
@@ -136,8 +188,16 @@ export function SelectMenu({
aria-expanded={Boolean(position)}
aria-controls={position ? `${id}-list` : undefined}
disabled={!options.length}
onClick={() => (position ? close() : open())}
onPointerDown={(event) => {
pointerTypeRef.current = event.pointerType;
}}
onClick={() =>
position
? close()
: open(!['touch', 'pen'].includes(pointerTypeRef.current))
}
onKeyDown={(event) => {
pointerTypeRef.current = 'keyboard';
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
open();
@@ -167,7 +227,8 @@ export function SelectMenu({
onBlur={(event) => {
if (
event.relatedTarget &&
!event.currentTarget.contains(event.relatedTarget)
!event.currentTarget.contains(event.relatedTarget) &&
!triggerRef.current?.contains(event.relatedTarget)
)
close();
}}
@@ -217,13 +278,14 @@ export function SelectMenu({
<p className={styles.menuLabel}>{placeholder}</p>
)}
<ul
ref={listRef}
id={`${id}-list`}
role="listbox"
aria-label={label}
className={styles.options}
tabIndex={searchable ? -1 : 0}
aria-activedescendant={
!searchable && activeValue
activeValue
? `${id}-option-${encodeURIComponent(activeValue)}`
: undefined
}
@@ -244,7 +306,10 @@ export function SelectMenu({
data-cursor-effect
data-cursor-padding="2"
data-cursor-border-radius="8"
onPointerMove={() => setActive(option.value)}
onPointerMove={(event) => {
if (event.pointerType === 'mouse')
setActive(option.value);
}}
onClick={() => choose(option.value)}
>
<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';

View File

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