Compare commits

...

24 Commits

Author SHA1 Message Date
sdarbinyan
bc174f7cba change api check 2026-06-01 02:15:38 +04:00
sdarbinyan
7c7cdf8da2 checking api 2026-06-01 00:21:58 +04:00
sdarbinyan
0d5cc8b28f style changes 2026-05-31 23:52:32 +04:00
sdarbinyan
05c739cd56 removed unused 2026-05-31 23:35:44 +04:00
sdarbinyan
a125732979 changed api 2026-05-31 23:23:44 +04:00
sdarbinyan
1d44edf4eb bots id 2026-05-25 16:18:43 +04:00
sdarbinyan
cc15d6521d changes 2026-05-25 00:38:22 +04:00
sdarbinyan
81bef8775e api change 2026-05-19 23:42:43 +04:00
sdarbinyan
a0ffcf0f86 checking auth 2026-05-15 13:41:11 +04:00
sdarbinyan
5d58a387ca ccpndition 2026-05-14 18:27:15 +04:00
sdarbinyan
60ccdacc63 added query param 2026-05-14 17:18:10 +04:00
sdarbinyan
1e4f0b6964 api 2026-05-14 14:19:29 +04:00
sdarbinyan
3a2e1bf65d api 2026-05-14 14:17:10 +04:00
sdarbinyan
b6df88d266 get 2026-05-14 13:07:39 +04:00
sdarbinyan
f1f2ccc777 changed api 2026-05-14 12:26:03 +04:00
sdarbinyan
c15a2317f8 changes 2026-05-14 00:52:27 +04:00
sdarbinyan
9705849aed pay btn 2026-05-13 16:46:13 +04:00
sdarbinyan
74dd52335f disabled btn 2026-05-13 14:48:38 +04:00
sdarbinyan
2554935875 status check 2026-05-13 14:47:02 +04:00
sdarbinyan
e9b50078fe api settings 2026-05-13 10:45:18 +04:00
sdarbinyan
985ec5db7f changes 2026-05-13 00:53:27 +04:00
sdarbinyan
e04514f97b cleaning 2026-05-08 23:43:44 +04:00
sdarbinyan
76e994e757 query 2026-05-08 23:35:20 +04:00
sdarbinyan
539d0b7b2e removed director 2026-05-08 23:15:29 +04:00
29 changed files with 1986 additions and 1015 deletions

View File

@@ -1,6 +1,6 @@
# Fastcheck Backend — требования к серверу
Документ для команды бэкенда. Описывает, что должен реализовать сервер `api.fastcheck.store`, чтобы веб-фронт (этот репозиторий) полностью заработал.
Документ для команды бэкенда. Описывает, что должен реализовать сервер `fastcheck.store/api`, чтобы веб-фронт (этот репозиторий) полностью заработал.
---
@@ -8,7 +8,7 @@
### 1.1 Транспорт
- **Протокол**: HTTPS обязателен (валидный TLS-сертификат, Let's Encrypt или иной).
- **Хост**: `api.fastcheck.store` (или другой — тогда поправить `FASTCHECK_API` в `src/app/api.ts`).
- **Хост**: `fastcheck.store/api` (или другой — тогда поправить `FASTCHECK_API` в `src/app/api.ts`).
- **Формат тел запроса/ответа**: `application/json; charset=utf-8`.
### 1.2 CORS — **критично**
@@ -44,7 +44,7 @@ Authorization: {"sessionID":"1AF3781BF6B94604B771AEA1D44FA63A"}
## 2. Эндпоинты
База: `https://api.fastcheck.store`
База: `https://fastcheck.store/api`
### 2.1 `GET /ping`
Healthcheck. Ответ: `200 { "message": "pong" }`. Без авторизации.
@@ -192,7 +192,7 @@ Logout / закрытие попапа.
## 4. Чеклист «готово к проду»
- [ ] HTTPS с валидным сертификатом на `api.fastcheck.store`.
- [ ] HTTPS с валидным сертификатом на `fastcheck.store/api`.
- [ ] CORS разрешает домен фронта на всех 6 эндпоинтах + OPTIONS.
- [ ] `GET /ping` отвечает.
- [ ] Полный цикл: `GET /websession` → бот ставит `Status:true``GET /websession/:id` это видит.

View File

@@ -137,11 +137,11 @@ Body:
## 6. CORS + HTTPS + DNS (блокер)
Сейчас `https://api.fastcheck.store` даёт `ERR_NAME_NOT_RESOLVED`
Сейчас `https://fastcheck.store/api` даёт `ERR_NAME_NOT_RESOLVED`
домен не резолвится. Без этого тестировать нечего.
Минимально:
- Поднять DNS A-запись на `api.fastcheck.store`.
- Поднять DNS A-запись на `fastcheck.store`.
- Валидный TLS-сертификат (Let's Encrypt подойдёт).
- На все эндпоинты + `OPTIONS` отвечать заголовками:
```

View File

@@ -20,7 +20,7 @@ npm run build # production-сборка
Эндпоинты заданы в `src/app/api.ts`:
- `FASTCHECK_API``https://api.fastcheck.store`
- `FASTCHECK_API``https://fastcheck.store/api`
- `QR_API``https://qr.vitanova.network:567/qr` (legacy, на текущих страницах не используется)
Имя Telegram-бота — в `src/app/pages/fastcheck-page/fastcheck-page.ts` (поле `telegramBot`, сейчас `DexarSupport_bot`).
@@ -104,7 +104,7 @@ RewriteRule . /index.html [L]
## 5. CORS на backend
`api.fastcheck.store` должен возвращать заголовки CORS, разрешающие домен фронта:
`fastcheck.store/api` должен возвращать заголовки CORS, разрешающие домен фронта:
```
Access-Control-Allow-Origin: https://pay.example.com
@@ -149,5 +149,5 @@ EXPOSE 80
1. Открой `https://pay.example.com/` — должна быть форма фастчека.
2. Открой `https://pay.example.com/new` напрямую — должна открыться страница создания (не 404).
3. В DevTools → Network проверь, что запросы к `https://api.fastcheck.store/...` идут без CORS-ошибок.
3. В DevTools → Network проверь, что запросы к `https://fastcheck.store/api/...` идут без CORS-ошибок.
4. Нажми «Оплатить» с заполненными полями — должен открыться popup с QR Telegram (`@DexarSupport_bot`).

View File

@@ -1,291 +0,0 @@
<!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="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg" alt="СБП" />
</div>
<h1 class="card__title">Оплата через СБП</h1>
<p class="card__subtitle">Система быстрых платежей</p>
</div>
<div class="card__body">
<div class="field">
<label class="field__label" for="amount">Сумма платежа</label>
<div class="input-wrap" 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="10"
/>
</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');
}
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);
fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payment: 'sbp', amount, currency: 'rub', id, note: noteInput.value.trim() })
})
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (data) {
setLoading(false);
if (data && data.payload) {
window.location.href = data.payload;
}
})
.catch(function () {
setLoading(false);
showError('Ошибка при создании платежа. Попробуйте ещё раз.');
});
}
</script>
</body>
</html>

View File

@@ -1,16 +1,9 @@
{
"/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",
"/proxy/fastcheck-store": {
"target": "https://fastcheck.store",
"secure": true,
"changeOrigin": true,
"pathRewrite": { "^/proxy/fastcheck": "" },
"pathRewrite": { "^/proxy/fastcheck-store": "" },
"logLevel": "debug"
},
"/proxy/qr-vitanova": {
@@ -19,5 +12,19 @@
"changeOrigin": true,
"pathRewrite": { "^/proxy/qr-vitanova": "" },
"logLevel": "debug"
},
"/proxy/api-vitanova": {
"target": "https://api.vitanova.network",
"secure": true,
"changeOrigin": true,
"pathRewrite": { "^/proxy/api-vitanova": "" },
"logLevel": "debug"
},
"/proxy/users-vitanova": {
"target": "https://users.vitanova.network:456",
"secure": true,
"changeOrigin": true,
"pathRewrite": { "^/proxy/users-vitanova": "" },
"logLevel": "debug"
}
}

View File

