Compare commits

..

2 Commits

Author SHA1 Message Date
678ab3773b backend old version request 2026-04-30 15:00:24 +04:00
d9b0c221f1 visual changes 2026-04-30 14:51:32 +04:00
20 changed files with 627 additions and 61 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,9 @@
/tmp
/out-tsc
/bazel-out
/dist
changes.txt
api.txt
# Node
/node_modules

179
BACKEND_CHANGES.md Normal file
View File

@@ -0,0 +1,179 @@
# Fastcheck Backend — изменения сверх api.txt
Документ с **дельтой** — что нужно добавить/изменить в спеке `public/api.txt`,
чтобы фронт полноценно заработал. Базовая спека остаётся в силе, здесь только
правки.
> Полный референс с примерами — см. `BACKEND.md` в этом же репозитории.
---
## 1. `POST /fastcheck` (создание) — расширить тело
Добавить поля `orderId`, `note`, `returnUrl` (все опциональные):
```diff
POST /fastcheck
HEADER: Authorization: {"sessionID": "..."}
Body:
{
"amount": 158000,
"currency": "RUB",
+ "orderId": "merchant-order-uuid", // id заказа на стороне мерчанта
+ "note": "Оплата заказа №123", // комментарий, видит получатель
+ "returnUrl": "https://shop.example.com/thanks" // куда вернуть юзера после оплаты
}
Response:
{
"fastcheck": "1234-5678-0001",
"expiration": "2026-07-07T09:08:18Z",
"code": "5864",
"Status": true,
+ "orderId": "merchant-order-uuid" // эхо обратно
}
```
`note` также возвращать в `GET /fastcheck`, чтобы получатель видел причину
платежа перед приёмом.
---
## 1.1. `GET /fastcheck` — добавить `amount` в ответ
Фронт автоматически делает `GET /fastcheck` после ввода полного номера
(`xxxx-xxxx-xxxx`), чтобы показать получателю сумму до ввода кода. Сейчас в
`api.txt` ответ содержит только `fastcheck`, `expiration`, `Status` — суммы нет.
Добавить:
```diff
GET /fastcheck
Body: { "fastcheck": "1234-5678-0001" }
Response:
{
"fastcheck": "1234-5678-0001",
"expiration": "2026-07-07T09:08:18Z",
+ "amount": 158000,
+ "currency": "RUB",
+ "note": "За кофе",
"Status": true
}
```
Также: GET с телом — нестандарт, многие HTTP-клиенты его выкидывают. **Принимать
`?fastcheck=...` как query-параметр** (фронт шлёт оба варианта одновременно).
---
## 2. Зафиксировать единицу `amount`
В `api.txt` пример `"amount": 158000` неоднозначен. Зафиксировать:
> `amount` — **целое число в основной единице валюты** (для RUB — рубли,
> не копейки). Минимум 1.
Если бэкенд считает в копейках — сообщить, фронт изменит формат.
---
## 3. Merchant webhook (новое)
После успешного `POST /fastcheck` (accept), сервер шлёт `POST` на
`webhookUrl`, привязанный к `orderId`/мерчанту:
```
POST <merchant_webhook_url>
Headers:
Content-Type: application/json
X-Fastcheck-Signature: <hmac-sha256 от тела с секретом мерчанта>
Body:
{
"event": "fastcheck.paid",
"fastcheck": "1234-5678-0001",
"orderId": "merchant-order-uuid",
"amount": 158000,
"currency": "RUB",
"paidAt": "2026-04-30T12:34:56Z"
}
```
Мерчант проверяет подпись и помечает заказ оплаченным. Ретраи (минимум
3 попытки с экспоненциальной задержкой) при не-2xx ответах.
---
## 4. Развести create и accept на разные пути (рекомендация)
Сейчас `POST /fastcheck` делает оба действия — отличается только формой тела
(`amount` vs `code`). Это хрупко.
```diff
- POST /fastcheck (создание)
- POST /fastcheck (приём)
+ POST /fastcheck (создание)
+ POST /fastcheck/accept (приём)
```
Фронт правится в одну строку. Если оставляете один путь — оставляем как есть.
---
## 5. Гранулярные ошибки `POST /fastcheck` (accept)
В `api.txt` любая ошибка = `404 { "message": "not authorized" }`. Юзер не
понимает, что пошло не так. Различать:
```
401 { "message": "not authorized" } — нет/невалидная сессия
404 { "message": "fastcheck not found" } — нет такого номера
403 { "message": "wrong code" } — код неверный
410 { "message": "already used" } — чек уже погашен
410 { "message": "expired" } — просрочен
```
---
## 6. CORS + HTTPS + DNS (блокер)
Сейчас `https://api.fastcheck.store` даёт `ERR_NAME_NOT_RESOLVED`
домен не резолвится. Без этого тестировать нечего.
Минимально:
- Поднять DNS A-запись на `api.fastcheck.store`.
- Валидный TLS-сертификат (Let's Encrypt подойдёт).
- На все эндпоинты + `OPTIONS` отвечать заголовками:
```
Access-Control-Allow-Origin: https://<домен-фронта>
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
```
`OPTIONS` → `204 No Content` без тела.
Подробности — `BACKEND.md` §1.2.
---
## Что **не меняется**
- `GET /ping`
- `GET /websession`, `GET /websession/:id`, `DELETE /websession/:id`
- `GET /fastcheck`
- Формат заголовка `Authorization: {"sessionID":"..."}`
- Telegram-логин через бот `@DexarSupport_bot` с `?start=<sessionId>`
---
## Чеклист для бэкенда
- [ ] DNS + HTTPS + CORS (блокер)
- [ ] `orderId`, `note`, `returnUrl` в `POST /fastcheck` (create)
- [ ] `note` возвращается в `GET /fastcheck`
- [ ] `amount` (+ currency) возвращается в `GET /fastcheck`
- [ ] `GET /fastcheck` принимает `?fastcheck=` как query-param
- [ ] Зафиксировать `amount` в основной единице (рубли)
- [ ] Webhook на `fastcheck.paid` с HMAC-подписью
- [ ] Гранулярные ошибки accept
- [ ] (опц.) Развести create / accept на разные пути

View File

@@ -1,4 +1,30 @@
--------------------------------------------------------------------------------
Package: @angular/forms
License: "MIT"
The MIT License
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/core
License: "MIT"
@@ -327,29 +353,3 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/forms
License: "MIT"
The MIT License
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------

BIN
dist/favicon.ico vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

4
dist/favicon.svg vendored
View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#2563eb"/>
<text x="16" y="23" font-family="Arial, sans-serif" font-size="20" font-weight="bold" fill="white" text-anchor="middle"></text>
</svg>

Before

Width:  |  Height:  |  Size: 256 B

2
dist/index.html vendored
View File

@@ -13,5 +13,5 @@
<style>*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background:#1e40af}</style><link rel="stylesheet" href="styles-4STSJS4C.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-4STSJS4C.css"></noscript></head>
<body>
<app-root></app-root>
<script src="main-S3GKETUH.js" type="module"></script></body>
<link rel="modulepreload" href="chunk-FBABAKVO.js"><script src="main-XKBOOIAP.js" type="module"></script></body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logo_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/logo_small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -3,8 +3,14 @@
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage)
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: 'new',

