Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da233c6d08 | |||
| 02ee9b5cfd | |||
| 5e33debfb4 | |||
| 5c309004f0 | |||
| a346aa071e | |||
| 6e947f2aa9 | |||
| 1158673fdf | |||
| ca9978f003 | |||
| 19dbdc5127 | |||
| 99562c2218 | |||
| 2b47ca799f | |||
| 7fd789e06a | |||
| 16725f013f | |||
| 1832b80374 | |||
| ee08795811 | |||
| 80087ded8b | |||
| 9345eb162a | |||
| 5c3f49204c | |||
| f1ab656305 | |||
| cac3846657 | |||
| f6943a53ce | |||
| 3275203303 | |||
| c96912fbb0 | |||
| e0b69bf35f | |||
| 1b183c334d | |||
| 43ef9bb710 | |||
| 17476e4a89 | |||
| 0284372385 | |||
| 147183ef90 | |||
| 6cf57596bf | |||
| b3d242e7e0 | |||
| 4f5786ee30 | |||
| 30f1ee7873 | |||
| 7fd8faf202 | |||
| 530115d8d1 | |||
| 6aa76f5f4d | |||
| 184e280b45 | |||
| 1b5cf23400 | |||
| 32538635d6 | |||
| fb6c778d8b | |||
| eadd42cacc | |||
| 6a3c8f2558 | |||
| f0244d20b8 | |||
| bc820ae72a | |||
| 53b48a62dd | |||
| 1d32c5d0c6 | |||
| ceab49f34f | |||
| 3411d71b00 | |||
| 65e776273d | |||
| 385a6e55ee | |||
| eda44d4218 | |||
| 19ae50af80 | |||
| cb48dcc706 | |||
| 421a960a82 | |||
| 0960b739dd | |||
| f08ed8b206 | |||
| ef8bda8683 | |||
| 5589fd189c | |||
| 4afecd23a6 | |||
| 04a71192fa | |||
| dfea0aec73 | |||
| a02befb78d |
-1
@@ -150,7 +150,6 @@ export class FlightsMapBodyComponent implements OnInit, AfterViewInit {
|
||||
{
|
||||
icon: markerBlueSmall,
|
||||
title: city.code,
|
||||
autoPanOnFocus: false,
|
||||
}
|
||||
)
|
||||
.on('click', ()=> this.handleMarkerClick(city.code))
|
||||
|
||||
+6
-1
@@ -35,6 +35,10 @@ ARG MAP_TILE_URL=https://flights.test.aeroflot.ru/map/api/tile/{z}/{x}/{y}.jpeg
|
||||
ENV MAP_TILE_URL=${MAP_TILE_URL}
|
||||
ARG API_BASE_URL=https://flights.test.aeroflot.ru/api
|
||||
ENV API_BASE_URL=${API_BASE_URL}
|
||||
ARG AEROFLOT_SHELL_LOADER_PROXY=1
|
||||
ENV AEROFLOT_SHELL_LOADER_PROXY=${AEROFLOT_SHELL_LOADER_PROXY}
|
||||
ARG AEROFLOT_SHELL_REFERRER_ORIGIN=https://flights.test.aeroflot.ru
|
||||
ENV AEROFLOT_SHELL_REFERRER_ORIGIN=${AEROFLOT_SHELL_REFERRER_ORIGIN}
|
||||
|
||||
RUN pnpm build:standalone
|
||||
|
||||
@@ -51,7 +55,8 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist/standalone/ ./dist/standalone/
|
||||
COPY --from=build /app/src/ ./src/
|
||||
COPY package.json modern.config.ts module-federation.config.ts ./
|
||||
COPY scripts/standalone-server.mjs scripts/aeroflot-url-rewrite.mjs ./scripts/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["pnpm", "exec", "modern", "serve"]
|
||||
CMD ["node", "scripts/standalone-server.mjs"]
|
||||
|
||||
@@ -37,6 +37,8 @@ PNPM := pnpm
|
||||
PID_FILE := .dev.pid
|
||||
LOG_FILE := .dev.log
|
||||
API_TARGET ?= https://flights.test.aeroflot.ru
|
||||
TRACKER_TARGET ?= https://platform.test.aeroflot.ru
|
||||
SIGNALR_HUB_URL ?= http://localhost:8080/tracker/hub
|
||||
|
||||
# Development
|
||||
dev:
|
||||
@@ -47,10 +49,12 @@ dev:
|
||||
|
||||
dev-full:
|
||||
@echo "Starting dev server with API proxy in background..."
|
||||
@API_TARGET="$(API_TARGET)" nohup $(PNPM) dev:full > $(LOG_FILE) 2>&1 & echo $$! > $(PID_FILE)
|
||||
@API_TARGET="$(API_TARGET)" TRACKER_TARGET="$(TRACKER_TARGET)" SIGNALR_HUB_URL="$(SIGNALR_HUB_URL)" nohup node scripts/dev-server.mjs > $(LOG_FILE) 2>&1 & echo $$! > $(PID_FILE)
|
||||
@echo "Dev server started (PID: $$(cat $(PID_FILE)))"
|
||||
@echo " App & API: http://localhost:8080"
|
||||
@echo " API target: $(API_TARGET)"
|
||||
@echo " Tracker target: $(TRACKER_TARGET)"
|
||||
@echo " SignalR hub: $(SIGNALR_HUB_URL)"
|
||||
@echo "View logs: make logs"
|
||||
|
||||
stop:
|
||||
|
||||
@@ -28,6 +28,12 @@ function build {
|
||||
if [ -n "${API_BASE_URL:-}" ]; then
|
||||
docker_args+=(--build-arg "API_BASE_URL=${API_BASE_URL}")
|
||||
fi
|
||||
if [ -n "${AEROFLOT_SHELL_LOADER_PROXY:-}" ]; then
|
||||
docker_args+=(--build-arg "AEROFLOT_SHELL_LOADER_PROXY=${AEROFLOT_SHELL_LOADER_PROXY}")
|
||||
fi
|
||||
if [ -n "${AEROFLOT_SHELL_REFERRER_ORIGIN:-}" ]; then
|
||||
docker_args+=(--build-arg "AEROFLOT_SHELL_REFERRER_ORIGIN=${AEROFLOT_SHELL_REFERRER_ORIGIN}")
|
||||
fi
|
||||
docker build -t "aeroflot.$K8NAMESPACE/$1:v$VERSION.$BUILD_NUMBER" \
|
||||
"${docker_args[@]}" \
|
||||
-f "$2/Dockerfile" "$2"
|
||||
@@ -35,4 +41,4 @@ function build {
|
||||
|
||||
build flights-ui "../Aeroflot.Flights.Front"
|
||||
|
||||
exit 0
|
||||
exit 0
|
||||
|
||||
@@ -43,4 +43,8 @@ spec:
|
||||
value: "https://flights.test.aeroflot.ru/map/api/tile/{z}/{x}/{y}.jpeg"
|
||||
- name: API_BASE_URL
|
||||
value: "https://flights.test.aeroflot.ru/api"
|
||||
- name: AEROFLOT_SHELL_LOADER_PROXY
|
||||
value: "1"
|
||||
- name: AEROFLOT_SHELL_REFERRER_ORIGIN
|
||||
value: "https://flights.test.aeroflot.ru"
|
||||
$IMAGE_PULL_SECRETS
|
||||
|
||||
+137
-2
@@ -3,6 +3,10 @@ import { moduleFederationPlugin } from "@module-federation/modern-js";
|
||||
|
||||
const buildTarget = process.env["BUILD_TARGET"];
|
||||
const isRemote = buildTarget === "remote";
|
||||
const plugins = [
|
||||
appTools({ bundler: "rspack" }),
|
||||
...(isRemote ? [moduleFederationPlugin()] : []),
|
||||
];
|
||||
|
||||
// Runtime env values that must reach the client bundle. Rspack resolves
|
||||
// `process.env` at BUILD time to an empty-ish polyfill in the browser, so
|
||||
@@ -18,7 +22,13 @@ const isRemote = buildTarget === "remote";
|
||||
// '{z}/{x}/{y}', so we serialize the whole payload to base64 and decode
|
||||
// it in the browser at runtime. Base64 output is A-Z/a-z/0-9/+/=/ — no
|
||||
// braces for the template engine to grab.
|
||||
const PUBLIC_ENV_KEYS = ["MAP_TILE_URL", "API_BASE_URL"] as const;
|
||||
const PUBLIC_ENV_KEYS = [
|
||||
"MAP_TILE_URL",
|
||||
"API_BASE_URL",
|
||||
"SIGNALR_HUB_URL",
|
||||
"REFRESH_PAUSE_MIN",
|
||||
"REFRESH_STOP_MIN",
|
||||
] as const;
|
||||
const PUBLIC_ENV: Record<string, string> = {};
|
||||
for (const k of PUBLIC_ENV_KEYS) {
|
||||
const v = process.env[k];
|
||||
@@ -33,9 +43,130 @@ const publicEnvB64 = Buffer.from(
|
||||
// Rspack HTML plugin. `Object.create(null)` replaces an empty `{}`.
|
||||
const PUBLIC_ENV_SCRIPT =
|
||||
`window.__ENV__=Object.assign(window.__ENV__||Object.create(null),JSON.parse(atob("${publicEnvB64}")));`;
|
||||
const SUPPRESS_AEROFLOT_LOADER_WARNINGS_SCRIPT = `
|
||||
window.__AFL_SHELL_CONSOLE_WARN__=window.__AFL_SHELL_CONSOLE_WARN__||console.warn.bind(console);
|
||||
window.__AFL_SHELL_CONSOLE_ERROR__=window.__AFL_SHELL_CONSOLE_ERROR__||console.error.bind(console);
|
||||
window.__AFL_SHELL_SHOULD_SUPPRESS__=function(args){
|
||||
return Array.prototype.some.call(args,function(value){
|
||||
return String(value).indexOf("Cannot find module './afl-frontend-lib/locales/")!==-1;
|
||||
});
|
||||
};
|
||||
console.warn=function(){
|
||||
if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;}
|
||||
return window.__AFL_SHELL_CONSOLE_WARN__.apply(console,arguments);
|
||||
};
|
||||
console.error=function(){
|
||||
if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;}
|
||||
return window.__AFL_SHELL_CONSOLE_ERROR__.apply(console,arguments);
|
||||
};`;
|
||||
const AEROFLOT_RUNTIME_CONFIG_SCRIPT = `
|
||||
window["afl-frontend-runtime-config"]=window["afl-frontend-runtime-config"]||Object.create(null);
|
||||
window["afl-frontend-runtime-config"].FRONTEND_PROXY=window.location.origin;
|
||||
window["afl-frontend-runtime-config"].FRONTEND_BACKEND="https://www.aeroflot.ru";`;
|
||||
const FIX_AEROFLOT_SHELL_LINKS_SCRIPT = `
|
||||
window.__AFL_SHELL_REWRITE_LINKS__=function(){
|
||||
var anchors=document.querySelectorAll("afl-component.header a[href],afl-component.footer a[href]");
|
||||
Array.prototype.forEach.call(anchors,function(anchor){
|
||||
var href=anchor.getAttribute("href")||"";
|
||||
if(href.indexOf("/personal/login")===0||href.indexOf("/pkl/app/")===0){
|
||||
anchor.setAttribute("href","https://www.aeroflot.ru"+href);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.__AFL_SHELL_LINK_OBSERVER__=new MutationObserver(function(){
|
||||
window.__AFL_SHELL_REWRITE_LINKS__();
|
||||
});
|
||||
window.__AFL_SHELL_LINK_OBSERVER__.observe(document.documentElement,{
|
||||
childList:true,
|
||||
subtree:true,
|
||||
attributes:true,
|
||||
attributeFilter:["href"]
|
||||
});
|
||||
window.addEventListener("DOMContentLoaded",function(){
|
||||
window.__AFL_SHELL_REWRITE_LINKS__();
|
||||
});`;
|
||||
|
||||
const modernCommand = process.argv.join(" ");
|
||||
const npmLifecycleEvent = process.env["npm_lifecycle_event"] ?? "";
|
||||
const isDevServer =
|
||||
/\bdev\b/.test(modernCommand) || npmLifecycleEvent.includes("dev");
|
||||
const shouldUseShellLoaderProxy =
|
||||
process.env["AEROFLOT_SHELL_LOADER_PROXY"] === "1";
|
||||
// Angular uses full `afl-component` loader assets in production
|
||||
// `index.html`, but its local `index.dev.html` keeps only shell
|
||||
// placeholders. `dev:full` exposes a same-origin static proxy so local
|
||||
// Chrome can hydrate the placeholders without CORS failures.
|
||||
const shouldLoadStandaloneShellLoader =
|
||||
!isRemote && (!isDevServer || shouldUseShellLoaderProxy);
|
||||
const standaloneShellLoaderMode = shouldLoadStandaloneShellLoader
|
||||
? shouldUseShellLoaderProxy
|
||||
? "proxy"
|
||||
: "external"
|
||||
: "placeholder";
|
||||
const aeroflotStaticBase = shouldUseShellLoaderProxy
|
||||
? "/frontend/static"
|
||||
: "https://www.aeroflot.ru/frontend/static";
|
||||
|
||||
const standaloneShellTags = isRemote
|
||||
? []
|
||||
: [
|
||||
...(shouldLoadStandaloneShellLoader
|
||||
? [
|
||||
...(shouldUseShellLoaderProxy
|
||||
? [
|
||||
{
|
||||
tag: "script",
|
||||
head: true,
|
||||
append: true,
|
||||
children: SUPPRESS_AEROFLOT_LOADER_WARNINGS_SCRIPT,
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
head: true,
|
||||
append: true,
|
||||
children: AEROFLOT_RUNTIME_CONFIG_SCRIPT,
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
head: true,
|
||||
append: true,
|
||||
children: FIX_AEROFLOT_SHELL_LINKS_SCRIPT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
tag: "link",
|
||||
head: true,
|
||||
append: true,
|
||||
attrs: {
|
||||
rel: "stylesheet",
|
||||
href: `${aeroflotStaticBase}/css/afl-frontend-loader.bundle.css`,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "script",
|
||||
head: false,
|
||||
append: true,
|
||||
attrs: {
|
||||
async: true,
|
||||
src: `${aeroflotStaticBase}/js/afl-frontend-loader.bundle.js`,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
tag: "meta",
|
||||
head: true,
|
||||
append: true,
|
||||
attrs: {
|
||||
name: "aeroflot-shell-loader",
|
||||
content: standaloneShellLoaderMode,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()],
|
||||
plugins,
|
||||
source: {
|
||||
entriesDir: "./src",
|
||||
},
|
||||
@@ -50,6 +181,7 @@ export default defineConfig({
|
||||
append: false,
|
||||
children: PUBLIC_ENV_SCRIPT,
|
||||
},
|
||||
...standaloneShellTags,
|
||||
{
|
||||
tag: "link",
|
||||
attrs: {
|
||||
@@ -95,6 +227,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
rspack(config) {
|
||||
config.cache = false;
|
||||
},
|
||||
cssLoader: {
|
||||
url: false,
|
||||
},
|
||||
|
||||
+7
-22
@@ -7,35 +7,20 @@ const startLocalServer = !process.env.BASE_URL;
|
||||
// /api/* by source IP; nginx proxy_cache absorbs most repeat fetches but a
|
||||
// burst can still trip 1-2 of them).
|
||||
const isCI = !!process.env.CI;
|
||||
const workers = Number(process.env.E2E_WORKERS || "1");
|
||||
|
||||
// Quarantine — tests that fail consistently against the deployed prod build
|
||||
// for reasons unrelated to deploy plumbing (Angular↔React parity gaps,
|
||||
// missing section breadcrumbs, day-tab/time-filter UI behavior diffs,
|
||||
// schedule date-picker week-snap, multi-segment connecting itinerary).
|
||||
//
|
||||
// Triaged in docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md ("Out
|
||||
// of scope" section). When CI_DEPLOY=1 (set by .gitea/workflows/ci-deploy
|
||||
// only), Playwright skips this list so the deploy gate stays green; the
|
||||
// release-verify workflow runs the full suite for slower-cadence triage.
|
||||
const QUARANTINED_PATTERNS = [
|
||||
"Breadcrumb parity with Angular.*Onlineboard (route page|details page)",
|
||||
"Online Board.*flight number clear button",
|
||||
"Online Board.*route search results page hydrates",
|
||||
"TIRREDESIGN-8.*Onlineboard day-tabs",
|
||||
"Onlineboard time-range filter.*TIRREDESIGN-11",
|
||||
"P1.*Table 7: breadcrumbs on search pages.*Schedule route",
|
||||
"Schedule date-range picker.*single click snaps to Mon-Sun",
|
||||
"Schedule date-range picker.*next-month bleed-in",
|
||||
"connecting itinerary navigates to a multi-segment URL",
|
||||
];
|
||||
// Deploy-only quarantine. Keep this list empty unless a documented external
|
||||
// blocker makes a specific e2e test unsuitable for CI_DEPLOY.
|
||||
const QUARANTINED_PATTERNS: string[] = [];
|
||||
const grepInvert = process.env.CI_DEPLOY
|
||||
&& QUARANTINED_PATTERNS.length > 0
|
||||
? new RegExp(QUARANTINED_PATTERNS.join("|"))
|
||||
: undefined;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "tests/e2e",
|
||||
timeout: 30000,
|
||||
workers: isCI ? 1 : undefined,
|
||||
workers,
|
||||
retries: isCI ? 2 : 0,
|
||||
...(grepInvert ? { grepInvert } : {}),
|
||||
use: {
|
||||
@@ -53,7 +38,7 @@ export default defineConfig({
|
||||
...(startLocalServer
|
||||
? {
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
command: "pnpm dev:full",
|
||||
url: "http://localhost:8080",
|
||||
reuseExistingServer: true,
|
||||
timeout: 30000,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const SAME_ORIGIN_AEROFLOT_ENDPOINTS =
|
||||
/https:\/\/(?:www\.)?aeroflot\.ru(?=\/(?:ws2|cms2|personal|offers|feedback|pkl|frontend\/static|media)(?:[/?#]|$))/g;
|
||||
|
||||
export function rewriteAeroflotShellUrls(value) {
|
||||
return value
|
||||
.replaceAll("https://gw.aeroflot.ru", "/gw")
|
||||
.replace(SAME_ORIGIN_AEROFLOT_ENDPOINTS, "");
|
||||
}
|
||||
+285
-9
@@ -6,19 +6,27 @@
|
||||
* /api/* → curl → https://flights.test.aeroflot.ru (bypasses WAF via curl TLS)
|
||||
* /flights/* → curl → https://flights.test.aeroflot.ru
|
||||
* /map/* → curl → https://flights.test.aeroflot.ru (binary JPEG tiles)
|
||||
* /tracker/* → https://platform.test.aeroflot.ru (SignalR, same-origin)
|
||||
* /* → localhost:8081 (Modern.js SSR + HMR)
|
||||
*/
|
||||
import express from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
import { createProxyMiddleware, responseInterceptor } from "http-proxy-middleware";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs";
|
||||
|
||||
const PUBLIC_PORT = 8080;
|
||||
const MODERNJS_PORT = 8081;
|
||||
const API_TARGET = process.env.API_TARGET || "https://flights.test.aeroflot.ru";
|
||||
const TRACKER_TARGET = process.env.TRACKER_TARGET || "https://platform.test.aeroflot.ru";
|
||||
const AEROFLOT_STATIC_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://www.aeroflot.ru";
|
||||
const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || "";
|
||||
const DEBUG_PROXY_BODY = process.env.DEBUG_PROXY_BODY === "1";
|
||||
const LOCAL_PUBLIC_ORIGIN = `http://localhost:${PUBLIC_PORT}`;
|
||||
const shellStaticAssetCache = new Map();
|
||||
|
||||
// Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` +
|
||||
// 307-to-self) only runs once per dev-server lifetime, not per request.
|
||||
@@ -35,12 +43,20 @@ console.log(`Starting Modern.js on :${MODERNJS_PORT}...`);
|
||||
const modernBin = resolve("node_modules", ".bin", "modern");
|
||||
const modernProcess = existsSync(modernBin)
|
||||
? spawn(modernBin, ["dev"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, PORT: String(MODERNJS_PORT) },
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(MODERNJS_PORT),
|
||||
AEROFLOT_SHELL_LOADER_PROXY: "1",
|
||||
},
|
||||
})
|
||||
: spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, PORT: String(MODERNJS_PORT) },
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(MODERNJS_PORT),
|
||||
AEROFLOT_SHELL_LOADER_PROXY: "1",
|
||||
},
|
||||
});
|
||||
modernProcess.on("error", (err) => {
|
||||
console.error("Modern.js failed:", err);
|
||||
@@ -138,16 +154,238 @@ app.use(["/api", "/flights"], (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function execCurlWithFallback(buildArgs, extraArgs, res) {
|
||||
function normalizeTrackerCookie(cookie) {
|
||||
if (!cookie.startsWith("signal-id=")) return cookie;
|
||||
|
||||
const parts = cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.filter(
|
||||
(part) =>
|
||||
part.length > 0 &&
|
||||
!part.toLowerCase().startsWith("domain=") &&
|
||||
!part.toLowerCase().startsWith("path=") &&
|
||||
!part.toLowerCase().startsWith("samesite="),
|
||||
);
|
||||
|
||||
return [...parts, "Path=/tracker/hub", "SameSite=Lax"].join("; ");
|
||||
}
|
||||
|
||||
function applyTrackerCookieHeaders(proxyRes, res) {
|
||||
const setCookie = proxyRes.headers["set-cookie"];
|
||||
if (Array.isArray(setCookie)) {
|
||||
res.setHeader("set-cookie", setCookie.map(normalizeTrackerCookie));
|
||||
} else if (typeof setCookie === "string") {
|
||||
res.setHeader("set-cookie", normalizeTrackerCookie(setCookie));
|
||||
}
|
||||
}
|
||||
|
||||
function isTrackerLongPollTimeout(proxyRes, req) {
|
||||
const requestUrl = req.originalUrl ?? req.url ?? "";
|
||||
return (
|
||||
req.method === "GET" &&
|
||||
(requestUrl.startsWith("/tracker/hub?id=") ||
|
||||
requestUrl.startsWith("/hub?id=")) &&
|
||||
proxyRes.statusCode === 504
|
||||
);
|
||||
}
|
||||
|
||||
// --- SignalR TrackerHub proxy ---
|
||||
// Browser-direct localhost → platform.test.aeroflot.ru fails CORS. Keep the
|
||||
// hub same-origin in development and let proxy-helper / gost route the
|
||||
// upstream request through the TIM tunnel when HTTPS_PROXY is set.
|
||||
const trackerProxy = createProxyMiddleware({
|
||||
target: TRACKER_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
logLevel: "warn",
|
||||
pathRewrite: (path) => `/tracker${path}`,
|
||||
selfHandleResponse: true,
|
||||
on: {
|
||||
proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
|
||||
applyTrackerCookieHeaders(proxyRes, res);
|
||||
|
||||
if (isTrackerLongPollTimeout(proxyRes, req)) {
|
||||
console.warn(`Tracker long poll timed out upstream, returning empty 200 for ${req.originalUrl ?? req.url}`);
|
||||
res.statusCode = 200;
|
||||
res.statusMessage = "OK";
|
||||
res.setHeader("content-type", "text/plain");
|
||||
return "";
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}),
|
||||
},
|
||||
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
||||
});
|
||||
app.use("/tracker", trackerProxy);
|
||||
|
||||
// --- Aeroflot frontend loader static proxy ---
|
||||
// The loader derives follow-up asset URLs from its own script origin. Serving
|
||||
// it through localhost keeps Header/Footer hydration visible in dev Chrome
|
||||
// without browser CORS errors from www.aeroflot.ru.
|
||||
app.use(
|
||||
"/frontend/static",
|
||||
createProxyMiddleware({
|
||||
target: AEROFLOT_STATIC_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: "warn",
|
||||
pathRewrite: (path) => `/frontend/static${path}`,
|
||||
selfHandleResponse: true,
|
||||
on: {
|
||||
proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
|
||||
const cacheKey = req.originalUrl ?? req.url ?? "";
|
||||
const contentType = inferShellStaticContentType(
|
||||
cacheKey,
|
||||
proxyRes.headers["content-type"],
|
||||
);
|
||||
if (!/(css|html|javascript|json|text)/i.test(String(contentType))) {
|
||||
if (proxyRes.statusCode && proxyRes.statusCode < 500) {
|
||||
shellStaticAssetCache.set(cacheKey, { body: buffer, contentType });
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
if (proxyRes.statusCode && proxyRes.statusCode >= 500) {
|
||||
const cached = shellStaticAssetCache.get(cacheKey);
|
||||
if (cached) {
|
||||
res.statusCode = 200;
|
||||
res.statusMessage = "OK";
|
||||
res.setHeader("content-type", cached.contentType);
|
||||
return cached.body;
|
||||
}
|
||||
|
||||
const retried = await retryAeroflotStaticAsset(cacheKey);
|
||||
if (retried) {
|
||||
const body = rewriteAeroflotShellUrls(retried.body);
|
||||
shellStaticAssetCache.set(cacheKey, { body, contentType });
|
||||
res.statusCode = 200;
|
||||
res.statusMessage = "OK";
|
||||
res.setHeader("content-type", contentType);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
const body = rewriteAeroflotShellUrls(buffer.toString("utf8"));
|
||||
if (!proxyRes.statusCode || proxyRes.statusCode < 500) {
|
||||
shellStaticAssetCache.set(cacheKey, { body, contentType });
|
||||
}
|
||||
return body;
|
||||
}),
|
||||
},
|
||||
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
function inferShellStaticContentType(requestPath, upstreamContentType) {
|
||||
const contentType = String(upstreamContentType ?? "");
|
||||
if (/(css|html|javascript|json|text)/i.test(contentType)) return contentType;
|
||||
const pathname = requestPath.split("?")[0] ?? "";
|
||||
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "application/javascript";
|
||||
if (pathname.endsWith(".css")) return "text/css";
|
||||
if (pathname.endsWith(".json")) return "application/json";
|
||||
if (pathname.endsWith(".html")) return "text/html";
|
||||
return contentType;
|
||||
}
|
||||
|
||||
function retryAeroflotStaticAsset(originalUrl) {
|
||||
const targetUrl = `${AEROFLOT_STATIC_TARGET}${originalUrl}`;
|
||||
const buildArgs = (noproxy) => [
|
||||
"-sS",
|
||||
"-f",
|
||||
"-L",
|
||||
...(noproxy ? ["--noproxy", "*"] : []),
|
||||
"-H", "Accept: */*",
|
||||
"-H", "User-Agent: Mozilla/5.0",
|
||||
targetUrl,
|
||||
];
|
||||
|
||||
return new Promise((resolveRetry) => {
|
||||
runCurlWithoutStatus(buildArgs(true), (direct) => {
|
||||
if (!direct.err && direct.stdout) {
|
||||
resolveRetry({ body: direct.stdout });
|
||||
return;
|
||||
}
|
||||
runCurlWithoutStatus(buildArgs(false), (proxy) => {
|
||||
resolveRetry(!proxy.err && proxy.stdout ? { body: proxy.stdout } : null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => {
|
||||
res.status(200).json({ data: null });
|
||||
});
|
||||
|
||||
app.use("/gw/api/pr/LKAB/Profile/v3/get", (_req, res) => {
|
||||
res.status(200).json({ data: null });
|
||||
});
|
||||
|
||||
app.use(["/ws2/v.0.0.1/json/currency/RU", "/ws2/v.0.0.1/json/calcurr/"], (_req, res) => {
|
||||
res.status(200).json({ data: { currency: "RUB" }, errors: [], isSuccess: true });
|
||||
});
|
||||
|
||||
app.use("/pkl/ws/json/v1/member/get", (_req, res) => {
|
||||
res.status(200).json({ data: null, errors: [], isSuccess: true });
|
||||
});
|
||||
|
||||
app.use(
|
||||
["/ws2", "/cms2", "/personal", "/offers", "/feedback"],
|
||||
express.raw({ type: "*/*" }),
|
||||
(req, res) => {
|
||||
const targetUrl = `${AEROFLOT_STATIC_TARGET}${req.originalUrl}`;
|
||||
const requestBody = req.body?.length
|
||||
? req.body.toString("utf8").replaceAll(LOCAL_PUBLIC_ORIGIN, API_TARGET)
|
||||
: "";
|
||||
|
||||
const buildArgs = (noproxy) => [
|
||||
"-s",
|
||||
"-L",
|
||||
...(noproxy ? ["--noproxy", "*"] : []),
|
||||
"-H", `Accept: ${req.headers.accept || "application/json"}`,
|
||||
"-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`,
|
||||
"-H", `Accept-Language: ${req.headers["accept-language"] || "ru"}`,
|
||||
"-w", "\n%{http_code}",
|
||||
targetUrl,
|
||||
];
|
||||
|
||||
const bodyArgs =
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? []
|
||||
: [
|
||||
"-X", req.method,
|
||||
"-H", `Content-Type: ${req.headers["content-type"] || "application/json"}`,
|
||||
...(requestBody ? ["--data-raw", requestBody] : []),
|
||||
];
|
||||
|
||||
execCurlWithFallback(buildArgs, bodyArgs, res, respondWithAeroflotFrontendResult);
|
||||
},
|
||||
);
|
||||
|
||||
app.use(
|
||||
"/media",
|
||||
createProxyMiddleware({
|
||||
target: AEROFLOT_STATIC_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: "warn",
|
||||
pathRewrite: (path) => `/media${path}`,
|
||||
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
function execCurlWithFallback(buildArgs, extraArgs, res, responder = respondWithCurlResult) {
|
||||
runCurl([...extraArgs, ...buildArgs(true)], (direct) => {
|
||||
if (isSuccessfulUpstream(direct)) {
|
||||
respondWithCurlResult(direct, res);
|
||||
responder(direct, res);
|
||||
return;
|
||||
}
|
||||
// Direct hit a WAF deny / throttle / network failure — retry through
|
||||
// the system HTTPS_PROXY (gost VPN tunnel on this host).
|
||||
runCurl([...extraArgs, ...buildArgs(false)], (proxy) => {
|
||||
respondWithCurlResult(isSuccessfulUpstream(proxy) ? proxy : direct, res);
|
||||
responder(isSuccessfulUpstream(proxy) ? proxy : direct, res);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -164,6 +402,18 @@ function runCurl(args, cb) {
|
||||
});
|
||||
}
|
||||
|
||||
function runCurlWithoutStatus(args, cb) {
|
||||
execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.warn(`static curl retry failed: ${err.message}`);
|
||||
}
|
||||
if (stderr) {
|
||||
console.warn(`static curl retry stderr: ${stderr.substring(0, 300)}`);
|
||||
}
|
||||
cb({ err, stdout: stdout ?? "" });
|
||||
});
|
||||
}
|
||||
|
||||
function isSuccessfulUpstream({ err, stdout }) {
|
||||
if (err) return false;
|
||||
const lastNewline = stdout.lastIndexOf("\n");
|
||||
@@ -214,6 +464,25 @@ function respondWithCurlResult({ err, stdout }, res) {
|
||||
res.send(body);
|
||||
}
|
||||
|
||||
function respondWithAeroflotFrontendResult(result, res) {
|
||||
if (result.err) {
|
||||
res.status(502).json({ error: result.err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const lastNewline = result.stdout.lastIndexOf("\n");
|
||||
const rawBody = lastNewline >= 0 ? result.stdout.substring(0, lastNewline) : result.stdout;
|
||||
const body = rewriteAeroflotShellUrls(rawBody);
|
||||
const statusStr = lastNewline >= 0 ? result.stdout.substring(lastNewline + 1).trim() : "200";
|
||||
const status = parseInt(statusStr) || 200;
|
||||
const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("[");
|
||||
|
||||
res.status(status);
|
||||
res.set("Content-Type", isJson ? "application/json" : "text/html");
|
||||
res.set("Access-Control-Allow-Origin", "*");
|
||||
res.send(body);
|
||||
}
|
||||
|
||||
function summarizeBody(body) {
|
||||
const trimmed = body.trimStart();
|
||||
if (!trimmed) return "body=empty";
|
||||
@@ -252,12 +521,19 @@ app.use(modernProxy);
|
||||
const server = app.listen(PUBLIC_PORT, () => {
|
||||
console.log(`\n ✓ Dev server: http://localhost:${PUBLIC_PORT}`);
|
||||
console.log(` /api/* → curl → ${API_TARGET}`);
|
||||
console.log(` /tracker/* → proxy → ${TRACKER_TARGET}`);
|
||||
console.log(` /* → Modern.js :${MODERNJS_PORT}\n`);
|
||||
});
|
||||
|
||||
// Forward WebSocket upgrades to Modern.js HMR server explicitly,
|
||||
// preventing reconnection spam from http-proxy-middleware's built-in ws handling.
|
||||
server.on("upgrade", modernProxy.upgrade);
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
if (req.url?.startsWith("/tracker")) {
|
||||
trackerProxy.upgrade(req, socket, head);
|
||||
return;
|
||||
}
|
||||
modernProxy.upgrade(req, socket, head);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => { modernProcess.kill(); process.exit(); });
|
||||
process.on("SIGTERM", () => { modernProcess.kill(); process.exit(); });
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Production-facing standalone server.
|
||||
*
|
||||
* The Aeroflot shell loader must be loaded from the same origin, and its CMS
|
||||
* requests must carry a referrer URL accepted by Aeroflot upstream services.
|
||||
* Stock ingress/nginx cannot safely rewrite JSON request bodies, so the
|
||||
* container exposes this small proxy in front of `modern serve`.
|
||||
*/
|
||||
import express from "express";
|
||||
import { createProxyMiddleware, responseInterceptor } from "http-proxy-middleware";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { existsSync } from "node:fs";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs";
|
||||
|
||||
const PUBLIC_PORT = Number(process.env.PORT || 8080);
|
||||
const MODERNJS_PORT = Number(process.env.MODERN_SERVER_PORT || 8081);
|
||||
const AEROFLOT_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://www.aeroflot.ru";
|
||||
const AEROFLOT_GW_TARGET = process.env.AEROFLOT_GW_TARGET || "https://gw.aeroflot.ru";
|
||||
const REFERRER_ORIGIN =
|
||||
process.env.AEROFLOT_SHELL_REFERRER_ORIGIN || "https://flights.test.aeroflot.ru";
|
||||
const ENABLE_SHELL_PROXY = process.env.AEROFLOT_SHELL_LOADER_PROXY === "1";
|
||||
const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || "";
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
||||
|
||||
const modernBin = resolve("node_modules", ".bin", "modern");
|
||||
const modernArgs = existsSync(modernBin)
|
||||
? [modernBin, "serve"]
|
||||
: [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "serve"];
|
||||
const modernProcess = spawn(modernArgs[0], modernArgs.slice(1), {
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(MODERNJS_PORT),
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
modernProcess.on("error", (err) => {
|
||||
console.error(`Failed to start Modern.js: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const app = express();
|
||||
const shellResponseCache = new Map();
|
||||
|
||||
function rewriteAeroflotUrls(value) {
|
||||
return rewriteAeroflotShellUrls(value);
|
||||
}
|
||||
|
||||
function requestOrigin(req) {
|
||||
const proto = (req.get("x-forwarded-proto") || req.protocol || "http")
|
||||
.split(",")[0]
|
||||
.trim();
|
||||
return `${proto}://${req.get("host")}`;
|
||||
}
|
||||
|
||||
function rewriteRequestBody(req) {
|
||||
if (!req.body?.length) return undefined;
|
||||
|
||||
return req.body
|
||||
.toString("utf8")
|
||||
.replaceAll(requestOrigin(req), REFERRER_ORIGIN.replace(/\/+$/, ""));
|
||||
}
|
||||
|
||||
function sendJson(res, body) {
|
||||
res.status(200).type("application/json").send(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function stubShellEndpoints(appInstance) {
|
||||
appInstance.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => {
|
||||
sendJson(res, { data: null });
|
||||
});
|
||||
appInstance.use("/gw/api/pr/LKAB/Profile/v3/get", (_req, res) => {
|
||||
sendJson(res, { data: null });
|
||||
});
|
||||
appInstance.use(["/ws2/v.0.0.1/json/currency/RU", "/ws2/v.0.0.1/json/calcurr/"], (_req, res) => {
|
||||
sendJson(res, { data: { currency: "RUB" }, errors: [], isSuccess: true });
|
||||
});
|
||||
appInstance.use("/pkl/ws/json/v1/member/get", (_req, res) => {
|
||||
sendJson(res, { data: null, errors: [], isSuccess: true });
|
||||
});
|
||||
}
|
||||
|
||||
function proxyStaticShellAssets(appInstance) {
|
||||
appInstance.use(
|
||||
"/frontend/static",
|
||||
createProxyMiddleware({
|
||||
target: AEROFLOT_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
logLevel: "warn",
|
||||
pathRewrite: (path) =>
|
||||
path.startsWith("/frontend/static") ? path : `/frontend/static${path}`,
|
||||
selfHandleResponse: true,
|
||||
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
||||
on: {
|
||||
proxyRes: responseInterceptor(async (buffer, proxyRes) => {
|
||||
const contentType = proxyRes.headers["content-type"] ?? "";
|
||||
if (!/(css|html|javascript|json|text)/i.test(String(contentType))) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
return rewriteAeroflotUrls(buffer.toString("utf8"));
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
appInstance.use(
|
||||
"/media",
|
||||
createProxyMiddleware({
|
||||
target: AEROFLOT_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
logLevel: "warn",
|
||||
pathRewrite: (path) => (path.startsWith("/media") ? path : `/media${path}`),
|
||||
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackForShellResponse(res, cacheKey, upstreamError) {
|
||||
const cached = shellResponseCache.get(cacheKey);
|
||||
if (cached) {
|
||||
res.status(200).type(cached.contentType).send(cached.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (upstreamError) {
|
||||
console.warn(`Aeroflot shell proxy fallback: ${upstreamError}`);
|
||||
}
|
||||
sendJson(res, { data: null, errors: [], isSuccess: true });
|
||||
}
|
||||
|
||||
async function proxyAeroflotService(req, res, targetOrigin, targetPath = req.originalUrl) {
|
||||
const body = rewriteRequestBody(req);
|
||||
const cacheKey = `${req.method} ${targetOrigin}${targetPath} ${body ?? ""}`;
|
||||
const headers = {
|
||||
accept: req.get("accept") || "application/json",
|
||||
"accept-language": req.get("accept-language") || "ru",
|
||||
"user-agent": req.get("user-agent") || USER_AGENT,
|
||||
};
|
||||
if (body) {
|
||||
headers["content-type"] = req.get("content-type") || "application/json";
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await requestText(`${targetOrigin}${targetPath}`, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
|
||||
});
|
||||
const rawBody = upstream.body;
|
||||
const contentType = String(upstream.headers["content-type"] || "application/json");
|
||||
const responseBody = /(json|html|text|javascript|css)/i.test(contentType)
|
||||
? rewriteAeroflotUrls(rawBody)
|
||||
: rawBody;
|
||||
|
||||
if (upstream.status < 200 || upstream.status >= 400) {
|
||||
fallbackForShellResponse(res, cacheKey, `${upstream.status} ${targetOrigin}${targetPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
shellResponseCache.set(cacheKey, { body: responseBody, contentType });
|
||||
res.status(upstream.status).type(contentType).send(responseBody);
|
||||
} catch (err) {
|
||||
fallbackForShellResponse(
|
||||
res,
|
||||
cacheKey,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestText(url, options, redirectCount = 0) {
|
||||
return new Promise((resolveRequest, rejectRequest) => {
|
||||
const parsed = new URL(url);
|
||||
const transport = parsed.protocol === "http:" ? http : https;
|
||||
const request = transport.request(
|
||||
parsed,
|
||||
{
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
agent:
|
||||
SYSTEM_PROXY && parsed.protocol === "https:"
|
||||
? new HttpsProxyAgent(SYSTEM_PROXY)
|
||||
: undefined,
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
response.on("end", () => {
|
||||
const status = response.statusCode || 0;
|
||||
const location = response.headers.location;
|
||||
if (
|
||||
status >= 300 &&
|
||||
status < 400 &&
|
||||
location &&
|
||||
redirectCount < 5
|
||||
) {
|
||||
const redirectUrl = new URL(location, parsed).toString();
|
||||
requestText(redirectUrl, options, redirectCount + 1)
|
||||
.then(resolveRequest)
|
||||
.catch(rejectRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
resolveRequest({
|
||||
status,
|
||||
headers: response.headers,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.on("error", rejectRequest);
|
||||
if (options.body) {
|
||||
request.write(options.body);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
if (ENABLE_SHELL_PROXY) {
|
||||
stubShellEndpoints(app);
|
||||
proxyStaticShellAssets(app);
|
||||
app.use(
|
||||
["/ws2", "/cms2", "/personal", "/offers", "/feedback", "/pkl"],
|
||||
express.raw({ type: "*/*" }),
|
||||
(req, res) => void proxyAeroflotService(req, res, AEROFLOT_TARGET),
|
||||
);
|
||||
app.use(
|
||||
"/gw",
|
||||
express.raw({ type: "*/*" }),
|
||||
(req, res) =>
|
||||
void proxyAeroflotService(
|
||||
req,
|
||||
res,
|
||||
AEROFLOT_GW_TARGET,
|
||||
req.originalUrl.replace(/^\/gw/, "") || "/",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const modernProxy = createProxyMiddleware({
|
||||
target: `http://127.0.0.1:${MODERNJS_PORT}`,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
app.use(modernProxy);
|
||||
|
||||
const server = app.listen(PUBLIC_PORT, () => {
|
||||
console.log(`Standalone server listening on :${PUBLIC_PORT}`);
|
||||
console.log(`Modern.js upstream listening on :${MODERNJS_PORT}`);
|
||||
console.log(`Aeroflot shell proxy: ${ENABLE_SHELL_PROXY ? "enabled" : "disabled"}`);
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
modernProxy.upgrade(req, socket, head);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
modernProcess.kill();
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
Vendored
+6
@@ -20,6 +20,8 @@ const EnvSchema = z.object({
|
||||
// skip the connection when blank so the browser does not emit CORS
|
||||
// errors for an unreachable placeholder host.
|
||||
SIGNALR_HUB_URL: z.string().default(""),
|
||||
REFRESH_PAUSE_MIN: z.coerce.number().nonnegative().default(15),
|
||||
REFRESH_STOP_MIN: z.coerce.number().nonnegative().default(60),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
|
||||
LOGS_ENDPOINT: z.string().url().optional(),
|
||||
@@ -45,6 +47,8 @@ export interface Env {
|
||||
PROD_ORIGIN: string;
|
||||
API_BASE_URL: string;
|
||||
SIGNALR_HUB_URL: string;
|
||||
REFRESH_PAUSE_MIN: number;
|
||||
REFRESH_STOP_MIN: number;
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
|
||||
OTEL_EXPORTER_OTLP_HEADERS?: string;
|
||||
LOGS_ENDPOINT?: string;
|
||||
@@ -92,6 +96,8 @@ export function getEnv(): Env {
|
||||
PROD_ORIGIN: raw.PROD_ORIGIN,
|
||||
API_BASE_URL: raw.API_BASE_URL,
|
||||
SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL,
|
||||
REFRESH_PAUSE_MIN: raw.REFRESH_PAUSE_MIN,
|
||||
REFRESH_STOP_MIN: raw.REFRESH_STOP_MIN,
|
||||
ANALYTICS_ENABLED: {
|
||||
metrica: raw.ANALYTICS_METRICA,
|
||||
ctm: raw.ANALYTICS_CTM,
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
* routes=MOW..LED → omitted: routes=MOW.LED
|
||||
*/
|
||||
|
||||
const BASE = "https://www.aeroflot.ru/sb/app/ru-ru#/search";
|
||||
const FIXED_PARAMS =
|
||||
"adults=1&cabin=economy&children=0&infants=0&autosearch=Y" +
|
||||
"&utm_source=aflwebbot&utm_medium=referral" +
|
||||
"&utm_campaign=ref_3015_general_rf_button.index__all_flight.map";
|
||||
import { buildAeroflotSbSearchUrl } from "@/shared/booking/aeroflot.js";
|
||||
|
||||
const MAP_UTM_PARAMS = {
|
||||
utm_source: "aflwebbot",
|
||||
utm_medium: "referral",
|
||||
utm_campaign: "ref_3015_general_rf_button.index__all_flight.map",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build the SB search URL for a single one-way leg.
|
||||
@@ -30,12 +32,12 @@ export function buildBuyTicketUrl(
|
||||
arrival: string,
|
||||
date: string | undefined,
|
||||
): string {
|
||||
// TZ §4.1.24.6 Table 71: route format is {dep}.{date}.{arr} for one-way
|
||||
// with a date; {dep}.{arr} (no dot-date segment) when no date is known.
|
||||
const routeTriple = date
|
||||
? `${departure}.${date}.${arrival}`
|
||||
: `${departure}.${arrival}`;
|
||||
return `${BASE}?${FIXED_PARAMS}&routes=${routeTriple}`;
|
||||
return buildAeroflotSbSearchUrl({
|
||||
departure,
|
||||
arrival,
|
||||
date,
|
||||
extraParams: MAP_UTM_PARAMS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,25 @@ describe("getMinDate / getMaxDate", () => {
|
||||
const max = getMaxDate();
|
||||
expect(max.getTime()).toBe(expected.getTime());
|
||||
});
|
||||
|
||||
it("derives date-only bounds from the provided yyyyMMdd anchor", () => {
|
||||
const min = getMinDate("20260506");
|
||||
const max = getMaxDate("20260506");
|
||||
|
||||
expect(min.toISOString()).toBe(new Date(2026, 4, 5).toISOString());
|
||||
expect(max.toISOString()).toBe(new Date(2026, 10, 6).toISOString());
|
||||
expect(min.getHours()).toBe(0);
|
||||
expect(max.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to the runtime clock when the provided anchor is invalid", () => {
|
||||
const min = getMinDate("20269999");
|
||||
const expected = addDays(new Date(), -1);
|
||||
|
||||
expect(min.getFullYear()).toBe(expected.getFullYear());
|
||||
expect(min.getMonth()).toBe(expected.getMonth());
|
||||
expect(min.getDate()).toBe(expected.getDate());
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDisabledDates", () => {
|
||||
|
||||
@@ -12,19 +12,33 @@
|
||||
import { mapWindowBounds } from "@/shared/dateWindow.js";
|
||||
|
||||
/** Today with time set to 00:00:00 local. */
|
||||
export function today(): Date {
|
||||
export function today(baseYyyymmdd?: string): Date {
|
||||
if (baseYyyymmdd) {
|
||||
const parsed = parseYyyymmdd(baseYyyymmdd);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** minDate = today - 1 day (Angular parity). */
|
||||
export function getMinDate(): Date {
|
||||
export function getMinDate(baseYyyymmdd?: string): Date {
|
||||
if (baseYyyymmdd) {
|
||||
const d = today(baseYyyymmdd);
|
||||
d.setDate(d.getDate() - 1);
|
||||
return d;
|
||||
}
|
||||
return mapWindowBounds()[0];
|
||||
}
|
||||
|
||||
/** maxDate = today + 6 months (Angular parity). */
|
||||
export function getMaxDate(): Date {
|
||||
export function getMaxDate(baseYyyymmdd?: string): Date {
|
||||
if (baseYyyymmdd) {
|
||||
const d = today(baseYyyymmdd);
|
||||
d.setMonth(d.getMonth() + 6);
|
||||
return d;
|
||||
}
|
||||
return mapWindowBounds()[1];
|
||||
}
|
||||
|
||||
@@ -92,3 +106,20 @@ function toYyyymmdd(d: Date): string {
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
return `${y}${m}${day}`;
|
||||
}
|
||||
|
||||
function parseYyyymmdd(value: string): Date | null {
|
||||
if (!/^\d{8}$/.test(value)) return null;
|
||||
const y = Number(value.slice(0, 4));
|
||||
const m = Number(value.slice(4, 6));
|
||||
const d = Number(value.slice(6, 8));
|
||||
const parsed = new Date(y, m - 1, d);
|
||||
parsed.setHours(0, 0, 0, 0);
|
||||
if (
|
||||
parsed.getFullYear() !== y ||
|
||||
parsed.getMonth() !== m - 1 ||
|
||||
parsed.getDate() !== d
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FlightsMapFilter } from "./FlightsMapFilter.js";
|
||||
import type { IFlightsMapFilterState } from "../types.js";
|
||||
@@ -40,7 +40,12 @@ vi.mock("@modern-js/runtime/router", () => ({
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string) =>
|
||||
key === "SHARED.TODAY"
|
||||
? "Сегодня"
|
||||
: key === "SHARED.TOMORROW"
|
||||
? "Завтра"
|
||||
: key,
|
||||
i18n: { language: "ru" },
|
||||
}),
|
||||
}));
|
||||
@@ -92,6 +97,25 @@ describe("FlightsMapFilter — Calendar wiring", () => {
|
||||
expect(max.getTime()).toBe(expectedMax.getTime());
|
||||
});
|
||||
|
||||
it("uses the provided SSR today anchor for date-only calendar bounds", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<FlightsMapFilter
|
||||
value={filter()}
|
||||
today="20260506"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const min = lastCalendarProps!["minDate"] as Date;
|
||||
const max = lastCalendarProps!["maxDate"] as Date;
|
||||
|
||||
expect(min.toISOString()).toBe(new Date(2026, 4, 5).toISOString());
|
||||
expect(max.toISOString()).toBe(new Date(2026, 10, 6).toISOString());
|
||||
expect(min.getHours()).toBe(0);
|
||||
expect(min.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it("disables every date when availableDays is empty", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
@@ -182,6 +206,83 @@ describe("FlightsMapFilter — Calendar wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlightsMapFilter — TIRREDESIGN-22: calendar today/tomorrow labels", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 4, 6, 12, 0, 0));
|
||||
lastCalendarProps = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("formats today's selected date as a word", () => {
|
||||
render(
|
||||
<FlightsMapFilter
|
||||
value={filter({ departure: "MOW", date: "20260506" })}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const formatDateTime = lastCalendarProps!["formatDateTime"] as (
|
||||
date: Date,
|
||||
) => string;
|
||||
|
||||
expect(formatDateTime(new Date(2026, 4, 6))).toBe("Сегодня");
|
||||
});
|
||||
|
||||
it("formats tomorrow's selected date as a word", () => {
|
||||
render(
|
||||
<FlightsMapFilter
|
||||
value={filter({ departure: "MOW", date: "20260507" })}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const formatDateTime = lastCalendarProps!["formatDateTime"] as (
|
||||
date: Date,
|
||||
) => string;
|
||||
|
||||
expect(formatDateTime(new Date(2026, 4, 7))).toBe("Завтра");
|
||||
});
|
||||
|
||||
it("keeps normal dates in dd.MM.yyyy format", () => {
|
||||
render(
|
||||
<FlightsMapFilter
|
||||
value={filter({ departure: "MOW", date: "20260508" })}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const formatDateTime = lastCalendarProps!["formatDateTime"] as (
|
||||
date: Date,
|
||||
) => string;
|
||||
|
||||
expect(formatDateTime(new Date(2026, 4, 8))).toBe("08.05.2026");
|
||||
});
|
||||
|
||||
it("parses typed today/tomorrow words back to dates", () => {
|
||||
render(
|
||||
<FlightsMapFilter
|
||||
value={filter({ departure: "MOW", date: "20260506" })}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const parseDateTime = lastCalendarProps!["parseDateTime"] as (
|
||||
text: string,
|
||||
) => Date;
|
||||
|
||||
expect(parseDateTime("Сегодня").toISOString()).toBe(
|
||||
new Date(2026, 4, 6).toISOString(),
|
||||
);
|
||||
expect(parseDateTime("Завтра").toISOString()).toBe(
|
||||
new Date(2026, 4, 7).toISOString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TZ §4.1.24.2 R16: Calendar disabled when Город вылета is empty
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { IFlightsMapFilterState } from "../types.js";
|
||||
export interface FlightsMapFilterProps {
|
||||
value: IFlightsMapFilterState;
|
||||
availableDays?: string[];
|
||||
today?: string;
|
||||
onChange: (state: IFlightsMapFilterState) => void;
|
||||
}
|
||||
|
||||
@@ -43,6 +44,64 @@ function dateToYyyymmdd(value: Date): string {
|
||||
return `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
function addDays(base: Date, days: number): Date {
|
||||
const date = new Date(base);
|
||||
date.setDate(date.getDate() + days);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateInputValue(value: Date, t: (key: string) => string): string {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (isSameDay(value, today)) return t("SHARED.TODAY");
|
||||
if (isSameDay(value, addDays(today, 1))) return t("SHARED.TOMORROW");
|
||||
|
||||
const d = value.getDate().toString().padStart(2, "0");
|
||||
const m = (value.getMonth() + 1).toString().padStart(2, "0");
|
||||
return `${d}.${m}.${value.getFullYear()}`;
|
||||
}
|
||||
|
||||
function parseDateInputValue(value: string, t: (key: string) => string): Date | null {
|
||||
const text = value.trim();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (text.localeCompare(t("SHARED.TODAY"), undefined, { sensitivity: "accent" }) === 0) {
|
||||
return today;
|
||||
}
|
||||
|
||||
if (
|
||||
text.localeCompare(t("SHARED.TOMORROW"), undefined, { sensitivity: "accent" }) === 0
|
||||
) {
|
||||
return addDays(today, 1);
|
||||
}
|
||||
|
||||
const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(text);
|
||||
if (!match) return null;
|
||||
|
||||
const day = Number(match[1]);
|
||||
const month = Number(match[2]) - 1;
|
||||
const year = Number(match[3]);
|
||||
const date = new Date(year, month, day);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
return date.getFullYear() === year &&
|
||||
date.getMonth() === month &&
|
||||
date.getDate() === day
|
||||
? date
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter component for the flights map. Controls departure, arrival,
|
||||
* connections, domestic/international toggles, and date selection.
|
||||
@@ -50,6 +109,7 @@ function dateToYyyymmdd(value: Date): string {
|
||||
export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
value,
|
||||
availableDays,
|
||||
today: todayYmd,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -76,8 +136,8 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
);
|
||||
}, [dictionaries, onChange, value]);
|
||||
|
||||
const minDate = useMemo(() => getMinDate(), []);
|
||||
const maxDate = useMemo(() => getMaxDate(), []);
|
||||
const minDate = useMemo(() => getMinDate(todayYmd), [todayYmd]);
|
||||
const maxDate = useMemo(() => getMaxDate(todayYmd), [todayYmd]);
|
||||
|
||||
const disabledDates = useMemo(
|
||||
() => buildDisabledDates(minDate, maxDate, availableDays ?? []),
|
||||
@@ -151,6 +211,16 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const formatDateTime = useCallback(
|
||||
(date: Date) => formatDateInputValue(date, t),
|
||||
[t],
|
||||
);
|
||||
|
||||
const parseDateTime = useCallback(
|
||||
(text: string) => parseDateInputValue(text, t) ?? new Date(Number.NaN),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flights-map-filter" data-testid="flights-map-filter">
|
||||
<div className="flights-map-filter-header">
|
||||
@@ -292,6 +362,8 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
maxDate={maxDate}
|
||||
disabledDates={disabledDates}
|
||||
dateFormat="dd.mm.yy"
|
||||
formatDateTime={formatDateTime}
|
||||
parseDateTime={parseDateTime}
|
||||
placeholder={t("SHARED.DATE_FORMAT")}
|
||||
showIcon
|
||||
disabled={!value.departure}
|
||||
|
||||
@@ -8,7 +8,11 @@ import { FlightsMapStartPage } from "./FlightsMapStartPage.js";
|
||||
import type * as DictionariesModuleNS from "@/shared/dictionaries/index.js";
|
||||
import { transformDictionaries } from "@/shared/dictionaries/index.js";
|
||||
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
|
||||
import type { FlightsMapSearchParams } from "../types.js";
|
||||
import type {
|
||||
FlightsMapCalendarParams,
|
||||
IFlightsMapFilterState,
|
||||
FlightsMapSearchParams,
|
||||
} from "../types.js";
|
||||
import {
|
||||
resetCrossSectionStore,
|
||||
setMapFilter,
|
||||
@@ -69,10 +73,12 @@ const searchState: {
|
||||
routes: Array<{ route: string[]; isDirect: boolean }>;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
completedParams: FlightsMapSearchParams | null;
|
||||
} = {
|
||||
routes: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
completedParams: null,
|
||||
};
|
||||
const searchCalls: Array<FlightsMapSearchParams | null> = [];
|
||||
vi.mock("../hooks/useFlightsMapSearch.js", () => ({
|
||||
@@ -82,8 +88,12 @@ vi.mock("../hooks/useFlightsMapSearch.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const calendarCalls: Array<FlightsMapCalendarParams | null> = [];
|
||||
vi.mock("../hooks/useFlightsMapCalendar.js", () => ({
|
||||
useFlightsMapCalendar: () => ({ availableDays: [] }),
|
||||
useFlightsMapCalendar: (params: FlightsMapCalendarParams | null) => {
|
||||
calendarCalls.push(params);
|
||||
return { availableDays: [] };
|
||||
},
|
||||
}));
|
||||
|
||||
const dictState: {
|
||||
@@ -309,6 +319,7 @@ describe("FlightsMapStartPage — polylines from search results (C.3)", () => {
|
||||
searchState.routes = [];
|
||||
searchState.loading = false;
|
||||
searchState.error = null;
|
||||
searchState.completedParams = null;
|
||||
});
|
||||
|
||||
it("passes an empty polylines array when no routes", () => {
|
||||
@@ -376,6 +387,7 @@ describe("TZ §4.1.4 Table 7 breadcrumbs — Flight-Map pages (rows 1-3)", () =>
|
||||
describe("FlightsMapStartPage — C.4 integration", () => {
|
||||
beforeEach(() => {
|
||||
lastMapCanvasProps = null;
|
||||
lastMapFilterProps = null;
|
||||
searchCalls.length = 0;
|
||||
dictState.dictionaries = buildDictionaries({
|
||||
regions: [],
|
||||
@@ -467,6 +479,28 @@ describe("FlightsMapStartPage — C.4 integration", () => {
|
||||
expect(popups[1]!.content).toContain("https://www.aeroflot.ru/sb/app/ru-ru");
|
||||
});
|
||||
|
||||
it("does not draw malformed multi-hop direct routes when transfers are off", () => {
|
||||
searchState.routes = [
|
||||
{ route: ["SVO", "LED"], isDirect: true },
|
||||
{ route: ["SVO", "AER", "LED"], isDirect: true },
|
||||
];
|
||||
setMapFilter({
|
||||
departure: "MOW",
|
||||
arrival: "LED",
|
||||
date: null,
|
||||
showInternal: false,
|
||||
showInternational: false,
|
||||
showTransfers: false,
|
||||
});
|
||||
|
||||
render(<FlightsMapStartPage />);
|
||||
|
||||
const polylines = lastMapCanvasProps!["polylines"] as Array<{ cityIds: string[] }>;
|
||||
expect(polylines).toEqual([
|
||||
expect.objectContaining({ cityIds: ["MOW", "LED"] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not render popups in spider mode (departure only)", () => {
|
||||
searchState.routes = [{ route: ["MOW", "LED"], isDirect: true }];
|
||||
render(<FlightsMapStartPage />);
|
||||
@@ -479,9 +513,9 @@ describe("FlightsMapStartPage — C.4 integration", () => {
|
||||
expect(popups).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-fallback: re-issues the search with connections=1 when direct routes come back empty", () => {
|
||||
it("auto-fallback: re-issues the search with connections=1 when a completed direct search comes back empty", () => {
|
||||
searchState.routes = [];
|
||||
render(<FlightsMapStartPage />);
|
||||
const { rerender } = render(<FlightsMapStartPage />);
|
||||
act(() => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
|
||||
});
|
||||
@@ -489,12 +523,78 @@ describe("FlightsMapStartPage — C.4 integration", () => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
|
||||
});
|
||||
|
||||
const direct = [...searchCalls]
|
||||
.reverse()
|
||||
.find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0);
|
||||
searchState.completedParams = direct ?? null;
|
||||
act(() => {
|
||||
rerender(<FlightsMapStartPage />);
|
||||
});
|
||||
|
||||
const withOne = searchCalls.filter((p) => p?.connections === 1);
|
||||
expect(withOne.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("mirror: last search call uses connections=1 after auto-fallback", () => {
|
||||
it("mirror: last search call uses connections=1 after completed auto-fallback", () => {
|
||||
searchState.routes = [];
|
||||
const { rerender } = render(<FlightsMapStartPage />);
|
||||
act(() => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
|
||||
});
|
||||
act(() => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
|
||||
});
|
||||
|
||||
const direct = [...searchCalls]
|
||||
.reverse()
|
||||
.find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0);
|
||||
searchState.completedParams = direct ?? null;
|
||||
act(() => {
|
||||
rerender(<FlightsMapStartPage />);
|
||||
});
|
||||
|
||||
const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
|
||||
expect(last?.connections).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps transfer-only toggle off after the user disables auto-fallback", () => {
|
||||
searchState.routes = [];
|
||||
const { rerender } = render(<FlightsMapStartPage />);
|
||||
act(() => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
|
||||
});
|
||||
act(() => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
|
||||
});
|
||||
|
||||
const direct = [...searchCalls]
|
||||
.reverse()
|
||||
.find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0);
|
||||
searchState.completedParams = direct ?? null;
|
||||
act(() => {
|
||||
rerender(<FlightsMapStartPage />);
|
||||
});
|
||||
|
||||
const autoFallbackValue = lastMapFilterProps!["value"] as IFlightsMapFilterState;
|
||||
expect(autoFallbackValue.connections).toBe(true);
|
||||
|
||||
act(() => {
|
||||
(lastMapFilterProps!["onChange"] as (state: IFlightsMapFilterState) => void)({
|
||||
...autoFallbackValue,
|
||||
connections: false,
|
||||
});
|
||||
});
|
||||
|
||||
const userValue = lastMapFilterProps!["value"] as IFlightsMapFilterState;
|
||||
expect(userValue.connections).toBe(false);
|
||||
|
||||
const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
|
||||
expect(last?.connections).toBe(0);
|
||||
});
|
||||
|
||||
it("does not auto-fallback before the current direct search has completed", () => {
|
||||
searchState.routes = [];
|
||||
searchState.completedParams = null;
|
||||
render(<FlightsMapStartPage />);
|
||||
act(() => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
|
||||
@@ -503,8 +603,9 @@ describe("FlightsMapStartPage — C.4 integration", () => {
|
||||
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
|
||||
});
|
||||
|
||||
const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
|
||||
expect(last?.connections).toBe(1);
|
||||
const value = lastMapFilterProps!["value"] as IFlightsMapFilterState;
|
||||
expect(value.connections).toBe(false);
|
||||
expect(searchCalls.some((p) => p?.connections === 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -984,6 +1085,7 @@ describe("§4.1.24.5 R44: routes API uses [-1d, +6mo] date window", () => {
|
||||
beforeEach(() => {
|
||||
lastMapFilterProps = null;
|
||||
searchCalls.length = 0;
|
||||
calendarCalls.length = 0;
|
||||
dictState.dictionaries = buildDictionaries();
|
||||
dictState.loading = false;
|
||||
dictState.error = null;
|
||||
@@ -1000,21 +1102,36 @@ describe("§4.1.24.5 R44: routes API uses [-1d, +6mo] date window", () => {
|
||||
expect(callsWithDeparture).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("4.1.24-R44: dateFrom/dateTo span today to +6 months when departure is set", () => {
|
||||
it("4.1.24-R44: dateFrom starts yesterday and dateTo spans to +6 months when departure is set", () => {
|
||||
resetCrossSectionStore();
|
||||
setMapFilter({
|
||||
departure: "MOW", arrival: null, date: null,
|
||||
showInternal: false, showInternational: false, showTransfers: false,
|
||||
});
|
||||
render(<FlightsMapStartPage />);
|
||||
render(<FlightsMapStartPage today="20260506" />);
|
||||
|
||||
const callsWithDeparture = searchCalls.filter((p) => p?.departure === "MOW");
|
||||
if (callsWithDeparture.length > 0) {
|
||||
const call = callsWithDeparture[0]!;
|
||||
const today = new Date();
|
||||
const expectedYear = today.getFullYear().toString();
|
||||
expect(call.dateFrom.slice(0, 4)).toBe(expectedYear);
|
||||
expect(call.dateTo.slice(0, 4)).toMatch(/^\d{4}$/);
|
||||
}
|
||||
expect(callsWithDeparture).not.toHaveLength(0);
|
||||
expect(callsWithDeparture[0]).toMatchObject({
|
||||
departure: "MOW",
|
||||
dateFrom: "20260505",
|
||||
dateTo: "20261105",
|
||||
});
|
||||
});
|
||||
|
||||
it("4.1.24-R44: calendar availability request starts from the same yesterday anchor", () => {
|
||||
resetCrossSectionStore();
|
||||
setMapFilter({
|
||||
departure: "MOW", arrival: null, date: null,
|
||||
showInternal: false, showInternational: false, showTransfers: false,
|
||||
});
|
||||
render(<FlightsMapStartPage today="20260506" />);
|
||||
|
||||
const callsWithDeparture = calendarCalls.filter((p) => p?.departure === "MOW");
|
||||
expect(callsWithDeparture).not.toHaveLength(0);
|
||||
expect(callsWithDeparture[0]).toMatchObject({
|
||||
departure: "MOW",
|
||||
date: "20260505",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,18 @@ function addMonthsYyyymmdd(base: string, months: number): string {
|
||||
return `${ry}${rm}${rd}`;
|
||||
}
|
||||
|
||||
function addDaysYyyymmdd(base: string, days: number): string {
|
||||
const y = Number(base.slice(0, 4));
|
||||
const m = Number(base.slice(4, 6)) - 1;
|
||||
const d = Number(base.slice(6, 8));
|
||||
const date = new Date(y, m, d);
|
||||
date.setDate(date.getDate() + days);
|
||||
const ry = date.getFullYear().toString();
|
||||
const rm = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const rd = date.getDate().toString().padStart(2, "0");
|
||||
return `${ry}${rm}${rd}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,6 +106,10 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
|
||||
// there but always called to keep hook order stable.
|
||||
const fallbackTodayYmd = useRef(todayYyyymmdd()).current;
|
||||
const todayYmd = today ?? fallbackTodayYmd;
|
||||
const searchDateFromYmd = useMemo(
|
||||
() => addDaysYyyymmdd(todayYmd, -1),
|
||||
[todayYmd],
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const { language } = useLocale();
|
||||
|
||||
@@ -145,6 +161,8 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
|
||||
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
|
||||
filterState.connections ? 1 : 0,
|
||||
);
|
||||
const [connectionsFallbackSuppressed, setConnectionsFallbackSuppressed] =
|
||||
useState(false);
|
||||
|
||||
const persistFilterState = useCallback((newState: IFlightsMapFilterState) => {
|
||||
setFilterState(newState);
|
||||
@@ -171,31 +189,54 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
|
||||
return {
|
||||
departure: filterState.departure,
|
||||
arrival: filterState.arrival,
|
||||
dateFrom: todayYmd,
|
||||
dateTo: addMonthsYyyymmdd(todayYmd, 6),
|
||||
// Angular starts the flights-map destinations window at minDateCalendar
|
||||
// (today - 1 day). Keeping this aligned preserves Moscow domestic
|
||||
// airport-internal directions such as DME-SVO and ZIA-SVO.
|
||||
dateFrom: searchDateFromYmd,
|
||||
dateTo: addMonthsYyyymmdd(searchDateFromYmd, 6),
|
||||
connections: filterState.arrival ? effectiveConnections : 0,
|
||||
};
|
||||
}, [filterState.departure, filterState.arrival, effectiveConnections, todayYmd]);
|
||||
}, [
|
||||
filterState.departure,
|
||||
filterState.arrival,
|
||||
effectiveConnections,
|
||||
searchDateFromYmd,
|
||||
]);
|
||||
|
||||
// Build calendar params
|
||||
const calendarParams = useMemo<FlightsMapCalendarParams | null>(() => {
|
||||
if (!filterState.departure) return null;
|
||||
|
||||
return {
|
||||
date: todayYmd,
|
||||
date: searchDateFromYmd,
|
||||
departure: filterState.departure,
|
||||
arrival: filterState.arrival,
|
||||
connections: filterState.arrival ? filterState.connections : false,
|
||||
};
|
||||
}, [filterState.departure, filterState.arrival, filterState.connections, todayYmd]);
|
||||
}, [
|
||||
filterState.departure,
|
||||
filterState.arrival,
|
||||
filterState.connections,
|
||||
searchDateFromYmd,
|
||||
]);
|
||||
|
||||
const { routes, loading, error } = useFlightsMapSearch(searchParams);
|
||||
const { routes, loading, error, completedParams } =
|
||||
useFlightsMapSearch(searchParams);
|
||||
const { availableDays } = useFlightsMapCalendar(calendarParams);
|
||||
|
||||
// Auto-fallback: empty result, route mode, connections=0 → retry with 1.
|
||||
useEffect(() => {
|
||||
if (loading || error) return;
|
||||
if (!searchParams || searchParams.connections !== 0) return;
|
||||
const directSearchCompleted =
|
||||
completedParams?.departure === searchParams.departure &&
|
||||
completedParams?.arrival === searchParams.arrival &&
|
||||
completedParams?.dateFrom === searchParams.dateFrom &&
|
||||
completedParams?.dateTo === searchParams.dateTo &&
|
||||
completedParams?.connections === 0;
|
||||
if (!directSearchCompleted) return;
|
||||
if (effectiveConnections !== 0) return;
|
||||
if (connectionsFallbackSuppressed) return;
|
||||
if (!filterState.departure || !filterState.arrival) return;
|
||||
if (routes.length > 0) return;
|
||||
setEffectiveConnections(1);
|
||||
@@ -203,21 +244,53 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
|
||||
loading,
|
||||
error,
|
||||
effectiveConnections,
|
||||
connectionsFallbackSuppressed,
|
||||
completedParams,
|
||||
filterState.departure,
|
||||
filterState.arrival,
|
||||
routes,
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
// Reflect fallback in the UI toggle once.
|
||||
useEffect(() => {
|
||||
if (filterState.arrival && effectiveConnections === 1 && !filterState.connections) {
|
||||
if (
|
||||
filterState.arrival &&
|
||||
effectiveConnections === 1 &&
|
||||
!filterState.connections &&
|
||||
!connectionsFallbackSuppressed
|
||||
) {
|
||||
setFilterState((prev) => ({ ...prev, connections: true }));
|
||||
}
|
||||
}, [effectiveConnections, filterState.arrival, filterState.connections]);
|
||||
}, [
|
||||
effectiveConnections,
|
||||
filterState.arrival,
|
||||
filterState.connections,
|
||||
connectionsFallbackSuppressed,
|
||||
]);
|
||||
|
||||
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
|
||||
const sameRoute =
|
||||
newState.departure === filterState.departure &&
|
||||
newState.arrival === filterState.arrival;
|
||||
const turnedConnectionsOff =
|
||||
sameRoute && filterState.connections && !newState.connections;
|
||||
|
||||
setConnectionsFallbackSuppressed(
|
||||
turnedConnectionsOff
|
||||
? true
|
||||
: sameRoute && !newState.connections
|
||||
? connectionsFallbackSuppressed
|
||||
: false,
|
||||
);
|
||||
persistFilterState(newState);
|
||||
}, [persistFilterState]);
|
||||
}, [
|
||||
connectionsFallbackSuppressed,
|
||||
filterState.arrival,
|
||||
filterState.connections,
|
||||
filterState.departure,
|
||||
persistFilterState,
|
||||
]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(markerId: string) => {
|
||||
@@ -364,6 +437,7 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
|
||||
<FlightsMapFilter
|
||||
value={filterState}
|
||||
availableDays={availableDays}
|
||||
today={todayYmd}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ interface MockMap {
|
||||
_zoom: number;
|
||||
_eventHandlers: Record<string, Array<() => void>>;
|
||||
_addedLayers: Set<MockLayerGroup>;
|
||||
_container: HTMLElement;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
remove: ReturnType<typeof vi.fn>;
|
||||
addLayer: ReturnType<typeof vi.fn>;
|
||||
removeLayer: ReturnType<typeof vi.fn>;
|
||||
hasLayer: ReturnType<typeof vi.fn>;
|
||||
fitBounds: ReturnType<typeof vi.fn>;
|
||||
getContainer: ReturnType<typeof vi.fn>;
|
||||
getZoom: ReturnType<typeof vi.fn>;
|
||||
setZoom: (z: number) => void;
|
||||
fireZoomend: () => void;
|
||||
@@ -138,10 +141,12 @@ vi.mock("leaflet", () => {
|
||||
}
|
||||
|
||||
function mapFn(_container: unknown, _opts: unknown) {
|
||||
const container = document.createElement("div");
|
||||
const m: MockMap = {
|
||||
_zoom: 5,
|
||||
_eventHandlers: {},
|
||||
_addedLayers: new Set<MockLayerGroup>(),
|
||||
_container: container,
|
||||
on: vi.fn((evt: string, fn: () => void) => {
|
||||
m._eventHandlers[evt] ??= [];
|
||||
m._eventHandlers[evt]!.push(fn);
|
||||
@@ -165,6 +170,8 @@ vi.mock("leaflet", () => {
|
||||
);
|
||||
return owning ? m._addedLayers.has(owning) : false;
|
||||
}),
|
||||
fitBounds: vi.fn(() => m),
|
||||
getContainer: vi.fn(() => container),
|
||||
getZoom: vi.fn(() => m._zoom),
|
||||
setZoom: (z: number) => {
|
||||
m._zoom = z;
|
||||
@@ -189,6 +196,10 @@ vi.mock("leaflet", () => {
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
function latLngBounds(points: unknown[]) {
|
||||
return points;
|
||||
}
|
||||
|
||||
function polyline(latlngs: unknown, opts: unknown) {
|
||||
const p: MockPolyline = {
|
||||
latlngs,
|
||||
@@ -214,7 +225,7 @@ vi.mock("leaflet", () => {
|
||||
stop: vi.fn(),
|
||||
};
|
||||
|
||||
const L = { marker, layerGroup, map: mapFn, tileLayer, icon, latLng, polyline, popup, DomEvent };
|
||||
const L = { marker, layerGroup, map: mapFn, tileLayer, icon, latLng, latLngBounds, polyline, popup, DomEvent };
|
||||
return { default: L, ...L };
|
||||
});
|
||||
|
||||
@@ -223,6 +234,7 @@ vi.mock("leaflet", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { MapCanvas } from "./MapCanvas.js";
|
||||
import L from "leaflet";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -305,6 +317,74 @@ describe("MapCanvas — legacy (flat) path", () => {
|
||||
clickHandlers?.[0]?.({} as unknown);
|
||||
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
|
||||
});
|
||||
|
||||
it("stops tooltip pre-click events so the first city-label click is not consumed by the map", () => {
|
||||
const onMarkerClick = vi.fn();
|
||||
renderCanvas(
|
||||
[{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }],
|
||||
{ onMarkerClick },
|
||||
);
|
||||
|
||||
const marker = createdMarkers[0];
|
||||
if (!marker) {
|
||||
throw new Error("expected marker to be created");
|
||||
}
|
||||
|
||||
const mousedownHandlers = marker.tooltipHandlers["mousedown"];
|
||||
expect(mousedownHandlers?.length).toBeGreaterThan(0);
|
||||
vi.mocked(L.DomEvent.stop).mockClear();
|
||||
mousedownHandlers?.[0]?.({} as unknown);
|
||||
|
||||
expect(L.DomEvent.stop).toHaveBeenCalled();
|
||||
expect(onMarkerClick).not.toHaveBeenCalled();
|
||||
|
||||
marker.tooltipHandlers["click"]?.[0]?.({} as unknown);
|
||||
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
|
||||
});
|
||||
|
||||
it("stops marker click propagation before selecting the city", () => {
|
||||
const onMarkerClick = vi.fn();
|
||||
renderCanvas(
|
||||
[{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }],
|
||||
{ onMarkerClick },
|
||||
);
|
||||
|
||||
const marker = createdMarkers[0];
|
||||
if (!marker) {
|
||||
throw new Error("expected marker to be created");
|
||||
}
|
||||
|
||||
const markerClick = marker.on.mock.calls.find((call) => call[0] === "click")?.[1] as
|
||||
| ((event: unknown) => void)
|
||||
| undefined;
|
||||
expect(markerClick).toBeDefined();
|
||||
vi.mocked(L.DomEvent.stop).mockClear();
|
||||
markerClick?.({});
|
||||
|
||||
expect(L.DomEvent.stop).toHaveBeenCalled();
|
||||
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
|
||||
});
|
||||
|
||||
it("selects marker icon clicks at the map container capture layer", () => {
|
||||
const onMarkerClick = vi.fn();
|
||||
renderCanvas(
|
||||
[{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }],
|
||||
{ onMarkerClick },
|
||||
);
|
||||
|
||||
const map = createdMaps[0];
|
||||
if (!map) {
|
||||
throw new Error("expected map to be created");
|
||||
}
|
||||
const markerIcon = document.createElement("img");
|
||||
markerIcon.className = "leaflet-marker-icon";
|
||||
markerIcon.setAttribute("title", "MOW");
|
||||
map._container.append(markerIcon);
|
||||
|
||||
markerIcon.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
|
||||
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapCanvas — categorized: visibility predicate", () => {
|
||||
@@ -503,10 +583,7 @@ describe("MapCanvas — polylines (C.3)", () => {
|
||||
expect(createdPolylines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("draws multi-hop polylines through every city in the index", () => {
|
||||
// Route/domestic filtering happens upstream in filterRoutes — by the time
|
||||
// a polyline reaches MapCanvas, every city on its path is expected to be
|
||||
// drawn, independent of zoom-tier visibility.
|
||||
it("skips hidden intermediate cities when drawing multi-hop polylines", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[cm("A"), cm("B", "other"), cm("C")]}
|
||||
@@ -517,6 +594,10 @@ describe("MapCanvas — polylines (C.3)", () => {
|
||||
);
|
||||
|
||||
expect(createdPolylines.length).toBeGreaterThan(0);
|
||||
const latlngs = createdPolylines[0]!.latlngs as unknown[];
|
||||
expect(latlngs).toHaveLength(2);
|
||||
const hiddenIntermediate = createdMarkers.find((m) => m.options.title === "B")!;
|
||||
expect(hiddenIntermediate.getLatLng).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies connecting style for non-direct routes", () => {
|
||||
@@ -573,6 +654,25 @@ describe("MapCanvas — polylines (C.3)", () => {
|
||||
expect(createdPolylines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("fits the map to route markers when polylines change", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
markers={[
|
||||
cm("A"),
|
||||
cm("B"),
|
||||
]}
|
||||
polylines={[pl("line", ["A", "B"])]}
|
||||
tileUrl="t"
|
||||
/>,
|
||||
);
|
||||
|
||||
const map = createdMaps[0]!;
|
||||
expect(map.fitBounds).toHaveBeenCalled();
|
||||
const [bounds, options] = map.fitBounds.mock.calls.at(-1)!;
|
||||
expect(bounds).toHaveLength(2);
|
||||
expect(options).toMatchObject({ padding: [24, 24], maxZoom: 6 });
|
||||
});
|
||||
|
||||
it("does not force-open route endpoint tooltip when city is not intermediate", () => {
|
||||
render(
|
||||
<MapCanvas
|
||||
|
||||
@@ -45,6 +45,16 @@ function getIcon(style: MarkerStyle): L.Icon {
|
||||
return L.icon(opts);
|
||||
}
|
||||
|
||||
function findMarkerIdFromEventTarget(target: EventTarget | null): string | undefined {
|
||||
if (!(target instanceof Element)) return undefined;
|
||||
|
||||
const tooltip = target.closest<HTMLElement>(".city-label[data-marker-id]");
|
||||
if (tooltip?.dataset.markerId) return tooltip.dataset.markerId;
|
||||
|
||||
const marker = target.closest<HTMLElement>(".leaflet-marker-icon[title]");
|
||||
return marker?.getAttribute("title") ?? undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polyline styles
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -268,8 +278,8 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
|
||||
const resolved: L.Marker[] = [];
|
||||
for (const cityId of pl.cityIds) {
|
||||
const m = markerIndexRef.current.get(cityId);
|
||||
if (m && map.hasLayer(m)) resolved.push(m);
|
||||
const marker = markerIndexRef.current.get(cityId);
|
||||
if (marker && map.hasLayer(marker)) resolved.push(marker);
|
||||
}
|
||||
if (resolved.length < 2) continue;
|
||||
|
||||
@@ -286,6 +296,25 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
function syncRouteBounds(): void {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const points: L.LatLng[] = [];
|
||||
for (const pl of polylinesRef.current) {
|
||||
for (const cityId of pl.cityIds) {
|
||||
const marker = markerIndexRef.current.get(cityId);
|
||||
if (marker && map.hasLayer(marker)) points.push(marker.getLatLng());
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length < 2) return;
|
||||
map.fitBounds(L.latLngBounds(points), {
|
||||
padding: [24, 24],
|
||||
maxZoom: maxZoomRef.current,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Initialize map ---
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
@@ -306,6 +335,24 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
minZoom: minZoomRef.current,
|
||||
}).addTo(map);
|
||||
|
||||
const container = map.getContainer();
|
||||
const stopCityPointer = (event: Event) => {
|
||||
const markerId = findMarkerIdFromEventTarget(event.target);
|
||||
if (!markerId || !markerIndexRef.current.has(markerId)) return;
|
||||
event.stopPropagation();
|
||||
};
|
||||
const selectCityFromDom = (event: Event) => {
|
||||
const markerId = findMarkerIdFromEventTarget(event.target);
|
||||
if (!markerId || !markerIndexRef.current.has(markerId)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onMarkerClickRef.current?.(markerId);
|
||||
};
|
||||
container.addEventListener("pointerdown", stopCityPointer, true);
|
||||
container.addEventListener("mousedown", stopCityPointer, true);
|
||||
container.addEventListener("touchstart", stopCityPointer, true);
|
||||
container.addEventListener("click", selectCityFromDom, true);
|
||||
|
||||
markersLayerRef.current = L.layerGroup().addTo(map);
|
||||
polylinesLayerRef.current = L.layerGroup().addTo(map);
|
||||
popupsLayerRef.current = L.layerGroup().addTo(map);
|
||||
@@ -337,6 +384,10 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("pointerdown", stopCityPointer, true);
|
||||
container.removeEventListener("mousedown", stopCityPointer, true);
|
||||
container.removeEventListener("touchstart", stopCityPointer, true);
|
||||
container.removeEventListener("click", selectCityFromDom, true);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
markersLayerRef.current = null;
|
||||
@@ -388,13 +439,47 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
});
|
||||
|
||||
const tooltip = marker.getTooltip();
|
||||
const attachTooltipDomHandlers = () => {
|
||||
const element = tooltip?.getElement();
|
||||
if (!element || element.dataset.markerClickBound === m.id) return;
|
||||
|
||||
element.dataset.markerClickBound = m.id;
|
||||
element.dataset.markerId = m.id;
|
||||
|
||||
const stopTooltipPointerEvent = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
const selectFromTooltip = (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onMarkerClickRef.current?.(m.id);
|
||||
};
|
||||
|
||||
element.addEventListener("pointerdown", stopTooltipPointerEvent, true);
|
||||
element.addEventListener("pointerup", stopTooltipPointerEvent, true);
|
||||
element.addEventListener("mousedown", stopTooltipPointerEvent, true);
|
||||
element.addEventListener("mouseup", stopTooltipPointerEvent, true);
|
||||
element.addEventListener("touchstart", stopTooltipPointerEvent, true);
|
||||
element.addEventListener("touchend", stopTooltipPointerEvent, true);
|
||||
element.addEventListener("dblclick", stopTooltipPointerEvent, true);
|
||||
element.addEventListener("click", selectFromTooltip, true);
|
||||
};
|
||||
const stopTooltipEvent = (event: L.LeafletMouseEvent) => {
|
||||
L.DomEvent.stop(event);
|
||||
};
|
||||
marker.on("tooltipopen", attachTooltipDomHandlers);
|
||||
tooltip?.on("mousedown", stopTooltipEvent);
|
||||
tooltip?.on("mouseup", stopTooltipEvent);
|
||||
tooltip?.on("dblclick", stopTooltipEvent);
|
||||
tooltip?.on("preclick", stopTooltipEvent);
|
||||
tooltip?.on("click", (event: L.LeafletMouseEvent) => {
|
||||
L.DomEvent.stop(event);
|
||||
onMarkerClickRef.current?.(m.id);
|
||||
});
|
||||
}
|
||||
|
||||
marker.on("click", () => {
|
||||
marker.on("click", (event: L.LeafletMouseEvent) => {
|
||||
L.DomEvent.stop(event);
|
||||
onMarkerClickRef.current?.(m.id);
|
||||
});
|
||||
|
||||
@@ -426,6 +511,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
// --- Sync polylines ---
|
||||
useEffect(() => {
|
||||
syncPolylines();
|
||||
syncRouteBounds();
|
||||
}, [polylines]);
|
||||
|
||||
// --- Sync popups ---
|
||||
|
||||
@@ -102,6 +102,49 @@ describe("filterRoutes — connections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterRoutes — route mode without transfer-only toggle", () => {
|
||||
it("drops malformed direct routes that contain intermediate cities", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
{ route: ["SVO", "LED"], isDirect: true },
|
||||
{ route: ["SVO", "AER", "LED"], isDirect: true },
|
||||
{ route: ["SVO", "AER", "LED"], isDirect: false },
|
||||
];
|
||||
|
||||
const out = filterRoutes(
|
||||
routes,
|
||||
filter({ departure: "MOW", arrival: "LED", connections: false }),
|
||||
d,
|
||||
);
|
||||
|
||||
expect(out).toEqual([{ route: ["SVO", "LED"], isDirect: true }]);
|
||||
});
|
||||
|
||||
it("keeps connecting routes in route mode when transfer-only toggle is active", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
{ route: ["SVO", "LED"], isDirect: true },
|
||||
{ route: ["SVO", "AER", "LED"], isDirect: false },
|
||||
];
|
||||
|
||||
const out = filterRoutes(
|
||||
routes,
|
||||
filter({ departure: "MOW", arrival: "LED", connections: true }),
|
||||
d,
|
||||
);
|
||||
|
||||
expect(out).toEqual([{ route: ["SVO", "AER", "LED"], isDirect: false }]);
|
||||
});
|
||||
|
||||
it("does not apply endpoint-direct filtering in spider mode", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
{ route: ["SVO", "AER", "LED"], isDirect: true },
|
||||
];
|
||||
|
||||
const out = filterRoutes(routes, filter({ departure: "MOW" }), d);
|
||||
|
||||
expect(out).toEqual(routes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterRoutes — airport-code normalization", () => {
|
||||
it("treats airport codes (SVO, JFK) as their city codes (MOW, NYC)", () => {
|
||||
const routes: IFlightRoute[] = [
|
||||
|
||||
@@ -17,7 +17,10 @@ import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";
|
||||
|
||||
export function filterRoutes(
|
||||
routes: IFlightRoute[],
|
||||
filter: Pick<IFlightsMapFilterState, "domestic" | "international" | "connections">,
|
||||
filter: Pick<
|
||||
IFlightsMapFilterState,
|
||||
"departure" | "arrival" | "domestic" | "international" | "connections"
|
||||
>,
|
||||
dictionaries: IDictionaries,
|
||||
): IFlightRoute[] {
|
||||
const { domestic, international, connections } = filter;
|
||||
@@ -32,6 +35,17 @@ export function filterRoutes(
|
||||
r.route.some((code) => dictionaries.otherCityCodes.has(toCityCode(code)));
|
||||
|
||||
const hasConnections = (r: IFlightRoute): boolean => !r.isDirect;
|
||||
const isEndpointDirectRoute = (r: IFlightRoute): boolean => {
|
||||
if (!filter.departure || !filter.arrival) return true;
|
||||
if (!r.isDirect) return false;
|
||||
|
||||
const normalized = r.route.map(toCityCode);
|
||||
return (
|
||||
normalized.length === 2 &&
|
||||
normalized[0] === toCityCode(filter.departure) &&
|
||||
normalized[1] === toCityCode(filter.arrival)
|
||||
);
|
||||
};
|
||||
|
||||
const predicates: Array<(r: IFlightRoute) => boolean> = [];
|
||||
|
||||
@@ -39,6 +53,7 @@ export function filterRoutes(
|
||||
else if (international && !domestic) predicates.push(isInternational);
|
||||
|
||||
if (connections) predicates.push(hasConnections);
|
||||
else if (filter.arrival) predicates.push(isEndpointDirectRoute);
|
||||
|
||||
if (predicates.length === 0) return routes;
|
||||
return routes.filter((r) => predicates.every((p) => p(r)));
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface UseFlightsMapSearchResult {
|
||||
routes: IFlightRoute[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
completedParams: FlightsMapSearchParams | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
@@ -31,6 +32,8 @@ export function useFlightsMapSearch(
|
||||
const [routes, setRoutes] = useState<IFlightRoute[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [completedParams, setCompletedParams] =
|
||||
useState<FlightsMapSearchParams | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
@@ -44,18 +47,21 @@ export function useFlightsMapSearch(
|
||||
if (!paramsRef.current) {
|
||||
setRoutes([]);
|
||||
setLoading(false);
|
||||
setCompletedParams(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestParams = { ...paramsRef.current };
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setRoutes([]);
|
||||
|
||||
searchDestinations(client, paramsRef.current)
|
||||
searchDestinations(client, requestParams)
|
||||
.then((response: IDestinationsResponse) => {
|
||||
if (!cancelled) {
|
||||
setRoutes(response.data.routes);
|
||||
setCompletedParams(requestParams);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
@@ -63,6 +69,7 @@ export function useFlightsMapSearch(
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setRoutes([]);
|
||||
setCompletedParams(requestParams);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
@@ -80,5 +87,5 @@ export function useFlightsMapSearch(
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
return { routes, loading, error, refresh };
|
||||
return { routes, loading, error, completedParams, refresh };
|
||||
}
|
||||
|
||||
@@ -289,6 +289,19 @@ describe("getCalendarDays", () => {
|
||||
expect(result).toEqual(["2025-01-15", "2025-01-16", "2025-01-17"]);
|
||||
});
|
||||
|
||||
it("parses bitmask days from the requested base date", async () => {
|
||||
const { client } = createMockClient({ days: "0101" });
|
||||
|
||||
const result = await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "route",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
});
|
||||
|
||||
expect(result).toEqual(["20250116", "20250118"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty days string", async () => {
|
||||
const { client } = createMockClient({ days: "" });
|
||||
|
||||
|
||||
@@ -118,8 +118,8 @@ export async function getFlightDetails(
|
||||
* Maps to: `GET /v1/days/{date}/31/{searchType}/{searchParams}/board/`
|
||||
*
|
||||
* The API returns `{ days: "1110101..." }` — a 31-char bitmask where each
|
||||
* character represents a day starting from (baseDate - 1). '1' = flights
|
||||
* available, '0' = no flights. This function converts enabled positions
|
||||
* character represents a day starting from the requested base date. '1' =
|
||||
* flights available, '0' = no flights. This function converts enabled positions
|
||||
* into yyyyMMdd date strings.
|
||||
*/
|
||||
export async function getCalendarDays(
|
||||
@@ -157,10 +157,10 @@ function buildCalendarSearchSegment(params: CalendarParams): string {
|
||||
* Parse a calendar days bitmask into an array of yyyyMMdd date strings.
|
||||
*
|
||||
* The API returns a 31-char string of '1' and '0'. Each position maps to
|
||||
* a day starting from (baseDate - 1 day). Positions with '1' are enabled.
|
||||
* a day starting from baseDate. Positions with '1' are enabled.
|
||||
*
|
||||
* Matches Angular's search-page-base.component.ts logic:
|
||||
* date.setDate(date.getDate() - 1);
|
||||
* Matches Angular's updateCalendar() logic:
|
||||
* date.setDate(date.getDate() - 1); // caller-provided base date
|
||||
* for (var i = 0; i < res.days.length; i++) { ... date.setDate(date.getDate() + 1); }
|
||||
*/
|
||||
function parseCalendarDays(days: string, baseDate: string): string[] {
|
||||
@@ -177,7 +177,7 @@ function parseCalendarDays(days: string, baseDate: string): string[] {
|
||||
|
||||
/**
|
||||
* Convert a bitmask string to yyyyMMdd date strings.
|
||||
* Base date is the search date; iteration starts from (baseDate - 1 day).
|
||||
* Base date is the first calendar date returned by the endpoint.
|
||||
*/
|
||||
function bitmaskToDates(bitmask: string, baseDate: string): string[] {
|
||||
// Parse baseDate — could be "yyyy-MM-ddT00:00:00" or "yyyyMMdd"
|
||||
@@ -193,9 +193,7 @@ function bitmaskToDates(bitmask: string, baseDate: string): string[] {
|
||||
day = parseInt(baseDate.slice(6, 8), 10);
|
||||
}
|
||||
|
||||
// Start from baseDate - 1 day (matching Angular)
|
||||
const cursor = new Date(year, month, day);
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < bitmask.length; i++) {
|
||||
|
||||
@@ -35,7 +35,6 @@ export const BoardDetailsHeader: FC<BoardDetailsHeaderProps> = ({ flight, locale
|
||||
<FlightActions
|
||||
flight={flight}
|
||||
locale={locale}
|
||||
showShare
|
||||
showPrint={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
import { parseISO, format } from "date-fns";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import {
|
||||
buildAeroflotSbSearchUrl,
|
||||
formatAeroflotRouteDate,
|
||||
} from "@/shared/booking/aeroflot.js";
|
||||
import type { ISimpleFlight } from "../../types.js";
|
||||
import "./actions.scss";
|
||||
|
||||
@@ -24,9 +27,11 @@ function buildBuyTicketUrl(flight: ISimpleFlight): string {
|
||||
if (!firstLeg || !lastLeg) return "";
|
||||
const dep = firstLeg.departure.scheduled.airportCode;
|
||||
const arr = lastLeg.arrival.scheduled.airportCode;
|
||||
const depDate = parseISO(firstLeg.departure.times.scheduledDeparture.utc);
|
||||
const date = format(depDate, "yyyyMMdd");
|
||||
return `https://www.aeroflot.ru/sb/app/ru-ru#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${date}.${arr}&autosearch=Y`;
|
||||
const date = formatAeroflotRouteDate(
|
||||
firstLeg.departure.times.scheduledDeparture.local ||
|
||||
firstLeg.departure.times.scheduledDeparture.utc,
|
||||
);
|
||||
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
|
||||
}
|
||||
|
||||
export const BuyTicketButton: FC<BuyTicketButtonProps> = ({ flight }) => {
|
||||
|
||||
@@ -88,6 +88,13 @@ describe("DetailsHeaderBadge", () => {
|
||||
expect(screen.getByText("SU 0022")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-13: renders suffix in primary flight number", () => {
|
||||
const flight = makeDirect();
|
||||
flight.flightId = { ...flight.flightId, flightNumber: "0038", suffix: "D" };
|
||||
render(<DetailsHeaderBadge flight={flight} locale="ru" />);
|
||||
expect(screen.getByText("SU 0038D")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders operator-logo", () => {
|
||||
render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" />);
|
||||
expect(screen.getByTestId("operator-logo")).toBeTruthy();
|
||||
|
||||
@@ -39,7 +39,7 @@ export const DetailsHeaderBadge: FC<DetailsHeaderBadgeProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const codeshareLegs = getCodeshareLegs(flight);
|
||||
const primaryNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
|
||||
const primaryNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}${flight.flightId.suffix ?? ""}`;
|
||||
const carrier = operatingCarrier(flight.operatingBy) ?? flight.flightId.carrier;
|
||||
|
||||
return (
|
||||
|
||||
@@ -78,9 +78,9 @@ function makeFlight(): ISimpleFlight {
|
||||
}
|
||||
|
||||
describe("FlightActions", () => {
|
||||
it("renders share, buy, register by default (status hidden)", () => {
|
||||
it("renders buy and register by default, without share or status", () => {
|
||||
render(<FlightActions flight={makeFlight()} locale="ru" />);
|
||||
expect(screen.getByTestId("share-button")).toBeTruthy();
|
||||
expect(screen.queryByTestId("share-button")).toBeNull();
|
||||
expect(screen.getByTestId("buy-ticket-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("registration-button")).toBeTruthy();
|
||||
expect(screen.queryByTestId("flight-status-button")).toBeNull();
|
||||
@@ -91,11 +91,6 @@ describe("FlightActions", () => {
|
||||
expect(screen.getByTestId("flight-status-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides share button when showShare=false", () => {
|
||||
render(<FlightActions flight={makeFlight()} locale="ru" showShare={false} />);
|
||||
expect(screen.queryByTestId("share-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides buy ticket when canBuyTicket returns false", async () => {
|
||||
const mod = await import("./visibility/buyTicketVisibility.js");
|
||||
vi.mocked(mod.canBuyTicket).mockReturnValue(false);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { canViewFlightStatus } from "./visibility/flightStatusVisibility.js";
|
||||
import { BuyTicketButton } from "./BuyTicketButton.js";
|
||||
import { RegistrationButton } from "./RegistrationButton.js";
|
||||
import { FlightStatusButton } from "./FlightStatusButton.js";
|
||||
import { ShareButton } from "./ShareButton.js";
|
||||
import { PrintButton } from "./PrintButton.js";
|
||||
|
||||
export interface FlightActionsProps {
|
||||
@@ -16,7 +15,6 @@ export interface FlightActionsProps {
|
||||
locale: string;
|
||||
showStatus?: boolean;
|
||||
showPrint?: boolean;
|
||||
showShare?: boolean;
|
||||
showRegister?: boolean;
|
||||
showBuy?: boolean;
|
||||
/**
|
||||
@@ -34,7 +32,6 @@ export const FlightActions: FC<FlightActionsProps> = ({
|
||||
locale,
|
||||
showStatus = false,
|
||||
showPrint = false,
|
||||
showShare = true,
|
||||
showRegister = true,
|
||||
showBuy = true,
|
||||
forceBuy = false,
|
||||
@@ -49,12 +46,9 @@ export const FlightActions: FC<FlightActionsProps> = ({
|
||||
showStatus &&
|
||||
canViewFlightStatus(flight, now, flightStatusAvailableFromHours, AIRLINES_WITH_STATUS);
|
||||
|
||||
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
|
||||
|
||||
return (
|
||||
<div className="flight-actions" data-testid="flight-actions">
|
||||
{showPrint && <PrintButton flight={flight} />}
|
||||
{showShare && <ShareButton url={shareUrl} locale={locale} />}
|
||||
{canBuy && <BuyTicketButton flight={flight} locale={locale} />}
|
||||
{canReg && <RegistrationButton flight={flight} />}
|
||||
{canStatus && <FlightStatusButton flight={flight} locale={locale} />}
|
||||
|
||||
@@ -87,8 +87,8 @@ describe("LastUpdate", () => {
|
||||
expect(ts.textContent).toMatch(/\d{2}:\d{2}\s+\d{2}\.\d{2}\.\d{4}/);
|
||||
});
|
||||
|
||||
it("renders share button", () => {
|
||||
it("does not render a share button", () => {
|
||||
render(<LastUpdate flight={makeFlight("2026-04-20T12:34:00Z")} locale="ru" />);
|
||||
expect(screen.getByTestId("share-button")).toBeTruthy();
|
||||
expect(screen.queryByTestId("share-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { ISimpleFlight } from "../../types.js";
|
||||
import { ShareButton } from "./ShareButton.js";
|
||||
|
||||
export interface LastUpdateProps {
|
||||
flight: ISimpleFlight;
|
||||
@@ -21,7 +20,7 @@ function formatStamp(d: Date): string {
|
||||
* timestamp. We mirror that here: capture `Date.now()` the first time we
|
||||
* see a given flight.id, then re-capture whenever the id changes.
|
||||
*/
|
||||
export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale }) => {
|
||||
export const LastUpdate: FC<LastUpdateProps> = ({ flight }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loadedAt, setLoadedAt] = useState<Date>(() => new Date());
|
||||
const seenFlightIdRef = useRef<string | null>(null);
|
||||
@@ -34,11 +33,9 @@ export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale }) => {
|
||||
}, [flight.id]);
|
||||
|
||||
const timestamp = formatStamp(loadedAt);
|
||||
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
|
||||
|
||||
return (
|
||||
<div className="last-update">
|
||||
<ShareButton url={shareUrl} locale={locale} />
|
||||
<span className="last-update__description">
|
||||
<span>{t("SHARED.LAST-UPDATE")}:</span>
|
||||
<span className="last-update__time" data-testid="last-update-timestamp">
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ShareButton } from "./ShareButton.js";
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
describe("ShareButton", () => {
|
||||
it("renders button with testid share-button", () => {
|
||||
render(<ShareButton url="https://example.com" locale="en" />);
|
||||
expect(screen.getByTestId("share-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("panel is closed by default", () => {
|
||||
render(<ShareButton url="https://example.com" locale="en" />);
|
||||
expect(screen.queryByTestId("share-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("click opens the share panel", () => {
|
||||
render(<ShareButton url="https://example.com" locale="en" />);
|
||||
fireEvent.click(screen.getByTestId("share-button"));
|
||||
expect(screen.getByTestId("share-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("second click closes the share panel", () => {
|
||||
render(<ShareButton url="https://example.com" locale="en" />);
|
||||
const btn = screen.getByTestId("share-button");
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByTestId("share-panel")).toBeTruthy();
|
||||
fireEvent.click(btn);
|
||||
expect(screen.queryByTestId("share-panel")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { type FC, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { SharePanel } from "./SharePanel.js";
|
||||
import "./actions.scss";
|
||||
|
||||
export interface ShareButtonProps {
|
||||
url: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export const ShareButton: FC<ShareButtonProps> = ({ url, locale }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="share-button-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="flight-action-btn flight-action-btn--transparent"
|
||||
data-testid="share-button"
|
||||
title={t("BOARD.SHARE")}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={t("BOARD.SHARE")}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && <SharePanel url={url} locale={locale} onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SharePanel } from "./SharePanel.js";
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
describe("SharePanel", () => {
|
||||
let writeTextMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: writeTextMock },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders 3 social links for en locale (no Weibo)", () => {
|
||||
render(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
|
||||
expect(screen.getByTestId("share-facebook")).toBeTruthy();
|
||||
expect(screen.getByTestId("share-vk")).toBeTruthy();
|
||||
expect(screen.getByTestId("share-twitter")).toBeTruthy();
|
||||
expect(screen.queryByTestId("share-weibo")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders 4 social links for zh locale (includes Weibo)", () => {
|
||||
render(<SharePanel url="https://example.com/flight" locale="zh" onClose={() => {}} />);
|
||||
expect(screen.getByTestId("share-facebook")).toBeTruthy();
|
||||
expect(screen.getByTestId("share-vk")).toBeTruthy();
|
||||
expect(screen.getByTestId("share-twitter")).toBeTruthy();
|
||||
expect(screen.getByTestId("share-weibo")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Facebook href contains encoded URL", () => {
|
||||
render(<SharePanel url="https://example.com/flight?a=1" locale="en" onClose={() => {}} />);
|
||||
const link = screen.getByTestId("share-facebook") as HTMLAnchorElement;
|
||||
expect(link.href).toContain(encodeURIComponent("https://example.com/flight?a=1"));
|
||||
});
|
||||
|
||||
it("copy button calls navigator.clipboard.writeText and onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<SharePanel url="https://example.com/flight" locale="en" onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId("share-copy"));
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith("https://example.com/flight");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { type FC, type MouseEvent, useEffect } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "./actions.scss";
|
||||
|
||||
export interface SharePanelProps {
|
||||
url: string;
|
||||
locale: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SharePanel: FC<SharePanelProps> = ({ url, locale, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const encoded = encodeURIComponent(url);
|
||||
|
||||
// Close on Escape — matches Angular's PrimeNG p-overlayPanel dismissable behaviour.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopy = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
onClose();
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-panel" data-testid="share-panel" role="dialog" aria-label={t("BOARD.SHARE")}>
|
||||
<div className="share-elements">
|
||||
<div>
|
||||
<a
|
||||
className="share-element facebook"
|
||||
data-testid="share-facebook"
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.FACEBOOK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element vk"
|
||||
data-testid="share-vk"
|
||||
href={`https://vk.com/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.VK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element twitter"
|
||||
data-testid="share-twitter"
|
||||
href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.TWITTER")}
|
||||
</a>
|
||||
</div>
|
||||
{locale === "zh" && (
|
||||
<div>
|
||||
<a
|
||||
className="share-element weibo"
|
||||
data-testid="share-weibo"
|
||||
href={`https://service.weibo.com/share/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.WEIBO")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<a
|
||||
className="share-element copy"
|
||||
data-testid="share-copy"
|
||||
href="#"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{t("SHARE.COPY")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -155,6 +155,22 @@ describe("DayTabs", () => {
|
||||
expect(screen.getByTestId("day-select")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deduplicates available dates before rendering the mobile select", () => {
|
||||
render(
|
||||
<DayTabs
|
||||
selectedDate="20260416"
|
||||
availableDates={["20260416", "20260416", "20260417"]}
|
||||
daysBefore={1}
|
||||
daysAfter={2}
|
||||
locale="en"
|
||||
onNavigate={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const options = screen.getByTestId("day-select").querySelectorAll("option");
|
||||
expect(options).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── TZ §4.1.13.1 compliance tests ──────────────────────────────────────
|
||||
|
||||
it("4.1.13.1: active range today-1 to today+14 yields 16 total dates", () => {
|
||||
|
||||
@@ -68,8 +68,15 @@ export const DayTabs: FC<DayTabsProps> = ({
|
||||
// the route has no day data). Treat every date as tappable in that
|
||||
// case — matches Angular where the tabs stay enabled until we *know*
|
||||
// the upstream reports no flights for a given day.
|
||||
const availableSet = useMemo(() => new Set(availableDates), [availableDates]);
|
||||
const disableByAvailability = availableDates.length > 0;
|
||||
const uniqueAvailableDates = useMemo(
|
||||
() => Array.from(new Set(availableDates)),
|
||||
[availableDates],
|
||||
);
|
||||
const availableSet = useMemo(
|
||||
() => new Set(uniqueAvailableDates),
|
||||
[uniqueAvailableDates],
|
||||
);
|
||||
const disableByAvailability = uniqueAvailableDates.length > 0;
|
||||
|
||||
const visibleDates = allDates.slice(
|
||||
currentPage * PAGE_SIZE,
|
||||
@@ -150,7 +157,7 @@ export const DayTabs: FC<DayTabsProps> = ({
|
||||
</div>
|
||||
<DaySelect
|
||||
selectedDate={selectedDate}
|
||||
availableDates={availableDates}
|
||||
availableDates={uniqueAvailableDates}
|
||||
locale={locale}
|
||||
onNavigate={onNavigate}
|
||||
{...(mobileCaptionKey ? { captionKey: mobileCaptionKey } : {})}
|
||||
|
||||
@@ -60,6 +60,15 @@ describe("FlightsMiniListItem", () => {
|
||||
expect(screen.getByText(/SU\s*0022/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-13: renders suffix in flight number", () => {
|
||||
const flight = makeDirectFlight({
|
||||
id: "SU0038D-20260514",
|
||||
flightId: { carrier: "SU", flightNumber: "0038", suffix: "D", date: "20260514" },
|
||||
});
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
expect(screen.getByText(/SU\s*0038D/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders departure and arrival times", () => {
|
||||
const flight = makeDirectFlight();
|
||||
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Link } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { ISimpleFlight, IFlightLeg } from "../../types.js";
|
||||
import { buildOnlineBoardUrl } from "../../url.js";
|
||||
import { getFlightSearchDate } from "../../flightSearchDate.js";
|
||||
import {
|
||||
formatLocalTime,
|
||||
formatDayMonthYear,
|
||||
@@ -51,7 +52,7 @@ export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniList
|
||||
carrier: flight.flightId.carrier,
|
||||
flightNumber: flight.flightId.flightNumber,
|
||||
...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}),
|
||||
date: flight.flightId.date,
|
||||
date: getFlightSearchDate(flight),
|
||||
})}`;
|
||||
|
||||
const depIso = getDepTimeIso(dep);
|
||||
@@ -126,14 +127,14 @@ export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniList
|
||||
<div className="mini-list__flight-number">
|
||||
{(() => {
|
||||
const childIds = (flight as typeof flight & {
|
||||
_childFlightIds?: { carrier: string; flightNumber: string }[];
|
||||
_childFlightIds?: { carrier: string; flightNumber: string; suffix?: string }[];
|
||||
})._childFlightIds;
|
||||
if (childIds && childIds.length > 1) {
|
||||
return childIds
|
||||
.map((c) => `${c.carrier} ${c.flightNumber}`)
|
||||
.map((c) => `${c.carrier} ${c.flightNumber}${c.suffix ?? ""}`)
|
||||
.join(", ");
|
||||
}
|
||||
return `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
|
||||
return `${flight.flightId.carrier} ${flight.flightId.flightNumber}${flight.flightId.suffix ?? ""}`;
|
||||
})()}
|
||||
<span
|
||||
className={`mini-list__status-icon ${iconColor}`}
|
||||
|
||||
@@ -173,6 +173,29 @@ describe("OnlineBoardDetailsPage", () => {
|
||||
expect(screen.getAllByText("SU 100").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-13: renders suffix in the details page flight number", () => {
|
||||
const suffixFlight = {
|
||||
...mockFlight,
|
||||
id: "SU0038D-20260514",
|
||||
flightId: { carrier: "SU", flightNumber: "0038", suffix: "D", date: "20260514" },
|
||||
} as IDirectFlight;
|
||||
mockState = {
|
||||
flight: suffixFlight,
|
||||
allFlights: [suffixFlight],
|
||||
daysOfFlight: ["20260514"],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={{ carrier: "SU", flightNumber: "0038", suffix: "D", date: "20260514" }}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://www.aeroflot.ru"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("SU 0038D").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders loading skeleton", () => {
|
||||
mockState = { flight: null, allFlights: [], daysOfFlight: [], loading: true, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
@@ -440,6 +463,20 @@ describe("OnlineBoardDetailsPage", () => {
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/SU1234-20260515");
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-13: flight context keeps suffix in breadcrumb back URL", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-flight-SU0038D-20260514");
|
||||
const { container } = render(
|
||||
<OnlineBoardDetailsPage
|
||||
flightId={mockFlightId}
|
||||
locale="ru"
|
||||
canonicalOrigin="https://example.com"
|
||||
/>,
|
||||
);
|
||||
const link = getCrumbLink(container, "BREADCRUMBS.FLIGHT-NUMBER");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/SU0038D-20260514");
|
||||
});
|
||||
|
||||
it("route context → leaf 'Маршрут: …' linking back to /route/", () => {
|
||||
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-route-MOW-LED-20260515");
|
||||
const { container } = render(
|
||||
|
||||
@@ -18,10 +18,12 @@ import { PageLayout } from "@/ui/layout/PageLayout.js";
|
||||
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
|
||||
import { useFlightDetails } from "../hooks/useFlightDetails.js";
|
||||
import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
|
||||
import { useStaleDataTimers } from "../hooks/useStaleDataTimers.js";
|
||||
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
||||
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
||||
import { buildFlightJsonLd } from "../json-ld.js";
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import { buildOnlineBoardUrl, parseFlightUrlParams } from "../url.js";
|
||||
import { getFlightSearchDate } from "../flightSearchDate.js";
|
||||
import { useCityName, useStationDisplayName } from "@/shared/hooks/useDictionaries.js";
|
||||
import { FlightDetailsAccordion } from "./details-panels/FlightDetailsAccordion.js";
|
||||
import { FlightsMiniList } from "./FlightsMiniList/index.js";
|
||||
@@ -30,6 +32,7 @@ import { BoardDetailsHeader } from "./BoardDetailsHeader/index.js";
|
||||
import { DetailsBackButton } from "./DetailsBackButton/index.js";
|
||||
import { FlightSchedule } from "./FlightSchedule/index.js";
|
||||
import { FullRouteTimeline } from "./FullRouteTimeline/index.js";
|
||||
import { StaleDataOverlay } from "./StaleDataOverlay.js";
|
||||
import { TransferBar } from "./TransferBar/index.js";
|
||||
import type { IParsedFlightId, IFlightLeg, FlightStatus as FlightStatusType } from "../types.js";
|
||||
import {
|
||||
@@ -64,6 +67,10 @@ export interface OnlineBoardDetailsPageProps {
|
||||
canonicalOrigin: string;
|
||||
}
|
||||
|
||||
function parseParentFlightRequest(flightNumber: string, date: string): IParsedFlightId | null {
|
||||
return parseFlightUrlParams(`${flightNumber}-${date}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One side of a leg's station block — station code + airport name + city
|
||||
* + terminal, plus scheduled / expected / actual times formatted as
|
||||
@@ -384,19 +391,21 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`,
|
||||
dates: `${flightId.date.slice(0, 4)}-${flightId.date.slice(4, 6)}-${flightId.date.slice(6, 8)}T00:00:00`,
|
||||
};
|
||||
const { flight: firstFlight, allFlights, daysOfFlight, loading, error } = useFlightDetails(detailsParams);
|
||||
const { flight: firstFlight, allFlights, daysOfFlight, loading, error, refresh } = useFlightDetails(detailsParams);
|
||||
|
||||
// Pick the flight matching the URL's flightId (date-based match). The API
|
||||
// response may contain multiple flights with the same flight number on
|
||||
// different dates; we need the one the user actually navigated to.
|
||||
// different dates; match Angular's dateToSearchBy, not backend flightId.date.
|
||||
const flight =
|
||||
allFlights.find((f) => f.flightId.date === flightId.date) ?? firstFlight;
|
||||
allFlights.find((f) => getFlightSearchDate(f) === flightId.date) ?? firstFlight;
|
||||
|
||||
// Live updates via SignalR
|
||||
const { flight: liveFlight, connectionStatus } = useLiveFlightDetails(
|
||||
flightId,
|
||||
flight?.flightId ?? null,
|
||||
flight,
|
||||
refresh,
|
||||
);
|
||||
const isStale = useStaleDataTimers();
|
||||
|
||||
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
|
||||
|
||||
@@ -447,13 +456,14 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
const backUrl = (() => {
|
||||
switch (parentRequest.kind) {
|
||||
case "flight": {
|
||||
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
|
||||
if (!m || !m[1] || !m[2]) return `/${locale}/onlineboard`;
|
||||
const parsed = parseParentFlightRequest(parentRequest.flightNumber, parentRequest.date);
|
||||
if (!parsed) return `/${locale}/onlineboard`;
|
||||
return `/${locale}/${buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: m[1],
|
||||
flightNumber: m[2],
|
||||
date: parentRequest.date,
|
||||
carrier: parsed.carrier,
|
||||
flightNumber: parsed.flightNumber,
|
||||
...(parsed.suffix ? { suffix: parsed.suffix } : {}),
|
||||
date: parsed.date,
|
||||
})}`;
|
||||
}
|
||||
case "departure":
|
||||
@@ -481,8 +491,10 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
switch (parentRequest.kind) {
|
||||
case "flight": {
|
||||
// Angular renders "Рейс: SU 6188" — carrier and number space-separated
|
||||
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
|
||||
const formatted = m?.[1] && m?.[2] ? `${m[1]} ${m[2]}` : parentRequest.flightNumber;
|
||||
const parsed = parseParentFlightRequest(parentRequest.flightNumber, parentRequest.date);
|
||||
const formatted = parsed
|
||||
? `${parsed.carrier} ${parsed.flightNumber}${parsed.suffix ?? ""}`
|
||||
: parentRequest.flightNumber;
|
||||
return t("BREADCRUMBS.FLIGHT-NUMBER", { flightNumber: formatted });
|
||||
}
|
||||
case "departure":
|
||||
@@ -592,7 +604,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
}
|
||||
|
||||
const legs = getLegs(displayFlight);
|
||||
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`;
|
||||
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}${displayFlight.flightId.suffix ?? ""}`;
|
||||
|
||||
const firstLeg = legs[0];
|
||||
const lastLeg = legs[legs.length - 1];
|
||||
@@ -610,6 +622,9 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
return (
|
||||
<>
|
||||
<JsonLdRenderer data={jsonLd} />
|
||||
{isStale && (
|
||||
<StaleDataOverlay message={t("SHARED.STALE-DATA-REFRESH")} />
|
||||
)}
|
||||
<PageLayout
|
||||
headerLeft={<DetailsBackButton locale={locale} />}
|
||||
title={<h1 className="flight-details__flight-number">{pageTitle}</h1>}
|
||||
|
||||
@@ -14,6 +14,11 @@ import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
const calendarDaysMock = vi.hoisted(() => ({
|
||||
days: [] as string[],
|
||||
loaded: false,
|
||||
params: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
@@ -35,7 +40,14 @@ vi.mock("@/shared/dictionaries/index.js", () => ({
|
||||
// useCalendarDays would otherwise pull the api provider into the test
|
||||
// tree just for the disabled-dates wiring added in TIRREDESIGN-12.
|
||||
vi.mock("../hooks/useCalendarDays.js", () => ({
|
||||
useCalendarDays: () => ({ days: [], loading: false }),
|
||||
useCalendarDays: (params: unknown) => {
|
||||
calendarDaysMock.params.push(params);
|
||||
return {
|
||||
days: calendarDaysMock.days,
|
||||
loading: false,
|
||||
loaded: calendarDaysMock.loaded,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/state/crossSectionNavigation.js", () => ({
|
||||
@@ -46,10 +58,15 @@ vi.mock("@/shared/state/crossSectionNavigation.js", () => ({
|
||||
vi.mock("primereact/calendar", () => ({
|
||||
Calendar: (props: Record<string, unknown>) => {
|
||||
const inputRef = props["inputRef"] as React.RefObject<HTMLInputElement> | undefined;
|
||||
const formatLocalYmd = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return (
|
||||
<input
|
||||
data-testid={props["data-testid"] as string}
|
||||
placeholder={props["placeholder"] as string}
|
||||
data-disabled-dates={((props["disabledDates"] as Date[] | undefined) ?? [])
|
||||
.map(formatLocalYmd)
|
||||
.join(",")}
|
||||
readOnly
|
||||
ref={inputRef}
|
||||
/>
|
||||
@@ -96,13 +113,21 @@ function renderRouteTab() {
|
||||
render(<OnlineBoardFilter initialTab="route" />);
|
||||
}
|
||||
|
||||
function resetTestState() {
|
||||
vi.clearAllMocks();
|
||||
calendarDaysMock.days = [];
|
||||
calendarDaysMock.loaded = false;
|
||||
calendarDaysMock.params = [];
|
||||
_sliderOnChange = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OnlineBoardFilter – clear-button (X) per TZ §4.1.9 Tables 11/12", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -214,8 +239,7 @@ describe("OnlineBoardFilter – clear-button (X) per TZ §4.1.9 Tables 11/12", (
|
||||
|
||||
describe("OnlineBoardFilter – time slider 1h minimum gap per TZ §4.1.9 Tables 12", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_sliderOnChange = null;
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("4.1.9-R: time slider enforces 1h minimum gap (zero gap → to clamped to from + 60)", () => {
|
||||
@@ -247,11 +271,166 @@ describe("OnlineBoardFilter – time slider 1h minimum gap per TZ §4.1.9 Tables
|
||||
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
|
||||
expect(value?.textContent).toBe("23:00 — 24:00");
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-11: syncs the slider label when URL time params change", () => {
|
||||
const { rerender } = render(
|
||||
<OnlineBoardFilter
|
||||
initialTab="route"
|
||||
initialTimeFrom="0900"
|
||||
initialTimeTo="2100"
|
||||
/>,
|
||||
);
|
||||
|
||||
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
|
||||
expect(value?.textContent).toBe("09:00 — 21:00");
|
||||
|
||||
rerender(
|
||||
<OnlineBoardFilter
|
||||
initialTab="route"
|
||||
initialTimeFrom="1400"
|
||||
initialTimeTo="1800"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(value?.textContent).toBe("14:00 — 18:00");
|
||||
|
||||
rerender(<OnlineBoardFilter initialTab="route" />);
|
||||
|
||||
expect(value?.textContent).toBe("00:00 — 24:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnlineBoardFilter – submit lock", () => {
|
||||
beforeEach(() => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("keeps the same submitted route search locked", () => {
|
||||
render(
|
||||
<OnlineBoardFilter
|
||||
initialTab="route"
|
||||
initialDeparture="MOW"
|
||||
initialArrival="LED"
|
||||
initialDate="20260515"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
|
||||
expect(screen.getByTestId("search-submit")).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("unlocks route search after the user changes the time range", () => {
|
||||
render(
|
||||
<OnlineBoardFilter
|
||||
initialTab="route"
|
||||
initialDeparture="MOW"
|
||||
initialArrival="LED"
|
||||
initialDate="20260515"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(screen.getByTestId("search-submit")).toHaveProperty("disabled", true);
|
||||
|
||||
act(() => {
|
||||
_sliderOnChange?.({ value: [600, 1440] });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("search-submit")).toHaveProperty("disabled", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnlineBoardFilter – operating days calendar parity (TIRREDESIGN-12)", () => {
|
||||
beforeEach(() => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("requests board calendar days from the calendar minimum date", () => {
|
||||
calendarDaysMock.days = ["20260505"];
|
||||
calendarDaysMock.loaded = true;
|
||||
|
||||
render(
|
||||
<OnlineBoardFilter
|
||||
initialTab="route"
|
||||
initialDeparture="SVO"
|
||||
initialArrival="GDX"
|
||||
today="20260505"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(calendarDaysMock.params).toContainEqual({
|
||||
date: "2026-05-04",
|
||||
searchType: "route",
|
||||
departure: "SVO",
|
||||
arrival: "GDX",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables dates missing from a loaded board operating-days response", () => {
|
||||
calendarDaysMock.days = ["20260505"];
|
||||
calendarDaysMock.loaded = true;
|
||||
|
||||
render(
|
||||
<OnlineBoardFilter
|
||||
initialTab="route"
|
||||
initialDeparture="SVO"
|
||||
initialArrival="GDX"
|
||||
today="20260505"
|
||||
/>,
|
||||
);
|
||||
|
||||
const disabledDates = screen
|
||||
.getByTestId("date-input")
|
||||
.getAttribute("data-disabled-dates")
|
||||
?.split(",");
|
||||
|
||||
expect(disabledDates).toContain("2026-05-04");
|
||||
expect(disabledDates).not.toContain("2026-05-05");
|
||||
expect(disabledDates).toContain("2026-05-06");
|
||||
});
|
||||
|
||||
it("treats a loaded all-zero board bitmask as no available dates", () => {
|
||||
calendarDaysMock.days = [];
|
||||
calendarDaysMock.loaded = true;
|
||||
|
||||
render(
|
||||
<OnlineBoardFilter
|
||||
initialTab="flight"
|
||||
initialFlightNumber="9999"
|
||||
today="20260505"
|
||||
/>,
|
||||
);
|
||||
|
||||
const disabledDates = screen
|
||||
.getByTestId("date-input")
|
||||
.getAttribute("data-disabled-dates")
|
||||
?.split(",");
|
||||
|
||||
expect(disabledDates).toContain("2026-05-04");
|
||||
expect(disabledDates).toContain("2026-05-05");
|
||||
});
|
||||
|
||||
it("requests flight calendar days with a normalized suffix flight number", () => {
|
||||
render(
|
||||
<OnlineBoardFilter
|
||||
initialTab="flight"
|
||||
initialFlightNumber="38d"
|
||||
today="20260505"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(calendarDaysMock.params).toContainEqual({
|
||||
date: "2026-05-04",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU0038D",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnlineBoardFilter – flight-number validation per TZ §4.1.9.3", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("4.1.9.3-R: '38' submits as SU0038 in URL", () => {
|
||||
@@ -288,7 +467,26 @@ describe("OnlineBoardFilter – flight-number validation per TZ §4.1.9.3", () =
|
||||
expect(url).toMatch(/SU1234/);
|
||||
});
|
||||
|
||||
it("4.1.9.3-R: letters in flight number show error and block submit", () => {
|
||||
it("TIRREDESIGN-13: suffix flight number submits as normalized SU0038D URL", () => {
|
||||
render(<OnlineBoardFilter initialTab="flight" initialDate="20260601" />);
|
||||
fireEvent.change(screen.getByTestId("flight-number-input"), {
|
||||
target: { value: "38d" },
|
||||
});
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalled();
|
||||
const url = mockNavigate.mock.calls[0]![0] as string;
|
||||
expect(url).toContain("/ru-ru/onlineboard/flight/SU0038D-20260601");
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-13: suffix flight number normalizes on blur like Angular", () => {
|
||||
render(<OnlineBoardFilter initialTab="flight" />);
|
||||
const input = screen.getByTestId("flight-number-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "123d" } });
|
||||
fireEvent.blur(input);
|
||||
expect(input.value).toBe("0123D");
|
||||
});
|
||||
|
||||
it("4.1.9.3-R: letters without digits show error and block submit", () => {
|
||||
render(<OnlineBoardFilter initialTab="flight" />);
|
||||
fireEvent.change(screen.getByTestId("flight-number-input"), {
|
||||
target: { value: "abc" },
|
||||
|
||||
@@ -18,10 +18,7 @@ import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.j
|
||||
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
|
||||
import { useDictionaries } from "@/shared/dictionaries/index.js";
|
||||
import { useCalendarDays } from "../hooks/useCalendarDays.js";
|
||||
import {
|
||||
todayYyyymmdd,
|
||||
yyyymmddToIso,
|
||||
} from "@/shared/utils/datetime/index.js";
|
||||
import { todayYyyymmdd } from "@/shared/utils/datetime/index.js";
|
||||
import type { CalendarParams } from "../api.js";
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import { setBoardFilter } from "@/shared/state/crossSectionNavigation.js";
|
||||
@@ -49,28 +46,52 @@ function dateToYyyymmdd(value: Date): string {
|
||||
return `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
function dateToIsoYmd(value: Date): string {
|
||||
const y = value.getFullYear().toString();
|
||||
const m = (value.getMonth() + 1).toString().padStart(2, "0");
|
||||
const d = value.getDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
interface NormalizedFlightNumber {
|
||||
flightNumber: string;
|
||||
suffix?: string;
|
||||
display: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a flight number string per TZ §4.1.9.3:
|
||||
* - Must be 1-4 digits only (no letters, no spaces).
|
||||
* - Error message: "только из цифр и не должен быть длиннее 4-х символов".
|
||||
* Shorter inputs (1-3 digits) are valid; they get zero-padded at submit time.
|
||||
* Normalise the Angular flight-number field:
|
||||
* `38` -> `0038`, `38D` -> `0038D`, `123d` -> `0123D`.
|
||||
*/
|
||||
function normalizeFlightNumberInput(value: string): NormalizedFlightNumber | null {
|
||||
const raw = value.trim();
|
||||
const match = /^(\d{1,4})([A-Za-z])?$/.exec(raw);
|
||||
if (!match) return null;
|
||||
const digits = match[1];
|
||||
if (!digits) return null;
|
||||
const flightNumber = digits.padStart(4, "0");
|
||||
const suffix = match[2]?.toUpperCase();
|
||||
return suffix
|
||||
? { flightNumber, suffix, display: `${flightNumber}${suffix}` }
|
||||
: { flightNumber, display: flightNumber };
|
||||
}
|
||||
|
||||
function formatFlightNumberInput(value: string): string {
|
||||
const normalized = normalizeFlightNumberInput(value);
|
||||
return normalized?.display ?? value.toUpperCase();
|
||||
}
|
||||
|
||||
function validateFlightNumber(value: string): string | null {
|
||||
if (!value.trim()) {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return "BOARD.FLIGHT_NUMBER-ERROR-EMPTY";
|
||||
}
|
||||
const reg = /^\d{1,4}$/;
|
||||
if (!reg.test(value.trim())) {
|
||||
if (!normalizeFlightNumberInput(raw)) {
|
||||
return "BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Zero-pads a 1-3 digit flight number to 4 digits per TZ §4.1.9.3 (e.g. "38" → "0038"). */
|
||||
function padFlightNumber(value: string): string {
|
||||
return value.trim().padStart(4, "0");
|
||||
}
|
||||
|
||||
export interface OnlineBoardFilterProps {
|
||||
/** Pre-populate filter from URL params on search results pages */
|
||||
initialDeparture?: string;
|
||||
@@ -164,7 +185,6 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
const fallbackTodayYmd = useRef(todayYyyymmdd()).current;
|
||||
const todayYmd = today ?? fallbackTodayYmd;
|
||||
const todayDate = useMemo(() => yyyymmddToDate(todayYmd), [todayYmd]);
|
||||
const todayIsoStr = useMemo(() => yyyymmddToIso(todayYmd), [todayYmd]);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { locale, language } = useLocale();
|
||||
@@ -202,6 +222,10 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
// lifetime updates the calendar window correctly on subsequent renders.
|
||||
const boardMinDate = useMemo(() => getBoardMinDate(todayDate), [todayDate]);
|
||||
const boardMaxDate = useMemo(() => getBoardMaxDate(todayDate), [todayDate]);
|
||||
const boardCalendarBaseDate = useMemo(
|
||||
() => dateToIsoYmd(boardMinDate),
|
||||
[boardMinDate],
|
||||
);
|
||||
|
||||
// TIRREDESIGN-12: fetch the 31-day operating-days bitmask for the
|
||||
// current tab so non-operating days in the [minDate, maxDate] window
|
||||
@@ -210,14 +234,14 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
// flight number or empty route produces no API call.
|
||||
const flightCalendarParams = useMemo<CalendarParams | null>(() => {
|
||||
if (activeTab !== "flight") return null;
|
||||
const digits = flightNumber.trim();
|
||||
if (digits.length < 1 || !/^\d{1,4}$/.test(digits)) return null;
|
||||
const normalized = normalizeFlightNumberInput(flightNumber);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
date: todayIsoStr,
|
||||
date: boardCalendarBaseDate,
|
||||
searchType: "flight",
|
||||
flightNumber: `SU${padFlightNumber(digits)}`,
|
||||
flightNumber: `SU${normalized.flightNumber}${normalized.suffix ?? ""}`,
|
||||
};
|
||||
}, [activeTab, flightNumber, todayIsoStr]);
|
||||
}, [activeTab, flightNumber, boardCalendarBaseDate]);
|
||||
|
||||
const routeCalendarParams = useMemo<CalendarParams | null>(() => {
|
||||
if (activeTab !== "route") return null;
|
||||
@@ -226,34 +250,41 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
if (!dep && !arr) return null;
|
||||
if (dep && arr) {
|
||||
if (dep === arr) return null;
|
||||
return { date: todayIsoStr, searchType: "route", departure: dep, arrival: arr };
|
||||
return { date: boardCalendarBaseDate, searchType: "route", departure: dep, arrival: arr };
|
||||
}
|
||||
if (dep) return { date: todayIsoStr, searchType: "departure", departure: dep };
|
||||
return { date: todayIsoStr, searchType: "arrival", arrival: arr };
|
||||
}, [activeTab, routeDepartureCode, routeArrivalCode, todayIsoStr]);
|
||||
if (dep) return { date: boardCalendarBaseDate, searchType: "departure", departure: dep };
|
||||
return { date: boardCalendarBaseDate, searchType: "arrival", arrival: arr };
|
||||
}, [activeTab, routeDepartureCode, routeArrivalCode, boardCalendarBaseDate]);
|
||||
|
||||
const { days: flightAvailableDays } = useCalendarDays(flightCalendarParams);
|
||||
const { days: routeAvailableDays } = useCalendarDays(routeCalendarParams);
|
||||
const {
|
||||
days: flightAvailableDays,
|
||||
loaded: flightCalendarLoaded,
|
||||
} = useCalendarDays(flightCalendarParams);
|
||||
const {
|
||||
days: routeAvailableDays,
|
||||
loaded: routeCalendarLoaded,
|
||||
} = useCalendarDays(routeCalendarParams);
|
||||
|
||||
const flightDisabledDates = useMemo(
|
||||
() =>
|
||||
flightAvailableDays.length === 0
|
||||
!flightCalendarLoaded
|
||||
? []
|
||||
: computeDisabledDates(flightAvailableDays, boardMinDate, boardMaxDate),
|
||||
[flightAvailableDays, boardMinDate, boardMaxDate],
|
||||
[flightAvailableDays, flightCalendarLoaded, boardMinDate, boardMaxDate],
|
||||
);
|
||||
const routeDisabledDates = useMemo(
|
||||
() =>
|
||||
routeAvailableDays.length === 0
|
||||
!routeCalendarLoaded
|
||||
? []
|
||||
: computeDisabledDates(routeAvailableDays, boardMinDate, boardMaxDate),
|
||||
[routeAvailableDays, boardMinDate, boardMaxDate],
|
||||
[routeAvailableDays, routeCalendarLoaded, boardMinDate, boardMaxDate],
|
||||
);
|
||||
|
||||
// §4.1.10 — submit button locked for 30 seconds after each search.
|
||||
// Value is the timestamp when the lock expires (or 0 if unlocked).
|
||||
// The 30-second constant is intentionally hardcoded (not configurable).
|
||||
const [submitLockedUntil, setSubmitLockedUntil] = useState(0);
|
||||
const [lockedSearchSignature, setLockedSearchSignature] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
// Tick every second while the lock is active so the disabled state
|
||||
// updates reactively.
|
||||
@@ -262,9 +293,38 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
const id = setTimeout(() => setNow(Date.now()), 1000);
|
||||
return () => clearTimeout(id);
|
||||
}, [submitLockedUntil, now]);
|
||||
const flightSearchSignature = useMemo(() => {
|
||||
const dateParam = dateToYyyymmdd(flightDate ?? todayDate);
|
||||
return [
|
||||
"flight",
|
||||
flightNumber.trim(),
|
||||
dateParam,
|
||||
].join("|");
|
||||
}, [flightNumber, flightDate, todayDate]);
|
||||
|
||||
const routeSearchSignature = useMemo(() => {
|
||||
const dateParam = dateToYyyymmdd(routeDate ?? todayDate);
|
||||
const timePart =
|
||||
timeRange[0] !== 0 || timeRange[1] !== 1440
|
||||
? `${minutesToHhmm(timeRange[0])}-${minutesToHhmm(timeRange[1])}`
|
||||
: "full-day";
|
||||
return [
|
||||
"route",
|
||||
routeDepartureCode.trim().toUpperCase(),
|
||||
routeArrivalCode.trim().toUpperCase(),
|
||||
dateParam,
|
||||
timePart,
|
||||
].join("|");
|
||||
}, [routeDepartureCode, routeArrivalCode, routeDate, timeRange, todayDate]);
|
||||
|
||||
const activeSearchSignature =
|
||||
activeTab === "flight" ? flightSearchSignature : routeSearchSignature;
|
||||
const isSubmitLocked = useMemo(
|
||||
() => submitLockedUntil > 0 && now < submitLockedUntil,
|
||||
[submitLockedUntil, now],
|
||||
() =>
|
||||
submitLockedUntil > 0 &&
|
||||
now < submitLockedUntil &&
|
||||
lockedSearchSignature === activeSearchSignature,
|
||||
[submitLockedUntil, now, lockedSearchSignature, activeSearchSignature],
|
||||
);
|
||||
|
||||
// Swap the Calendar input's display text to "Сегодня" / "Завтра" per
|
||||
@@ -301,6 +361,8 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
date: initialDate,
|
||||
tab: initialTab,
|
||||
flightNumber: initialFlightNumber,
|
||||
timeFrom: initialTimeFrom,
|
||||
timeTo: initialTimeTo,
|
||||
});
|
||||
useEffect(() => {
|
||||
const prev = lastInitialRef.current;
|
||||
@@ -322,14 +384,30 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
if (prev.flightNumber !== initialFlightNumber) {
|
||||
setFlightNumber(initialFlightNumber ?? "");
|
||||
}
|
||||
if (prev.timeFrom !== initialTimeFrom || prev.timeTo !== initialTimeTo) {
|
||||
setTimeRange([
|
||||
hhmmToMinutes(initialTimeFrom, 0),
|
||||
hhmmToMinutes(initialTimeTo, 1440),
|
||||
]);
|
||||
}
|
||||
lastInitialRef.current = {
|
||||
departure: initialDeparture,
|
||||
arrival: initialArrival,
|
||||
date: initialDate,
|
||||
tab: initialTab,
|
||||
flightNumber: initialFlightNumber,
|
||||
timeFrom: initialTimeFrom,
|
||||
timeTo: initialTimeTo,
|
||||
};
|
||||
}, [initialDeparture, initialArrival, initialDate, initialTab, initialFlightNumber]);
|
||||
}, [
|
||||
initialDeparture,
|
||||
initialArrival,
|
||||
initialDate,
|
||||
initialTab,
|
||||
initialFlightNumber,
|
||||
initialTimeFrom,
|
||||
initialTimeTo,
|
||||
]);
|
||||
|
||||
const handleTabClick = useCallback((tab: AccordionTab) => {
|
||||
setActiveTab((prev) => (prev === tab ? prev : tab));
|
||||
@@ -354,14 +432,13 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
// the placeholder ДД.ММ.ГГГГ is left untouched.
|
||||
const dateParam = dateToYyyymmdd(flightDate ?? new Date());
|
||||
const carrier = "SU";
|
||||
// TZ §4.1.9.3: zero-pad to 4 digits (38 → 0038, 383 → 0383).
|
||||
const num = padFlightNumber(flightNumber);
|
||||
if (!num) return;
|
||||
const normalized = normalizeFlightNumberInput(flightNumber);
|
||||
if (!normalized) return;
|
||||
|
||||
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
|
||||
setBoardFilter({
|
||||
mode: "flight-number",
|
||||
flightNumber: `${carrier}${num}`,
|
||||
flightNumber: `${carrier}${normalized.flightNumber}${normalized.suffix ?? ""}`,
|
||||
date: dateParam,
|
||||
timeFrom: "0000",
|
||||
timeTo: "2400",
|
||||
@@ -369,13 +446,28 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
});
|
||||
|
||||
// Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable)
|
||||
setLockedSearchSignature(flightSearchSignature);
|
||||
setSubmitLockedUntil(Date.now() + 30_000);
|
||||
setNow(Date.now());
|
||||
|
||||
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
|
||||
const urlParams = normalized.suffix
|
||||
? {
|
||||
type: "flight" as const,
|
||||
carrier,
|
||||
flightNumber: normalized.flightNumber,
|
||||
suffix: normalized.suffix,
|
||||
date: dateParam,
|
||||
}
|
||||
: {
|
||||
type: "flight" as const,
|
||||
carrier,
|
||||
flightNumber: normalized.flightNumber,
|
||||
date: dateParam,
|
||||
};
|
||||
const url = buildOnlineBoardUrl(urlParams);
|
||||
void navigate(`/${locale}/${url}`);
|
||||
},
|
||||
[flightNumber, flightDate, navigate, locale, isSubmitLocked],
|
||||
[flightNumber, flightDate, navigate, locale, isSubmitLocked, flightSearchSignature],
|
||||
);
|
||||
|
||||
const handleRouteSubmit = useCallback(
|
||||
@@ -448,11 +540,12 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam, ...timeExtras });
|
||||
}
|
||||
// Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable)
|
||||
setLockedSearchSignature(routeSearchSignature);
|
||||
setSubmitLockedUntil(Date.now() + 30_000);
|
||||
setNow(Date.now());
|
||||
void navigate(`/${locale}/${url}`);
|
||||
},
|
||||
[routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale, isSubmitLocked],
|
||||
[routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale, isSubmitLocked, routeSearchSignature],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -516,6 +609,7 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
setFlightNumber(e.target.value);
|
||||
if (flightNumberError) setFlightNumberError(null);
|
||||
}}
|
||||
onBlur={(e) => setFlightNumber(formatFlightNumberInput(e.target.value))}
|
||||
data-testid="flight-number-input"
|
||||
/>
|
||||
{flightNumber && (
|
||||
|
||||
@@ -34,8 +34,12 @@ vi.mock("@/ui/layout/PageTabs.js", () => ({
|
||||
PageTabs: () => <div data-testid="page-tabs" />,
|
||||
}));
|
||||
|
||||
let capturedFilterProps: Record<string, unknown> | undefined;
|
||||
vi.mock("./OnlineBoardFilter.js", () => ({
|
||||
OnlineBoardFilter: () => <div data-testid="online-board-filter" />,
|
||||
OnlineBoardFilter: (props: Record<string, unknown>) => {
|
||||
capturedFilterProps = props;
|
||||
return <div data-testid="online-board-filter" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
|
||||
@@ -46,13 +50,17 @@ vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({
|
||||
useFeatureFlag: () => false,
|
||||
}));
|
||||
|
||||
let capturedSearchParams: Record<string, unknown> | undefined;
|
||||
vi.mock("../hooks/useOnlineBoard.js", () => ({
|
||||
useOnlineBoard: () => ({
|
||||
flights: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
useOnlineBoard: (params: Record<string, unknown>) => {
|
||||
capturedSearchParams = params;
|
||||
return {
|
||||
flights: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
|
||||
@@ -143,6 +151,8 @@ describe("OnlineBoardSearchPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedInitialCurrentFlightId = undefined;
|
||||
capturedFilterProps = undefined;
|
||||
capturedSearchParams = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -176,6 +186,23 @@ describe("OnlineBoardSearchPage", () => {
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-13: preserves suffix in flight API params and filter value", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(capturedSearchParams?.flightNumber).toBe("SU0100D");
|
||||
expect(capturedFilterProps?.initialFlightNumber).toBe("0100D");
|
||||
});
|
||||
|
||||
it("renders for route search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
|
||||
@@ -32,18 +32,22 @@ import "./OnlineBoardSearchPage.scss";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
||||
import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
|
||||
import { useStaleDataTimers } from "../hooks/useStaleDataTimers.js";
|
||||
import { useCalendarDays } from "../hooks/useCalendarDays.js";
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import { buildFlightListJsonLd } from "../json-ld.js";
|
||||
import { sortFlights } from "../sortFlights.js";
|
||||
import { getFlightSearchDate } from "../flightSearchDate.js";
|
||||
import {
|
||||
PobedaAuroraBanner,
|
||||
shouldShowPobedaAuroraBanner,
|
||||
} from "./PobedaAuroraBanner.js";
|
||||
import { StaleDataOverlay } from "./StaleDataOverlay.js";
|
||||
import type { SortMode } from "../sortFlights.js";
|
||||
import type { OnlineBoardParams } from "../url.js";
|
||||
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
||||
import type { FlightRequestType, ISimpleFlight } from "../types.js";
|
||||
import { boardWindowBounds } from "@/shared/dateWindow.js";
|
||||
|
||||
export interface OnlineBoardSearchPageProps {
|
||||
/** Parsed and validated URL params from the route */
|
||||
@@ -64,6 +68,13 @@ function formatDateForApi(yyyymmdd: string): string {
|
||||
return yyyymmdd.includes("T") ? yyyymmdd : `${yyyymmdd}T00:00:00`;
|
||||
}
|
||||
|
||||
function formatDateOnlyForApi(value: Date): string {
|
||||
const y = value.getFullYear().toString();
|
||||
const m = (value.getMonth() + 1).toString().padStart(2, "0");
|
||||
const d = value.getDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the yyyyMMdd date one day after `yyyymmdd`. The API treats the
|
||||
* board range as a half-open interval: `dateFrom=D, dateTo=D` yields zero
|
||||
@@ -106,7 +117,7 @@ function toSearchParams(
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
@@ -133,29 +144,24 @@ function toSearchParams(
|
||||
/**
|
||||
* Convert parsed params into calendar API params.
|
||||
*
|
||||
* TIRREDESIGN-8: the 31-day availability bitmask is always anchored to
|
||||
* today (Angular parity — `updateCalendar()` builds `date = new Date();
|
||||
* date.setDate(date.getDate()-1)` regardless of the URL-selected day).
|
||||
* Using `params.date` as the anchor shifts the window forward as the
|
||||
* user navigates and causes DayTabs inside the -1/+14 range to fall
|
||||
* outside the returned bitmask, grey-listing them even when flights run
|
||||
* that day.
|
||||
* TIRREDESIGN-12: the 31-day availability bitmask is anchored to the
|
||||
* first board-calendar date (today - 1), matching Angular's
|
||||
* `updateCalendar()` (`date = new Date(); date.setDate(date.getDate()-1)`).
|
||||
* Using the URL-selected date as the anchor shifts the window as the
|
||||
* user navigates and corrupts disabled-day parity.
|
||||
*/
|
||||
function toCalendarParams(
|
||||
params: OnlineBoardSearchPageProps["params"],
|
||||
): CalendarParams {
|
||||
const today = new Date();
|
||||
const y = today.getFullYear().toString();
|
||||
const m = (today.getMonth() + 1).toString().padStart(2, "0");
|
||||
const d = today.getDate().toString().padStart(2, "0");
|
||||
const [boardMinDate] = boardWindowBounds();
|
||||
const base: CalendarParams = {
|
||||
date: `${y}-${m}-${d}T00:00:00`,
|
||||
date: formatDateOnlyForApi(boardMinDate),
|
||||
searchType: params.type as FlightRequestType,
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
@@ -225,7 +231,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
: params.type === "arrival" ? params.station
|
||||
: undefined;
|
||||
const flightNumber = isFlightNumber
|
||||
? `${params.carrier} ${params.flightNumber}`
|
||||
? `${params.carrier} ${params.flightNumber}${params.type === "flight" ? params.suffix ?? "" : ""}`
|
||||
: undefined;
|
||||
const labelParts: string[] = [];
|
||||
if (departure) labelParts.push(departure);
|
||||
@@ -252,6 +258,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
: undefined,
|
||||
params.type === "flight" ? params.carrier : undefined,
|
||||
params.type === "flight" ? params.flightNumber : undefined,
|
||||
params.type === "flight" ? params.suffix : undefined,
|
||||
params.date,
|
||||
]);
|
||||
|
||||
@@ -291,7 +298,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
||||
break;
|
||||
case "flight":
|
||||
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}`;
|
||||
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
|
||||
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
||||
break;
|
||||
default:
|
||||
@@ -370,7 +377,9 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
|
||||
liveBoardParams,
|
||||
flights,
|
||||
refresh,
|
||||
);
|
||||
const isStale = useStaleDataTimers();
|
||||
|
||||
// Calendar days
|
||||
const calendarParams = toCalendarParams(params);
|
||||
@@ -385,13 +394,13 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
carrier: flight.flightId.carrier,
|
||||
flightNumber: flight.flightId.flightNumber,
|
||||
suffix: flight.flightId.suffix,
|
||||
date: flight.flightId.date,
|
||||
date: getFlightSearchDate(flight),
|
||||
}
|
||||
: {
|
||||
type: "details",
|
||||
carrier: flight.flightId.carrier,
|
||||
flightNumber: flight.flightId.flightNumber,
|
||||
date: flight.flightId.date,
|
||||
date: getFlightSearchDate(flight),
|
||||
};
|
||||
const detailsUrl = buildOnlineBoardUrl(detailsParams);
|
||||
void navigate(`/${locale}/${detailsUrl}`);
|
||||
@@ -457,6 +466,9 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
data-searching={loading ? "true" : undefined}
|
||||
>
|
||||
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
||||
{isStale && (
|
||||
<StaleDataOverlay message={t("SHARED.STALE-DATA-REFRESH")} />
|
||||
)}
|
||||
<PageLayout
|
||||
headerLeft={<PageTabs viewType="onlineboard" />}
|
||||
title={
|
||||
@@ -499,7 +511,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
}
|
||||
: params.type === "flight"
|
||||
? {
|
||||
initialFlightNumber: params.flightNumber,
|
||||
initialFlightNumber: `${params.flightNumber}${params.suffix ?? ""}`,
|
||||
initialDate: params.date,
|
||||
initialTab: "flight" as const,
|
||||
}
|
||||
@@ -616,7 +628,6 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
<FlightActions
|
||||
flight={flight}
|
||||
locale={language}
|
||||
showShare={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
setBoardFilter,
|
||||
type ScheduleFilterSnapshot,
|
||||
} from "@/shared/state/crossSectionNavigation.js";
|
||||
import { sessionStore } from "@/shared/storage.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook mocks for geo + viewport — controlled per test
|
||||
@@ -63,6 +64,12 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
|
||||
>
|
||||
Popular
|
||||
</button>
|
||||
<button
|
||||
data-testid="popular-click-schedule-route"
|
||||
onClick={() => onRequestClick?.({ mode: "Route", departure: "MOW", arrival: "MMK", type: "Schedule" })}
|
||||
>
|
||||
Schedule Route
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -77,6 +84,7 @@ vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
|
||||
|
||||
vi.mock("@/shared/dictionaries/index.js", () => ({
|
||||
useDictionaries: () => ({ dictionaries: null, loading: false, error: null }),
|
||||
getCityCodeByAirportCode: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useCalendarDays.js", () => ({
|
||||
@@ -163,6 +171,7 @@ describe("buildOnlineBoardPrefillState", () => {
|
||||
describe("OnlineBoardStartPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStore.clear();
|
||||
});
|
||||
|
||||
it("renders start page with page layout structure", () => {
|
||||
@@ -264,6 +273,25 @@ describe("OnlineBoardStartPage", () => {
|
||||
// Same-page click — the filter remounts via key bump, no nav.
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-9: schedule Route popular click opens Schedule with prefilled data", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0));
|
||||
try {
|
||||
render(<OnlineBoardStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-schedule-route"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
|
||||
expect(JSON.parse(sessionStore.getRaw("afl-prefill:schedule") ?? "{}")).toEqual({
|
||||
departure: "MOW",
|
||||
arrival: "MMK",
|
||||
withReturn: false,
|
||||
dateFrom: "20260514",
|
||||
dateTo: "20260517",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -244,16 +244,10 @@ export const OnlineBoardStartPage: FC<OnlineBoardStartPageProps> = ({ today }) =
|
||||
|
||||
const handlePopularRequestClick = useCallback(
|
||||
(request: PopularRequest) => {
|
||||
// Schedule-type requests navigate to the schedule feature. City
|
||||
// codes come off the popular-request API as airport codes in some
|
||||
// cases (SVO/LED instead of MOW/LED); resolve them to owning city
|
||||
// so the destination form pre-fills with a city rather than an
|
||||
// airport pin.
|
||||
// Schedule-type requests open the Schedule start page with the form
|
||||
// prefilled from the clicked popular item, matching Angular's shared
|
||||
// filter-state behavior.
|
||||
if (request.type === "Schedule") {
|
||||
// TZ §4.1.5: Schedule-bound popular clicks prefill the date range
|
||||
// to the current ISO week (Mon-Sun); round-trip additionally sets
|
||||
// the return range to the following ISO week. Without these the
|
||||
// Schedule calendar renders empty until submit.
|
||||
const state: Record<string, unknown> =
|
||||
request.mode === "Route" || request.mode === "RouteWithBack"
|
||||
? (() => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.stale-data-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
text-align: center;
|
||||
padding-top: 20%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgb(255 255 255 / 70%);
|
||||
z-index: 9999;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FC } from "react";
|
||||
import "./StaleDataOverlay.scss";
|
||||
|
||||
interface StaleDataOverlayProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const StaleDataOverlay: FC<StaleDataOverlayProps> = ({ message }) => (
|
||||
<div
|
||||
className="stale-data-overlay"
|
||||
data-testid="stale-data-overlay"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
@@ -67,22 +67,12 @@ describe("AircraftPanel", () => {
|
||||
expect(screen.getByTestId("aircraft-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
// §4.1.15.4: tail number (registration mark) in aircraft panel
|
||||
it("4.1.15.4-TailNumber: renders tail number when aircraft.registration is present", () => {
|
||||
it("TIRREDESIGN-29: omits tail number when aircraft.registration is present", () => {
|
||||
const eq: IEquipmentFull = {
|
||||
aircraft: { actual: { title: "Airbus A320", registration: "VP-BQS" } },
|
||||
};
|
||||
render(<AircraftPanel equipment={eq} />);
|
||||
expect(screen.getByText("VP-BQS")).toBeTruthy();
|
||||
// label key is AIRPLANE.TAIL-NUMBER (t mock returns key)
|
||||
expect(screen.getByText("AIRPLANE.TAIL-NUMBER")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("4.1.15.4-TailNumber-Absent: omits tail-number row when registration is not present", () => {
|
||||
const eq: IEquipmentFull = {
|
||||
aircraft: { actual: { title: "Airbus A320" } },
|
||||
};
|
||||
render(<AircraftPanel equipment={eq} />);
|
||||
expect(screen.queryByText("VP-BQS")).toBeNull();
|
||||
expect(screen.queryByText("AIRPLANE.TAIL-NUMBER")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -112,11 +112,6 @@ export const AircraftPanel: FC<AircraftPanelProps> = ({
|
||||
className?: string;
|
||||
}> = [];
|
||||
if (aircraft?.name) props.push({ label: t("AIRPLANE.NAME"), value: aircraft.name });
|
||||
// §4.1.15.4: tail number (registration mark, e.g. "VP-BQS") from the
|
||||
// aircraft.actual.registration field. Shown when present.
|
||||
if (aircraft?.registration) {
|
||||
props.push({ label: t("AIRPLANE.TAIL-NUMBER"), value: aircraft.registration });
|
||||
}
|
||||
if (total > 0) props.push({ label: t("AIRPLANE.SEATS-TOTAL"), value: total });
|
||||
if (economy > 0) props.push({ label: t("AIRPLANE.SEATS-ECONOMY"), value: economy });
|
||||
if (comfort > 0) props.push({ label: t("AIRPLANE.SEATS-COMFORT"), value: comfort });
|
||||
|
||||
@@ -113,6 +113,16 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__subtitle-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Status label (Закончена / Идет / Ожидается). Angular sizes it 16px
|
||||
// and uses Aeroflot red (#C8102E) for the 'Finished' state.
|
||||
&__status {
|
||||
|
||||
@@ -112,6 +112,27 @@ describe("FlightDetailsAccordion", () => {
|
||||
expect(screen.getByText("Airbus A320")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-24: renders aircraft title as an external plane-park link", () => {
|
||||
const leg = makeLeg({
|
||||
equipment: { name: "A320", aircraft: { actual: { title: "Airbus A320" } } },
|
||||
});
|
||||
render(<FlightDetailsAccordion leg={leg} viewType="Onlineboard" locale="ru-ru" />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Airbus A320" });
|
||||
expect(link.getAttribute("href")).toBe("http://www.aeroflot.ru/cms/ru/flight/plane_park");
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-24: uses locale language in the plane-park link", () => {
|
||||
const leg = makeLeg({
|
||||
equipment: { name: "A320", aircraft: { scheduled: { title: "Airbus A320" } } },
|
||||
});
|
||||
render(<FlightDetailsAccordion leg={leg} viewType="Onlineboard" locale="en-us" />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Airbus A320" }).getAttribute("href"))
|
||||
.toBe("http://www.aeroflot.ru/cms/en/flight/plane_park");
|
||||
});
|
||||
|
||||
it("renders meal tab when equipment.meal has items", () => {
|
||||
const leg = makeLeg({
|
||||
equipment: { name: "A320", meal: [{ type: "Economy" }] },
|
||||
|
||||
@@ -48,6 +48,11 @@ interface RowDef {
|
||||
isTransition?: boolean;
|
||||
}
|
||||
|
||||
function aircraftParkHref(locale: string | undefined): string {
|
||||
const language = locale?.split("-")[0] || "ru";
|
||||
return `http://www.aeroflot.ru/cms/${language}/flight/plane_park`;
|
||||
}
|
||||
|
||||
// Registration — person with a badge/ID on the chest, mirroring Angular's
|
||||
// sprite #service icon (check-in agent silhouette).
|
||||
const ICON_REGISTRATION: JSX.Element = (
|
||||
@@ -210,7 +215,16 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
|
||||
id: "aircraft",
|
||||
icon: ICON_AIRCRAFT,
|
||||
title: t("SHARED.PLANE"),
|
||||
subtitle: title,
|
||||
subtitle: title ? (
|
||||
<a
|
||||
className="details-row__subtitle-link"
|
||||
href={aircraftParkHref(locale)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
) : null,
|
||||
body: (
|
||||
<AircraftPanel
|
||||
equipment={leg.equipment}
|
||||
|
||||
@@ -27,18 +27,19 @@ describe("shouldShowTransition", () => {
|
||||
expect(shouldShowTransition(undefined, "Scheduled", "Onlineboard")).toBe(false);
|
||||
});
|
||||
|
||||
// TIRREDESIGN-7: the rule now reads the API's isActual flag rather
|
||||
// than inferring from status, so a transition marked inactive hides
|
||||
// even if its status has left Scheduled (e.g. a cancelled boarding
|
||||
// phase that hasn't been cleared to Scheduled).
|
||||
it("returns false when item.isActual is false", () => {
|
||||
const inactive = { ...validItem, isActual: false };
|
||||
expect(shouldShowTransition(inactive, "Scheduled", "Onlineboard")).toBe(false);
|
||||
it("returns false when transition status is Scheduled", () => {
|
||||
const scheduled = { ...validItem, status: "Scheduled" as const };
|
||||
expect(shouldShowTransition(scheduled, "Scheduled", "Onlineboard")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when item.isActual is true on Onlineboard", () => {
|
||||
it("returns true when transition status has left Scheduled on Onlineboard", () => {
|
||||
expect(shouldShowTransition(validItem, "Scheduled", "Onlineboard")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores isActual on the full details page like Angular", () => {
|
||||
const inactiveButInProgress = { ...validItem, isActual: false };
|
||||
expect(shouldShowTransition(inactiveButInProgress, "Scheduled", "Onlineboard")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowAircraft", () => {
|
||||
|
||||
@@ -8,14 +8,11 @@ import type {
|
||||
export type DetailsViewType = "Onlineboard" | "Schedule";
|
||||
|
||||
/**
|
||||
* Matches Angular's `showBoardProperty` in flight-details-wrapper.component.ts,
|
||||
* tightened for TIRREDESIGN-7 to use the payload's `isActual` flag. The
|
||||
* API sets `isActual=true` precisely when a transition block is in its
|
||||
* current operational phase — the backend computes that from status +
|
||||
* clocked times, so consumers shouldn't rediscover it locally.
|
||||
* Matches Angular's `showBoardProperty` in flight-details-wrapper.component.ts.
|
||||
*
|
||||
* Transition panels remain hidden for Schedule mode or Cancelled legs
|
||||
* regardless of the flag (those contexts never show transition detail).
|
||||
* Full online-board details hide only scheduled transition blocks. This differs
|
||||
* from the inline board row body, where Angular gates registration/boarding/
|
||||
* deboarding on `isActual`.
|
||||
*/
|
||||
export function shouldShowTransition(
|
||||
item: IFlightTransitionItem | undefined,
|
||||
@@ -25,7 +22,7 @@ export function shouldShowTransition(
|
||||
if (viewType === "Schedule") return false;
|
||||
if (legStatus === "Cancelled") return false;
|
||||
if (!item) return false;
|
||||
return item.isActual === true;
|
||||
return item.status !== "Scheduled";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getFlightSearchDate } from "./flightSearchDate.js";
|
||||
import type { IDirectFlight, IMultiLegFlight, IFlightLeg } from "./types.js";
|
||||
|
||||
function leg(local: string): IFlightLeg {
|
||||
return {
|
||||
arrival: {
|
||||
scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local,
|
||||
localTime: "",
|
||||
tzOffset: 0,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
dayChange: 0,
|
||||
departure: {
|
||||
scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
|
||||
checkingStatus: "Scheduled",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local,
|
||||
localTime: "",
|
||||
tzOffset: 0,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
equipment: {},
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
flyingTime: "",
|
||||
index: 0,
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
updated: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("getFlightSearchDate", () => {
|
||||
it("uses scheduled local departure date instead of backend flightId.date", () => {
|
||||
const flight: IDirectFlight = {
|
||||
id: "SU6951",
|
||||
routeType: "Direct",
|
||||
flyingTime: "",
|
||||
operatingBy: {},
|
||||
status: "Arrived",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "6951",
|
||||
suffix: "",
|
||||
date: "2026-05-04",
|
||||
dateLT: "2026-05-05",
|
||||
},
|
||||
leg: leg("2026-05-05T00:30:00+03:00"),
|
||||
};
|
||||
|
||||
expect(getFlightSearchDate(flight)).toBe("20260505");
|
||||
});
|
||||
|
||||
it("uses the first leg for multi-leg flights", () => {
|
||||
const flight: IMultiLegFlight = {
|
||||
id: "SU100",
|
||||
routeType: "MultiLeg",
|
||||
flyingTime: "",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "",
|
||||
date: "20260504",
|
||||
},
|
||||
legs: [
|
||||
leg("2026-05-05T23:30:00+03:00"),
|
||||
leg("2026-05-06T01:30:00+03:00"),
|
||||
],
|
||||
};
|
||||
|
||||
expect(getFlightSearchDate(flight)).toBe("20260505");
|
||||
});
|
||||
|
||||
it("falls back to dateLT and then flightId.date when leg date is unavailable", () => {
|
||||
const flight: IDirectFlight = {
|
||||
id: "SU42",
|
||||
routeType: "Direct",
|
||||
flyingTime: "",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "0042",
|
||||
suffix: "",
|
||||
date: "2026-05-04",
|
||||
dateLT: "2026-05-05",
|
||||
},
|
||||
leg: leg("10:00"),
|
||||
};
|
||||
|
||||
expect(getFlightSearchDate(flight)).toBe("20260505");
|
||||
|
||||
const { dateLT: _dateLT, ...flightIdWithoutDateLt } = flight.flightId;
|
||||
const withoutDateLt: IDirectFlight = {
|
||||
...flight,
|
||||
flightId: flightIdWithoutDateLt,
|
||||
};
|
||||
|
||||
expect(getFlightSearchDate(withoutDateLt)).toBe("20260504");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IFlightLeg, ISimpleFlight } from "./types.js";
|
||||
|
||||
function compactDate(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
if (/^\d{8}$/.test(value)) return value;
|
||||
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
if (!match) return null;
|
||||
|
||||
return `${match[1]}${match[2]}${match[3]}`;
|
||||
}
|
||||
|
||||
function getFirstLeg(flight: ISimpleFlight): IFlightLeg | null {
|
||||
if (flight.routeType === "Direct") return flight.leg;
|
||||
return flight.legs[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular's FlightDateUtils.getFlightDate derives the details URL date from
|
||||
* the first leg's scheduled local departure date, not from flightId.date.
|
||||
* Overnight flights can have flightId.date set to the previous backend
|
||||
* service day while dateLT / scheduled local departure belongs to the board
|
||||
* day the user clicked.
|
||||
*/
|
||||
export function getFlightSearchDate(flight: ISimpleFlight): string {
|
||||
const firstLeg = getFirstLeg(flight);
|
||||
|
||||
return (
|
||||
compactDate(firstLeg?.departure.times.scheduledDeparture.local) ??
|
||||
compactDate(flight.flightId.dateLT) ??
|
||||
compactDate(flight.flightId.date) ??
|
||||
flight.flightId.date.replace(/-/g, "")
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { CalendarParams } from "../api.js";
|
||||
export interface UseCalendarDaysResult {
|
||||
days: string[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,28 +31,33 @@ export function useCalendarDays(
|
||||
const client = useApiClient();
|
||||
const [days, setDays] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(params));
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params) {
|
||||
setDays([]);
|
||||
setLoading(false);
|
||||
setLoaded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
|
||||
getCalendarDays(client, params)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setDays(result);
|
||||
setLoading(false);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDays([]);
|
||||
setLoading(false);
|
||||
setLoaded(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,5 +73,5 @@ export function useCalendarDays(
|
||||
params?.arrival,
|
||||
]);
|
||||
|
||||
return { days, loading };
|
||||
return { days, loading, loaded };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useCallback, useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getFlightDetails } from "../api.js";
|
||||
import type { FlightDetailsParams } from "../api.js";
|
||||
@@ -21,6 +21,7 @@ export interface UseFlightDetailsResult {
|
||||
daysOfFlight: string[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,10 +36,15 @@ export function useFlightDetails(
|
||||
const [daysOfFlight, setDaysOfFlight] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
@@ -62,7 +68,7 @@ export function useFlightDetails(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, params.flights, params.dates]);
|
||||
}, [client, params.flights, params.dates, refreshKey]);
|
||||
|
||||
return {
|
||||
flight: allFlights[0] ?? null,
|
||||
@@ -70,5 +76,6 @@ export function useFlightDetails(
|
||||
daysOfFlight,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ function createMockHub() {
|
||||
return {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
|
||||
const list = handlers[method] ?? [];
|
||||
handlers[method] = list;
|
||||
@@ -179,7 +180,7 @@ describe("useLiveBoardSearch", () => {
|
||||
expect(result.current.flights).toEqual(initial);
|
||||
});
|
||||
|
||||
it("subscribes to the correct channel", async () => {
|
||||
it("subscribes to Angular TrackerHub refresh and invokes SubscribeDate", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const params: LiveBoardSearchParams = {
|
||||
date: "20250115",
|
||||
@@ -193,17 +194,19 @@ describe("useLiveBoardSearch", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(hub.on).toHaveBeenCalledWith(
|
||||
"board:20250115:SVO:LED",
|
||||
expect.any(Function),
|
||||
expect(hub.on).toHaveBeenCalledWith("RefreshDate", expect.any(Function));
|
||||
expect(hub.invoke).toHaveBeenCalledWith(
|
||||
"SubscribeDate",
|
||||
"20250115",
|
||||
"SVO",
|
||||
"LED",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates flights when a SignalR message arrives", async () => {
|
||||
it("updates flights when a legacy array message arrives", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const params: LiveBoardSearchParams = { date: "20250115" };
|
||||
const initial = [makeFlight("f1")];
|
||||
const channel = "board:20250115::";
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLiveBoardSearch(params, initial),
|
||||
@@ -215,12 +218,30 @@ describe("useLiveBoardSearch", () => {
|
||||
|
||||
const updated = [makeFlight("f2")];
|
||||
act(() => {
|
||||
hub._simulateMessage(channel, updated);
|
||||
hub._simulateMessage("RefreshDate", updated);
|
||||
});
|
||||
|
||||
expect(result.current.flights).toEqual(updated);
|
||||
});
|
||||
|
||||
it("refreshes API data when TrackerHub sends RefreshDate", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const params: LiveBoardSearchParams = { date: "20250115" };
|
||||
const onRefresh = vi.fn();
|
||||
|
||||
renderHook(() => useLiveBoardSearch(params, [], onRefresh));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
hub._simulateMessage("RefreshDate", "20250115");
|
||||
});
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns idle connectionStatus during SSR", () => {
|
||||
const origWindow = globalThis.window;
|
||||
// @ts-expect-error -- intentionally deleting window for SSR simulation
|
||||
|
||||
@@ -49,13 +49,24 @@ export function buildBoardChannelKey(params: LiveBoardSearchParams): string {
|
||||
export function useLiveBoardSearch(
|
||||
params: LiveBoardSearchParams,
|
||||
initialFlights: ISimpleFlight[],
|
||||
onRefresh?: () => void,
|
||||
): UseLiveBoardSearchResult {
|
||||
const config = useMemo<UseLiveFlightsConfig<LiveBoardSearchParams>>(
|
||||
() => ({
|
||||
hubUrl: getEnv().SIGNALR_HUB_URL,
|
||||
channelKey: buildBoardChannelKey,
|
||||
subscription: {
|
||||
eventName: "RefreshDate",
|
||||
invokeMethodName: "SubscribeDate",
|
||||
args: (p) => [p.date, p.departure, p.arrival],
|
||||
},
|
||||
onMessage: (message) => {
|
||||
if (Array.isArray(message)) return message;
|
||||
onRefresh?.();
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const { data, connectionStatus } = useLiveFlights<
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/shared/signalr/connection.js";
|
||||
import {
|
||||
buildFlightChannelKey,
|
||||
buildTrackerFlightSubscriptionKey,
|
||||
useLiveFlightDetails,
|
||||
} from "./useLiveFlightDetails.js";
|
||||
import type { ISimpleFlight, IFlightId, IFlightLeg, IParsedFlightId } from "../types.js";
|
||||
@@ -32,6 +33,7 @@ function createMockHub() {
|
||||
return {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
|
||||
const list = handlers[method] ?? [];
|
||||
handlers[method] = list;
|
||||
@@ -160,6 +162,33 @@ describe("buildFlightChannelKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTrackerFlightSubscriptionKey", () => {
|
||||
it("uses compact URL date when dateLT is unavailable", () => {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
suffix: "A",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
expect(buildTrackerFlightSubscriptionKey(id)).toBe("SU100A@20250115");
|
||||
});
|
||||
|
||||
it("uses API dateLT as-is when available", () => {
|
||||
const id: IFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "",
|
||||
date: "2025-01-15",
|
||||
dateLT: "2025-01-15T00:00:00",
|
||||
};
|
||||
|
||||
expect(buildTrackerFlightSubscriptionKey(id)).toBe(
|
||||
"SU0100@2025-01-15T00:00:00",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLiveFlightDetails", () => {
|
||||
const HUB_URL = "https://hub.test/tracker";
|
||||
|
||||
@@ -211,13 +240,30 @@ describe("useLiveFlightDetails", () => {
|
||||
expect(result.current.flight).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes to the correct channel", async () => {
|
||||
it("does not subscribe before the API flight id is available", async () => {
|
||||
const { hub, conn } = installMockHub(HUB_URL);
|
||||
const subscribeSpy = vi.spyOn(conn, "subscribe");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLiveFlightDetails(null, null),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.connectionStatus).toBe("idle");
|
||||
expect(subscribeSpy).not.toHaveBeenCalled();
|
||||
expect(hub.invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("subscribes to Angular TrackerHub refresh and invokes Subscribe", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
suffix: "A",
|
||||
date: "2025-01-15",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
renderHook(() => useLiveFlightDetails(id, null));
|
||||
@@ -226,20 +272,17 @@ describe("useLiveFlightDetails", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(hub.on).toHaveBeenCalledWith(
|
||||
"flight:SU100A@2025-01-15",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(hub.on).toHaveBeenCalledWith("Refresh", expect.any(Function));
|
||||
expect(hub.invoke).toHaveBeenCalledWith("Subscribe", "SU100A@20250115");
|
||||
});
|
||||
|
||||
it("updates flight when a SignalR message arrives", async () => {
|
||||
it("updates flight when a legacy array message arrives", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
const channel = "flight:SU100@2025-01-15";
|
||||
const initial = makeFlight("f1");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -252,13 +295,35 @@ describe("useLiveFlightDetails", () => {
|
||||
|
||||
const updated = [makeFlight("f2")];
|
||||
act(() => {
|
||||
hub._simulateMessage(channel, updated);
|
||||
hub._simulateMessage("Refresh", updated);
|
||||
});
|
||||
|
||||
// useLiveFlights replaces the full array; our hook takes [0]
|
||||
expect(result.current.flight).toEqual(updated[0]);
|
||||
});
|
||||
|
||||
it("refreshes API data when TrackerHub sends Refresh", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
};
|
||||
const onRefresh = vi.fn();
|
||||
|
||||
renderHook(() => useLiveFlightDetails(id, null, onRefresh));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
hub._simulateMessage("Refresh", "SU0100@20250115");
|
||||
});
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns idle connectionStatus during SSR", () => {
|
||||
const origWindow = globalThis.window;
|
||||
// @ts-expect-error -- intentionally deleting window for SSR simulation
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type UseLiveFlightsConfig,
|
||||
} from "@/shared/hooks/useLiveFlights.js";
|
||||
import type { ConnectionStatus } from "@/shared/signalr/connection.js";
|
||||
import type { ISimpleFlight, IParsedFlightId } from "../types.js";
|
||||
import type { ISimpleFlight, IFlightId, IParsedFlightId } from "../types.js";
|
||||
import { getEnv } from "@/env/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -32,24 +32,51 @@ export interface UseLiveFlightDetailsResult {
|
||||
// Channel key builder (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildFlightChannelKey(id: IParsedFlightId): string {
|
||||
type LiveFlightId = IParsedFlightId | IFlightId;
|
||||
|
||||
const DISABLED_ID: LiveFlightId = {
|
||||
carrier: "",
|
||||
flightNumber: "",
|
||||
suffix: "",
|
||||
date: "",
|
||||
};
|
||||
|
||||
export function buildFlightChannelKey(id: LiveFlightId): string {
|
||||
return `flight:${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${id.date}`;
|
||||
}
|
||||
|
||||
export function buildTrackerFlightSubscriptionKey(id: LiveFlightId): string {
|
||||
const date = "dateLT" in id && id.dateLT ? id.dateLT : id.date.replace(/-/g, "");
|
||||
return `${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${date}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLiveFlightDetails(
|
||||
id: IParsedFlightId,
|
||||
id: LiveFlightId | null,
|
||||
initialFlight: ISimpleFlight | null,
|
||||
onRefresh?: () => void,
|
||||
): UseLiveFlightDetailsResult {
|
||||
const config = useMemo<UseLiveFlightsConfig<IParsedFlightId>>(
|
||||
const env = getEnv();
|
||||
const effectiveId = id ?? DISABLED_ID;
|
||||
const config = useMemo<UseLiveFlightsConfig<LiveFlightId>>(
|
||||
() => ({
|
||||
hubUrl: getEnv().SIGNALR_HUB_URL,
|
||||
hubUrl: id ? env.SIGNALR_HUB_URL : "",
|
||||
channelKey: buildFlightChannelKey,
|
||||
subscription: {
|
||||
eventName: "Refresh",
|
||||
invokeMethodName: "Subscribe",
|
||||
args: (flightId) => [buildTrackerFlightSubscriptionKey(flightId)],
|
||||
},
|
||||
onMessage: (message) => {
|
||||
if (Array.isArray(message)) return message;
|
||||
onRefresh?.();
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[env.SIGNALR_HUB_URL, id, onRefresh],
|
||||
);
|
||||
|
||||
// useLiveFlights expects an array — wrap/unwrap the single flight
|
||||
@@ -59,9 +86,9 @@ export function useLiveFlightDetails(
|
||||
);
|
||||
|
||||
const { data, connectionStatus } = useLiveFlights<
|
||||
IParsedFlightId,
|
||||
LiveFlightId,
|
||||
ISimpleFlight
|
||||
>(id, initialData, config);
|
||||
>(effectiveId, initialData, config);
|
||||
|
||||
const flight = data[0] ?? null;
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useStaleDataTimers } from "./useStaleDataTimers.js";
|
||||
|
||||
describe("useStaleDataTimers", () => {
|
||||
const originalLocation = window.location;
|
||||
const assign = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
assign.mockClear();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { assign, pathname: "/ru/onlineboard" },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks data stale after the pause timeout", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStaleDataTimers({ pauseMinutes: 0.001, stopMinutes: 1 }),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("redirects after the stop timeout", () => {
|
||||
renderHook(() =>
|
||||
useStaleDataTimers({
|
||||
pauseMinutes: 1,
|
||||
stopMinutes: 0.001,
|
||||
redirectTo: "/",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60);
|
||||
});
|
||||
|
||||
expect(assign).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("clears timers on unmount", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useStaleDataTimers({ pauseMinutes: 0.001, stopMinutes: 0.002 }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(120);
|
||||
});
|
||||
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getEnv } from "@/env/index.js";
|
||||
|
||||
const MILLIS_PER_MINUTE = 60_000;
|
||||
|
||||
export interface StaleDataTimersOptions {
|
||||
pauseMinutes?: number;
|
||||
stopMinutes?: number;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
function toDelay(minutes: number): number {
|
||||
return Math.max(0, minutes * MILLIS_PER_MINUTE);
|
||||
}
|
||||
|
||||
export function useStaleDataTimers(options: StaleDataTimersOptions = {}): boolean {
|
||||
const env = getEnv();
|
||||
const pauseMinutes = options.pauseMinutes ?? env.REFRESH_PAUSE_MIN;
|
||||
const stopMinutes = options.stopMinutes ?? env.REFRESH_STOP_MIN;
|
||||
const redirectTo = options.redirectTo ?? "/";
|
||||
const [stale, setStale] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setStale(false);
|
||||
|
||||
const pauseTimer = window.setTimeout(() => {
|
||||
setStale(true);
|
||||
}, toDelay(pauseMinutes));
|
||||
const stopTimer = window.setTimeout(() => {
|
||||
window.location.assign(redirectTo);
|
||||
}, toDelay(stopMinutes));
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(pauseTimer);
|
||||
window.clearTimeout(stopTimer);
|
||||
};
|
||||
}, [pauseMinutes, redirectTo, stopMinutes]);
|
||||
|
||||
return stale;
|
||||
}
|
||||
@@ -94,6 +94,14 @@ describe("buildFlightJsonLd", () => {
|
||||
expect(result.flightNumber).toBe("SU0100");
|
||||
});
|
||||
|
||||
it("TIRREDESIGN-13: includes suffix in flightNumber", () => {
|
||||
const flight = makeDirectFlight({ carrier: "SU", flightNumber: "0038" });
|
||||
flight.flightId.suffix = "D";
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.flightNumber).toBe("SU0038D");
|
||||
});
|
||||
|
||||
it("maps departure airport", () => {
|
||||
const flight = makeDirectFlight({ depCode: "SVO", depAirport: "Sheremetyevo" });
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
@@ -48,11 +48,11 @@ export function buildFlightJsonLd(flight: ISimpleFlight): Flight {
|
||||
const firstLeg = getFirstLeg(flight);
|
||||
const lastLeg = getLastLeg(flight);
|
||||
|
||||
const { carrier, flightNumber } = flight.flightId;
|
||||
const { carrier, flightNumber, suffix } = flight.flightId;
|
||||
|
||||
const result: Flight = {
|
||||
"@type": "Flight",
|
||||
flightNumber: `${carrier}${flightNumber}`,
|
||||
flightNumber: `${carrier}${flightNumber}${suffix ?? ""}`,
|
||||
provider: {
|
||||
"@type": "Airline",
|
||||
name: "Aeroflot",
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function getScheduleCalendarDays(
|
||||
* Handles two shapes:
|
||||
* - legacy comma-separated ("2025-01-01,2025-01-02,…")
|
||||
* - bitmask ("1111000…") where each position maps to a day starting from
|
||||
* `baseDate - 1`. This is what the upstream actually returns today.
|
||||
* the requested base date. This is what the upstream actually returns today.
|
||||
*/
|
||||
function parseCalendarDays(days: string, baseDate: string): string[] {
|
||||
if (!days) return [];
|
||||
|
||||
@@ -278,6 +278,32 @@ describe("schedule search results body — action strip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule search results — inline buy link", () => {
|
||||
it("threads buyUrlFor into grouped multi-day FlightList rows", () => {
|
||||
const firstDay = makeDirectFlight({
|
||||
depLocal: "2026-05-06T08:00:00+03:00",
|
||||
arrLocal: "2026-05-06T10:30:00+03:00",
|
||||
});
|
||||
const secondDay = makeDirectFlight({
|
||||
depLocal: "2026-05-07T14:00:00+03:00",
|
||||
arrLocal: "2026-05-07T16:30:00+03:00",
|
||||
});
|
||||
|
||||
render(
|
||||
<DayGroupedFlightList
|
||||
flights={[
|
||||
{ ...firstDay, id: "f-first-day" },
|
||||
{ ...secondDay, id: "f-second-day" },
|
||||
] as ISimpleFlight[]}
|
||||
buyUrlFor={(flight) => `https://example.test/buy/${flight.id}`}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstBuyLink = screen.getByTestId("flight-card-buy-link");
|
||||
expect(firstBuyLink.getAttribute("href")).toBe("https://example.test/buy/f-first-day");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §4.1.14.3 — DayGroupedFlightList grouping + day headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -363,6 +363,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
|
||||
direction="schedule"
|
||||
renderExpandedBody={renderScheduleBody}
|
||||
{...(onFlightClick ? { onFlightClick } : {})}
|
||||
{...(buyUrlFor ? { buyUrlFor } : {})}
|
||||
{...(resolvedInitialFlightId
|
||||
? { initialCurrentFlightId: resolvedInitialFlightId }
|
||||
: {})}
|
||||
|
||||
@@ -60,8 +60,14 @@ vi.mock("@/i18n/resolver.js", () => ({
|
||||
}));
|
||||
|
||||
// Mutable for individual test overrides
|
||||
let mockScheduleDetailsResult: { flights: unknown[]; loading: boolean; error: unknown } = {
|
||||
let mockScheduleDetailsResult: {
|
||||
flights: unknown[];
|
||||
daysOfFlight: string[];
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
} = {
|
||||
flights: [],
|
||||
daysOfFlight: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
@@ -145,7 +151,9 @@ vi.mock("@/features/online-board/components/FlightsMiniList/index.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/features/online-board/components/DayTabs/index.js", () => ({
|
||||
DayTabs: () => <div data-testid="day-tabs" />,
|
||||
DayTabs: ({ availableDates }: { availableDates: string[] }) => (
|
||||
<div data-testid="day-tabs" data-available-dates={availableDates.join(",")} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/ui/flights/FlightCard.js", () => ({
|
||||
@@ -187,7 +195,7 @@ const flightId: IScheduleFlightId = {
|
||||
describe("ScheduleDetailsPage breadcrumbs", () => {
|
||||
beforeEach(() => {
|
||||
mockSearchParamsGet = () => null;
|
||||
mockScheduleDetailsResult = { flights: [], loading: true, error: null };
|
||||
mockScheduleDetailsResult = { flights: [], daysOfFlight: [], loading: true, error: null };
|
||||
});
|
||||
|
||||
it("shows 1-item trail when no ?request= param (share-link)", () => {
|
||||
@@ -253,7 +261,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
|
||||
beforeEach(() => {
|
||||
mockSearchParamsGet = () => null;
|
||||
// Reset to loading state (breadcrumb tests rely on this)
|
||||
mockScheduleDetailsResult = { flights: [], loading: true, error: null };
|
||||
mockScheduleDetailsResult = { flights: [], daysOfFlight: [], loading: true, error: null };
|
||||
// The new ScheduleFlightsMiniList scrolls the highlighted row into
|
||||
// view on mount; JSDOM doesn't ship `scrollIntoView`, so stub it.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -288,6 +296,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
|
||||
},
|
||||
},
|
||||
],
|
||||
daysOfFlight: ["20260515", "20260516"],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
@@ -333,6 +342,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
|
||||
},
|
||||
},
|
||||
],
|
||||
daysOfFlight: ["20260515", "20260516"],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
@@ -345,6 +355,9 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
|
||||
);
|
||||
// Success state renders DayTabs in stickyContent (Schedule window = +330 days)
|
||||
expect(screen.getByTestId("day-tabs")).toBeTruthy();
|
||||
expect(screen.getByTestId("day-tabs").getAttribute("data-available-dates")).toBe(
|
||||
"20260515,20260516",
|
||||
);
|
||||
});
|
||||
|
||||
it("4.1.16.1-R4: back link navigates to scheduleHref (success state)", () => {
|
||||
@@ -377,6 +390,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
|
||||
},
|
||||
},
|
||||
],
|
||||
daysOfFlight: ["20260515", "20260516"],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { FC } from "react";
|
||||
import { Fragment, useCallback, useMemo } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { localeToLanguage, normalizeLocaleParam, DEFAULT_LANGUAGE } from "@/i18n/resolver.js";
|
||||
import { FlightCard } from "@/ui/flights/FlightCard.js";
|
||||
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
|
||||
import { IFlyWarning } from "@/ui/flights/IFlyWarning.js";
|
||||
@@ -20,6 +19,10 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
|
||||
import { PageLayout } from "@/ui/layout/PageLayout.js";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
||||
import {
|
||||
buildAeroflotSbSearchUrl,
|
||||
formatAeroflotRouteDate,
|
||||
} from "@/shared/booking/aeroflot.js";
|
||||
import { buildScheduleUrl } from "../url.js";
|
||||
import { useScheduleDetails } from "../hooks/useScheduleDetails.js";
|
||||
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
|
||||
@@ -92,7 +95,7 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
arrival: "",
|
||||
};
|
||||
|
||||
const { flights, loading, error } = useScheduleDetails(detailsParams);
|
||||
const { flights, daysOfFlight, loading, error } = useScheduleDetails(detailsParams);
|
||||
|
||||
const scheduleHref = `/${locale}/schedule`;
|
||||
|
||||
@@ -235,10 +238,8 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
const selectedDate = flightIds[0]?.date ?? "";
|
||||
|
||||
// `Купить билет` link — navigates to Aeroflot's booking flow in a new
|
||||
// tab. Mirrors BoardDetailsHeader's BuyTicketButton / Schedule search
|
||||
// page. Returns null when we can't assemble the query (missing legs).
|
||||
const language =
|
||||
localeToLanguage(normalizeLocaleParam(locale) ?? "ru-ru") ?? DEFAULT_LANGUAGE;
|
||||
// tab. Mirrors Angular's hardcoded ru-ru SB URL. Returns null when we
|
||||
// can't assemble the query (missing legs).
|
||||
const buyUrlFor = useCallback(
|
||||
(flight: ISimpleFlight): string | null => {
|
||||
const legs = flight.routeType === "Direct" ? [flight.leg] : flight.legs;
|
||||
@@ -247,14 +248,13 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
if (!firstLeg || !lastLeg) return null;
|
||||
const dep = firstLeg.departure.scheduled.airportCode;
|
||||
const arr = lastLeg.arrival.scheduled.airportCode;
|
||||
const depUtc = firstLeg.departure.times.scheduledDeparture.utc;
|
||||
const depDate = new Date(depUtc);
|
||||
const yyyy = depDate.getFullYear().toString();
|
||||
const mm = (depDate.getMonth() + 1).toString().padStart(2, "0");
|
||||
const dd = depDate.getDate().toString().padStart(2, "0");
|
||||
return `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`;
|
||||
const date = formatAeroflotRouteDate(
|
||||
firstLeg.departure.times.scheduledDeparture.local ||
|
||||
firstLeg.departure.times.scheduledDeparture.utc,
|
||||
);
|
||||
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
|
||||
},
|
||||
[language],
|
||||
[],
|
||||
);
|
||||
const backLink = (
|
||||
<Link
|
||||
@@ -382,7 +382,7 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
// TZ §4.1.16.3 R22-R28: day tabs (Schedule window: [-1, +330] from today)
|
||||
<DayTabs
|
||||
selectedDate={selectedDate}
|
||||
availableDates={[]}
|
||||
availableDates={daysOfFlight}
|
||||
daysBefore={scheduleSearchFrom}
|
||||
daysAfter={scheduleSearchTo}
|
||||
locale={locale}
|
||||
@@ -473,7 +473,10 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
<FlightCard flight={flight} direction="schedule" />
|
||||
<IFlyWarning flightNumber={flight.flightId.flightNumber} />
|
||||
{renderBody(flight)}
|
||||
<ScheduleLegDetails flight={flight as unknown as ISimpleFlight} />
|
||||
<ScheduleLegDetails
|
||||
flight={flight as unknown as ISimpleFlight}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{/* Angular hides the weekly operating schedule on
|
||||
multi-leg chains; keep it on direct flights. */}
|
||||
|
||||
@@ -90,7 +90,10 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
|
||||
CityAutocomplete: (props: Record<string, unknown>) => (
|
||||
<input
|
||||
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
|
||||
defaultValue={(props["value"] as string) ?? ""}
|
||||
value={(props["value"] as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
(props["onChange"] as (value: string) => void)(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
|
||||
@@ -467,4 +470,63 @@ describe("ScheduleFilter – validation per TZ §4.1.9.4", () => {
|
||||
expect(screen.queryByTestId("schedule-date-clear")).toBeNull();
|
||||
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale return dates when return flights toggle changes", () => {
|
||||
render(
|
||||
<ScheduleFilter
|
||||
initialDeparture="SVO"
|
||||
initialArrival="LED"
|
||||
initialDateFrom="20260601"
|
||||
initialDateTo="20260607"
|
||||
initialReturnFlights={true}
|
||||
initialReturnDateFrom="20260608"
|
||||
initialReturnDateTo="20260614"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("schedule-return-date-clear")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId("schedule-return-flights").querySelector("input")!);
|
||||
fireEvent.click(screen.getByTestId("schedule-return-flights").querySelector("input")!);
|
||||
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScheduleFilter – submit parity", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scheduleCalendarMock.days = [];
|
||||
scheduleCalendarMock.loaded = false;
|
||||
scheduleCalendarMock.params = [];
|
||||
_sliderOnChange = null;
|
||||
_sliderOnChanges.length = 0;
|
||||
});
|
||||
|
||||
it("allows immediate repeated submits like Angular", () => {
|
||||
render(
|
||||
<ScheduleFilter
|
||||
initialDeparture="SVO"
|
||||
initialArrival="LED"
|
||||
initialDateFrom="20260601"
|
||||
initialDateTo="20260607"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(2);
|
||||
|
||||
fireEvent.change(screen.getByTestId("schedule-arrival-input"), {
|
||||
target: { value: "KUF" },
|
||||
});
|
||||
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(3);
|
||||
expect(mockNavigate).toHaveBeenLastCalledWith(
|
||||
"/ru-ru/schedule/route/SVO-KUF-20260601-20260607",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,20 +251,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
[returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate],
|
||||
);
|
||||
|
||||
// §4.1.11 — submit button locked for 30 seconds after each search.
|
||||
// The 30-second constant is intentionally hardcoded (not configurable).
|
||||
const [submitLockedUntil, setSubmitLockedUntil] = useState(0);
|
||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (submitLockedUntil === 0 || nowTs >= submitLockedUntil) return;
|
||||
const id = setTimeout(() => setNowTs(Date.now()), 1000);
|
||||
return () => clearTimeout(id);
|
||||
}, [submitLockedUntil, nowTs]);
|
||||
const isSubmitLocked = useMemo(
|
||||
() => submitLockedUntil > 0 && nowTs < submitLockedUntil,
|
||||
[submitLockedUntil, nowTs],
|
||||
);
|
||||
|
||||
// TZ §4.1.9.4 Table 16: when the outbound range moves forward such
|
||||
// that the already-chosen return starts before the new outbound
|
||||
// dateTo, blank the return picker and any coupled error so the user
|
||||
@@ -366,7 +352,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitLocked) return;
|
||||
const dep = departure.trim().toUpperCase();
|
||||
const arr = arrival.trim().toUpperCase();
|
||||
if (!dep || !arr) return;
|
||||
@@ -489,9 +474,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
: {}),
|
||||
searchExecuted: true,
|
||||
});
|
||||
// Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable)
|
||||
setSubmitLockedUntil(Date.now() + 30_000);
|
||||
setNowTs(Date.now());
|
||||
void navigate(`/${locale}/${url}`);
|
||||
},
|
||||
[
|
||||
@@ -505,7 +487,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
returnTimeRange,
|
||||
navigate,
|
||||
locale,
|
||||
isSubmitLocked,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -662,7 +643,11 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={returnFlights}
|
||||
onChange={(e) => setReturnFlights(e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setReturnFlights(e.target.checked);
|
||||
setReturnDateRange([null, null]);
|
||||
setReturnBeforeOutboundError(null);
|
||||
}}
|
||||
/>
|
||||
<span>{t("SHARED.RETURN_FLIGHT_VIEW")}</span>
|
||||
</label>
|
||||
@@ -765,8 +750,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
||||
type="submit"
|
||||
className="search-button"
|
||||
data-testid="search-submit"
|
||||
disabled={isSubmitLocked}
|
||||
aria-disabled={isSubmitLocked}
|
||||
>
|
||||
<span>{t("SHARED.SCHEDULES_VIEW")}</span>
|
||||
</button>
|
||||
|
||||
@@ -397,7 +397,6 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
|
||||
<FlightActions
|
||||
flight={flight}
|
||||
locale={locale}
|
||||
showShare
|
||||
showBuy
|
||||
showRegister
|
||||
showStatus
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--meals .schedule-leg-details__label {
|
||||
&--meals .schedule-leg-details__label,
|
||||
&--services .schedule-leg-details__label {
|
||||
align-self: flex-start;
|
||||
padding-top: 8px;
|
||||
}
|
||||
@@ -99,7 +100,8 @@
|
||||
font-size: fonts.$font-size-m;
|
||||
}
|
||||
|
||||
&__meals {
|
||||
&__meals,
|
||||
&__services {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: vars.$space-xl;
|
||||
@@ -125,4 +127,34 @@
|
||||
&__meal-caption {
|
||||
color: colors.$blue;
|
||||
}
|
||||
|
||||
&__service {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 120px;
|
||||
padding: vars.$space-s2;
|
||||
border-radius: vars.$border-radius;
|
||||
color: colors.$blue-light;
|
||||
font-size: fonts.$font-size-s;
|
||||
font-weight: fonts.$font-medium;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: colors.$blue-extra-light;
|
||||
color: colors.$blue;
|
||||
}
|
||||
}
|
||||
|
||||
&__service-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&__service-caption {
|
||||
color: inherit;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ScheduleLegDetails } from "./ScheduleLegDetails.js";
|
||||
import type { ISimpleFlight, MealType } from "@/features/online-board/types.js";
|
||||
import type {
|
||||
IOnBoardService,
|
||||
ISimpleFlight,
|
||||
MealType,
|
||||
} from "@/features/online-board/types.js";
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
|
||||
function makeFlight(
|
||||
meal: MealType[] | undefined,
|
||||
services: IOnBoardService[] = [],
|
||||
): ISimpleFlight {
|
||||
return {
|
||||
routeType: "Direct",
|
||||
id: "x",
|
||||
@@ -34,7 +41,13 @@ function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
|
||||
} as never,
|
||||
equipment: {
|
||||
name: "Sukhoi SuperJet 100",
|
||||
aircraft: { scheduled: { title: "Sukhoi SuperJet 100" } },
|
||||
aircraft: {
|
||||
scheduled: { title: "Sukhoi SuperJet 100" },
|
||||
actual: {
|
||||
title: "Sukhoi SuperJet 100",
|
||||
onBoardServices: services,
|
||||
},
|
||||
},
|
||||
...(meal !== undefined ? { meal: meal.map((type) => ({ type })) } : {}),
|
||||
} as never,
|
||||
} as never,
|
||||
@@ -42,6 +55,30 @@ function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
|
||||
}
|
||||
|
||||
describe("ScheduleLegDetails Питание sub-icons", () => {
|
||||
it("links the aircraft title to Angular's Aeroflot plane park URL", () => {
|
||||
render(<ScheduleLegDetails flight={makeFlight([])} locale="ru-ru" />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Sukhoi SuperJet 100" });
|
||||
|
||||
expect(link.getAttribute("href")).toBe(
|
||||
"http://www.aeroflot.ru/cms/ru/flight/plane_park",
|
||||
);
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
});
|
||||
|
||||
it("uses the language prefix from locale for the plane park URL", () => {
|
||||
render(<ScheduleLegDetails flight={makeFlight([])} locale="en-us" />);
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole("link", { name: "Sukhoi SuperJet 100" })
|
||||
.getAttribute("href"),
|
||||
).toBe(
|
||||
"http://www.aeroflot.ru/cms/en/flight/plane_park",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no meal-class sub-icons when equipment.meal is empty", () => {
|
||||
render(<ScheduleLegDetails flight={makeFlight([])} />);
|
||||
expect(screen.queryByText("FOOD.ECONOMY")).toBeNull();
|
||||
@@ -64,4 +101,52 @@ describe("ScheduleLegDetails Питание sub-icons", () => {
|
||||
expect(screen.getByText("FOOD.COMFORT")).toBeTruthy();
|
||||
expect(screen.getByText("FOOD.BUSINESS")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders onboard services from actual aircraft data", () => {
|
||||
render(
|
||||
<ScheduleLegDetails
|
||||
flight={makeFlight(["Economy"], [
|
||||
{
|
||||
id: "2",
|
||||
title: "Space+",
|
||||
url: "http://www.aeroflot.ru/cms/ru/flight/space_plus",
|
||||
},
|
||||
{ id: "5", title: "Интернет на борту" },
|
||||
{ id: "8", title: "Выбор места" },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("SHARED.SERVICE")).toBeTruthy();
|
||||
expect(screen.getByText("Space+")).toBeTruthy();
|
||||
expect(screen.getByText("Интернет на борту")).toBeTruthy();
|
||||
expect(screen.getByText("Выбор места")).toBeTruthy();
|
||||
expect(screen.getByTestId("schedule-service-icon-2")).toBeTruthy();
|
||||
expect(screen.getByTestId("schedule-service-icon-5")).toBeTruthy();
|
||||
expect(screen.getByTestId("schedule-service-icon-8")).toBeTruthy();
|
||||
expect(screen.getByText("Space+").closest("a")?.getAttribute("href")).toBe(
|
||||
"http://www.aeroflot.ru/cms/ru/flight/space_plus",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Интернет на борту").closest("a")?.getAttribute("href"),
|
||||
).toBe("https://aeroflot.ru");
|
||||
});
|
||||
|
||||
it("does not render onboard services when only scheduled aircraft has services", () => {
|
||||
const flight = makeFlight(["Economy"]);
|
||||
const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
|
||||
expect(leg).toBeTruthy();
|
||||
if (!leg) return;
|
||||
leg.equipment.aircraft = {
|
||||
scheduled: {
|
||||
title: "Sukhoi SuperJet 100",
|
||||
onBoardServices: [{ id: "2", title: "Space+" }],
|
||||
},
|
||||
};
|
||||
|
||||
render(<ScheduleLegDetails flight={flight} />);
|
||||
|
||||
expect(screen.queryByText("SHARED.SERVICE")).toBeNull();
|
||||
expect(screen.queryByText("Space+")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
* each flight leg. Mirrors Angular's `flight-details-wrapper` accordion
|
||||
* (schedule view): two rows when the data exists.
|
||||
*
|
||||
* 1. Борт — aircraft type with a link to its Aeroflot info page.
|
||||
* 1. Борт — aircraft type with a link to Aeroflot's aircraft park.
|
||||
* 2. Питание на борту — meal-class sub-icons (Эконом / Комфорт /
|
||||
* Бизнес), each rendered ONLY when the API's
|
||||
* `equipment.meal[]` array contains the matching `type`. Matches
|
||||
* Angular's `*ngIf="hasEconomyMeal"` etc. — flights with no meal
|
||||
* data show just the cutlery icon + caption with no sub-icons.
|
||||
* 3. Услуги на борту — service icons from
|
||||
* `equipment.aircraft.actual.onBoardServices`, matching Angular's
|
||||
* `model.showServices`.
|
||||
*
|
||||
* Expanded by default, toggled via the header.
|
||||
*
|
||||
@@ -17,26 +20,41 @@
|
||||
|
||||
import { useState, type FC } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { ISimpleFlight } from "@/features/online-board/types.js";
|
||||
import type {
|
||||
IOnBoardService,
|
||||
ISimpleFlight,
|
||||
} from "@/features/online-board/types.js";
|
||||
import {
|
||||
SERVICE_ICON_FALLBACK,
|
||||
SERVICE_ICON_MAP,
|
||||
} from "@/features/online-board/components/details-panels/shared.js";
|
||||
import economIcon from "@/features/online-board/components/details-panels/icons/econom.svg";
|
||||
import comfortIcon from "@/features/online-board/components/details-panels/icons/comfort.svg";
|
||||
import businessIcon from "@/features/online-board/components/details-panels/icons/business.svg";
|
||||
import shoppingIcon from "@/features/online-board/components/details-panels/icons/shopping.svg";
|
||||
import spaceIcon from "@/features/online-board/components/details-panels/icons/space.svg";
|
||||
import taxiIcon from "@/features/online-board/components/details-panels/icons/taxi.svg";
|
||||
import wifiIcon from "@/features/online-board/components/details-panels/icons/wifi.svg";
|
||||
import gsmIcon from "@/features/online-board/components/details-panels/icons/gsm.svg";
|
||||
import entertainmentIcon from "@/features/online-board/components/details-panels/icons/entertaintment.svg";
|
||||
import seatReservationIcon from "@/features/online-board/components/details-panels/icons/seat_reservation.svg";
|
||||
import comfortPlusIcon from "@/features/online-board/components/details-panels/icons/comfort-plus.svg";
|
||||
import "./ScheduleLegDetails.scss";
|
||||
|
||||
export interface ScheduleLegDetailsProps {
|
||||
flight: ISimpleFlight;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/** Slug used for the aircraft catalog deep-link on www.aeroflot.ru. */
|
||||
function aircraftSlug(title: string | undefined | null): string | null {
|
||||
if (!title) return null;
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
function aircraftParkHref(locale: string | undefined): string {
|
||||
const language = locale?.split("-")[0] || "ru";
|
||||
return `http://www.aeroflot.ru/cms/${language}/flight/plane_park`;
|
||||
}
|
||||
|
||||
export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
|
||||
export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({
|
||||
flight,
|
||||
locale,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState<boolean>(true);
|
||||
|
||||
@@ -47,13 +65,8 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
|
||||
leg.equipment?.aircraft?.actual?.title ??
|
||||
leg.equipment?.aircraft?.scheduled?.title ??
|
||||
"";
|
||||
const slug = aircraftSlug(aircraft);
|
||||
// External Aeroflot page that describes the aircraft model. Angular
|
||||
// uses a curated slug map; the simple kebab-case works for common
|
||||
// models (e.g. sukhoi-superjet-100).
|
||||
const planeUrl = slug
|
||||
? `https://www.aeroflot.ru/ru-ru/about/aircrafts/${slug}`
|
||||
: null;
|
||||
const services = leg.equipment?.aircraft?.actual?.onBoardServices ?? [];
|
||||
const planeUrl = aircraftParkHref(locale);
|
||||
|
||||
return (
|
||||
<div className="schedule-leg-details" data-testid="schedule-leg-details">
|
||||
@@ -117,18 +130,14 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
|
||||
<span className="schedule-leg-details__label">
|
||||
{t("SHARED.PLANE")}
|
||||
</span>
|
||||
{planeUrl ? (
|
||||
<a
|
||||
className="schedule-leg-details__link"
|
||||
href={planeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{aircraft}
|
||||
</a>
|
||||
) : (
|
||||
<span className="schedule-leg-details__value">{aircraft}</span>
|
||||
)}
|
||||
<a
|
||||
className="schedule-leg-details__link"
|
||||
href={planeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{aircraft}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,6 +211,50 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{services.length > 0 && (
|
||||
<div className="schedule-leg-details__row schedule-leg-details__row--services">
|
||||
<span
|
||||
className="schedule-leg-details__icon schedule-leg-details__icon--services"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 47"
|
||||
width="36"
|
||||
height="36"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M31.087 15.427A9.551 9.551 0 0 1 31 14.136c0-4.81 3.582-8.71 8-8.71s8 3.9 8 8.71a9.551 9.551 0 0 1-.087 1.29" />
|
||||
<path d="m31 16 .806 1.812A2 2 0 0 0 33.633 19h10.734a2 2 0 0 0 1.828-1.188L47 16" />
|
||||
<path d="M3 7.093V3h1.639c1.561 0 0 3.349 0 3.349v1.079h1.093c.585 0 2.459 5.209 2.459 5.209h6.517v3.014H4.951A23.353 23.353 0 0 1 3 7.093Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14.474 15.651c2.77 0 .928 3.721 0 3.721H8.463c-2.537 0-2.927-2.046-3.512-3.721Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="m15.1 15.651 3.9 2.828" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18.086 12.339H9.929" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.4 32.821c-1.12 0-1.917-.365-1.917-1.246V31H5.519v.576c0 .881-.8 1.246-1.918 1.246H3v4.734c0 5.934 7.877 10.114 8.212 10.289l.3.156.293-.166c.339-.192 8.31-4.759 8.2-10.279v-4.735Z" />
|
||||
<path d="m46.435 35.6.38.838.866-.308a1.829 1.829 0 0 1 .708-.13.452.452 0 0 1 .321.136.851.851 0 0 1 .252.789 1.187 1.187 0 0 1-.655.685l-.842.427.379.865a14.856 14.856 0 0 1 .686 1.908l.014.049.013.032a1.255 1.255 0 0 1 0 .2c0 .068-.01.133-.017.212 0 .027-.005.055-.008.085-.01.107-.021.248-.021.389 0 .908-.026 1.818-.052 2.744v.006c-.023.813-.046 1.639-.052 2.47h-2.155V44.87l-1.119.13a52.486 52.486 0 0 1-12.255 0l-1.118-.133V47h-2.17c-.005-1.343-.04-2.68-.074-4.008q-.015-.587-.03-1.172c0-.136-.012-.272-.021-.377l-.007-.085c-.007-.081-.013-.149-.017-.219a1.462 1.462 0 0 1 0-.191v-.033l.009-.028.061-.186c.206-.63.4-1.211.638-1.763l.378-.865-.842-.427a1.694 1.694 0 0 1-.618-.481.373.373 0 0 1-.068-.15.315.315 0 0 1 .023-.16l.025-.07.014-.074c.083-.42.258-.561.385-.62a1.192 1.192 0 0 1 .881.04l.866.308.379-.838c.115-.254.223-.56.32-.842l.049-.142c.086-.248.169-.491.261-.729a5.421 5.421 0 0 1 .324-.718 1.089 1.089 0 0 1 .224-.3l.057-.043.05-.051a4.824 4.824 0 0 1 2.24-.925 20.565 20.565 0 0 1 7.826.009 4.826 4.826 0 0 1 2.223.916 3.611 3.611 0 0 1 .639 1.154c.086.216.165.433.248.66l.062.17c.105.273.218.57.34.84Z" />
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<rect width="2" height="2.647" rx="1" transform="translate(38 3)" />
|
||||
<rect width="20" height="2" rx="1" transform="translate(29 14.385)" />
|
||||
<path d="M21.243 26.215h2.486V28.7a1.243 1.243 0 1 0 2.486 0v-2.485H28.7a1.243 1.243 0 1 0 0-2.486h-2.485v-2.486a1.243 1.243 0 1 0-2.486 0v2.486h-2.486a1.243 1.243 0 1 0 0 2.486Z" />
|
||||
<path d="M8.875 39.375h1.75v1.75a.875.875 0 0 0 1.75 0v-1.75h1.75a.875.875 0 0 0 0-1.75h-1.75v-1.75a.875.875 0 0 0-1.75 0v1.75h-1.75a.875.875 0 0 0 0 1.75Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="schedule-leg-details__label">
|
||||
{t("SHARED.SERVICE")}
|
||||
</span>
|
||||
<div className="schedule-leg-details__services">
|
||||
{services.map((service, index) => (
|
||||
<ServicePill
|
||||
key={`${service.id}-${index}`}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,3 +286,49 @@ const MealPill: FC<MealPillProps> = ({ icon, labelKey, t }) => (
|
||||
<span className="schedule-leg-details__meal-caption">{t(labelKey)}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const SERVICE_ICON_BY_NAME: Record<string, string> = {
|
||||
shopping: shoppingIcon,
|
||||
space: spaceIcon,
|
||||
taxi: taxiIcon,
|
||||
wifi: wifiIcon,
|
||||
gsm: gsmIcon,
|
||||
entertaintment: entertainmentIcon,
|
||||
seat_reservation: seatReservationIcon,
|
||||
"comfort-plus": comfortPlusIcon,
|
||||
};
|
||||
|
||||
function serviceIconSrc(service: IOnBoardService): string {
|
||||
const numericId = typeof service.id === "string" ? Number(service.id) : service.id;
|
||||
const iconName = SERVICE_ICON_MAP[numericId] ?? SERVICE_ICON_FALLBACK;
|
||||
return (
|
||||
SERVICE_ICON_BY_NAME[iconName] ??
|
||||
SERVICE_ICON_BY_NAME[SERVICE_ICON_FALLBACK] ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
const ServicePill: FC<{ service: IOnBoardService }> = ({ service }) => {
|
||||
const iconSrc = serviceIconSrc(service);
|
||||
const title = service.title ?? "";
|
||||
|
||||
return (
|
||||
<a
|
||||
className="schedule-leg-details__service"
|
||||
href={service.url || "https://aeroflot.ru"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
className="schedule-leg-details__service-icon"
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-testid={`schedule-service-icon-${service.id}`}
|
||||
/>
|
||||
{title && (
|
||||
<span className="schedule-leg-details__service-caption">{title}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// §4.1.11 — block filter/tabs/breadcrumbs while loading
|
||||
// Angular parity: loading blocks only the sticky results controls.
|
||||
// The sidebar filter stays interactive so users can correct criteria
|
||||
// while the previous request is still in-flight.
|
||||
&[data-searching="true"] {
|
||||
.page-layout__left,
|
||||
.page-layout__sticky,
|
||||
.page-layout__breadcrumbs {
|
||||
.page-layout__sticky {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -30,8 +30,13 @@ import "./ScheduleSearchPage.scss";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
|
||||
import { buildScheduleUrl } from "../url.js";
|
||||
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
|
||||
import { buildFlightUrlParams } from "../../online-board/url.js";
|
||||
import { buildDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
||||
import {
|
||||
buildAeroflotSbSearchUrl,
|
||||
formatAeroflotRouteDate,
|
||||
} from "@/shared/booking/aeroflot.js";
|
||||
import { buildScheduleFlightListJsonLd } from "../json-ld.js";
|
||||
import type { ScheduleParams } from "../url.js";
|
||||
import type { IScheduleSearchRequest, ISimpleFlight } from "../types.js";
|
||||
@@ -48,11 +53,12 @@ function toSearchRequest(
|
||||
direction: IScheduleRouteDirectionParams,
|
||||
attribute?: 1 | 2,
|
||||
): IScheduleSearchRequest {
|
||||
const [dateFrom, dateTo] = clampDirectionDatesToScheduleWindow(direction);
|
||||
const request: IScheduleSearchRequest = {
|
||||
departure: direction.departure,
|
||||
arrival: direction.arrival,
|
||||
dateFrom: formatApiDate(direction.dateFrom),
|
||||
dateTo: formatApiDate(direction.dateTo),
|
||||
dateFrom: formatApiDate(dateFrom),
|
||||
dateTo: formatApiDate(dateTo),
|
||||
};
|
||||
|
||||
if (direction.timeFrom) request.timeFrom = direction.timeFrom;
|
||||
@@ -71,6 +77,35 @@ function formatApiDate(yyyymmdd: string): string {
|
||||
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function yyyymmddToDate(value: string): Date | null {
|
||||
if (!/^\d{8}$/.test(value)) return null;
|
||||
const y = Number(value.slice(0, 4));
|
||||
const m = Number(value.slice(4, 6));
|
||||
const d = Number(value.slice(6, 8));
|
||||
const date = new Date(y, m - 1, d);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
function dateToYyyymmdd(value: Date): string {
|
||||
return `${value.getFullYear()}${String(value.getMonth() + 1).padStart(2, "0")}${String(value.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function clampDirectionDatesToScheduleWindow(
|
||||
direction: IScheduleRouteDirectionParams,
|
||||
): [string, string] {
|
||||
const from = yyyymmddToDate(direction.dateFrom);
|
||||
const to = yyyymmddToDate(direction.dateTo);
|
||||
if (!from || !to) return [direction.dateFrom, direction.dateTo];
|
||||
|
||||
const [windowMin, windowMax] = scheduleWindowBounds();
|
||||
const clampedFrom = from.getTime() < windowMin.getTime() ? windowMin : from;
|
||||
const clampedTo = to.getTime() > windowMax.getTime() ? windowMax : to;
|
||||
|
||||
return [dateToYyyymmdd(clampedFrom), dateToYyyymmdd(clampedTo)];
|
||||
}
|
||||
|
||||
import { extractSimpleFlights } from "../extractSimpleFlights.js";
|
||||
|
||||
export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
@@ -103,13 +138,20 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
childIds && childIds.length > 1
|
||||
? childIds
|
||||
: [flight.flightId];
|
||||
const buildSeg = (id: typeof flight.flightId): string =>
|
||||
buildFlightUrlParams({
|
||||
const buildSeg = (
|
||||
id: typeof flight.flightId,
|
||||
leg?: typeof allLegs[number],
|
||||
): string => {
|
||||
const localDate = leg?.departure.times.scheduledDeparture.local
|
||||
?.slice(0, 10)
|
||||
.replace(/-/g, "");
|
||||
return buildFlightUrlParams({
|
||||
carrier: id.carrier,
|
||||
flightNumber: id.flightNumber,
|
||||
...(id.suffix ? { suffix: id.suffix } : {}),
|
||||
date: id.date,
|
||||
date: localDate || id.date,
|
||||
});
|
||||
};
|
||||
// Interleave airport codes between flight segments so the URL
|
||||
// round-trips through `parseFlightSegments` (3-char codes are
|
||||
// skipped) AND keeps Angular-compatible structure for SEO.
|
||||
@@ -117,7 +159,7 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
flightIds.forEach((id, i) => {
|
||||
const leg = allLegs[i] ?? allLegs[allLegs.length - 1];
|
||||
if (i === 0 && leg) parts.push(leg.departure.scheduled.airportCode);
|
||||
parts.push(buildSeg(id));
|
||||
parts.push(buildSeg(id, leg));
|
||||
if (leg) parts.push(leg.arrival.scheduled.airportCode);
|
||||
});
|
||||
const segment = parts.join("/");
|
||||
@@ -156,9 +198,8 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
[locale, navigate, outbound, inbound],
|
||||
);
|
||||
|
||||
// Builds the Aeroflot booking URL for a flight — shares shape with
|
||||
// BoardDetailsHeader's BuyTicketButton:
|
||||
// https://www.aeroflot.ru/sb/app/{lang}-{lang}#/search?…&routes={dep}.{yyyyMMdd}.{arr}
|
||||
// Builds the Aeroflot booking URL for a flight. Angular's
|
||||
// BuyTicketLogic hardcodes the SB path to ru-ru for every UI locale.
|
||||
// Returns null when the flight lacks the data we need to assemble it.
|
||||
const buyUrlFor = useCallback(
|
||||
(flight: ISimpleFlight): string | null => {
|
||||
@@ -168,14 +209,13 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
if (!firstLeg || !lastLeg) return null;
|
||||
const dep = firstLeg.departure.scheduled.airportCode;
|
||||
const arr = lastLeg.arrival.scheduled.airportCode;
|
||||
const depUtc = firstLeg.departure.times.scheduledDeparture.utc;
|
||||
const depDate = new Date(depUtc);
|
||||
const yyyy = depDate.getFullYear().toString();
|
||||
const mm = (depDate.getMonth() + 1).toString().padStart(2, "0");
|
||||
const dd = depDate.getDate().toString().padStart(2, "0");
|
||||
return `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`;
|
||||
const date = formatAeroflotRouteDate(
|
||||
firstLeg.departure.times.scheduledDeparture.local ||
|
||||
firstLeg.departure.times.scheduledDeparture.utc,
|
||||
);
|
||||
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
|
||||
},
|
||||
[language],
|
||||
[],
|
||||
);
|
||||
|
||||
// Round-trip schedules render only one direction at a time, with a
|
||||
@@ -257,7 +297,7 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
flights: inboundFlights,
|
||||
loading: inboundLoading,
|
||||
cancel: cancelInbound,
|
||||
} = useScheduleSearch(inboundRequest);
|
||||
} = useScheduleSearch(inboundRequest, Boolean(inbound));
|
||||
|
||||
const isLoading = outboundLoading || (inbound ? inboundLoading : false);
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ vi.mock("@/shared/dictionaries/index.js", () => ({
|
||||
getCityCodeByAirportCode: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useScheduleCalendar.js", () => ({
|
||||
useScheduleCalendar: () => ({ days: [], loading: false, loaded: false }),
|
||||
}));
|
||||
|
||||
let geoMockEnabled = false;
|
||||
|
||||
vi.mock("@/shared/hooks/useGeoCityDefault.js", () => ({
|
||||
@@ -195,9 +199,6 @@ describe("ScheduleStartPage", () => {
|
||||
// 2026-05-15 (Fri) → raw Mon 2026-05-11, raw Sun 2026-05-17
|
||||
// `from` is clamped to today−1 = 2026-05-14 so the route guard does
|
||||
// not redirect the search back to the start page.
|
||||
// Same-page Schedule click updates form state directly (navigate to
|
||||
// the same route would no-op), so we assert visible form state and
|
||||
// submit the form to verify the dates landed in component state.
|
||||
render(<ScheduleStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-route"));
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
@@ -206,7 +207,6 @@ describe("ScheduleStartPage", () => {
|
||||
expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.queryByTestId("return-date-range-input")).toBeNull();
|
||||
|
||||
// Submit drives the dates from state into the URL — proves they were set.
|
||||
fireEvent.submit(screen.getByTestId("schedule-search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Schedule start page -- search form for route-based schedule search.
|
||||
*
|
||||
* No API calls on load. Pure form that navigates to the appropriate
|
||||
* search route on submit.
|
||||
* No schedule-search API calls on load. Once both cities are selected,
|
||||
* fetches route operating days so unavailable dates are greyed out before
|
||||
* submit, matching Angular's schedule-filter.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type FC, useState, useCallback, useEffect, useRef, type FormEvent } from "react";
|
||||
import { type FC, useState, useCallback, useEffect, useMemo, useRef, type FormEvent } from "react";
|
||||
import { useNavigate } from "@modern-js/runtime/router";
|
||||
import { useLocale } from "@/i18n/useLocale.js";
|
||||
import { Calendar } from "primereact/calendar";
|
||||
@@ -36,6 +37,8 @@ import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
|
||||
import { buildScheduleUrl } from "../url.js";
|
||||
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
|
||||
import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js";
|
||||
import { useScheduleCalendar } from "../hooks/useScheduleCalendar.js";
|
||||
import type { IScheduleCalendarParams } from "../types.js";
|
||||
import "./ScheduleStartPage.scss";
|
||||
|
||||
function toCityCode(code: string, dictionaries: IDictionaries | null): string {
|
||||
@@ -56,6 +59,33 @@ function dateToYyyymmdd(value: Date): string {
|
||||
return `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
function dateToIsoYmd(value: Date): string {
|
||||
const y = value.getFullYear().toString();
|
||||
const m = (value.getMonth() + 1).toString().padStart(2, "0");
|
||||
const d = value.getDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function computeDisabledDates(
|
||||
availableYmd: string[],
|
||||
minDate: Date,
|
||||
maxDate: Date,
|
||||
): Date[] {
|
||||
const available = new Set(availableYmd);
|
||||
const disabled: Date[] = [];
|
||||
const cursor = new Date(minDate);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor.getTime() <= maxDate.getTime()) {
|
||||
if (!available.has(dateToIsoYmd(cursor))) {
|
||||
disabled.push(new Date(cursor));
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return disabled;
|
||||
}
|
||||
|
||||
function addDays(base: Date, days: number): Date {
|
||||
const result = new Date(base);
|
||||
result.setDate(result.getDate() + days);
|
||||
@@ -245,6 +275,59 @@ export const ScheduleStartPage: FC = () => {
|
||||
|
||||
const scheduleMinDate = useRef(getScheduleMinDate()).current;
|
||||
const scheduleMaxDate = useRef(getScheduleMaxDate()).current;
|
||||
const scheduleCalendarBaseDate = useMemo(
|
||||
() => dateToIsoYmd(scheduleMinDate),
|
||||
[scheduleMinDate],
|
||||
);
|
||||
|
||||
const outboundCalendarParams = useMemo<IScheduleCalendarParams | null>(() => {
|
||||
const dep = toCityCode(departureCode.trim().toUpperCase(), dictionaries);
|
||||
const arr = toCityCode(arrivalCode.trim().toUpperCase(), dictionaries);
|
||||
if (!dep || !arr || dep === arr) return null;
|
||||
return {
|
||||
date: scheduleCalendarBaseDate,
|
||||
departure: dep,
|
||||
arrival: arr,
|
||||
connections: !directOnly,
|
||||
};
|
||||
}, [departureCode, arrivalCode, dictionaries, directOnly, scheduleCalendarBaseDate]);
|
||||
|
||||
const returnCalendarParams = useMemo<IScheduleCalendarParams | null>(() => {
|
||||
if (!isRoundTrip) return null;
|
||||
const dep = toCityCode(departureCode.trim().toUpperCase(), dictionaries);
|
||||
const arr = toCityCode(arrivalCode.trim().toUpperCase(), dictionaries);
|
||||
if (!dep || !arr || dep === arr) return null;
|
||||
return {
|
||||
date: scheduleCalendarBaseDate,
|
||||
departure: arr,
|
||||
arrival: dep,
|
||||
connections: !directOnly,
|
||||
};
|
||||
}, [departureCode, arrivalCode, dictionaries, directOnly, isRoundTrip, scheduleCalendarBaseDate]);
|
||||
|
||||
const {
|
||||
days: outboundAvailableDays,
|
||||
loaded: outboundCalendarLoaded,
|
||||
} = useScheduleCalendar(outboundCalendarParams);
|
||||
const {
|
||||
days: returnAvailableDays,
|
||||
loaded: returnCalendarLoaded,
|
||||
} = useScheduleCalendar(returnCalendarParams);
|
||||
|
||||
const outboundDisabledDates = useMemo(
|
||||
() =>
|
||||
!outboundCalendarLoaded
|
||||
? []
|
||||
: computeDisabledDates(outboundAvailableDays, scheduleMinDate, scheduleMaxDate),
|
||||
[outboundAvailableDays, outboundCalendarLoaded, scheduleMinDate, scheduleMaxDate],
|
||||
);
|
||||
const returnDisabledDates = useMemo(
|
||||
() =>
|
||||
!returnCalendarLoaded
|
||||
? []
|
||||
: computeDisabledDates(returnAvailableDays, scheduleMinDate, scheduleMaxDate),
|
||||
[returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate],
|
||||
);
|
||||
|
||||
// TZ §4.1.9 Table 14 / Angular CalendarInputWeekComponent: when the
|
||||
// selected date range contains today, the Calendar input shows
|
||||
@@ -383,12 +466,9 @@ export const ScheduleStartPage: FC = () => {
|
||||
|
||||
const handlePopularRequestClick = useCallback(
|
||||
(request: PopularRequest) => {
|
||||
// Deviation from Angular: every popular-request click on Schedule
|
||||
// populates the form in-place and stays on the page. Angular
|
||||
// redirects Arrival/Departure/FlightNumber clicks to /onlineboard
|
||||
// unconditionally; we instead treat the click as a Schedule prefill
|
||||
// because users land here to plan a trip, not to switch sections.
|
||||
// Codes sometimes arrive as airport (SVO/LED) — normalize to city.
|
||||
// Popular route clicks prefill the Schedule form from the clicked
|
||||
// item. This mirrors Angular: users land on the Schedule page with
|
||||
// route/date fields ready, then submit when they want results.
|
||||
switch (request.mode) {
|
||||
case "Route":
|
||||
case "RouteWithBack": {
|
||||
@@ -489,6 +569,7 @@ export const ScheduleStartPage: FC = () => {
|
||||
selectionMode="range"
|
||||
minDate={scheduleMinDate}
|
||||
maxDate={scheduleMaxDate}
|
||||
disabledDates={outboundDisabledDates}
|
||||
selectOtherMonths
|
||||
dateFormat="dd.mm.yy"
|
||||
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
||||
@@ -555,6 +636,7 @@ export const ScheduleStartPage: FC = () => {
|
||||
selectionMode="range"
|
||||
minDate={scheduleMinDate}
|
||||
maxDate={scheduleMaxDate}
|
||||
disabledDates={returnDisabledDates}
|
||||
selectOtherMonths
|
||||
dateFormat="dd.mm.yy"
|
||||
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface UseScheduleDetailsParams {
|
||||
|
||||
export interface UseScheduleDetailsResult {
|
||||
flights: ISimpleFlight[];
|
||||
daysOfFlight: string[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
}
|
||||
@@ -34,6 +35,7 @@ export function useScheduleDetails(
|
||||
): UseScheduleDetailsResult {
|
||||
const client = useApiClient();
|
||||
const [flights, setFlights] = useState<ISimpleFlight[]>([]);
|
||||
const [daysOfFlight, setDaysOfFlight] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
@@ -53,6 +55,7 @@ export function useScheduleDetails(
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setFlights(response.data.routes);
|
||||
setDaysOfFlight(response.data.daysOfFlight ?? []);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
@@ -68,5 +71,5 @@ export function useScheduleDetails(
|
||||
};
|
||||
}, [client, flightsKey, datesKey, params.departure, params.arrival]);
|
||||
|
||||
return { flights, loading, error };
|
||||
return { flights, daysOfFlight, loading, error };
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ export interface UseScheduleSearchResult {
|
||||
*/
|
||||
export function useScheduleSearch(
|
||||
params: IScheduleSearchRequest,
|
||||
enabled = true,
|
||||
): UseScheduleSearchResult {
|
||||
const client = useApiClient();
|
||||
const [flights, setFlights] = useState<IFlight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
@@ -55,6 +56,17 @@ export function useScheduleSearch(
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
setFlights([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any previous in-flight request (§4.1.12 — new search aborts in-flight)
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
@@ -92,6 +104,7 @@ export function useScheduleSearch(
|
||||
params.timeFrom,
|
||||
params.timeTo,
|
||||
params.connections,
|
||||
enabled,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"FLIGHT_NUMBER": "Flight number",
|
||||
"FLIGHT_NUMBER-ERROR-BIG": "Incorrect flight number. The flight number may not be longer than 4 digits",
|
||||
"FLIGHT_NUMBER-ERROR-EMPTY": "Specify flight number",
|
||||
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number may only consist of digits and must not exceed 4 characters.",
|
||||
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number must consist of 3–4 digits and may include a single Latin letter suffix at the end.",
|
||||
"GPS-BUTTON": "Detect my location",
|
||||
"GPS-HELP": "Enable geolocation in your browser to detect the city automatically. Geolocation will not work if any anonymizers are enabled.",
|
||||
"NOT-FOUND-LOCATION": "You are seeing this page because we could not access your current location. \nAllow the app to access your location to view flights to your destination.",
|
||||
@@ -432,6 +432,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"FLIGHT_NUMBER": "Номер рейса",
|
||||
"FLIGHT_NUMBER-ERROR-BIG": "Неверно указан номер рейса. Номер рейса не должен быть длиннее 4-х символов",
|
||||
"FLIGHT_NUMBER-ERROR-EMPTY": "Укажите номер рейса",
|
||||
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса может состоять только из цифр и не должен быть длиннее 4-х символов.",
|
||||
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса должен состоять из 3-4 цифр и может содержать в конце одну латинскую букву-суффикс.",
|
||||
"GPS-BUTTON": "Определить мое местоположение",
|
||||
"GPS-HELP": "Для автоматического определения города разрешите браузеру доступ к геолокации. Определение может не работать при включенных анонимайзерах.",
|
||||
"NOT-FOUND-LOCATION": "Мы не смогли определить Ваше месторасположение — поэтому Вы видите эту страницу. \nРазрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",
|
||||
@@ -432,6 +432,7 @@
|
||||
"CONNECTION-LIVE": "Онлайн",
|
||||
"CONNECTION-RECONNECTING": "Соединение…",
|
||||
"CONNECTION-OFFLINE": "Нет связи",
|
||||
"STALE-DATA-REFRESH": "Данные устарели, обновите страницу!",
|
||||
"INVALID-PARAMS": "Неверные параметры URL.",
|
||||
"LOADING": "Загрузка…",
|
||||
"A11Y-PREV-PAGE": "Предыдущая страница",
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
|
||||
@@ -31,12 +31,18 @@ describe("isLanguage", () => {
|
||||
});
|
||||
|
||||
describe("resolveLocaleFromPath", () => {
|
||||
it("extracts BCP-47 locale from the first path segment", () => {
|
||||
it("extracts Angular country-language locale from the first path segment", () => {
|
||||
expect(resolveLocaleFromPath("/ru-ru/onlineboard")).toBe("ru-ru");
|
||||
expect(resolveLocaleFromPath("/en-en/onlineboard/flight/SU100")).toBe("en-en");
|
||||
expect(resolveLocaleFromPath("/de-de/schedule")).toBe("de-de");
|
||||
});
|
||||
|
||||
it("accepts mixed country-language URLs and resolves language from the second part", () => {
|
||||
expect(resolveLocaleFromPath("/ru-en/onlineboard")).toBe("ru-en");
|
||||
expect(resolveLocaleFromPath("/ru-de/schedule")).toBe("ru-de");
|
||||
expect(resolveLocaleFromPath("/ad-fr/onlineboard")).toBe("ad-fr");
|
||||
});
|
||||
|
||||
it("auto-promotes a bare short language code to its BCP-47 cousin", () => {
|
||||
expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru-ru");
|
||||
expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en-en");
|
||||
@@ -47,6 +53,8 @@ describe("resolveLocaleFromPath", () => {
|
||||
expect(resolveLocaleFromPath("/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/xx-xx/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/russia-en/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/ru-eng/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/")).toBeNull();
|
||||
expect(resolveLocaleFromPath("")).toBeNull();
|
||||
});
|
||||
@@ -58,7 +66,7 @@ describe("resolveLocaleFromPath", () => {
|
||||
});
|
||||
|
||||
describe("stripLocaleFromPath", () => {
|
||||
it("strips BCP-47 locale and returns the rest", () => {
|
||||
it("strips Angular country-language locale and returns the rest", () => {
|
||||
expect(stripLocaleFromPath("/ru-ru/onlineboard")).toEqual({
|
||||
locale: "ru-ru",
|
||||
rest: "/onlineboard",
|
||||
@@ -67,6 +75,10 @@ describe("stripLocaleFromPath", () => {
|
||||
locale: "en-en",
|
||||
rest: "/onlineboard/flight/SU100",
|
||||
});
|
||||
expect(stripLocaleFromPath("/ru-en/onlineboard")).toEqual({
|
||||
locale: "ru-en",
|
||||
rest: "/onlineboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-promotes short codes when stripping", () => {
|
||||
|
||||
+18
-28
@@ -2,25 +2,16 @@
|
||||
* Locale codes used in URLs vs the short language code used for i18n
|
||||
* file lookup, API path segment, and `Accept-Language` header.
|
||||
*
|
||||
* Mirrors Angular's `LocalizationService`: URL is BCP-47 (`/ru-ru/`,
|
||||
* `/en-en/`, `/zh-zh/`...), backend + translation files use the short
|
||||
* 2-letter language part only (`ru`, `en`, `zh`...). This split keeps
|
||||
* the customer's URL contract while reusing a single set of locale
|
||||
* Mirrors Angular's `LocalizationService`: URL is `/{country}-{language}`
|
||||
* (`/ru-ru/`, `/ru-en/`, `/ru-de/`...), while backend + translation files
|
||||
* use the second 2-letter segment only (`ru`, `en`, `de`...). This split
|
||||
* keeps the customer's URL contract while reusing a single set of locale
|
||||
* resources.
|
||||
*/
|
||||
|
||||
export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de";
|
||||
|
||||
export type LocaleCode =
|
||||
| "ru-ru"
|
||||
| "en-en"
|
||||
| "es-es"
|
||||
| "fr-fr"
|
||||
| "it-it"
|
||||
| "ja-ja"
|
||||
| "ko-ko"
|
||||
| "zh-zh"
|
||||
| "de-de";
|
||||
export type LocaleCode = `${string}-${Language}`;
|
||||
|
||||
export const LANGUAGES: readonly Language[] = [
|
||||
"ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de",
|
||||
@@ -34,13 +25,11 @@ export const DEFAULT_LOCALE_CODE: LocaleCode = "ru-ru";
|
||||
export const DEFAULT_LANGUAGE: Language = "ru";
|
||||
|
||||
const languageSet: ReadonlySet<string> = new Set(LANGUAGES);
|
||||
const localeCodeSet: ReadonlySet<string> = new Set(LOCALE_CODES);
|
||||
const URL_LOCALE_PATTERN = /^([a-z]{2})-([a-z]{2})$/;
|
||||
|
||||
// Angular's URL contract is `/{country}-{language}` where the two
|
||||
// halves repeat the same 2-letter language code (see
|
||||
// `ClientApp/src/app/shared/services/localization.service.ts` —
|
||||
// `Country = baseHref[1..3]`, `Language = baseHref[4..6]`). We mirror
|
||||
// that exact shape so the React MF remote shares Angular's URL surface.
|
||||
// `languageToLocale` returns the canonical same-language URL used for
|
||||
// generated links, while `normalizeLocaleParam` below also accepts Angular's
|
||||
// mixed country-language URLs such as `/ru-en`.
|
||||
const LANGUAGE_TO_LOCALE_CODE: Record<Language, LocaleCode> = {
|
||||
ru: "ru-ru",
|
||||
en: "en-en",
|
||||
@@ -58,15 +47,16 @@ export function isLanguage(x: string): x is Language {
|
||||
}
|
||||
|
||||
export function isLocaleCode(x: string): x is LocaleCode {
|
||||
return localeCodeSet.has(x);
|
||||
const match = URL_LOCALE_PATTERN.exec(x);
|
||||
return match ? isLanguage(match[2] ?? "") : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the language part from a BCP-47 locale code.
|
||||
* `localeToLanguage("en-en")` → `"en"`.
|
||||
* Extract the language part from an Angular URL locale code.
|
||||
* `localeToLanguage("ru-en")` → `"en"`.
|
||||
*/
|
||||
export function localeToLanguage(code: LocaleCode): Language {
|
||||
return code.slice(0, 2) as Language;
|
||||
return code.split("-")[1] as Language;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,10 +69,10 @@ export function languageToLocale(lang: Language): LocaleCode {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the locale code from `:lang` URL params. Accepts both BCP-47
|
||||
* (`ru-ru`) and bare short codes (`ru`) — the short form is promoted
|
||||
* to its canonical BCP-47 cousin so legacy / direct API consumers
|
||||
* keep working during migration.
|
||||
* Read the locale code from `:lang` URL params. Accepts Angular
|
||||
* country-language URLs (`ru-en`) and bare short codes (`en`) — the short
|
||||
* form is promoted to its canonical same-language URL (`en-en`) so legacy /
|
||||
* direct API consumers keep working during migration.
|
||||
*/
|
||||
export function normalizeLocaleParam(raw: string | undefined): LocaleCode | null {
|
||||
if (!raw) return null;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { boardDateRedirect } from "./_guards.js";
|
||||
import { boardDateRedirect, boardSearchRedirect } from "./_guards.js";
|
||||
|
||||
describe("boardDateRedirect (clock frozen 2026-05-15)", () => {
|
||||
beforeEach(() => {
|
||||
@@ -40,3 +40,53 @@ describe("boardDateRedirect (clock frozen 2026-05-15)", () => {
|
||||
expect(boardDateRedirect("en-us", "20260101")).toBe("/en-us/onlineboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boardSearchRedirect time-range guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 4, 15, 12)); // May 15 2026 noon
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("allows a valid time-range suffix", () => {
|
||||
expect(
|
||||
boardSearchRedirect("ru-ru", {
|
||||
date: "20260515",
|
||||
timeFrom: "1400",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows 2400 as the upper bound", () => {
|
||||
expect(
|
||||
boardSearchRedirect("ru-ru", {
|
||||
date: "20260515",
|
||||
timeFrom: "2359",
|
||||
timeTo: "2400",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects when the time range is reversed", () => {
|
||||
expect(
|
||||
boardSearchRedirect("ru-ru", {
|
||||
date: "20260515",
|
||||
timeFrom: "1800",
|
||||
timeTo: "1400",
|
||||
}),
|
||||
).toBe("/ru-ru/onlineboard");
|
||||
});
|
||||
|
||||
it("redirects when a time component is outside HHmm bounds", () => {
|
||||
expect(
|
||||
boardSearchRedirect("ru-ru", {
|
||||
date: "20260515",
|
||||
timeFrom: "1460",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("/ru-ru/onlineboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,3 +17,46 @@ export function boardDateRedirect(locale: string, yyyymmdd: string): string | nu
|
||||
if (!isInBoardWindow(yyyymmdd)) return `/${locale}/onlineboard`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function hhmmToMinutes(value: string): number | null {
|
||||
if (!/^\d{4}$/.test(value)) return null;
|
||||
const hours = Number(value.slice(0, 2));
|
||||
const minutes = Number(value.slice(2, 4));
|
||||
if (hours === 24 && minutes === 0) return 24 * 60;
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular parity for Online-Board date + optional `Время рейса` URL guards.
|
||||
*
|
||||
* A missing time range is valid. When a range is present, both ends must be
|
||||
* valid HHmm values and `timeFrom` must be strictly before `timeTo`.
|
||||
* `2400` is accepted as the upper bound, matching Angular/moment behavior.
|
||||
*/
|
||||
export function boardSearchRedirect(
|
||||
locale: string,
|
||||
params: { date: string; timeFrom?: string; timeTo?: string },
|
||||
): string | null {
|
||||
const dateRedirect = boardDateRedirect(locale, params.date);
|
||||
if (dateRedirect) return dateRedirect;
|
||||
|
||||
const hasFrom = params.timeFrom !== undefined;
|
||||
const hasTo = params.timeTo !== undefined;
|
||||
if (!hasFrom && !hasTo) return null;
|
||||
if (!hasFrom || !hasTo) return `/${locale}/onlineboard`;
|
||||
|
||||
const timeFrom = params.timeFrom;
|
||||
const timeTo = params.timeTo;
|
||||
if (timeFrom === undefined || timeTo === undefined) {
|
||||
return `/${locale}/onlineboard`;
|
||||
}
|
||||
|
||||
const from = hhmmToMinutes(timeFrom);
|
||||
const to = hhmmToMinutes(timeTo);
|
||||
if (from === null || to === null || from >= to) {
|
||||
return `/${locale}/onlineboard`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user