@@ -1,4 +1,4 @@
eFastcheck.store
eFastcheck.store
General Information
Information exchange with the Fastcheck server is realized via RESTful API. All requests to the server must be executed via HTTPS using GET||POST||PUT||DELETE requests to the given ROOT address. Body of requests must be in JSON format. All not public requests must be signed by the client and the public key must be sent to the server for client identification and sign checking.
@@ -6,9 +6,9 @@ Information exchange with the Fastcheck server is realized via RESTful API. All
Check if server is available
Client needs to periodically check if the server is available by sending “ping” to the client. On error corresponding message must be shown.
Client needs to periodically check if the server is available by sending “ping” to the client. On error corresponding message must be shown.
Protocol: https
Root Path: api.Fastcheck.store
Root Path: fastcheck.store/api
Type GET
Path /ping
Request Parameters:
@@ -26,7 +26,7 @@ ________________
Create new websession
Creates a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
Protocol: https
Root Path: api.Fastcheck.store
Root Path: fastcheck.store/api
Type GET
Path /websession
Request Parameters:
@@ -36,7 +36,7 @@ Request Parameters:
}
Response (OK):
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”
"userId" : "",
"expires" : "sessionId",
"userSessionId": "",
@@ -48,7 +48,7 @@ ________________
Check websession status
Check if the user is already logged in. a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
Protocol: https
Root Path: api.Fastcheck.store
Root Path: fastcheck.store/api
Type GET
Path /websession/:webSessionID
Request Parameters:
@@ -58,7 +58,7 @@ Request Parameters:
}
Response (OK):
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
"expires" : "sessionId",
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
@@ -68,12 +68,12 @@ ________________
Delete websession status
Delete the session to log out from the system.
Protocol: https
Root Path: api.Fastcheck.store
Root Path: fastcheck.store/api
Type DELETE
Path /websession/:webSessionID
Request Parameters:
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”
}
Response (OK):
{
@@ -84,14 +84,14 @@ ________________
Check Fastcheck status
Check if fastcheck exists and get the amount assigned to check.
Protocol: https
Root Path: api.Fastcheck.store
Root Path: fastcheck.store/api
Type GET
Path /fastcheck
Request Parameters:
{
"fastcheck": “1234-5678-0001”,
"fastcheck": “1234-5678-0001”,
}
Response (OK):
{
@@ -103,7 +103,7 @@ ________________
New Fastcheck
Create a fastcheck for a given amount. The Users must have a sufficient amount on the balance.
Protocol: https
Root Path: api.Fastcheck.store
Root Path: fastcheck.store/api
Type POST
Path /fastcheck
HEADER: Authorization - {"sessionID": "1AF3781BF6B94604B771AEA1D44FA63A"}
@@ -123,7 +123,7 @@ ________________
Accept Fastcheck
Accept fastcheck to the user balance.
Protocol: https
Root Path: api.Fastcheck.store
Root Path: fastcheck.store/api
Type POST
Path /fastcheck
HEADER: Authorization - {"sessionID": "1AF3781BF6B94604B771AEA1D44FA63A"}

1
public/flags/cn.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32"><defs><path id="cn-star" d="m0-30 17.634 54.27-46.166-33.54h57.064l-46.166 33.54Z"/></defs><rect x="1" y="4" width="30" height="24" rx="4" ry="4" fill="#ee1c25"/><g fill="#ffde00"><g transform="translate(8.3 10.8) scale(.11)"><use xlink:href="#cn-star"/></g><g transform="translate(13.8 7.4) rotate(20) scale(.043)"><use xlink:href="#cn-star"/></g><g transform="translate(16.1 10.1) rotate(40) scale(.043)"><use xlink:href="#cn-star"/></g><g transform="translate(15.7 13.5) scale(.043)"><use xlink:href="#cn-star"/></g><g transform="translate(13.2 16) rotate(-20) scale(.043)"><use xlink:href="#cn-star"/></g></g><path d="M27,4H5c-2.209,0-4,1.791-4,4V24c0,2.209,1.791,4,4,4H27c2.209,0,4-1.791,4-4V8c0-2.209-1.791-4-4-4Zm3,20c0,1.654-1.346,3-3,3H5c-1.654,0-3-1.346-3-3V8c0-1.654,1.346-3,3-3H27c1.654,0,3,1.346,3,3V24Z" opacity=".15"/><path d="M27,5H5c-1.657,0-3,1.343-3,3v1c0-1.657,1.343-3,3-3H27c1.657,0,3,1.343,3,3v-1c0-1.657-1.343-3-3-3Z" fill="#fff" opacity=".2"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

147
public/i18n/cn.json Normal file
View File

@@ -0,0 +1,147 @@
{
"header": {
"nav_about": "关于服务",
"nav_contacts": "联系方式",
"nav_partners": "合作伙伴",
"nav_support": "支持",
"aria_nav": "导航",
"aria_menu": "移动菜单",
"aria_burger": "菜单",
"aria_close": "关闭菜单"
},
"footer": {
"desc": "面向个人的创新型虚拟支票服务。在线创建数字支票,并可通过合作银行的 ATM 24/7 提现。",
"contacts_heading": "联系方式",
"russia": "俄罗斯",
"armenia": "亚美尼亚",
"support_label": "技术支持",
"support_hours": "24/7",
"questions_label": "咨询",
"questions_hours": "10:0019:00 MSK",
"legal_heading": "法律信息",
"legal_company": "LLC «VIAEXPORT»",
"legal_inn_ru": "税号RU9909675800",
"legal_inn_am": "税号AM01051049",
"legal_kpp": "KPP770287001",
"legal_ogrn": "OGRN282.110.1296681",
"legal_address": "亚美尼亚0201埃里温明斯卡亚街 21-23 号44 室",
"rights": "LLC «VIAEXPORT». 保留所有权利。",
"director": "董事Amirkhanyan Sargis Artashesovich"
},
"fastcheck": {
"subtitle": "输入 fastCHECK 信息或创建新的",
"number_label": "fastCHECK 编号",
"number_placeholder": "123456-123456-123456",
"number_new": "新建",
"amount_label": "金额",
"amount_checking": "正在检查…",
"code_label": "验证码",
"code_placeholder": "000000",
"pay_btn": "支付",
"modal_title": "通过 Telegram 登录",
"modal_sub": "扫描二维码或打开链接",
"modal_loading": "加载中…",
"modal_open_tg": "在 Telegram 中打开",
"modal_confirming": "正在确认支付…",
"modal_waiting": "等待登录…",
"modal_paid_title": "已支付",
"modal_paid_sub": "fastCHECK 已成功接收。",
"share_email": "通过电子邮件发送",
"share_tg": "发送到 Telegram",
"modal_loggedin_title": "已登录",
"modal_loggedin_sub": "您已登录 fastCHECK。"
},
"auth": {
"close_aria": "关闭对话框",
"title": "需要登录",
"desc": "请通过 Telegram 登录以继续您的订单。",
"checking": "检查中...",
"telegram_btn": "使用 Telegram 登录",
"qr_hint": "或扫描二维码",
"qr_alt": "二维码",
"refresh_aria": "刷新二维码",
"expired": "二维码已过期。点击刷新",
"redirect_note": "登录后您将返回此页面。",
"session_failed": "无法启动 Telegram 登录。点击重试。"
},
"create": {
"title": "新建",
"subtitle": "输入充值金额",
"back_label": "返回",
"payment_label": "支付方式",
"currency_label": "货币",
"amount_label": "支付金额",
"note_label": "备注",
"note_placeholder": "付款原因...",
"creating": "创建中…",
"create_btn": "创建",
"amount_hint": "允许金额:",
"qr_label": "扫描二维码支付",
"qr_waiting": "等待支付确认…"
},
"sbp": {
"title": "通过 SBP 支付",
"subtitle": "快速支付系统",
"amount_label": "支付金额",
"currency_name": "俄罗斯卢布",
"note_label": "备注",
"note_placeholder": "付款原因...",
"pay_loading": "请稍候...",
"pay_btn": "前往支付"
},
"about": {
"title": "关于服务",
"lead": "fastCHECK 是一项面向个人、全年无休的创新型虚拟支票服务。",
"what_title": "什么是 fastCHECK",
"what_text": "fastCHECK 是一种数字支票,您可以在线创建,并可在合作银行的 ATM 上随时提现。无需排队,无需前往办公室,只需您的手机和最近的 ATM。",
"how_title": "如何运作?",
"step1": "登录并创建一张所需金额的 fastCHECK。",
"step2": "保存支票编号和 5 位代码。",
"step3": "在网站上输入信息并通过 Telegram 确认。",
"step4": "以您方便的方式收款。",
"why_title": "为什么选择 fastCHECK",
"why1": "24/7 可用,包括周末和节假日。",
"why2": "通过 Telegram 进行安全授权。",
"why3": "支持 SBP 和其他常用支付方式。",
"why4": "处理速度快,从几秒到几分钟。",
"company_title": "关于公司",
"company_text": "该服务由 LLC «VIAEXPORT»税号 9909675800开发。公司注册于俄罗斯和亚美尼亚。法律地址亚美尼亚0201埃里温明斯卡亚街 21-23 号44 室。"
},
"contacts": {
"title": "联系方式",
"lead": "我们 24/7 在线。请选择您偏好的联系方式。",
"ru_label": "电话 — 俄罗斯",
"am_label": "电话 — 亚美尼亚",
"email_label": "电子邮箱",
"tg_label": "Telegram 机器人",
"hours_title": "工作时间"
},
"errors": {
"not_found": "未找到支付或已过期。",
"lookup_failed": "无法验证编号。请重试。",
"session_failed": "无法创建会话。请重试。",
"payment_failed": "无法处理付款。请检查验证码后重试。",
"invalid_code": "验证码无效。请检查后重试。",
"invalid_amount": "请输入有效金额。",
"settings_failed": "无法加载设置。您可以手动继续。",
"settings_missing_id": "缺少合作伙伴 ID。您可以手动继续。"
},
"common": {
"secure": "安全连接"
},
"partners": {
"title": "合作伙伴",
"lead": "接受 fastCHECK 作为支付方式的商店、服务和公司。",
"cat_finance": "金融",
"cat_retail": "零售",
"cat_hotels": "酒店",
"cat_services": "服务",
"p1_desc": "覆盖整个亚美尼亚的货币兑换与转账服务。",
"p2_desc": "支持使用 fastCHECK 为账户充值的外汇经纪商。",
"p3_desc": "覆盖俄罗斯和独联体地区配送的在线零售商。",
"p4_desc": "通过 fastCHECK 进行酒店预订和支付。",
"cta_title": "想成为合作伙伴吗?",
"cta_text": "将 fastCHECK 接入您的业务,快速且手续最少。",
"cta_btn": "联系我们"
}
}

View File

@@ -51,6 +51,19 @@
"modal_loggedin_title": "Signed in",
"modal_loggedin_sub": "You are now signed in to fastCHECK."
},
"auth": {
"close_aria": "Close dialog",
"title": "Login required",
"desc": "Please log in via Telegram to proceed with your order.",
"checking": "Checking...",
"telegram_btn": "Log in with Telegram",
"qr_hint": "Or scan the QR code",
"qr_alt": "QR code",
"refresh_aria": "Refresh QR code",
"expired": "QR code expired. Click to refresh",
"redirect_note": "You will be redirected back after login.",
"session_failed": "Unable to start Telegram login. Click to retry."
},
"create": {
"title": "New",
"subtitle": "Enter the amount to top up",
@@ -109,7 +122,9 @@
"session_failed": "Could not create a session. Please try again.",
"payment_failed": "Could not process the payment. Check the code and try again.",
"invalid_code": "Invalid code. Please check and try again.",
"invalid_amount": "Please enter a valid amount."
"invalid_amount": "Please enter a valid amount.",
"settings_failed": "Could not load settings. You can continue manually.",
"settings_missing_id": "Partner id is missing. You can continue manually."
},
"common": {
"secure": "Secure connection"

View File

@@ -51,6 +51,19 @@
"modal_loggedin_title": "Մուտք գործել",
"modal_loggedin_sub": "Դուք մուտք գործել եք fastCHECK:"
},
"auth": {
"close_aria": "Փակել պատուհանը",
"title": "Պահանջվում է մուտք",
"desc": "Խնդրում ենք մուտք գործել Telegram-ի միջոցով՝ պատվերը շարունակելու համար:",
"checking": "Ստուգվում է…",
"telegram_btn": "Մուտք գործել Telegram-ով",
"qr_hint": "Կամ սկանավորեք QR կոդը",
"qr_alt": "QR կոդ",
"refresh_aria": "Թարմացնել QR կոդը",
"expired": "QR կոդը ժամկետանց է։ Սեղմեք թարմացնելու համար",
"redirect_note": "Մուտքից հետո դուք կվերադառնաք այս էջ։",
"session_failed": "Չհաջողվեց սկսել Telegram մուտքը։ Սեղմեք՝ կրկին փորձելու համար։"
},
"create": {
"title": "Նոր",
"subtitle": "Նշեք համալրման գումարը",
@@ -109,7 +122,9 @@
"session_failed": "Չհաջողվեց ստեղծել նիստ: Կրկին փորձեք:",
"payment_failed": "Չհաջողվեց մշակել վճարումը: Ստուգեք կոդը և կրկին փորձեք:",
"invalid_code": "Սխալ կոդ: Ստուգեք և կրկին մուտքագրեք:",
"invalid_amount": "Մուտքագրեք ճիշտ գումար:"
"invalid_amount": "Մուտքագրեք ճիշտ գումար:",
"settings_failed": "Չհաջողվեց բեռնել կարգավորումները: Կարող եք շարունակել ձեռքով:",
"settings_missing_id": "Գործընկերոջ ID-ն բացակայում է: Կարող եք շարունակել ձեռքով:"
},
"common": {
"secure": "Անվտանգ կապ"

View File

@@ -51,6 +51,19 @@
"modal_loggedin_title": "Вы вошли",
"modal_loggedin_sub": "Вы авторизованы в fastCHECK."
},
"auth": {
"close_aria": "Закрыть диалог",
"title": "Требуется вход",
"desc": "Пожалуйста, войдите через Telegram, чтобы продолжить оформление заказа.",
"checking": "Проверяем...",
"telegram_btn": "Войти через Telegram",
"qr_hint": "Или отсканируйте QR-код",
"qr_alt": "QR-код",
"refresh_aria": "Обновить QR-код",
"expired": "Срок действия QR-кода истёк. Нажмите, чтобы обновить",
"redirect_note": "После входа вы вернётесь обратно.",
"session_failed": "Не удалось запустить вход через Telegram. Нажмите, чтобы повторить."
},
"create": {
"title": "Новый",
"subtitle": "Укажите сумму для пополнения",
@@ -109,7 +122,9 @@
"session_failed": "Не удалось создать сессию. Попробуйте ещё раз.",
"payment_failed": "Не удалось принять платёж. Проверьте код и попробуйте снова.",
"invalid_code": "Неверный код. Проверьте и введите снова.",
"invalid_amount": "Введите корректную сумму."
"invalid_amount": "Введите корректную сумму.",
"settings_failed": "Не удалось загрузить настройки. Можно продолжить вручную.",
"settings_missing_id": "Отсутствует идентификатор партнёра. Можно продолжить вручную."
},
"common": {
"secure": "Защищённое соединение"

View File

@@ -0,0 +1,551 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Login Dialog</title>
<style>
:root {
--bg-page: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%);
--bg-card: #ffffff;
--bg-hover: #f0f0f0;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--accent-color: #497671;
--accent-light: rgba(73, 118, 113, 0.1);
--telegram: #2aabee;
--telegram-hover: #229ed9;
--border: #e8e8e8;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
color: var(--text-primary);
background: var(--bg-page);
}
.page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(320px, 448px) minmax(320px, 560px);
gap: 32px;
padding: 40px 32px;
align-items: center;
justify-content: center;
}
.panel {
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 28px;
padding: 24px;
box-shadow: 0 18px 50px rgba(38, 52, 73, 0.12);
backdrop-filter: blur(14px);
}
.info h1 {
margin: 0 0 12px;
font-size: 32px;
line-height: 1.1;
}
.info p {
margin: 0 0 18px;
color: var(--text-secondary);
line-height: 1.6;
}
.state-switcher {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0 24px;
}
.state-switcher button {
border: 1px solid #cfd8e3;
border-radius: 999px;
background: #fff;
color: var(--text-primary);
padding: 10px 14px;
font-size: 14px;
cursor: pointer;
transition: 0.2s ease;
}
.state-switcher button.active {
border-color: var(--accent-color);
background: var(--accent-light);
color: var(--accent-color);
}
.api-grid {
display: grid;
gap: 12px;
margin-top: 20px;
}
.api-card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 16px;
padding: 14px 16px;
}
.api-card strong {
display: block;
margin-bottom: 6px;
font-size: 14px;
}
.api-card code {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #f3f7fb;
color: #21425f;
font-size: 13px;
}
.api-card p {
margin: 8px 0 0;
font-size: 13px;
}
.login-overlay {
position: relative;
min-height: 700px;
border-radius: 28px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
padding: 16px;
}
.login-dialog {
position: relative;
background: var(--bg-card);
border-radius: 20px;
padding: 32px 28px;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: var(--shadow);
animation: scaleIn 0.25s ease;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--bg-hover);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #e0e0e0;
color: #333;
}
.login-icon {
margin: 0 auto 16px;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
}
.login-dialog h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.login-desc {
margin: 0 0 24px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.telegram-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 24px;
border: none;
border-radius: 12px;
background: var(--telegram);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.telegram-btn:hover {
background: var(--telegram-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
}
.telegram-btn:active {
transform: translateY(0);
}
.tg-icon {
flex-shrink: 0;
}
.qr-section {
margin-top: 20px;
}
.qr-hint {
margin: 0 0 12px;
font-size: 13px;
color: #999;
}
.qr-container {
display: inline-flex;
padding: 12px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border);
}
.qr-container img {
display: block;
border-radius: 4px;
}
.qr-loading {
align-items: center;
justify-content: center;
width: 204px;
height: 204px;
}
.qr-loading .spinner,
.login-status .spinner {
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.qr-loading .spinner {
width: 32px;
height: 32px;
border: 3px solid #e0e0e0;
border-top-color: var(--accent-color);
}
.qr-expired {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 204px;
height: 204px;
cursor: pointer;
color: #999;
transition: color 0.2s ease;
}
.qr-expired:hover {
color: var(--accent-color);
}
.qr-expired span {
font-size: 13px;
}
.login-note {
margin: 16px 0 0;
font-size: 12px;
color: #999;
line-height: 1.4;
}
.login-status {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
color: var(--text-secondary);
font-size: 14px;
}
.login-status .spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: var(--accent-color);
}
.dialog-content[data-state="checking"] .login-status {
display: flex;
}
.dialog-content[data-state="checking"] .action-block,
.dialog-content[data-state="loading"] .qr-ready,
.dialog-content[data-state="loading"] .qr-expired,
.dialog-content[data-state="expired"] .qr-ready,
.dialog-content[data-state="expired"] .qr-loading,
.dialog-content[data-state="error"] .qr-loading,
.dialog-content[data-state="error"] .qr-expired,
.dialog-content[data-state="checking"] .qr-section,
.dialog-content[data-state="checking"] .login-note {
display: none;
}
.dialog-content[data-state="ready"] .qr-loading,
.dialog-content[data-state="ready"] .qr-expired,
.dialog-content[data-state="ready"] .qr-error,
.dialog-content[data-state="loading"] .qr-ready,
.dialog-content[data-state="loading"] .qr-expired,
.dialog-content[data-state="loading"] .qr-error,
.dialog-content[data-state="expired"] .qr-loading,
.dialog-content[data-state="expired"] .qr-ready,
.dialog-content[data-state="expired"] .qr-error,
.dialog-content[data-state="error"] .qr-loading,
.dialog-content[data-state="error"] .qr-expired {
display: none;
}
.dialog-content[data-state="error"] .qr-ready {
display: inline-flex;
}
.metadata {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid #e9edf2;
font-size: 13px;
color: var(--text-secondary);
}
.metadata ul {
margin: 10px 0 0;
padding-left: 18px;
}
.metadata li + li {
margin-top: 6px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 980px) {
.page {
grid-template-columns: 1fr;
padding: 24px 16px 32px;
}
.login-overlay {
min-height: 560px;
}
}
@media (max-width: 480px) {
.panel {
border-radius: 22px;
padding: 18px;
}
.login-dialog {
padding: 24px 20px;
border-radius: 16px;
}
.qr-container img {
width: 140px;
height: 140px;
}
.qr-loading,
.qr-expired {
width: 164px;
height: 164px;
}
}
</style>
</head>
<body>
<main class="page">
<section class="panel info">
<h1>Telegram Login Dialog</h1>
<p>
Standalone extraction of the current login popup: same layout, same visual states,
same QR flow, but with reusable neutral endpoint names for moving into a separate repo.
</p>
<div class="state-switcher" aria-label="Dialog state switcher">
<button class="active" data-state-btn="ready" type="button">Ready</button>
<button data-state-btn="loading" type="button">QR Loading</button>
<button data-state-btn="checking" type="button">Checking</button>
<button data-state-btn="expired" type="button">Expired</button>
<button data-state-btn="error" type="button">Fallback</button>
</div>
<div class="api-grid">
<div class="api-card">
<strong>Start QR session</strong>
<code>POST /userauth/qr/create</code>
<p>Returns a one-time token and Telegram deeplink for the QR image.</p>
</div>
<div class="api-card">
<strong>Poll QR confirmation</strong>
<code>GET /userauth/qr/poll?token=...</code>
<p>Returns pending, confirmed, or expired. On confirmed, also returns the user session.</p>
</div>
<div class="api-card">
<strong>Read current session</strong>
<code>GET /userauth/session</code>
<p>Cookie-based session lookup used for initial auth check and fallback polling.</p>
</div>
<div class="api-card">
<strong>Sync cart after login</strong>
<code>POST /usersession/{sessionId}</code>
<p>Existing cart payload is preserved. Only the namespace is generalized for reuse.</p>
</div>
</div>
<div class="metadata">
<strong>Behavior kept intact</strong>
<ul>
<li>Open Telegram directly from the primary button.</li>
<li>Show QR immediately and poll every 3 seconds.</li>
<li>Expire the QR after 100 checks and allow manual refresh.</li>
<li>Re-check cookie session if QR creation fails.</li>
</ul>
</div>
</section>
<section class="panel">
<div class="login-overlay">
<div class="login-dialog">
<button class="close-btn" type="button" aria-label="Close dialog">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"></path>
</svg>
</button>
<div class="dialog-content" data-state="ready" id="dialog-content">
<div class="login-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
</svg>
</div>
<h2>Login required</h2>
<p class="login-desc">Please log in via Telegram to proceed with your order.</p>
<div class="login-status checking">
<div class="spinner"></div>
<span>Checking...</span>
</div>
<div class="action-block">
<button class="telegram-btn" type="button">
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path>
</svg>
Log in with Telegram
</button>
<div class="qr-section">
<p class="qr-hint">Or scan the QR code</p>
<div class="qr-container qr-loading">
<div class="spinner"></div>
</div>
<div class="qr-container qr-ready">
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=https%3A%2F%2Ft.me%2FmyAMLKYCBOT%3Fstart%3Dlogin_sample_token"
alt="QR Code"
width="180"
height="180"
loading="eager"
/>
</div>
<div class="qr-container qr-expired" role="button" tabindex="0" aria-label="Refresh QR code">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"></path>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
<span>QR code expired. Click to refresh</span>
</div>
</div>
<p class="login-note">You will be redirected back after login.</p>
</div>
</div>
</div>
</div>
</section>
</main>
<script>
const content = document.getElementById('dialog-content');
const buttons = document.querySelectorAll('[data-state-btn]');
buttons.forEach((button) => {
button.addEventListener('click', () => {
const state = button.getAttribute('data-state-btn');
content.setAttribute('data-state', state);
buttons.forEach((candidate) => candidate.classList.remove('active'));
button.classList.add('active');
});
});
</script>
</body>
</html>

View File

@@ -7,5 +7,39 @@
* to avoid CORS issues. In production the real URLs are used.
*/
export const FASTCHECK_API = isDevMode()
? '/proxy/fastcheck'
: 'https://api.fastcheck.store';
? '/proxy/fastcheck-store/api'
: 'https://fastcheck.store/api';
/**
* Base URL for Telegram/websession authorization.
* Auth routes: POST /users/sessions, GET/DELETE /users/sessions/:webSessionID.
*/
export const USERS_VITANOVA_API = isDevMode()
? '/proxy/users-vitanova'
: 'https://users.vitanova.network:456';
/**
* Base URL for the QR/SBP backend (qr.vitanova.network).
* Used for the /api/settings endpoint that may return active check data on load.
*/
export const QR_VITANOVA_API = isDevMode()
? '/proxy/qr-vitanova/api'
: 'https://qr.vitanova.network/api';
/**
* Base URL for API.VITANOVA.NETWORK.
* Used for partner authorization check: GET /partners/{partnerID}
*/
export const API_VITANOVA_NETWORK = isDevMode()
? '/proxy/api-vitanova'
: 'https://api.vitanova.network';
/**
* Base URL for the public fastcheck.store API.
* Used for /api/fastcheck/settings/{id} and /api/fastcheck/message/{tgId}.
* Kept as a separate constant for clarity at call sites; currently identical
* to FASTCHECK_API.
*/
export const FASTCHECK_STORE_API = isDevMode()
? '/proxy/fastcheck-store/api'
: 'https://fastcheck.store/api';

View File

@@ -1,13 +1,20 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { APP_INITIALIZER, ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AuthSessionService } from './auth-session.service';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient()
provideHttpClient(),
{
provide: APP_INITIALIZER,
multi: true,
deps: [AuthSessionService],
useFactory: (authSession: AuthSessionService) => () => authSession.initialize()
}
]
};