View File

@@ -8,7 +8,10 @@
<path d="M15 18l-6-6 6-6" />
</svg>
</a>
<h1 class="card__title">Новый Фастчек</h1>
<h1 class="card__title">
Новый
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
</h1>
<p class="card__subtitle">Укажите сумму для пополнения</p>
</div>
@@ -111,7 +114,12 @@
<path d="M12 5v14M5 12h14" />
</svg>
</span>
{{ loading() ? 'Создание…' : 'Создать Фастчек' }}
@if (loading()) {
Создание…
} @else {
Создать&nbsp;
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
}
</button>
</div>

View File

@@ -82,12 +82,12 @@ export class CreatePage {
});
this.router.navigate(['/']);
} else {
this.error.set('Не удалось создать Фастчек.');
this.error.set('Не удалось создать платёж.');
}
},
error: () => {
this.loading.set(false);
this.error.set('Ошибка при создании Фастчека. Попробуйте ещё раз.');
this.error.set('Ошибка при создании платежа. Попробуйте ещё раз.');
}
});
}

View File

@@ -2,27 +2,36 @@
<div class="card">
<div class="card__header">
<h1 class="card__title">Оплата Фастчеком</h1>
<p class="card__subtitle">Введите данные Фастчека или создайте новый</p>
<img class="card__brand" src="/logo_big.png"
alt="fastCHECK" width="220" height="60" />
<p class="card__subtitle">
Введите данные
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
или создайте новый
</p>
</div>
<div class="card__body">
<!-- Fastcheck number + new -->
<div class="field">
<label class="field__label" for="fcNumber">Номер Фастчека</label>
<label class="field__label" for="fcNumber">
Номер
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
</label>
<div class="row">
<input
id="fcNumber"
type="text"
class="input"
[ngModel]="fastcheckNumber()"
(ngModelChange)="fastcheckNumber.set($event)"
(ngModelChange)="onNumberChange($event)"
placeholder="1234-5678-0001"
inputmode="numeric"
autocomplete="off"
maxlength="14"
/>
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый Фастчек">Новый</a>
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый fastCHECK">Новый</a>
</div>
</div>
@@ -41,8 +50,12 @@
step="1"
inputmode="numeric"
placeholder="0"
[readonly]="amountLoading() || fastcheckAmount() !== null"
/>
</div>
@if (amountLoading()) {
<span class="field__hint">Проверяем…</span>
}
</div>
<!-- Code -->
@@ -53,10 +66,10 @@
type="text"
class="input"
[ngModel]="fastcheckCode()"
(ngModelChange)="fastcheckCode.set($event)"
placeholder="0000"
(ngModelChange)="onCodeChange($event)"
placeholder="00000"
inputmode="numeric"
maxlength="8"
maxlength="5"
autocomplete="one-time-code"
/>
@if (error()) {
@@ -64,7 +77,7 @@
}
</div>
<button class="pay-btn" type="button" (click)="pay()">
<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">
@@ -101,9 +114,14 @@
<path d="M20 6L9 17l-5-5" />
</svg>
<h2 class="modal__title">Оплачено</h2>
<p class="modal__sub">Фастчек успешно принят.</p>
<p class="modal__sub">
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
успешно принят.
</p>
</div>
} @else {
<img class="brand-logo brand-logo--small" src="/logo_small.png"
alt="fastCHECK" width="32" height="32" />
<h2 class="modal__title">Войти через Telegram</h2>
<p class="modal__sub">Отсканируйте QR или откройте ссылку</p>

View File

@@ -13,6 +13,13 @@ interface WebSessionResponse {
Status: boolean;
}
interface CheckFastcheckResponse {
fastcheck: string;
amount?: number;
expiration: string;
Status: boolean;
}
@Component({
selector: 'app-fastcheck-page',
imports: [FormsModule, RouterLink],
@@ -31,6 +38,7 @@ export class FastcheckPage {
fastcheckAmount = signal<number | null>(null);
fastcheckCode = signal<string>('');
error = signal<string>('');
amountLoading = signal<boolean>(false);
popupOpen = signal<boolean>(false);
popupLoading = signal<boolean>(false);
@@ -38,6 +46,13 @@ export class FastcheckPage {
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 === 12 && codeDigits.length === 5 && !this.amountLoading();
});
telegramLink = computed(() => {
const sid = this.webSessionId();
@@ -62,12 +77,7 @@ export class FastcheckPage {
}
pay(): void {
if (!this.fastcheckNumber().trim()) {
this.error.set('Введите номер Фастчека');
return;
}
if (!this.fastcheckCode().trim()) {
this.error.set('Введите код Фастчека');
if (!this.canPay()) {
return;
}
this.error.set('');
@@ -148,7 +158,7 @@ export class FastcheckPage {
},
error: () => {
this.popupLoading.set(false);
this.popupError.set('Не удалось принять Фастчек.');
this.popupError.set('Не удалось принять платёж.');
}
});
}
@@ -168,4 +178,64 @@ export class FastcheckPage {
onAmountChange(value: number | null): void {
this.fastcheckAmount.set(value);
}
/** Mask fastcheck number as XXXX-XXXX-XXXX, allow only digits. */
onNumberChange(raw: string): void {
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 12);
const groups: string[] = [];
for (let i = 0; i < digits.length; i += 4) {
groups.push(digits.slice(i, i + 4));
}
const masked = groups.join('-');
this.fastcheckNumber.set(masked);
this.error.set('');
// If number became incomplete, drop the previously fetched amount so the
// user doesn't see a stale value tied to a different (now-edited) number.
if (digits.length < 12 && this.lastLookedUpNumber) {
this.fastcheckAmount.set(null);
this.lastLookedUpNumber = '';
}
// Auto-lookup when 12 digits are entered (and we haven't already looked it up).
if (digits.length === 12 && masked !== this.lastLookedUpNumber) {
this.lookupFastcheck(masked);
}
}
/** Allow only digits, max 5, in the code field. */
onCodeChange(raw: string): void {
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 5);
this.fastcheckCode.set(digits);
this.error.set('');
}
private lookupFastcheck(number: string): void {
this.lastLookedUpNumber = number;
this.amountLoading.set(true);
this.fastcheckAmount.set(null);
// GET /fastcheck — body in GET is non-standard; many HTTP libs strip it.
// The backend should accept ?fastcheck= as a query param too. We send both.
this.http
.request<CheckFastcheckResponse>('GET', `${FASTCHECK_API}/fastcheck`, {
body: { fastcheck: number },
params: { fastcheck: number }
})
.subscribe({
next: (res) => {
this.amountLoading.set(false);
if (res?.Status && typeof res.amount === 'number') {
this.fastcheckAmount.set(res.amount);
} else if (res?.Status === false) {
this.error.set('Платёж не найден или просрочен.');
}
},
error: () => {
this.amountLoading.set(false);
this.error.set('Не удалось проверить номер. Попробуйте ещё раз.');
this.lastLookedUpNumber = '';
}
});
}
}

View File

@@ -0,0 +1,78 @@
<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>
<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)="pay()" [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>

View File

@@ -0,0 +1,60 @@
@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; }
}
.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;
}
}

