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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@
|
|||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
/public/images/og/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
34
Dockerfile
34
Dockerfile
@@ -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"]
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -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
|
||||||
|
|||||||
BIN
assets/fonts/og/Caveat-Bold.ttf
Normal file
BIN
assets/fonts/og/Caveat-Bold.ttf
Normal file
Binary file not shown.
93
assets/fonts/og/Caveat-OFL.txt
Normal file
93
assets/fonts/og/Caveat-OFL.txt
Normal 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.
|
||||||
BIN
assets/fonts/og/Manrope-ExtraBold.ttf
Normal file
BIN
assets/fonts/og/Manrope-ExtraBold.ttf
Normal file
Binary file not shown.
93
assets/fonts/og/Manrope-OFL.txt
Normal file
93
assets/fonts/og/Manrope-OFL.txt
Normal 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.
|
||||||
BIN
assets/fonts/og/Manrope-SemiBold.ttf
Normal file
BIN
assets/fonts/og/Manrope-SemiBold.ttf
Normal file
Binary file not shown.
8
assets/fonts/og/README.md
Normal file
8
assets/fonts/og/README.md
Normal 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.
|
||||||
15
assets/images/og-background.svg
Normal file
15
assets/images/og-background.svg
Normal 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 |
69
components/pageSeo/pageSeo.tsx
Normal file
69
components/pageSeo/pageSeo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
179
lib/og-template.mjs
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
57
lib/serviceAlerts.ts
Normal 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
114
lib/social.mjs
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 />
|
||||||
@@ -57,4 +12,4 @@ export default function Document(props: DocumentProps) {
|
|||||||
</body>
|
</body>
|
||||||
</Html>
|
</Html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
65
scripts/generate-og-images.mjs
Normal file
65
scripts/generate-og-images.mjs
Normal 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)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
305
tests/service-alerts.test.mjs
Normal file
305
tests/service-alerts.test.mjs
Normal 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' });
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user