View File

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

View File

@@ -6,6 +6,7 @@
.app-main {
flex: 1;
min-height: 80vh;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,70 @@
@if (open()) {
<div class="login-overlay" (click)="requestClose()">
<div class="login-dialog" (click)="$event.stopPropagation()">
<button class="close-btn" type="button" [attr.aria-label]="'auth.close_aria' | translate" (click)="requestClose()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"></path>
</svg>
</button>
<div class="dialog-content" [attr.data-state]="state()">
<div class="login-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
</svg>
</div>
<h2>{{ 'auth.title' | translate }}</h2>
<p class="login-desc">{{ 'auth.desc' | translate }}</p>
<div class="login-status">
<div class="spinner"></div>
<span>{{ 'auth.checking' | translate }}</span>
</div>
<div class="action-block">
<button class="telegram-btn" type="button" (click)="openTelegram()">
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path>
</svg>
{{ 'auth.telegram_btn' | translate }}
</button>
<!-- <a class="telegram-link" [href]="telegramLink()" target="_blank" rel="noopener">
{{ telegramLink() }}
</a> -->
<div class="qr-section">
<p class="qr-hint">{{ 'auth.qr_hint' | translate }}</p>
<div class="qr-container qr-loading">
<div class="spinner"></div>
</div>
<div class="qr-container qr-ready">
<img [src]="qrUrl()" [attr.alt]="'auth.qr_alt' | translate" width="180" height="180" loading="eager" />
</div>
<div
class="qr-container qr-expired"
role="button"
tabindex="0"
[attr.aria-label]="'auth.refresh_aria' | translate"
(click)="refreshQr()"
(keydown.enter)="refreshQr()"
(keydown.space)="refreshQr()"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"></path>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
<span>{{ (messageKey() || 'auth.expired') | translate }}</span>
</div>
</div>
<p class="login-note">{{ 'auth.redirect_note' | translate }}</p>
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,294 @@
:host {
--bg-card: #ffffff;
--bg-hover: #f0f0f0;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--accent-color: #497671;
--accent-light: rgba(73, 118, 113, 0.1);
--telegram: #2aabee;
--telegram-hover: #229ed9;
--border: #e8e8e8;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.login-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
padding: 16px;
overflow-y: auto;
}
.login-dialog {
position: relative;
background: var(--bg-card);
border-radius: 20px;
padding: 32px 28px;
max-width: 400px;
width: 100%;
max-height: calc(100dvh - 32px);
overflow-y: auto;
text-align: center;
box-shadow: var(--shadow);
animation: scaleIn 0.25s ease;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--bg-hover);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #e0e0e0;
color: #333;
}
.login-icon {
margin: 0 auto 16px;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
}
h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.login-desc {
margin: 0 0 24px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.telegram-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 24px;
border: none;
border-radius: 12px;
background: var(--telegram);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.telegram-btn:hover {
background: var(--telegram-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
}
.telegram-btn:active {
transform: translateY(0);
}
.tg-icon {
flex-shrink: 0;
}
.telegram-link {
display: block;
margin-top: 10px;
color: var(--telegram-hover);
font-size: 12px;
line-height: 1.4;
overflow-wrap: anywhere;
text-decoration: none;
}
.telegram-link:hover {
text-decoration: underline;
}
.qr-section {
margin-top: 20px;
}
.qr-hint {
margin: 0 0 12px;
font-size: 13px;
color: #999;
}
.qr-container {
display: inline-flex;
padding: 12px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border);
}
.qr-container img {
display: block;
border-radius: 4px;
}
.qr-loading {
align-items: center;
justify-content: center;
width: 204px;
height: 204px;
}
.qr-loading .spinner,
.login-status .spinner {
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.qr-loading .spinner {
width: 32px;
height: 32px;
border: 3px solid #e0e0e0;
border-top-color: var(--accent-color);
}
.qr-expired {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 204px;
height: 204px;
cursor: pointer;
color: #999;
transition: color 0.2s ease;
}
.qr-expired:hover {
color: var(--accent-color);
}
.qr-expired span {
font-size: 13px;
}
.login-note {
margin: 16px 0 0;
font-size: 12px;
color: #999;
line-height: 1.4;
}
.login-status {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
color: var(--text-secondary);
font-size: 14px;
}
.login-status .spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: var(--accent-color);
}
.dialog-content[data-state='checking'] .login-status {
display: flex;
}
.dialog-content[data-state='checking'] .action-block,
.dialog-content[data-state='loading'] .qr-ready,
.dialog-content[data-state='loading'] .qr-expired,
.dialog-content[data-state='expired'] .qr-ready,
.dialog-content[data-state='expired'] .qr-loading,
.dialog-content[data-state='error'] .qr-loading,
.dialog-content[data-state='checking'] .qr-section,
.dialog-content[data-state='checking'] .login-note {
display: none;
}
.dialog-content[data-state='ready'] .qr-loading,
.dialog-content[data-state='ready'] .qr-expired,
.dialog-content[data-state='expired'] .qr-loading,
.dialog-content[data-state='error'] .qr-loading {
display: none;
}
.dialog-content[data-state='error'] .qr-ready,
.dialog-content[data-state='error'] .qr-expired {
display: inline-flex;
}
.metadata {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid #e9edf2;
font-size: 13px;
color: var(--text-secondary);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 480px) {
.login-dialog {
padding: 24px 20px;
border-radius: 16px;
max-height: calc(100dvh - 24px);
}
.qr-container img {
width: 140px;
height: 140px;
}
.qr-loading,
.qr-expired {
width: 164px;
height: 164px;
}
}

View File

@@ -0,0 +1,246 @@
import { HttpClient } from '@angular/common/http';
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { USERS_VITANOVA_API } from '../api';
import { AuthSessionService, WebSessionResponse } from '../auth-session.service';
import { TranslatePipe } from '../translate/translate.pipe';
export type AuthDialogMode = 'payment' | 'login' | 'new';
export interface AuthDialogAuthorizedEvent {
sessionId: string;
userId: string;
userSessionId: string;
}
type AuthDialogState = 'loading' | 'ready' | 'checking' | 'expired' | 'error';
@Component({
selector: 'app-auth-dialog',
imports: [TranslatePipe],
templateUrl: './auth-dialog.html',
styleUrl: './auth-dialog.scss'
})
export class AuthDialogComponent {
private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionService);
private readonly telegramBot = 'myAMLKYCBOT';
open = input(false);
mode = input<AuthDialogMode>('login');
processing = input(false);
authorized = output<AuthDialogAuthorizedEvent>();
closed = output<void>();
state = signal<AuthDialogState>('loading');
webSessionId = signal('');
messageKey = signal('');
telegramLink = computed(() => {
const sessionId = this.webSessionId();
return sessionId
? `https://t.me/${this.telegramBot}?start=${encodeURIComponent(sessionId)}`
: `https://t.me/${this.telegramBot}`;
});
qrUrl = computed(() => {
const link = this.telegramLink();
return `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(link)}`;
});
get isMobile(): boolean {
return typeof window !== 'undefined' && window.innerWidth < 768;
}
private pollHandle: ReturnType<typeof setInterval> | null = null;
private wasOpen = false;
private authenticated = false;
constructor() {
effect(() => {
const isOpen = this.open();
if (isOpen && !this.wasOpen) {
this.wasOpen = true;
this.startAuthFlow();
return;
}
if (!isOpen && this.wasOpen) {
this.wasOpen = false;
this.finishFlow();
}
});
effect(() => {
if (!this.open()) return;
if (!this.processing()) return;
this.state.set('checking');
});
}
requestClose(): void {
this.closed.emit();
}
openTelegram(): void {
const link = this.telegramLink();
if (!link) return;
window.open(link, '_blank', 'noopener');
}
refreshQr(): void {
this.cleanupSession(false);
this.startAuthFlow();
}
private startAuthFlow(): void {
this.stopPolling();
this.authenticated = false;
this.messageKey.set('');
this.webSessionId.set('');
this.state.set('checking');
const existingSession = this.authSession.getSessionId();
if (existingSession) {
this.checkExistingSession(existingSession);
return;
}
this.createSession();
}
private checkExistingSession(sessionId: string): void {
this.authSession.validateSession(sessionId).subscribe({
next: (response) => {
if (response) {
this.webSessionId.set(sessionId);
this.handleAuthorized(response, sessionId);
return;
}
this.createSession();
},
error: () => this.createSession()
});
}
private createSession(): void {
this.state.set('loading');
const sessionId = this.createGuid();
this.webSessionId.set(sessionId);
this.http.post<WebSessionResponse>(`${USERS_VITANOVA_API}/users/sessions`, {
webSessionID: sessionId,
sessionId
}).subscribe({
next: (response) => {
const responseSessionId = this.getSessionId(response) || sessionId;
if (!responseSessionId) {
this.messageKey.set('auth.session_failed');
this.state.set('error');
return;
}
this.webSessionId.set(responseSessionId);
if (this.isAuthorized(response)) {
this.handleAuthorized(response, responseSessionId);
return;
}
this.state.set('ready');
this.startPolling(responseSessionId);
},
error: () => {
this.messageKey.set('auth.session_failed');
this.state.set('error');
}
});
}
private startPolling(sessionId: string): void {
this.stopPolling();
this.pollHandle = setInterval(() => {
this.http.get<WebSessionResponse>(`${USERS_VITANOVA_API}/users/sessions/${sessionId}`).subscribe({
next: (response) => {
if (!this.isAuthorized(response)) return;
this.handleAuthorized(response, sessionId);
},
error: () => undefined
});
}, 5000);
}
private handleAuthorized(response: WebSessionResponse, sessionId: string): void {
if (this.authenticated) return;
this.authenticated = true;
this.stopPolling();
this.webSessionId.set(sessionId);
this.authSession.persistAuthorizedSession(sessionId, response.expires);
this.state.set('checking');
this.authorized.emit({
sessionId,
userId: response.userId ?? response.userID ?? response.telegramID ?? '',
userSessionId: response.userSessionId ?? response.userSessionID ?? ''
});
}
private finishFlow(): void {
const shouldPersistSession = this.authenticated && (this.mode() === 'login' || this.mode() === 'new');
this.cleanupSession(shouldPersistSession);
this.messageKey.set('');
this.webSessionId.set('');
this.state.set('loading');
this.authenticated = false;
}
private cleanupSession(persistSession: boolean): void {
this.stopPolling();
const sessionId = this.webSessionId();
if (!sessionId) {
return;
}
if (persistSession) {
return;
}
this.http
.delete(`${USERS_VITANOVA_API}/users/sessions/${sessionId}`)
.subscribe({ error: () => undefined });
if (this.authSession.getSessionId() === sessionId) {
this.authSession.clearSession();
}
}
private getSessionId(response: WebSessionResponse | null | undefined): string {
return response?.webSessionID ?? response?.webSessionId ?? response?.sessionId ?? '';
}
private isAuthorized(response: WebSessionResponse | null | undefined): boolean {
const status = response?.Status ?? response?.status;
return status === true || String(status).toLowerCase() === 'true';
}
private createGuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
const randomValue = Math.floor(Math.random() * 16);
const value = char === 'x' ? randomValue : (randomValue & 0x3) | 0x8;
return value.toString(16);
});
}
private stopPolling(): void {
if (this.pollHandle === null) return;
clearInterval(this.pollHandle);
this.pollHandle = null;
}
}

