changes
This commit is contained in:
8
src/app/api.ts
Normal file
8
src/app/api.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Endpoint constants for the Fastcheck backend (see public/api.txt).
|
||||
* Centralised so they can be swapped in one place.
|
||||
*/
|
||||
export const FASTCHECK_API = 'https://api.fastcheck.store';
|
||||
|
||||
// Legacy QR endpoint kept for the SBP amount → payload redirect flow.
|
||||
export const QR_API = 'https://qr.vitanova.network:567/qr';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
|
||||
@@ -1,67 +1 @@
|
||||
<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">Оплата через СБП</h1>
|
||||
<p class="card__subtitle">Система быстрых платежей</p>
|
||||
</div>
|
||||
|
||||
<div class="card__body">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="amount">Сумма платежа</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">Российский рубль</span>
|
||||
</div>
|
||||
|
||||
<button class="pay-btn" (click)="goToPayment()" [disabled]="loading()">
|
||||
<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>
|
||||
{{ loading() ? 'Подождите...' : 'Перейти к оплате' }}
|
||||
</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>
|
||||
Защищённое соединение
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<router-outlet />
|
||||
@@ -1,3 +1,15 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage)
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
loadComponent: () =>
|
||||
import('./pages/create-page/create-page').then((m) => m.CreatePage)
|
||||
},
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
|
||||
270
src/app/app.scss
270
src/app/app.scss
@@ -1,270 +1,2 @@
|
||||
// ─── Page shell ─────────────────────────────────────────────────────────────
|
||||
:host { display: block; min-height: 100dvh; }
|
||||
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #1e40af 0%, #2563eb 40%, #0ea5e9 100%);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
min-height: 50dvh;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
border-radius: 24px 24px 0 0;
|
||||
max-width: 100%;
|
||||
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
|
||||
padding: 32px 28px 28px;
|
||||
text-align: center;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 28px 24px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 14px 0 4px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 28px 28px 20px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 0 28px 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 0 20px 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Logo ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.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);
|
||||
|
||||
img {
|
||||
height: 40px;
|
||||
display: block;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Field ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Amount input ────────────────────────────────────────────────────────────
|
||||
|
||||
.input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
&__prefix {
|
||||
padding: 0 4px 0 18px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 16px 16px 16px 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
// Remove number spinners
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 28px;
|
||||
padding: 14px 14px 14px 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Currency badge ──────────────────────────────────────────────────────────
|
||||
|
||||
.currency-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pay button ──────────────────────────────────────────────────────────────
|
||||
|
||||
.pay-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 17px 24px;
|
||||
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.38);
|
||||
font-family: inherit;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 16px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Secure badge ────────────────────────────────────────────────────────────
|
||||
|
||||
.secure-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, qr_vitanova');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,65 +1,11 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
const API_URL = 'https://qr.vitanova.network:567/qr';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [FormsModule],
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
amount = signal<number>(10);
|
||||
error = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
|
||||
private get paymentId(): string | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('id');
|
||||
}
|
||||
|
||||
goToPayment(): void {
|
||||
const val = this.amount();
|
||||
if (!val || val <= 0) {
|
||||
this.error.set('Введите корректную сумму');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.paymentId;
|
||||
if (id === null) {
|
||||
this.error.set('Не указан идентификатор платежа (параметр id)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.error.set('');
|
||||
this.loading.set(true);
|
||||
|
||||
this.http.post<{ qrId: string; qrStatus: string; qrExpirationDate: string; payload: string; qrUrl: string }>(API_URL, {
|
||||
payment: 'sbp',
|
||||
amount: val,
|
||||
currency: 'rub',
|
||||
id
|
||||
}).subscribe({
|
||||
next: (res) => {
|
||||
this.loading.set(false);
|
||||
if (res?.payload) {
|
||||
window.location.href = res.payload;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.error.set('Ошибка при создании платежа. Попробуйте ещё раз.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAmountChange(value: number): void {
|
||||
this.amount.set(value);
|
||||
if (value > 0) this.error.set('');
|
||||
}
|
||||
}
|
||||
export class App {}
|
||||
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
128
src/app/pages/create-page/create-page.html
Normal file
128
src/app/pages/create-page/create-page.html
Normal file
@@ -0,0 +1,128 @@
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<div class="card__header">
|
||||
<a class="back" routerLink="/" aria-label="Назад">
|
||||
<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">Новый Фастчек</h1>
|
||||
<p class="card__subtitle">Укажите сумму для пополнения</p>
|
||||
</div>
|
||||
|
||||
<div class="card__body">
|
||||
|
||||
<!-- Payment methods -->
|
||||
<div class="field">
|
||||
<span class="field__label">Способ оплаты</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="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">Валюта</span>
|
||||
<div class="currencies">
|
||||
<button type="button" class="chip" [class.chip--active]="currency() === 'RUB'"
|
||||
(click)="selectCurrency('RUB', true)">
|
||||
<!-- <span class="chip__flag">🇷🇺</span> -->
|
||||
<span class="chip__sign">₽</span>
|
||||
<span class="chip__code">RUB</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<!-- <span class="chip__flag">🇨🇳</span> -->
|
||||
<span class="chip__sign">¥</span>
|
||||
<span class="chip__code">CNY</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<!-- <span class="chip__flag">🇺🇸</span> -->
|
||||
<span class="chip__sign">$</span>
|
||||
<span class="chip__code">USD</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<!-- <span class="chip__flag">🇪🇺</span> -->
|
||||
<span class="chip__sign">€</span>
|
||||
<span class="chip__code">EUR</span>
|
||||
</button>
|
||||
<button type="button" class="chip chip--disabled" disabled>
|
||||
<!-- <span class="chip__flag">🇦🇲</span> -->
|
||||
<span class="chip__sign">֏</span>
|
||||
<span class="chip__code">AMD</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="amount">Сумма платежа</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="field">
|
||||
<label class="field__label" for="note">Примечание</label>
|
||||
<textarea
|
||||
id="note"
|
||||
class="note-input"
|
||||
[ngModel]="note()"
|
||||
(ngModelChange)="onNoteChange($event)"
|
||||
placeholder="Причина платежа..."
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button class="pay-btn" type="button" (click)="createCheck()" [disabled]="loading()">
|
||||
<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>
|
||||
{{ loading() ? 'Создание…' : 'Создать Фастчек' }}
|
||||
</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>
|
||||
Защищённое соединение
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
163
src/app/pages/create-page/create-page.scss
Normal file
163
src/app/pages/create-page/create-page.scss
Normal file
@@ -0,0 +1,163 @@
|
||||
@use './../../../shared' as *;
|
||||
|
||||
.card__header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover { background: rgba(255, 255, 255, 0.28); }
|
||||
&:active { background: rgba(255, 255, 255, 0.36); }
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&__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: 38px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
103
src/app/pages/create-page/create-page.ts
Normal file
103
src/app/pages/create-page/create-page.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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 } from '../../api';
|
||||
|
||||
interface CreateFastcheckResponse {
|
||||
fastcheck: string;
|
||||
expiration: string;
|
||||
code: string;
|
||||
Status: boolean;
|
||||
}
|
||||
|
||||
type PaymentMethod = 'sbp' | 'wechat' | 'visa' | 'master';
|
||||
type Currency = 'RUB' | 'CNY' | 'USD' | 'EUR' | 'AMD';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-page',
|
||||
imports: [FormsModule, RouterLink],
|
||||
templateUrl: './create-page.html',
|
||||
styleUrl: './create-page.scss'
|
||||
})
|
||||
export class CreatePage {
|
||||
private http = inject(HttpClient);
|
||||
private store = inject(FastcheckService);
|
||||
private router = inject(Router);
|
||||
|
||||
amount = signal<number>(10);
|
||||
note = signal<string>('');
|
||||
error = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
|
||||
payment = signal<PaymentMethod>('sbp');
|
||||
currency = signal<Currency>('RUB');
|
||||
|
||||
/** sessionID for the Authorization header. Comes from ?session=... or websession. */
|
||||
private get sessionId(): string {
|
||||
return new URLSearchParams(window.location.search).get('session') ?? '';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
createCheck(): void {
|
||||
const val = this.amount();
|
||||
if (!val || val <= 0) {
|
||||
this.error.set('Введите корректную сумму');
|
||||
return;
|
||||
}
|
||||
|
||||
this.error.set('');
|
||||
this.loading.set(true);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.sessionId) {
|
||||
headers['Authorization'] = JSON.stringify({ sessionID: this.sessionId });
|
||||
}
|
||||
|
||||
this.http
|
||||
.post<CreateFastcheckResponse>(
|
||||
`${FASTCHECK_API}/fastcheck`,
|
||||
{ amount: val, currency: this.currency() },
|
||||
{ headers }
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.loading.set(false);
|
||||
if (res?.fastcheck) {
|
||||
this.store.setCreated({
|
||||
fastcheck: res.fastcheck,
|
||||
code: res.code,
|
||||
amount: val,
|
||||
expiration: res.expiration
|
||||
});
|
||||
this.router.navigate(['/']);
|
||||
} else {
|
||||
this.error.set('Не удалось создать Фастчек.');
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.error.set('Ошибка при создании Фастчека. Попробуйте ещё раз.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAmountChange(value: number): void {
|
||||
this.amount.set(value);
|
||||
if (value > 0) this.error.set('');
|
||||
}
|
||||
|
||||
onNoteChange(value: string): void {
|
||||
this.note.set(value);
|
||||
}
|
||||
}
|
||||
139
src/app/pages/fastcheck-page/fastcheck-page.html
Normal file
139
src/app/pages/fastcheck-page/fastcheck-page.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<div class="card__header">
|
||||
<h1 class="card__title">Оплата Фастчеком</h1>
|
||||
<p class="card__subtitle">Введите данные Фастчека или создайте новый</p>
|
||||
</div>
|
||||
|
||||
<div class="card__body">
|
||||
|
||||
<!-- Fastcheck number + new -->
|
||||
<div class="field">
|
||||
<label class="field__label" for="fcNumber">Номер Фастчека</label>
|
||||
<div class="row">
|
||||
<input
|
||||
id="fcNumber"
|
||||
type="text"
|
||||
class="input"
|
||||
[ngModel]="fastcheckNumber()"
|
||||
(ngModelChange)="fastcheckNumber.set($event)"
|
||||
placeholder="1234-5678-0001"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый Фастчек">Новый</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div class="field">
|
||||
<label class="field__label" for="fcAmount">Сумма</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code -->
|
||||
<div class="field">
|
||||
<label class="field__label" for="fcCode">Код</label>
|
||||
<input
|
||||
id="fcCode"
|
||||
type="text"
|
||||
class="input"
|
||||
[ngModel]="fastcheckCode()"
|
||||
(ngModelChange)="fastcheckCode.set($event)"
|
||||
placeholder="0000"
|
||||
inputmode="numeric"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
@if (error()) {
|
||||
<span class="field__error">{{ error() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="pay-btn" type="button" (click)="pay()">
|
||||
<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>
|
||||
Оплатить
|
||||
</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>
|
||||
Защищённое соединение
|
||||
</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">Оплачено</h2>
|
||||
<p class="modal__sub">Фастчек успешно принят.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<h2 class="modal__title">Войти через Telegram</h2>
|
||||
<p class="modal__sub">Отсканируйте QR или откройте ссылку</p>
|
||||
|
||||
<div class="qr">
|
||||
@if (popupLoading() && !webSessionId()) {
|
||||
<div class="qr__placeholder">Загрузка…</div>
|
||||
} @else if (webSessionId()) {
|
||||
<img [src]="qrUrl()" width="240" height="240" alt="QR Telegram" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@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>
|
||||
Открыть в Telegram
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (popupLoading() && webSessionId()) {
|
||||
<p class="modal__hint">Подтверждение оплаты…</p>
|
||||
} @else if (webSessionId()) {
|
||||
<p class="modal__hint">Ожидание входа…</p>
|
||||
}
|
||||
|
||||
@if (popupError()) {
|
||||
<p class="modal__error">{{ popupError() }}</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
204
src/app/pages/fastcheck-page/fastcheck-page.scss
Normal file
204
src/app/pages/fastcheck-page/fastcheck-page.scss
Normal file
@@ -0,0 +1,204 @@
|
||||
@use './../../../shared' as *;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.input { flex: 1; min-width: 0; }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
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;
|
||||
|
||||
&--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;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
align-items: flex-end;
|
||||
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;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
max-width: 100%;
|
||||
border-radius: 24px 24px 0 0;
|
||||
padding: 24px 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background .15s;
|
||||
|
||||
&: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;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 360px) {
|
||||
width: 100%;
|
||||
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;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
background: #229ED9;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: opacity .15s;
|
||||
|
||||
&:hover { opacity: .9; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
171
src/app/pages/fastcheck-page/fastcheck-page.ts
Normal file
171
src/app/pages/fastcheck-page/fastcheck-page.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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';
|
||||
|
||||
interface WebSessionResponse {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
expires: string;
|
||||
userSessionId: string;
|
||||
Status: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-fastcheck-page',
|
||||
imports: [FormsModule, RouterLink],
|
||||
templateUrl: './fastcheck-page.html',
|
||||
styleUrl: './fastcheck-page.scss'
|
||||
})
|
||||
export class FastcheckPage {
|
||||
private http = inject(HttpClient);
|
||||
private store = inject(FastcheckService);
|
||||
private router = inject(Router);
|
||||
|
||||
// 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>('');
|
||||
error = signal<string>('');
|
||||
|
||||
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;
|
||||
|
||||
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)}`;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Pull autofill data left over by the create page.
|
||||
const created = this.store.consume();
|
||||
if (created) {
|
||||
this.fastcheckNumber.set(created.fastcheck);
|
||||
this.fastcheckAmount.set(created.amount);
|
||||
this.fastcheckCode.set(created.code);
|
||||
}
|
||||
}
|
||||
|
||||
pay(): void {
|
||||
if (!this.fastcheckNumber().trim()) {
|
||||
this.error.set('Введите номер Фастчека');
|
||||
return;
|
||||
}
|
||||
if (!this.fastcheckCode().trim()) {
|
||||
this.error.set('Введите код Фастчека');
|
||||
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);
|
||||
this.startPolling(res.sessionId);
|
||||
},
|
||||
error: () => {
|
||||
this.popupLoading.set(false);
|
||||
this.popupError.set('Не удалось создать сессию. Попробуйте ещё раз.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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-and-forget merchant callback if a return_url is on the page.
|
||||
this.fireMerchantCallback();
|
||||
},
|
||||
error: () => {
|
||||
this.popupLoading.set(false);
|
||||
this.popupError.set('Не удалось принять Фастчек.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
|
||||
184
src/shared.scss
Normal file
184
src/shared.scss
Normal file
@@ -0,0 +1,184 @@
|
||||
// Shared page-level styles for the Fastcheck and Create pages.
|
||||
// Imported via @use './../../../shared' as *;
|
||||
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #1e40af 0%, #2563eb 40%, #0ea5e9 100%);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
border-radius: 24px 24px 0 0;
|
||||
max-width: 100%;
|
||||
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
|
||||
padding: 28px 24px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 24px 22px 18px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 22px 18px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 0 24px 22px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 0 18px 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
&__prefix {
|
||||
padding: 0 4px 0 18px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 14px 14px 14px 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::placeholder { color: #cbd5e1; }
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 26px;
|
||||
padding: 12px 12px 12px 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pay-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.38);
|
||||
font-family: inherit;
|
||||
|
||||
&:hover { opacity: 0.92; box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45); }
|
||||
&:active { transform: scale(0.98); opacity: 0.88; }
|
||||
&:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
|
||||
|
||||
&__icon { display: flex; align-items: center; }
|
||||
}
|
||||
|
||||
.secure-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
|
||||
svg { flex-shrink: 0; }
|
||||
}
|
||||
Reference in New Issue
Block a user