Compare commits
31 Commits
be4e44d102
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a52fd07273 | |||
| fe7fea151a | |||
| e9acbd4898 | |||
| 4841cdf90d | |||
| c2a0675c79 | |||
| e62afe07eb | |||
| 926afc5691 | |||
| 45769ca817 | |||
| 02a33e9b14 | |||
| 9c96370235 | |||
| 9cbb6660f8 | |||
| b1ffd577c5 | |||
| bee56afedc | |||
| ce2c9c42fe | |||
| 17dfad5eaa | |||
| abb4f7b849 | |||
| 097064281a | |||
| 0330e0a212 | |||
| 5147d05ea2 | |||
| 14d9642568 | |||
| 6e7527cf1e | |||
| 957321ae1e | |||
| 11ea0793ba | |||
| ea291525e9 | |||
| 76e02e5ca6 | |||
| d37ca14f69 | |||
| e0df81c071 | |||
| 98423be0c3 | |||
| 1d19ddd47c | |||
| c0b7ac08fb | |||
| 889f289489 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,8 +5,10 @@
|
|||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# Local-only docs and scratch (not for publishing)
|
||||||
|
/docs/
|
||||||
changes.txt
|
changes.txt
|
||||||
api.txt
|
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|||||||
@@ -68,7 +68,8 @@
|
|||||||
"buildTarget": "qr_vitanova:build:production"
|
"buildTarget": "qr_vitanova:build:production"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "qr_vitanova:build:development"
|
"buildTarget": "qr_vitanova:build:development",
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
|
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="sbp-logo">
|
<div class="sbp-logo">
|
||||||
<img src="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg" alt="СБП" />
|
<img src="public/sbp.svg" alt="СБП" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="card__title">Оплата через СБП</h1>
|
<h1 class="card__title">Оплата через СБП</h1>
|
||||||
<p class="card__subtitle">Система быстрых платежей</p>
|
<p class="card__subtitle">Система быстрых платежей</p>
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
id="note"
|
id="note"
|
||||||
class="note-input"
|
class="note-input"
|
||||||
placeholder="Причина платежа..."
|
placeholder="Причина платежа..."
|
||||||
rows="3"
|
rows="2"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
proxy.conf.json
Normal file
23
proxy.conf.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"/proxy/legacy-qr": {
|
||||||
|
"target": "https://qr.vitanova.network:567",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"pathRewrite": { "^/proxy/legacy-qr": "" },
|
||||||
|
"logLevel": "debug"
|
||||||
|
},
|
||||||
|
"/proxy/fastcheck": {
|
||||||
|
"target": "https://api.fastcheck.store",
|
||||||
|
"secure": true,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"pathRewrite": { "^/proxy/fastcheck": "" },
|
||||||
|
"logLevel": "debug"
|
||||||
|
},
|
||||||
|
"/proxy/qr-vitanova": {
|
||||||
|
"target": "https://qr.vitanova.network",
|
||||||
|
"secure": true,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"pathRewrite": { "^/proxy/qr-vitanova": "" },
|
||||||
|
"logLevel": "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
public/example.json
Normal file
26
public/example.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"nspkID": "AD10001UFDAT5FDD9LVPEJVDHN75UHHK",
|
||||||
|
"amount": 100,
|
||||||
|
"currency": "RUB",
|
||||||
|
"order": "",
|
||||||
|
"paymentDetails": "",
|
||||||
|
"qrType": "QRDynamic",
|
||||||
|
"qrExpirationDate": "",
|
||||||
|
"qrExecutionDate": "",
|
||||||
|
"sbpBank": "",
|
||||||
|
"sbpMerchant": "",
|
||||||
|
"sbpMerchantId": "",
|
||||||
|
"sbpOperationId": 633730903,
|
||||||
|
"status": "",
|
||||||
|
"nspkurl": "https://qr.nspk.ru/AD10001UFDAT5FDD9LVPEJVDHN75UHHK",
|
||||||
|
"statusurl": "https://pay.payworld.online/webapi/SbpPaymentStatus?id=3426680585\u0026sbpOperationId=633730903\u0026sector=24819\u0026signature=NjZkZjMyZDBjZTI1Zjc1NWUyZjYxNDRkM2ZjN2ViZWQ2YzcwYTc0NWM1ZWRlMGE1YzQyZWNlOTVjNWY1ODUxYg%3D%3D",
|
||||||
|
"redirectUrl": "",
|
||||||
|
"qrDescription": "Payment for shop 00897f30-414f-4b89-b1af-25596b15411c",
|
||||||
|
"additionalInfo": "",
|
||||||
|
"TTL": 0,
|
||||||
|
"callbackUrl": "",
|
||||||
|
"retry": 0,
|
||||||
|
"partnerID": "",
|
||||||
|
"partnerqrID": "00897f30-414f-4b89-b1af-25596b15411c",
|
||||||
|
"requestIP": ""
|
||||||
|
}
|
||||||
@@ -62,7 +62,8 @@
|
|||||||
"create_btn": "Create",
|
"create_btn": "Create",
|
||||||
"amount_hint": "Allowed amount:",
|
"amount_hint": "Allowed amount:",
|
||||||
"qr_label": "Scan QR to pay",
|
"qr_label": "Scan QR to pay",
|
||||||
"qr_waiting": "Waiting for payment confirmation…"
|
"qr_waiting": "Waiting for payment confirmation…",
|
||||||
|
"payment_done": "Payment completed successfully."
|
||||||
},
|
},
|
||||||
"sbp": {
|
"sbp": {
|
||||||
"title": "Pay via SBP",
|
"title": "Pay via SBP",
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"create_btn": "Ստեղծել",
|
"create_btn": "Ստեղծել",
|
||||||
"amount_hint": "Թույլատրելի գումար՝",
|
"amount_hint": "Թույլատրելի գումար՝",
|
||||||
"qr_label": "Սկանավորեք QR-կոդը վճարելու համար",
|
"qr_label": "Սկանավորեք QR-կոդը վճարելու համար",
|
||||||
"qr_waiting": "Սպասում ենք վճարման հաստատման…"
|
"qr_waiting": "Սպասում ենք վճարման հաստատման…",
|
||||||
|
"payment_done": "Վճարումը հաջողությամբ ավարտվեց."
|
||||||
},
|
},
|
||||||
"sbp": {
|
"sbp": {
|
||||||
"title": "Վճարել SBP-ով",
|
"title": "Վճարել SBP-ով",
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"create_btn": "Создать",
|
"create_btn": "Создать",
|
||||||
"amount_hint": "Допустимая сумма:",
|
"amount_hint": "Допустимая сумма:",
|
||||||
"qr_label": "Отсканируйте QR для оплаты",
|
"qr_label": "Отсканируйте QR для оплаты",
|
||||||
"qr_waiting": "Ожидаем подтверждения оплаты…"
|
"qr_waiting": "Ожидаем подтверждения оплаты…",
|
||||||
|
"payment_done": "Оплата успешно завершена."
|
||||||
},
|
},
|
||||||
"sbp": {
|
"sbp": {
|
||||||
"title": "Оплата через СБП",
|
"title": "Оплата через СБП",
|
||||||
|
|||||||
302
public/index (4).html
Normal file
302
public/index (4).html
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Оплата через СБП</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<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;
|
||||||
|
background: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
||||||
|
.page { align-items: flex-end; padding: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0,0,0,.18);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card { border-radius: 24px 24px 0 0; max-width: 100%; box-shadow: 0 -8px 40px rgba(0,0,0,.15); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header {
|
||||||
|
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) { .card__header { padding: 28px 24px 24px; } }
|
||||||
|
|
||||||
|
.card__title { color: #fff; font-size: 22px; font-weight: 700; margin: 14px 0 4px; letter-spacing: -.3px; }
|
||||||
|
.card__subtitle { color: rgba(255,255,255,.7); font-size: 13px; }
|
||||||
|
|
||||||
|
.card__body { padding: 28px 28px 20px; }
|
||||||
|
@media (max-width: 480px) { .card__body { padding: 24px 20px 16px; } }
|
||||||
|
|
||||||
|
.card__footer { padding: 0 28px 24px; display: flex; justify-content: center; }
|
||||||
|
@media (max-width: 480px) { .card__footer { padding: 0 20px 32px; } }
|
||||||
|
|
||||||
|
.sbp-logo {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(255,255,255,.15); backdrop-filter: blur(8px);
|
||||||
|
border-radius: 16px; padding: 12px 20px;
|
||||||
|
border: 1px solid rgba(255,255,255,.25);
|
||||||
|
}
|
||||||
|
.sbp-logo img { height: 40px; display: block; }
|
||||||
|
@media (max-width: 480px) { .sbp-logo img { height: 34px; } }
|
||||||
|
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field__label {
|
||||||
|
display: block; font-size: 13px; font-weight: 600; color: #64748b;
|
||||||
|
margin-bottom: 8px; text-transform: uppercase; letter-spacing: .6px;
|
||||||
|
}
|
||||||
|
.field__error { display: block; margin-top: 6px; font-size: 13px; color: #ef4444; font-weight: 500; }
|
||||||
|
.field__error:empty { display: none; }
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
border: 2px solid #e2e8f0; border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border-color .2s, box-shadow .2s, background .2s;
|
||||||
|
}
|
||||||
|
.input-wrap:focus-within {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 4px rgba(37,99,235,.12);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.input-wrap--error { border-color: #ef4444; box-shadow: 0 0 0 4px rgba(239,68,68,.1); }
|
||||||
|
.input-wrap__prefix { padding: 0 4px 0 18px; font-size: 26px; font-weight: 700; color: #2563eb; user-select: none; line-height: 1; }
|
||||||
|
.input-wrap__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;
|
||||||
|
appearance: textfield; -moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.input-wrap__input::placeholder { color: #cbd5e1; }
|
||||||
|
.input-wrap__input::-webkit-outer-spin-button,
|
||||||
|
.input-wrap__input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
@media (max-width: 480px) { .input-wrap__input { font-size: 28px; padding: 14px 14px 14px 6px; } }
|
||||||
|
|
||||||
|
.currency-badge {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
background: #f1f5f9; border-radius: 12px; padding: 12px 16px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.currency-badge__flag { font-size: 22px; line-height: 1; }
|
||||||
|
.currency-badge__code { font-size: 15px; font-weight: 700; color: #0f172a; }
|
||||||
|
.currency-badge__name { font-size: 13px; color: #64748b; margin-left: auto; }
|
||||||
|
|
||||||
|
.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: #fff; border: none; border-radius: 14px;
|
||||||
|
font-size: 17px; font-weight: 700; letter-spacing: .2px;
|
||||||
|
cursor: pointer; font-family: inherit;
|
||||||
|
transition: opacity .15s, transform .1s, box-shadow .15s;
|
||||||
|
box-shadow: 0 6px 20px rgba(37,99,235,.38);
|
||||||
|
}
|
||||||
|
.pay-btn:hover { opacity: .92; box-shadow: 0 8px 28px rgba(37,99,235,.45); }
|
||||||
|
.pay-btn:active { transform: scale(.98); opacity: .88; }
|
||||||
|
.pay-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
|
||||||
|
.pay-btn__icon { display: flex; align-items: center; }
|
||||||
|
@media (max-width: 480px) { .pay-btn { padding: 16px 24px; font-size: 16px; } }
|
||||||
|
|
||||||
|
.secure-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: #94a3b8; font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 .2s, box-shadow .2s, background .2s;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.note-input::placeholder { color: #cbd5e1; font-weight: 400; }
|
||||||
|
.note-input:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 4px rgba(37,99,235,.12);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="sbp-logo">
|
||||||
|
<img src="sbp.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" id="inputWrap">
|
||||||
|
<span class="input-wrap__prefix">₽</span>
|
||||||
|
<input
|
||||||
|
id="amount"
|
||||||
|
type="number"
|
||||||
|
class="input-wrap__input"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
autofocus
|
||||||
|
value="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="field__error" id="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"
|
||||||
|
placeholder="Причина платежа..."
|
||||||
|
rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="pay-btn" id="payBtn" onclick="goToPayment()">
|
||||||
|
<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>
|
||||||
|
<span id="btnText">Перейти к оплате</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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_URL = 'https://qr.vitanova.network:567/qr';
|
||||||
|
|
||||||
|
const amountInput = document.getElementById('amount');
|
||||||
|
const noteInput = document.getElementById('note');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const payBtn = document.getElementById('payBtn');
|
||||||
|
const btnText = document.getElementById('btnText');
|
||||||
|
const inputWrap = document.getElementById('inputWrap');
|
||||||
|
|
||||||
|
amountInput.addEventListener('input', function () {
|
||||||
|
if (Number(this.value) > 0) {
|
||||||
|
errorEl.textContent = '';
|
||||||
|
inputWrap.classList.remove('input-wrap--error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getPaymentId() {
|
||||||
|
return new URLSearchParams(window.location.search).get('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
payBtn.disabled = loading;
|
||||||
|
btnText.textContent = loading
|
||||||
|
? 'Подождите...'
|
||||||
|
: 'Перейти к оплате';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
inputWrap.classList.add('input-wrap--error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPayment() {
|
||||||
|
const amount = Number(amountInput.value);
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
showError('Введите корректную сумму');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getPaymentId();
|
||||||
|
if (!id) {
|
||||||
|
showError('Не указан идентификатор платежа (параметр id)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorEl.textContent = '';
|
||||||
|
inputWrap.classList.remove('input-wrap--error');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
qrtype: 'QRDynamic',
|
||||||
|
amount: amount,
|
||||||
|
currency: 'RUB',
|
||||||
|
partnerqrID: id,
|
||||||
|
qrDescription: noteInput.value.trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.nspkurl) {
|
||||||
|
window.location.href = data.nspkurl;
|
||||||
|
} else {
|
||||||
|
showError("nspkurl не найден в ответе сервера");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showError("Ошибка запроса");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
public/sbp.svg
Normal file
1
public/sbp.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 10 KiB |
@@ -1,11 +1,6 @@
|
|||||||
/**
|
import { isDevMode } from '@angular/core';
|
||||||
* 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.
|
// QR Vitanova API (dynamic QR, settings, polling).
|
||||||
export const QR_API = 'https://qr.vitanova.network:567/qr';
|
export const QR_VITANOVA_API = isDevMode()
|
||||||
|
? '/proxy/qr-vitanova/api'
|
||||||
// New QR Vitanova API (dynamic QR, settings, polling).
|
: 'https://qr.vitanova.network/api';
|
||||||
export const QR_VITANOVA_API = 'https://qr.vitanova.network/api';
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<app-site-header />
|
<!-- <app-site-header /> -->
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</main>
|
</main>
|
||||||
<app-site-footer />
|
<!-- <app-site-footer /> -->
|
||||||
@@ -3,17 +3,6 @@
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
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',
|
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/create-page/create-page').then((m) => m.CreatePage)
|
import('./pages/create-page/create-page').then((m) => m.CreatePage)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { SiteHeader } from './site-header/site-header';
|
|
||||||
import { SiteFooter } from './site-footer/site-footer';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, SiteHeader, SiteFooter],
|
imports: [RouterOutlet],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
|
||||||
|
|
||||||
export interface FastcheckData {
|
|
||||||
fastcheck: string;
|
|
||||||
amount: number | null;
|
|
||||||
code: string;
|
|
||||||
expiration?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared state between the home (Fastcheck) page and the create-new page.
|
|
||||||
* When a new fastcheck is created via POST /fastcheck, the create page stores
|
|
||||||
* the returned data here and the home page reads it to autofill its fields.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class FastcheckService {
|
|
||||||
readonly created = signal<FastcheckData | null>(null);
|
|
||||||
|
|
||||||
setCreated(data: FastcheckData): void {
|
|
||||||
this.created.set(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
consume(): FastcheckData | null {
|
|
||||||
const value = this.created();
|
|
||||||
this.created.set(null);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<a class="back" routerLink="/" [attr.aria-label]="'create.back_label' | translate">
|
<h1 class="card__title">Оплата через СБП</h1>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
||||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<h1 class="card__title">
|
|
||||||
{{ 'create.title' | translate }}
|
|
||||||
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
|
||||||
</h1>
|
|
||||||
<p class="card__subtitle">{{ 'create.subtitle' | translate }}</p>
|
<p class="card__subtitle">{{ 'create.subtitle' | translate }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,13 +15,13 @@
|
|||||||
<button type="button" class="method" [class.method--active]="payment() === 'sbp'"
|
<button type="button" class="method" [class.method--active]="payment() === 'sbp'"
|
||||||
(click)="selectPayment('sbp', true)" aria-label="СБП">
|
(click)="selectPayment('sbp', true)" aria-label="СБП">
|
||||||
<img class="method__logo"
|
<img class="method__logo"
|
||||||
src="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg"
|
src="/sbp.svg"
|
||||||
alt="СБП" />
|
alt="СБП" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="method method--disabled" disabled aria-label="WeChat Pay">
|
<button type="button" class="method method--disabled" disabled aria-label="WeChat Pay">
|
||||||
<img class="method__logo" src="/wechat-pay.svg" alt="WeChat Pay" />
|
<img class="method__logo" src="/wechat-pay.svg" alt="WeChat Pay" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="method method--disabled" disabled aria-label="Alipay">
|
<button type="button" class="method method--disabled" disabled aria-label="Alipay">
|
||||||
<img class="method__logo" src="/alipay.svg" alt="Alipay" />
|
<img class="method__logo" src="/alipay.svg" alt="Alipay" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="method method--disabled" disabled aria-label="Visa">
|
<button type="button" class="method method--disabled" disabled aria-label="Visa">
|
||||||
@@ -102,7 +93,7 @@
|
|||||||
[ngModel]="note()"
|
[ngModel]="note()"
|
||||||
(ngModelChange)="onNoteChange($event)"
|
(ngModelChange)="onNoteChange($event)"
|
||||||
[placeholder]="'create.note_placeholder' | translate"
|
[placeholder]="'create.note_placeholder' | translate"
|
||||||
rows="3"
|
rows="2"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,8 +108,7 @@
|
|||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
{{ 'create.creating' | translate }}
|
{{ 'create.creating' | translate }}
|
||||||
} @else {
|
} @else {
|
||||||
{{ 'create.create_btn' | translate }}
|
{{ 'create.create_btn' | translate }}
|
||||||
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -140,6 +130,9 @@
|
|||||||
@if (qrPolling()) {
|
@if (qrPolling()) {
|
||||||
<p class="qr-modal__hint">{{ 'create.qr_waiting' | translate }}</p>
|
<p class="qr-modal__hint">{{ 'create.qr_waiting' | translate }}</p>
|
||||||
}
|
}
|
||||||
|
@if (paymentDone()) {
|
||||||
|
<p class="qr-modal__hint qr-modal__hint--done">{{ 'create.payment_done' | translate }}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,15 @@
|
|||||||
|
|
||||||
// ─── Methods row ────────────────────────────────────────────────────────────
|
// ─── Methods row ────────────────────────────────────────────────────────────
|
||||||
.methods {
|
.methods {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -63,6 +69,8 @@
|
|||||||
transition: border-color .15s, background .15s, transform .1s, box-shadow .15s;
|
transition: border-color .15s, background .15s, transform .1s, box-shadow .15s;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
flex: 0 0 72px;
|
||||||
|
width: 72px;
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
height: 52px;
|
height: 52px;
|
||||||
@@ -104,8 +112,14 @@
|
|||||||
// ─── Currency chips ─────────────────────────────────────────────────────────
|
// ─── Currency chips ─────────────────────────────────────────────────────────
|
||||||
.currencies {
|
.currencies {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
@@ -220,11 +234,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 13px;
|
font-size: 11px !important;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 8px auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__img {
|
&__img {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { FastcheckService } from '../../fastcheck.service';
|
import { QR_VITANOVA_API } from '../../api';
|
||||||
import { FASTCHECK_API, QR_VITANOVA_API } from '../../api';
|
|
||||||
import { TranslatePipe } from '../../translate/translate.pipe';
|
import { TranslatePipe } from '../../translate/translate.pipe';
|
||||||
import { TranslationService } from '../../translate/translation.service';
|
import { TranslationService } from '../../translate/translation.service';
|
||||||
|
|
||||||
@@ -11,9 +9,19 @@ type PaymentMethod = 'sbp';
|
|||||||
type Currency = 'RUB';
|
type Currency = 'RUB';
|
||||||
|
|
||||||
interface SettingsResponse {
|
interface SettingsResponse {
|
||||||
|
sbp?: boolean;
|
||||||
|
wechat?: boolean;
|
||||||
|
visa?: boolean;
|
||||||
|
mastercard?: boolean;
|
||||||
|
alipay?: boolean;
|
||||||
|
rubles?: boolean;
|
||||||
|
usd?: boolean;
|
||||||
|
euro?: boolean;
|
||||||
|
cny?: boolean;
|
||||||
|
dram?: boolean;
|
||||||
minAmount?: number;
|
minAmount?: number;
|
||||||
maxAmount?: number;
|
maxAmount?: number;
|
||||||
[key: string]: unknown;
|
qrTTL?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateQrResponse {
|
interface CreateQrResponse {
|
||||||
@@ -23,44 +31,25 @@ interface CreateQrResponse {
|
|||||||
nspkurl?: string; // actual field name in real responses
|
nspkurl?: string; // actual field name in real responses
|
||||||
qrUrl?: string;
|
qrUrl?: string;
|
||||||
status?: string; // e.g. "REGISTERED"
|
status?: string; // e.g. "REGISTERED"
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QrStatusResponse {
|
interface QrStatusResponse {
|
||||||
status?: string; // "REGISTERED" | "NEW" | "APPROVED" | "REJECTED" | "COMPLETED"
|
status?: string; // "REGISTERED" | "NEW" | "APPROVED" | "REJECTED" | "COMPLETED"
|
||||||
nspkurl?: string;
|
|
||||||
nspkID?: string;
|
|
||||||
[key: string]: unknown;
|
[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({
|
@Component({
|
||||||
selector: 'app-create-page',
|
selector: 'app-create-page',
|
||||||
imports: [FormsModule, RouterLink, TranslatePipe],
|
imports: [FormsModule, TranslatePipe],
|
||||||
templateUrl: './create-page.html',
|
templateUrl: './create-page.html',
|
||||||
styleUrl: './create-page.scss'
|
styleUrl: './create-page.scss'
|
||||||
})
|
})
|
||||||
export class CreatePage {
|
export class CreatePage {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private store = inject(FastcheckService);
|
|
||||||
private router = inject(Router);
|
|
||||||
private i18n = inject(TranslationService);
|
private i18n = inject(TranslationService);
|
||||||
|
private readonly sites: Record<string, string> = {
|
||||||
|
'51': 'fastcheck.store'
|
||||||
|
};
|
||||||
|
|
||||||
private t(key: string): string { return this.i18n.translate(key); }
|
private t(key: string): string { return this.i18n.translate(key); }
|
||||||
|
|
||||||
@@ -72,7 +61,6 @@ export class CreatePage {
|
|||||||
note = signal<string>('');
|
note = signal<string>('');
|
||||||
error = signal<string>('');
|
error = signal<string>('');
|
||||||
loading = signal<boolean>(false);
|
loading = signal<boolean>(false);
|
||||||
settingsLoaded = signal<boolean>(false);
|
|
||||||
|
|
||||||
currency = signal<Currency>('RUB');
|
currency = signal<Currency>('RUB');
|
||||||
payment = signal<PaymentMethod>('sbp');
|
payment = signal<PaymentMethod>('sbp');
|
||||||
@@ -91,8 +79,8 @@ export class CreatePage {
|
|||||||
qrImageUrl = signal<string | null>(null);
|
qrImageUrl = signal<string | null>(null);
|
||||||
qrPolling = signal<boolean>(false);
|
qrPolling = signal<boolean>(false);
|
||||||
qrStatus = signal<string>('');
|
qrStatus = signal<string>('');
|
||||||
|
paymentDone = signal<boolean>(false);
|
||||||
private pollHandle: ReturnType<typeof setInterval> | null = null;
|
private pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
private activeQrId = '';
|
|
||||||
|
|
||||||
/** Auth credentials passed by the host page as URL params. */
|
/** Auth credentials passed by the host page as URL params. */
|
||||||
private get authKey(): string {
|
private get authKey(): string {
|
||||||
@@ -101,25 +89,32 @@ export class CreatePage {
|
|||||||
private get userId(): string {
|
private get userId(): string {
|
||||||
return new URLSearchParams(window.location.search).get('userid-value') ?? '';
|
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 {
|
private get reference(): string {
|
||||||
return new URLSearchParams(window.location.search).get('ref') ?? window.location.hostname;
|
return new URLSearchParams(window.location.search).get('ref') ?? window.location.hostname;
|
||||||
}
|
}
|
||||||
|
private get partnerqrID(): string {
|
||||||
|
return new URLSearchParams(window.location.search).get('id') ?? '';
|
||||||
|
}
|
||||||
|
private get fromSite(): string {
|
||||||
|
return new URLSearchParams(window.location.search).get('from') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMobile(): boolean {
|
||||||
|
return window.innerWidth < 768;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadSettings(): void {
|
private loadSettings(): void {
|
||||||
this.http.get<SettingsResponse>(`${QR_VITANOVA_API}/settings`).subscribe({
|
// Fetch limits from /qr/settings. If the call fails, keep defaults.
|
||||||
|
const url = `${QR_VITANOVA_API}/qr/settings`;
|
||||||
|
this.http.get<SettingsResponse>(url).subscribe({
|
||||||
next: (s) => {
|
next: (s) => {
|
||||||
if (typeof s?.minAmount === 'number') this.minAmount.set(s.minAmount);
|
if (typeof s?.minAmount === 'number') this.minAmount.set(s.minAmount);
|
||||||
if (typeof s?.maxAmount === 'number') this.maxAmount.set(s.maxAmount);
|
if (typeof s?.maxAmount === 'number') this.maxAmount.set(s.maxAmount);
|
||||||
this.settingsLoaded.set(true);
|
}
|
||||||
},
|
|
||||||
error: () => this.settingsLoaded.set(true) // proceed with defaults
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +129,12 @@ export class CreatePage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const partnerqrID = this.partnerqrID;
|
||||||
|
if (!partnerqrID) {
|
||||||
|
this.error.set(this.t('errors.lookup_failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.error.set('');
|
this.error.set('');
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
|
||||||
@@ -141,8 +142,6 @@ export class CreatePage {
|
|||||||
if (this.authKey) headers['authorization-key'] = this.authKey;
|
if (this.authKey) headers['authorization-key'] = this.authKey;
|
||||||
if (this.userId) headers['userid-value'] = this.userId;
|
if (this.userId) headers['userid-value'] = this.userId;
|
||||||
|
|
||||||
const partnerqrID = generateUUID();
|
|
||||||
|
|
||||||
this.http
|
this.http
|
||||||
.post<CreateQrResponse>(
|
.post<CreateQrResponse>(
|
||||||
`${QR_VITANOVA_API}/qr`,
|
`${QR_VITANOVA_API}/qr`,
|
||||||
@@ -153,7 +152,8 @@ export class CreatePage {
|
|||||||
partnerqrID,
|
partnerqrID,
|
||||||
qrDescription: this.note().trim(),
|
qrDescription: this.note().trim(),
|
||||||
Userid: this.userId,
|
Userid: this.userId,
|
||||||
Reference: this.reference
|
Reference: this.reference,
|
||||||
|
RedirectUrl: `https://fastcheck.store?id=fast-c202-4062-bcfb-8b4c8cc59adc`
|
||||||
},
|
},
|
||||||
{ headers }
|
{ headers }
|
||||||
)
|
)
|
||||||
@@ -164,8 +164,13 @@ export class CreatePage {
|
|||||||
// Real API uses 'nspkurl'; doc says 'Payload' — try both
|
// Real API uses 'nspkurl'; doc says 'Payload' — try both
|
||||||
const nspkUrl = res?.nspkurl ?? res?.Payload;
|
const nspkUrl = res?.nspkurl ?? res?.Payload;
|
||||||
this.qrStatus.set(res?.status ?? '');
|
this.qrStatus.set(res?.status ?? '');
|
||||||
|
|
||||||
|
if (nspkUrl && this.isMobile) {
|
||||||
|
window.location.href = nspkUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (qrId || nspkUrl) {
|
if (qrId || nspkUrl) {
|
||||||
this.activeQrId = qrId;
|
|
||||||
const qrData = nspkUrl
|
const qrData = nspkUrl
|
||||||
? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(nspkUrl)}`
|
? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(nspkUrl)}`
|
||||||
: (res.qrUrl ?? null);
|
: (res.qrUrl ?? null);
|
||||||
@@ -187,14 +192,13 @@ export class CreatePage {
|
|||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.qrPolling.set(true);
|
this.qrPolling.set(true);
|
||||||
this.pollHandle = setInterval(() => {
|
this.pollHandle = setInterval(() => {
|
||||||
this.http.get<QrStatusResponse>(`${QR_VITANOVA_API}/qr/dynamic/${qrId}`)
|
this.http.get<QrStatusResponse>(`${QR_VITANOVA_API}/qr/dynamic/${encodeURIComponent(this.partnerqrID)}/${qrId}`)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
const st = res?.status ?? '';
|
const st = res?.status ?? '';
|
||||||
this.qrStatus.set(st);
|
this.qrStatus.set(st);
|
||||||
if (st === 'COMPLETED' || st === 'APPROVED') {
|
if (st === 'COMPLETED' || st === 'APPROVED') {
|
||||||
this.stopPolling();
|
this.handlePaymentSuccess(res);
|
||||||
this.createFastcheck();
|
|
||||||
} else if (st === 'REJECTED') {
|
} else if (st === 'REJECTED') {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.error.set(this.t('errors.payment_failed'));
|
this.error.set(this.t('errors.payment_failed'));
|
||||||
@@ -202,7 +206,10 @@ export class CreatePage {
|
|||||||
}
|
}
|
||||||
// REGISTERED / NEW / '' — keep polling
|
// REGISTERED / NEW / '' — keep polling
|
||||||
},
|
},
|
||||||
error: () => undefined
|
error: () => {
|
||||||
|
this.closeQr();
|
||||||
|
this.error.set('оплата не прошла');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
@@ -215,34 +222,6 @@ export class CreatePage {
|
|||||||
this.qrPolling.set(false);
|
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 {
|
onAmountChange(value: number | null): void {
|
||||||
this.amount.set(value || null);
|
this.amount.set(value || null);
|
||||||
if (value && value > 0) this.error.set('');
|
if (value && value > 0) this.error.set('');
|
||||||
@@ -252,13 +231,52 @@ export class CreatePage {
|
|||||||
this.note.set(value);
|
this.note.set(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeQr(): void {
|
private handlePaymentSuccess(paidQr: QrStatusResponse): void {
|
||||||
|
this.stopPolling();
|
||||||
this.qrImageUrl.set(null);
|
this.qrImageUrl.set(null);
|
||||||
this.qrPolling.set(false);
|
|
||||||
this.qrStatus.set('');
|
this.qrStatus.set('');
|
||||||
if (this.pollHandle !== null) {
|
this.paymentDone.set(true);
|
||||||
clearInterval(this.pollHandle);
|
|
||||||
this.pollHandle = null;
|
const id = this.partnerqrID;
|
||||||
|
if (!id) {
|
||||||
|
this.redirectToSource();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.http
|
||||||
|
.post(`https://fastcheck.store/api/fastcheck/settings/${encodeURIComponent(id)}`, paidQr)
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.redirectToSource(id),
|
||||||
|
error: () => this.redirectToSource(id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private redirectToSource(id?: string): void {
|
||||||
|
const withId = (target: string): string => {
|
||||||
|
if (!id) return target;
|
||||||
|
|
||||||
|
const normalizedTarget = /^https?:\/\//i.test(target) ? target : `https://${target}`;
|
||||||
|
const url = new URL(normalizedTarget);
|
||||||
|
url.searchParams.set('id', id);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const from = this.fromSite.trim();
|
||||||
|
const target = this.sites[from];
|
||||||
|
if (target) {
|
||||||
|
window.location.href = withId(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
window.history.back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeQr(): void {
|
||||||
|
this.stopPolling();
|
||||||
|
this.qrImageUrl.set(null);
|
||||||
|
this.qrStatus.set('');
|
||||||
|
this.paymentDone.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
<div class="qr">
|
|
||||||
@if (popupLoading() && !webSessionId()) {
|
|
||||||
<div class="qr__placeholder">{{ 'fastcheck.modal_loading' | translate }}</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>
|
|
||||||
{{ '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>
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
@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; }
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
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)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<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 (qrPayUrl()) {
|
|
||||||
<div class="qr-pay">
|
|
||||||
<img
|
|
||||||
[src]="'https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=' + qrPayUrl()"
|
|
||||||
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() || !!qrPayUrl()">
|
|
||||||
<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>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Component, computed, inject, 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 = 'https://qr.vitanova.network:567/qr';
|
|
||||||
|
|
||||||
amount = signal<number | null>(null);
|
|
||||||
note = signal<string>('');
|
|
||||||
error = signal<string>('');
|
|
||||||
loading = signal<boolean>(false);
|
|
||||||
qrPayUrl = signal<string>('');
|
|
||||||
|
|
||||||
paymentId = signal<string>('');
|
|
||||||
|
|
||||||
readonly isMobile: boolean = typeof navigator !== 'undefined'
|
|
||||||
&& /android|iphone|ipad|ipod|mobile/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
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 = {
|
|
||||||
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?.nspkurl) {
|
|
||||||
if (this.isMobile) {
|
|
||||||
window.location.href = res.nspkurl;
|
|
||||||
} else {
|
|
||||||
this.qrPayUrl.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'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,6 +52,6 @@
|
|||||||
|
|
||||||
<div class="site-footer__bottom">
|
<div class="site-footer__bottom">
|
||||||
<p>© {{ year }} {{ 'footer.rights' | translate }}</p>
|
<p>© {{ year }} {{ 'footer.rights' | translate }}</p>
|
||||||
<p>{{ 'footer.director' | translate }}</p>
|
<!-- <p>{{ 'footer.director' | translate }}</p> -->
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
|
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<a class="site-header__brand" routerLink="/" (click)="closeMenu()">
|
<a class="site-header__brand" routerLink="/" (click)="closeMenu()">
|
||||||
<img src="/logo_small.png" alt="fastCHECK" width="32" height="32" />
|
<img src="/logo_small.png" alt="SBP Pay" width="22" height="22" />
|
||||||
<span class="site-header__wordmark">
|
|
||||||
<span class="wm-fast">fast</span><span class="wm-check">CHECK</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop nav -->
|
<!-- Desktop nav -->
|
||||||
@@ -71,7 +68,7 @@
|
|||||||
<div class="mobile-overlay" (click)="closeMenu()">
|
<div class="mobile-overlay" (click)="closeMenu()">
|
||||||
<nav class="mobile-panel" (click)="$event.stopPropagation()" [attr.aria-label]="'header.aria_menu' | translate">
|
<nav class="mobile-panel" (click)="$event.stopPropagation()" [attr.aria-label]="'header.aria_menu' | translate">
|
||||||
<div class="mobile-panel__header">
|
<div class="mobile-panel__header">
|
||||||
<span class="mobile-panel__title">fastCHECK</span>
|
<span class="mobile-panel__title">SBP Pay</span>
|
||||||
<button type="button" class="mobile-panel__close" (click)="closeMenu()" [attr.aria-label]="'header.aria_close' | translate">
|
<button type="button" class="mobile-panel__close" (click)="closeMenu()" [attr.aria-label]="'header.aria_close' | translate">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 32px;
|
width: 22px;
|
||||||
height: 32px;
|
height: 22px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>fastCHECK</title>
|
<title>QR Vitanova</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user