From ee08795811365195b9e8197ad72eca47c90109bf Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 20 May 2026 16:01:30 +0300 Subject: [PATCH] Restore standalone shell chrome --- src/routes/layout.tsx | 11 +- src/styles/_layout.scss | 2 +- src/ui/shell/AeroflotShell.scss | 307 +++++++++++++++++++++++++++++ src/ui/shell/AeroflotShell.tsx | 214 ++++++++++++++++++++ src/ui/shell/index.ts | 1 + tests/e2e/smoke.spec.ts | 4 +- tests/e2e/standalone-shell.spec.ts | 46 +++++ 7 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 src/ui/shell/AeroflotShell.scss create mode 100644 src/ui/shell/AeroflotShell.tsx create mode 100644 src/ui/shell/index.ts create mode 100644 tests/e2e/standalone-shell.spec.ts 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 ( +
+
+
+ {text.country} + + +
+ + + {text.skip} + + +
+ + + +
+ {text.auth} + {text.account} + {text.lang} +
+
+ +
+
+ {text.express} + {text.expressText} +
+ {text.order} + {text.ad} +
+ +
{text.test}
+
+ +
+ {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"); + }); +});