diff --git a/modern.config.ts b/modern.config.ts index 63ea14b2..fb5b05e0 100644 --- a/modern.config.ts +++ b/modern.config.ts @@ -44,6 +44,52 @@ const publicEnvB64 = Buffer.from( const PUBLIC_ENV_SCRIPT = `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({ plugins, source: { @@ -60,6 +106,7 @@ export default defineConfig({ append: false, children: PUBLIC_ENV_SCRIPT, }, + ...standaloneShellTags, { tag: "link", attrs: { diff --git a/src/routes/layout.tsx b/src/routes/layout.tsx index 49b38e6b..9331c775 100644 --- a/src/routes/layout.tsx +++ b/src/routes/layout.tsx @@ -1,5 +1,5 @@ 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 { AeroflotShell } from "@/ui/shell"; import { LoggerProvider } from "@/observability/logger/provider"; @@ -7,7 +7,6 @@ import { createRootLogger } from "@/observability/logger/root"; import { ApiClientProvider } from "@/shared/api/provider"; import { ApiClient } from "@/shared/api/client"; import { getEnv } from "@/env/index"; -import { resolveLocaleFromPath, localeToLanguage } from "@/i18n/resolver"; // Global styles import "@/styles/index.scss"; @@ -21,10 +20,7 @@ import "leaflet/dist/leaflet.css"; * nested routes. */ export default function RootLayout(): JSX.Element { - const location = useLocation(); const logger = useMemo(() => createRootLogger(), []); - const locale = resolveLocaleFromPath(location.pathname); - const language = locale ? localeToLanguage(locale) : "ru"; const apiClient = useMemo( () => { @@ -42,7 +38,7 @@ export default function RootLayout(): JSX.Element { - + diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index 07374f21..ef625ee4 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -78,6 +78,13 @@ body { } } +.afl-component-header-placeholder, +.afl-component-footer-placeholder { + flex-shrink: 0; + width: 100%; + min-height: 10px; +} + .page-title { width: auto; display: inline-block; diff --git a/src/ui/shell/AeroflotShell.scss b/src/ui/shell/AeroflotShell.scss deleted file mode 100644 index ee6bec3e..00000000 --- a/src/ui/shell/AeroflotShell.scss +++ /dev/null @@ -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; - } - } -} diff --git a/src/ui/shell/AeroflotShell.tsx b/src/ui/shell/AeroflotShell.tsx index dc15beb3..87cd9305 100644 --- a/src/ui/shell/AeroflotShell.tsx +++ b/src/ui/shell/AeroflotShell.tsx @@ -1,214 +1,70 @@ import type { ReactNode } from "react"; -import "./AeroflotShell.scss"; - -type ShellLanguage = "ru" | "en"; +import { createElement } from "react"; interface AeroflotShellProps { children: ReactNode; - language: string; } -const TEXT = { - ru: { - country: "Ваша страна - Россия?", - yes: "Да", - change: "Изменить", - skip: "Перейти к основному содержимому", - nav: [ - "Купить билет", - "Сервисы и услуги", - "Спецпредложения", - "Аэрофлот Бонус", - "Информация", - "Для Бизнеса", - ], - auth: "Авторизация", - account: "Личный кабинет", - lang: "RU", - express: "Аэроэкспресс", - expressText: "Быстрый способ добраться до аэропортов Москвы или в город", - order: "Заказать", - ad: "Реклама", - test: "Тестовая версия", - branch: "\"develop\"", - contacts: "Контакты", - mobile: "555", - mobileText: "МТС, Билайн, Мегафон, Т2, Т-Мобайл (c мобильного бесплатно)", - russiaPhone: "8 (800) 444-55-55", - russiaPhoneText: "бесплатно по России", - moscowPhone: "+7 (495) 223-55-55", - moscowPhoneText: - "бесплатно для Москвы, для международных звонков в соответствии с тарифами вашего оператора связи", - feedback: "Обратная связь", - company: "Компания", - companyLinks: [ - "О компании", - "Контакты", - "Работа в Аэрофлоте", - "Политика конфиденциальности", - "Противодействие коррупции", - "Карта сайта", - ], - partners: "Партнерам", - partnerLinks: ["Агентам", "Грузовые перевозки", "Группа Аэрофлот", "Акционерам и инвесторам"], - copyright: "© Авиакомпания «Аэрофлот» 2008-2026", - app: "Приложение «Аэрофлот»", - appText: "для вашего мобильного устройства", - }, - en: { - country: "Your country is Russia?", - yes: "Yes", - change: "Change", - skip: "Skip to main content", - nav: [ - "Book a flight", - "Services", - "Special offers", - "Aeroflot Bonus", - "Information", - "Business", - ], - 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 ( -
-
-
- {text.country} - - -
- - - {text.skip} - - -
- - - -
- {text.auth} - {text.account} - {text.lang} -
-
- -
-
- {text.express} - {text.expressText} -
- {text.order} - {text.ad} -
- -
{text.test}
-
- -
- {children} -
- -
-
-
{text.branch}
- -
-

{text.contacts}

-
-
-
{text.mobile}
-
{text.mobileText}
-
-
-
{text.russiaPhone}
-
{text.russiaPhoneText}
-
-
-
{text.moscowPhone}
-
{text.moscowPhoneText}
-
-
- {text.feedback} -
- -
-

{text.company}

- {text.companyLinks.map((item) => ( - - {item} - - ))} -
- -
-

{text.partners}

- {text.partnerLinks.map((item) => ( - - {item} - - ))} -
- -
-

Аэрофлот

- {text.app} - {text.appText} -
- -

{text.copyright}

-
-
-
+function AflComponent(props: { + className?: string; + component: string; + children?: ReactNode; +}): JSX.Element { + return createElement( + "afl-component", + { + class: props.className, + "data-component": props.component, + }, + props.children, + ); +} + +function AflItem(props: { item: string; children: ReactNode }): JSX.Element { + return createElement( + "afl-item", + { + "data-item": props.item, + }, + props.children, + ); +} + +/** + * Standalone page chrome mirrors Angular's `ClientApp/src/index.html`: + * external Aeroflot frontend loader hydrates these `afl-component` + * placeholders, while React owns only the flights content between them. + */ +export function AeroflotShell({ children }: AeroflotShellProps): JSX.Element { + return ( + <> +
+ +
+ +
+ + 383 + +
+ + {children} + +
+
+ + 384 + +
+
+ + + ); } diff --git a/tests/e2e/standalone-shell.spec.ts b/tests/e2e/standalone-shell.spec.ts index 498b0474..435beb94 100644 --- a/tests/e2e/standalone-shell.spec.ts +++ b/tests/e2e/standalone-shell.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "./fixtures/console-gate"; 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, consoleMessages, }) => { @@ -9,38 +9,31 @@ test.describe("TIRREDESIGN-30 — standalone header and footer", () => { await page.waitForLoadState("domcontentloaded"); const header = page.getByTestId("standalone-header"); - await expect(header).toBeVisible(); - await expect(header).toContainText("Купить билет"); - await expect(header).toContainText("Аэроэкспресс"); - await expect(header).toContainText("Тестовая версия"); + await expect(header.locator('afl-component.header[data-component="Header"]')).toHaveCount(1); - await expect(page.locator("#main-content h1")).toHaveText("Страница проверки"); + await expect(page.locator("h1")).toHaveText("Страница проверки"); - const footer = page.getByTestId("standalone-footer"); - await expect(footer).toBeVisible(); - await expect(footer).toContainText("Контакты"); - await expect(footer).toContainText("8 (800) 444-55-55"); - await expect(footer).toContainText("© Авиакомпания «Аэрофлот» 2008-2026"); + await expect(page.locator('afl-component.footer[data-component="Footer"]')).toHaveCount(1); + await expect( + page.locator('.banner--top afl-component[data-component="BannersOffers"] afl-item[data-item="positionId"]'), + ).toHaveText("383"); + 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, consoleMessages, }) => { - await page.goto("/en/smoke"); + await page.goto("/ru/smoke"); await page.waitForLoadState("domcontentloaded"); - const header = page.getByTestId("standalone-header"); - await expect(header).toBeVisible(); - await expect(header).toContainText("Book a flight"); - await expect(header).toContainText("Aeroexpress"); - await expect(header).toContainText("Test version"); - - 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"); + await expect( + page.locator('meta[name="aeroflot-shell-loader"][content="placeholder"]'), + ).toHaveCount(1); + await expect( + page.locator('script[src="https://www.aeroflot.ru/frontend/static/js/afl-frontend-loader.bundle.js"]'), + ).toHaveCount(0); }); });