rebasing
This commit is contained in:
21
src/app/api.ts
Normal file
21
src/app/api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { isDevMode } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Endpoint constants for the Fastcheck backend (see public/api.txt).
|
||||
* Centralised so they can be swapped in one place.
|
||||
* In dev mode (ng serve) requests go through the Angular proxy (proxy.conf.json)
|
||||
* to avoid CORS issues. In production the real URLs are used.
|
||||
*/
|
||||
export const FASTCHECK_API = isDevMode()
|
||||
? '/proxy/fastcheck'
|
||||
: 'https://api.fastcheck.store';
|
||||
|
||||
// Legacy QR endpoint kept for the SBP amount → payload redirect flow.
|
||||
export const QR_API = isDevMode()
|
||||
? '/proxy/legacy-qr/qr'
|
||||
: 'https://qr.vitanova.network:567/qr';
|
||||
|
||||
// New QR Vitanova API (dynamic QR, settings, polling).
|
||||
export const QR_VITANOVA_API = isDevMode()
|
||||
? '/proxy/qr-vitanova/api'
|
||||
: 'https://qr.vitanova.network/api';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
@@ -8,6 +8,6 @@ export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withFetch())
|
||||
provideHttpClient()
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
<router-outlet></router-outlet>
|
||||
<app-site-header />
|
||||
<main class="app-main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
<app-site-footer />
|
||||
@@ -1,38 +1,36 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard, loginGuard } from './guards/auth.guard';
|
||||
import { LoginComponent } from './components/login/login.component';
|
||||
import { DashboardComponent } from './components/dashboard/dashboard.component';
|
||||
import { ActiveChecksComponent } from './components/active-checks/active-checks.component';
|
||||
import { HistoryComponent } from './components/history/history.component';
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: '/login',
|
||||
pathMatch: 'full'
|
||||
loadComponent: () => {
|
||||
// Branch: ?id=<orderId> means legacy SBP merchant flow.
|
||||
const hasLegacyId = typeof window !== 'undefined'
|
||||
&& new URLSearchParams(window.location.search).has('id');
|
||||
return hasLegacyId
|
||||
? import('./pages/legacy-pay-page/legacy-pay-page').then((m) => m.LegacyPayPage)
|
||||
: import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage);
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
component: LoginComponent,
|
||||
canActivate: [loginGuard]
|
||||
path: 'new',
|
||||
loadComponent: () =>
|
||||
import('./pages/create-page/create-page').then((m) => m.CreatePage)
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: DashboardComponent,
|
||||
canActivate: [authGuard]
|
||||
path: 'about',
|
||||
loadComponent: () =>
|
||||
import('./pages/about-page/about-page').then((m) => m.AboutPage)
|
||||
},
|
||||
{
|
||||
path: 'active-checks',
|
||||
component: ActiveChecksComponent,
|
||||
canActivate: [authGuard]
|
||||
path: 'contacts',
|
||||
loadComponent: () =>
|
||||
import('./pages/contacts-page/contacts-page').then((m) => m.ContactsPage)
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
component: HistoryComponent,
|
||||
canActivate: [authGuard]
|
||||
path: 'partners',
|
||||
loadComponent: () =>
|
||||
import('./pages/partners-page/partners-page').then((m) => m.PartnersPage)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: '/login'
|
||||
}
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter([])]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
@@ -13,11 +15,4 @@ describe('App', () => {
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, FastCheck');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { SiteHeader } from './site-header/site-header';
|
||||
import { SiteFooter } from './site-footer/site-footer';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, SiteHeader, SiteFooter],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('FastCheck');
|
||||
}
|
||||
export class App {}
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<div class="page-container">
|
||||
<header class="header">
|
||||
<div class="logo">FastCheck</div>
|
||||
<nav class="nav">
|
||||
<a routerLink="/dashboard" class="nav-link">Dashboard</a>
|
||||
<a routerLink="/active-checks" class="nav-link active">Active Checks</a>
|
||||
<a routerLink="/history" class="nav-link">History</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="page-header">
|
||||
<h1>Active FastChecks</h1>
|
||||
<p>View all your unused FastChecks</p>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading active checks...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-card">
|
||||
<p>{{ error() }}</p>
|
||||
<button (click)="loadActiveChecks()" class="btn-retry">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isLoading() && !error()) {
|
||||
@if (checks().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>No Active Checks</h3>
|
||||
<p>You don't have any active FastChecks at the moment.</p>
|
||||
<a routerLink="/dashboard" class="btn-primary">Create FastCheck</a>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="checks-grid">
|
||||
@for (check of checks(); track check.fastcheck) {
|
||||
<div class="check-card">
|
||||
<div class="check-header">
|
||||
<span class="check-badge">Active</span>
|
||||
<span class="check-amount">{{ formatAmount(check.amount) }} ₽</span>
|
||||
</div>
|
||||
|
||||
<div class="check-details">
|
||||
<div class="detail-item">
|
||||
<span class="label">FastCheck Number</span>
|
||||
<div class="value-copy">
|
||||
<span class="value">{{ check.fastcheck }}</span>
|
||||
<button
|
||||
(click)="copyToClipboard(check.fastcheck, 'Number')"
|
||||
class="btn-copy"
|
||||
title="Copy">
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">Code</span>
|
||||
<div class="value-copy">
|
||||
<span class="value code">{{ check.code }}</span>
|
||||
<button
|
||||
(click)="copyToClipboard(check.code!, 'Code')"
|
||||
class="btn-copy"
|
||||
title="Copy">
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">Created</span>
|
||||
<span class="value">{{ check.createdAt | date:'short' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="label">Expires</span>
|
||||
<span class="value">{{ check.expiration | date:'short' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="check-warning">
|
||||
⚠️ Keep this information secure. Anyone with these credentials can claim the money.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,280 +0,0 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 20px 40px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #667eea;
|
||||
background: #e8ebff;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 40px;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
|
||||
p {
|
||||
color: #c33;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 14px 30px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #764ba2;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.checks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 30px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.check-card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
border: 2px solid #e8ebff;
|
||||
transition: all 0.3s;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.check-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.check-badge {
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.check-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.check-details {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 15px;
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
|
||||
&.code {
|
||||
font-size: 20px;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value-copy {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
background: #f0f0f0;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.check-warning {
|
||||
background: #fffbe6;
|
||||
border-left: 4px solid #faad14;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FastCheckService } from '../../services/fastcheck.service';
|
||||
import { FastCheck } from '../../models/fastcheck.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-active-checks',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
templateUrl: './active-checks.component.html',
|
||||
styleUrls: ['./active-checks.component.scss']
|
||||
})
|
||||
export class ActiveChecksComponent implements OnInit {
|
||||
checks = signal<FastCheck[]>([]);
|
||||
isLoading = signal<boolean>(true);
|
||||
error = signal<string>('');
|
||||
|
||||
constructor(private fastCheckService: FastCheckService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadActiveChecks();
|
||||
}
|
||||
|
||||
loadActiveChecks(): void {
|
||||
this.isLoading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.fastCheckService.getActiveFastChecks().subscribe({
|
||||
next: (response) => {
|
||||
this.checks.set(response.checks);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load active checks');
|
||||
this.isLoading.set(false);
|
||||
console.error('Load error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(amount);
|
||||
}
|
||||
|
||||
copyToClipboard(text: string, type: string): void {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert(`${type} copied to clipboard!`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<div class="dashboard-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">FastCheck</div>
|
||||
<nav class="nav">
|
||||
<a routerLink="/dashboard" class="nav-link active">Dashboard</a>
|
||||
<a routerLink="/active-checks" class="nav-link">Active Checks</a>
|
||||
<a routerLink="/history" class="nav-link">History</a>
|
||||
<button (click)="logout()" class="btn-logout">Logout</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Balance Card -->
|
||||
<div class="balance-card">
|
||||
@if (isLoadingBalance()) {
|
||||
<div class="loading-small">
|
||||
<div class="spinner-small"></div>
|
||||
</div>
|
||||
} @else if (balance()) {
|
||||
<div class="balance-info">
|
||||
<span class="balance-label">Current Balance</span>
|
||||
<h2 class="balance-amount">{{ formatAmount(balance()!.balance) }} ₽</h2>
|
||||
<button (click)="topUpBalance()" class="btn-topup">+ Top Up Balance</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions-grid">
|
||||
<!-- Create FastCheck -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">Create New FastCheck</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Amount (RUB)</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="createAmount"
|
||||
placeholder="Enter amount"
|
||||
class="input"
|
||||
[disabled]="isCreating()">
|
||||
</div>
|
||||
|
||||
@if (createError()) {
|
||||
<div class="error-message">{{ createError() }}</div>
|
||||
}
|
||||
|
||||
<button
|
||||
(click)="createFastCheck()"
|
||||
[disabled]="isCreating() || !createAmount()"
|
||||
class="btn-primary">
|
||||
@if (isCreating()) {
|
||||
<span>Creating...</span>
|
||||
} @else {
|
||||
<span>Create FastCheck</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Accept FastCheck -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">Accept FastCheck</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>FastCheck Number</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="acceptNumber()"
|
||||
(input)="onFastCheckNumberInput($event)"
|
||||
placeholder="xxxx-xxxx-xxxx"
|
||||
maxlength="14"
|
||||
class="input"
|
||||
[disabled]="isAccepting()">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Code</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="acceptCode"
|
||||
placeholder="Enter 4-digit code"
|
||||
maxlength="4"
|
||||
class="input"
|
||||
[disabled]="isAccepting()">
|
||||
</div>
|
||||
|
||||
@if (acceptError()) {
|
||||
<div class="error-message">{{ acceptError() }}</div>
|
||||
}
|
||||
|
||||
@if (acceptSuccess()) {
|
||||
<div class="success-message">FastCheck accepted successfully!</div>
|
||||
}
|
||||
|
||||
<button
|
||||
(click)="acceptFastCheck()"
|
||||
[disabled]="isAccepting() || !acceptNumber() || !acceptCode()"
|
||||
class="btn-primary">
|
||||
@if (isAccepting()) {
|
||||
<span>Accepting...</span>
|
||||
} @else {
|
||||
<span>Accept FastCheck</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Created Check Modal -->
|
||||
@if (createdCheck()) {
|
||||
<div class="modal-overlay" (click)="closeCreatedCheckModal()">
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>FastCheck Created!</h3>
|
||||
<button class="close-btn" (click)="closeCreatedCheckModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="check-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">FastCheck Number:</span>
|
||||
<span class="detail-value">{{ createdCheck()!.fastcheck }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Code:</span>
|
||||
<span class="detail-value code">{{ createdCheck()!.code }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Expires:</span>
|
||||
<span class="detail-value">{{ createdCheck()!.expiration | date:'short' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-note">
|
||||
<p>⚠️ Save this information securely. Anyone with the number and code can claim this FastCheck.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button (click)="closeCreatedCheckModal()" class="btn-primary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
.dashboard-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 20px 40px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 15px 20px;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #667eea;
|
||||
background: #e8ebff;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #ff7875;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
color: white;
|
||||
margin-bottom: 40px;
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 30px 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin: 10px 0 30px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 36px;
|
||||
margin: 10px 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-topup {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
padding: 12px 30px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 30px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 25px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
margin-top: 10px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #764ba2;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #efe;
|
||||
color: #3c3;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.loading-small {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
|
||||
.spinner-small {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 3px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Modal
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 30px 30px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.check-details {
|
||||
background: #f9f9f9;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
&.code {
|
||||
font-size: 20px;
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-note {
|
||||
background: #fffbe6;
|
||||
border-left: 4px solid #faad14;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px 30px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FastCheckService } from '../../services/fastcheck.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Balance, CreateFastCheckResponse } from '../../models/fastcheck.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.scss']
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
balance = signal<Balance | null>(null);
|
||||
isLoadingBalance = signal<boolean>(true);
|
||||
|
||||
// Create FastCheck
|
||||
createAmount = signal<number>(0);
|
||||
isCreating = signal<boolean>(false);
|
||||
createdCheck = signal<CreateFastCheckResponse | null>(null);
|
||||
createError = signal<string>('');
|
||||
|
||||
// Accept FastCheck
|
||||
acceptNumber = signal<string>('');
|
||||
acceptCode = signal<string>('');
|
||||
isAccepting = signal<boolean>(false);
|
||||
acceptSuccess = signal<boolean>(false);
|
||||
acceptError = signal<string>('');
|
||||
|
||||
constructor(
|
||||
private fastCheckService: FastCheckService,
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBalance();
|
||||
}
|
||||
|
||||
loadBalance(): void {
|
||||
this.isLoadingBalance.set(true);
|
||||
this.fastCheckService.getBalance().subscribe({
|
||||
next: (balance) => {
|
||||
this.balance.set(balance);
|
||||
this.isLoadingBalance.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load balance:', err);
|
||||
this.isLoadingBalance.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createFastCheck(): void {
|
||||
const amount = this.createAmount();
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
this.createError.set('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBalance = this.balance();
|
||||
if (currentBalance && amount > currentBalance.balance) {
|
||||
this.createError.set('Insufficient balance');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCreating.set(true);
|
||||
this.createError.set('');
|
||||
this.createdCheck.set(null);
|
||||
|
||||
this.fastCheckService.createFastCheck({
|
||||
amount: amount,
|
||||
currency: 'RUB'
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
this.createdCheck.set(response);
|
||||
this.isCreating.set(false);
|
||||
this.createAmount.set(0);
|
||||
this.loadBalance(); // Refresh balance
|
||||
},
|
||||
error: (err) => {
|
||||
this.createError.set('Failed to create FastCheck. Please try again.');
|
||||
this.isCreating.set(false);
|
||||
console.error('Create error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
acceptFastCheck(): void {
|
||||
const number = this.acceptNumber().trim();
|
||||
const code = this.acceptCode().trim();
|
||||
|
||||
if (!number || !code) {
|
||||
this.acceptError.set('Please enter both FastCheck number and code');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAccepting.set(true);
|
||||
this.acceptError.set('');
|
||||
this.acceptSuccess.set(false);
|
||||
|
||||
this.fastCheckService.acceptFastCheck({
|
||||
fastcheck: number,
|
||||
code: code
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.acceptSuccess.set(true);
|
||||
this.isAccepting.set(false);
|
||||
this.acceptNumber.set('');
|
||||
this.acceptCode.set('');
|
||||
this.loadBalance(); // Refresh balance
|
||||
|
||||
setTimeout(() => {
|
||||
this.acceptSuccess.set(false);
|
||||
}, 3000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.acceptError.set('Failed to accept FastCheck. Check your credentials.');
|
||||
this.isAccepting.set(false);
|
||||
console.error('Accept error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(amount);
|
||||
}
|
||||
|
||||
formatFastCheckNumber(input: string): string {
|
||||
const cleaned = input.replace(/\D/g, '');
|
||||
const formatted = cleaned.match(/.{1,4}/g)?.join('-') || '';
|
||||
return formatted.slice(0, 14); // xxxx-xxxx-xxxx
|
||||
}
|
||||
|
||||
onFastCheckNumberInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const formatted = this.formatFastCheckNumber(input.value);
|
||||
this.acceptNumber.set(formatted);
|
||||
}
|
||||
|
||||
closeCreatedCheckModal(): void {
|
||||
this.createdCheck.set(null);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
const sessionId = this.authService.getSessionId();
|
||||
if (sessionId) {
|
||||
this.authService.deleteWebSession(sessionId).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Logout error:', err);
|
||||
this.authService.clearAuthentication();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
topUpBalance(): void {
|
||||
// TODO: Implement bank integration
|
||||
alert('Bank integration will be implemented. You will be redirected to bank payment page.');
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<div class="page-container">
|
||||
<header class="header">
|
||||
<div class="logo">FastCheck</div>
|
||||
<nav class="nav">
|
||||
<a routerLink="/dashboard" class="nav-link">Dashboard</a>
|
||||
<a routerLink="/active-checks" class="nav-link">Active Checks</a>
|
||||
<a routerLink="/history" class="nav-link active">History</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="page-header">
|
||||
<h1>Transaction History</h1>
|
||||
<p>View all used and expired FastChecks</p>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading history...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-card">
|
||||
<p>{{ error() }}</p>
|
||||
<button (click)="loadHistory()" class="btn-retry">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isLoading() && !error()) {
|
||||
@if (checks().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📜</div>
|
||||
<h3>No History</h3>
|
||||
<p>Your transaction history will appear here.</p>
|
||||
<a routerLink="/dashboard" class="btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="history-list">
|
||||
@for (check of checks(); track check.fastcheck) {
|
||||
<div class="history-item">
|
||||
<div class="item-header">
|
||||
<div class="item-info">
|
||||
<span [class]="'type-badge ' + getTypeClass(check.type)">
|
||||
{{ getTypeLabel(check.type) }}
|
||||
</span>
|
||||
<span class="item-number">{{ check.fastcheck }}</span>
|
||||
</div>
|
||||
<span class="item-amount">{{ formatAmount(check.amount) }} ₽</span>
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
@if (check.createdAt) {
|
||||
<div class="detail">
|
||||
<span class="detail-label">Created:</span>
|
||||
<span class="detail-value">{{ check.createdAt | date:'short' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (check.usedAt) {
|
||||
<div class="detail">
|
||||
<span class="detail-label">Used:</span>
|
||||
<span class="detail-value">{{ check.usedAt | date:'short' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (check.acceptedAt) {
|
||||
<div class="detail">
|
||||
<span class="detail-label">Accepted:</span>
|
||||
<span class="detail-value">{{ check.acceptedAt | date:'short' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail">
|
||||
<span class="detail-label">Status:</span>
|
||||
<span class="status-badge">{{ check.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,270 +0,0 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 20px 40px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #667eea;
|
||||
background: #e8ebff;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 40px;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
|
||||
p {
|
||||
color: #c33;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 14px 30px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #764ba2;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.type-created {
|
||||
background: #e8ebff;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
&.type-accepted {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.item-number {
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-amount {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FastCheckService } from '../../services/fastcheck.service';
|
||||
import { FastCheck } from '../../models/fastcheck.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
templateUrl: './history.component.html',
|
||||
styleUrls: ['./history.component.scss']
|
||||
})
|
||||
export class HistoryComponent implements OnInit {
|
||||
checks = signal<FastCheck[]>([]);
|
||||
isLoading = signal<boolean>(true);
|
||||
error = signal<string>('');
|
||||
|
||||
constructor(private fastCheckService: FastCheckService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
loadHistory(): void {
|
||||
this.isLoading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.fastCheckService.getFastCheckHistory().subscribe({
|
||||
next: (response) => {
|
||||
this.checks.set(response.checks);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load history');
|
||||
this.isLoading.set(false);
|
||||
console.error('Load error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(amount);
|
||||
}
|
||||
|
||||
getTypeLabel(type?: string): string {
|
||||
return type === 'created' ? 'Created' : 'Accepted';
|
||||
}
|
||||
|
||||
getTypeClass(type?: string): string {
|
||||
return type === 'created' ? 'type-created' : 'type-accepted';
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1 class="title">FastCheck</h1>
|
||||
<p class="subtitle">Scan QR code to login</p>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Generating QR code...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-message">
|
||||
<p>{{ error() }}</p>
|
||||
<button (click)="refreshQR()" class="btn-secondary">Try Again</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (qrData() && !isLoading()) {
|
||||
<div class="qr-section">
|
||||
<div class="qr-wrapper">
|
||||
<qrcode
|
||||
[qrdata]="qrData()"
|
||||
[width]="250"
|
||||
[errorCorrectionLevel]="'M'">
|
||||
</qrcode>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator">
|
||||
<div class="pulse"></div>
|
||||
<span>Waiting for scan...</span>
|
||||
</div>
|
||||
|
||||
<button (click)="refreshQR()" class="btn-link">Refresh QR Code</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,177 +0,0 @@
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 30px 20px;
|
||||
border-radius: 15px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 40px 0;
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 20px;
|
||||
background: #fee;
|
||||
border-radius: 10px;
|
||||
color: #c33;
|
||||
margin: 20px 0;
|
||||
|
||||
p {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
display: inline-block;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
::ng-deep canvas {
|
||||
max-width: 100%;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
margin: 20px 0;
|
||||
|
||||
.pulse {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
|
||||
&:hover {
|
||||
color: #764ba2;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #764ba2;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Component, OnInit, OnDestroy, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, QRCodeComponent],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.scss']
|
||||
})
|
||||
export class LoginComponent implements OnInit, OnDestroy {
|
||||
qrData = signal<string>('');
|
||||
sessionId = signal<string>('');
|
||||
isLoading = signal<boolean>(true);
|
||||
error = signal<string>('');
|
||||
|
||||
private pollSubscription?: Subscription;
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.createSession();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pollSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
createSession(): void {
|
||||
this.isLoading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.authService.createWebSession().subscribe({
|
||||
next: (session) => {
|
||||
this.sessionId.set(session.sessionId);
|
||||
this.qrData.set(`fastcheck://login?session=${session.sessionId}`);
|
||||
this.isLoading.set(false);
|
||||
this.startPolling(session.sessionId);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to create session. Please try again.');
|
||||
this.isLoading.set(false);
|
||||
console.error('Session creation error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startPolling(sessionId: string): void {
|
||||
this.pollSubscription = this.authService.startPolling(sessionId).subscribe({
|
||||
next: (session) => {
|
||||
if (session.Status) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Authentication failed. Please try again.');
|
||||
console.error('Polling error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshQR(): void {
|
||||
this.pollSubscription?.unsubscribe();
|
||||
this.createSession();
|
||||
}
|
||||
}
|
||||
28
src/app/fastcheck.service.ts
Normal file
28
src/app/fastcheck.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface FastcheckData {
|
||||
fastcheck: string;
|
||||
amount: number | null;
|
||||
code: string;
|
||||
expiration?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared state between the home (Fastcheck) page and the create-new page.
|
||||
* When a new fastcheck is created via POST /fastcheck, the create page stores
|
||||
* the returned data here and the home page reads it to autofill its fields.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FastcheckService {
|
||||
readonly created = signal<FastcheckData | null>(null);
|
||||
|
||||
setCreated(data: FastcheckData): void {
|
||||
this.created.set(data);
|
||||
}
|
||||
|
||||
consume(): FastcheckData | null {
|
||||
const value = this.created();
|
||||
this.created.set(null);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (authService.isAuthenticated().isAuthenticated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
};
|
||||
|
||||
export const loginGuard: CanActivateFn = () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (!authService.isAuthenticated().isAuthenticated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PingResponse {
|
||||
message: string;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
export interface FastCheck {
|
||||
fastcheck: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
code?: string;
|
||||
expiration: string;
|
||||
status: 'active' | 'used' | 'expired';
|
||||
createdAt?: string;
|
||||
usedAt?: string;
|
||||
acceptedAt?: string;
|
||||
type?: 'created' | 'accepted';
|
||||
}
|
||||
|
||||
export interface CreateFastCheckRequest {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface CreateFastCheckResponse {
|
||||
fastcheck: string;
|
||||
expiration: string;
|
||||
code: string;
|
||||
Status: boolean;
|
||||
}
|
||||
|
||||
export interface AcceptFastCheckRequest {
|
||||
fastcheck: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface CheckStatusResponse {
|
||||
fastcheck: string;
|
||||
expiration: string;
|
||||
Status: boolean;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
balance: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface FastCheckListResponse {
|
||||
checks: FastCheck[];
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export interface WebSession {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
expires: string;
|
||||
userSessionId: string;
|
||||
Status: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
sessionId: string | null;
|
||||
userSessionId: string | null;
|
||||
}
|
||||
40
src/app/pages/about-page/about-page.html
Normal file
40
src/app/pages/about-page/about-page.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<div class="info-page">
|
||||
<div class="info-page__hero">
|
||||
<h1 class="info-page__title">{{ 'about.title' | translate }}</h1>
|
||||
<p class="info-page__lead">{{ 'about.lead' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-page__body">
|
||||
|
||||
<section class="info-section">
|
||||
<h2 class="info-section__title">{{ 'about.what_title' | translate }}</h2>
|
||||
<p class="info-section__text">{{ 'about.what_text' | translate }}</p>
|
||||
</section>
|
||||
|
||||
<section class="info-section">
|
||||
<h2 class="info-section__title">{{ 'about.how_title' | translate }}</h2>
|
||||
<ol class="info-section__steps">
|
||||
<li>{{ 'about.step1' | translate }}</li>
|
||||
<li>{{ 'about.step2' | translate }}</li>
|
||||
<li>{{ 'about.step3' | translate }}</li>
|
||||
<li>{{ 'about.step4' | translate }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="info-section">
|
||||
<h2 class="info-section__title">{{ 'about.why_title' | translate }}</h2>
|
||||
<ul class="info-section__list">
|
||||
<li>{{ 'about.why1' | translate }}</li>
|
||||
<li>{{ 'about.why2' | translate }}</li>
|
||||
<li>{{ 'about.why3' | translate }}</li>
|
||||
<li>{{ 'about.why4' | translate }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="info-section">
|
||||
<h2 class="info-section__title">{{ 'about.company_title' | translate }}</h2>
|
||||
<p class="info-section__text">{{ 'about.company_text' | translate }}</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
75
src/app/pages/about-page/about-page.scss
Normal file
75
src/app/pages/about-page/about-page.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
:host {
|
||||
display: block;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// Shared info page layout — used by AboutPage and ContactsPage
|
||||
.info-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 72px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 32px 16px 56px;
|
||||
}
|
||||
|
||||
&__hero {
|
||||
margin-bottom: 48px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.5px;
|
||||
|
||||
@media (max-width: 600px) { font-size: 26px; }
|
||||
}
|
||||
|
||||
&__lead {
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-section {
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.75;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__steps, &__list {
|
||||
padding-left: 22px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
li {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.65;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/app/pages/about-page/about-page.ts
Normal file
10
src/app/pages/about-page/about-page.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TranslatePipe } from '../../translate/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-page',
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './about-page.html',
|
||||
styleUrl: './about-page.scss'
|
||||
})
|
||||
export class AboutPage {}
|
||||
66
src/app/pages/contacts-page/contacts-page.html
Normal file
66
src/app/pages/contacts-page/contacts-page.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<div class="info-page">
|
||||
<div class="info-page__hero">
|
||||
<h1 class="info-page__title">{{ 'contacts.title' | translate }}</h1>
|
||||
<p class="info-page__lead">{{ 'contacts.lead' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-page__body">
|
||||
|
||||
<div class="contacts-grid">
|
||||
|
||||
<!-- Phone Russia -->
|
||||
<a class="contact-card" href="tel:+79299037443">
|
||||
<div class="contact-card__icon">🇷🇺</div>
|
||||
<div class="contact-card__body">
|
||||
<span class="contact-card__label">{{ 'contacts.ru_label' | translate }}</span>
|
||||
<span class="contact-card__value">+7 (929) 903-74-43</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Phone Armenia -->
|
||||
<a class="contact-card" href="tel:+37498632421">
|
||||
<div class="contact-card__icon">🇦🇲</div>
|
||||
<div class="contact-card__body">
|
||||
<span class="contact-card__label">{{ 'contacts.am_label' | translate }}</span>
|
||||
<span class="contact-card__value">+374 98 632421</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Email -->
|
||||
<a class="contact-card" href="mailto:info@viaexport.store">
|
||||
<div class="contact-card__icon">✉️</div>
|
||||
<div class="contact-card__body">
|
||||
<span class="contact-card__label">{{ 'contacts.email_label' | translate }}</span>
|
||||
<span class="contact-card__value">info@viaexport.store</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Telegram -->
|
||||
<a class="contact-card" href="https://t.me/DexarSupport_bot" target="_blank" rel="noopener">
|
||||
<div class="contact-card__icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="#2b9fd0"><path d="M9.04 15.65l-.36 4.06c.51 0 .73-.22.99-.48l2.38-2.27 4.93 3.6c.9.5 1.55.24 1.79-.83l3.24-15.18h.01c.29-1.34-.48-1.86-1.36-1.54L1.13 9.66c-1.32.5-1.3 1.23-.22 1.56l4.92 1.53L17.27 5.6c.54-.34 1.03-.15.62.19"/></svg>
|
||||
</div>
|
||||
<div class="contact-card__body">
|
||||
<span class="contact-card__label">{{ 'contacts.tg_label' | translate }}</span>
|
||||
<span class="contact-card__value">@DexarSupport_bot</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<section class="info-section">
|
||||
<h2 class="info-section__title">{{ 'contacts.hours_title' | translate }}</h2>
|
||||
<div class="hours-table">
|
||||
<div class="hours-row">
|
||||
<span class="hours-row__label">{{ 'footer.support_label' | translate }}</span>
|
||||
<span class="hours-row__value hours-row__value--green">24/7</span>
|
||||
</div>
|
||||
<div class="hours-row">
|
||||
<span class="hours-row__label">{{ 'footer.questions_label' | translate }}</span>
|
||||
<span class="hours-row__value">10:00–19:00 МСК</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
146
src/app/pages/contacts-page/contacts-page.scss
Normal file
146
src/app/pages/contacts-page/contacts-page.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
:host {
|
||||
display: block;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.info-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 72px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 32px 16px 56px;
|
||||
}
|
||||
|
||||
&__hero {
|
||||
margin-bottom: 48px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.5px;
|
||||
|
||||
@media (max-width: 600px) { font-size: 26px; }
|
||||
}
|
||||
|
||||
&__lead {
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-section {
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 540px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 4px 16px rgba(30, 64, 175, 0.08);
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.hours-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hours-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&__label {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
|
||||
&--green { color: #16a34a; }
|
||||
}
|
||||
}
|
||||
10
src/app/pages/contacts-page/contacts-page.ts
Normal file
10
src/app/pages/contacts-page/contacts-page.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TranslatePipe } from '../../translate/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts-page',
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './contacts-page.html',
|
||||
styleUrl: './contacts-page.scss'
|
||||
})
|
||||
export class ContactsPage {}
|
||||
158
src/app/pages/create-page/create-page.html
Normal file
158
src/app/pages/create-page/create-page.html
Normal file
@@ -0,0 +1,158 @@
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<div class="card__header">
|
||||
<a class="back" routerLink="/" [attr.aria-label]="'create.back_label' | translate">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="card__title">
|
||||
{{ 'create.title' | translate }}
|
||||
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||
</h1>
|
||||
<p class="card__subtitle">{{ 'create.subtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card__body">
|
||||
|
||||
<!-- Payment methods -->
|
||||
<div class="field">
|
||||
<span class="field__label">{{ 'create.payment_label' | translate }}</span>
|
||||
<div class="methods">
|
||||
<button type="button" class="method" [class.method--active]="payment() === 'sbp'"
|
||||
(click)="selectPayment('sbp', true)" aria-label="СБП">
|
||||
<img class="method__logo"
|
||||
src="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg"
|
||||
alt="СБП" />
|
||||
</button>
|
||||
<button type="button" class="method method--disabled" disabled aria-label="WeChat Pay">
|
||||
<img class="method__logo" src="/wechat-pay.svg" alt="WeChat Pay" />
|
||||
</button>
|
||||
<button type="button" class="method method--disabled" disabled aria-label="Alipay">
|
||||
<img class="method__logo" src="/alipay.svg" alt="Alipay" />
|
||||
</button>
|
||||
<button type="button" class="method method--disabled" disabled aria-label="Visa">
|
||||
<img class="method__logo" src="/visa.svg" alt="Visa" />
|
||||
</button>
|
||||
<button type="button" class="method method--disabled" disabled aria-label="MasterCard">
|
||||
<img class="method__logo" src="/mastercard.svg" alt="Mastercard" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currencies -->
|
||||
<div class="field">
|
||||
<span class="field__label">{{ 'create.currency_label' | translate }}</span>
|
||||
<div class="currencies">
|
||||
<button type="button" class="chip" [class.chip--active]="currency() === 'RUB'"
|
||||
(click)="selectCurrency('RUB', true)">
|
||||
<span class="chip__sign">₽</span>
|
||||
<span class="chip__code">RUB</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<span class="chip__sign">¥</span>
|
||||
<span class="chip__code">CNY</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<span class="chip__sign">$</span>
|
||||
<span class="chip__code">USD</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<span class="chip__sign">€</span>
|
||||
<span class="chip__code">EUR</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<span class="chip__sign">֏</span>
|
||||
<span class="chip__code">AMD</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="amount">{{ 'create.amount_label' | translate }}</label>
|
||||
<div class="input-wrap" [class.input-wrap--error]="error()">
|
||||
<span class="input-wrap__prefix">₽</span>
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
class="input-wrap__input"
|
||||
[ngModel]="amount()"
|
||||
(ngModelChange)="onAmountChange($event)"
|
||||
[min]="minAmount()"
|
||||
[max]="maxAmount()"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<span class="field__hint">{{ 'create.amount_hint' | translate }} {{ minAmount() }}–{{ maxAmount().toLocaleString('ru') }} ₽</span>
|
||||
@if (error()) {
|
||||
<span class="field__error">{{ error() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="note">{{ 'create.note_label' | translate }}</label>
|
||||
<textarea
|
||||
id="note"
|
||||
class="note-input"
|
||||
[ngModel]="note()"
|
||||
(ngModelChange)="onNoteChange($event)"
|
||||
[placeholder]="'create.note_placeholder' | translate"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button class="pay-btn" type="button" (click)="createCheck()" [disabled]="loading() || qrImageUrl() !== null">
|
||||
<span class="pay-btn__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
@if (loading()) {
|
||||
{{ 'create.creating' | translate }}
|
||||
} @else {
|
||||
{{ 'create.create_btn' | translate }}
|
||||
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- QR popup overlay -->
|
||||
@if (qrImageUrl()) {
|
||||
<div class="qr-overlay" (click)="closeQr()">
|
||||
<div class="qr-modal" (click)="$event.stopPropagation()">
|
||||
<button class="qr-modal__close" type="button" (click)="closeQr()" aria-label="Close">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="qr-modal__label">{{ 'create.qr_label' | translate }}</p>
|
||||
<img class="qr-modal__img" [src]="qrImageUrl()!" width="260" height="260" alt="QR" />
|
||||
@if (qrStatus()) {
|
||||
<span class="qr-modal__status">{{ qrStatus() }}</span>
|
||||
}
|
||||
@if (qrPolling()) {
|
||||
<p class="qr-modal__hint">{{ 'create.qr_waiting' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card__footer">
|
||||
<span class="secure-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
{{ 'common.secure' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
266
src/app/pages/create-page/create-page.scss
Normal file
266
src/app/pages/create-page/create-page.scss
Normal file
@@ -0,0 +1,266 @@
|
||||
@use './../../../shared' as *;
|
||||
|
||||
.card__header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover { background: #e2e8f0; color: #0f172a; }
|
||||
&:active { background: #cbd5e1; }
|
||||
}
|
||||
|
||||
.currency-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
&__flag { font-size: 22px; line-height: 1; }
|
||||
&__code { font-size: 15px; font-weight: 700; color: #0f172a; }
|
||||
&__name { font-size: 13px; color: #64748b; margin-left: auto; }
|
||||
}
|
||||
|
||||
// ─── Methods row ────────────────────────────────────────────────────────────
|
||||
.methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 360px) {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.method {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56px;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, transform .1s, box-shadow .15s;
|
||||
-webkit-appearance: none;
|
||||
font-family: inherit;
|
||||
|
||||
@media (max-width: 360px) {
|
||||
height: 52px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
max-width: 100%;
|
||||
max-height: 28px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(.method--disabled) {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) { transform: scale(.97); }
|
||||
|
||||
&--active {
|
||||
border-color: #2563eb;
|
||||
background: rgba(37, 99, 235, .06);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .1);
|
||||
}
|
||||
|
||||
&--disabled,
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #f8fafc;
|
||||
|
||||
.method__logo {
|
||||
filter: grayscale(1);
|
||||
opacity: .45;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Currency chips ─────────────────────────────────────────────────────────
|
||||
.currencies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 14px;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&__flag { font-size: 16px; line-height: 1; }
|
||||
&__sign {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #1e40af;
|
||||
line-height: 1;
|
||||
}
|
||||
&__code { letter-spacing: .3px; }
|
||||
|
||||
&--active {
|
||||
border-color: #2563eb;
|
||||
background: rgba(37, 99, 235, .08);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&--disabled,
|
||||
&:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
color: #94a3b8;
|
||||
|
||||
.chip__sign { color: #94a3b8; }
|
||||
}
|
||||
}
|
||||
|
||||
.note-input {
|
||||
width: 100%;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
line-height: 1.5;
|
||||
|
||||
&::placeholder { color: #cbd5e1; font-weight: 400; }
|
||||
|
||||
&:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── QR section ─────────────────────────────────────────────────────────────
|
||||
// ─── QR popup ───────────────────────────────────────────────────────────────
|
||||
.qr-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: overlay-in 0.2s ease;
|
||||
}
|
||||
|
||||
.qr-modal {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 32px 28px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
|
||||
animation: modal-in 0.22s cubic-bezier(.34,1.56,.64,1);
|
||||
max-width: 340px;
|
||||
width: 90vw;
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #e2e8f0; }
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__img {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
274
src/app/pages/create-page/create-page.ts
Normal file
274
src/app/pages/create-page/create-page.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { FastcheckService } from '../../fastcheck.service';
|
||||
import { FASTCHECK_API, QR_VITANOVA_API } from '../../api';
|
||||
import { TranslatePipe } from '../../translate/translate.pipe';
|
||||
import { TranslationService } from '../../translate/translation.service';
|
||||
|
||||
type PaymentMethod = 'sbp';
|
||||
type Currency = 'RUB';
|
||||
|
||||
interface SettingsResponse {
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CreateQrResponse {
|
||||
qrId?: string;
|
||||
nspkID?: string;
|
||||
Payload?: string; // per API doc (capital P)
|
||||
nspkurl?: string; // actual field name in real responses
|
||||
qrUrl?: string;
|
||||
status?: string; // e.g. "REGISTERED"
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QrStatusResponse {
|
||||
status?: string; // "REGISTERED" | "NEW" | "APPROVED" | "REJECTED" | "COMPLETED"
|
||||
nspkurl?: string;
|
||||
nspkID?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CreateFastcheckResponse {
|
||||
id?: string; // real field name from server
|
||||
fastcheck?: string; // per API doc fallback
|
||||
expiration?: string;
|
||||
code?: string;
|
||||
amount?: number;
|
||||
Status?: boolean;
|
||||
}
|
||||
|
||||
/** Generate a v4-like UUID without crypto dependency. */
|
||||
function generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-page',
|
||||
imports: [FormsModule, RouterLink, TranslatePipe],
|
||||
templateUrl: './create-page.html',
|
||||
styleUrl: './create-page.scss'
|
||||
})
|
||||
export class CreatePage {
|
||||
private http = inject(HttpClient);
|
||||
private store = inject(FastcheckService);
|
||||
private router = inject(Router);
|
||||
private i18n = inject(TranslationService);
|
||||
|
||||
private t(key: string): string { return this.i18n.translate(key); }
|
||||
|
||||
// Limits – updated from settings API on init.
|
||||
minAmount = signal<number>(30);
|
||||
maxAmount = signal<number>(200_000);
|
||||
|
||||
amount = signal<number | null>(null);
|
||||
note = signal<string>('');
|
||||
error = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
settingsLoaded = signal<boolean>(false);
|
||||
|
||||
currency = signal<Currency>('RUB');
|
||||
payment = signal<PaymentMethod>('sbp');
|
||||
|
||||
selectPayment(method: PaymentMethod, enabled: boolean): void {
|
||||
if (!enabled) return;
|
||||
this.payment.set(method);
|
||||
}
|
||||
|
||||
selectCurrency(c: Currency, enabled: boolean): void {
|
||||
if (!enabled) return;
|
||||
this.currency.set(c);
|
||||
}
|
||||
|
||||
// QR display state
|
||||
qrImageUrl = signal<string | null>(null);
|
||||
qrPolling = signal<boolean>(false);
|
||||
qrStatus = signal<string>('');
|
||||
private pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
private activeQrId = '';
|
||||
|
||||
/** Auth credentials passed by the host page as URL params. */
|
||||
private get authKey(): string {
|
||||
return new URLSearchParams(window.location.search).get('authorization-key') ?? '';
|
||||
}
|
||||
private get userId(): string {
|
||||
return new URLSearchParams(window.location.search).get('userid-value') ?? '';
|
||||
}
|
||||
private get sessionId(): string {
|
||||
return new URLSearchParams(window.location.search).get('session') ?? '';
|
||||
}
|
||||
private get reference(): string {
|
||||
return new URLSearchParams(window.location.search).get('ref') ?? window.location.hostname;
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return window.innerWidth < 768;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
private loadSettings(): void {
|
||||
this.http.get<SettingsResponse>(`${QR_VITANOVA_API}/settings`).subscribe({
|
||||
next: (s) => {
|
||||
if (typeof s?.minAmount === 'number') this.minAmount.set(s.minAmount);
|
||||
if (typeof s?.maxAmount === 'number') this.maxAmount.set(s.maxAmount);
|
||||
this.settingsLoaded.set(true);
|
||||
},
|
||||
error: () => this.settingsLoaded.set(true) // proceed with defaults
|
||||
});
|
||||
}
|
||||
|
||||
createCheck(): void {
|
||||
const val = this.amount();
|
||||
if (val !== null && val < this.minAmount()) {
|
||||
this.error.set(`${this.t('errors.invalid_amount')} (мин. ${this.minAmount()} ₽)`);
|
||||
return;
|
||||
}
|
||||
if (val !== null && val > this.maxAmount()) {
|
||||
this.error.set(`${this.t('errors.invalid_amount')} (макс. ${this.maxAmount().toLocaleString('ru')} ₽)`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.error.set('');
|
||||
this.loading.set(true);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.authKey) headers['authorization-key'] = this.authKey;
|
||||
if (this.userId) headers['userid-value'] = this.userId;
|
||||
|
||||
const partnerqrID = generateUUID();
|
||||
|
||||
this.http
|
||||
.post<CreateQrResponse>(
|
||||
`${QR_VITANOVA_API}/qr`,
|
||||
{
|
||||
qrtype: 'QRDynamic',
|
||||
...(val !== null ? { amount: val } : {}),
|
||||
currency: this.currency(),
|
||||
partnerqrID,
|
||||
qrDescription: this.note().trim(),
|
||||
Userid: this.userId,
|
||||
Reference: this.reference
|
||||
},
|
||||
{ headers }
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.loading.set(false);
|
||||
const qrId = res?.qrId ?? res?.nspkID ?? '';
|
||||
// Real API uses 'nspkurl'; doc says 'Payload' — try both
|
||||
const nspkUrl = res?.nspkurl ?? res?.Payload;
|
||||
this.qrStatus.set(res?.status ?? '');
|
||||
|
||||
if (nspkUrl && this.isMobile) {
|
||||
window.location.href = nspkUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrId || nspkUrl) {
|
||||
this.activeQrId = qrId;
|
||||
const qrData = nspkUrl
|
||||
? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(nspkUrl)}`
|
||||
: (res.qrUrl ?? null);
|
||||
this.qrImageUrl.set(qrData);
|
||||
if (qrId) this.startPolling(qrId);
|
||||
} else {
|
||||
this.error.set(this.t('errors.payment_failed'));
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading.set(false);
|
||||
const msg: string | undefined = err?.error?.message;
|
||||
this.error.set(msg ?? this.t('errors.lookup_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startPolling(qrId: string): void {
|
||||
this.stopPolling();
|
||||
this.qrPolling.set(true);
|
||||
this.pollHandle = setInterval(() => {
|
||||
this.http.get<QrStatusResponse>(`${QR_VITANOVA_API}/qr/dynamic/${qrId}`)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
const st = res?.status ?? '';
|
||||
this.qrStatus.set(st);
|
||||
if (st === 'COMPLETED' || st === 'APPROVED') {
|
||||
this.stopPolling();
|
||||
this.createFastcheck();
|
||||
} else if (st === 'REJECTED') {
|
||||
this.stopPolling();
|
||||
this.error.set(this.t('errors.payment_failed'));
|
||||
this.qrImageUrl.set(null);
|
||||
}
|
||||
// REGISTERED / NEW / '' — keep polling
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollHandle !== null) {
|
||||
clearInterval(this.pollHandle);
|
||||
this.pollHandle = null;
|
||||
}
|
||||
this.qrPolling.set(false);
|
||||
}
|
||||
|
||||
private createFastcheck(): void {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.sessionId) headers['Authorization'] = JSON.stringify({ sessionID: this.sessionId });
|
||||
|
||||
this.http
|
||||
.post<CreateFastcheckResponse>(
|
||||
`${FASTCHECK_API}/fastcheck`,
|
||||
{ amount: this.amount(), currency: this.currency() },
|
||||
{ headers }
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
const fcNumber = res?.id ?? res?.fastcheck ?? '';
|
||||
const payload = {
|
||||
fastcheck: fcNumber,
|
||||
code: res?.code ?? '',
|
||||
amount: res?.amount ?? this.amount() ?? null,
|
||||
expiration: res?.expiration
|
||||
};
|
||||
if (fcNumber) {
|
||||
this.store.setCreated(payload);
|
||||
}
|
||||
this.router.navigate(['/'], { state: fcNumber ? payload : {} });
|
||||
},
|
||||
error: () => this.router.navigate(['/'])
|
||||
});
|
||||
}
|
||||
|
||||
onAmountChange(value: number | null): void {
|
||||
this.amount.set(value || null);
|
||||
if (value && value > 0) this.error.set('');
|
||||
}
|
||||
|
||||
onNoteChange(value: string): void {
|
||||
this.note.set(value);
|
||||
}
|
||||
|
||||
closeQr(): void {
|
||||
this.qrImageUrl.set(null);
|
||||
this.qrPolling.set(false);
|
||||
this.qrStatus.set('');
|
||||
if (this.pollHandle !== null) {
|
||||
clearInterval(this.pollHandle);
|
||||
this.pollHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/app/pages/fastcheck-page/fastcheck-page.html
Normal file
177
src/app/pages/fastcheck-page/fastcheck-page.html
Normal file
@@ -0,0 +1,177 @@
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<div class="card__header">
|
||||
<img class="card__brand" src="/logo_big.png"
|
||||
alt="fastCHECK" width="220" height="60" />
|
||||
<p class="card__subtitle">
|
||||
{{ 'fastcheck.subtitle' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card__body">
|
||||
|
||||
<!-- Fastcheck number + new -->
|
||||
<div class="field">
|
||||
<label class="field__label" for="fcNumber">
|
||||
{{ 'fastcheck.number_label' | translate }}
|
||||
</label>
|
||||
<div class="row">
|
||||
<input
|
||||
id="fcNumber"
|
||||
type="text"
|
||||
class="input"
|
||||
[ngModel]="fastcheckNumber()"
|
||||
(ngModelChange)="onNumberChange($event)"
|
||||
[placeholder]="'fastcheck.number_placeholder' | translate"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
maxlength="20"
|
||||
/>
|
||||
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый fastCHECK">{{ 'fastcheck.number_new' | translate }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div class="field">
|
||||
<label class="field__label" for="fcAmount">{{ 'fastcheck.amount_label' | translate }}</label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-wrap__prefix">₽</span>
|
||||
<input
|
||||
id="fcAmount"
|
||||
type="number"
|
||||
class="input-wrap__input"
|
||||
[ngModel]="fastcheckAmount()"
|
||||
(ngModelChange)="onAmountChange($event)"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
[disabled]="true"
|
||||
/>
|
||||
</div>
|
||||
@if (amountLoading()) {
|
||||
<span class="field__hint">{{ 'fastcheck.amount_checking' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Share row — always visible, enabled once amount is known -->
|
||||
<div class="share-row">
|
||||
<!-- <button type="button" class="share-btn share-btn--email" (click)="shareByEmail()"
|
||||
[disabled]="fastcheckAmount() === null || amountLoading()"
|
||||
[title]="'fastcheck.share_email' | translate">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="M2 7l10 7 10-7"/>
|
||||
</svg>
|
||||
{{ 'fastcheck.share_email' | translate }}
|
||||
</button> -->
|
||||
<button type="button" class="share-btn share-btn--tg" (click)="shareByTelegram()"
|
||||
[disabled]="fastcheckAmount() === null || amountLoading()"
|
||||
[title]="'fastcheck.share_tg' | translate">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.04 15.65l-.36 4.06c.51 0 .73-.22.99-.48l2.38-2.27 4.93 3.6c.9.5 1.55.24 1.79-.83l3.24-15.18h.01c.29-1.34-.48-1.86-1.36-1.54L1.13 9.66c-1.32.5-1.3 1.23-.22 1.56l4.92 1.53L17.27 5.6c.54-.34 1.03-.15.62.19"/>
|
||||
</svg>
|
||||
{{ 'fastcheck.share_tg' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Code -->
|
||||
<div class="field">
|
||||
<label class="field__label" for="fcCode">{{ 'fastcheck.code_label' | translate }}</label>
|
||||
<input
|
||||
id="fcCode"
|
||||
type="text"
|
||||
class="input"
|
||||
[ngModel]="fastcheckCode()"
|
||||
(ngModelChange)="onCodeChange($event)"
|
||||
[placeholder]="'fastcheck.code_placeholder' | translate"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
[disabled]="!codeEnabled()"
|
||||
/>
|
||||
@if (error()) {
|
||||
<span class="field__error">{{ error() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="pay-btn" type="button" (click)="pay()" [disabled]="!canPay()">
|
||||
<span class="pay-btn__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ 'fastcheck.pay_btn' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card__footer">
|
||||
<span class="secure-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
{{ 'common.secure' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram sign-in popup -->
|
||||
@if (popupOpen()) {
|
||||
<div class="modal" (click)="closePopup()">
|
||||
<div class="modal__card" (click)="$event.stopPropagation()">
|
||||
<button class="modal__close" type="button" (click)="closePopup()" aria-label="Закрыть">×</button>
|
||||
|
||||
@if (paid()) {
|
||||
<div class="modal__success">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#16a34a"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
<h2 class="modal__title">{{ 'fastcheck.modal_paid_title' | translate }}</h2>
|
||||
<p class="modal__sub">
|
||||
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||
{{ 'fastcheck.modal_paid_sub' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<img class="brand-logo brand-logo--small" src="/logo_small.png"
|
||||
alt="fastCHECK" width="32" height="32" />
|
||||
<h2 class="modal__title">{{ 'fastcheck.modal_title' | translate }}</h2>
|
||||
<p class="modal__sub">{{ 'fastcheck.modal_sub' | translate }}</p>
|
||||
|
||||
@if (popupLoading() && !webSessionId()) {
|
||||
<div class="qr__placeholder">{{ 'fastcheck.modal_loading' | translate }}</div>
|
||||
}
|
||||
|
||||
@if (webSessionId() && !isMobile) {
|
||||
<img [src]="qrUrl()" width="240" height="240" alt="QR Telegram" style="border-radius:12px;display:block;margin:0 auto 12px;" />
|
||||
}
|
||||
|
||||
@if (webSessionId()) {
|
||||
<a class="tg-link" [href]="telegramLink()" target="_blank" rel="noopener">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.04 15.65l-.36 4.06c.51 0 .73-.22.99-.48l2.38-2.27 4.93 3.6c.9.5 1.55.24 1.79-.83l3.24-15.18h.01c.29-1.34-.48-1.86-1.36-1.54L1.13 9.66c-1.32.5-1.3 1.23-.22 1.56l4.92 1.53L17.27 5.6c.54-.34 1.03-.15.62.19" />
|
||||
</svg>
|
||||
{{ 'fastcheck.modal_open_tg' | translate }}
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (popupLoading() && webSessionId()) {
|
||||
<p class="modal__hint">{{ 'fastcheck.modal_confirming' | translate }}</p>
|
||||
} @else if (webSessionId()) {
|
||||
<p class="modal__hint">{{ 'fastcheck.modal_waiting' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (popupError()) {
|
||||
<p class="modal__error">{{ popupError() }}</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
260
src/app/pages/fastcheck-page/fastcheck-page.scss
Normal file
260
src/app/pages/fastcheck-page/fastcheck-page.scss
Normal file
@@ -0,0 +1,260 @@
|
||||
@use './../../../shared' as *;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.input { flex: 1; min-width: 0; }
|
||||
}
|
||||
|
||||
.share-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
|
||||
&--email {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
&:hover { background: #e2e8f0; border-color: #cbd5e1; }
|
||||
}
|
||||
|
||||
&--tg {
|
||||
background: #e7f3fe;
|
||||
color: #0088cc;
|
||||
border-color: #bfdbfe;
|
||||
&:hover { background: #dbeafe; border-color: #93c5fd; }
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
min-width: 64px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: opacity .15s, transform .1s, background .15s;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&--ghost {
|
||||
background: #f1f5f9;
|
||||
color: #2563eb;
|
||||
border-color: #e2e8f0;
|
||||
|
||||
&:hover { background: #e2e8f0; }
|
||||
&:active { transform: scale(.97); }
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
padding: 0 14px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .2s, box-shadow .2s, background .2s;
|
||||
|
||||
&::placeholder { color: #cbd5e1; font-weight: 500; }
|
||||
|
||||
&:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(37,99,235,.12);
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal (Telegram QR popup) ──────────────────────────────────────────────
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(15, 23, 42, .55);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
animation: fade-in .15s ease-out;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 28px 24px 24px;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,.25);
|
||||
animation: pop-in .2s ease-out;
|
||||
margin: auto;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: calc(28px + env(safe-area-inset-top)) 20px calc(28px + env(safe-area-inset-bottom));
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background .15s;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&:hover { background: #e2e8f0; }
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 4px 0 6px;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
&__success {
|
||||
padding: 12px 0 4px;
|
||||
|
||||
svg { display: block; margin: 0 auto 10px; }
|
||||
}
|
||||
}
|
||||
|
||||
.qr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
width: 264px;
|
||||
height: 264px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 380px) {
|
||||
width: min(264px, 70vw);
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 240px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.tg-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 14px 22px;
|
||||
min-height: 48px;
|
||||
border-radius: 12px;
|
||||
background: #229ED9;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: opacity .15s;
|
||||
|
||||
&:hover { opacity: .9; }
|
||||
&:active { transform: scale(.97); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from { transform: translateY(12px) scale(.98); opacity: 0; }
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
293
src/app/pages/fastcheck-page/fastcheck-page.ts
Normal file
293
src/app/pages/fastcheck-page/fastcheck-page.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { FastcheckService } from '../../fastcheck.service';
|
||||
import { FASTCHECK_API } from '../../api';
|
||||
import { TranslatePipe } from '../../translate/translate.pipe';
|
||||
import { TranslationService } from '../../translate/translation.service';
|
||||
|
||||
interface WebSessionResponse {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
expires: string;
|
||||
userSessionId: string;
|
||||
Status: boolean;
|
||||
}
|
||||
|
||||
interface CheckFastcheckResponse {
|
||||
id: string;
|
||||
code: string;
|
||||
owneID: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
creattransactionID: string;
|
||||
firedAT: string;
|
||||
firetransactionID: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-fastcheck-page',
|
||||
imports: [FormsModule, RouterLink, TranslatePipe],
|
||||
templateUrl: './fastcheck-page.html',
|
||||
styleUrl: './fastcheck-page.scss'
|
||||
})
|
||||
export class FastcheckPage {
|
||||
private http = inject(HttpClient);
|
||||
private store = inject(FastcheckService);
|
||||
private router = inject(Router);
|
||||
private i18n = inject(TranslationService);
|
||||
|
||||
private t(key: string): string { return this.i18n.translate(key); }
|
||||
|
||||
// Telegram bot used for the sign-in deep link.
|
||||
private readonly telegramBot = 'DexarSupport_bot';
|
||||
|
||||
fastcheckNumber = signal<string>('');
|
||||
fastcheckAmount = signal<number | null>(null);
|
||||
fastcheckCode = signal<string>('');
|
||||
codeEnabled = signal<boolean>(false);
|
||||
error = signal<string>('');
|
||||
amountLoading = signal<boolean>(false);
|
||||
|
||||
popupOpen = signal<boolean>(false);
|
||||
popupLoading = signal<boolean>(false);
|
||||
popupError = signal<string>('');
|
||||
webSessionId = signal<string>('');
|
||||
paid = signal<boolean>(false);
|
||||
private pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
private lastLookedUpNumber = '';
|
||||
|
||||
canPay = computed(() => {
|
||||
const digits = this.fastcheckNumber().replace(/\D/g, '');
|
||||
const codeDigits = this.fastcheckCode().replace(/\D/g, '');
|
||||
return digits.length === 18 && codeDigits.length === 6
|
||||
&& this.codeEnabled() && !this.amountLoading();
|
||||
});
|
||||
|
||||
telegramLink = computed(() => {
|
||||
const sid = this.webSessionId();
|
||||
return sid
|
||||
? `https://t.me/${this.telegramBot}?start=${encodeURIComponent(sid)}`
|
||||
: `https://t.me/${this.telegramBot}`;
|
||||
});
|
||||
|
||||
qrUrl = computed(() => {
|
||||
const link = this.telegramLink();
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=${encodeURIComponent(link)}`;
|
||||
});
|
||||
|
||||
get isMobile(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Pull autofill data: prefer router navigation state, fall back to service.
|
||||
const navState = typeof window !== 'undefined' ? (window.history?.state ?? {}) : {};
|
||||
const created = (navState?.fastcheck)
|
||||
? { fastcheck: navState.fastcheck, code: navState.code ?? '', amount: navState.amount ?? null, expiration: navState.expiration }
|
||||
: this.store.consume();
|
||||
|
||||
if (created) {
|
||||
this.fastcheckNumber.set(created.fastcheck);
|
||||
this.fastcheckAmount.set(created.amount);
|
||||
this.fastcheckCode.set(created.code);
|
||||
this.codeEnabled.set(true);
|
||||
}
|
||||
|
||||
// ?iid=xxxxxx-xxxxxx-xxxxxx — auto-fill and trigger lookup
|
||||
const iidParam = new URLSearchParams(window.location.search).get('iid') ?? '';
|
||||
if (iidParam && !created) {
|
||||
const digits = iidParam.replace(/\D/g, '').slice(0, 18);
|
||||
const groups: string[] = [];
|
||||
for (let i = 0; i < digits.length; i += 6) groups.push(digits.slice(i, i + 6));
|
||||
const masked = groups.join('-');
|
||||
this.fastcheckNumber.set(masked);
|
||||
if (digits.length === 18) this.lookupFastcheck(masked);
|
||||
}
|
||||
}
|
||||
|
||||
pay(): void {
|
||||
if (!this.canPay()) {
|
||||
return;
|
||||
}
|
||||
this.error.set('');
|
||||
this.openPopup();
|
||||
}
|
||||
|
||||
private openPopup(): void {
|
||||
this.popupOpen.set(true);
|
||||
this.popupError.set('');
|
||||
this.paid.set(false);
|
||||
this.popupLoading.set(true);
|
||||
|
||||
this.http.get<WebSessionResponse>(`${FASTCHECK_API}/websession`).subscribe({
|
||||
next: (res) => {
|
||||
this.popupLoading.set(false);
|
||||
this.webSessionId.set(res.sessionId);
|
||||
if (this.isMobile) {
|
||||
window.location.href = `https://t.me/${this.telegramBot}?start=${encodeURIComponent(res.sessionId)}`;
|
||||
} else {
|
||||
this.startPolling(res.sessionId);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.popupLoading.set(false);
|
||||
this.popupError.set(this.t('errors.session_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closePopup(): void {
|
||||
this.popupOpen.set(false);
|
||||
this.stopPolling();
|
||||
if (this.webSessionId()) {
|
||||
// Best-effort logout; ignore errors.
|
||||
this.http
|
||||
.request('DELETE', `${FASTCHECK_API}/websession/${this.webSessionId()}`, {
|
||||
body: { sessionId: this.webSessionId() }
|
||||
})
|
||||
.subscribe({ error: () => undefined });
|
||||
}
|
||||
this.webSessionId.set('');
|
||||
}
|
||||
|
||||
private startPolling(sessionId: string): void {
|
||||
this.stopPolling();
|
||||
this.pollHandle = setInterval(() => {
|
||||
this.http
|
||||
.get<WebSessionResponse>(`${FASTCHECK_API}/websession/${sessionId}`)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
if (res?.Status) {
|
||||
this.stopPolling();
|
||||
this.acceptFastcheck(sessionId);
|
||||
}
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollHandle !== null) {
|
||||
clearInterval(this.pollHandle);
|
||||
this.pollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
private acceptFastcheck(sessionId: string): void {
|
||||
this.popupLoading.set(true);
|
||||
this.http
|
||||
.post(
|
||||
`${FASTCHECK_API}/fastcheck`,
|
||||
{ fastcheck: this.fastcheckNumber().trim(), code: this.fastcheckCode().trim() },
|
||||
{ headers: { Authorization: JSON.stringify({ sessionID: sessionId }) } }
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.popupLoading.set(false);
|
||||
this.paid.set(true);
|
||||
// Fire DELETE to mark fastcheck as consumed on the merchant side.
|
||||
this.http
|
||||
.delete(`${FASTCHECK_API}/fastcheck/${encodeURIComponent(this.fastcheckNumber())}`)
|
||||
.subscribe({ error: () => undefined });
|
||||
this.fireMerchantCallback();
|
||||
},
|
||||
error: () => {
|
||||
this.popupLoading.set(false);
|
||||
this.popupError.set(this.t('errors.payment_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fireMerchantCallback(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const returnUrl = params.get('return_url');
|
||||
if (returnUrl) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `${returnUrl}${returnUrl.includes('?') ? '&' : '?'}fastcheck=${encodeURIComponent(
|
||||
this.fastcheckNumber()
|
||||
)}&status=ok`;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
onAmountChange(value: number | null): void {
|
||||
this.fastcheckAmount.set(value);
|
||||
}
|
||||
|
||||
/** Mask fastcheck number as XXXXXX-XXXXXX-XXXXXX, allow only digits. */
|
||||
onNumberChange(raw: string): void {
|
||||
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 18);
|
||||
const groups: string[] = [];
|
||||
for (let i = 0; i < digits.length; i += 6) {
|
||||
groups.push(digits.slice(i, i + 6));
|
||||
}
|
||||
const masked = groups.join('-');
|
||||
this.fastcheckNumber.set(masked);
|
||||
this.error.set('');
|
||||
|
||||
if (digits.length < 18 && this.lastLookedUpNumber) {
|
||||
this.fastcheckAmount.set(null);
|
||||
this.codeEnabled.set(false);
|
||||
this.lastLookedUpNumber = '';
|
||||
}
|
||||
|
||||
if (digits.length === 18 && masked !== this.lastLookedUpNumber) {
|
||||
this.lookupFastcheck(masked);
|
||||
}
|
||||
}
|
||||
|
||||
/** Allow only digits, max 6, in the code field. */
|
||||
onCodeChange(raw: string): void {
|
||||
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 6);
|
||||
this.fastcheckCode.set(digits);
|
||||
this.error.set('');
|
||||
}
|
||||
|
||||
private lookupFastcheck(number: string): void {
|
||||
this.lastLookedUpNumber = number;
|
||||
this.amountLoading.set(true);
|
||||
this.fastcheckAmount.set(null);
|
||||
this.codeEnabled.set(false);
|
||||
|
||||
// API doc: GET /fastcheck/<id>
|
||||
this.http
|
||||
.get<CheckFastcheckResponse>(`${FASTCHECK_API}/fastcheck/${number}`)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.amountLoading.set(false);
|
||||
if (res?.id) {
|
||||
this.fastcheckAmount.set(typeof res.amount === 'number' ? res.amount : null);
|
||||
this.codeEnabled.set(true);
|
||||
} else {
|
||||
this.error.set(this.t('errors.not_found'));
|
||||
this.lastLookedUpNumber = '';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.amountLoading.set(false);
|
||||
const serverMsg: string | undefined = err?.error?.message;
|
||||
this.error.set(serverMsg ?? this.t('errors.lookup_failed'));
|
||||
this.lastLookedUpNumber = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shareByEmail(): void {
|
||||
const num = this.fastcheckNumber();
|
||||
const amount = this.fastcheckAmount();
|
||||
const subject = encodeURIComponent('fastCHECK');
|
||||
const body = encodeURIComponent(`Номер: ${num}\nСумма: ${amount} ₽\nhttps://qr.vitanova.network/`);
|
||||
window.open(`mailto:?subject=${subject}&body=${body}`, '_blank');
|
||||
}
|
||||
|
||||
shareByTelegram(): void {
|
||||
const num = this.fastcheckNumber();
|
||||
const amount = this.fastcheckAmount();
|
||||
const text = encodeURIComponent(`fastCHECK: ${num} — ${amount} ₽`);
|
||||
window.open(`https://t.me/share/url?url=https%3A%2F%2Fqr.vitanova.network%2F&text=${text}`, '_blank');
|
||||
}
|
||||
}
|
||||
93
src/app/pages/legacy-pay-page/legacy-pay-page.html
Normal file
93
src/app/pages/legacy-pay-page/legacy-pay-page.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<div class="card__header">
|
||||
<div class="sbp-logo">
|
||||
<img src="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg"
|
||||
alt="СБП" />
|
||||
</div>
|
||||
<h1 class="card__title">{{ 'sbp.title' | translate }}</h1>
|
||||
<p class="card__subtitle">{{ 'sbp.subtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card__body">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="amount">{{ 'sbp.amount_label' | translate }}</label>
|
||||
<div class="input-wrap" [class.input-wrap--error]="error()">
|
||||
<span class="input-wrap__prefix">₽</span>
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
class="input-wrap__input"
|
||||
[ngModel]="amount()"
|
||||
(ngModelChange)="onAmountChange($event)"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<span class="field__error">{{ error() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="currency-badge">
|
||||
<span class="currency-badge__flag">🇷🇺</span>
|
||||
<span class="currency-badge__code">RUB</span>
|
||||
<span class="currency-badge__name">{{ 'sbp.currency_name' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="note">{{ 'sbp.note_label' | translate }}</label>
|
||||
<textarea
|
||||
id="note"
|
||||
class="note-input"
|
||||
[ngModel]="note()"
|
||||
(ngModelChange)="onNoteChange($event)"
|
||||
[placeholder]="'sbp.note_placeholder' | translate"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@if (nspkUrl()) {
|
||||
<div class="qr-pay">
|
||||
<img
|
||||
[src]="'https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=' + nspkUrl()"
|
||||
width="240" height="240"
|
||||
alt="SBP QR"
|
||||
/>
|
||||
<p class="qr-pay__hint">Отсканируйте QR-код в приложении вашего банка</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="pay-btn" type="button" (click)="pay()" [disabled]="loading() || !!nspkUrl()">
|
||||
<span class="pay-btn__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
</span>
|
||||
@if (loading()) {
|
||||
{{ 'sbp.pay_loading' | translate }}
|
||||
} @else {
|
||||
{{ 'sbp.pay_btn' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card__footer">
|
||||
<span class="secure-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
{{ 'common.secure' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
81
src/app/pages/legacy-pay-page/legacy-pay-page.scss
Normal file
81
src/app/pages/legacy-pay-page/legacy-pay-page.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
@use './../../../shared' as *;
|
||||
|
||||
.sbp-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 16px;
|
||||
padding: 12px 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
margin-bottom: 14px;
|
||||
|
||||
img {
|
||||
height: 40px;
|
||||
display: block;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.currency-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
&__flag { font-size: 22px; line-height: 1; }
|
||||
&__code { font-size: 15px; font-weight: 700; color: #0f172a; }
|
||||
&__name { font-size: 13px; color: #64748b; margin-left: auto; }
|
||||
}
|
||||
|
||||
.qr-pay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
img {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.note-input {
|
||||
width: 100%;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
line-height: 1.5;
|
||||
|
||||
&::placeholder { color: #cbd5e1; font-weight: 400; }
|
||||
|
||||
&:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
107
src/app/pages/legacy-pay-page/legacy-pay-page.ts
Normal file
107
src/app/pages/legacy-pay-page/legacy-pay-page.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Component, computed, inject, isDevMode, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TranslatePipe } from '../../translate/translate.pipe';
|
||||
import { TranslationService } from '../../translate/translation.service';
|
||||
|
||||
interface LegacyPayResponse {
|
||||
nspkurl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy SBP merchant payment flow.
|
||||
* Activated when the root URL has `?id=<orderId>`.
|
||||
* Mirrors public/payment.html behaviour:
|
||||
* POST https://qr.vitanova.network:567/qr
|
||||
* { payment, amount, currency, id, note } -> { payload: '<sbp-deep-link>' }
|
||||
* then window.location.href = payload.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-legacy-pay-page',
|
||||
imports: [FormsModule, TranslatePipe],
|
||||
templateUrl: './legacy-pay-page.html',
|
||||
styleUrl: './legacy-pay-page.scss'
|
||||
})
|
||||
export class LegacyPayPage {
|
||||
private http = inject(HttpClient);
|
||||
private route = inject(ActivatedRoute);
|
||||
private i18n = inject(TranslationService);
|
||||
|
||||
private t(key: string): string { return this.i18n.translate(key); }
|
||||
|
||||
private readonly LEGACY_API = isDevMode()
|
||||
? '/proxy/legacy-qr/qr'
|
||||
: 'https://qr.vitanova.network:567/qr';
|
||||
|
||||
amount = signal<number | null>(null);
|
||||
note = signal<string>('');
|
||||
error = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
nspkUrl = signal<string>('');
|
||||
|
||||
get isMobile(): boolean {
|
||||
return window.innerWidth < 768;
|
||||
}
|
||||
|
||||
paymentId = signal<string>('');
|
||||
|
||||
canPay = computed(() => {
|
||||
const a = this.amount();
|
||||
return !!this.paymentId() && a !== null && a > 0 && !this.loading();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const id = this.route.snapshot.queryParamMap.get('id') ?? '';
|
||||
this.paymentId.set(id);
|
||||
}
|
||||
|
||||
onAmountChange(value: number | null): void {
|
||||
this.amount.set(value);
|
||||
if (this.error()) this.error.set('');
|
||||
}
|
||||
|
||||
onNoteChange(value: string): void {
|
||||
this.note.set(value);
|
||||
}
|
||||
|
||||
pay(): void {
|
||||
if (!this.canPay()) {
|
||||
if (!this.paymentId()) {
|
||||
this.error.set(this.t('errors.not_found'));
|
||||
} else {
|
||||
this.error.set(this.t('errors.invalid_amount'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.error.set('');
|
||||
this.loading.set(true);
|
||||
|
||||
const body = {
|
||||
qrtype: 'QRDynamic',
|
||||
amount: this.amount(),
|
||||
currency: 'RUB',
|
||||
partnerqrID: this.paymentId(),
|
||||
qrDescription: this.note().trim()
|
||||
};
|
||||
|
||||
this.http.post<LegacyPayResponse>(this.LEGACY_API, body).subscribe({
|
||||
next: (res) => {
|
||||
this.loading.set(false);
|
||||
if (res?.nspkurl) {
|
||||
if (this.isMobile) {
|
||||
window.location.href = res.nspkurl;
|
||||
} else {
|
||||
this.nspkUrl.set(res.nspkurl);
|
||||
}
|
||||
} else {
|
||||
this.error.set(this.t('errors.payment_failed'));
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.error.set(this.t('errors.lookup_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
26
src/app/pages/partners-page/partners-page.html
Normal file
26
src/app/pages/partners-page/partners-page.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<div class="info-page">
|
||||
<div class="info-page__hero">
|
||||
<h1 class="info-page__title">{{ 'partners.title' | translate }}</h1>
|
||||
<p class="info-page__lead">{{ 'partners.lead' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="partners-grid">
|
||||
@for (p of partners; track p.name) {
|
||||
<div class="partner-card">
|
||||
<div class="partner-card__logo">{{ p.logo }}</div>
|
||||
<div class="partner-card__body">
|
||||
<span class="partner-card__cat">{{ p.category | translate }}</span>
|
||||
<h3 class="partner-card__name">{{ p.name }}</h3>
|
||||
<p class="partner-card__city">📍 {{ p.city }}</p>
|
||||
<p class="partner-card__desc">{{ p.desc | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="partners-cta">
|
||||
<h2 class="partners-cta__title">{{ 'partners.cta_title' | translate }}</h2>
|
||||
<p class="partners-cta__text">{{ 'partners.cta_text' | translate }}</p>
|
||||
<a class="partners-cta__btn" routerLink="/contacts">{{ 'partners.cta_btn' | translate }}</a>
|
||||
</div>
|
||||
</div>
|
||||
146
src/app/pages/partners-page/partners-page.scss
Normal file
146
src/app/pages/partners-page/partners-page.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
:host {
|
||||
display: block;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.info-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 72px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 32px 16px 56px;
|
||||
}
|
||||
|
||||
&__hero {
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.5px;
|
||||
|
||||
@media (max-width: 600px) { font-size: 26px; }
|
||||
}
|
||||
|
||||
&__lead {
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.partners-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.partner-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 22px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 4px 16px rgba(30, 64, 175, 0.08);
|
||||
}
|
||||
|
||||
&__logo {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__cat {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__city {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: #475569;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.partners-cta {
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
&__title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #1e3a8a;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: #3b5998;
|
||||
margin: 0 0 24px;
|
||||
max-width: 480px;
|
||||
margin-inline: auto;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1d3a9f; }
|
||||
}
|
||||
}
|
||||
26
src/app/pages/partners-page/partners-page.ts
Normal file
26
src/app/pages/partners-page/partners-page.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '../../translate/translate.pipe';
|
||||
|
||||
interface Partner {
|
||||
name: string;
|
||||
category: string;
|
||||
city: string;
|
||||
logo: string; // emoji placeholder until real logos are provided
|
||||
desc: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-partners-page',
|
||||
imports: [RouterLink, TranslatePipe],
|
||||
templateUrl: './partners-page.html',
|
||||
styleUrl: './partners-page.scss'
|
||||
})
|
||||
export class PartnersPage {
|
||||
partners: Partner[] = [
|
||||
{ name: 'Vitanova Exchange', category: 'partners.cat_finance', city: 'Ереван', logo: '🏦', desc: 'partners.p1_desc' },
|
||||
{ name: 'ForEx.am', category: 'partners.cat_finance', city: 'Ереван', logo: '💱', desc: 'partners.p2_desc' },
|
||||
{ name: 'Dexar Market', category: 'partners.cat_retail', city: 'Москва', logo: '🛒', desc: 'partners.p3_desc' },
|
||||
{ name: 'City Hotel Yerevan', category: 'partners.cat_hotels', city: 'Ереван', logo: '🏨', desc: 'partners.p4_desc' },
|
||||
];
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly API_URL = 'https://api.fastcheck.store';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
ping(): Observable<{ message: string }> {
|
||||
return this.http.get<{ message: string }>(`${this.API_URL}/ping`);
|
||||
}
|
||||
|
||||
get<T>(path: string, sessionId?: string): Observable<T> {
|
||||
const headers = sessionId ? this.createAuthHeaders(sessionId) : undefined;
|
||||
return this.http.get<T>(`${this.API_URL}${path}`, { headers });
|
||||
}
|
||||
|
||||
post<T>(path: string, body: any, sessionId?: string): Observable<T> {
|
||||
const headers = sessionId ? this.createAuthHeaders(sessionId) : undefined;
|
||||
return this.http.post<T>(`${this.API_URL}${path}`, body, { headers });
|
||||
}
|
||||
|
||||
delete<T>(path: string, sessionId?: string): Observable<T> {
|
||||
const headers = sessionId ? this.createAuthHeaders(sessionId) : undefined;
|
||||
return this.http.delete<T>(`${this.API_URL}${path}`, { headers });
|
||||
}
|
||||
|
||||
private createAuthHeaders(sessionId: string): HttpHeaders {
|
||||
return new HttpHeaders({
|
||||
'Authorization': JSON.stringify({ sessionID: sessionId }),
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable, interval, switchMap, takeWhile, tap } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSession, AuthState } from '../models/session.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private authState = signal<AuthState>({
|
||||
isAuthenticated: false,
|
||||
sessionId: null,
|
||||
userSessionId: null
|
||||
});
|
||||
|
||||
readonly isAuthenticated = this.authState.asReadonly();
|
||||
|
||||
constructor(private apiService: ApiService) {
|
||||
this.loadSessionFromStorage();
|
||||
}
|
||||
|
||||
createWebSession(): Observable<WebSession> {
|
||||
return this.apiService.get<WebSession>('/websession');
|
||||
}
|
||||
|
||||
checkWebSessionStatus(sessionId: string): Observable<WebSession> {
|
||||
return this.apiService.get<WebSession>(`/websession/${sessionId}`);
|
||||
}
|
||||
|
||||
startPolling(sessionId: string): Observable<WebSession> {
|
||||
return interval(2000).pipe(
|
||||
switchMap(() => this.checkWebSessionStatus(sessionId)),
|
||||
tap(session => {
|
||||
if (session.Status) {
|
||||
this.setAuthenticated(session);
|
||||
}
|
||||
}),
|
||||
takeWhile(session => !session.Status, true)
|
||||
);
|
||||
}
|
||||
|
||||
deleteWebSession(sessionId: string): Observable<any> {
|
||||
return this.apiService.delete(`/websession/${sessionId}`, sessionId).pipe(
|
||||
tap(() => this.clearAuthentication())
|
||||
);
|
||||
}
|
||||
|
||||
private setAuthenticated(session: WebSession): void {
|
||||
const state = {
|
||||
isAuthenticated: true,
|
||||
sessionId: session.sessionId,
|
||||
userSessionId: session.userSessionId
|
||||
};
|
||||
this.authState.set(state);
|
||||
sessionStorage.setItem('authState', JSON.stringify(state));
|
||||
}
|
||||
|
||||
private loadSessionFromStorage(): void {
|
||||
const stored = sessionStorage.getItem('authState');
|
||||
if (stored) {
|
||||
this.authState.set(JSON.parse(stored));
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthentication(): void {
|
||||
this.authState.set({
|
||||
isAuthenticated: false,
|
||||
sessionId: null,
|
||||
userSessionId: null
|
||||
});
|
||||
sessionStorage.removeItem('authState');
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.authState().sessionId;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
FastCheck,
|
||||
CreateFastCheckRequest,
|
||||
CreateFastCheckResponse,
|
||||
AcceptFastCheckRequest,
|
||||
CheckStatusResponse,
|
||||
Balance,
|
||||
FastCheckListResponse
|
||||
} from '../models/fastcheck.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FastCheckService {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
|
||||
checkStatus(fastcheckNumber: string): Observable<CheckStatusResponse> {
|
||||
return this.apiService.post<CheckStatusResponse>(
|
||||
'/fastcheck',
|
||||
{ fastcheck: fastcheckNumber }
|
||||
);
|
||||
}
|
||||
|
||||
createFastCheck(request: CreateFastCheckRequest): Observable<CreateFastCheckResponse> {
|
||||
const sessionId = this.authService.getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
return this.apiService.post<CreateFastCheckResponse>(
|
||||
'/fastcheck',
|
||||
request,
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
acceptFastCheck(request: AcceptFastCheckRequest): Observable<{ message: string }> {
|
||||
const sessionId = this.authService.getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
return this.apiService.post<{ message: string }>(
|
||||
'/fastcheck',
|
||||
request,
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
// MOCKED - Backend needs to implement
|
||||
getBalance(): Observable<Balance> {
|
||||
const sessionId = this.authService.getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// TODO: Replace with real API call
|
||||
// return this.apiService.get<Balance>('/balance', sessionId);
|
||||
|
||||
// MOCK DATA
|
||||
return of({
|
||||
balance: 150000,
|
||||
currency: 'RUB'
|
||||
});
|
||||
}
|
||||
|
||||
// MOCKED - Backend needs to implement
|
||||
getActiveFastChecks(): Observable<FastCheckListResponse> {
|
||||
const sessionId = this.authService.getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// TODO: Replace with real API call
|
||||
// return this.apiService.get<FastCheckListResponse>('/fastcheck/active', sessionId);
|
||||
|
||||
// MOCK DATA
|
||||
return of({
|
||||
checks: [
|
||||
{
|
||||
fastcheck: '4568-1109-3402',
|
||||
amount: 15000,
|
||||
currency: 'RUB',
|
||||
code: '5568',
|
||||
expiration: '2026-01-26T09:08:18Z',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-19T09:08:18Z'
|
||||
},
|
||||
{
|
||||
fastcheck: '7890-2234-5566',
|
||||
amount: 25000,
|
||||
currency: 'RUB',
|
||||
code: '1234',
|
||||
expiration: '2026-01-26T10:15:30Z',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-19T10:15:30Z'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// MOCKED - Backend needs to implement
|
||||
getFastCheckHistory(): Observable<FastCheckListResponse> {
|
||||
const sessionId = this.authService.getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// TODO: Replace with real API call
|
||||
// return this.apiService.get<FastCheckListResponse>('/fastcheck/history', sessionId);
|
||||
|
||||
// MOCK DATA
|
||||
return of({
|
||||
checks: [
|
||||
{
|
||||
fastcheck: '1234-5678-0003',
|
||||
amount: 5000,
|
||||
currency: 'RUB',
|
||||
type: 'created',
|
||||
createdAt: '2026-01-15T09:08:18Z',
|
||||
usedAt: '2026-01-15T10:20:00Z',
|
||||
status: 'used',
|
||||
expiration: '2026-01-22T09:08:18Z'
|
||||
},
|
||||
{
|
||||
fastcheck: '9876-5432-0100',
|
||||
amount: 10000,
|
||||
currency: 'RUB',
|
||||
type: 'accepted',
|
||||
acceptedAt: '2026-01-14T14:30:00Z',
|
||||
status: 'used',
|
||||
expiration: '2026-01-21T14:30:00Z'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
57
src/app/site-footer/site-footer.html
Normal file
57
src/app/site-footer/site-footer.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<footer class="site-footer">
|
||||
<div class="site-footer__inner">
|
||||
|
||||
<!-- Brand + about -->
|
||||
<div class="site-footer__col site-footer__col--brand">
|
||||
<a class="site-footer__brand" href="/">
|
||||
<!-- <img src="/logo_big.png" alt="fastCHECK" width="28" height="28" /> -->
|
||||
<span class="site-footer__wordmark">
|
||||
<span class="wm-fast">fast</span><span class="wm-check">CHECK</span>
|
||||
</span>
|
||||
</a>
|
||||
<p class="site-footer__desc" id="about">{{ 'footer.desc' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="site-footer__col" id="contacts">
|
||||
<h3 class="site-footer__heading">{{ 'footer.contacts_heading' | translate }}</h3>
|
||||
<ul class="site-footer__list">
|
||||
<li>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.07 10.5a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 16.92z"/></svg>
|
||||
<a href="tel:+79299037443">+7 (929) 903-74-43</a> <span class="site-footer__note">{{ 'footer.russia' | translate }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.07 10.5a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 16.92z"/></svg>
|
||||
<a href="tel:+37498632421">+374 98 632421</a> <span class="site-footer__note">{{ 'footer.armenia' | translate }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
<a href="mailto:info@viaexport.store">info@viaexport.store</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="site-footer__hours">
|
||||
<p><strong>{{ 'footer.support_label' | translate }}:</strong> {{ 'footer.support_hours' | translate }}</p>
|
||||
<p><strong>{{ 'footer.questions_label' | translate }}:</strong> {{ 'footer.questions_hours' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal -->
|
||||
<div class="site-footer__col">
|
||||
<h3 class="site-footer__heading">{{ 'footer.legal_heading' | translate }}</h3>
|
||||
<ul class="site-footer__list site-footer__list--legal">
|
||||
<li>{{ 'footer.legal_company' | translate }}</li>
|
||||
<li>{{ 'footer.legal_inn_ru' | translate }}</li>
|
||||
<li>{{ 'footer.legal_inn_am' | translate }}</li>
|
||||
<li>{{ 'footer.legal_kpp' | translate }}</li>
|
||||
<li>{{ 'footer.legal_ogrn' | translate }}</li>
|
||||
<li class="site-footer__address">{{ 'footer.legal_address' | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="site-footer__bottom">
|
||||
<p>© {{ year }} {{ 'footer.rights' | translate }}</p>
|
||||
<p>{{ 'footer.director' | translate }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
156
src/app/site-footer/site-footer.scss
Normal file
156
src/app/site-footer/site-footer.scss
Normal file
@@ -0,0 +1,156 @@
|
||||
:host { display: block; }
|
||||
|
||||
.site-footer {
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
|
||||
&__inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 40px;
|
||||
|
||||
@media (max-width: 860px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
padding: 36px 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__col {
|
||||
&--brand {
|
||||
@media (max-width: 860px) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
margin-bottom: 14px;
|
||||
|
||||
img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&__wordmark {
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.65;
|
||||
color: #64748b;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
|
||||
svg { flex-shrink: 0; opacity: 0.5; }
|
||||
}
|
||||
|
||||
a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover { color: #e2e8f0; }
|
||||
}
|
||||
|
||||
&--legal {
|
||||
li {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&__hours {
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__address {
|
||||
color: #475569;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
border-top: 1px solid #1e293b;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 24px;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
|
||||
@media (max-width: 560px) {
|
||||
flex-direction: column;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wm-fast {
|
||||
font-weight: 400;
|
||||
font-size: 0.72em;
|
||||
color: #64748b;
|
||||
margin-right: 0.04em;
|
||||
}
|
||||
.wm-check {
|
||||
font-weight: 700;
|
||||
font-size: 1em;
|
||||
color: #93c5fd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
12
src/app/site-footer/site-footer.ts
Normal file
12
src/app/site-footer/site-footer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TranslatePipe } from '../translate/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './site-footer.html',
|
||||
styleUrl: './site-footer.scss'
|
||||
})
|
||||
export class SiteFooter {
|
||||
year = new Date().getFullYear();
|
||||
}
|
||||
98
src/app/site-header/site-header.html
Normal file
98
src/app/site-header/site-header.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
|
||||
<!-- Brand -->
|
||||
<a class="site-header__brand" routerLink="/" (click)="closeMenu()">
|
||||
<img src="/logo_small.png" alt="fastCHECK" width="32" height="32" />
|
||||
<span class="site-header__wordmark">
|
||||
<span class="wm-fast">fast</span><span class="wm-check">CHECK</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<nav class="site-header__nav" [attr.aria-label]="'header.aria_nav' | translate">
|
||||
<a class="site-header__link" routerLink="/about">{{ 'header.nav_about' | translate }}</a>
|
||||
<a class="site-header__link" routerLink="/partners">{{ 'header.nav_partners' | translate }}</a>
|
||||
<a class="site-header__link" routerLink="/contacts">{{ 'header.nav_contacts' | translate }}</a>
|
||||
<a class="site-header__link" href="mailto:info@viaexport.store">{{ 'header.nav_support' | translate }}</a>
|
||||
</nav>
|
||||
|
||||
<!-- Language dropdown -->
|
||||
<div class="lang-select" [class.lang-select--open]="langOpen()">
|
||||
<button type="button" class="lang-select__trigger" (click)="toggleLang()">
|
||||
<img class="lang-select__flag" [src]="activeLang.flag" [alt]="activeLang.label" width="20" height="20" />
|
||||
<span class="lang-select__code">{{ activeLang.code | uppercase }}</span>
|
||||
<svg class="lang-select__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
@if (langOpen()) {
|
||||
<div class="lang-select__dropdown">
|
||||
@for (lang of langs; track lang.code) {
|
||||
<button type="button" class="lang-select__option"
|
||||
[class.lang-select__option--active]="currentLang() === lang.code"
|
||||
(click)="setLang(lang.code)">
|
||||
<img class="lang-select__flag" [src]="lang.flag" [alt]="lang.label" width="20" height="20" />
|
||||
<span class="lang-select__name">{{ lang.label }}</span>
|
||||
@if (currentLang() === lang.code) {
|
||||
<svg class="lang-select__check" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Mobile hamburger -->
|
||||
<button class="site-header__burger" type="button"
|
||||
[attr.aria-expanded]="menuOpen()"
|
||||
[attr.aria-label]="'header.aria_burger' | translate"
|
||||
(click)="toggleMenu()">
|
||||
@if (menuOpen()) {
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="3" y1="7" x2="21" y2="7"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="17" x2="21" y2="17"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile overlay + drawer -->
|
||||
@if (menuOpen()) {
|
||||
<div class="mobile-overlay" (click)="closeMenu()">
|
||||
<nav class="mobile-panel" (click)="$event.stopPropagation()" [attr.aria-label]="'header.aria_menu' | translate">
|
||||
<div class="mobile-panel__header">
|
||||
<span class="mobile-panel__title">fastCHECK</span>
|
||||
<button type="button" class="mobile-panel__close" (click)="closeMenu()" [attr.aria-label]="'header.aria_close' | translate">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<a class="mobile-panel__link" routerLink="/about" (click)="closeMenu()">{{ 'header.nav_about' | translate }}</a>
|
||||
<a class="mobile-panel__link" routerLink="/partners" (click)="closeMenu()">{{ 'header.nav_partners' | translate }}</a>
|
||||
<a class="mobile-panel__link" routerLink="/contacts" (click)="closeMenu()">{{ 'header.nav_contacts' | translate }}</a>
|
||||
<a class="mobile-panel__link" href="mailto:info@viaexport.store" (click)="closeMenu()">{{ 'header.nav_support' | translate }}</a>
|
||||
<div class="mobile-panel__langs">
|
||||
@for (lang of langs; track lang.code) {
|
||||
<button type="button" class="site-header__lang"
|
||||
[class.site-header__lang--active]="currentLang() === lang.code"
|
||||
(click)="setLang(lang.code); closeMenu()">
|
||||
<img [src]="lang.flag" [alt]="lang.label" width="20" height="20" />
|
||||
<span>{{ lang.code | uppercase }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
324
src/app/site-header/site-header.scss
Normal file
324
src/app/site-header/site-header.scss
Normal file
@@ -0,0 +1,324 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__wordmark {
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
&__lang {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover { background: #f1f5f9; color: #475569; }
|
||||
|
||||
&--active {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-langs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 14px 4px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__burger {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
-webkit-appearance: none;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover { background: #f1f5f9; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-menu { display: none; } // replaced by .mobile-overlay / .mobile-panel
|
||||
|
||||
&__mobile-link { display: none; }
|
||||
}
|
||||
|
||||
// Wordmark colours
|
||||
.wm-fast {
|
||||
font-weight: 400;
|
||||
font-size: 0.72em;
|
||||
color: #64748b;
|
||||
margin-right: 0.04em;
|
||||
}
|
||||
.wm-check {
|
||||
font-weight: 700;
|
||||
font-size: 1em;
|
||||
color: #1e40af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
// Language dropdown
|
||||
.lang-select {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { background: #f8fafc; border-color: #cbd5e1; }
|
||||
}
|
||||
|
||||
&--open &__trigger {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
&__flag { width: 20px; height: 20px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
|
||||
|
||||
&__code { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; }
|
||||
|
||||
&__chevron {
|
||||
color: #94a3b8;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&--open &__chevron { transform: rotate(180deg); }
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
animation: dropdown-in 0.12s ease;
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s;
|
||||
|
||||
&:hover { background: #f8fafc; }
|
||||
|
||||
&--active { color: #1e40af; background: #eff6ff; }
|
||||
}
|
||||
|
||||
&__name { flex: 1; }
|
||||
|
||||
&__check { color: #1e40af; margin-left: auto; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
@keyframes dropdown-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
// ── Mobile overlay + drawer ──────────────────────────────────────
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 998;
|
||||
animation: overlay-in 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: min(300px, 85vw);
|
||||
background: #fff;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
animation: panel-in 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
&__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover { background: #f1f5f9; color: #0f172a; }
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: block;
|
||||
padding: 14px 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
transition: background 0.12s;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover { background: #f8fafc; }
|
||||
}
|
||||
|
||||
&__langs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes panel-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
48
src/app/site-header/site-header.ts
Normal file
48
src/app/site-header/site-header.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { UpperCasePipe } from '@angular/common';
|
||||
import { Component, HostListener, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '../translate/translate.pipe';
|
||||
import { TranslationService, Lang } from '../translate/translation.service';
|
||||
|
||||
interface LangOption { code: Lang; label: string; flag: string; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-header',
|
||||
imports: [RouterLink, TranslatePipe, UpperCasePipe],
|
||||
templateUrl: './site-header.html',
|
||||
styleUrl: './site-header.scss'
|
||||
})
|
||||
export class SiteHeader {
|
||||
private i18n = inject(TranslationService);
|
||||
|
||||
menuOpen = signal(false);
|
||||
langOpen = signal(false);
|
||||
currentLang = this.i18n.currentLang;
|
||||
|
||||
langs: LangOption[] = [
|
||||
{ code: 'ru', label: 'Русский', flag: '/flags/ru.svg' },
|
||||
{ code: 'en', label: 'English', flag: '/flags/en.svg' },
|
||||
{ code: 'hy', label: 'Հայերեն', flag: '/flags/arm.svg' },
|
||||
];
|
||||
|
||||
get activeLang(): LangOption {
|
||||
return this.langs.find(l => l.code === this.currentLang()) ?? this.langs[0];
|
||||
}
|
||||
|
||||
toggleMenu(): void { this.menuOpen.update(v => !v); }
|
||||
closeMenu(): void { this.menuOpen.set(false); }
|
||||
toggleLang(): void { this.langOpen.update(v => !v); }
|
||||
closeLang(): void { this.langOpen.set(false); }
|
||||
|
||||
setLang(lang: Lang): void {
|
||||
this.i18n.setLanguage(lang);
|
||||
this.langOpen.set(false);
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event.target'])
|
||||
onDocClick(target: EventTarget | null): void {
|
||||
if (!(target instanceof HTMLElement) || !target.closest('.lang-select')) {
|
||||
this.langOpen.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/app/translate/translate.pipe.ts
Normal file
11
src/app/translate/translate.pipe.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import { TranslationService } from './translation.service';
|
||||
|
||||
@Pipe({ name: 'translate', pure: false, standalone: true })
|
||||
export class TranslatePipe implements PipeTransform {
|
||||
private svc = inject(TranslationService);
|
||||
|
||||
transform(key: string): string {
|
||||
return this.svc.translate(key);
|
||||
}
|
||||
}
|
||||
36
src/app/translate/translation.service.ts
Normal file
36
src/app/translate/translation.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
export type Lang = 'ru' | 'en' | 'hy';
|
||||
type Translations = Record<string, Record<string, string>>;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TranslationService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
currentLang = signal<Lang>('ru');
|
||||
private translations = signal<Translations>({});
|
||||
|
||||
constructor() {
|
||||
this.load('ru');
|
||||
}
|
||||
|
||||
setLanguage(lang: Lang): void {
|
||||
this.currentLang.set(lang);
|
||||
this.load(lang);
|
||||
}
|
||||
|
||||
private load(lang: Lang): void {
|
||||
this.http.get<Translations>(`/i18n/${lang}.json`).subscribe({
|
||||
next: data => this.translations.set(data),
|
||||
});
|
||||
}
|
||||
|
||||
translate(key: string): string {
|
||||
const dot = key.indexOf('.');
|
||||
if (dot === -1) return key;
|
||||
const section = key.slice(0, dot);
|
||||
const k = key.slice(dot + 1);
|
||||
return this.translations()[section]?.[k] ?? key;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user