View File

@@ -0,0 +1,91 @@
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { HttpClient } from '@angular/common/http';
interface LegacyPayResponse {
payload?: 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],
templateUrl: './legacy-pay-page.html',
styleUrl: './legacy-pay-page.scss'
})
export class LegacyPayPage {
private http = inject(HttpClient);
private route = inject(ActivatedRoute);
private readonly LEGACY_API = 'https://qr.vitanova.network:567/qr';
amount = signal<number | null>(null);
note = signal<string>('');
error = signal<string>('');
loading = signal<boolean>(false);
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('Не указан идентификатор платежа (параметр id)');
} else {
this.error.set('Введите корректную сумму');
}
return;
}
this.error.set('');
this.loading.set(true);
const body = {
payment: 'sbp',
amount: this.amount(),
currency: 'rub',
id: this.paymentId(),
note: this.note().trim()
};
this.http.post<LegacyPayResponse>(this.LEGACY_API, body).subscribe({
next: (res) => {
this.loading.set(false);
if (res?.payload) {
window.location.href = res.payload;
} else {
this.error.set('Сервер не вернул ссылку для оплаты.');
}
},
error: () => {
this.loading.set(false);
this.error.set('Ошибка при создании платежа. Попробуйте ещё раз.');
}
});
}
}

