This commit is contained in:
2026-04-30 01:17:17 +04:00
parent dc0f0409af
commit ed78bb603b
24 changed files with 1554 additions and 410 deletions

View 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>
}

View 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; }
}

View 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);
}
}