diff --git a/src/routes/layout.tsx b/src/routes/layout.tsx
index f14b5979..49b38e6b 100644
--- a/src/routes/layout.tsx
+++ b/src/routes/layout.tsx
@@ -1,11 +1,13 @@
import { useMemo } from "react";
-import { Outlet } from "@modern-js/runtime/router";
+import { Outlet, useLocation } from "@modern-js/runtime/router";
import { ErrorBoundary } from "@/ui/errors/ErrorBoundary";
+import { AeroflotShell } from "@/ui/shell";
import { LoggerProvider } from "@/observability/logger/provider";
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";
@@ -19,7 +21,10 @@ 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(
() => {
@@ -37,7 +42,9 @@ export default function RootLayout(): JSX.Element {
-
+
+
+
diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss
index 65aefa24..07374f21 100644
--- a/src/styles/_layout.scss
+++ b/src/styles/_layout.scss
@@ -67,7 +67,7 @@ body {
#root {
display: block;
flex: 1 0 auto;
- padding: 0 24px;
+ padding: 0;
}
.footer {
diff --git a/src/ui/shell/AeroflotShell.scss b/src/ui/shell/AeroflotShell.scss
new file mode 100644
index 00000000..ee6bec3e
--- /dev/null
+++ b/src/ui/shell/AeroflotShell.scss
@@ -0,0 +1,307 @@
+@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
new file mode 100644
index 00000000..dc15beb3
--- /dev/null
+++ b/src/ui/shell/AeroflotShell.tsx
@@ -0,0 +1,214 @@
+import type { ReactNode } from "react";
+import "./AeroflotShell.scss";
+
+type ShellLanguage = "ru" | "en";
+
+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 (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/src/ui/shell/index.ts b/src/ui/shell/index.ts
new file mode 100644
index 00000000..d9957c02
--- /dev/null
+++ b/src/ui/shell/index.ts
@@ -0,0 +1 @@
+export { AeroflotShell } from "./AeroflotShell";
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
index 96ad98f6..064addd6 100644
--- a/tests/e2e/smoke.spec.ts
+++ b/tests/e2e/smoke.spec.ts
@@ -36,7 +36,7 @@ test.describe("Smoke tests", () => {
await expect(heading).toHaveText("Страница проверки");
// Locale should be displayed
- await expect(page.locator("text=ru")).toBeVisible();
+ await expect(page.getByText("ru-ru", { exact: true })).toBeVisible();
});
test("/en/smoke renders with English text", async ({ page, consoleMessages }) => {
@@ -47,7 +47,7 @@ test.describe("Smoke tests", () => {
await expect(heading).toBeVisible({ timeout: 10000 });
await expect(heading).toHaveText("Smoke test page");
- await expect(page.locator("text=en")).toBeVisible();
+ await expect(page.getByText("en-en", { exact: true })).toBeVisible();
});
test("/xx/smoke shows 404 or unknown locale message", async ({ page, consoleMessages }) => {
diff --git a/tests/e2e/standalone-shell.spec.ts b/tests/e2e/standalone-shell.spec.ts
new file mode 100644
index 00000000..498b0474
--- /dev/null
+++ b/tests/e2e/standalone-shell.spec.ts
@@ -0,0 +1,46 @@
+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 ({
+ page,
+ consoleMessages,
+ }) => {
+ await page.goto("/ru/smoke");
+ 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(page.locator("#main-content 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");
+ });
+
+ test("English standalone pages localize shell chrome", async ({
+ page,
+ consoleMessages,
+ }) => {
+ await page.goto("/en/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");
+ });
+});