Mirror Angular shell placeholders

This commit is contained in:
2026-05-20 16:30:07 +03:00
parent ee08795811
commit 1832b80374
6 changed files with 136 additions and 544 deletions
+47
View File
@@ -44,6 +44,52 @@ const publicEnvB64 = Buffer.from(
const PUBLIC_ENV_SCRIPT = const PUBLIC_ENV_SCRIPT =
`window.__ENV__=Object.assign(window.__ENV__||Object.create(null),JSON.parse(atob("${publicEnvB64}")));`; `window.__ENV__=Object.assign(window.__ENV__||Object.create(null),JSON.parse(atob("${publicEnvB64}")));`;
const modernCommand = process.argv.join(" ");
const npmLifecycleEvent = process.env["npm_lifecycle_event"] ?? "";
const isDevServer =
/\bdev\b/.test(modernCommand) || npmLifecycleEvent.includes("dev");
// Angular uses full `afl-component` loader assets in production
// `index.html`, but its local `index.dev.html` keeps only shell
// placeholders. Do the same here: the production loader's follow-up XHRs
// are CORS-blocked on localhost and would pollute every e2e run.
const shouldLoadStandaloneShellLoader = !isRemote && !isDevServer;
const standaloneShellTags = isRemote
? []
: [
...(shouldLoadStandaloneShellLoader
? [
{
tag: "link",
head: true,
append: true,
attrs: {
rel: "stylesheet",
href: "https://www.aeroflot.ru/frontend/static/css/afl-frontend-loader.bundle.css",
},
},
{
tag: "script",
head: false,
append: true,
attrs: {
async: true,
src: "https://www.aeroflot.ru/frontend/static/js/afl-frontend-loader.bundle.js",
},
},
]
: []),
{
tag: "meta",
head: true,
append: true,
attrs: {
name: "aeroflot-shell-loader",
content: shouldLoadStandaloneShellLoader ? "external" : "placeholder",
},
},
];
export default defineConfig({ export default defineConfig({
plugins, plugins,
source: { source: {
@@ -60,6 +106,7 @@ export default defineConfig({
append: false, append: false,
children: PUBLIC_ENV_SCRIPT, children: PUBLIC_ENV_SCRIPT,
}, },
...standaloneShellTags,
{ {
tag: "link", tag: "link",
attrs: { attrs: {
+2 -6
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Outlet, useLocation } from "@modern-js/runtime/router"; import { Outlet } from "@modern-js/runtime/router";
import { ErrorBoundary } from "@/ui/errors/ErrorBoundary"; import { ErrorBoundary } from "@/ui/errors/ErrorBoundary";
import { AeroflotShell } from "@/ui/shell"; import { AeroflotShell } from "@/ui/shell";
import { LoggerProvider } from "@/observability/logger/provider"; import { LoggerProvider } from "@/observability/logger/provider";
@@ -7,7 +7,6 @@ import { createRootLogger } from "@/observability/logger/root";
import { ApiClientProvider } from "@/shared/api/provider"; import { ApiClientProvider } from "@/shared/api/provider";
import { ApiClient } from "@/shared/api/client"; import { ApiClient } from "@/shared/api/client";
import { getEnv } from "@/env/index"; import { getEnv } from "@/env/index";
import { resolveLocaleFromPath, localeToLanguage } from "@/i18n/resolver";
// Global styles // Global styles
import "@/styles/index.scss"; import "@/styles/index.scss";
@@ -21,10 +20,7 @@ import "leaflet/dist/leaflet.css";
* nested routes. * nested routes.
*/ */
export default function RootLayout(): JSX.Element { export default function RootLayout(): JSX.Element {
const location = useLocation();
const logger = useMemo(() => createRootLogger(), []); const logger = useMemo(() => createRootLogger(), []);
const locale = resolveLocaleFromPath(location.pathname);
const language = locale ? localeToLanguage(locale) : "ru";
const apiClient = useMemo( const apiClient = useMemo(
() => { () => {
@@ -42,7 +38,7 @@ export default function RootLayout(): JSX.Element {
<LoggerProvider logger={logger}> <LoggerProvider logger={logger}>
<ApiClientProvider client={apiClient}> <ApiClientProvider client={apiClient}>
<ErrorBoundary> <ErrorBoundary>
<AeroflotShell language={language}> <AeroflotShell>
<Outlet /> <Outlet />
</AeroflotShell> </AeroflotShell>
</ErrorBoundary> </ErrorBoundary>
+7
View File
@@ -78,6 +78,13 @@ body {
} }
} }
.afl-component-header-placeholder,
.afl-component-footer-placeholder {
flex-shrink: 0;
width: 100%;
min-height: 10px;
}
.page-title { .page-title {
width: auto; width: auto;
display: inline-block; display: inline-block;
-307
View File
@@ -1,307 +0,0 @@
@use "../../styles/colors" as colors;
@use "../../styles/variables" as vars;
.aeroflot-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
&__header,
&__footer {
flex: 0 0 auto;
}
&__country {
min-height: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: vars.$space-m;
padding: vars.$space-s2 vars.$space-xl;
background: colors.$white;
color: colors.$text-color;
font-size: 14px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08);
button {
min-width: 46px;
min-height: 30px;
border: 0;
border-radius: 3px;
padding: 0 vars.$space-l;
background: colors.$extra-blue;
color: colors.$white;
font: inherit;
cursor: pointer;
}
}
&__link-button {
background: transparent !important;
color: colors.$blue-link !important;
}
&__skip {
position: absolute;
left: vars.$space-xl;
top: -100px;
z-index: 1003;
padding: vars.$space-m vars.$space-l;
background: colors.$white;
color: colors.$extra-blue;
&:focus {
top: vars.$space-m;
}
}
&__nav {
min-height: 82px;
display: grid;
grid-template-columns: minmax(160px, auto) 1fr auto;
align-items: center;
gap: vars.$space-xl;
max-width: vars.$site-width;
margin: 0 auto;
padding: 0 vars.$space-xl;
color: colors.$white;
nav {
display: flex;
align-items: center;
gap: vars.$space-xl;
min-width: 0;
}
a {
color: colors.$white;
text-decoration: none;
white-space: nowrap;
&:hover {
text-decoration: underline;
}
}
}
&__brand {
display: inline-flex;
align-items: center;
gap: vars.$space-m;
font-size: 23px;
font-weight: 700;
}
&__brand-mark {
width: 46px;
height: 46px;
border-radius: 50%;
background:
radial-gradient(circle at 34% 50%, colors.$white 0 21%, transparent 22%),
radial-gradient(circle at 66% 50%, colors.$white 0 21%, transparent 22%),
colors.$orange;
display: inline-block;
}
&__account {
display: flex;
align-items: center;
gap: vars.$space-l;
font-size: 14px;
span {
min-width: 40px;
text-align: center;
font-weight: 700;
}
}
&__promo {
max-width: vars.$site-width;
min-height: 80px;
margin: 0 auto vars.$space-l;
padding: vars.$space-l vars.$space-xl;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: vars.$space-xl;
background: colors.$white;
color: colors.$text-color;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
div {
display: flex;
flex-direction: column;
gap: vars.$space-s;
}
strong {
font-size: 20px;
font-weight: 700;
}
a {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 vars.$space-xl;
border-radius: 3px;
background: colors.$extra-blue;
color: colors.$white;
text-decoration: none;
}
small {
color: colors.$light-gray;
font-size: 12px;
}
}
&__test-version {
max-width: vars.$site-width;
margin: 0 auto;
padding: 0 vars.$space-xl vars.$space-m;
color: colors.$white;
font-size: 14px;
}
&__content {
flex: 1 0 auto;
padding: 0 24px;
}
&__footer {
margin-top: vars.$space-xxxl;
background: colors.$white;
color: colors.$text-color;
}
&__footer-inner {
max-width: vars.$site-width;
margin: 0 auto;
padding: vars.$space-xxl vars.$space-xl;
display: grid;
grid-template-columns: minmax(260px, 1.3fr) 1fr 1fr minmax(220px, 0.9fr);
gap: vars.$space-xxl;
}
&__branch,
&__copyright {
grid-column: 1 / -1;
}
&__branch {
color: colors.$light-gray;
}
&__contacts,
&__footer-column,
&__app {
display: flex;
flex-direction: column;
gap: vars.$space-m;
h2 {
font-size: 18px;
font-weight: 700;
margin-bottom: vars.$space-s;
}
a {
color: colors.$text-color;
text-decoration: none;
&:hover {
color: colors.$blue-link;
}
}
}
&__contacts {
dl {
display: flex;
flex-direction: column;
gap: vars.$space-l;
}
dt {
font-size: 24px;
font-weight: 700;
color: colors.$extra-blue;
}
dd {
margin-top: vars.$space-s;
color: colors.$light-gray;
line-height: 1.35;
}
}
&__app {
h2 {
color: colors.$extra-blue;
font-size: 24px;
}
strong {
font-weight: 700;
}
span {
color: colors.$light-gray;
}
}
&__copyright {
color: colors.$light-gray;
}
@media (max-width: vars.$media-breakpoint-tablet) {
&__nav {
grid-template-columns: 1fr auto;
nav {
grid-column: 1 / -1;
flex-wrap: wrap;
gap: vars.$space-m vars.$space-xl;
padding-bottom: vars.$space-l;
}
}
&__promo,
&__footer-inner {
grid-template-columns: 1fr;
}
}
@media (max-width: vars.$media-breakpoint-mobile) {
&__country {
flex-wrap: wrap;
justify-content: flex-start;
}
&__nav {
padding: vars.$space-l;
gap: vars.$space-l;
}
&__account {
justify-content: flex-end;
flex-wrap: wrap;
gap: vars.$space-s2;
}
&__brand {
font-size: 20px;
}
&__brand-mark {
width: 38px;
height: 38px;
}
&__content {
padding: 0 vars.$space-m;
}
}
}
+62 -206
View File
@@ -1,214 +1,70 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "./AeroflotShell.scss"; import { createElement } from "react";
type ShellLanguage = "ru" | "en";
interface AeroflotShellProps { interface AeroflotShellProps {
children: ReactNode; children: ReactNode;
language: string;
} }
const TEXT = { function AflComponent(props: {
ru: { className?: string;
country: "Ваша страна - Россия?", component: string;
yes: "Да", children?: ReactNode;
change: "Изменить", }): JSX.Element {
skip: "Перейти к основному содержимому", return createElement(
nav: [ "afl-component",
"Купить билет", {
"Сервисы и услуги", class: props.className,
"Спецпредложения", "data-component": props.component,
"Аэрофлот Бонус", },
"Информация", props.children,
"Для Бизнеса", );
], }
auth: "Авторизация",
account: "Личный кабинет", function AflItem(props: { item: string; children: ReactNode }): JSX.Element {
lang: "RU", return createElement(
express: "Аэроэкспресс", "afl-item",
expressText: "Быстрый способ добраться до аэропортов Москвы или в город", {
order: "Заказать", "data-item": props.item,
ad: "Реклама", },
test: "Тестовая версия", props.children,
branch: "\"develop\"", );
contacts: "Контакты", }
mobile: "555",
mobileText: "МТС, Билайн, Мегафон, Т2, Т-Мобайл (c мобильного бесплатно)", /**
russiaPhone: "8 (800) 444-55-55", * Standalone page chrome mirrors Angular's `ClientApp/src/index.html`:
russiaPhoneText: "бесплатно по России", * external Aeroflot frontend loader hydrates these `afl-component`
moscowPhone: "+7 (495) 223-55-55", * placeholders, while React owns only the flights content between them.
moscowPhoneText: */
"бесплатно для Москвы, для международных звонков в соответствии с тарифами вашего оператора связи", export function AeroflotShell({ children }: AeroflotShellProps): JSX.Element {
feedback: "Обратная связь", return (
company: "Компания", <>
companyLinks: [ <div
"О компании", className="wrapper-header p-print-none afl-component-header-placeholder"
"Контакты", data-testid="standalone-header"
"Работа в Аэрофлоте", >
"Политика конфиденциальности", <AflComponent className="header" component="Header" />
"Противодействие коррупции", </div>
"Карта сайта",
], <div
partners: "Партнерам", className="banner--top p-print-none afl-component--banners"
partnerLinks: ["Агентам", "Грузовые перевозки", "Группа Аэрофлот", "Акционерам и инвесторам"], style={{ display: "none" }}
copyright: "© Авиакомпания «Аэрофлот» 2008-2026", >
app: "Приложение «Аэрофлот»", <AflComponent component="BannersOffers">
appText: "для вашего мобильного устройства", <AflItem item="positionId">383</AflItem>
}, </AflComponent>
en: { </div>
country: "Your country is Russia?",
yes: "Yes", {children}
change: "Change",
skip: "Skip to main content", <div className="banner--bottom p-print-none afl-component-footer-placeholder">
nav: [ <div className="banner--bottom__content">
"Book a flight", <AflComponent component="BannersOffers">
"Services", <AflItem item="positionId">384</AflItem>
"Special offers", </AflComponent>
"Aeroflot Bonus", </div>
"Information", </div>
"Business",
], <AflComponent className="footer p-print-none" component="Footer" />
auth: "Sign in", </>
account: "Personal account",
lang: "EN",
express: "Aeroexpress",
expressText: "Fast way to get to Moscow airports or the city",
order: "Order",
ad: "Advertisement",
test: "Test version",
branch: "\"develop\"",
contacts: "Contacts",
mobile: "555",
mobileText: "free from mobile phones in Russia",
russiaPhone: "8 (800) 444-55-55",
russiaPhoneText: "free within Russia",
moscowPhone: "+7 (495) 223-55-55",
moscowPhoneText: "for Moscow and international calls according to your operator rates",
feedback: "Feedback",
company: "Company",
companyLinks: [
"About Aeroflot",
"Contacts",
"Careers",
"Privacy policy",
"Anti-corruption",
"Site map",
],
partners: "Partners",
partnerLinks: ["Agents", "Cargo", "Aeroflot Group", "Shareholders and investors"],
copyright: "© Aeroflot 2008-2026",
app: "Aeroflot app",
appText: "for your mobile device",
},
} as const;
function toShellLanguage(language: string): ShellLanguage {
return language.toLowerCase().startsWith("en") ? "en" : "ru";
}
export function AeroflotShell({ children, language }: AeroflotShellProps): JSX.Element {
const text = TEXT[toShellLanguage(language)];
return (
<div className="aeroflot-shell">
<header className="aeroflot-shell__header" data-testid="standalone-header">
<section className="aeroflot-shell__country" aria-label={text.country}>
<span>{text.country}</span>
<button type="button">{text.yes}</button>
<button type="button" className="aeroflot-shell__link-button">
{text.change}
</button>
</section>
<a className="aeroflot-shell__skip" href="#main-content">
{text.skip}
</a>
<div className="aeroflot-shell__nav">
<a className="aeroflot-shell__brand" href={`/${text.lang.toLowerCase()}/onlineboard`}>
<span className="aeroflot-shell__brand-mark" aria-hidden="true" />
<span>Аэрофлот</span>
</a>
<nav aria-label="Aeroflot">
{text.nav.map((item) => (
<a href="/" key={item}>
{item}
</a>
))}
</nav>
<div className="aeroflot-shell__account">
<a href="/">{text.auth}</a>
<a href="/">{text.account}</a>
<span>{text.lang}</span>
</div>
</div>
<section className="aeroflot-shell__promo" aria-label={text.express}>
<div>
<strong>{text.express}</strong>
<span>{text.expressText}</span>
</div>
<a href="/">{text.order}</a>
<small>{text.ad}</small>
</section>
<div className="aeroflot-shell__test-version">{text.test}</div>
</header>
<main className="aeroflot-shell__content" id="main-content">
{children}
</main>
<footer className="aeroflot-shell__footer" data-testid="standalone-footer">
<div className="aeroflot-shell__footer-inner">
<div className="aeroflot-shell__branch">{text.branch}</div>
<section className="aeroflot-shell__contacts" aria-label={text.contacts}>
<h2>{text.contacts}</h2>
<dl>
<div>
<dt>{text.mobile}</dt>
<dd>{text.mobileText}</dd>
</div>
<div>
<dt>{text.russiaPhone}</dt>
<dd>{text.russiaPhoneText}</dd>
</div>
<div>
<dt>{text.moscowPhone}</dt>
<dd>{text.moscowPhoneText}</dd>
</div>
</dl>
<a href="/">{text.feedback}</a>
</section>
<section className="aeroflot-shell__footer-column">
<h2>{text.company}</h2>
{text.companyLinks.map((item) => (
<a href="/" key={item}>
{item}
</a>
))}
</section>
<section className="aeroflot-shell__footer-column">
<h2>{text.partners}</h2>
{text.partnerLinks.map((item) => (
<a href="/" key={item}>
{item}
</a>
))}
</section>
<section className="aeroflot-shell__app">
<h2>Аэрофлот</h2>
<strong>{text.app}</strong>
<span>{text.appText}</span>
</section>
<p className="aeroflot-shell__copyright">{text.copyright}</p>
</div>
</footer>
</div>
); );
} }
+18 -25
View File
@@ -1,7 +1,7 @@
import { test, expect } from "./fixtures/console-gate"; import { test, expect } from "./fixtures/console-gate";
test.describe("TIRREDESIGN-30 — standalone header and footer", () => { test.describe("TIRREDESIGN-30 — standalone header and footer", () => {
test("Russian standalone pages render Aeroflot shell around React content", async ({ test("Russian standalone pages render Angular-style Aeroflot shell placeholders", async ({
page, page,
consoleMessages, consoleMessages,
}) => { }) => {
@@ -9,38 +9,31 @@ test.describe("TIRREDESIGN-30 — standalone header and footer", () => {
await page.waitForLoadState("domcontentloaded"); await page.waitForLoadState("domcontentloaded");
const header = page.getByTestId("standalone-header"); const header = page.getByTestId("standalone-header");
await expect(header).toBeVisible(); await expect(header.locator('afl-component.header[data-component="Header"]')).toHaveCount(1);
await expect(header).toContainText("Купить билет");
await expect(header).toContainText("Аэроэкспресс");
await expect(header).toContainText("Тестовая версия");
await expect(page.locator("#main-content h1")).toHaveText("Страница проверки"); await expect(page.locator("h1")).toHaveText("Страница проверки");
const footer = page.getByTestId("standalone-footer"); await expect(page.locator('afl-component.footer[data-component="Footer"]')).toHaveCount(1);
await expect(footer).toBeVisible(); await expect(
await expect(footer).toContainText("Контакты"); page.locator('.banner--top afl-component[data-component="BannersOffers"] afl-item[data-item="positionId"]'),
await expect(footer).toContainText("8 (800) 444-55-55"); ).toHaveText("383");
await expect(footer).toContainText("© Авиакомпания «Аэрофлот» 2008-2026"); await expect(
page.locator('.banner--bottom afl-component[data-component="BannersOffers"] afl-item[data-item="positionId"]'),
).toHaveText("384");
}); });
test("English standalone pages localize shell chrome", async ({ test("local dev uses Angular-style placeholder loader mode", async ({
page, page,
consoleMessages, consoleMessages,
}) => { }) => {
await page.goto("/en/smoke"); await page.goto("/ru/smoke");
await page.waitForLoadState("domcontentloaded"); await page.waitForLoadState("domcontentloaded");
const header = page.getByTestId("standalone-header"); await expect(
await expect(header).toBeVisible(); page.locator('meta[name="aeroflot-shell-loader"][content="placeholder"]'),
await expect(header).toContainText("Book a flight"); ).toHaveCount(1);
await expect(header).toContainText("Aeroexpress"); await expect(
await expect(header).toContainText("Test version"); page.locator('script[src="https://www.aeroflot.ru/frontend/static/js/afl-frontend-loader.bundle.js"]'),
).toHaveCount(0);
await expect(page.locator("#main-content h1")).toHaveText("Smoke test page");
const footer = page.getByTestId("standalone-footer");
await expect(footer).toBeVisible();
await expect(footer).toContainText("Contacts");
await expect(footer).toContainText("© Aeroflot 2008-2026");
}); });
}); });