View File

@@ -0,0 +1,202 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { catchError, finalize, firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { USERS_VITANOVA_API } from './api';
export interface WebSessionResponse {
sessionId?: string;
webSessionID?: string;
webSessionId?: string;
userId?: string;
userID?: string;
telegramID?: string;
expires?: string;
userSessionId?: string;
userSessionID?: string;
Status?: boolean;
status?: boolean | string;
}
@Injectable({ providedIn: 'root' })
export class AuthSessionService {
private readonly http = inject(HttpClient);
private readonly cookieName = 'fc_session';
private readonly legacyStorageKey = 'fc_session';
private readonly cookieLifetimeMs = 60 * 60 * 1000;
private initialized = false;
readonly sessionId = signal('');
readonly authenticated = signal(false);
readonly validating = signal(false);
initialize(): Promise<void> {
if (this.initialized) {
return Promise.resolve();
}
this.initialized = true;
const storedSessionId = this.readStoredSessionId();
if (!storedSessionId) {
this.clearSession();
return Promise.resolve();
}
this.sessionId.set(storedSessionId);
this.authenticated.set(false);
return firstValueFrom(this.validateSession(storedSessionId).pipe(map(() => undefined)));
}
getSessionId(): string {
const current = this.sessionId().trim();
if (current) {
return current;
}
const storedSessionId = this.readStoredSessionId();
if (storedSessionId) {
this.sessionId.set(storedSessionId);
}
return storedSessionId;
}
persistAuthorizedSession(sessionId: string, expires?: string): void {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
this.clearSession();
return;
}
this.writeCookie(normalizedSessionId, this.resolveExpiry(expires));
this.removeLegacySession();
this.sessionId.set(normalizedSessionId);
this.authenticated.set(true);
}
validateStoredSession(): Observable<WebSessionResponse | null> {
return this.validateSession(this.getSessionId());
}
validateSession(sessionId: string): Observable<WebSessionResponse | null> {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
this.clearSession();
return of(null);
}
this.validating.set(true);
return this.http
.get<WebSessionResponse>(`${USERS_VITANOVA_API}/users/sessions/${normalizedSessionId}`)
.pipe(
tap((response) => {
if (this.isAuthorized(response)) {
this.persistAuthorizedSession(normalizedSessionId, response.expires);
return;
}
this.clearSession();
}),
map((response) => (this.isAuthorized(response) ? response : null)),
catchError(() => {
this.clearSession();
return of(null);
}),
finalize(() => this.validating.set(false))
);
}
clearSession(): void {
this.deleteCookie();
this.removeLegacySession();
this.sessionId.set('');
this.authenticated.set(false);
}
private readStoredSessionId(): string {
const cookieSessionId = this.readCookie();
if (cookieSessionId) {
return cookieSessionId;
}
if (typeof localStorage === 'undefined') {
return '';
}
return (localStorage.getItem(this.legacyStorageKey) ?? '').trim();
}
private removeLegacySession(): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(this.legacyStorageKey);
}
private resolveExpiry(expires?: string): Date {
const fallback = new Date(Date.now() + this.cookieLifetimeMs);
if (!expires) {
return fallback;
}
const parsed = new Date(expires);
if (Number.isNaN(parsed.getTime())) {
return fallback;
}
return new Date(Math.min(parsed.getTime(), fallback.getTime()));
}
private isAuthorized(response: WebSessionResponse | null | undefined): boolean {
const status = response?.Status ?? response?.status;
return status === true || String(status).toLowerCase() === 'true';
}
private readCookie(): string {
if (typeof document === 'undefined') {
return '';
}
const prefix = `${this.cookieName}=`;
for (const part of document.cookie.split(';')) {
const segment = part.trim();
if (!segment.startsWith(prefix)) {
continue;
}
return decodeURIComponent(segment.slice(prefix.length));
}
return '';
}
private writeCookie(sessionId: string, expiresAt: Date): void {
if (typeof document === 'undefined') {
return;
}
const cookieParts = [
`${this.cookieName}=${encodeURIComponent(sessionId)}`,
'Path=/',
`Expires=${expiresAt.toUTCString()}`,
'SameSite=Lax'
];
if (typeof location !== 'undefined' && location.protocol === 'https:') {
cookieParts.push('Secure');
}
document.cookie = cookieParts.join('; ');
}
private deleteCookie(): void {
if (typeof document === 'undefined') {
return;
}
// document.cookie = `${this.cookieName}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
}
}

