This commit is contained in:
sdarbinyan
2026-05-06 23:26:00 +04:00
parent 34f6c80e57
commit 742b2665e9
91 changed files with 6310 additions and 4723 deletions

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

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

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

View 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:0019:00 МСК</span>
</div>
</div>
</section>
</div>
</div>

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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' },
];
}