View File

@@ -2,14 +2,15 @@
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Оплата через СБП</title>
<title>fastCHECK</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" href="logo_small.png">
<link rel="apple-touch-icon" href="logo_small.png">
</head>
<body>
<app-root></app-root>

View File

@@ -58,6 +58,18 @@
margin: 0;
}
&__brand {
display: block;
margin: 0 auto 10px;
max-width: 220px;
height: auto;
object-fit: contain;
@media (max-width: 480px) {
max-width: 200px;
}
}
&__body {
padding: 24px 22px 18px;
@@ -102,6 +114,14 @@
color: #ef4444;
font-weight: 500;
}
&__hint {
display: block;
margin-top: 6px;
font-size: 13px;
color: #64748b;
font-weight: 500;
}
}
.input-wrap {
@@ -179,6 +199,7 @@
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;
appearance: none;
-webkit-appearance: none;
&:hover { opacity: 0.92; box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45); }
@@ -198,3 +219,43 @@
svg { flex-shrink: 0; }
}
// ─── Brand wordmark: "fastCHECK" inline ─────────────────────────────────────
// "fast" sits a bit smaller and lighter than "CHECK".
.brand {
display: inline-flex;
align-items: baseline;
font-weight: inherit;
letter-spacing: -0.02em;
white-space: nowrap;
font-size: calc(1em + 3px);
&__fast {
font-size: 0.72em;
font-weight: 400;
text-transform: lowercase;
margin-right: 0.05em;
opacity: 0.85;
}
&__check {
font-size: 1em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
}
}
// Standalone logo image (used inside modal/header)
.brand-logo {
display: block;
height: auto;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
&--small {
max-height: 32px;
margin: 0 auto 8px;
}
}