View File

@@ -11,6 +11,10 @@
<div class="card__body">
<!-- @if (settingsError()) {
<p class="field__hint" role="status">{{ settingsError() }}</p>
} -->
<!-- Fastcheck number + new -->
<div class="field">
<label class="field__label" for="fcNumber">
@@ -28,7 +32,9 @@
autocomplete="off"
maxlength="20"
/>
<a class="btn btn--ghost" href="https://qr.vitanova.network/" target="_blank" rel="noopener" aria-label="Создать новый fastCHECK">{{ 'fastcheck.number_new' | translate }}</a>
<button class="btn btn--ghost" type="button" (click)="createNewFastcheck($event)" aria-label="Создать новый fastCHECK">
{{ 'fastcheck.number_new' | translate }}
</button>
</div>
</div>
@@ -55,28 +61,6 @@
}
</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>
@@ -97,16 +81,29 @@
}
</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>
@if (settingsLoaded()) {
@if (hasPartnerId() && validId()) {
<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>
} @else {
<button class="pay-btn" type="button" (click)="shareByTelegram()" [disabled]="!canShare()">
<span class="pay-btn__icon">
<svg width="20" height="20" 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>
</span>
{{ 'fastcheck.share_tg' | translate }}
</button>
}
}
</div>
<div class="card__footer">
@@ -121,62 +118,10 @@
</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>
@if (loginOnly()) {
<h2 class="modal__title">{{ 'fastcheck.modal_loggedin_title' | translate }}</h2>
<p class="modal__sub">{{ 'fastcheck.modal_loggedin_sub' | translate }}</p>
} @else {
<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>
}
<app-auth-dialog
[open]="authOpen()"
[mode]="authMode()"
[processing]="authProcessing()"
(authorized)="onAuthAuthorized($event)"
(closed)="onAuthClosed()"
/>

