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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
22
modules/home/changesCard/useActiveAlerts.ts
Normal file
22
modules/home/changesCard/useActiveAlerts.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Locales } from '@/utils/useLocale';
|
||||
import type { Locales } from '@/utils/useLocale';
|
||||
|
||||
export type LegalDocumentId = 'privacy' | 'terms';
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user