Initial commit: Aeroflot Flights Web Angular 12 application

This commit is contained in:
2026-04-03 10:10:52 +03:00
commit 2342f2e66e
1311 changed files with 128350 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "3.1.8",
"commands": [
"dotnet-ef"
]
}
}
}
+30
View File
@@ -0,0 +1,30 @@
# .NET
bin/
obj/
*.user
*.suo
.vs/
publish/
# Node / Angular
node_modules/
dist/
.angular/
ClientApp/dist/
ClientApp/coverage/
ClientApp/.storybook-out/
# Logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# Env / secrets
*.env
appsettings.Development.json
# wwwroot build output (keep static assets, ignore generated JS)
wwwroot/dist/
+287
View File
@@ -0,0 +1,287 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<SpaRoot>ClientApp\</SpaRoot>
<UserSecretsId>3fa67cb5-d27b-45a4-9d50-39219ff7aa70</UserSecretsId>
<EnableMSDeployAppOffline>true</EnableMSDeployAppOffline>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
<ContainerDevelopmentMode>Regular</ContainerDevelopmentMode>
<EnableSourceControlManagerQueries>true</EnableSourceControlManagerQueries>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="..\Common\stylecop.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</AdditionalFiles>
<AdditionalFiles Include="..\.globalconfig">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</AdditionalFiles>
</ItemGroup>
<ItemGroup>
<Compile Remove="ClientApp\dist\**" />
<Compile Remove="ClientApp\src\app\modules\pages\board\components\**" />
<Compile Remove="ClientApp\src\app\shared\components\**" />
<Compile Remove="ClientApp\src\app\shared\constants\**" />
<Content Remove="$(SpaRoot)**" />
<Content Remove="ClientApp\dist\**" />
<Content Remove="ClientApp\src\app\modules\pages\board\components\**" />
<Content Remove="ClientApp\src\app\shared\components\**" />
<Content Remove="ClientApp\src\app\shared\constants\**" />
<EmbeddedResource Remove="ClientApp\dist\**" />
<EmbeddedResource Remove="ClientApp\src\app\modules\pages\board\components\**" />
<EmbeddedResource Remove="ClientApp\src\app\shared\components\**" />
<EmbeddedResource Remove="ClientApp\src\app\shared\constants\**" />
<None Remove="$(SpaRoot)**" />
<None Remove="ClientApp\dist\**" />
<None Remove="ClientApp\src\app\modules\pages\board\components\**" />
<None Remove="ClientApp\src\app\shared\components\**" />
<None Remove="ClientApp\src\app\shared\constants\**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.300" />
<PackageReference Include="AWSSDK.S3" Version="3.7.305.29" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="Iuliia" Version="2.0.4" />
<PackageReference Include="IdentityModel.AspNetCore" Version="3.0.0" />
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="5.1.0" />
<PackageReference Include="IdentityModel.AspNetCore.ScopeValidation" Version="1.1.1" />
<PackageReference Include="MessagePack" Version="2.5.187" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.16" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.16" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.9.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="Microsoft.VisualStudio.Web.BrowserLink" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.19" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.15" />
<PackageReference Include="Microsoft.Exchange.WebServices.NETStandard" Version="1.1.1" />
<PackageReference Include="NuGet.Packaging" Version="6.14.0" />
<PackageReference Include="RazorEngineCore" Version="2023.11.1" />
<PackageReference Include="MiaPlaza.ExpressionUtils" Version="1.2.0" />
<PackageReference Include="Miyconst.Cyriller" Version="0.1.1" />
<PackageReference Include="prometheus-net" Version="6.0.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="6.0.0" />
<PackageReference Include="Serilog.Enrichers.ClientInfo" Version="2.1.2" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
<PackageReference Include="Serilog.Sinks.AmazonS3" Version="1.2.3" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.OpenTelemetry" Version="4.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="6.1.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.1" />
<PackageReference Include="System.Formats.Asn1" Version="8.0.2" />
<PackageReference Include="System.IO.Packaging" Version="9.0.5" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.2" />
<PackageReference Include="System.Text.Encodings.Web" Version="9.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="Scrutor" Version="6.1.0" />
</ItemGroup>
<ItemGroup>
<Folder Remove="ClientApp\dist" />
<Folder Include="wwwroot\css\" />
<Folder Include="wwwroot\img\" />
<None Remove="App_Data\key-3822b1fb-84eb-4772-a64f-401ce0307c0b.xml" />
<None Remove="ClientApp\src\app\modules\components\index.ts" />
<None Remove="ClientApp\src\app\modules\flights.module.ts" />
<None Remove="ClientApp\src\app\modules\schedule.module.ts" />
<None Remove="ClientApp\src\app\shared\enumerators\dispatch-mode.enum.ts" />
<None Remove="ClientApp\src\app\shared\enumerators\flight-loaded-state.enum.ts" />
<None Remove="ClientApp\src\app\shared\enumerators\flight-status.enum.ts" />
<None Remove="ClientApp\src\app\shared\enumerators\index.ts" />
<None Remove="ClientApp\src\app\shared\enumerators\language.enum.ts" />
<None Remove="ClientApp\src\app\shared\enumerators\operation-status.enum.ts" />
<None Remove="ClientApp\src\app\shared\enumerators\time-type.enum.ts" />
<None Remove="ClientApp\src\app\shared\models\aircraft.model.ts" />
<None Remove="ClientApp\src\app\shared\models\airport-info-modification.model.ts" />
<None Remove="ClientApp\src\app\shared\models\airport-info.model.ts" />
<None Remove="ClientApp\src\app\shared\models\arrival-station.model.ts" />
<None Remove="ClientApp\src\app\shared\models\arrival-times.model.ts" />
<None Remove="ClientApp\src\app\shared\models\booking-classes.model.ts" />
<None Remove="ClientApp\src\app\shared\models\cross-leg-index.model.ts" />
<None Remove="ClientApp\src\app\shared\models\day-change.model.ts" />
<None Remove="ClientApp\src\app\shared\models\days-of-week.model.ts" />
<None Remove="ClientApp\src\app\shared\models\departure-station.model.ts" />
<None Remove="ClientApp\src\app\shared\models\departure-times.model.ts" />
<None Remove="ClientApp\src\app\shared\models\equipment.model.ts" />
<None Remove="ClientApp\src\app\shared\models\flight-id.model.ts" />
<None Remove="ClientApp\src\app\shared\models\flight-time.model.ts" />
<None Remove="ClientApp\src\app\shared\models\flight-update-signal.model.ts" />
<None Remove="ClientApp\src\app\shared\models\flight.model.ts" />
<None Remove="ClientApp\src\app\shared\models\index.ts" />
<None Remove="ClientApp\src\app\shared\models\onboard-service.model.ts" />
<None Remove="ClientApp\src\app\shared\models\operating-by.model.ts" />
<None Remove="ClientApp\src\app\shared\models\operator.model.ts" />
<None Remove="ClientApp\src\app\shared\models\passenger-info.model.ts" />
<None Remove="ClientApp\src\app\shared\models\passengers.model.ts" />
<None Remove="ClientApp\src\app\shared\models\period-flight-time.model.ts" />
<None Remove="ClientApp\src\app\shared\models\requests\aircraft-type.model.ts" />
<None Remove="ClientApp\src\app\shared\models\requests\search-flight-request.ts" />
<None Remove="ClientApp\src\app\shared\models\schedule-item.model.ts" />
<None Remove="ClientApp\src\app\shared\models\service-class.model.ts" />
<None Remove="ClientApp\src\app\shared\models\service-type.model.ts" />
<None Remove="ClientApp\src\app\shared\models\traffic-restrictions.model.ts" />
<None Remove="ClientApp\src\app\shared\models\type-flight-time.model.ts" />
<None Remove="ClientApp\src\app\shared\services\flights-updater.service.ts" />
<None Remove="ClientApp\src\app\shared\services\http-cancel.service.ts" />
<None Remove="ClientApp\src\app\shared\services\localization.service.ts" />
<None Remove="ClientApp\src\app\shared\services\version.service.ts" />
<Content Include="App_Data\key-3822b1fb-84eb-4772-a64f-401ce0307c0b.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="ClientApp\src\app\test\schedule.json" />
<Folder Include="ClientApp\src\app\modules\pages\schedule\components\" />
<Folder Include="ClientApp\src\assets\img\" />
<Folder Include="ClientApp\src\styles\components\shared\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Aeroflot.Flights.WebApi\Aeroflot.Flights.WebApi.csproj" />
<ProjectReference Include="..\Aeroflot.Flights.DataSpace\Aeroflot.Flights.DataSpace.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Aeroflot.Common.Core">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Common.Core.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Common2">
<HintPath>..\Common\Aeroflot.Common2.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Common2.DataSpace">
<HintPath>..\Common\Aeroflot.Common2.DataSpace.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Common2.Observability.AspNetCore">
<HintPath>..\Common\Aeroflot.Common2.Observability.AspNetCore.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Platform.ApiWrapper">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Platform.ApiWrapper.dll</HintPath>
</Reference>
<!--<Reference Include="Aeroflot.Platform.Common.DataContracts">
<HintPath>..\Common\Aeroflot.Platform.Common.DataContracts.dll</HintPath>
</Reference>-->
<Reference Include="Aeroflot.Platform.Common.DataContracts">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Platform.Common.DataContracts.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Platform.Common.Observability">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Platform.Common.Observability.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Common2.ApiWrapper">
<HintPath>..\Common\Aeroflot.Common2.ApiWrapper.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Platform.Flights.Entities">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Platform.Flights.Entities.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Platform.Handlers.Interfaces">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Platform.Handlers.Interfaces.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Platform.Readers.Interfaces">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Platform.Readers.Interfaces.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Security.Core">
<HintPath>..\Aeroflot.Platform.Libraries\Aeroflot.Security.Core.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Security2">
<HintPath>..\Common\Aeroflot.Security2.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Security2.DataSpace">
<HintPath>..\Common\Aeroflot.Security2.DataSpace.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Security2.Entities">
<HintPath>..\Common\Aeroflot.Security2.Entities.dll</HintPath>
</Reference>
<Reference Include="Aeroflot.Security2.Entities.DataSpace">
<HintPath>..\Common\Aeroflot.Security2.Entities.DataSpace.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<TypeScriptCompile Include="ClientApp\src\app\modules\components\index.ts" />
<TypeScriptCompile Include="ClientApp\src\app\modules\flights.module.ts" />
<TypeScriptCompile Include="ClientApp\src\app\modules\page-modules\schedule.module.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\enumerators\dispatch-mode.enum.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\enumerators\flight-loaded-state.enum.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\enumerators\flight-status.enum.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\enumerators\index.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\enumerators\language.enum.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\enumerators\operation-status.enum.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\enumerators\time-type.enum.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\aircraft.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\airport-info-modification.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\airport-info.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\arrival-station.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\arrival-times.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\booking-classes.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\cross-leg-index.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\day-change.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\days-of-week.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\departure-station.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\departure-times.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\equipment.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\flight-id.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\flight-time.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\flight-update-signal.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\index.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\onboard-service.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\operating-by.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\operator.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\passenger-info.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\passengers.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\period-flight-time.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\aircraft-type.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\requests\search-flight-request.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\leg.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\flight.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\schedule-item.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\service-class.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\service-type.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\traffic-restrictions.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\models\type-flight-time.model.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\services\flights-updater.service.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\services\http-cancel.service.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\services\localization.service.ts" />
<TypeScriptCompile Include="ClientApp\src\app\shared\services\version.service.ts" />
</ItemGroup>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" Condition="'$(DOTNET_RUNNING_IN_CONTAINER)' != 'true'" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build:prod" Condition="'$(DOTNET_RUNNING_IN_CONTAINER)' != 'true'" />
<!-- Include the newly-built files in the publish output -->
<ItemGroup>
<DistFiles Include="$(SpaRoot)dist\**; $(SpaRoot)dist-server\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>%(DistFiles.Identity)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project>
+129
View File
@@ -0,0 +1,129 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is the Aeroflot Flights Web application — a flight information/booking interface. The current codebase is **Angular 12** (located in `ClientApp/`), and it is being **rewritten to React** using ModernJS with Module Federation 2.0 as a remote micro-frontend component.
## Current Angular App (ClientApp/)
### Dev Commands
```bash
npm start # Dev server on :4200 (proxies /api, /flights → flights.test.aeroflot.ru)
npm run build:prod # Production build
npm run build:dev # Dev build with source maps
npm run build:testing # Testing environment build
npm run test # Karma/Jasmine with coverage → coverage/test/
npm run test:ci # Tests with TeamCity reporter
npm run lint # ESLint
npm run pretty # Prettier (ts + html)
npm run analyze # Webpack bundle analyzer
npm run storybook # Storybook component docs
```
### Path Aliases (tsconfig.json)
| Alias | Resolves To |
|---|---|
| `@app/*` | `src/app/*` |
| `@components/*` | `src/app/components/*` |
| `@shared/*` | `src/app/shared/*` |
| `@modules/*` | `src/app/modules/*` |
| `@features/*` | — (use explicit paths) |
| `@online-board/*` | `src/app/features/online-board/*` |
| `@schedule/*` | `src/app/features/schedule/*` |
| `@toolkit/*` | `src/app/toolkit/*` |
| `@utils/*` | `src/app/utils/*` |
| `@typings/*` | `src/typings/*` |
| `@environment` | `src/environments/environment` |
### Architecture
```
src/app/
├── features/ # Lazy-loaded feature modules
│ ├── online-board/ # Main flight departure/arrival board
│ ├── schedule/ # Schedule search
│ ├── flights-map/ # Map view (feature-flag gated)
│ └── popular-requests/
├── modules/
│ ├── components/ # Reusable display components
│ ├── pages/ # Page-level components (board, details, schedule, errors)
│ └── prime-components-module.ts
├── shared/
│ ├── services/ # ~37 services (API, localization, settings, SEO, etc.)
│ ├── pipes/
│ ├── pipes-legacy/
│ ├── models-legacy/ # ~50 legacy DTOs
│ ├── interceptor/ # AppInterceptor (HTTP)
│ └── shared.module.ts
├── guards/
│ └── feature-flag.guard.ts
└── toolkit/ # Custom UI component library
```
**State management**: No NgRx/Akita — pure RxJS with BehaviorSubjects exposed as Observables from services.
**Real-time**: `@microsoft/signalr` for WebSocket connections (tracker hub URL set per environment).
**Routing**: All language prefixes (`/ru`, `/en`, `/es`, `/fr`, `/it`, `/ja`, `/ko`, `/zh`, `/de`) redirect to `/onlineboard`. Feature modules are lazy-loaded; `flights-map` is guarded by `FeatureFlagGuard`.
**i18n**: `@ngx-translate` with `messageformat` compiler. Translation JSON files in `src/assets/i18n/`. Supports 9 languages.
**UI**: PrimeNG 10 + custom `toolkit/` components.
### Environment Config
Each `src/environments/environment*.ts` exposes:
- `apiRootUrl` / `wsRootUrl` — proxied in dev, real URLs in prod
- `features.flightsMap` — boolean feature flag
- Refresh intervals, calendar date ranges
- Ticket purchase time windows (prod only)
## React Rewrite Requirements
The new component must be a **ModernJS SSR** remote micro-frontend with:
### Stack
- **Framework**: ModernJS (SSR enabled)
- **Bundler**: Webpack 5, Rsbuild, Rspack, or Vite — whichever supports Module Federation 2.0
- **Module Federation**: Must expose `mf-manifest.json` at `https://<domain>/mf-manifest.json`
- **React**: 18+ with Concurrent Mode, `<Suspense>` support, no side-effects outside `useEffect`, dynamic imports via `React.lazy()`
### Functional Parity (port from Angular)
- **Features to port**: online-board, schedule, flights-map, popular-requests
- **Data source**: REST API (JSON) — same endpoints currently proxied under `/api`
- **Real-time**: SignalR hub integration
- **Maps**: Leaflet (or equivalent)
- **i18n**: 9 languages
- **Multi-theme**: Responsive / "rubber layout" for Web + PWA embedding
### Non-functional Requirements
- SEO: SSR-rendered meta tags, JSON-LD, OpenGraph markup
- Analytics: Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром
- Logging: Structured frontend log collection → customer logging system
- Monitoring: System events → metrics aggregator
- Isolation: Component must not affect or be affected by host application styles/globals
- Availability: 24/7, recovery < 6h after hardware restoration
### Code Style for React Code
- Prettier config from `.prettierrc.json`: single quotes, trailing commas `all`, 4-space indent, semicolons
- ESLint config from `.eslintrc.js`: max line length 80, TypeScript strict
## Markdown Style
Do not wrap or break lines in markdown files. Write each paragraph or list item as a single long line.
## Release & Changelog
This project uses [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). Version is tracked in two places: `pyproject.toml` and `audio_transcribe/__init__.py`.
**Per-commit rule**: When committing a `fix:`, `feat:`, or breaking change, also add a line to the `[Unreleased]` section of `CHANGELOG.md` under the appropriate heading (`### Added`, `### Fixed`, `### Changed`, `### Removed`). This keeps the changelog current while context is fresh.
**Releasing**: Use `/release` to bump version, stamp changelog, commit, tag, and optionally push. The skill auto-detects the bump level from commit prefixes (`fix:` → patch, `feat:` → minor, `BREAKING CHANGE` → major) and lets you override.
## Git Conventions
Do not include `Co-Authored-By` lines in commit messages.
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.html
+19
View File
@@ -0,0 +1,19 @@
// eslint-disable-next-line no-undef
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier', 'plugin:storybook/recommended'],
rules: {
'@typescript-eslint/no-empty-function': 'warn',
'max-len': [
1,
{
code: 80,
ignoreComments: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
},
],
},
};
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 4
}
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
framework: '@storybook/angular',
core: {
builder: '@storybook/builder-webpack5',
},
staticDirs: [{ from: '../src/assets', to: '/assets' }],
};
+17
View File
@@ -0,0 +1,17 @@
import { setCompodocJson } from '@storybook/addon-docs/angular';
import docJson from '../documentation.json';
import { translationsDecorators } from './translations-decorator';
setCompodocJson(docJson);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
docs: { inlineStories: true },
};
export const decorators = [...translationsDecorators];
@@ -0,0 +1,33 @@
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { Component } from '@angular/core';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { componentWrapperDecorator, moduleMetadata } from '@storybook/angular';
import { TranslateHttpLoaderFactory } from '../src/app/shared/factories';
@Component({
selector: 'storybook-translate',
template: `<ng-content></ng-content>`,
})
class StorybookTranslateComponent {
constructor(translateService: TranslateService) {
translateService.setDefaultLang('ru');
translateService.use('ru');
}
}
export const translationsDecorators = [
moduleMetadata({
declarations: [StorybookTranslateComponent],
imports: [
HttpClientModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: TranslateHttpLoaderFactory,
deps: [HttpClient],
},
}),
],
}),
componentWrapperDecorator(StorybookTranslateComponent),
];
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"node"
],
"allowSyntheticDefaultImports": true
},
"exclude": [
"../src/test.ts",
"../src/**/*.spec.ts",
"../projects/**/*.spec.ts"
],
"include": [
"../src/**/*",
"../projects/**/*"
],
"files": [
"./typings.d.ts"
]
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.md' {
const content: string;
export default content;
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"angular.ng-template"
]
}
+144
View File
@@ -0,0 +1,144 @@
{
"version": 1,
"projects": {
"flightsapp": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"customWebpackConfig": {
"path": "./custom-webpack.config.js"
},
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.json",
"aot": true,
"assets": [
"src/favicon.ico",
"src/assets",
"src/googledeb8cf566236306b.html",
"src/yandex_02230d34b29eaf63.html",
"src/yandex_1947cc7ca150f934.html"
],
"styles": [
"src/styles.scss",
"src/fonts.scss",
"node_modules/primeng/resources/primeng.min.css",
"node_modules/primeicons/primeicons.css",
"node_modules/leaflet/dist/leaflet.css"
],
"scripts": [
]
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"vendorChunk": false,
"buildOptimizer": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"testing": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"vendorChunk": false,
"buildOptimizer": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.testing.ts"
}
]
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true,
"serviceWorker": false,
"index": {
"input": "src/index.dev.html",
"output": "index.html"
},
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
}
},
"serve": {
"builder": "@angular-builders/custom-webpack:dev-server",
"options": {
"proxyConfig": "src/proxy.conf.json"
},
"configurations": {
"production": {
"browserTarget": "flightsapp:build:production"
},
"testing": {
"browserTarget": "flightsapp:build:testing"
},
"development": {
"browserTarget": "flightsapp:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"browserTarget": "angular-cli:build",
"port": 6006
}
},
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"options": {
"browserTarget": "angular-cli:build"
}
}
}
}
},
"defaultProject": "flightsapp",
"cli": {
"analytics": false
}
}
+6
View File
@@ -0,0 +1,6 @@
/* eslint-disable no-undef */
module.exports = {
output: {
chunkLoadingGlobal: 'webpackJsonp' + Math.random().toString(),
},
};
+27
View File
@@ -0,0 +1,27 @@
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4200",
"screenshotOnRunFailure": false,
"video": false,
"viewportHeight": 768,
"viewportWidth": 1366,
"chromeWebSecurity": false,
"env": {
"browserPermissions": {
"notifications": "allow",
"geolocation": "block",
"camera": "block",
"microphone": "block",
"images": "allow",
"javascript": "allow",
"popups": "ask",
"plugins": "ask",
"cookies": "allow"
}
}
}
@@ -0,0 +1,63 @@
import * as moment from 'moment';
describe('Онлайн табло: Прилет', () => {
const arrivalCity = {
name: 'Анапа',
code: 'AAQ',
latitude: 55.7558,
longitude: 37.62,
};
const today = moment().format('DD.MM.YYYY');
const expectedUrlDateTime = `${moment().format('DDMMYYYY')}-0000-2400`;
beforeEach(() => {
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
const assertSearchResults = () => {
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
};
it(`Должен искать рейсы (${arrivalCity.name} - ручной ввод), открывать корректный URL и детали рейса`, () => {
cy.getByTestId('city-autocomplete-input').type(`${arrivalCity.name}`).type('{enter}');
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('city-code').contains(`${arrivalCity.code}`).should('be.visible');
cy.getByTestId('arrival-search-button').click();
cy.url().should('include', `ru-ru/onlineboard/arrival/${arrivalCity.code}/${expectedUrlDateTime}`);
assertSearchResults();
cy.getByTestId('flight-result')
.first()
.getByTestId('flight-carrier-number')
.should('be.visible')
.getByTestId('flight-company-logo')
.should('be.visible');
cy.getByTestId('flight-details-button').click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-company-logo').should('be.visible');
});
it(`Должен искать рейсы (${arrivalCity.name} - выбор из списка)`, () => {
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('autocomplete-popup-button').click();
cy.getByTestId(`city-name-cell-${arrivalCity.name}`).click();
cy.getByTestId('city-code').contains(`${arrivalCity.code}`).should('be.visible');
cy.getByTestId('arrival-search-button').click();
assertSearchResults();
});
});
@@ -0,0 +1,61 @@
import * as moment from 'moment';
describe('Онлайн табло: Вылет', () => {
const departureCity = {
name: 'Анапа',
code: 'AAQ',
latitude: 44.8857008,
longitude: 37.3199191,
};
const today = moment().format('DD.MM.YYYY');
const expectedUrlDateTime = `${moment().format('DDMMYYYY')}-0000-2400`;
beforeEach(() => {
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
cy.getByTestId('departure-filter').click();
});
const assertSearchResults = () => {
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
};
it(`Должен искать рейсы (${departureCity.name} - ручной ввод), открывать корректный URL и детали рейса`, () => {
cy.getByTestId('departure-city-input').type(`${departureCity.name}`);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('city-code').contains(`${departureCity.code}`).should('be.visible');
cy.getByTestId('departure-search-button').click();
cy.url().should('include', `ru-ru/onlineboard/departure/${departureCity.code}/${expectedUrlDateTime}`);
assertSearchResults();
cy.getByTestId('flight-result')
.first()
.getByTestId('flight-carrier-number')
.should('be.visible')
.getByTestId('flight-company-logo')
.should('be.visible');
cy.getByTestId('flight-details-button').click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-company-logo').should('be.visible');
});
it(`Должен искать рейсы (${departureCity.name} - выбор из списка)`, () => {
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('autocomplete-popup-button').click();
cy.getByTestId(`city-name-cell-${departureCity.name}`).click();
cy.getByTestId('city-code').contains(`${departureCity.code}`).should('be.visible');
cy.getByTestId('departure-search-button').click();
assertSearchResults();
});
});
@@ -0,0 +1,58 @@
import * as moment from 'moment';
describe('Онлайн табло: Поиск по маршруту', () => {
const route = {
departureCity: {
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.62,
},
arrivalCity: {
name: 'Сочи',
code: 'AER',
latitude: 43.58,
longitude: 39.72,
},
};
const today = moment().format('DD.MM.YYYY');
beforeEach(() => {
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
cy.getByTestId('route-filter').click();
});
const assertSearchResults = () => {
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
};
it(`Должен искать рейсы ${route.departureCity.name} - ${route.arrivalCity.name} , открывать корректный URL и детали рейса`, () => {
cy.getByTestId('route-departure-city-input').type(`${route.departureCity.name}`);
cy.getByTestId('route-arrival-city-input').type(`${route.arrivalCity.name}`);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('route-arrival-city-input').getByTestId('city-code').contains(`${route.arrivalCity.code}`).should('be.visible');
cy.getByTestId('route-departure-city-input').getByTestId('city-code').contains(`${route.departureCity.code}`).should('be.visible');
cy.getByTestId('route-search-button').click();
assertSearchResults();
cy.getByTestId('flight-result')
.first()
.getByTestId('flight-carrier-number')
.should('be.visible')
.getByTestId('flight-company-logo')
.should('be.visible');
cy.getByTestId('flight-details-button').click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-company-logo').should('be.visible');
});
});
@@ -0,0 +1,44 @@
import * as moment from 'moment';
describe('Онлайн табло: Поиск рейса', () => {
const flightNumber = '0022'; // Москва - Санкт-Петербург
const expectedUrlDateTime = `${moment().format('DDMMYYYY')}`;
const today = moment().format('DD.MM.YYYY');
beforeEach(() => {
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.visit('/');
cy.getByTestId('flight-filter').click();
});
const assertSearchResults = () => {
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
};
it(`Должен искать рейс SU${flightNumber} , открывать корректный URL и детали рейса`, () => {
cy.getByTestId('flight-number-input').type(`${flightNumber}`).type('{enter}');
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('flight-number-search-button').click();
assertSearchResults();
cy.url().should('include', `ru-ru/onlineboard/flight/SU${flightNumber}/${expectedUrlDateTime}`);
cy.getByTestId('flight-result')
.first()
.getByTestId('flight-carrier-number')
.should('be.visible')
.getByTestId('flight-company-logo')
.should('be.visible');
cy.getByTestId('flight-details-button').click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-company-logo').should('be.visible');
});
});
@@ -0,0 +1,59 @@
describe('Расписание: Поиск по маршруту', () => {
const route = {
departureCity: {
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.62,
},
arrivalCity: {
name: 'Сочи',
code: 'AER',
latitude: 43.58,
longitude: 39.72,
},
};
beforeEach(() => {
cy.intercept('GET', '**/api/flights/1/ru/schedule**').as('getFlights');
cy.mockGeolocation(route.departureCity);
cy.visit('/ru-ru/schedule');
});
const assertSearchResults = () => {
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('schedule-search-results').should('be.visible');
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
};
it(`Должен искать рейсы ${route.departureCity.name} - ${route.arrivalCity.name} , открывать корректный URL и детали рейса`, () => {
// cy.getByTestId('schedule-departure-city-input').type(`${route.departureCity.name}`).type('{enter}');
cy.getByTestId('schedule-arrival-city-input').type(`${route.arrivalCity.name}`).type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').getByTestId('city-code').contains(`${route.arrivalCity.code}`).should('be.visible');
cy.getByTestId('schedule-departure-city-input')
.getByTestId('city-code')
.contains(`${route.departureCity.code}`)
.should('be.visible');
cy.getByTestId('schedule-search-button').click();
assertSearchResults();
cy.getByTestId('schedule-search-result')
.first()
.getByTestId('flight-number')
.should('be.visible')
.getByTestId('flight-carrier')
.should('be.visible');
cy.getByTestId('flight-details-button').click({ force: true });
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-company-logo').should('be.visible');
});
});
+10
View File
@@ -0,0 +1,10 @@
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api
import { cypressBrowserPermissionsPlugin } from 'cypress-browser-permissions';
module.exports = (on, config) => {
config = cypressBrowserPermissionsPlugin(on, config);
return config;
};
+73
View File
@@ -0,0 +1,73 @@
/// <reference types="." />
// ***********************************************
// This example namespace declaration will help
// with Intellisense and code completion in your
// IDE or Text Editor.
// ***********************************************
// declare namespace Cypress {
// interface Chainable<Subject = any> {
// customCommand(param: any): typeof customCommand;
// }
// }
//
// function customCommand(param: any): void {
// console.warn(param);
// }
//
// NOTE: You can use it like so:
// Cypress.Commands.add('customCommand', customCommand);
//
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
Cypress.Commands.add('getByTestId', (id: string, timeout = 8000) => {
return cy.get(`[data-testid="${id}"]`, { timeout });
});
Cypress.Commands.add('mockGeolocation', ({ latitude, longitude }) => {
cy.on('window:before:load', (window) => {
const callback = (cb) => {
return cb({ coords: { latitude, longitude } });
};
cy.stub(window.navigator.geolocation, 'getCurrentPosition').callsFake(callback);
cy.stub(window.navigator.geolocation, 'watchPosition').callsFake(callback);
});
});
Cypress.Commands.add('forbidGeolocation', () => {
cy.on('window:before:load', (window) => {
const callback = (_, error) => {
return error({});
};
cy.stub(window.navigator.geolocation, 'getCurrentPosition').callsFake(callback);
cy.stub(window.navigator.geolocation, 'watchPosition').callsFake(callback);
});
});
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable {
getByTestId(id: string, timeout?: number): Chainable;
mockGeolocation({ latitude, longitude }): void;
forbidGeolocation();
}
}
+17
View File
@@ -0,0 +1,17 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// When a command from ./commands is ready to use, import with `import './commands'` syntax
import './commands';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["**/*.ts"],
"compilerOptions": {
"sourceMap": true,
"types": ["cypress"]
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"pipes": [],
"interfaces": [],
"injectables": [],
"guards": [],
"interceptors": [],
"classes": [],
"directives": [],
"components": [],
"modules": [],
"miscellaneous": {
"variables": [
{
"name": "platform",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/main.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "",
"defaultValue": "platformBrowserDynamic()"
}
],
"functions": [],
"typealiases": [],
"enumerations": [],
"groupedVariables": {
"src/main.ts": [
{
"name": "platform",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/main.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "",
"defaultValue": "platformBrowserDynamic()"
}
]
},
"groupedFunctions": {},
"groupedEnumerations": {},
"groupedTypeAliases": {}
},
"routes": [],
"coverage": {
"count": 0,
"status": "low",
"files": [
{
"filePath": "src/main.ts",
"type": "variable",
"linktype": "miscellaneous",
"linksubtype": "variable",
"name": "platform",
"coveragePercent": 0,
"coverageCount": "0/1",
"status": "low"
}
]
}
}
+19
View File
@@ -0,0 +1,19 @@
// direct
- MOW -> PRG (direct)
// multileg - many legs
- MOW -> TKY -> PRG
// connected (direct + direct) - many legs
- MOW -> TKY
TKY -> PRG
// connected (multileg + direct) - many legs
- MOW -> TKY -> RYZ
RYZ -> PRG
// connected (multileg + multileg) - many legs
- MOW -> TKY -> RYZ
RYZ -> PRG -> LSB
+45
View File
@@ -0,0 +1,45 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable no-undef */
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma'),
require('karma-teamcity-reporter'),
require('karma-phantomjs-launcher')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true, // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/test'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }],
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true,
});
};
+28792
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
{
"version": "1.0.0",
"name": "asp.net",
"private": true,
"author": "",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:stats": "ng build --stats-json",
"build:prod": "ng build --configuration production",
"build:testing": "ng build --configuration testing",
"build:dev": "ng build --configuration development",
"watch": "ng build --watch --configuration development",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"test": "ng test --code-coverage",
"test:ci": "ng test --watch=false --reporters=teamcity",
"pretty": "prettier --write \"./**/*.{ts,html}\"",
"analyze": "webpack-bundle-analyzer dist/stats.json",
"docs:json": "compodoc -p ./tsconfig.json -e json -d .",
"storybook": "npm run docs:json && start-storybook -p 6006",
"build-storybook": "npm run docs:json && build-storybook"
},
"dependencies": {
"@angular/animations": "~12.2.13",
"@angular/cdk": "~12.2.13",
"@angular/common": "~12.2.13",
"@angular/compiler": "~12.2.13",
"@angular/core": "~12.2.13",
"@angular/forms": "~12.2.13",
"@angular/platform-browser": "~12.2.13",
"@angular/platform-browser-dynamic": "~12.2.13",
"@angular/router": "~12.2.13",
"@microsoft/signalr": "^9.0.6",
"@ngx-translate/core": "~12.1.2",
"@ngx-translate/http-loader": "^4.0.0",
"applicationinsights-js": "^1.0.21",
"leaflet": "^1.7.1",
"messageformat": "^2.3.0",
"moment": "^2.30.1",
"ngx-moment": "^5.0.0",
"ngx-translate-messageformat-compiler": "^4.9.0",
"primeicons": "^4.0.0",
"primeng": "^10.0.3",
"rxjs": "~6.5.4",
"rxjs-compat": "^6.6.7",
"sass": "^1.27.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-builders/custom-webpack": "~12.0.0",
"@angular-devkit/build-angular": "~12.2.13",
"@angular/cli": "~12.2.13",
"@angular/compiler-cli": "~12.2.13",
"@babel/core": "^7.17.9",
"@compodoc/compodoc": "^1.1.19",
"@storybook/addon-actions": "^6.4.20",
"@storybook/addon-essentials": "^6.4.20",
"@storybook/addon-interactions": "^6.4.20",
"@storybook/addon-links": "^6.4.20",
"@storybook/angular": "^6.4.20",
"@storybook/builder-webpack5": "^6.4.20",
"@storybook/manager-webpack5": "^6.4.20",
"@storybook/testing-library": "0.0.9",
"@types/jasmine": "^3.10.2",
"@types/leaflet": "^1.7.1",
"@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"babel-loader": "^8.2.4",
"eslint": "^8.2.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-storybook": "^0.5.7",
"jasmine-core": "~3.10.1",
"karma": "^6.3.20",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "^2.0.3",
"karma-jasmine": "~4.0.1",
"karma-jasmine-html-reporter": "~1.7.0",
"karma-phantomjs-launcher": "^1.0.4",
"karma-teamcity-reporter": "^1.1.0",
"prettier": "2.4.1",
"start-server-and-test": "~1.14.0",
"timezone-mock": "^1.3.2",
"typescript": "~4.3.5",
"webpack-bundle-analyzer": "^4.5.0"
},
"browserslist": [
"Chrome > 45",
"Safari > 9.1",
"Firefox > 45",
"Opera > 41",
"Android > 4.3",
"iOS > 9",
"Edge > 13"
]
}
+103
View File
@@ -0,0 +1,103 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SettingsResolver } from '@shared/services';
import { FeatureFlagGuard } from './guards/feature-flag.guard'
const routes: Routes = [
{
path: '',
redirectTo: 'onlineboard',
pathMatch: 'full',
},
{
path: 'ru',
redirectTo: 'onlineboard'
},
{
path: 'en',
redirectTo: 'onlineboard'
},
{
path: 'es',
redirectTo: 'onlineboard'
},
{
path: 'fr',
redirectTo: 'onlineboard'
},
{
path: 'it',
redirectTo: 'onlineboard'
},
{
path: 'ja',
redirectTo: 'onlineboard'
},
{
path: 'ko',
redirectTo: 'onlineboard'
},
{
path: 'zh',
redirectTo: 'onlineboard'
},
{
path: 'de',
redirectTo: 'onlineboard'
},
{
path: 'onlineboard',
loadChildren: () =>
import('./features/online-board/online-board.module').then(
(m) => m.OnlineBoardModule,
),
resolve: {
[SettingsResolver.KEY]: SettingsResolver,
},
},
{
path: 'schedule',
loadChildren: () =>
import('./features/schedule/schedule.module').then(
(m) => m.ScheduleModule,
),
resolve: {
[SettingsResolver.KEY]: SettingsResolver,
},
},
{
path: 'flights-map',
data: { featureFlag: 'flightsMap' } as const,
canLoad: [FeatureFlagGuard],
loadChildren: () =>
import('./features/flights-map/flights-map.module').then(
(m) => m.FlightsMapModule,
),
resolve: {
[SettingsResolver.KEY]: SettingsResolver,
},
},
{
path: 'error',
loadChildren: () =>
import('./modules/pages/error-pages/error-pages.module').then(
(m) => m.ErrorPagesModule,
),
},
{
path: '**',
redirectTo: 'error/404',
},
];
@NgModule({
imports: [
RouterModule.forRoot(routes, {
onSameUrlNavigation: 'reload',
enableTracing: false,
initialNavigation: 'enabled',
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}
+3
View File
@@ -0,0 +1,3 @@
<router-outlet></router-outlet>
<app-version></app-version>
<chat-bot></chat-bot>
+14
View File
@@ -0,0 +1,14 @@
import { Component, OnInit } from '@angular/core';
import { UserLocationService } from '@shared/services/user-location/user-location.service';
@Component({
selector: 'flights-root',
templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
constructor(private userLocation: UserLocationService) {}
ngOnInit() {
this.userLocation.locate();
}
}
+116
View File
@@ -0,0 +1,116 @@
import { NgModule, ErrorHandler, LOCALE_ID } from '@angular/core';
import {
HttpClient,
HttpClientModule,
HTTP_INTERCEPTORS,
} from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FormsModule } from '@angular/forms';
import {
TranslateModule,
TranslateLoader,
TranslateService,
} from '@ngx-translate/core';
import { AppRoutingModule } from '@app/app-routing.module';
import { AppComponent } from '@app/app.component';
import { ErrorHandlerProvider } from '@shared/providers';
import {
AppInsightService,
appInsightsProvider,
baseHrefProvider,
LocalizationService,
settingsProvider,
windowProvider,
} from '@shared/services';
import { TranslateHttpLoaderFactory } from '@shared/factories';
import { SharedModule } from '@shared';
import { FlightsModule } from '@modules/flights.module';
import localeRu from '@angular/common/locales/ru';
import localeEn from '@angular/common/locales/en';
import localeEs from '@angular/common/locales/es';
import localeFr from '@angular/common/locales/fr';
import localeIt from '@angular/common/locales/it';
import localeJa from '@angular/common/locales/ja';
import localeKo from '@angular/common/locales/ko';
import localeZh from '@angular/common/locales/zh';
import localeDe from '@angular/common/locales/de';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { LayoutModule } from '@angular/cdk/layout';
import { AppInterceptor } from '@shared/interceptor/app.interceptor';
import { MomentModule } from 'ngx-moment';
registerLocaleData(localeRu);
registerLocaleData(localeZh);
registerLocaleData(localeEn);
registerLocaleData(localeEs);
registerLocaleData(localeFr);
registerLocaleData(localeIt);
registerLocaleData(localeJa);
registerLocaleData(localeKo);
registerLocaleData(localeDe);
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
//BrowserModule.withServerTransition({ appId: 'serverApp' }),
BrowserAnimationsModule,
AppRoutingModule,
FormsModule,
SharedModule,
FlightsModule,
HttpClientModule,
ScrollingModule,
LayoutModule,
MomentModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: TranslateHttpLoaderFactory,
deps: [HttpClient],
},
}),
],
providers: [
baseHrefProvider,
{
provide: LOCALE_ID,
deps: [LocalizationService],
useFactory: (locale: LocalizationService) => locale.Language,
},
{
provide: ErrorHandler,
useClass: ErrorHandlerProvider,
},
{
provide: HTTP_INTERCEPTORS,
useClass: AppInterceptor,
multi: true,
},
appInsightsProvider,
windowProvider,
settingsProvider,
],
bootstrap: [AppComponent],
})
export class AppModule {
constructor(
appInsights: AppInsightService,
translate: TranslateService,
locale: LocalizationService,
) {
appInsights.configure();
locale.init(translate);
}
}
@@ -0,0 +1,121 @@
import { TestBed } from '@angular/core/testing';
import { getDictionariesServiceMock } from '@modules/components/page-filters/services/dictionaries-service.mock';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
import { Language } from '@shared/enumerators';
import { sortStrings } from '@shared/helpers/sorterts';
import { LocalizationService } from '@shared/services';
import { CitiesSearchService } from './cities-search.service';
describe('CitiesSearchService', () => {
let service: CitiesSearchService;
let dictService: DictionariesService;
let localizationService: LocalizationService;
beforeEach(async () => {
dictService = getDictionariesServiceMock();
localizationService = {
Language: Language.ru,
} as any;
TestBed.configureTestingModule({
providers: [
CitiesSearchService,
{ provide: LocalizationService, useValue: localizationService },
{ provide: DictionariesService, useValue: dictService },
],
});
service = TestBed.inject(CitiesSearchService);
await dictService.ready$;
});
it('should sort results', () => {
const input = 'М';
const result = service.findCitiesByNameUnprocessed(input);
const citiesNames = result.map((city) => city.name);
const citiesNamesSorted = citiesNames.sort(sortStrings);
expect(citiesNamesSorted).toEqual(citiesNames);
});
it('should return 10 cities that start with "М"', () => {
const input = 'М';
const result = service.findCitiesByNameUnprocessed(input);
expect(result.length).toBe(11);
for (const item of result) {
expect(item.name[0]).toBe(input);
}
});
it('should return 3 cities. First should start with "Мо", other should include it', () => {
const input = 'Мо';
const inputLowerCase = input.toLowerCase();
const result = service.findCitiesByNameUnprocessed(input);
expect(result.length).toBe(3);
const names = result.map((city) => city.name.toLowerCase());
expect(names[0].indexOf(inputLowerCase)).toBe(0);
expect(names[1].indexOf(inputLowerCase)).not.toBe(0);
expect(names[1].includes(inputLowerCase)).toBeTruthy();
expect(names[2].indexOf(inputLowerCase)).not.toBe(0);
expect(names[2].includes(inputLowerCase)).toBeTruthy();
});
it('should return Moscow for "Мос" input', () => {
const input = 'Мос';
const result = service.findCitiesByNameUnprocessed(input);
expect(result.length).toBe(1);
expect(result[0].name).toBe('Москва');
});
it('should be case insensitive', () => {
function assertResult(input: string) {
const result = service.findCitiesByNameUnprocessed(input);
expect(result.length).toBe(1);
expect(result[0].name).toBe('Москва');
}
assertResult('Мос');
assertResult('МОС');
assertResult('МоС');
});
it('should add airports to found city', () => {
const input = 'Мос';
const result = service.findCitiesByName(input);
expect(result.length).toBe(3);
expect(result[0].name).toBe('Москва');
expect(result[1].name).toBe('Внуково');
expect(result[2].name).toBe('Шереметьево');
});
it('should find city by city code', () => {
const code = 'MOW';
const result = service.findCitiesByCode(code);
expect(result.length).toBe(3);
expect(result[0].name).toBe('Москва');
expect(result[1].name).toBe('Внуково');
expect(result[2].name).toBe('Шереметьево');
});
it('should find city by airport code', () => {
const code = 'VKO';
const result = service.findCitiesByAirportCode(code);
expect(result.length).toBe(3);
expect(result[0].name).toBe('Москва');
expect(result[1].name).toBe('Внуково');
expect(result[2].name).toBe('Шереметьево');
});
});
@@ -0,0 +1,149 @@
import { Injectable } from '@angular/core';
import {
AirportModel,
CityModel,
} from '@modules/components/page-filters/models';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
import { Language } from '@shared/enumerators';
import { sortByName } from '@shared/helpers/sorterts';
import { LocalizationService } from '@shared/services';
type AutocompleteItem = CityModel | AirportModel;
@Injectable()
export class CitiesSearchService {
private MAX_ITEMS_COUNT = 10;
private CODE_LENGTH = 3;
private lang: Language;
constructor(
private localizationService: LocalizationService,
private dictService: DictionariesService,
) {
this.lang = localizationService.Language;
}
private static addAirportsToCities(
cities: CityModel[],
): AutocompleteItem[] {
return cities.reduce((result, city) => {
const airports = city.airports
.filter((airport: AirportModel) => {
return airport.code !== city.code;
})
.sort(sortByName);
return [...result, city, ...airports];
}, [] as AutocompleteItem[]);
}
private static removeDuplicates(cities: CityModel[]): CityModel[] {
const citiesMap = new Map(cities.map((city) => [city.name, city]));
return Array.from(citiesMap, (mapItem) => mapItem[1]);
}
private findAirportsByName(airportName: string) {
return this.dictService.airportsAll
.filter((airport) => {
return (
airport.name
.toLowerCase()
.indexOf(airportName.toLowerCase()) === 0
);
})
.sort(sortByName);
}
private findCitiesStartedWith(cityNamePart: string) {
return this.dictService.citiesAll
.filter((city) => {
return (
city.name
.toLowerCase()
.indexOf(cityNamePart.toLowerCase()) === 0
);
})
.sort(sortByName);
}
private findCitiesThatIncludes(citiNamePart: string) {
return this.dictService.citiesAll
.filter((city) => {
return city.name
.toLowerCase()
.includes(citiNamePart.toLowerCase());
})
.sort(sortByName);
}
private findCitiesByAirportName(airportName) {
return this.findAirportsByName(airportName).map((airport) => {
return this.dictService.getCityByCode(airport.city_code);
});
}
private findAirportByCode(code: string): AirportModel | null {
if (code.length !== this.CODE_LENGTH) {
return null;
}
return this.dictService.getAirportByCode(code);
}
private processResult(cities: CityModel[]): AutocompleteItem[] {
const prepared = cities.slice(0, this.MAX_ITEMS_COUNT);
return CitiesSearchService.addAirportsToCities(prepared);
}
public findCitiesByNameUnprocessed(cityNamePart: string): CityModel[] {
const startsWith = this.findCitiesStartedWith(cityNamePart);
if (startsWith.length > this.MAX_ITEMS_COUNT) {
return startsWith;
}
const includes = this.findCitiesThatIncludes(cityNamePart);
const foundByCitiesNamePart = CitiesSearchService.removeDuplicates([
...startsWith,
...includes,
]);
if (foundByCitiesNamePart.length > this.MAX_ITEMS_COUNT) {
return foundByCitiesNamePart;
}
const byAirports = this.findCitiesByAirportName(cityNamePart);
const notStartsWith = [...includes, ...byAirports].sort(sortByName);
return CitiesSearchService.removeDuplicates([
...startsWith,
...notStartsWith,
]);
}
public findCitiesByName(cityNamePart: string) {
return this.processResult(
this.findCitiesByNameUnprocessed(cityNamePart),
);
}
public findCitiesByAirportCode(code): AutocompleteItem[] {
const airport = this.findAirportByCode(code);
if (!airport) {
return [];
}
return this.findCitiesByCode(airport.city_code);
}
public findCitiesByCode(code: string): AutocompleteItem[] {
if (code.length !== this.CODE_LENGTH) {
return [];
}
const city = this.dictService.getCityByCode(code);
return city ? this.processResult([city]) : [];
}
}
@@ -0,0 +1,9 @@
<div class="city-autocomplete__item" *ngIf="$any(item).countryName; else elseBlock">
<span class="city">{{ item.name }},</span>
<span class="country">&nbsp;{{ $any(item).countryName }}</span>
</div>
<ng-template #elseBlock>
<div class="city-autocomplete__item city-autocomplete__item--airport">
<span class="airport">{{ item.name }}</span>
</div>
</ng-template>
@@ -0,0 +1,33 @@
@use "src/styles/framework" as *;
.city-autocomplete__item {
white-space: nowrap;
height: $button-height;
border-bottom: 1.5px solid $border-input !important;
display: flex;
align-items: center;
padding-left: 0.429em;
&:last-child {
border-bottom: none;
}
&--airport {
padding-left: $space-xl;
}
.city {
white-space: nowrap;
font-weight: $font-bold;
}
.country {
white-space: nowrap;
color: $gray;
}
.airport {
white-space: nowrap;
color: $gray;
}
}
@@ -0,0 +1,14 @@
import { Component, Input } from '@angular/core';
import {
AirportModel,
CityModel,
} from '@modules/components/page-filters/models';
@Component({
selector: 'city-autocomplete-item',
templateUrl: './city-autocomplete-item.component.html',
styleUrls: ['./city-autocomplete-item.component.scss'],
})
export class CityAutocompleteItemComponent {
@Input() item: CityModel | AirportModel;
}
@@ -0,0 +1,50 @@
<div class="city-autocomplete">
<div class="city-autocomplete__labels-container">
<label class="city-autocomplete__label">{{ label | translate }}</label>
<label class="city-autocomplete__label" data-testid="city-code">{{ city?.code }}</label>
</div>
<tooltip *ngIf="error">
{{ error | translate }}
</tooltip>
<div
class="city-autocomplete__input"
[ngClass]="{ 'city-autocomplete__input--has-value': city, 'city-autocomplete__input--has-error': error }"
>
<p-autoComplete
#autocomplite
[(ngModel)]="city"
(ngModelChange)="onCityChange()"
[autoHighlight]="true"
[suggestions]="items"
(completeMethod)="filterCity($event)"
field="name"
[size]="30"
[minLength]="1"
(onSelect)="selectLocation(city)"
(onKeyUp)="inputChange()"
placeholder="{{ placeholder | translate }}"
data-testid="city-autocomplete-input"
>
<ng-template let-value pTemplate="item">
<city-autocomplete-item [item]="value"></city-autocomplete-item>
</ng-template>
</p-autoComplete>
<!-- //todo: remove button-clear styles; create component instead -->
<button pButton label=" " class="button-clear" (click)="clearInput()" data-testid="autocomplete-clear-input"></button>
<button
pButton
label=" "
class="city-autocomplete__search-button"
[ngClass]="{ 'city-autocomplete__search-button--opened': openedPopup }"
(click)="openPopUp()"
data-testid="autocomplete-popup-button"
></button>
</div>
</div>
<div class="city-autocomplete-popup-wrapper" *ngIf="openedPopup">
<city-select [city]="city" (selectLocation)="selectLocation($event)" (locate)="onLocate()"></city-select>
</div>
@@ -0,0 +1,60 @@
@use "src/styles/framework" as *;
.city-autocomplete {
display: flex;
flex-direction: column;
&__labels-container {
justify-content: space-between;
margin-bottom: $space-m;
width: 100%;
display: flex;
align-items: center;
}
&__label {
@include font-overflow();
@include font-small($gray);
}
&__input {
display: flex;
flex-direction: row;
position: relative;
align-items: center;
width: 100%;
@include control-border-shadow();
}
&__search-button {
width: $buttons-width !important;
min-width: $buttons-width;
border-radius: 0 $border-radius $border-radius 0 !important;
border: none !important;
border-left: 1px solid white !important;
background-color: transparent !important;
height: $standard-button-height - 2px;
&:hover {
background-color: $white !important;
border-left-color: $border-input !important;
}
}
&__input--has-value {
.button-clear {
display: block !important;
}
.city-autocomplete__search-button {
border-left: 1px solid $border-input !important;
}
}
&__input--has-error {
border-color: $red;
}
}
@@ -0,0 +1,94 @@
import { ElementRef, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CitiesSearchService } from '@components/city-autocomplete/cities-search-service/cities-search.service';
import { CityAutocompleteComponent } from '@components/city-autocomplete/city-autocomplete.component';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
import { getDictionariesServiceMock } from '@modules/components/page-filters/services/dictionaries-service.mock';
import { Language } from '@shared/enumerators';
import { getMockPipe } from '@shared/pipes/pipe.mock';
import { GeoService, LocalizationService } from '@shared/services';
describe('CityAutocompleteComponent', () => {
let component: CityAutocompleteComponent;
let fixture: ComponentFixture<CityAutocompleteComponent>;
let citiesSearchService: CitiesSearchService;
let dictService: DictionariesService;
let elementRef: ElementRef;
let geoService: GeoService;
let localizationService: LocalizationService;
beforeEach(async () => {
dictService = getDictionariesServiceMock();
localizationService = {
Language: Language.ru,
} as any;
citiesSearchService = new CitiesSearchService(
localizationService,
dictService,
);
elementRef = {
nativeElement: {
contains: jasmine.createSpy(),
},
};
geoService = {
getPosition: () => {
return Promise.resolve({
latitude: 1,
longitude: 1,
});
},
} as any;
TestBed.configureTestingModule({
declarations: [CityAutocompleteComponent, getMockPipe('translate')],
providers: [
{ provide: LocalizationService, useValue: localizationService },
{ provide: DictionariesService, useValue: dictService },
{ provide: CitiesSearchService, useValue: citiesSearchService },
{ provide: ElementRef, useValue: elementRef },
{ provide: GeoService, useValue: geoService },
],
schemas: [NO_ERRORS_SCHEMA],
});
await dictService.ready$;
});
beforeEach(() => {
fixture = TestBed.createComponent(CityAutocompleteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.autocomplite = {
hide: jasmine.createSpy(),
} as any;
});
it('should set items', () => {
const event = { query: 'Мос' };
component.filterCity(event);
expect(component.items.length).not.toBe(0);
});
it("should set items even if keyboard layout isn't russian", () => {
const event = { query: 'Vjc' }; // Vjc === Мос
component.filterCity(event);
expect(component.items.length).not.toBe(0);
});
it('should city if there is full match', () => {
const event = { query: 'Москва' }; // Vjc === Мос
jasmine.clock().install();
component.filterCity(event);
jasmine.clock().tick(100);
expect(component.items).toBe(undefined);
expect(component.city.name).toBe(event.query);
jasmine.clock().uninstall();
});
});
@@ -0,0 +1,192 @@
import {
Component,
ElementRef,
EventEmitter,
forwardRef,
HostListener,
Input,
Output,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { CitiesSearchService } from './cities-search-service/cities-search.service';
import { Language } from '@shared/enumerators';
import { LocalizationService } from '@shared/services';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { AutoComplete } from 'primeng/autocomplete';
import { Destroyable } from '@shared/destroyable';
import {
AirportModel,
CityModel,
} from '@modules/components/page-filters/models';
import { ruKeyboardLayout } from '@shared/helpers/keyboardLayoutConverter';
type AutocompleteItem = CityModel | AirportModel;
@Component({
selector: 'city-autocomplete',
templateUrl: 'city-autocomplete.component.html',
styleUrls: ['./city-autocomplete.component.scss'],
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CityAutocompleteComponent), // replace name as appropriate
multi: true,
},
],
})
export class CityAutocompleteComponent
extends Destroyable
implements ControlValueAccessor
{
openedPopup = false;
items: AutocompleteItem[];
regions: any[];
countres: any[];
airports: any[];
city: CityModel | AirportModel;
@Input() label: string;
@Input() placeholder: string;
@Input() error: string;
@Output() errorChange = new EventEmitter<string>();
@ViewChild('autocomplite') autocomplite: AutoComplete;
@HostListener('document:click', ['$event'])
clickout(event) {
if (!this.eRef.nativeElement.contains(event.target)) {
this.focusOut();
}
}
constructor(
private localizationService: LocalizationService,
private dictService: DictionariesService,
private eRef: ElementRef,
private citiesSearch: CitiesSearchService,
) {
super();
}
writeValue(value: string) {
this.city = this.dictService.getCityOrAirport(value);
}
onTouched = () => {};
onChange = (_: string) => {};
registerOnChange(fn: (value: string) => void) {
this.onChange = fn;
}
registerOnTouched(fn: () => void) {
this.onTouched = fn;
}
async onLocate() {
const city = await this.dictService.locate();
if (city) {
this.selectLocation(city);
}
}
onCityChange() {
if (!this.city || typeof this.city === 'string') {
this.onChange(undefined);
}
}
focusOut() {
this.openedPopup = false;
}
filterCity(event) {
const text: string = event.query;
//this.onChange(text);
if (!this.dictService.citiesAll) {
return;
}
if (text.length === 3) {
const byCityCode = this.citiesSearch.findCitiesByCode(text);
if (byCityCode.length) {
this.items = byCityCode;
return;
}
}
const byName = this.citiesSearch.findCitiesByName(
this.convertText(text),
);
// If there is full match - select city and hide autocomplete menu
if (
byName.length &&
text.toLowerCase() === byName[0].name.toLowerCase()
) {
setTimeout(() => {
this.autocomplite.hide();
this.selectLocation(byName[0]);
this.autocomplite.loading = false;
}, 100);
return;
}
if (byName.length) {
this.items = byName;
return;
}
const byCityCode = this.citiesSearch.findCitiesByCode(text);
if (byCityCode.length) {
this.items = byCityCode;
return;
}
this.items = this.citiesSearch.findCitiesByAirportCode(text);
}
convertText(text: string) {
return this.localizationService.Language === Language.ru
? ruKeyboardLayout.fromEn(text)
: text;
}
selectLocation(value?: CityModel | AirportModel) {
this.city = value;
this.onChange(value?.code);
this.openedPopup = false;
if (!value) {
return;
}
this.resetError();
}
openPopUp() {
this.openedPopup = !this.openedPopup;
this.resetError();
}
clearInput() {
this.selectLocation(null);
}
inputChange() {
this.openedPopup = false;
this.resetError();
}
private resetError() {
this.error = undefined;
this.errorChange.emit(undefined);
}
}
@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CitiesSearchService } from '@components/city-autocomplete/cities-search-service/cities-search.service';
import { CityAutocompleteItemComponent } from '@components/city-autocomplete/city-autocomplete-item/city-autocomplete-item.component';
import { CitySelectComponent } from '@components/city-autocomplete/city-select/city-select.component';
import { AutoCompleteModule } from 'primeng/autocomplete';
import { ToolkitModule } from '@toolkit/toolkit.module';
import { ScrollPanelModule } from 'primeng/scrollpanel';
import { CityAutocompleteComponent } from './city-autocomplete.component';
@NgModule({
declarations: [
CityAutocompleteComponent,
CityAutocompleteItemComponent,
CitySelectComponent,
],
providers: [CitiesSearchService],
exports: [CityAutocompleteComponent],
imports: [
CommonModule,
FormsModule,
AutoCompleteModule,
ToolkitModule,
ScrollPanelModule,
],
})
export class CityAutocompleteModule {}
@@ -0,0 +1,81 @@
<div class="city-autocomplete-popup">
<div class="tabs">
<button
pButton
*ngFor="let item of dictService.regions"
[ngClass]="{ active: item.id == currentRegion.id }"
type="button"
role="tab"
aria-selected="true"
label="{{ item.name }}"
class="tab-button"
(click)="loadRegion(item)"
></button>
</div>
<div class="content">
<p-scrollPanel #sp class="content--scroll-panel" [style]="{ width: '100%', height: '350px' }">
<div *ngFor="let item of currentRegion.countries" class="row" [ngClass]="{ 'country-start-row': item.name }">
<div class="cell contry">
<div>
{{ item.name }}
</div>
</div>
<div
*ngIf="item.city1.airports.length == 1"
class="cell city"
[ngClass]="{ 'city-active': item.city1 == city }"
attr.data-testid="city-name-cell-{{ item.city1.name }}"
>
<div class="city--item" (click)="onLocationSelect(item.city1)">
{{ item.city1.name }}
</div>
</div>
<div *ngIf="item.city1.airports.length == 1" class="cell city" [ngClass]="{ 'city-active': item.city2 == city }">
<div
class="city--item"
*ngIf="item.city2"
(click)="onLocationSelect(item.city2)"
attr.data-testid="city-name-cell-{{ item.city2.name }}"
>
{{ item.city2.name }}
</div>
</div>
<div *ngIf="item.city1.airports.length > 1" class="cell city" [ngClass]="{ 'city-active': item.city1 == city }">
<div class="city--item" (click)="onLocationSelect(item.city1)" attr.data-testid="city-name-cell-{{ item.city1.name }}">
{{ item.city1.name }}
</div>
<div class="airports-column">
<div
*ngFor="let airport of item.city1.airports"
pTooltip="{{ airport.name }}"
tooltipPosition="top"
class="city--item"
(click)="onLocationSelect(airport)"
attr.data-testid="airport-name-cell-{{ airport.name }}"
>
<!--[ngClass]="{'city-active':airport.code==city.code}"-->
{{ airport.name }}
</div>
</div>
</div>
</div>
</p-scrollPanel>
</div>
<div *ngIf="featureFlags.FIND_MY_LOCATION_BUTTON" class="city-autocomplete-popup-footer">
<div class="gps-contaner">
<button
class="gps-button color blue-light"
pButton
type="button"
label="{{ 'BOARD.GPS-BUTTON' | translate }}"
(click)="onLocate()"
></button>
<div class="gps-label-help">
{{ 'BOARD.GPS-HELP' | translate }}
</div>
</div>
</div>
</div>
@@ -0,0 +1,136 @@
@use 'sass:math';
@use "src/styles/framework" as *;
.city-autocomplete-popup {
position: absolute;
width: 630px;
background: #ffffff;
border-radius: $border-radius;
opacity: 1;
@include control-border-shadow();
z-index: 10000;
margin-top: $space-s;
.tabs {
display: flex;
align-items: stretch;
.tab-button {
height: auto;
flex-grow: 1;
border-radius: 0;
background-color: $blue-extra-light;
border: none;
border-right: 1px solid $border;
border-bottom: 1px solid $border;
&:hover {
background-color: $blue-extra-light;
border-right: 1px solid $border;
border-bottom: 1px solid $border;
.p-button-label {
color: $text-color;
}
}
&.active {
background-color: $white;
border-bottom: 1px solid $white;
.p-button-label {
color: $text-color;
}
}
&:first-child {
border-radius: $border-radius 0 0 0;
}
&:last-child {
border-radius: 0 $border-radius 0 0;
border-right: none;
}
.p-button-label {
color: $blue;
font-weight: $font-bold;
font-size: 14px;
padding: 10px !important;
}
}
}
.city-autocomplete-popup-footer {
width: 100%;
background: #f3f9ff;
.gps-contaner {
width: 100%;
padding: $space-xl;
border-top: 1px solid $border;
.gps-button {
height: $standard-button-height;
width: 100%;
background-color: $blue-light;
.p-button-label {
font-size: $font-size-m;
}
}
.gps-label-help {
margin-top: $space-l;
@include font-small($gray);
}
}
}
.cell {
&.contry {
align-self: flex-start;
color: $text-color;
font-weight: $font-bold;
padding: 7.5px $space-m 7.5px 7.5px;
width: 30%;
}
&.city {
flex: 1;
padding-right: $space-m;
@include font-overflow();
.city--item {
width: auto;
max-width: 100%;
cursor: pointer;
@include font-overflow();
display: inline-block;
border-radius: $border-radius;
padding: math.div($space-l, 2) math.div($space-l, 2);
transition-duration: 0.1s;
&:hover {
background-color: $blue-extra-light;
}
}
.airports-column {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: $space-m;
.city--item {
color: $light-gray;
margin-right: $space-m;
}
}
&:last-child {
padding-right: 0;
}
}
}
}
@@ -0,0 +1,65 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {
AirportModel,
CityModel,
RegionFlatModel,
} from '@modules/components/page-filters/models';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
import { ScrollPanel } from 'primeng/scrollpanel';
@Component({
selector: 'city-select',
templateUrl: './city-select.component.html',
styleUrls: ['./city-select.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class CitySelectComponent implements OnInit {
currentRegion: RegionFlatModel;
@Input() city: CityModel | AirportModel;
@Output() selectLocation: EventEmitter<CityModel | AirportModel> =
new EventEmitter<CityModel | AirportModel>();
@Output() locate = new EventEmitter();
@ViewChild('sp', { static: false }) scrollPanel: ScrollPanel;
constructor(
public dictService: DictionariesService,
public featureFlags: FeatureFlagsService,
) {}
ngOnInit() {
this.currentRegion = this.dictService.regions.find(
(x) => x.id == 500374,
);
}
loadRegion(region) {
this.currentRegion = region;
this.scrollPanel.scrollTop(0);
}
getRegionData() {
if (this.currentRegion) {
return this.currentRegion;
}
return [];
}
onLocationSelect(model: CityModel | AirportModel) {
this.selectLocation.emit(model);
}
onLocate() {
this.locate.emit();
}
}
@@ -0,0 +1,33 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CityAutocompleteModule } from '@components/city-autocomplete/city-autocomplete.module';
import { DayTabsComponent } from '@components/dates-selectors/day-tabs/day-tabs.component';
import { WeekTabsComponent } from '@components/dates-selectors/week-tabs/week-tabs.component';
import { PageModule } from '@components/page/page.module';
import { SearchHistoryModule } from '@components/search-history/search-history.module';
import { ToolkitModule } from '@toolkit/toolkit.module';
import { SameUrlNavigationDetectorComponent } from './same-url-navigation-detector/same-url-navigation-detector.component';
@NgModule({
declarations: [
WeekTabsComponent,
DayTabsComponent,
SameUrlNavigationDetectorComponent,
],
imports: [
CommonModule,
ToolkitModule,
PageModule,
CityAutocompleteModule,
SearchHistoryModule,
],
exports: [
PageModule,
WeekTabsComponent,
DayTabsComponent,
SameUrlNavigationDetectorComponent,
CityAutocompleteModule,
SearchHistoryModule,
],
})
export class ComponentsModule {}
@@ -0,0 +1,87 @@
import {
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
} from '@angular/core';
import { IDateTab } from '@toolkit/date-tabs/date-tabs.component';
import { areSame, isSameOrBefore } from '@utils/date';
import { DatesTranslationService } from '@shared/services/dates-translation.service';
@Component({
selector: 'date-selector-base',
template: '',
})
export abstract class DateSelectorBaseComponent<T = unknown>
implements OnInit, OnChanges
{
@Input() caption!: string;
@Input() selectedDate!: Date;
@Input() dateFrom!: Date;
@Input() dateTo!: Date;
@Input() minDate: Date;
@Input() maxDate: Date;
@Input() tabsPerPage = 7;
@Output() tabClick = new EventEmitter<T>();
tabs: IDateTab<T>[] = [];
protected constructor(
protected translationService: DatesTranslationService,
) {}
ngOnInit() {
this.translationService.init().then(() => {
this.tabs = this.generateTabs();
});
}
ngOnChanges(changes: SimpleChanges) {
this.updateTabs();
}
public onTabClick(value: T) {
this.tabClick.emit(value);
}
protected isActive(date: Date) {
if (!this.selectedDate || !date) {
return false;
}
return areSame(this.selectedDate, date);
}
protected generateTabs(): IDateTab<T>[] {
const tabs = [];
let date = this.computeFirstDate();
// eslint-disable-next-line no-constant-condition
while (true) {
const tab = this.generateTab(date);
tabs.push(tab);
date = this.computeNextDate(date);
// generate tabs until we reach this.dateTo and
// can fulfill integer number of pages
if (
!isSameOrBefore(date, this.dateTo) &&
tabs.length % this.tabsPerPage === 0
) {
break;
}
}
return tabs;
}
protected abstract updateTabs();
protected abstract generateTab(date: Date): IDateTab<T>;
protected abstract computeNextDate(date: Date): Date;
protected abstract computeFirstDate(): Date;
}
@@ -0,0 +1,6 @@
<date-tabs
[tabs]="tabs"
[caption]="caption"
[tabsPerPage]="tabsPerPage"
(tabClick)="onTabClick($any($event))"
></date-tabs>
@@ -0,0 +1,99 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { DateSelectorBaseComponent } from '../date-selector-base';
import { IDateTab } from '@toolkit/date-tabs/date-tabs.component';
import { DatesTranslationService } from '@shared/services/dates-translation.service';
import * as moment from 'moment';
import { FlightModel, getFirstLeg, Leg } from '../../../shared/models-legacy';
@Component({
selector: 'day-tabs',
templateUrl: './day-tabs.component.html',
})
export class DayTabsComponent extends DateSelectorBaseComponent<Date>
implements OnInit, OnChanges {
@Input() disabledDates: Date[];
@Input() flight: FlightModel | null;
departure: Leg;
ngOnChanges(changes: SimpleChanges) {
if ('flight' in changes && this.flight) {
this.departure = getFirstLeg(this.flight);
}
super.ngOnChanges(changes);
}
constructor(protected translationService: DatesTranslationService) {
super(translationService);
}
protected computeNextDate(date: Date): Date {
return moment(date).add(1, 'day').toDate();
}
protected computeFirstDate(): Date {
return this.dateFrom;
}
protected updateTabs() {
this.tabs = this.tabs.map((tab) => {
var dayForTab = moment(tab.value).format('YYYYMMDD');
const isTabActive = this.isActive(tab.value);
let isTabDisabled = !this.isEnabled(tab.value);
if (this.departure?.daysForTabs) {
isTabDisabled = !this.departure.daysForTabs.includes(dayForTab) || isTabDisabled;
}
if (this.disabledDates) {
isTabDisabled ||= this.disabledDates.findIndex((x)=>moment(x).format('YYYYMMDD')==dayForTab) > -1;
}
return {
...tab,
active: isTabActive,
disabled: isTabDisabled,
};
});
}
protected generateTab(day: Date): IDateTab<Date> {
var dayForTab = moment(day).format('YYYYMMDD');
const isTabActive = this.isActive(day);
let isTabDisabled = !this.isEnabled(day);
if (this.departure?.daysForTabs) {
isTabDisabled = !this.departure.daysForTabs.includes(dayForTab) || isTabDisabled;
}
if (this.disabledDates) {
isTabDisabled ||= this.disabledDates.findIndex((x)=>moment(x).format('YYYYMMDD')==dayForTab) > -1;
}
return {
label: this.getTabLabel(day, isTabActive),
mobileLabel: this.getTabMobileLabel(day),
value: day,
active: isTabActive,
disabled: isTabDisabled,
};
}
private getTabMobileLabel(day: Date) {
return this.translationService.getFullDateString(day, true);
}
private getTabLabel(day: Date, isTabActive: boolean) {
const shortDateString = this.translationService.getShortDateString(day);
const fullDateString = this.translationService.getFullDateString(day);
return isTabActive ? fullDateString.toLowerCase() : shortDateString;
}
private isEnabled(day: Date) {
return moment(day).isBetween(this.minDate, this.maxDate, 'day', '[]');
}
}
@@ -0,0 +1,6 @@
<date-tabs
[tabs]="tabs"
[caption]="caption"
[tabsPerPage]="tabsPerPage"
(tabClick)="onTabClick($any($event))"
></date-tabs>
@@ -0,0 +1,94 @@
import { Component, OnChanges, OnInit } from '@angular/core';
import { getSunday, getStartOfTheWeek } from '@utils/date';
import { DateSelectorBaseComponent } from '../date-selector-base';
import { IDateTab } from '@toolkit/date-tabs/date-tabs.component';
import { DatesTranslationService } from '@shared/services/dates-translation.service';
import * as moment from 'moment';
export type IWeekTabValue = {
dateFrom: Date;
dateTo: Date;
};
@Component({
selector: 'week-tabs',
templateUrl: './week-tabs.component.html',
})
export class WeekTabsComponent
extends DateSelectorBaseComponent<IWeekTabValue>
implements OnChanges, OnInit {
ngOnInit() {
super.ngOnInit();
}
constructor(protected translationService: DatesTranslationService) {
super(translationService);
}
protected updateTabs() {
this.tabs = this.tabs.map((tab) => {
const sunday = tab.value.dateTo;
const monday = tab.value.dateFrom;
const isTabActive = this.isActive(monday);
const isTabDisabled = !this.isEnabled(monday, sunday);
return {
...tab,
active: isTabActive,
disabled: isTabDisabled,
};
});
}
protected computeNextDate(date: Date): Date {
return moment(date).add(1, 'week').toDate();
}
protected computeFirstDate(): Date {
return getStartOfTheWeek(this.dateFrom);
}
protected generateTab(monday: Date): IDateTab<IWeekTabValue> {
const isTabActive = this.isActive(monday);
const sunday = getSunday(monday);
const isTabDisabled = !this.isEnabled(monday, sunday);
return {
label: this.getTabLabel(monday, sunday),
mobileLabel: this.getTabMobileLabel(monday, sunday),
value: {
dateFrom: monday,
dateTo: sunday,
},
active: isTabActive,
disabled: isTabDisabled,
};
}
private getTabLabel(monday: Date, sunday: Date) {
const mondayShortDateString =
this.translationService.getShortDateString(monday);
const sundayShortDateString =
this.translationService.getShortDateString(sunday);
return `${mondayShortDateString} - ${sundayShortDateString}`;
}
private getTabMobileLabel(monday: Date, sunday: Date) {
const mondayFullDateString =
this.translationService.getFullDateString(monday);
const sundayFullDateString =
this.translationService.getFullDateString(sunday);
return `${mondayFullDateString} - ${sundayFullDateString}`;
}
private isEnabled(monday: Date, sunday: Date) {
return (
moment(monday).isSameOrAfter(this.minDate) &&
moment(sunday).isSameOrBefore(this.maxDate)
);
}
}
@@ -0,0 +1,39 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DictionariesService } from '@modules/components/page-filters/services/dictionaries-service';
@Component({
selector: 'meta-base',
template: '',
})
export abstract class MetaBaseComponent implements OnInit {
title = '';
description = '';
protected paramsName = 'params';
protected constructor(
protected route: ActivatedRoute,
protected dictionaries: DictionariesService,
) {}
protected abstract translateTitle(params: string): string;
protected abstract translateDescription(params: string): string;
protected getCity(cityCode: string) {
return this.dictionaries.getCityOrAirport(cityCode)?.name || '';
}
ngOnInit(): void {
this.route.params.subscribe({
next: (routerParams) => {
const params = routerParams[this.paramsName];
this.dictionaries.ready$.then(() => {
this.title = this.translateTitle(params);
this.description = this.translateDescription(params);
});
},
});
}
}
@@ -0,0 +1 @@
<p-breadcrumb [model]="items"></p-breadcrumb>
@@ -0,0 +1,63 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { PageBreadcrumbsComponent } from '@components/page/breadcrumds/page-breadcrumbs.component';
import { TranslateService } from '@ngx-translate/core';
import { IRouteData } from '@typings/common/route';
import { BehaviorSubject } from 'rxjs';
describe('PageBreadcrumbsComponent', () => {
let fixture: ComponentFixture<PageBreadcrumbsComponent>;
let component: PageBreadcrumbsComponent;
const data: IRouteData = {};
const instantMock = jasmine.createSpy('instant');
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PageBreadcrumbsComponent],
providers: [
{
provide: TranslateService,
useValue: {
instant: instantMock,
},
},
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject(data),
},
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PageBreadcrumbsComponent);
component = fixture.componentInstance;
data.breadcrumbs = undefined;
instantMock.calls.reset();
});
it('should contain only default breadcrumb item', () => {
component.ngOnInit();
expect(component.items.length).toBe(1);
});
it('should contain default breadcrumb item and items from data.breadcrumbs', () => {
data.breadcrumbs = [
{
label: 'breadcrumb 1',
url: 'url1',
},
{
label: 'breadcrumb 2',
url: 'url2',
},
];
component.ngOnInit();
expect(component.items.length).toBe(3);
});
});
@@ -0,0 +1,42 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { IRouteData } from '@typings/common/route';
import { MenuItem } from 'primeng/api';
export type IBreadCrumbItem = Required<Pick<MenuItem, 'label' | 'url'>>;
const defaultMenuItem: IBreadCrumbItem = {
label: 'SHARED.MAIN',
url: 'https://www.aeroflot.ru',
};
@Component({
selector: 'flights-page-breadcrumbs',
templateUrl: './page-breadcrumbs.component.html',
})
export class PageBreadcrumbsComponent implements OnInit {
items: IBreadCrumbItem[] = [];
constructor(
private translation: TranslateService,
private route: ActivatedRoute,
) {}
ngOnInit() {
this.route.data.subscribe((data: IRouteData) => {
let items = [defaultMenuItem];
if (data.breadcrumbs) {
items = items.concat(data.breadcrumbs);
}
this.items = this.translate(items);
});
}
private translate(items: IBreadCrumbItem[]) {
return items.map((item) => ({
...item,
label: this.translation.instant(item.label) || item.label,
}));
}
}
@@ -0,0 +1,4 @@
<button class="feedback-button" (click)="showForm()">
{{ 'SHARED.FEEDBACK' | translate }}
</button>
<feedback-form *ngIf="formVisible" (clickOutside)="hideForm()"></feedback-form>
@@ -0,0 +1,21 @@
@use 'src/styles/framework' as *;
.feedback-button {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 3px 10px;
height: 25px;
min-height: 25px;
border-radius: 3px;
border: none;
background-color: $orange--hover;
color: $white;
font-size: $font-size-s;
line-height: 1.6;
}
@@ -0,0 +1,18 @@
import { Component } from '@angular/core';
@Component({
selector: 'feedback-button',
templateUrl: './feedback-button.component.html',
styleUrls: ['./feedback-button.component.scss'],
})
export class FeedbackButtonComponent {
formVisible = false;
showForm() {
this.formVisible = true;
}
hideForm() {
this.formVisible = false;
}
}
@@ -0,0 +1,23 @@
<div class="feedback-form" (click)="handleWrapperClick()">
<button class="feedback-form__close">
<!-- inlined from assets/img/close.svg to change fill color -->
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
<path
id="close"
d="M19,6.41,17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z"
transform="translate(-5 -5)"
fill="#FFFFFF"
/>
</svg>
</button>
<iframe
class="feedback-form__iframe"
src="https://forms.office.com/Pages/ResponsePage.aspx?id=DQSIkWdsW0yxEjajBLZtrQAAAAAAAAAAAAN__pz85X5UQ1dYVjA0RklSVlYwT0czWlhNSTZXWDA2Uy4u&embed=true"
style="border: none; max-width: 100%; max-height: 100vh"
allowfullscreen
webkitallowfullscreen
mozallowfullscreen
msallowfullscreen
>
</iframe>
</div>
@@ -0,0 +1,58 @@
@use "src/styles/framework" as *;
.feedback-form {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: 1009;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
background-color: $dark-blue-opacity;
@include mobile() {
padding: $space-m;
}
&__iframe {
width: 640px;
height: 480px;
@include mobile() {
height: 100%;
}
}
&__close {
position: absolute;
top: 24px;
right: 24px;
display: none;
width: 40px;
height: 40px;
background-color: transparent;
border: none;
color: white;
@include mobile() {
display: flex;
align-items: center;
justify-content: center;
}
svg {
width: 28px;
height: 28px;
}
}
}
@@ -0,0 +1,15 @@
import { Component, EventEmitter, Output } from '@angular/core';
@Component({
selector: 'feedback-form',
templateUrl: './feedback-form.component.html',
styleUrls: ['./feedback-form.component.scss'],
})
export class FeedbackFormComponent {
@Output() clickOutside: EventEmitter<undefined> =
new EventEmitter<undefined>();
handleWrapperClick() {
this.clickOutside.emit();
}
}
@@ -0,0 +1,28 @@
<div class="page-layout__row page-layout__header">
<aside class="page-layout__column-left page-layout__header-left">
<ng-content select="[header-left]"></ng-content>
</aside>
<div class="page-layout__column-right page-layout__header-right">
<div [ngClass]="pageLayoutTitleClasses">
<flights-page-breadcrumbs></flights-page-breadcrumbs>
<ng-content select="[title]"></ng-content>
</div>
<feedback-button
*ngIf="featureFlags.FEEDBACK_BUTTON_AVAILABLE"
class="page-layout__feedback-button"
></feedback-button>
</div>
</div>
<div class="page-layout__row page-layout__content">
<aside class="page-layout__column-left">
<ng-content select="[content-left]"></ng-content>
</aside>
<main class="page-layout__column-right">
<div class="page-layout__sticky-content">
<ng-content select="[sticky-content]"></ng-content>
</div>
<scroll-up-button *ngIf="withScrollUp"></scroll-up-button>
<ng-content></ng-content>
</main>
</div>
<div *ngIf="withScrollOverlay" class="page-layout__scroll-overlay"></div>
@@ -0,0 +1,164 @@
@use "src/styles/framework" as *;
@use "src/styles/positioning";
.page-layout {
&__row {
position: relative;
display: flex;
flex-flow: row nowrap;
align-items: flex-start;
margin: 0 auto;
width: 100%;
max-width: $site-width;
@include print {
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
}
@include smTablet {
flex-flow: column wrap;
}
}
&__column-left {
position: sticky;
top: 20px;
bottom: 20px;
z-index: 1001;
flex-shrink: 0;
margin-left: 0;
margin-right: 1.5%;
width: $left-aside-width;
@media (max-width: $media-breakpoint-desktop) {
width: $left-aside-width-desktop;
}
@media (max-width: $media-breakpoint-tablet) {
position: relative;
top: 0;
width: 100%;
margin-right: 0;
}
}
&__column-right {
position: relative;
width: calc(100% - #{$left-aside-width} - #{$column-spacing});
@include print {
width: 100% !important;
margin-top: 20px !important;
}
@media (max-width: $media-breakpoint-desktop) {
width: calc(100% - #{$left-aside-width-desktop} - 1.5%);
}
@media (max-width: $media-breakpoint-tablet) {
width: 100%;
}
}
&__header-right {
margin-top: auto;
display: flex;
justify-content: space-between;
@include smTablet {
flex-direction: column;
order: 1;
}
}
&__header-left {
margin-top: auto;
@include smTablet {
order: 2;
}
}
&__title {
width: calc(100% - 120px);
}
&__title--fullwidth {
width: 100%;
}
&__header {
position: relative;
z-index: 1001;
padding-top: $space-top-site;
margin-bottom: $space-xl;
@include mobile {
margin-bottom: $space-m;
}
}
&__content &__column-left{
@include smTablet {
margin-bottom: $space-xl;
}
@include mobile {
margin-bottom: $space-m;
}
}
&__content &__column-left:empty {
@include smTablet {
margin-bottom: 0;
}
}
&__feedback-button {
margin-top: auto;
flex-shrink: 0;
@include smTablet {
margin-bottom: $space-m;
}
@include print {
display: none;
}
}
&__scroll-overlay {
@include mobile {
display: none;
}
position: fixed;
z-index: positioning.$sticky-elements-z-index - 1;
top: 0;
left: 0;
width: calc(100% - 20px); // set width less than viewport to prevent scroll clipping on windows
height: positioning.$sticky-threshold + 5px; // increase height to prevent content revealing when sticky element corners rounded
background-color: $blue-dark !important;
background-image: url('~src/assets/img/background.jpg');
background-repeat: no-repeat;
background-size: 100vw;
}
&__sticky-content {
@include gt-mobile {
position: sticky;
z-index: positioning.$sticky-elements-z-index;
top: positioning.$sticky-threshold;
}
}
}
@@ -0,0 +1,106 @@
import { ChangeDetectorRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { PageBreadcrumbsComponent } from '@components/page/breadcrumds/page-breadcrumbs.component';
import { FeedbackButtonComponent } from '@components/page/feedback/button/feedback-button.component';
import { FeedbackFormComponent } from '@components/page/feedback/form/feedback-form.component';
import { PageLayoutComponent } from '@components/page/layout/page-layout.component';
import { TranslateService } from '@ngx-translate/core';
import { getMockPipe } from '@shared/pipes/pipe.mock';
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
import { TitleComponent } from '@toolkit/title/title.component';
import { BreadcrumbModule } from 'primeng/breadcrumb';
import { TooltipModule } from 'primeng/tooltip';
import { BehaviorSubject } from 'rxjs';
describe('PageLayoutComponent', () => {
let component: PageLayoutComponent;
let fixture: ComponentFixture<PageLayoutComponent>;
let featureFlags: FeatureFlagsService;
let hostElement: HTMLElement;
const instantMock = jasmine.createSpy('instant');
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
PageLayoutComponent,
PageBreadcrumbsComponent,
FeedbackButtonComponent,
FeedbackFormComponent,
TitleComponent,
getMockPipe('translate'),
],
providers: [
FeatureFlagsService,
{
provide: TranslateService,
useValue: {
instant: instantMock,
},
},
{
provide: ActivatedRoute,
useValue: {
data: new BehaviorSubject({}),
},
},
],
imports: [BreadcrumbModule, TooltipModule],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PageLayoutComponent);
component = fixture.componentInstance;
hostElement = fixture.nativeElement;
featureFlags = TestBed.inject(FeatureFlagsService);
instantMock.calls.reset();
});
it('should return object with page-layout__title class if feedback feature is enabled', () => {
featureFlags.FEEDBACK_BUTTON_AVAILABLE = true;
expect(component.pageLayoutTitleClasses).toEqual({
'page-layout__title': true,
'page-layout__title--fullwidth': false,
});
});
it('should return object with all classes if feedback feature is disabled', () => {
expect(component.pageLayoutTitleClasses).toEqual({
'page-layout__title': true,
'page-layout__title--fullwidth': true,
});
});
it('should not render feedback button component', () => {
const button = hostElement.querySelector('feedback-button');
expect(button).toBe(null);
});
it('should render feedback button component', () => {
featureFlags.FEEDBACK_BUTTON_AVAILABLE = true;
fixture.detectChanges();
const button = hostElement.querySelector('feedback-button');
expect(button).not.toBe(null);
});
it('should not render scroll overlay if withScrollOverlay is false', () => {
fixture.detectChanges();
let overlay = hostElement.querySelector('.page-layout__scroll-overlay');
expect(overlay).toBeDefined();
component.withScrollOverlay = false;
const changeDetectorRef =
fixture.debugElement.injector.get<ChangeDetectorRef>(
ChangeDetectorRef,
);
changeDetectorRef.detectChanges();
overlay = hostElement.querySelector('.page-layout__scroll-overlay');
expect(overlay).toBe(null);
});
});
@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
@Component({
selector: 'page-layout',
templateUrl: './page-layout.component.html',
styleUrls: ['./page-layout.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PageLayoutComponent {
@Input() withScrollOverlay = true;
@Input() withScrollUp = true;
constructor(public featureFlags: FeatureFlagsService) {}
get pageLayoutTitleClasses() {
return {
'page-layout__title': true,
'page-layout__title--fullwidth':
!this.featureFlags.FEEDBACK_BUTTON_AVAILABLE,
};
}
}
@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FeedbackFormComponent } from '@components/page/feedback/form/feedback-form.component';
import { ToolkitModule } from '@toolkit/toolkit.module';
import { TranslateModule } from '@ngx-translate/core';
import { BreadcrumbModule } from 'primeng/breadcrumb';
import { PageBreadcrumbsComponent } from './breadcrumds/page-breadcrumbs.component';
import { FeedbackButtonComponent } from './feedback/button/feedback-button.component';
import { PageLayoutComponent } from './layout/page-layout.component';
import { ScrollUpButtonComponent } from './scroll-up-button/scroll-up-button.component';
@NgModule({
declarations: [
PageLayoutComponent,
FeedbackButtonComponent,
FeedbackFormComponent,
PageBreadcrumbsComponent,
ScrollUpButtonComponent,
],
imports: [CommonModule, ToolkitModule, TranslateModule, BreadcrumbModule],
exports: [PageLayoutComponent],
})
export class PageModule {}
@@ -0,0 +1,4 @@
<div id="observer-target"></div>
<div [ngClass]="buttonClasses">
<div class="scroll-up__button" (click)="handleClick()"></div>
</div>
@@ -0,0 +1,45 @@
@use "./src/styles/colors";
@use "./src/styles/screen";
.scroll-up {
display: none;
&--visible {
display: block;
position: fixed;
right: 30px;
bottom: 80px;
z-index: 2000;
}
&__button {
position: static;
margin: 0;
width: 40px;
height: 40px;
background-color: colors.$extra-blue;
background-image: url("~src/assets/img/arrow-up.svg");
background-repeat: no-repeat;
background-position: center center;
border-radius: 50%;
border: 1px solid colors.$blue-extra-light;
box-sizing: border-box;
cursor: pointer;
}
}
#observer-target {
position: absolute;
@include screen.gt-mobile {
top: -20px;
width: 10px;
height: 70px;
visibility: hidden;
}
}
@@ -0,0 +1,83 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@Component({
selector: 'scroll-up-button',
templateUrl: './scroll-up-button.component.html',
styleUrls: ['./scroll-up-button.component.scss'],
})
export class ScrollUpButtonComponent implements OnInit, OnDestroy {
buttonVisible = false;
observer: IntersectionObserver;
interval: number;
constructor(private cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: null,
rootMargin: '0px',
threshold: [1],
},
);
this.observer.observe(document.querySelector('#observer-target'));
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.interval = setInterval(() => {
// need to check if visibility changed because of programmatic scroll in scrollTo directive
this.checkVisibility();
}, 500);
}
ngOnDestroy() {
this.observer.disconnect();
clearInterval(this.interval);
}
setButtonVisible(visible: boolean) {
this.buttonVisible = visible;
this.cdRef.detectChanges();
}
checkVisibility() {
if (this.buttonVisible) {
return;
}
const header = document.querySelector('.page-layout__header');
const { bottom } = header.getBoundingClientRect();
this.setButtonVisible(bottom < 0);
}
handleIntersection(entries: IntersectionObserverEntry[]) {
const entry = entries[0];
// entry.boundingClientRect.top < 0 means that observable target crossed
// top of the viewport. If top > 0 - target crossed the bottom of the
// viewport, and we don't need to show button.
const visible =
entry.intersectionRatio !== 1 && entry.boundingClientRect.top < 0;
this.setButtonVisible(visible);
}
handleClick() {
const body = document.querySelector('body');
// const bannerTop = document.querySelector('.wrapper-header');
const flightsRoot = document.querySelector('flights-root');
const { top } = flightsRoot.getBoundingClientRect();
body.scrollTo(0, body.scrollTop + top);
this.setButtonVisible(false);
}
get buttonClasses() {
return {
'scroll-up': true,
'scroll-up--visible': this.buttonVisible,
};
}
}
@@ -0,0 +1,40 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { ActivatedRoute, UrlSegment } from '@angular/router';
@Component({
selector: 'same-url-navigation-detector',
template: '<ng-container></ng-container>',
})
export class SameUrlNavigationDetectorComponent implements OnInit {
private segments: UrlSegment[] = [];
@Output() sameUrlNavigation = new EventEmitter<void>();
constructor(protected route: ActivatedRoute) {}
ngOnInit(): void {
this.observeSameUrlNavigation();
}
private observeSameUrlNavigation() {
this.route.url.subscribe({
next: (segments) => {
if (this.areSegmentsEqual(segments)) {
this.sameUrlNavigation.emit();
}
this.segments = segments;
},
});
}
private areSegmentsEqual(newSegments: UrlSegment[]) {
if (this.segments.length !== newSegments.length) {
return false;
}
return this.segments.every((segment, index) => {
return segment.path === newSegments[index].path;
});
}
}
@@ -0,0 +1,6 @@
<div class="row-city">SU {{ params.flightNumber }}{{ params.suffix }}</div>
<div class="description-row">
<span class="description">
{{ params.date | aflDate }}
</span>
</div>
@@ -0,0 +1,16 @@
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
import { IParsedFlightId } from '@typings/flight/flight-id';
@Component({
selector: 'online-board-flight-number-history-item',
templateUrl: './online-board-flight-number-history-item.component.html',
encapsulation: ViewEncapsulation.None,
})
export class OnlineBoardFlightNumberHistoryItemComponent {
@Input() item: ISearchHistoryItem;
get params() {
return this.item.params as IParsedFlightId;
}
}
@@ -0,0 +1,8 @@
<online-board-route-history-item
*ngIf="!isFlightNumberSearch"
[item]="item"
></online-board-route-history-item>
<online-board-flight-number-history-item
*ngIf="isFlightNumberSearch"
[item]="item"
></online-board-flight-number-history-item>
@@ -0,0 +1,18 @@
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
import { IParsedFlightId } from '@typings/flight/flight-id';
@Component({
selector: 'online-board-history-item',
templateUrl: './online-board-history-item.component.html',
encapsulation: ViewEncapsulation.None,
})
export class OnlineBoardHistoryItemComponent {
@Input() item: ISearchHistoryItem;
get isFlightNumberSearch(): boolean {
const params = this.item.params as IParsedFlightId;
return !!params.flightNumber;
}
}
@@ -0,0 +1,20 @@
<div class="row-city" *ngIf="arrival && departure">
{{ departure | cityName }} —
{{ arrival | cityName }}
</div>
<div class="row-city" *ngIf="arrival && !departure">
<span class="description">{{ 'BOARD.ARRIVAL' | translate }}</span>
{{ arrival | cityName }}
</div>
<div class="row-city" *ngIf="!arrival && departure">
<span class="description">{{ 'BOARD.DEPARTURE' | translate }}</span>
{{ departure | cityName }}
</div>
<div class="description-row">
<span class="description">
{{ date | aflDate }}
</span>
<span class="description" *ngIf="timeRange">
&nbsp;<span>{{ timeRange | timeRange }}</span>
</span>
</div>
@@ -0,0 +1,39 @@
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { IOnlineBoardRoutePageUrlParams } from '@online-board/services/url/types';
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
import { IUrlTimeRange } from '@typings/common/url';
@Component({
selector: 'online-board-route-history-item',
templateUrl: './online-board-route-history-item.component.html',
encapsulation: ViewEncapsulation.None,
})
export class OnlineBoardRouteHistoryItemComponent {
@Input() item: ISearchHistoryItem;
get params() {
return this.item.params as IOnlineBoardRoutePageUrlParams;
}
get arrival() {
return this.params.arrival;
}
get departure() {
return this.params.departure;
}
get date() {
return this.params.date;
}
get timeRange(): IUrlTimeRange {
const { timeTo, timeFrom } = this.params;
return timeFrom && timeTo
? {
timeFrom,
timeTo,
}
: undefined;
}
}
@@ -0,0 +1,18 @@
<div class="description-row">
<span class="description">{{ title }}</span>
</div>
<div class="row-city">
{{ departure | cityName }} —
{{ arrival | cityName }}
</div>
<div class="description-row">
<span class="description">
{{ params.outbound.dateFrom | aflDate }} -
{{ params.outbound.dateTo | aflDate }}
<ng-container *ngIf="params.inbound">&nbsp;/&nbsp;</ng-container>
</span>
<span class="description" *ngIf="params.inbound">
{{ params.inbound.dateFrom | aflDate }} -
{{ params.inbound.dateTo | aflDate }}
</span>
</div>
@@ -0,0 +1,51 @@
import { Component, Input, OnChanges, ViewEncapsulation } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { IScheduleRouteParams } from '@schedule/services/url/types';
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
@Component({
selector: 'schedule-history-item',
templateUrl: './schedule-history-item.component.html',
encapsulation: ViewEncapsulation.None,
})
export class ScheduleHistoryItemComponent implements OnChanges {
@Input() item: ISearchHistoryItem;
title: string;
constructor(private translate: TranslateService) {}
ngOnChanges() {
this.title = this.getTitle();
}
get params() {
return this.item.params as IScheduleRouteParams;
}
get arrival() {
return this.params.outbound.arrival;
}
get departure() {
return this.params.outbound.departure;
}
getTitle(): string {
const { outbound, inbound } = this.params;
let result = this.translate.instant('SCHEDULE.TITLE');
if (!inbound) {
result += ', ' + this.translate.instant('SHARED.PART_ONE_WAY');
} else {
result += ', ' + this.translate.instant('SHARED.THERE_AND_BACK');
}
if (outbound.connections === 0) {
result += ', ' + this.translate.instant('SHARED.ONLY_DIRECT');
}
return result;
}
}
@@ -0,0 +1,26 @@
<div class="row search-history-item">
<div class="row-icon search-history-item__icon">
<plane-icon
*ngIf="item.type !== 'schedule-route'"
pTooltip="{{ 'SHARED.LAST-SEARCH-BOARD' | translate }}"
tooltipPosition="top"
tooltipStyleClass="afl-tooltip"
></plane-icon>
<alarm-clock-icon
*ngIf="item.type === 'schedule-route'"
pTooltip="{{ 'SHARED.LAST-SEARCH-SCHEDULE' | translate }}"
tooltipPosition="top"
tooltipStyleClass="afl-tooltip"
></alarm-clock-icon>
</div>
<div class="row-data search-history-item__data">
<schedule-history-item
*ngIf="item.type === 'schedule-route'"
[item]="item"
></schedule-history-item>
<online-board-history-item
*ngIf="item.type !== 'schedule-route'"
[item]="item"
></online-board-history-item>
</div>
</div>
@@ -0,0 +1,29 @@
@use 'src/styles/colors';
.search-history-item {
display: flex !important;
&:hover .search-history-item__icon svg {
fill: colors.$blue;
}
&__icon {
display: flex !important;
align-items: center;
flex-grow: 0;
}
&__data {
display: flex !important;
flex-direction: column;
flex-grow: 1;
.description-row {
flex-wrap: wrap;
}
.description {
white-space: normal;
}
}
}
@@ -0,0 +1,12 @@
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { ISearchHistoryItem } from '@shared/services/history/search-history.service';
@Component({
selector: 'search-history-item',
templateUrl: './search-history-item.component.html',
styleUrls: ['./search-history-item.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class SearchHistoryItemComponent {
@Input() item: ISearchHistoryItem;
}
@@ -0,0 +1,19 @@
<section class="frame search-history" *ngIf="historyItems.length">
<p-accordion expandIcon="" collapseIcon="">
<p-accordionTab>
<p-header>
{{ 'BOARD.YOU_SEARCH' | translate }}
<arrow-down-icon></arrow-down-icon>
</p-header>
<div class="search-history__content">
<div
*ngFor="let item of historyItems"
(click)="openHistoryUrl(item)"
>
<search-history-item [item]="item"></search-history-item>
</div>
</div>
</p-accordionTab>
</p-accordion>
</section>
@@ -0,0 +1,21 @@
@use "src/styles/variables" as *;
@use "src/styles/screen" as *;
.search-history {
margin: $space-xl 0;
@include tablets() {
margin-bottom: 0;
}
@include mobile() {
margin-top: $space-m;
margin-bottom: 0;
}
&__content {
max-height: 600px;
overflow-y: scroll;
}
}
@@ -0,0 +1,37 @@
import { Component, Inject, ViewEncapsulation } from '@angular/core';
import { Router } from '@angular/router';
import {
FlightRequestType,
ViewType,
} from '@shared/enumerators/flight-request-type.enum';
import { AppSettings } from '@shared/models-legacy';
import { APP_SETTINGS } from '@shared/services';
import {
ISearchHistoryItem,
SearchHistoryService,
} from '@shared/services/history/search-history.service';
@Component({
selector: 'search-history',
templateUrl: './search-history.component.html',
styleUrls: ['./search-history.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class SearchHistoryComponent {
ViewType = ViewType;
FlightRequestType = FlightRequestType;
constructor(
@Inject(APP_SETTINGS) public settings: AppSettings,
private historyService: SearchHistoryService,
private router: Router,
) {}
get historyItems() {
return this.historyService.items;
}
openHistoryUrl(item: ISearchHistoryItem) {
return this.router.navigateByUrl(item.url);
}
}
@@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { SearchHistoryComponent } from '@components/search-history/search-history.component';
import { PipesModule } from '@shared/pipes/pipes.module';
import { ToolkitModule } from '@toolkit/toolkit.module';
import { AccordionModule } from 'primeng/accordion';
import { OnlineBoardHistoryItemComponent } from './components/online-board-item/online-board-history-item.component';
import { ScheduleHistoryItemComponent } from './components/schedule-item/schedule-history-item.component';
import { SearchHistoryItemComponent } from './components/search-history-item/search-history-item.component';
import { OnlineBoardRouteHistoryItemComponent } from './components/online-board-route-history-item/online-board-route-history-item.component';
import { OnlineBoardFlightNumberHistoryItemComponent } from './components/online-board-flight-number-history-item/online-board-flight-number-history-item.component';
@NgModule({
declarations: [
SearchHistoryComponent,
OnlineBoardHistoryItemComponent,
ScheduleHistoryItemComponent,
SearchHistoryItemComponent,
OnlineBoardRouteHistoryItemComponent,
OnlineBoardFlightNumberHistoryItemComponent,
],
exports: [SearchHistoryComponent],
imports: [CommonModule, ToolkitModule, AccordionModule, PipesModule],
})
export class SearchHistoryModule {}
@@ -0,0 +1,68 @@
import { Component, Input, OnChanges } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { RouteType } from '@typings/enums';
import { IFlight } from '@typings/flight/flight';
import { UrlBuilderService } from '@shared/services/url/url-builder.service';
import { getFlightArrivalCity } from '@utils/flight/arrival/city';
import { getFlightDepartureCity } from '@utils/flight/departure/city';
@Component({
selector: 'details-title-base',
template: '',
})
export abstract class DetailsTitleBase implements OnChanges {
@Input() flight: IFlight;
title: string;
protected constructor(
protected translatePipe: TranslatePipe,
protected urlService: UrlBuilderService,
) {}
ngOnChanges() {
this.title = this.translate();
}
protected abstract get titleKey(): string;
protected abstract get pluralTitleKey(): string;
protected abstract translatePluralTitle(): string;
protected abstract translateTitle(): string;
protected get base() {
return this.isConnecting
? this.translatePipe.transform(this.pluralTitleKey)
: this.translatePipe.transform(this.titleKey);
}
protected get flightInfo() {
if (this.flight.routeType !== RouteType.CONNECTING) {
return this.urlService.formatFlightId(this.flight.flightId);
}
return this.flight.flights
.map((flight) => this.urlService.formatFlightId(flight.flightId))
.join(', ');
}
protected get departure() {
return getFlightDepartureCity(this.flight);
}
protected get arrival() {
return getFlightArrivalCity(this.flight);
}
private translate(): string {
if (!this.flight) {
return '';
}
return this.isConnecting
? this.translatePluralTitle()
: this.translateTitle();
}
private get isConnecting() {
return this.flight.routeType === RouteType.CONNECTING;
}
}
@@ -0,0 +1,8 @@
<div class="map-wrapper">
<div id="map" class="map"></div>
<loader-sheet *ngIf="isLoading"></loader-sheet>
<no-directions-sheet
*ngIf="isNoDirections && !isLoading"
(dismiss)="hideNoDirections()">
</no-directions-sheet>
</div>
@@ -0,0 +1,12 @@
.map-wrapper {
position: relative;
height: 800px;
width: 100%;
}
.map {
height: 100%;
width: 100%;
}
@@ -0,0 +1,780 @@
import { AfterViewInit, Component, OnInit } from '@angular/core';
import { DictionariesService } from '@app/modules/components/page-filters/services/dictionaries-service';
import { FlightsMapFiltersStateService, IFlightsMapFilterState } from '@app/shared/services/filters/flights-map-filters-state.service';
import { environment } from '@environment';
import * as L from 'leaflet';
import { from, of, Subject, Subscription } from 'rxjs';
import { catchError, distinctUntilChanged, finalize, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { FlightsMapApiService, IDestinationsRequestParams } from '../../services/flights-map-api.service';
import { CityCategoryService } from '../../services/category-city.service';
import { IDestinationsResponse } from '@typings/responses';
import * as moment from 'moment';
import { CityModel } from '@app/modules/components/page-filters/models';
import { IDestinationResponse } from '../../../../../typings/responses';
import { airports } from '@app/shared/services';
export const markerBlue = L.icon({
iconUrl: 'assets/img/leaflet/marker-blue.png',
iconSize: [15, 15],
iconAnchor: [5, 5],
popupAnchor: [0, -10]
});
export const markerBlueSmall = L.icon({
iconUrl: 'assets/img/leaflet/marker-blue-small.png',
iconSize: [11, 11],
iconAnchor: [5, 5],
popupAnchor: [0, -10]
});
export const markerOrange = L.icon({
iconUrl: 'assets/img/leaflet/marker-orange.png',
iconSize: [20, 20],
iconAnchor: [10, 10],
popupAnchor: [0, -20]
});
const directRoutePolyLine : L.PolylineOptions =
{
color : '#2457ff',
weight : 1,
opacity: 1
}
const dashRoutePolyLine: L.PolylineOptions =
{
color : '#2433ff',
weight : 1,
opacity : 1,
dashArray: '4 14'
}
type CountryType = 'ru' | 'other';
@Component({
selector: 'flights-map-body',
templateUrl: './flights-map-body.component.html',
styleUrls: ['./flights-map-body.component.scss']
})
export class FlightsMapBodyComponent implements OnInit, AfterViewInit {
private currentFilterState: IFlightsMapFilterState;
private map!: L.Map
/** код города ИЛИ код аэропорта → маркер */
private markerIndex = new Map<string, L.Marker>();
private airportToCityCode = new Map<string, string>();
private highlighted: Set<L.Marker> = new Set<L.Marker>();
private highlightedLayer: L.LayerGroup<L.Marker>;
private zoomLayers: Record<CountryType, Record<number, L.LayerGroup>> = { ru: {}, other: {} };
private departurePopup?: L.Popup;
private routePopup?: L.Popup;
private destinationsSub?: Subscription;
private destinationsLayer: L.LayerGroup;
private destinations: IDestinationsResponse = { data : { routes: []} };
private skipNextFetchOnce = false; //флаг для пропуска повторного запроса при синхронизации UI-фильтра
private destroy$ = new Subject<void>()
isLoading = true;
isNoDirections: boolean;
constructor(
private dictService: DictionariesService,
private cityCategoryService: CityCategoryService,
private filterStateService: FlightsMapFiltersStateService,
private apiService: FlightsMapApiService
) { }
async ngOnInit(): Promise<void> {
}
async ngAfterViewInit(): Promise<void> {
await this.dictService.ready$;
this.initMap();
this.initMarkers();
this.watchRouteChanges();
this.watchDateChanges();
this.isLoading = false;
}
private initMap() {
const baseMapURl = environment.mapApiUrl;
const southWest: L.LatLngExpression = [ -70, -185 ];
const northEast: L.LatLngExpression = [ 80, 200 ];
this.map = L.map('map',
{
center: [53, 45],
zoom: 5,
attributionControl: false,
maxBounds: [ southWest, northEast ],
maxBoundsViscosity: 1
});
[2, 3, 4, 5, 6].forEach(z => {
this.zoomLayers.ru[z] = L.layerGroup();
this.zoomLayers.other[z] = L.layerGroup();
});
L.tileLayer(baseMapURl, {
maxZoom: 6,
minZoom: 3,
}).addTo(this.map);
this.destinationsLayer = L.layerGroup().addTo(this.map);
this.highlightedLayer = L.layerGroup().addTo(this.map);
}
private initMarkers() {
this.dictService.citiesAll.forEach(city => {
if (city.location?.lat == null || city.location?.lon == null) {
return;
}
const marker = L.marker(
[city.location.lat, city.location.lon],
{
icon: markerBlueSmall,
title: city.code,
}
)
.on('click', ()=> this.handleMarkerClick(city.code))
.bindTooltip(city.name, {
permanent : true,
direction : 'top',
className : 'city-label'
});
const countryType: CountryType = city.country_code === 'RU' ? 'ru' : 'other';
const zMin = this.cityCategoryService.zoomLevel(city.code);
this.zoomLayers[countryType][zMin].addLayer(marker);
this.markerIndex.set(city.code, marker);
});
this.dictService.airportsAll?.forEach(airport => {
const cityMarker = this.markerIndex.get(airport.city_code);
if (!cityMarker) return;
this.markerIndex.set(airport.code, cityMarker);
this.airportToCityCode.set(airport.code, airport.city_code);
});
this.updateVisibility();
this.map.on('zoomend', () => this.updateVisibility());
}
private getMarkerByAnyCode(code: string): L.Marker | undefined {
return this.markerIndex.get(code);
}
private updateHighlight(state: IFlightsMapFilterState): void {
this.highlighted.forEach(marker =>
{
marker.setIcon(markerBlueSmall);
const code = (marker as any).options?.title as string;
const zlvl = this.cityCategoryService.zoomLevel(code);
const countryType = this.dictService.getCityByCode(code).country_code === 'RU' ? 'ru': 'other';
this.moveBetweenLayer(marker, this.highlightedLayer, this.zoomLayers[countryType][zlvl]);
});
this.highlighted.clear();
this.highlightedLayer.clearLayers();
const codes = [state.departure, state.arrival].filter(Boolean) as string[];
codes.forEach(code => {
const marker = this.getMarkerByAnyCode(code);
if (!marker) { return; }
marker.setIcon(markerOrange);
this.highlighted.add(marker);
const zLvl = this.cityCategoryService.zoomLevel(code);
const countryType = this.dictService.getCityByCode(code).country_code === 'RU' ? 'ru': 'other';
this.moveBetweenLayer(marker, this.zoomLayers[countryType][zLvl], this.highlightedLayer);
});
this.updateVisibility();
}
private updateVisibility(): void
{
this.updateMarkers();
this.updateHighlightedTooltips();
// if(this.currentFilterState){
// this.fetchAndDraw(this.currentFilterState);
// }
}
private updateMarkers() {
const z = this.map.getZoom();
(['ru', 'other'] as const).forEach(countryType => {
Object.entries(this.zoomLayers[countryType]).forEach(([lvl, layer]) =>
{
const layerShouldBeVisible = +lvl <= z;
if(countryType === 'ru')
{
if(!this.currentFilterState?.international && layerShouldBeVisible)
{
this.map.addLayer(layer);
}
else {
this.map.removeLayer(layer);
}
}
else
{
if(!this.currentFilterState?.domestic && layerShouldBeVisible)
{
this.map.addLayer(layer);
}
else {
this.map.removeLayer(layer);
}
}
});
});
}
private updateHighlightedTooltips() {
const z = this.map.getZoom();
if(z <= 3)
{
this.markerIndex.forEach((m) => {
if(this.highlighted.has(m))
{
return;
}
m.closeTooltip();
});
}
else if(this.highlighted.size >= 2)
{
this.markerIndex.forEach((m) => {
if(this.highlighted.has(m))
{
return;
}
m.closeTooltip();
});
}
else
{
this.markerIndex.forEach((m) => {
m.openTooltip();
});
}
}
private updateIntermediateTooltip() {
const routes = this.destinations?.data.routes;
if(!routes) {return;}
for (let i = 0; i < routes.length; i++)
{
const route = routes[i].route;
if(route.length <= 2){ continue; }
for (let cityIndex = 1; cityIndex < route.length - 1; cityIndex++)
{
const intermediateCityCode = route[cityIndex];
const intermediateCityMarker = this.getMarkerByAnyCode(intermediateCityCode);
if(!intermediateCityMarker) {continue;}
intermediateCityMarker.openTooltip();
}
}
}
private watchRouteChanges() {
this.filterStateService.state$
.pipe(
distinctUntilChanged(
(a, b) => a.departure === b.departure &&
a.arrival === b.arrival &&
a.connections === b.connections &&
a.domestic === b.domestic &&
a.international === b.international
),
takeUntil(this.destroy$),
tap(_=> this.isLoading = true)
)
.subscribe(state => {
this.currentFilterState = state;
this.updateHighlight(state);
this.hideNoDirections();
if (this.skipNextFetchOnce) {
this.skipNextFetchOnce = false; // пропускаем ровно один раз
return;
}
this.fetchAndDraw(state);
}
);
}
private watchDateChanges() {
this.filterStateService.state$
.pipe(
distinctUntilChanged(
(a, b) => a.date === b.date
),
takeUntil(this.destroy$)
)
.subscribe(state => {
this.currentFilterState = state;
if(this.destinations?.data?.routes?.length > 0)
{
this.showRoutePopup(this.destinations.data.routes);
}
}
);
}
private handleMarkerClick(cityCode: string) : void
{
if(!this.currentFilterState.departure)
{
this.filterStateService.setDeparture(cityCode);
}
else if(this.currentFilterState.departure && !this.currentFilterState.arrival)
{
if (cityCode !== this.currentFilterState.departure)
{
this.filterStateService.setArrival(cityCode);
}
}
else
{
this.filterStateService.setDeparture(cityCode);
this.filterStateService.setArrival(undefined);
}
}
private fetchAndDraw(state: IFlightsMapFilterState)
{
this.clearRoutes();
this.clearPopup();
const dateFrom = new Date();
dateFrom.setDate(dateFrom.getDate() - 1);
dateFrom.setHours(0, 0, 0, 0);
const dateTo = new Date();
dateTo.setMonth(dateFrom.getMonth() + 6);
dateTo.setHours(0, 0, 0, 0);
if((state.departure && state.arrival) && (state.departure != state.arrival))
{
this.fetchAndDrawRoute(state.departure, state.arrival, dateFrom, dateTo, state.connections? 1: 0);
}
else if(state.departure && !state.arrival)
{
this.fetchAndDrawSpider(state.departure, dateFrom, dateTo);
}else
{
this.isLoading = false;
}
}
private fetchAndDrawRoute(departure: string, arrival: string, dateFrom: Date, dateTo: Date, connections: number) {
const base: IDestinationsRequestParams = {
departure: departure,
arrival: arrival,
dateFrom: dateFrom,
dateTo: dateTo,
connections: connections
};
const withConn1: IDestinationsRequestParams = { ...base, connections: 1 };
this.destinationsSub = this.apiService.getDestinations(base).pipe(
switchMap(first => {
const hasRoutes = !!first?.data?.routes?.length;
if (hasRoutes || base.connections === 1) {
return of({ res: first, usedConn: base.connections ?? 0 });
}
// второй заход с пересадками
return this.apiService.getDestinations(withConn1).pipe(
map((second: IDestinationsResponse) => {
const hasSecond = !!second?.data?.routes?.length;
return {
res: hasSecond ? second : first,
usedConn: hasSecond ? 1 : 0
};
})
);
}),
tap(({ usedConn }) => {
// если фолбэк (показывать с пересадкой) дал маршруты и в UI ещё выключены пересадки — включим их
this.isLoading = true;
if (usedConn === 1 && !this.currentFilterState.connections) {
this.skipNextFetchOnce = true; // не дёргать повторно fetch
this.filterStateService.setConnections(true); // обновим UI-состояние
}
}),
map(res => this.filterRoutes(res.res)),
catchError(() => of<IDestinationsResponse>({ data: { routes: [] } })),
takeUntil(this.destroy$),
finalize(() => (this.isLoading = false))
)
.subscribe(destinations => {
this.destinations = destinations;
this.buildRoute(destinations);
this.updateIntermediateTooltip();
});
}
private fetchAndDrawSpider(cityFromCode: string, dateFrom: Date, dateTo: Date)
{
const params: IDestinationsRequestParams = {
departure : cityFromCode,
dateFrom: dateFrom,
dateTo: dateTo
};
this.currentFilterState.connections = false;
this.destinationsSub = this.getDestinations(params)
.pipe(
finalize(() => (this.isLoading = false))
)
.subscribe(destinations =>
this.buildSpider(destinations)
);
}
private getDestinations(params: IDestinationsRequestParams) {
return this.apiService.getDestinations(params).pipe(
catchError(() => of<IDestinationsResponse>({
data: { routes: [] }
}))
);
}
private filterRoutes(res: IDestinationsResponse): IDestinationsResponse {
const routes = res?.data?.routes ?? [];
const { domestic, international, connections } = this.currentFilterState;
const isDomestic = (r: { route: string[] }) =>
r.route.every(code => this.dictService.ruCitiesCodes.has(this.dictService.getCityCodeByAirportCode(code)));
const isInternational = (r: { route: string[] }) =>
r.route.some(code => this.dictService.otherCitiesCodes.has(this.dictService.getCityCodeByAirportCode(code)));
const hasConnections = (r: { isDirect: boolean }) => !r.isDirect;
const predicates: Array<(r: any) => boolean> = [];
if (domestic && !international) {
predicates.push(isDomestic);
} else if (international && !domestic) {
predicates.push(isInternational);
}
if (connections) {
predicates.push(hasConnections);
}
const filtered = predicates.length
? routes.filter(r => predicates.every(p => p(r)))
: routes;
return {
...res,
data: {
...res.data,
routes: filtered
}
};
}
private buildRoute(res: IDestinationsResponse): void
{
const routes = res.data?.routes ?? [];
if(routes.length == 0)
{
this.isNoDirections = true;
}
if (!routes.length) { return; }
let line : L.Polyline;
routes.filter(_ => _.isDirect).forEach(path => {
this.drawPolyline(path.route, directRoutePolyLine, this.destinationsLayer);
});
routes.filter(_ => !_.isDirect).forEach(path => {
this.drawPolyline(path.route, dashRoutePolyLine, this.destinationsLayer);
});
this.showRoutePopup(routes);
}
private buildSpider(res: IDestinationsResponse): void
{
const routes = res.data?.routes ?? [];
if (!routes.length) { return; }
const fromCode = routes[0].route[0];
const destCodes = new Set<string>();
routes.forEach(path => {
if (Array.isArray(path.route) && path.route.length > 1)
{
const dest = path.route[path.route.length - 1];
if (dest !== fromCode) { destCodes.add(dest); }
}
});
destCodes.forEach(code => {
this.drawPolyline([fromCode, code], directRoutePolyLine, this.destinationsLayer);
});
}
private drawPolyline(cities: string[], style: L.PolylineOptions, target: L.LayerGroup | L.Map = this.map): L.Polyline | undefined
{
const segments: L.LatLng[] = [];
const visibleCities = cities.filter(code => {
const m = this.getMarkerByAnyCode(code);
return m && this.map.hasLayer(m);
});
if (visibleCities.length < 2) { return; }
for (let i = 0; i < visibleCities.length - 1; i++) {
const from = this.getMarkerByAnyCode(visibleCities[i])!;
const to = this.getMarkerByAnyCode(visibleCities[i + 1])!;
const arc = this.buildGreatCircle(from.getLatLng(), to.getLatLng());
segments.push(...(i === 0 ? arc : arc.slice(1)));
}
return L.polyline(segments, style).addTo(target);
}
private clearRoutes()
{
this.destinationsSub?.unsubscribe();
this.destinationsLayer?.clearLayers();
this.destinations = {data: {routes:[]}};
}
private clearPopup(){
if (this.routePopup) {
this.map.removeLayer(this.routePopup);
this.routePopup = undefined;
}
if(this.departurePopup){
this.map.removeLayer(this.departurePopup);
this.departurePopup = undefined;
}
}
private showRoutePopup(rotues: IDestinationResponse[]): void {
const firstRoute = rotues[0].route;
const departureCode = firstRoute[0];
const arrivalCode = firstRoute[firstRoute.length -1];
const markerDeparture = this.getMarkerByAnyCode(departureCode);
const markerArrival = this.getMarkerByAnyCode(arrivalCode);
if (!markerArrival) { return; }
const cityDeparture = this.dictService.getCityByCode(this.airportToCityCode.get(departureCode));
const cityArrival = this.dictService.getCityByCode(this.airportToCityCode.get(arrivalCode));
if(!cityArrival) { return; }
this.clearPopup();
const linkUrl = this.getLink();
//ToDo: translate
const buyTicketText = 'Купить билет';
const htmlDeparture = `
<div class="popup-header-test">
<span>${cityDeparture.name}</span>
</div>
`;
const htmlInermediate = `
<div class="popup-header-test">
<span>${cityDeparture.name}</span>
</div>
`;
const htmlArrival = `
<div class="popup-header-test">
<span>${cityArrival.name}</span>
</div>
<div style="text-align:center;">
<a href="${linkUrl}" target="_blank" class="popup-buy-ticket">
${buyTicketText}
</a>
</div>
`;
this.departurePopup = L.popup({
closeButton: true,
autoClose: false,
closeOnClick: false
})
.setLatLng(markerDeparture.getLatLng())
.setContent(htmlDeparture)
.openOn(this.map);
this.routePopup = L.popup({
closeButton: true,
autoClose: false,
closeOnClick: false
})
.setLatLng(markerArrival.getLatLng())
.setContent(htmlArrival)
.openOn(this.map);
}
private moveBetweenLayer(marker: L.Marker, from: L.LayerGroup, to: L.LayerGroup)
{
if (from?.hasLayer(marker)) {
from.removeLayer(marker);
}
if (!to?.hasLayer(marker)) {
to.addLayer(marker);
}
}
private buildGreatCircle(from: L.LatLng, to: L.LatLng, segments = 64): L.LatLng[]
{
const φ1 = this.deg2rad(from.lat);
const λ1 = this.deg2rad(from.lng);
const φ2 = this.deg2rad(to.lat);
const λ2 = this.deg2rad(to.lng);
const Δ = 2 * Math.asin(
Math.sqrt(
Math.sin((φ2 - φ1) / 2) ** 2 +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin((λ2 - λ1) / 2) ** 2
)
);
if (Δ === 0) { return [from, to]; } // совпадают
const points: L.LatLng[] = [];
for (let i = 0; i <= segments; i++)
{
const f = i / segments;
const A = Math.sin((1 - f) * Δ) / Math.sin(Δ);
const B = Math.sin(f * Δ) / Math.sin(Δ);
const x =
A * Math.cos(φ1) * Math.cos(λ1) +
B * Math.cos(φ2) * Math.cos(λ2);
const y =
A * Math.cos(φ1) * Math.sin(λ1) +
B * Math.cos(φ2) * Math.sin(λ2);
const z =
A * Math.sin(φ1) +
B * Math.sin(φ2);
const φi = Math.atan2(z, Math.sqrt(x * x + y * y));
const λi = Math.atan2(y, x);
points.push(L.latLng(this.rad2deg(φi), this.rad2deg(λi)));
}
return points;
}
private deg2rad(deg: number): number { return (deg * Math.PI) / 180; }
private rad2deg(rad: number): number { return (rad * 180) / Math.PI; }
private getLink(): string {
const state = this.currentFilterState;
const d = new Date();
d.setHours(0,0,0,0);
const date = moment(state.date ?? d).format('YYYYMMDD');
const params = `${state.departure}.${date}.${state.arrival}`;
return `https://www.aeroflot.ru/sb/app/ru-ru#/search?adults=1&cabin=economy&children=0&infants=0&routes=${params}&autosearch=Y&utm_source=aflwebbot&utm_medium=referral&utm_campaign=ref_3015_general_rf_button.index__all_flight.map`;
}
hideNoDirections(): void {
this.isNoDirections = false;
}
}
@@ -0,0 +1,83 @@
<section>
<p-accordion expandIcon="" collapseIcon="" [activeIndex]="0">
<p-accordionTab [selected]="true" [disabled]="true">
<div class="flights-map-filter-content">
<div class="flights-map-filter-header">
<h3>{{ 'FLIGHTS-MAP.ROUTE' | translate }}</h3>
</div>
<div class="flights-map-filter-content-cities">
<city-autocomplete
label="SHARED.DEPARTURE_CITY"
[(ngModel)]="departure"
[placeholder]="departurePlaceholder"
data-testid="route-departure-city-input">
</city-autocomplete>
<div class="change-container">
<button
class="button-change"
pButton
type="button"
(click)="exchange()">
<svg class="svg--change-city">
<use xlink:href="/assets/img/sprite.svg#changeCity"/>
</svg>
</button>
</div>
<city-autocomplete
label="SHARED.ARRIVAL_CITY"
[(ngModel)]="arrival"
[placeholder]="arrivalPlaceholder"
data-testid="route-arrival-city-input"
></city-autocomplete>
</div>
<div class="flights-map-filter-info">
<p>{{'FLIGHTS-MAP.FILTER_INFO' | translate}}</p>
</div>
<div class="flights-map-filter-content-checkboxes">
<toggle-switch
[disabled]="departure ? false : true"
[(ngModel)]="domestic"
label="{{ 'FLIGHTS-MAP.DOMESTIC_FLIGHTS' | translate }}">
</toggle-switch>
<toggle-switch
[disabled]="departure ? false : true"
[(ngModel)]="international"
label="{{ 'FLIGHTS-MAP.INTERNATIONAL_FLIGHTS' | translate }}">
</toggle-switch>
<toggle-switch
[disabled]="departure && arrival ? false : true"
[(ngModel)]="connections"
label="{{ 'FLIGHTS-MAP.CONNECTING_FLIGHTS' | translate }}">
</toggle-switch>
</div>
<div class="flighs-map-filter-date">
<calendar-input
label="SHARED.FLIGHT_DATE"
[(ngModel)]="date"
[minDate]="minDate"
[maxDate]="maxDate"
[disabledDates]="disabledDates"
data-testid="route-calendar-input"
>
</calendar-input>
</div>
</div>
</p-accordionTab>
</p-accordion>
</section>
@@ -0,0 +1,22 @@
.flights-map-filter-header{
padding: 10px 0;
}
.flights-map-filter-info{
padding: 10px 0;
}
.mt2{
margin-top: 20px;
}
// .svg--change-city {
// transform: rotate(180deg) !important;
// }
// .change-container {
// justify-content: right !important;
// }
@@ -0,0 +1,154 @@
import { Component, OnInit, Input, OnDestroy, ChangeDetectorRef, Inject } from '@angular/core';
import { ScheduleFilterValidationService } from '@app/features/schedule/components/schedule-filter/services/schedule-filter-validation.service';
import { UserLocationService } from '@app/shared/services/user-location/user-location.service';
import { FlightsMapFiltersStateService, IFlightsMapFilterState } from '@shared/services/filters/flights-map-filters-state.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AppSettings } from '@shared/models-legacy';
import { APP_SETTINGS } from '@shared/services';
import { OnlineBoardApiService } from "@app/features/online-board/services/api.service";
import { FlightsMapApiService } from '../../services/flights-map-api.service';
@Component({
selector: 'flights-map-filter',
templateUrl: './flights-map-filter.component.html',
styleUrls: ['./flights-map-filter.component.scss'],
providers: [ScheduleFilterValidationService],
})
export class FlightsMapFilterComponent implements OnInit, OnDestroy {
private currentFilterState: IFlightsMapFilterState;
withReturn: boolean;
private destroy$ = new Subject<void>();
constructor(
public validationService: ScheduleFilterValidationService,
private apiService: FlightsMapApiService,
@Inject(APP_SETTINGS) public settings: AppSettings,
private filterStateService: FlightsMapFiltersStateService,
private locationService: UserLocationService,
private cdr : ChangeDetectorRef
) { }
ngOnInit() {
this.filterStateService.state$
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.currentFilterState = state;
this.cdr.markForCheck();
});
this.locationService.location.subscribe((location) => {
// set default state only if user permitted geo position
// search and filter isn't filled
if (location && !this.departure && !this.arrival) {
this.filterStateService.setDeparture(location);
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get departure() {
return this.currentFilterState.departure;
}
set departure(departure: string)
{
this.filterStateService.setDeparture(departure);
}
get arrival()
{
return this.currentFilterState.arrival;
}
set arrival(arrival: string){
this.filterStateService.setArrival(arrival);
}
get connections()
{
return this.currentFilterState.connections;
}
set connections(showConenctions: boolean)
{
this.filterStateService.setConnections(showConenctions);
}
get domestic()
{
return this.currentFilterState.domestic;
}
set domestic(showDomestic: boolean)
{
this.filterStateService.setDomestic(showDomestic);
}
get international()
{
return this.currentFilterState.international;
}
set international(showInternational: boolean)
{
this.filterStateService.setInternational(showInternational);
}
get date()
{
return this.currentFilterState.date;
}
set date(date: Date)
{
this.filterStateService.setDate(date);
}
get departurePlaceholder() {
return 'FLIGHTS-MAP.FILTER_DEPARTURE_PLACEHOLDER'
}
get arrivalPlaceholder() {
return 'FLIGHTS-MAP.FILTER_ARRIVAL_PLACEHOLDER'
}
get minDate(){
return this.currentFilterState.minDate;
}
get maxDate(){
return this.currentFilterState.maxDate;
}
get disabledDates()
{
return this.currentFilterState.disabledDates;
}
set disabledDates(disabledDates: Date[]){
this.filterStateService.setDisabledDates(disabledDates);
}
exchange() {
[
this.validationService.departureError,
this.validationService.arrivalError,
] = [null, null];
[this.departure, this.arrival] = [this.arrival, this.departure];
}
resetReturnDateRange() {
throw new Error('Method not implemented.');
}
}
@@ -0,0 +1,5 @@
<meta-tags
[title]="'SEO.FLIGHTS-MAP.MAIN.TITLE' | translate"
[description]="'SEO.FLIGHTS-MAP.MAIN.DESCRIPTION' | translate"
[noRobots]="false"
></meta-tags>
@@ -0,0 +1,8 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'flights-map-meta-tags',
templateUrl: './flights-map-meta-tags.component.html',
styleUrls: ['./flights-map-meta-tags.component.scss']
})
export class FlightsMapMetaTagsComponent{}
@@ -0,0 +1 @@
<aero-title [title]="title | translate"></aero-title>

Some files were not shown because too many files have changed in this diff Show More