View File

@@ -28,12 +28,6 @@
cursor: pointer;
transition: background .15s, border-color .15s;
&--email {
background: #f8fafc;
color: #475569;
&:hover { background: #e2e8f0; border-color: #cbd5e1; }
}
&--tg {
background: #e7f3fe;
color: #0088cc;
@@ -64,6 +58,7 @@
font-family: inherit;
white-space: nowrap;
transition: opacity .15s, transform .1s, background .15s;
appearance: none;
-webkit-appearance: none;
&--ghost {
@@ -98,163 +93,3 @@
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

@@ -1,9 +1,10 @@
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { FastcheckService } from '../../fastcheck.service';
import { FASTCHECK_API } from '../../api';
import { API_VITANOVA_NETWORK, FASTCHECK_API, FASTCHECK_STORE_API, QR_VITANOVA_API } from '../../api';
import { AuthDialogAuthorizedEvent, AuthDialogComponent, AuthDialogMode } from '../../auth-dialog/auth-dialog';
import { AuthSessionService } from '../../auth-session.service';
import { TranslatePipe } from '../../translate/translate.pipe';
import { TranslationService } from '../../translate/translation.service';
@@ -27,23 +28,40 @@ interface CheckFastcheckResponse {
firetransactionID: string;
}
/**
* Response of POST /api/fastcheck/settings/{id}.
* Validates the partner id and returns active fastcheck data + callbackurl.
*/
interface SettingsResponse {
fastcheck?: string;
fastcheckNumber?: string;
code?: string;
Code?: string;
amount?: number;
currency?: string;
note?: string;
status?: string;
telegramID?: string;
callbackurl?: string;
callbackUrl?: string;
}
@Component({
selector: 'app-fastcheck-page',
imports: [FormsModule, TranslatePipe],
imports: [FormsModule, TranslatePipe, AuthDialogComponent],
templateUrl: './fastcheck-page.html',
styleUrl: './fastcheck-page.scss'
})
export class FastcheckPage {
private readonly defaultPartnerId = 'fast-c202-4062-bcfb-8b4c8cc59adc';
private http = inject(HttpClient);
private authSession = inject(AuthSessionService);
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>('');
@@ -51,38 +69,44 @@ export class FastcheckPage {
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);
loginOnly = signal<boolean>(false);
sessionToken = signal<string>(localStorage.getItem('fc_session') ?? '');
private pollHandle: ReturnType<typeof setInterval> | null = null;
// Pass-through partner id from ?id= used by the "New" button link.
partnerId = signal<string>('');
// True only when a real ?id was present in the URL (not the default fallback).
hasPartnerId = signal<boolean>(false);
newQrUrl = computed(() => {
const id = this.partnerId() || this.defaultPartnerId;
return `https://qr.vitanova.network/?id=${encodeURIComponent(id)}&from=51`;
});
// Non-blocking settings hint shown above the form when /settings fails.
settingsError = signal<string>('');
settingsLoaded = signal<boolean>(false);
// True only after POST /fastcheck/settings/{id} returned 200.
// Drives button visibility: validId → Pay; !validId → Send-to-Telegram.
validId = signal<boolean>(false);
// Ready callback URL from settings response — we just redirect here on Pay.
callbackUrl = signal<string>('');
// telegramID returned by settings (or from URL); empty means user has no
// linked telegram yet and we must run the login flow before sending.
telegramId = signal<string>('');
fastcheckCurrency = signal<string>('RUB');
authOpen = signal<boolean>(false);
authMode = signal<AuthDialogMode>('payment');
authProcessing = signal<boolean>(false);
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();
return digits.length === 18 && codeDigits.length === 6 && !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;
}
/**
* Send via Telegram is enabled with the same rules as Pay (18-digit number
* + 6-digit code). Button is only shown when validId is false.
*/
canShare = computed(() => this.canPay());
constructor() {
// Pull autofill data: prefer router navigation state, fall back to service.
@@ -98,8 +122,20 @@ export class FastcheckPage {
this.codeEnabled.set(true);
}
const params = new URLSearchParams(window.location.search);
// ?id=<partnerId> — used by the "New" button URL; fallback to default id.
// hasPartnerId is true ONLY when a real ?id was provided AND it is not the
// default fallback. The Pay button is gated on this — without a real
// partner id we never call /settings and always show the Telegram button.
const idParam = params.get('id');
const hasIdParam = !!idParam;
const isRealId = hasIdParam && idParam !== this.defaultPartnerId;
this.partnerId.set(idParam ?? this.defaultPartnerId);
this.hasPartnerId.set(isRealId);
// ?iid=xxxxxx-xxxxxx-xxxxxx — auto-fill and trigger lookup
const iidParam = new URLSearchParams(window.location.search).get('iid') ?? '';
const iidParam = params.get('iid') ?? '';
if (iidParam && !created) {
const digits = iidParam.replace(/\D/g, '').slice(0, 18);
const groups: string[] = [];
@@ -108,117 +144,248 @@ export class FastcheckPage {
this.fastcheckNumber.set(masked);
if (digits.length === 18) this.lookupFastcheck(masked);
}
}
pay(): void {
if (!this.canPay()) {
return;
// Call /settings whenever URL has ?id=... (including the default id)
// so QR-return flows can still autofill data and callback fields.
// Pay button visibility remains gated by hasPartnerId (real id only).
if (hasIdParam) {
this.loadSettings(!!created || !!iidParam);
} else {
this.settingsLoaded.set(true);
this.validId.set(false);
}
this.error.set('');
this.loginOnly.set(false);
this.openPopup();
// Connectivity check — makes visible requests in DevTools and surfaces
// backend availability issues early.
this.pingBackends();
}
private openPopup(): void {
this.popupOpen.set(true);
this.popupError.set('');
this.paid.set(false);
this.popupLoading.set(true);
const existing = this.sessionToken();
if (existing) {
this.http.get<WebSessionResponse>(`${FASTCHECK_API}/websession/${existing}`).subscribe({
next: (res) => {
if (res?.Status) {
this.popupLoading.set(false);
this.webSessionId.set(existing);
if (this.loginOnly()) {
this.paid.set(true);
} else {
this.acceptFastcheck(existing);
}
} else {
this.sessionToken.set('');
this.createNewSession();
}
},
error: () => { this.sessionToken.set(''); this.createNewSession(); }
/** Fire GET /ping on both backends and log status. Non-blocking. */
private pingBackends(): void {
const targets = [
{ name: 'fastcheck', url: `${FASTCHECK_API}/ping` },
{ name: 'qr-vitanova', url: `${QR_VITANOVA_API}/ping` }
];
for (const t of targets) {
this.http.get(t.url, { observe: 'response' }).subscribe({
next: (res) => console.debug(`[ping:${t.name}]`, res.status, t.url),
error: (err) => console.warn(`[ping:${t.name}] failed`, err?.status ?? err, t.url)
});
}
}
/**
* POST /api/fastcheck/settings/{id} on each load.
* Validates the partner id and may return active fastcheck data + callbackurl.
* If response is non-200 → validId stays false and we fall back to the
* default partner id; UI shows the Telegram-send button instead of Pay.
*/
private loadSettings(alreadyAutofilled: boolean): void {
const id = this.partnerId();
if (!id) {
this.settingsLoaded.set(true);
this.validId.set(false);
return;
}
this.createNewSession();
}
private createNewSession(): void {
this.http.get<WebSessionResponse>(`${FASTCHECK_API}/websession`).subscribe({
const url = `${FASTCHECK_STORE_API}/fastcheck/settings/${encodeURIComponent(id)}`;
this.http.get<SettingsResponse>(url).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);
}
this.settingsLoaded.set(true);
this.settingsError.set('');
this.validId.set(true);
this.applySettings(res ?? {}, alreadyAutofilled);
},
error: () => {
this.popupLoading.set(false);
this.popupError.set(this.t('errors.session_failed'));
// Not a valid id — fall back to default and hide Pay button.
this.settingsLoaded.set(true);
this.validId.set(false);
this.partnerId.set(this.defaultPartnerId);
this.hasPartnerId.set(false);
this.settingsError.set(this.t('errors.settings_failed'));
}
});
}
closePopup(): void {
this.popupOpen.set(false);
this.stopPolling();
if (this.loginOnly() && this.paid()) {
// Keep session alive — user is logged in, preserve token for next action.
const tok = this.webSessionId();
localStorage.setItem('fc_session', tok);
this.sessionToken.set(tok);
} else if (this.webSessionId()) {
// Best-effort logout; ignore errors.
this.http
.request('DELETE', `${FASTCHECK_API}/websession/${this.webSessionId()}`, {
body: { sessionId: this.webSessionId() }
})
.subscribe({ error: () => undefined });
localStorage.removeItem('fc_session');
this.sessionToken.set('');
/** Apply settings response — autofill, callback url, telegram id. */
private applySettings(res: SettingsResponse, alreadyAutofilled: boolean): void {
const cb = res.callbackurl ?? res.callbackUrl ?? '';
if (cb) this.callbackUrl.set(cb);
if (res.telegramID) this.telegramId.set(res.telegramID);
if (res.currency) this.fastcheckCurrency.set(res.currency);
if (alreadyAutofilled) return;
const rawNumber = res.fastcheck ?? res.fastcheckNumber ?? '';
if (rawNumber) {
const digits = String(rawNumber).replace(/\D/g, '').slice(0, 18);
if (digits.length > 0) {
const groups: string[] = [];
for (let i = 0; i < digits.length; i += 6) groups.push(digits.slice(i, i + 6));
this.fastcheckNumber.set(groups.join('-'));
if (digits.length === 18) {
this.lookupFastcheck(groups.join('-'));
}
}
}
if (typeof res.amount === 'number') this.fastcheckAmount.set(res.amount);
const rawCode = res.code ?? res.Code ?? '';
if (rawCode) {
const codeDigits = String(rawCode).replace(/\D/g, '').slice(0, 6);
this.fastcheckCode.set(codeDigits);
this.codeEnabled.set(true);
}
const status = (res.status ?? '').toUpperCase();
if (status === 'COMPLETED' || status === 'APPROVED') {
this.error.set('');
}
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();
if (this.loginOnly()) {
this.paid.set(true);
} else {
this.acceptFastcheck(sessionId);
}
/**
* Pay button — run the standard Accept-Fastcheck flow (Telegram login +
* POST /fastcheck with code) and, on success, redirect the user to the
* callbackurl returned by /settings. If no callback was provided we keep
* the legacy return_url query-param behaviour via fireMerchantCallback().
*/
pay(): void {
if (!this.canPay()) return;
this.error.set('');
this.openAuth('payment');
}
/**
* Send fastcheck to Telegram. If we already know the telegramID, fire
* POST /api/fastcheck/message/{telegramID} directly. Otherwise run the
* Telegram-login popup first; after login we'll retry the send.
*/
shareByTelegram(): void {
if (!this.canShare()) return;
this.error.set('');
const tg = this.telegramId();
if (tg) {
this.sendFastcheckToTelegram(tg);
return;
}
this.openAuth('login');
}
createNewFastcheck(event: Event): void {
event.preventDefault();
const id = this.partnerId() || this.defaultPartnerId;
// Validate any stored session (cookie) with the server first.
// If there's no valid stored session — open the auth dialog to create one.
this.authSession.validateStoredSession().subscribe({
next: (response) => {
const sessionId = this.authSession.getSessionId();
if (!sessionId) {
this.openAuth('new');
return;
}
const headers: Record<string, string> = {
Authorization: JSON.stringify({ sessionID: sessionId, partnerID: id })
};
this.http
.get(`${API_VITANOVA_NETWORK}/partners/${encodeURIComponent(id)}`, { headers })
.subscribe({
next: () => {
// Authorized partner: skip Telegram auth popup and go directly.
this.doRedirectToNew(sessionId);
},
error: () => {
// Not authorized: force fresh Telegram auth QR popup.
this.authSession.clearSession();
this.openAuth('new');
}
},
error: () => undefined
});
}, 3000);
});
},
error: () => {
this.authSession.clearSession();
this.openAuth('new');
}
});
}
private stopPolling(): void {
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
private doRedirectToNew(sessionId?: string): void {
const tok = sessionId || this.authSession.getSessionId() || '';
if (!tok) {
this.openAuth('new');
return;
}
window.location.href = this.newQrUrl();
}
/** POST /api/fastcheck/message/{telegramID} with all fastcheck fields. */
private sendFastcheckToTelegram(telegramId: string): void {
const url = `${FASTCHECK_STORE_API}/fastcheck/message/${encodeURIComponent(telegramId)}`;
const body = {
id: this.partnerId(),
fastcheck: this.fastcheckNumber(),
code: this.fastcheckCode(),
amount: this.fastcheckAmount(),
currency: this.fastcheckCurrency()
};
this.http.post(url, body).subscribe({
next: () => {
this.authProcessing.set(false);
this.authOpen.set(false);
},
error: () => {
this.authProcessing.set(false);
this.authOpen.set(false);
this.error.set(this.t('errors.payment_failed'));
}
});
}
private openAuth(mode: AuthDialogMode): void {
this.authMode.set(mode);
this.authProcessing.set(false);
this.authOpen.set(true);
}
onAuthClosed(): void {
this.authProcessing.set(false);
this.authOpen.set(false);
}
onAuthAuthorized(event: AuthDialogAuthorizedEvent): void {
this.authProcessing.set(true);
if (this.authMode() === 'new') {
this.authProcessing.set(false);
this.authOpen.set(false);
this.doRedirectToNew(event.sessionId);
return;
}
if (this.authMode() === 'login') {
const tg = event.userId || event.userSessionId || '';
if (!tg) {
this.authProcessing.set(false);
this.authOpen.set(false);
this.error.set(this.t('errors.payment_failed'));
return;
}
this.telegramId.set(tg);
this.sendFastcheckToTelegram(tg);
return;
}
this.acceptFastcheck(event.sessionId);
}
private acceptFastcheck(sessionId: string): void {
this.popupLoading.set(true);
this.http
.post(
`${FASTCHECK_API}/fastcheck`,
@@ -227,8 +394,8 @@ export class FastcheckPage {
)
.subscribe({
next: () => {
this.popupLoading.set(false);
this.paid.set(true);
this.authProcessing.set(false);
this.authOpen.set(false);
// Fire DELETE to mark fastcheck as consumed on the merchant side.
this.http
.delete(`${FASTCHECK_API}/fastcheck/${encodeURIComponent(this.fastcheckNumber())}`)
@@ -236,13 +403,22 @@ export class FastcheckPage {
this.fireMerchantCallback();
},
error: () => {
this.popupLoading.set(false);
this.popupError.set(this.t('errors.payment_failed'));
this.authProcessing.set(false);
this.authOpen.set(false);
this.error.set(this.t('errors.payment_failed'));
}
});
}
private fireMerchantCallback(): void {
// Prefer the callbackurl returned by POST /fastcheck/settings/{id}; this
// is the merchant URL the partner expects us to land on after pay.
const cb = this.callbackUrl();
if (cb) {
setTimeout(() => { window.location.href = cb; }, 1500);
return;
}
// Legacy fallback: ?return_url=... query param.
const params = new URLSearchParams(window.location.search);
const returnUrl = params.get('return_url');
if (returnUrl) {
@@ -315,17 +491,4 @@ export class FastcheckPage {
}
});
}
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 {
this.loginOnly.set(true);
this.openPopup();
}
}

View File

@@ -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 (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

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

View File

@@ -1,107 +0,0 @@
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

@@ -52,6 +52,6 @@
<div class="site-footer__bottom">
<p>© {{ year }} {{ 'footer.rights' | translate }}</p>
<p>{{ 'footer.director' | translate }}</p>
<!-- <p>{{ 'footer.director' | translate }}</p> -->
</div>
</footer>

View File

@@ -22,6 +22,7 @@ export class SiteHeader {
langs: LangOption[] = [
{ code: 'ru', label: 'Русский', flag: '/flags/ru.svg' },
{ code: 'en', label: 'English', flag: '/flags/en.svg' },
{ code: 'cn', label: '中文', flag: '/flags/cn.svg' },
{ code: 'hy', label: 'Հայերեն', flag: '/flags/arm.svg' },
];

View File

@@ -1,7 +1,7 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export type Lang = 'ru' | 'en' | 'hy';
export type Lang = 'ru' | 'en' | 'hy' | 'cn';
type Translations = Record<string, Record<string, string>>;
@Injectable({ providedIn: 'root' })