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

4
.gitignore vendored
View File

@@ -1,10 +1,12 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
/dist
changes.txt
api.txt
# Node
/node_modules

View File

@@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
.vscode/launch.json vendored
View File

@@ -1,20 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
.vscode/tasks.json vendored
View File

@@ -1,42 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

214
BACKEND.md Normal file
View File

@@ -0,0 +1,214 @@
# Fastcheck Backend — требования к серверу
Документ для команды бэкенда. Описывает, что должен реализовать сервер `api.fastcheck.store`, чтобы веб-фронт (этот репозиторий) полностью заработал.
---
## 1. Общие требования
### 1.1 Транспорт
- **Протокол**: HTTPS обязателен (валидный TLS-сертификат, Let's Encrypt или иной).
- **Хост**: `api.fastcheck.store` (или другой — тогда поправить `FASTCHECK_API` в `src/app/api.ts`).
- **Формат тел запроса/ответа**: `application/json; charset=utf-8`.
### 1.2 CORS — **критично**
Браузер фронта пойдёт с другого origin. Без правильных CORS-заголовков **ничего не заработает** (preflight упадёт, fetch вернёт network error — ровно то, что мы видим сейчас).
Сервер должен на любой `OPTIONS` (preflight) и на ответы реальных запросов отдавать:
```
Access-Control-Allow-Origin: https://<домен-фронта> # либо * для dev
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
```
Если используются cookies/credentials — добавить `Access-Control-Allow-Credentials: true` и **нельзя** использовать `*` в `Allow-Origin`.
`OPTIONS` должен отвечать `204 No Content` без тела.
### 1.3 Авторизация
Заголовок передаётся как **JSON-строка**, а не Bearer:
```
Authorization: {"sessionID":"1AF3781BF6B94604B771AEA1D44FA63A"}
```
Парсинг на сервере: `JSON.parse(req.headers.authorization)``{ sessionID }`.
Если заголовок отсутствует или сессия невалидна — `404 { "message": "not authorized" }`.
### 1.4 Ошибки
Любая ошибка — JSON `{ "message": "<человекочитаемое описание>" }` + HTTP-статус (4xx/5xx). Фронт показывает `message` пользователю.
---
## 2. Эндпоинты
База: `https://api.fastcheck.store`
### 2.1 `GET /ping`
Healthcheck. Ответ: `200 { "message": "pong" }`. Без авторизации.
---
### 2.2 `GET /websession`
Создаёт новую веб-сессию для QR-логина через Telegram-бот.
**Запрос**: без тела, без авторизации.
**Ответ** `200`:
```json
{
"sessionId": "1AF3781BF6B94604B771AEA1D44FA63A",
"userId": "",
"expires": "sessionId",
"userSessionId": "",
"Status": false
}
```
`sessionId` фронт подставляет в QR-код и в deeplink на бот:
`https://t.me/DexarSupport_bot?start=<sessionId>`
**TTL сессии**: рекомендуем 510 минут. По истечении `GET /websession/:id` должен вернуть `Status: false` навсегда (фронт сам пересоздаст).
---
### 2.3 `GET /websession/:webSessionID`
Поллинг статуса логина. Фронт зовёт каждые **3 секунды**, пока попап открыт.
**Ответ** `200` пока пользователь не залогинился:
```json
{ "sessionId": "...", "userId": "", "expires": "sessionId", "userSessionId": "", "Status": false }
```
**Ответ** `200` после того, как Telegram-бот подтвердил вход:
```json
{
"sessionId": "1AF3781BF6B94604B771AEA1D44FA63A",
"userId": "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
"expires": "sessionId",
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
"Status": true
}
```
Если сессия не найдена/истекла — `404 { "message": "session expired" }`.
---
### 2.4 `DELETE /websession/:webSessionID`
Logout / закрытие попапа.
**Запрос**:
```json
{ "sessionId": "1AF3781BF6B94604B771AEA1D44FA63A" }
```
**Ответ** `200 {}`. Идемпотентно — повторный вызов не должен ломаться.
---
### 2.5 `GET /fastcheck`
Проверка существования и срока действия чека (используется опционально перед оплатой).
**Запрос** (тело в GET):
```json
{ "fastcheck": "1234-5678-0001" }
```
**Ответ** `200`:
```json
{
"fastcheck": "1234-5678-0001",
"expiration": "2026-07-07T09:08:18Z",
"Status": true
}
```
Не существует / просрочен → `404 { "message": "not found" }`.
---
### 2.6 `POST /fastcheck` — **создание чека**
Юзер уже залогинен, его `sessionID` есть на фронте. С этого аккаунта списывается `amount`, выпускается чек.
**Заголовок**: `Authorization: {"sessionID":"..."}` обязателен.
**Тело**:
```json
{ "amount": 158000, "currency": "RUB" }
```
`amount` — в минимальных единицах валюты (копейки для RUB). Уточнить с фронтом, если иначе.
**Ответ** `200`:
```json
{
"fastcheck": "1234-5678-0001",
"expiration": "2026-07-07T09:08:18Z",
"code": "5864",
"Status": true
}
```
**Ошибки**:
- `404 { "message": "not authorized" }` — нет/невалидная сессия.
- `400 { "message": "insufficient balance" }` — мало средств.
- `400 { "message": "invalid amount" }` — некорректная сумма/валюта.
---
### 2.7 `POST /fastcheck` — **приём чека (оплата)**
Этот же путь, отличается шейпом тела (без `amount`, с `code`).
**Заголовок**: `Authorization: {"sessionID":"..."}` обязателен (сессия получателя — того, кто оплачивает).
**Тело**:
```json
{ "fastcheck": "1234-5678-0001", "code": "5864" }
```
**Ответ** `200 { "message": "ok" }` — чек погашен, средства зачислены получателю.
**Ошибки**:
- `404 { "message": "not authorized" }` — сессия невалидна **или** код неверный, **или** чек уже использован, **или** просрочен. (Так в текущей доке. Если можно различать — лучше отдельные сообщения.)
> ⚠️ Серверу важно различать два POST-кейса по наличию поля `amount` vs `code` в теле. Альтернатива (предпочтительнее на проде) — развести на разные пути: `POST /fastcheck` (создание) и `POST /fastcheck/accept` (приём). Если разведёте — скажите, фронт правится за 5 минут.
---
## 3. Интеграция с Telegram-ботом
Фронт сам бот не дёргает — это задача бэкенда.
1. Юзер сканит QR / кликает deeplink → попадает в бот `@DexarSupport_bot` с параметром `?start=<sessionId>`.
2. Бот идентифицирует Telegram-аккаунт (по `from.id`) → находит/создаёт `userId` → биндит его к `sessionId` → ставит `Status: true`, заполняет `userId` и `userSessionId`.
3. Следующий поллинг с фронта вернёт `Status: true` — фронт переходит к `POST /fastcheck`.
Если юзер впервые в боте — стандартный onboarding, потом всё то же самое.
---
## 4. Чеклист «готово к проду»
- [ ] HTTPS с валидным сертификатом на `api.fastcheck.store`.
- [ ] CORS разрешает домен фронта на всех 6 эндпоинтах + OPTIONS.
- [ ] `GET /ping` отвечает.
- [ ] Полный цикл: `GET /websession` → бот ставит `Status:true``GET /websession/:id` это видит.
- [ ] `POST /fastcheck` (create) с заголовком `Authorization` создаёт чек, списывает баланс.
- [ ] `POST /fastcheck` (accept) погашает чек только один раз, зачисляет получателю.
- [ ] `DELETE /websession/:id` корректно завершает сессию.
- [ ] Все ошибки в формате `{ "message": "..." }` + правильный HTTP-код.
- [ ] Сессии экспайрятся (510 мин для websession, разумный TTL для userSession).
- [ ] Rate-limit на `GET /websession/:id` (фронт поллит каждые 3 с) и на `POST /fastcheck`.
---
## 5. Открытые вопросы (нужны ответы от бэкенда)
1. **Единица `amount`**: рубли или копейки?
2. **Currency**: какие коды поддерживаете кроме `RUB`? (фронт уже умеет показывать, но шлёт пока только RUB)
3. **Merchant callback** для эквайринга: после успешного `POST /fastcheck (accept)` нужно ли серверу самому пинговать мерчант-вебхук, или это полностью на фронте через `?return_url=`?
4. **Различение ошибок accept**: можно ли вместо общего `404 not authorized` отдавать `not found` / `wrong code` / `already used` / `expired`?
5. **WebSession TTL** — сколько живёт?

179
BACKEND_CHANGES.md Normal file
View File

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

View File

@@ -1,426 +0,0 @@
# FastCheck Backend Implementation Guide
## QR Code Authentication Flow
### Overview
The frontend displays a QR code that contains a session ID. When a user scans this QR code with the mobile app, the mobile app authenticates and links to that session. The frontend polls the backend every 2 seconds to check if the session has been authenticated.
### Step-by-Step Implementation
---
## 1. Create WebSession (QR Code Generation)
### Frontend Request:
```typescript
GET https://api.fastcheck.store/websession
Headers: {
"Content-Type": "application/json"
}
```
### Backend Response:
```json
{
"sessionId": "1AF3781BF6B94604B771AEA1D44FA63A",
"userId": "",
"expires": "2026-01-19T10:50:00Z",
"userSessionId": "",
"Status": false
}
```
### Backend Implementation:
```javascript
// Example Node.js/Express
app.get('/websession', (req, res) => {
// Generate unique session ID (UUID or similar)
const sessionId = generateUUID(); // e.g., "1AF3781BF6B94604B771AEA1D44FA63A"
// Set expiration time (e.g., 5 minutes from now)
const expires = new Date(Date.now() + 5 * 60 * 1000).toISOString();
// Store session in database or cache (Redis recommended)
await sessionStore.create({
sessionId: sessionId,
userId: null,
userSessionId: null,
status: false,
expiresAt: expires,
createdAt: new Date()
});
// Return session data
res.json({
sessionId: sessionId,
userId: "",
expires: expires,
userSessionId: "",
Status: false
});
});
```
### What Frontend Does:
```typescript
// Frontend generates QR code data from session ID
const qrData = `fastcheck://login?session=${sessionId}`;
// Example: "fastcheck://login?session=1AF3781BF6B94604B771AEA1D44FA63A"
```
**QR Code Contains:** Deep link URL with session ID
- Format: `fastcheck://login?session={sessionId}`
- Mobile app will parse this URL and extract the sessionId
- Mobile app will then authenticate and update this session
---
## 2. Check WebSession Status (Polling)
### Frontend Request (Every 2 seconds):
```typescript
GET https://api.fastcheck.store/websession/1AF3781BF6B94604B771AEA1D44FA63A
Headers: {
"Content-Type": "application/json"
}
```
### Backend Response (Not Authenticated Yet):
```json
{
"sessionId": "1AF3781BF6B94604B771AEA1D44FA63A",
"userId": "",
"expires": "2026-01-19T10:50:00Z",
"userSessionId": "",
"Status": false
}
```
### Backend Response (Authenticated):
```json
{
"sessionId": "1AF3781BF6B94604B771AEA1D44FA63A",
"userId": "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
"expires": "2026-01-19T12:00:00Z",
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
"Status": true
}
```
### Backend Implementation:
```javascript
app.get('/websession/:sessionId', async (req, res) => {
const { sessionId } = req.params;
// Retrieve session from database/cache
const session = await sessionStore.get(sessionId);
if (!session) {
return res.status(404).json({ message: "Session not found" });
}
// Check if session expired
if (new Date() > new Date(session.expiresAt)) {
await sessionStore.delete(sessionId);
return res.status(404).json({ message: "Session expired" });
}
// Return session status
res.json({
sessionId: session.sessionId,
userId: session.userId || "",
expires: session.expiresAt,
userSessionId: session.userSessionId || "",
Status: session.status || false
});
});
```
---
## 3. Mobile App Authenticates Session
**This is what the MOBILE APP does** (not the web frontend):
### Mobile App Flow:
1. User scans QR code: `fastcheck://login?session=1AF3781BF6B94604B771AEA1D44FA63A`
2. Mobile app extracts sessionId: `1AF3781BF6B94604B771AEA1D44FA63A`
3. Mobile app authenticates user (PIN, biometrics, etc.)
4. Mobile app sends authentication request to backend:
```typescript
POST https://api.fastcheck.store/websession/authenticate
Headers: {
"Authorization": "Bearer {mobile_app_token}",
"Content-Type": "application/json"
}
Body: {
"sessionId": "1AF3781BF6B94604B771AEA1D44FA63A",
"userId": "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo"
}
```
### Backend Implementation:
```javascript
app.post('/websession/authenticate', authenticateMobileApp, async (req, res) => {
const { sessionId, userId } = req.body;
const mobileUserId = req.user.id; // From mobile app authentication
// Verify the mobile user matches
if (userId !== mobileUserId) {
return res.status(403).json({ message: "Unauthorized" });
}
// Update session with user information
const userSessionId = generateUUID();
await sessionStore.update(sessionId, {
userId: userId,
userSessionId: userSessionId,
status: true,
authenticatedAt: new Date()
});
res.json({ message: "Session authenticated" });
});
```
---
## 4. Logout (Delete Session)
### Frontend Request:
```typescript
DELETE https://api.fastcheck.store/websession/1AF3781BF6B94604B771AEA1D44FA63A
Headers: {
"Authorization": "{\"sessionID\": \"1AF3781BF6B94604B771AEA1D44FA63A\"}",
"Content-Type": "application/json"
}
```
### Backend Implementation:
```javascript
app.delete('/websession/:sessionId', async (req, res) => {
const { sessionId } = req.params;
// Delete session from database/cache
await sessionStore.delete(sessionId);
res.json({ message: "Session deleted" });
});
```
---
## 5. Authenticated API Requests
After login, all API requests include the sessionId in the Authorization header:
### Frontend Request:
```typescript
POST https://api.fastcheck.store/fastcheck
Headers: {
"Authorization": "{\"sessionID\": \"1AF3781BF6B94604B771AEA1D44FA63A\"}",
"Content-Type": "application/json"
}
Body: {
"amount": 150000,
"currency": "RUB"
}
```
### Backend Authentication Middleware:
```javascript
// Middleware to verify session
const authenticateSession = async (req, res, next) => {
try {
// Parse Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ message: "not authorized" });
}
// Parse JSON from Authorization header
const { sessionID } = JSON.parse(authHeader);
// Verify session exists and is authenticated
const session = await sessionStore.get(sessionID);
if (!session || !session.status) {
return res.status(401).json({ message: "not authorized" });
}
// Check if session expired
if (new Date() > new Date(session.expiresAt)) {
await sessionStore.delete(sessionID);
return res.status(401).json({ message: "not authorized" });
}
// Attach user info to request
req.user = {
userId: session.userId,
userSessionId: session.userSessionId,
sessionId: sessionID
};
next();
} catch (error) {
return res.status(401).json({ message: "not authorized" });
}
};
// Use middleware on protected routes
app.post('/fastcheck', authenticateSession, async (req, res) => {
const { amount, currency } = req.body;
const userId = req.user.userId;
// Create FastCheck logic...
});
```
---
## QR Code Data Format
### What the QR Code Contains:
```
fastcheck://login?session=1AF3781BF6B94604B771AEA1D44FA63A
```
**Format breakdown:**
- **Scheme**: `fastcheck://` - Deep link scheme for mobile app
- **Path**: `login` - Indicates this is a login QR code
- **Parameter**: `session={sessionId}` - The web session ID
### Frontend QR Code Implementation:
```typescript
// In login.component.ts
const sessionResponse = await createWebSession();
const qrData = `fastcheck://login?session=${sessionResponse.sessionId}`;
// QR code component displays this as a QR image
<qrcode [qrdata]="qrData" [width]="250"></qrcode>
```
---
## Database Schema Recommendations
### WebSession Table:
```sql
CREATE TABLE web_sessions (
session_id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(255),
user_session_id VARCHAR(64),
status BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
authenticated_at TIMESTAMP,
INDEX idx_expires (expires_at),
INDEX idx_status (status)
);
-- Auto-delete expired sessions
CREATE EVENT cleanup_expired_sessions
ON SCHEDULE EVERY 1 HOUR
DO
DELETE FROM web_sessions WHERE expires_at < NOW();
```
### Or use Redis (Recommended for sessions):
```javascript
// Redis structure
const sessionKey = `websession:${sessionId}`;
await redis.setex(sessionKey, 300, JSON.stringify({
sessionId: sessionId,
userId: userId,
userSessionId: userSessionId,
status: true
}));
```
---
## Security Considerations
1. **Session Expiration**: Sessions should expire after 5 minutes if not authenticated
2. **HTTPS Only**: All communication must be over HTTPS
3. **CORS Configuration**: Configure CORS to allow frontend domain
4. **Session Cleanup**: Regularly clean up expired sessions
5. **Rate Limiting**: Limit polling requests to prevent abuse
6. **Mobile App Authentication**: Mobile app must authenticate before linking session
---
## Testing the Flow
### 1. Test Session Creation:
```bash
curl -X GET https://api.fastcheck.store/websession
```
Expected: New session with Status: false
### 2. Test Polling:
```bash
curl -X GET https://api.fastcheck.store/websession/{sessionId}
```
Expected: Same session, Status: false (until mobile app authenticates)
### 3. Test Mobile Authentication (simulate):
```bash
curl -X POST https://api.fastcheck.store/websession/authenticate \
-H "Authorization: Bearer {mobile_token}" \
-H "Content-Type: application/json" \
-d '{"sessionId": "{sessionId}", "userId": "{userId}"}'
```
Expected: Session updated with Status: true
### 4. Test Polling After Auth:
```bash
curl -X GET https://api.fastcheck.store/websession/{sessionId}
```
Expected: Session with Status: true, userId populated
---
## Frontend Polling Implementation (Already Done)
```typescript
// In auth.service.ts
startPolling(sessionId: string): Observable<WebSession> {
return interval(2000).pipe( // Poll every 2 seconds
switchMap(() => this.checkWebSessionStatus(sessionId)),
tap(session => {
if (session.Status) {
this.setAuthenticated(session);
}
}),
takeWhile(session => !session.Status, true) // Stop when authenticated
);
}
```
---
## Summary for Backend Team
**Required Endpoints:**
1.`GET /websession` - Create session for QR
2.`GET /websession/:id` - Check session status (polled)
3. ⚠️ `POST /websession/authenticate` - Mobile app authenticates session (NEW)
4.`DELETE /websession/:id` - Logout
**Required Logic:**
- Generate unique session IDs
- Store sessions with expiration
- Mobile app updates session status
- Web frontend polls until Status = true
- All authenticated APIs verify session in Authorization header
**QR Code Data:**
- Format: `fastcheck://login?session={sessionId}`
- Mobile app parses and authenticates
- Web polls until mobile authenticates

153
DEPLOY.md Normal file
View File

@@ -0,0 +1,153 @@
# Развёртывание (Deploy)
## 1. Требования
- **Node.js 20+** и **npm 10+**
- Доступ к серверу с веб-сервером (nginx / Apache / Caddy / IIS) или к статическому хостингу (Netlify, Vercel, GitHub Pages, S3 + CloudFront и т.п.)
- HTTPS (обязательно — backend принимает только HTTPS, а Telegram QR не будет работать на http)
## 2. Сборка production-бандла
```powershell
# в корне проекта
npm ci # чистая установка зависимостей
npm run build # production-сборка
```
Результат окажется в папке `dist/qr-vitanova/browser/` — это и есть набор статических файлов, который надо опубликовать.
## 3. Конфигурация API
Эндпоинты заданы в `src/app/api.ts`:
- `FASTCHECK_API``https://api.fastcheck.store`
- `QR_API``https://qr.vitanova.network:567/qr` (legacy, на текущих страницах не используется)
Имя Telegram-бота — в `src/app/pages/fastcheck-page/fastcheck-page.ts` (поле `telegramBot`, сейчас `DexarSupport_bot`).
## 4. Публикация статики
Скопируй содержимое `dist/qr-vitanova/browser/` в корень сайта.
### Важно: SPA-routing
У приложения два маршрута (`/` и `/new`), поэтому веб-сервер должен возвращать `index.html` для всех неизвестных путей, иначе обновление страницы на `/new` даст 404.
#### nginx
```nginx
server {
listen 443 ssl http2;
server_name pay.example.com;
root /var/www/qr-vitanova;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Кэш для статики
location ~* \.(?:js|css|svg|woff2?|ico|png|jpg)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# index.html — без кеша, чтобы быстро прилетал новый билд
location = /index.html {
add_header Cache-Control "no-cache";
}
ssl_certificate /etc/letsencrypt/live/pay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pay.example.com/privkey.pem;
}
server {
listen 80;
server_name pay.example.com;
return 301 https://$host$request_uri;
}
```
#### Apache (`.htaccess`)
```apache
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
```
#### IIS (`web.config`)
```xml
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="SPA">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>
```
## 5. CORS на backend
`api.fastcheck.store` должен возвращать заголовки CORS, разрешающие домен фронта:
```
Access-Control-Allow-Origin: https://pay.example.com
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
```
## 6. Параметры запуска страницы
- `?session=<sessionID>` — необязательный, передаётся на `/new`, чтобы вставить `Authorization: {"sessionID": ...}` при `POST /fastcheck`.
- `?return_url=<url>` — необязательный, на `/`. После успешного приёма фасчека (`POST /fastcheck` с `code`) страница редиректит на этот URL с параметрами `?fastcheck=...&status=ok` — это и есть merchant-callback.
Пример: `https://pay.example.com/?return_url=https://shop.example.com/order/42`
## 7. Деплой одной командой (пример через rsync)
```powershell
npm run build
rsync -az --delete dist/qr-vitanova/browser/ user@server:/var/www/qr-vitanova/
ssh user@server "sudo systemctl reload nginx"
```
## 8. Docker (опционально)
`Dockerfile`:
```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist/qr-vitanova/browser/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
## 9. Проверка после деплоя
1. Открой `https://pay.example.com/` — должна быть форма фастчека.
2. Открой `https://pay.example.com/new` напрямую — должна открыться страница создания (не 404).
3. В DevTools → Network проверь, что запросы к `https://api.fastcheck.store/...` идут без CORS-ошибок.
4. Нажми «Оплатить» с заполненными полями — должен открыться popup с QR Telegram (`@DexarSupport_bot`).

View File

@@ -1,200 +0,0 @@
# FastCheck Application - Implementation Summary
## ✅ Completed Features
### 1. Project Structure
- ✅ Angular 21 standalone components architecture
- ✅ TypeScript models for type safety
- ✅ SCSS styling with modern design
- ✅ Modular service-based architecture
### 2. Authentication System
- ✅ QR Code login component
- ✅ WebSession management
- ✅ Auto-polling every 2 seconds to check login status
- ✅ Session persistence in sessionStorage
- ✅ Route guards for protected pages
### 3. Dashboard
- ✅ Balance display (mocked)
- ✅ Create FastCheck with custom amount
- ✅ Accept FastCheck with number (xxxx-xxxx-xxxx) and code (xxxx)
- ✅ FastCheck number auto-formatting
- ✅ Success/error handling
- ✅ Modal to display created check details
### 4. Active Checks Page
- ✅ List all unused FastChecks
- ✅ Copy to clipboard functionality
- ✅ Display check details (number, code, amount, expiration)
- ✅ Security warnings
### 5. Transaction History
- ✅ View all used/expired checks
- ✅ Distinguish between created and accepted checks
- ✅ Timestamps and status display
## 📁 File Structure
```
FastCheck/
├── public/
│ ├── api.txt # Original API documentation
│ └── missing-apis.txt # Missing API specifications for backend
├── src/
│ ├── app/
│ │ ├── components/
│ │ │ ├── login/ # QR login with polling
│ │ │ ├── dashboard/ # Main dashboard
│ │ │ ├── active-checks/ # Active checks list
│ │ │ └── history/ # Transaction history
│ │ ├── services/
│ │ │ ├── api.service.ts # HTTP client wrapper
│ │ │ ├── auth.service.ts # Authentication & session management
│ │ │ └── fastcheck.service.ts # FastCheck operations
│ │ ├── models/
│ │ │ ├── session.model.ts # Session interfaces
│ │ │ ├── fastcheck.model.ts # FastCheck interfaces
│ │ │ └── api.model.ts # API response interfaces
│ │ ├── guards/
│ │ │ └── auth.guard.ts # Route protection
│ │ ├── app.routes.ts # Route configuration
│ │ ├── app.config.ts # App configuration
│ │ ├── app.ts # Root component
│ │ ├── app.html # Root template
│ │ └── app.scss # Global styles
│ ├── environments/
│ │ └── environment.ts # Environment configuration
│ ├── index.html # Main HTML
│ ├── main.ts # Bootstrap
│ └── styles.scss # Global styles
├── package.json
└── README.md # Project documentation
```
## 🔧 Technologies Used
- **Angular 21** - Modern standalone components
- **TypeScript** - Type-safe development
- **RxJS** - Reactive programming (polling, API calls)
- **SCSS** - Styling
- **angularx-qrcode** - QR code generation
- **HttpClient** - API communication
## 🎨 Design Features
- Modern gradient UI (purple/violet theme)
- Responsive layout
- Smooth animations and transitions
- Loading states and spinners
- Error handling with user-friendly messages
- Copy-to-clipboard functionality
- Modal dialogs for important information
## 🔌 API Integration
### Fully Integrated:
1. `GET /ping` - Server health check
2. `GET /websession` - Create login session
3. `GET /websession/:id` - Poll login status
4. `DELETE /websession/:id` - Logout
5. `POST /fastcheck` - Create new check (with Authorization)
6. `POST /fastcheck` - Accept check (with Authorization)
7. `GET /fastcheck` - Check status
### Mocked (Need Backend Implementation):
1. `GET /balance` - Get user balance
2. `GET /fastcheck/active` - List active checks
3. `GET /fastcheck/history` - Transaction history
See `public/missing-apis.txt` for complete API specifications.
## 🚀 Running the Application
```bash
# Navigate to project directory
cd F:\dx\remote\FastCheck\FastCheck
# Install dependencies (already done)
npm install
# Start development server
npm start
# Open browser at http://localhost:4200
```
## 📝 Next Steps for Backend Team
1. **Implement Missing APIs:**
- Balance endpoint
- Active checks endpoint
- History endpoint
2. **Bank Integration:**
- Payment gateway API
- Redirect URLs for payment flow
- Webhook for payment confirmation
- Balance top-up mechanism
3. **Update Frontend When Ready:**
- Uncomment real API calls in `fastcheck.service.ts`
- Remove mock `of()` observables
- Test with real data
## 🔐 Security Considerations
- SessionID stored in sessionStorage (clears on tab close)
- Authorization header on all authenticated requests
- CORS must be configured on backend
- HTTPS required in production
- FastCheck codes are sensitive - handle securely
## 📱 User Flow
1. **Login:**
- User opens app → sees QR code
- Scans with mobile app
- Frontend polls every 2s
- Redirects to dashboard on success
2. **Create FastCheck:**
- Enter amount
- Click create
- Get number + code in modal
- Save credentials securely
3. **Accept FastCheck:**
- Enter number (auto-formatted)
- Enter code
- Submit
- Money added to balance
4. **View Checks:**
- Active checks → unused checks with copy feature
- History → all used/expired transactions
## 🐛 Known Limitations (Temporary)
- Balance API is mocked (returns 150,000 RUB)
- Active checks are mocked (returns 2 sample checks)
- History is mocked (returns 2 sample transactions)
- Bank integration not implemented yet
- No actual QR scanning (need mobile app integration)
## 📞 Contact
Developer: sdarbinyan@4pay.ru
Project: FastCheck СБП Payment System
Company: 4Pay
## ✨ Status
**Development Server:** ✅ Running on http://localhost:4200
**All Components:** ✅ Implemented
**Routing:** ✅ Configured with guards
**Styling:** ✅ Complete with modern UI
**Mock Data:** ✅ In place for testing
**Documentation:** ✅ Complete
Ready for backend integration and testing!

180
README.md
View File

@@ -1,175 +1,59 @@
# FastCheck - СБП Payment System
# QrVitanova
FastCheck is an online payment system that allows users to create and manage payment checks with СБП (Faster Payment System).
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.4.
## Features
## Development server
- ✅ QR Code Authentication
- ✅ Balance Management
- ✅ Create FastCheck with custom amount
- ✅ Accept FastCheck with number and PIN
- ✅ View Active Checks
- ✅ Transaction History
- ⏳ Bank Integration (To be implemented)
## Tech Stack
- **Framework**: Angular 21
- **Language**: TypeScript
- **Styling**: SCSS
- **HTTP Client**: Angular HttpClient
- **QR Code**: angularx-qrcode
- **API**: RESTful API (api.fastcheck.store)
## Getting Started
### Prerequisites
- Node.js (v18 or higher)
- npm (v10 or higher)
### Installation
To start a local development server, run:
```bash
# Install dependencies
npm install
# Start development server
npm start
# The app will run on http://localhost:4200
ng serve
```
### Build
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
# Production build
npm run build
# Output will be in dist/ folder
ng generate component component-name
```
## Project Structure
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```
src/
├── app/
│ ├── components/
│ │ ├── login/ # QR login with polling
│ │ ├── dashboard/ # Main dashboard
│ │ ├── active-checks/ # Active FastChecks list
│ │ └── history/ # Transaction history
│ ├── services/
│ │ ├── api.service.ts # HTTP client wrapper
│ │ ├── auth.service.ts # Authentication logic
│ │ └── fastcheck.service.ts # FastCheck operations
│ ├── models/ # TypeScript interfaces
│ ├── guards/ # Route guards
│ └── app.routes.ts # Route configuration
```bash
ng generate --help
```
## API Documentation
## Building
### Implemented APIs
To build the project run:
-`GET /ping` - Check server availability
-`GET /websession` - Create QR session
-`GET /websession/:id` - Check login status (polling)
-`DELETE /websession/:id` - Logout
-`POST /fastcheck` - Create new FastCheck
-`POST /fastcheck` - Accept FastCheck
-`GET /fastcheck` - Check FastCheck status
### Missing APIs (Mocked in Frontend)
See `public/missing-apis.txt` for complete specifications:
-`GET /balance` - Get user balance
-`GET /fastcheck/active` - Get active checks
-`GET /fastcheck/history` - Get transaction history
**Note**: These APIs are currently mocked in the frontend. The backend team needs to implement them.
## Features Overview
### 1. Authentication
- Scan QR code with mobile app
- Auto-polling every 2 seconds
- Session management with sessionStorage
### 2. Dashboard
- View current balance
- Create new FastCheck
- Accept existing FastCheck
- FastCheck format: `xxxx-xxxx-xxxx`
- Code format: `xxxx`
### 3. Active Checks
- View all unused FastChecks
- Copy number and code to clipboard
- See expiration dates
### 4. Transaction History
- View used/expired checks
- Filter by created/accepted
- See timestamps
### 5. Balance Top-Up (To be implemented)
- Bank integration needed
- Will redirect to bank payment page
- Auto-refresh balance after payment
## Development Notes
### Mock Data
The following services return mock data:
- `getBalance()` - Returns 150,000 RUB
- `getActiveFastChecks()` - Returns 2 sample active checks
- `getFastCheckHistory()` - Returns 2 sample history records
Replace the mocked `of()` observables with real API calls once backend is ready.
### Environment Configuration
Update `src/environments/environment.ts` for different API URLs:
```typescript
export const environment = {
production: false,
apiUrl: 'https://api.fastcheck.store'
};
```bash
ng build
```
## Backend Requirements
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
Backend team needs to implement:
## Running unit tests
1. **Balance API** - `GET /balance`
2. **Active Checks API** - `GET /fastcheck/active`
3. **History API** - `GET /fastcheck/history`
4. **Bank Integration** - Payment gateway integration
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
See `public/missing-apis.txt` for detailed API specifications.
```bash
ng test
```
## Security Notes
## Running end-to-end tests
- SessionId stored in sessionStorage (clears on tab close)
- All authenticated requests include Authorization header
- FastCheck codes are sensitive - handle securely
- Implement HTTPS in production
For end-to-end (e2e) testing, run:
## Browser Support
```bash
ng e2e
```
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## License
## Additional Resources
Private - 4Pay
## Contact
For questions or issues, contact: sdarbinyan@4pay.ru
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -6,7 +6,7 @@
},
"newProjectRoot": "projects",
"projects": {
"FastCheck": {
"qr_vitanova": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
@@ -20,6 +20,10 @@
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist",
"browser": ""
},
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
@@ -43,8 +47,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "10kB",
"maximumError": "16kB"
}
],
"outputHashing": "all"
@@ -61,10 +65,11 @@
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "FastCheck:build:production"
"buildTarget": "qr_vitanova:build:production"
},
"development": {
"buildTarget": "FastCheck:build:development"
"buildTarget": "qr_vitanova:build:development",
"proxyConfig": "proxy.conf.json"
}
},
"defaultConfiguration": "development"

2732
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "fast-check",
"name": "qr-vitanova",
"version": "0.0.0",
"scripts": {
"ng": "ng",
@@ -29,7 +29,6 @@
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"angularx-qrcode": "^21.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},

291
payment.html Normal file
View File

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

23
proxy.conf.json Normal file
View File

@@ -0,0 +1,23 @@
{
"/proxy/legacy-qr": {
"target": "https://qr.vitanova.network:567",
"secure": false,
"changeOrigin": true,
"pathRewrite": { "^/proxy/legacy-qr": "" },
"logLevel": "debug"
},
"/proxy/fastcheck": {
"target": "https://api.fastcheck.store",
"secure": true,
"changeOrigin": true,
"pathRewrite": { "^/proxy/fastcheck": "" },
"logLevel": "debug"
},
"/proxy/qr-vitanova": {
"target": "https://qr.vitanova.network",
"secure": true,
"changeOrigin": true,
"pathRewrite": { "^/proxy/qr-vitanova": "" },
"logLevel": "debug"
}
}

View File

@@ -0,0 +1,142 @@
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.
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.
Protocol: https
Root Path: api.Fastcheck.store
Type GET
Path /ping
Request Parameters:
{
}
Response (OK):
{
"message": "pong",
}
________________
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
Type GET
Path /websession
Request Parameters:
{
}
Response (OK):
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”
"userId" : "",
"expires" : "sessionId",
"userSessionId": "",
"Status": false
}
________________
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
Type GET
Path /websession/:webSessionID
Request Parameters:
{
}
Response (OK):
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
"expires" : "sessionId",
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
"Status": true
}
________________
Delete websession status
Delete the session to log out from the system.
Protocol: https
Root Path: api.Fastcheck.store
Type DELETE
Path /websession/:webSessionID
Request Parameters:
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”
}
Response (OK):
{
}
________________
Check Fastcheck status
Check if fastcheck exists and get the amount assigned to check.
Protocol: https
Root Path: api.Fastcheck.store
Type GET
Path /fastcheck
Request Parameters:
{
"fastcheck": “1234-5678-0001”,
}
Response (OK):
{
"fastcheck": "1234-5678-0001",
"expiration": 2021-07-07T09:08:18Z ,
"Status": true
}
________________
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
Type POST
Path /fastcheck
HEADER: Authorization - {"sessionID": "1AF3781BF6B94604B771AEA1D44FA63A"}
Request Parameters:
{
"amount": 158000,
"currency": "RUB"
}
Response (OK):
{
"fastcheck": "1234-5678-0001",
"expiration": 2021-07-07T09:08:18Z ,
"code": "5864",
"Status": true
}
________________
Accept Fastcheck
Accept fastcheck to the user balance.
Protocol: https
Root Path: api.Fastcheck.store
Type POST
Path /fastcheck
HEADER: Authorization - {"sessionID": "1AF3781BF6B94604B771AEA1D44FA63A"}
Request Parameters:
{
"fastcheck": "1234-5678-0001",
"code": "5864"
}
Response (404-ERROR):
{
"message": "not authorized"
}
Response (200-OK):
{
"message": "ok"
}

262
public/SBP QR API.txt Normal file
View File

@@ -0,0 +1,262 @@
General Information
Information exchange with the SBP 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.
Header:
“Authorization”: {JSON WITH KEY AND PARTNERID}
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.
Protocol: https
Root Path: QR.VITANOVA.NETWORK
Type GET
Path /ping
Request Parameters:
{
}
Response (Error):
{
"message": "pong",
"status": "Wrong Header"
}
Response (OK):
{
"message": "pong",
"status": "Correct Header"
}
________________
Create New QR code
Create New QR for payment via SBP
Protocol: https
Root Path: QR.VITANOVA.NETWORK
Type POST
Path /qr
Request Parameters:
{
"amount": 10.00, //amount from 10Rub to 499.000 Rub
"qrDescription": "Item description",
"order": "540", //orderid at partners platform
"partnerID": 102 //same as in header
"Phonemask": 79xxxx66265 //User phone number mask, needed only for crypto based operations. Payment will be accepted only from phone numbers corresponding to the mask
"Namelastname": Hakxx Sargxxxx /Mask for User name, lastname in cyrilic, needed only for crypto based operations. Payment will be accepted only from the user corresponding to that mask.
}
Response !=200(Error):
{
"error": "wrong key"
}
Response =200(OK):
{
"qrId": "BD10002CI1V3JP1T8QR8TIQ8K35RBVQB",
"qrStatus": "NEW",
"qrExpirationDate": "2025-11-20T10:10:44Z",
"Payload": "https://qr.nspk.ru/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB?type=02&bank=100000000007&sum=1000&cur=RUB&crc=8ACC",
"qrUrl": "https://e-commerce.raiffeisen.ru/api/sbp/v1/qr/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB/image"
}
________________
Check Dynamic QR code
Check QR status
Protocol: https
Root Path: QR.VITANOVA.NETWORK
Type GET
Path /qr/dynamic/{qrId}
Request Parameters:
{
}
Response !=200(Error):
{
"error": "Error from the bank "
}
Response =200(OK):
{
`json:"nspkID"` //": "AD100060JFQF8FSB9Q28FFL88IH6SST0" `json:"amount"` // "1235"
`json:"currency" // "RUB"
`json:"order"` // "126" partner order id PaymentDetails
`json:"paymentDetails"` // "Назначение платежа 2",
`json:"qrType"` //"QRDynamic",
`json:"qrExpirationDate"` //: "2025-11-22T09:14:38+03:00" `json:"sbpBank"` // "raiffeisen"
`json:"sbpMerchant"` //"Dexar"
`json:"sbpMerchantId"` //"", uint64
`json:"sbpOperationId"` //0 Status
`json:"status"` //": "NEW", "APPROVED", "REJECTED", "COMPLETED"
`json:"nspkurl"` //"https://qr.nspk.ru/AD100060JFQF8FSB9Q28FFL88IH6SST0
`json:"statusurl"` // "https://partner.com/1234321/status" url for checking QR `json:"redirectUrl"` //"https://fastcheck.store/"
`json:"qrDescription"` //"QR для оплаты заказа"
`json:"additionalInfo"` // TTL
`json:"TTL"` //10 timeout in minutes
`json:"callbackUrl"` // https://partner.com/1234321 callback after QR get paid
`json:"retry"` //0 retry count for calling partner
`json:"partnerID"` //103 Partner created QR PartnerqrID `json:"partnerqrID"` //QR ID in partner system RequestIP
`json:"requestIP"` //IP address of client requested QR
}
________________
Check Static QR code
Get all qr-s paid by static QR for today, skipping already read qr codes
Protocol: https
Root Path: QR.VITANOVA.NETWORK
Type GET
Path /qr/static/{qrId}?skip=25
Request Parameters:
{
}
Response =200(OK):
[{
`json:"nspkID"` //": "AD100060JFQF8FSB9Q28FFL88IH6SST0" `json:"amount"` // "1235"
`json:"currency" // "RUB"
`json:"order"` // "126" partner order id PaymentDetails
`json:"paymentDetails"` // "Назначение платежа 2",
`json:"qrType"` //"QRDynamic",
`json:"qrExpirationDate"` //: "2025-11-22T09:14:38+03:00" `json:"sbpBank"` // "raiffeisen"
`json:"sbpMerchant"` //"Dexar"
`json:"sbpMerchantId"` //"", uint64
`json:"sbpOperationId"` //0 Status
`json:"status"` //": "NEW", "APPROVED", "REJECTED", "COMPLETED"
`json:"nspkurl"` //"https://qr.nspk.ru/AD100060JFQF8FSB9Q28FFL88IH6SST0
`json:"statusurl"` // "https://partner.com/1234321/status" url for checking QR `json:"redirectUrl"` //"https://fastcheck.store/"
`json:"qrDescription"` //"QR для оплаты заказа"
`json:"additionalInfo"` // TTL
`json:"TTL"` //10 timeout in minutes
`json:"callbackUrl"` // https://partner.com/1234321 callback after QR get paid
`json:"retry"` //0 retry count for calling partner
`json:"partnerID"` //103 Partner created QR PartnerqrID `json:"partnerqrID"` //QR ID in partner system RequestIP
`json:"requestIP"` //IP address of client requested QR
}]
________________
Delete QR
Delete unused QR. If QR is not paid until expiration time, it will be automatically deleted.
Protocol: https
Root Path: QR.VITANOVA.NETWORK
Type DELETE
Path /qr/{qrId}
Request Parameters:
{
}
Response !=200(Error):
{
"error": "Error from the bank "
}
Response =200(OK):
{
}
________________
Check Partner
Returns partner status, with balance and transactions. Each transaction id is QR code, which can be checked additionally.
Root Path: API.VITANOVA.NETWORK
Type Get
Path /partners/{partnerID}
Request Parameters:
{
}
Response !=200(Error):
{
"error": "Not authorized "
}
Response =200(OK):
{
"telegram_id": 8285633,
"username": "ZZZ",
"first_name": "АMAN",
"last_name": "",
"balance": 22,
"transaction": [
{
"additionalInfo": "Ручка",
"paymentPurpose": "Ручка",
"amount": 22,
"code": "SUCCESS",
"createDate": "2025-11-22T15:57:40.925104+03:00",
"currency": "RUB",
"order": "8285633735_301",
"paymentStatus": "SUCCESS",
"qrId": "AD10004C1K9N71MN907RD56UOA0BHIBR",
"transactionDate": "2025-11-22T15:58:14.814187+03:00",
"transactionId": 771515533,
"qrExpirationDate": "2025-11-22T16:12:40+03:00"
}
],
"inn": 0
}
________________
Withdraw
Get amount from balance and Creates fastcheck, which then can be for buying usdt, transferring to bank account and to bank card. Fastcheck can be checked on site or via API only by Id, but can be used only with code.
Root Path: QR.VITANOVA.NETWORK
Type POST
Path/partners/withdraw/{partnerID}
Request Parameters:
{
“amount”: 10600.00
“currency”: “RUB”
“partnerId: “1023454”
“wallet”: “TBia4uHnb3oSSZm5isP284cA7Np1v15Vhi”
“”
“rate”:79.50
}
Response !=200(Error):
{
"error": "Not enough amount on balance "
}
Response !=200(Error):
{
"error": "Rate is not correct "
}
Response =200(OK):
{
“trxID”:”T5Mv2v8n9L7jY4k1pW3QhUoZfE9R1X3s7rY6tB0pA2C4D6E8F5H”
}
________________
RATE
Get currency exchange rate.
Root Path: QR.VITANOVA.NETWORK
Type GET
Path/partners/rate
Request Parameters:
Response !=200(Error):
{
"error": "Not Authorized "
}
Response =200(OK):
{
"rate": 78.5
}

5
public/alipay.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<rect width="48" height="48" rx="8" fill="#1677FF"/>
<text x="24" y="34" font-family="Arial,Helvetica,sans-serif" font-size="26" font-weight="900"
text-anchor="middle" fill="#fff">A</text>
</svg>

After

Width:  |  Height:  |  Size: 269 B

0
public/example.json Normal file
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="#102f9b" d="M1 11H31V21H1z"></path><path d="M5,4H27c2.208,0,4,1.792,4,4v4H1v-4c0-2.208,1.792-4,4-4Z" fill="#c82a20"></path><path d="M5,20H27c2.208,0,4,1.792,4,4v4H1v-4c0-2.208,1.792-4,4-4Z" transform="rotate(180 16 24)" fill="#e8ad3b"></path><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><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"></path></svg>

After

Width:  |  Height:  |  Size: 709 B

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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect x="1" y="4" width="30" height="24" rx="4" ry="4" fill="#fff"></rect><path d="M1.638,5.846H30.362c-.711-1.108-1.947-1.846-3.362-1.846H5c-1.414,0-2.65,.738-3.362,1.846Z" fill="#a62842"></path><path d="M2.03,7.692c-.008,.103-.03,.202-.03,.308v1.539H31v-1.539c0-.105-.022-.204-.03-.308H2.03Z" fill="#a62842"></path><path fill="#a62842" d="M2 11.385H31V13.231H2z"></path><path fill="#a62842" d="M2 15.077H31V16.923000000000002H2z"></path><path fill="#a62842" d="M1 18.769H31V20.615H1z"></path><path d="M1,24c0,.105,.023,.204,.031,.308H30.969c.008-.103,.031-.202,.031-.308v-1.539H1v1.539Z" fill="#a62842"></path><path d="M30.362,26.154H1.638c.711,1.108,1.947,1.846,3.362,1.846H27c1.414,0,2.65-.738,3.362-1.846Z" fill="#a62842"></path><path d="M5,4h11v12.923H1V8c0-2.208,1.792-4,4-4Z" fill="#102d5e"></path><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><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"></path><path fill="#fff" d="M4.601 7.463L5.193 7.033 4.462 7.033 4.236 6.338 4.01 7.033 3.279 7.033 3.87 7.463 3.644 8.158 4.236 7.729 4.827 8.158 4.601 7.463z"></path><path fill="#fff" d="M7.58 7.463L8.172 7.033 7.441 7.033 7.215 6.338 6.989 7.033 6.258 7.033 6.849 7.463 6.623 8.158 7.215 7.729 7.806 8.158 7.58 7.463z"></path><path fill="#fff" d="M10.56 7.463L11.151 7.033 10.42 7.033 10.194 6.338 9.968 7.033 9.237 7.033 9.828 7.463 9.603 8.158 10.194 7.729 10.785 8.158 10.56 7.463z"></path><path fill="#fff" d="M6.066 9.283L6.658 8.854 5.927 8.854 5.701 8.158 5.475 8.854 4.744 8.854 5.335 9.283 5.109 9.979 5.701 9.549 6.292 9.979 6.066 9.283z"></path><path fill="#fff" d="M9.046 9.283L9.637 8.854 8.906 8.854 8.68 8.158 8.454 8.854 7.723 8.854 8.314 9.283 8.089 9.979 8.68 9.549 9.271 9.979 9.046 9.283z"></path><path fill="#fff" d="M12.025 9.283L12.616 8.854 11.885 8.854 11.659 8.158 11.433 8.854 10.702 8.854 11.294 9.283 11.068 9.979 11.659 9.549 12.251 9.979 12.025 9.283z"></path><path fill="#fff" d="M6.066 12.924L6.658 12.494 5.927 12.494 5.701 11.799 5.475 12.494 4.744 12.494 5.335 12.924 5.109 13.619 5.701 13.19 6.292 13.619 6.066 12.924z"></path><path fill="#fff" d="M9.046 12.924L9.637 12.494 8.906 12.494 8.68 11.799 8.454 12.494 7.723 12.494 8.314 12.924 8.089 13.619 8.68 13.19 9.271 13.619 9.046 12.924z"></path><path fill="#fff" d="M12.025 12.924L12.616 12.494 11.885 12.494 11.659 11.799 11.433 12.494 10.702 12.494 11.294 12.924 11.068 13.619 11.659 13.19 12.251 13.619 12.025 12.924z"></path><path fill="#fff" d="M13.539 7.463L14.13 7.033 13.399 7.033 13.173 6.338 12.947 7.033 12.216 7.033 12.808 7.463 12.582 8.158 13.173 7.729 13.765 8.158 13.539 7.463z"></path><path fill="#fff" d="M4.601 11.104L5.193 10.674 4.462 10.674 4.236 9.979 4.01 10.674 3.279 10.674 3.87 11.104 3.644 11.799 4.236 11.369 4.827 11.799 4.601 11.104z"></path><path fill="#fff" d="M7.58 11.104L8.172 10.674 7.441 10.674 7.215 9.979 6.989 10.674 6.258 10.674 6.849 11.104 6.623 11.799 7.215 11.369 7.806 11.799 7.58 11.104z"></path><path fill="#fff" d="M10.56 11.104L11.151 10.674 10.42 10.674 10.194 9.979 9.968 10.674 9.237 10.674 9.828 11.104 9.603 11.799 10.194 11.369 10.785 11.799 10.56 11.104z"></path><path fill="#fff" d="M13.539 11.104L14.13 10.674 13.399 10.674 13.173 9.979 12.947 10.674 12.216 10.674 12.808 11.104 12.582 11.799 13.173 11.369 13.765 11.799 13.539 11.104z"></path><path fill="#fff" d="M4.601 14.744L5.193 14.315 4.462 14.315 4.236 13.619 4.01 14.315 3.279 14.315 3.87 14.744 3.644 15.44 4.236 15.01 4.827 15.44 4.601 14.744z"></path><path fill="#fff" d="M7.58 14.744L8.172 14.315 7.441 14.315 7.215 13.619 6.989 14.315 6.258 14.315 6.849 14.744 6.623 15.44 7.215 15.01 7.806 15.44 7.58 14.744z"></path><path fill="#fff" d="M10.56 14.744L11.151 14.315 10.42 14.315 10.194 13.619 9.968 14.315 9.237 14.315 9.828 14.744 9.603 15.44 10.194 15.01 10.785 15.44 10.56 14.744z"></path><path fill="#fff" d="M13.539 14.744L14.13 14.315 13.399 14.315 13.173 13.619 12.947 14.315 12.216 14.315 12.808 14.744 12.582 15.44 13.173 15.01 13.765 15.44 13.539 14.744z"></path></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="#1435a1" d="M1 11H31V21H1z"></path><path d="M5,4H27c2.208,0,4,1.792,4,4v4H1v-4c0-2.208,1.792-4,4-4Z" fill="#fff"></path><path d="M5,20H27c2.208,0,4,1.792,4,4v4H1v-4c0-2.208,1.792-4,4-4Z" transform="rotate(180 16 24)" fill="#c53a28"></path><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><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"></path></svg>

After

Width:  |  Height:  |  Size: 706 B

130
public/i18n/en.json Normal file
View File

@@ -0,0 +1,130 @@
{
"header": {
"nav_about": "About",
"nav_contacts": "Contacts",
"nav_partners": "Partners",
"nav_support": "Support",
"aria_nav": "Navigation",
"aria_menu": "Mobile menu",
"aria_burger": "Menu",
"aria_close": "Close menu"
},
"footer": {
"desc": "An innovative virtual check service for individuals. Create digital checks online and cash them out at partner bank ATMs 24/7.",
"contacts_heading": "Contacts",
"russia": "Russia",
"armenia": "Armenia",
"support_label": "Tech support",
"support_hours": "24/7",
"questions_label": "Questions",
"questions_hours": "10:0019:00 MSK",
"legal_heading": "Legal details",
"legal_company": "LLC «VIAEXPORT»",
"legal_inn_ru": "TIN (RU): 9909675800",
"legal_inn_am": "TIN (AM): 01051049",
"legal_kpp": "KPP: 770287001",
"legal_ogrn": "OGRN: 282.110.1296681",
"legal_address": "Armenia, 0201, Yerevan, Minskaya St. 21-23, apt. 44",
"rights": "LLC «VIAEXPORT». All rights reserved.",
"director": "Director: Amirkhanyan Sargis Artashesovich"
},
"fastcheck": {
"subtitle": "Enter fastCHECK details or create a new one",
"number_label": "fastCHECK number",
"number_placeholder": "123456-123456-123456",
"number_new": "New",
"amount_label": "Amount",
"amount_checking": "Checking…",
"code_label": "Code",
"code_placeholder": "000000",
"pay_btn": "Pay",
"modal_title": "Sign in via Telegram",
"modal_sub": "Scan QR or open the link",
"modal_loading": "Loading…",
"modal_open_tg": "Open in Telegram",
"modal_confirming": "Confirming payment…",
"modal_waiting": "Waiting for sign-in…",
"modal_paid_title": "Paid",
"modal_paid_sub": "fastCHECK successfully accepted.",
"share_email": "Send by email",
"share_tg": "Send via Telegram"
},
"create": {
"title": "New",
"subtitle": "Enter the amount to top up",
"back_label": "Back",
"payment_label": "Payment method",
"currency_label": "Currency",
"amount_label": "Payment amount",
"note_label": "Note",
"note_placeholder": "Reason for payment...",
"creating": "Creating…",
"create_btn": "Create",
"amount_hint": "Allowed amount:",
"qr_label": "Scan QR to pay",
"qr_waiting": "Waiting for payment confirmation…"
},
"sbp": {
"title": "Pay via SBP",
"subtitle": "Fast Payment System",
"amount_label": "Payment amount",
"currency_name": "Russian ruble",
"note_label": "Note",
"note_placeholder": "Reason for payment...",
"pay_loading": "Please wait...",
"pay_btn": "Proceed to payment"
},
"about": {
"title": "About the service",
"lead": "fastCHECK is an innovative virtual check service for individuals, available 24/7.",
"what_title": "What is fastCHECK?",
"what_text": "fastCHECK is a digital check you create online and cash out at partner bank ATMs at any time of day. No queues, no offices — just your phone and the nearest ATM.",
"how_title": "How does it work?",
"step1": "Log in and create a new fastCHECK with the required amount.",
"step2": "Save the check number and 5-digit code.",
"step3": "Enter the details on the site and confirm via Telegram.",
"step4": "Receive the funds in a convenient way.",
"why_title": "Why fastCHECK?",
"why1": "Available 24/7 — including weekends and holidays.",
"why2": "Secure authorisation via Telegram.",
"why3": "Supports SBP and other popular payment methods.",
"why4": "Fast processing — from seconds to a few minutes.",
"company_title": "About the company",
"company_text": "The service is developed by LLC VIAEXPORT (TIN 9909675800). The company is registered in Russia and Armenia. Legal address: Armenia, 0201, Yerevan, Minskaya St. 21-23, apt. 44."
},
"contacts": {
"title": "Contacts",
"lead": "We are available 24/7. Choose your preferred way to reach us.",
"ru_label": "Phone — Russia",
"am_label": "Phone — Armenia",
"email_label": "Email",
"tg_label": "Telegram bot",
"hours_title": "Working hours"
},
"errors": {
"not_found": "Payment not found or expired.",
"lookup_failed": "Could not verify the number. Please try again.",
"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."
},
"common": {
"secure": "Secure connection"
},
"partners": {
"title": "Partners",
"lead": "Stores, services and companies accepting fastCHECK as a payment method.",
"cat_finance": "Finance",
"cat_retail": "Retail",
"cat_hotels": "Hotels",
"cat_services": "Services",
"p1_desc": "Currency exchange and transfers across Armenia.",
"p2_desc": "Forex broker supporting fastCHECK for account top-ups.",
"p3_desc": "Online retailer with delivery across Russia and CIS.",
"p4_desc": "Hotel booking and payment via fastCHECK.",
"cta_title": "Want to become a partner?",
"cta_text": "Connect fastCHECK to your business — fast, with minimal paperwork.",
"cta_btn": "Contact us"
}
}

130
public/i18n/hy.json Normal file
View File

@@ -0,0 +1,130 @@
{
"header": {
"nav_about": "Ծառայության մասին",
"nav_contacts": "Կապ",
"nav_partners": "Գործընկերներ",
"nav_support": "Աջակցություն",
"aria_nav": "Նավիգացիա",
"aria_menu": "Բջջային ընտրացանկ",
"aria_burger": "Ընտրացանկ",
"aria_close": "Փակել ընտրացանկը"
},
"footer": {
"desc": "Ֆիզիկական անձանց համար վիրտուալ չեկերի նորարարական ծառայություն: Ստեղծեք թվային չեկեր առցանց և կանխիկացրեք դրանք գործընկեր բանկերի բանկոմատներում 24/7:",
"contacts_heading": "Կապ",
"russia": "Ռուսաստան",
"armenia": "Հայաստան",
"support_label": "Տեխ. աջակցություն",
"support_hours": "24/7",
"questions_label": "Հարցեր",
"questions_hours": "10:0019:00 MSK",
"legal_heading": "Իրավաբանական տվյալներ",
"legal_company": "ООО «ВИАЭКСПОРТ»",
"legal_inn_ru": "ИНН (РФ): 9909675800",
"legal_inn_am": "ИНН (AM): 01051049",
"legal_kpp": "КПП: 770287001",
"legal_ogrn": "ОГРН: 282.110.1296681",
"legal_address": "Հայաստան, 0201, Երևան, Մինսկայա փ. 21-23, բն. 44",
"rights": "ООО «ВИАЭКСПОРТ»: Բոլոր իրավունքները պաշտպանված են:",
"director": "Տնօրեն՝ Ամիրխանյան Սարգիս Արտաշեսի"
},
"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": "Սկանավորեք QR կամ բացեք հղումը",
"modal_loading": "Բեռնվում է…",
"modal_open_tg": "Բացել Telegram-ում",
"modal_confirming": "Վճարման հաստատում…",
"modal_waiting": "Սպասում ենք մուտքի…",
"modal_paid_title": "Վճարված է",
"modal_paid_sub": "fastCHECK-ը հաջողությամբ ընդունված է:",
"share_email": "Ուղարկել էլ. նամակով",
"share_tg": "Ուղարկել Telegram-ով"
},
"create": {
"title": "Նոր",
"subtitle": "Նշեք համալրման գումարը",
"back_label": "Հետ",
"payment_label": "Վճարման եղանակ",
"currency_label": "Արժույթ",
"amount_label": "Վճարման գումար",
"note_label": "Նշում",
"note_placeholder": "Վճարման պատճառ...",
"creating": "Ստեղծվում է…",
"create_btn": "Ստեղծել",
"amount_hint": "Թույլատրելի գումար՝",
"qr_label": "Սկանավորեք QR-կոդը վճարելու համար",
"qr_waiting": "Սպասում ենք վճարման հաստատման…"
},
"sbp": {
"title": "Վճարել SBP-ով",
"subtitle": "Արագ վճարումների համակարգ",
"amount_label": "Վճարման գումար",
"currency_name": "Ռուսական ռուբլի",
"note_label": "Նշում",
"note_placeholder": "Վճարման պատճառ...",
"pay_loading": "Սպասեք...",
"pay_btn": "Անցնել վճարմանը"
},
"about": {
"title": "Ծառայության մասին",
"lead": "fastCHECK-ը ֆիզիկական անձանց համար վիրտուալ չեկերի նորարարական ծառայություն է, հասանելի 24/7:",
"what_title": "Ի՞նչ է fastCHECK-ը",
"what_text": "fastCHECK-ը թվային չեկ է, որը ստեղծում եք առցանց և կանխիկացնում գործընկեր բանկերի բանկոմատներում: Հերթեր չկան, գրասենյակներ չկան — միայն հեռախոս և ամենամոտ բանկոմատ:",
"how_title": "Ինչպե՞ս է դա աշխատում",
"step1": "Մուտք գործեք և ստեղծեք նոր fastCHECK անհրաժեշտ գումարով:",
"step2": "Պահպանեք չեկի համարն ու 5-նիշ կոդը:",
"step3": "Մուտքագրեք տվյալները կայքում և հաստատեք Telegram-ի միջոցով:",
"step4": "Ստացեք գումարն ձեզ հարմար ձևով:",
"why_title": "Ինչու՞ fastCHECK",
"why1": "Հասանելի 24/7 — ներառյալ հանգստյան և տոն օրերը:",
"why2": "Անվտանգ թույլտվություն Telegram-ի միջոցով:",
"why3": "Աջակցում է ՍԲՊ-ին և այլ հայտնի վճարման եղանակներ:",
"why4": "Արագ մշակում — վայրկյաններից մինչև մի քանի րոպե:",
"company_title": "Ընկերության մասին",
"company_text": "Ծառայությունը մշակվել է ООО «ВИАЭКСПОРТ»-ի կողմից (ИНН 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": "Մուտքագրեք ճիշտ գումար:"
},
"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": "Կապվեք մեզ հետ"
}
}

130
public/i18n/ru.json Normal file
View File

@@ -0,0 +1,130 @@
{
"header": {
"nav_about": "О сервисе",
"nav_contacts": "Контакты",
"nav_partners": "Партнёры",
"nav_support": "Поддержка",
"aria_nav": "Навигация",
"aria_menu": "Мобильное меню",
"aria_burger": "Меню",
"aria_close": "Закрыть меню"
},
"footer": {
"desc": "Инновационный сервис виртуальных чеков для физических лиц. Создавайте цифровые чеки онлайн и обналичивайте их через банкоматы банков-партнёров 24/7.",
"contacts_heading": "Контакты",
"russia": "Россия",
"armenia": "Армения",
"support_label": "Техподдержка",
"support_hours": "24/7",
"questions_label": "Вопросы",
"questions_hours": "10:0019:00 МСК",
"legal_heading": "Реквизиты",
"legal_company": "ООО «ВИАЭКСПОРТ»",
"legal_inn_ru": "ИНН (РФ): 9909675800",
"legal_inn_am": "ИНН (AM): 01051049",
"legal_kpp": "КПП: 770287001",
"legal_ogrn": "ОГРН: 282.110.1296681",
"legal_address": "Армения, 0201, Ереван, ул. Минская, дом 21-23, кв. 44",
"rights": "ООО «ВИАЭКСПОРТ». Все права защищены.",
"director": "Директор: Амирханян Саргис Арташесович"
},
"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": "Отсканируйте QR или откройте ссылку",
"modal_loading": "Загрузка…",
"modal_open_tg": "Открыть в Telegram",
"modal_confirming": "Подтверждение оплаты…",
"modal_waiting": "Ожидание входа…",
"modal_paid_title": "Оплачено",
"modal_paid_sub": "fastCHECK успешно принят.",
"share_email": "Отправить на почту",
"share_tg": "Отправить в Telegram"
},
"create": {
"title": "Новый",
"subtitle": "Укажите сумму для пополнения",
"back_label": "Назад",
"payment_label": "Способ оплаты",
"currency_label": "Валюта",
"amount_label": "Сумма платежа",
"note_label": "Примечание",
"note_placeholder": "Причина платежа...",
"creating": "Создание…",
"create_btn": "Создать",
"amount_hint": "Допустимая сумма:",
"qr_label": "Отсканируйте QR для оплаты",
"qr_waiting": "Ожидаем подтверждения оплаты…"
},
"sbp": {
"title": "Оплата через СБП",
"subtitle": "Система быстрых платежей",
"amount_label": "Сумма платежа",
"currency_name": "Российский рубль",
"note_label": "Примечание",
"note_placeholder": "Причина платежа...",
"pay_loading": "Подождите...",
"pay_btn": "Перейти к оплате"
},
"about": {
"title": "О сервисе",
"lead": "fastCHECK — инновационный сервис виртуальных чеков для физических лиц, доступный 24/7.",
"what_title": "Что такое fastCHECK?",
"what_text": "fastCHECK — это цифровой чек, который вы создаёте онлайн и обналичиваете через банкоматы банков-партнёров в любое время суток. Никакой очереди, никаких офисов — только телефон и ближайший банкомат.",
"how_title": "Как это работает?",
"step1": "Зайдите в личный кабинет и создайте новый fastCHECK с нужной суммой.",
"step2": "Запомните или сохраните номер чека и 5-значный код.",
"step3": "Введите данные на сайте и подтвердите операцию через Telegram.",
"step4": "Получите средства удобным вам способом.",
"why_title": "Почему fastCHECK?",
"why1": "Работает 24/7 — включая выходные и праздники.",
"why2": "Безопасная авторизация через Telegram.",
"why3": "Поддержка СБП и других популярных методов оплаты.",
"why4": "Быстрое обслуживание — от секунд до нескольких минут.",
"company_title": "О компании",
"company_text": "Сервис разработан ООО «ВИАЭКСПОРТ» (ИНН 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": "Введите корректную сумму."
},
"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": "Связаться с нами"
}
}

BIN
public/logo_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

BIN
public/logo_small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

1
public/mastercard.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="999.2" height="776" viewBox="0 0 999.2 776" xml:space="preserve"><path d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/><path fill="#ff5a00" d="M364 66.1h270.4v485.8H364z"/><path fill="#eb001b" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="#f79e1b" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,124 +0,0 @@
MISSING APIs - TO BE IMPLEMENTED BY BACKEND
==============================================
Get User Balance
----------------
Get the current balance of the authenticated user.
Protocol: https
Root Path: api.Fastcheck.store
Type: GET
Path: /balance
HEADER: Authorization - {"sessionID": "1AF3781BF6B94604B771AEA1D44FA63A"}
Request Parameters:
{
}
Response (200-OK):
{
"balance": 150000,
"currency": "RUB"
}
Response (401-ERROR):
{
"message": "not authorized"
}
Get Active FastChecks
---------------------
Get all active (unused) FastChecks created by the current user.
Protocol: https
Root Path: api.Fastcheck.store
Type: GET
Path: /fastcheck/active
HEADER: Authorization - {"sessionID": "1AF3781BF6B94604B771AEA1D44FA63A"}
Request Parameters:
{
}
Response (200-OK):
{
"checks": [
{
"fastcheck": "1234-5678-0001",
"amount": 15000,
"currency": "RUB",
"code": "5864",
"createdAt": "2026-01-19T09:08:18Z",
"expiration": "2026-01-26T09:08:18Z",
"status": "active"
},
{
"fastcheck": "1234-5678-0002",
"amount": 25000,
"currency": "RUB",
"code": "1234",
"createdAt": "2026-01-19T10:15:30Z",
"expiration": "2026-01-26T10:15:30Z",
"status": "active"
}
]
}
Response (401-ERROR):
{
"message": "not authorized"
}
Get FastCheck History
---------------------
Get all used/expired FastChecks (both created and accepted by user).
Protocol: https
Root Path: api.Fastcheck.store
Type: GET
Path: /fastcheck/history
HEADER: Authorization - {"sessionID": "1AF3781BF6B94604B771AEA1D44FA63A"}
Request Parameters:
{
}
Response (200-OK):
{
"checks": [
{
"fastcheck": "1234-5678-0003",
"amount": 5000,
"currency": "RUB",
"type": "created",
"createdAt": "2026-01-15T09:08:18Z",
"usedAt": "2026-01-15T10:20:00Z",
"status": "used"
},
{
"fastcheck": "9876-5432-0100",
"amount": 10000,
"currency": "RUB",
"type": "accepted",
"acceptedAt": "2026-01-14T14:30:00Z",
"status": "used"
}
]
}
Response (401-ERROR):
{
"message": "not authorized"
}
Bank Top-Up Integration (To be provided by bank)
-------------------------------------------------
WHAT WE NEED FROM BANK:
1. Payment page URL or API endpoint to initialize payment
2. Required parameters:
- Amount
- Currency
- Return URL (redirect after payment)
- Callback URL (for payment confirmation webhook)
3. Payment confirmation webhook format
4. Transaction ID for tracking
EXPECTED FLOW:
1. User clicks "Top Up Balance"
2. Frontend redirects to bank payment page (or opens popup)
3. User completes card payment on bank side
4. Bank sends webhook to backend with payment confirmation
5. Backend updates user balance
6. Bank redirects user back to our app
7. Frontend refreshes balance

1
public/visa.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 324.68"><path fill="#1434cb" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

After

Width:  |  Height:  |  Size: 815 B

1
public/wechat-pay.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

21
src/app/api.ts Normal file
View File

@@ -0,0 +1,21 @@
import { isDevMode } from '@angular/core';
/**
* Endpoint constants for the Fastcheck backend (see public/api.txt).
* Centralised so they can be swapped in one place.
* In dev mode (ng serve) requests go through the Angular proxy (proxy.conf.json)
* to avoid CORS issues. In production the real URLs are used.
*/
export const FASTCHECK_API = isDevMode()
? '/proxy/fastcheck'
: 'https://api.fastcheck.store';
// Legacy QR endpoint kept for the SBP amount → payload redirect flow.
export const QR_API = isDevMode()
? '/proxy/legacy-qr/qr'
: 'https://qr.vitanova.network:567/qr';
// New QR Vitanova API (dynamic QR, settings, polling).
export const QR_VITANOVA_API = isDevMode()
? '/proxy/qr-vitanova/api'
: 'https://qr.vitanova.network/api';

View File

@@ -1,6 +1,6 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
@@ -8,6 +8,6 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withFetch())
provideHttpClient()
]
};

View File

@@ -1 +1,5 @@
<router-outlet></router-outlet>
<app-site-header />
<main class="app-main">
<router-outlet />
</main>
<app-site-footer />

View File

@@ -1,38 +1,36 @@
import { Routes } from '@angular/router';
import { authGuard, loginGuard } from './guards/auth.guard';
import { LoginComponent } from './components/login/login.component';
import { DashboardComponent } from './components/dashboard/dashboard.component';
import { ActiveChecksComponent } from './components/active-checks/active-checks.component';
import { HistoryComponent } from './components/history/history.component';
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: '/login',
pathMatch: 'full'
loadComponent: () => {
// Branch: ?id=<orderId> means legacy SBP merchant flow.
const hasLegacyId = typeof window !== 'undefined'
&& new URLSearchParams(window.location.search).has('id');
return hasLegacyId
? import('./pages/legacy-pay-page/legacy-pay-page').then((m) => m.LegacyPayPage)
: import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage);
}
},
{
path: 'login',
component: LoginComponent,
canActivate: [loginGuard]
path: 'new',
loadComponent: () =>
import('./pages/create-page/create-page').then((m) => m.CreatePage)
},
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [authGuard]
path: 'about',
loadComponent: () =>
import('./pages/about-page/about-page').then((m) => m.AboutPage)
},
{
path: 'active-checks',
component: ActiveChecksComponent,
canActivate: [authGuard]
path: 'contacts',
loadComponent: () =>
import('./pages/contacts-page/contacts-page').then((m) => m.ContactsPage)
},
{
path: 'history',
component: HistoryComponent,
canActivate: [authGuard]
path: 'partners',
loadComponent: () =>
import('./pages/partners-page/partners-page').then((m) => m.PartnersPage)
},
{
path: '**',
redirectTo: '/login'
}
{ path: '**', redirectTo: '' }
];

View File

@@ -1,18 +1,12 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
:host {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
html, body {
height: 100%;
width: 100%;
overflow-x: hidden;
.app-main {
flex: 1;
display: flex;
flex-direction: column;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -1,10 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])]
}).compileComponents();
});
@@ -13,11 +15,4 @@ describe('App', () => {
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, FastCheck');
});
});

View File

@@ -1,12 +1,13 @@
import { Component, signal } from '@angular/core';
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SiteHeader } from './site-header/site-header';
import { SiteFooter } from './site-footer/site-footer';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, SiteHeader, SiteFooter],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
protected readonly title = signal('FastCheck');
}
export class App {}

View File

@@ -1,95 +0,0 @@
<div class="page-container">
<header class="header">
<div class="logo">FastCheck</div>
<nav class="nav">
<a routerLink="/dashboard" class="nav-link">Dashboard</a>
<a routerLink="/active-checks" class="nav-link active">Active Checks</a>
<a routerLink="/history" class="nav-link">History</a>
</nav>
</header>
<div class="content">
<div class="page-header">
<h1>Active FastChecks</h1>
<p>View all your unused FastChecks</p>
</div>
@if (isLoading()) {
<div class="loading">
<div class="spinner"></div>
<p>Loading active checks...</p>
</div>
}
@if (error()) {
<div class="error-card">
<p>{{ error() }}</p>
<button (click)="loadActiveChecks()" class="btn-retry">Retry</button>
</div>
}
@if (!isLoading() && !error()) {
@if (checks().length === 0) {
<div class="empty-state">
<div class="empty-icon">📭</div>
<h3>No Active Checks</h3>
<p>You don't have any active FastChecks at the moment.</p>
<a routerLink="/dashboard" class="btn-primary">Create FastCheck</a>
</div>
} @else {
<div class="checks-grid">
@for (check of checks(); track check.fastcheck) {
<div class="check-card">
<div class="check-header">
<span class="check-badge">Active</span>
<span class="check-amount">{{ formatAmount(check.amount) }} ₽</span>
</div>
<div class="check-details">
<div class="detail-item">
<span class="label">FastCheck Number</span>
<div class="value-copy">
<span class="value">{{ check.fastcheck }}</span>
<button
(click)="copyToClipboard(check.fastcheck, 'Number')"
class="btn-copy"
title="Copy">
📋
</button>
</div>
</div>
<div class="detail-item">
<span class="label">Code</span>
<div class="value-copy">
<span class="value code">{{ check.code }}</span>
<button
(click)="copyToClipboard(check.code!, 'Code')"
class="btn-copy"
title="Copy">
📋
</button>
</div>
</div>
<div class="detail-item">
<span class="label">Created</span>
<span class="value">{{ check.createdAt | date:'short' }}</span>
</div>
<div class="detail-item">
<span class="label">Expires</span>
<span class="value">{{ check.expiration | date:'short' }}</span>
</div>
</div>
<div class="check-warning">
⚠️ Keep this information secure. Anyone with these credentials can claim the money.
</div>
</div>
}
</div>
}
}
</div>
</div>

View File

@@ -1,280 +0,0 @@
.page-container {
min-height: 100vh;
background: #f5f7fa;
}
.header {
background: white;
padding: 20px 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 24px;
font-weight: 700;
color: #667eea;
}
.nav {
display: flex;
gap: 30px;
}
.nav-link {
text-decoration: none;
color: #666;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s;
&:hover {
color: #667eea;
background: #f0f0f0;
}
&.active {
color: #667eea;
background: #e8ebff;
}
}
.content {
padding: 40px;
max-width: 1200px;
margin: 0 auto;
@media (max-width: 768px) {
padding: 20px;
}
}
.page-header {
margin-bottom: 40px;
h1 {
font-size: 32px;
color: #333;
margin-bottom: 10px;
}
p {
color: #666;
font-size: 16px;
}
}
.loading {
text-align: center;
padding: 60px 20px;
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
p {
color: #666;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-card {
background: white;
border-radius: 15px;
padding: 40px;
text-align: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
p {
color: #c33;
margin-bottom: 20px;
}
}
.btn-retry {
background: #667eea;
color: white;
border: none;
padding: 12px 30px;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
&:hover {
background: #764ba2;
}
}
.empty-state {
background: white;
border-radius: 20px;
padding: 60px 40px;
text-align: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
.empty-icon {
font-size: 64px;
margin-bottom: 20px;
}
h3 {
font-size: 24px;
color: #333;
margin-bottom: 10px;
}
p {
color: #666;
margin-bottom: 30px;
}
}
.btn-primary {
display: inline-block;
background: #667eea;
color: white;
text-decoration: none;
padding: 14px 30px;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
&:hover {
background: #764ba2;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
}
.checks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 30px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 20px;
}
}
.check-card {
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
border: 2px solid #e8ebff;
transition: all 0.3s;
@media (max-width: 768px) {
padding: 20px;
}
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.15);
}
}
.check-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.check-badge {
background: #52c41a;
color: white;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.check-amount {
font-size: 24px;
font-weight: 700;
color: #667eea;
}
.check-details {
margin-bottom: 15px;
}
.detail-item {
margin-bottom: 15px;
.label {
display: block;
font-size: 12px;
color: #999;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 5px;
}
.value {
font-size: 16px;
color: #333;
font-weight: 500;
&.code {
font-size: 20px;
color: #667eea;
font-weight: 700;
}
}
}
.value-copy {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.btn-copy {
background: #f0f0f0;
border: none;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
&:hover {
background: #e0e0e0;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
.check-warning {
background: #fffbe6;
border-left: 4px solid #faad14;
padding: 12px;
border-radius: 6px;
font-size: 12px;
color: #666;
line-height: 1.5;
}

View File

@@ -1,51 +0,0 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FastCheckService } from '../../services/fastcheck.service';
import { FastCheck } from '../../models/fastcheck.model';
@Component({
selector: 'app-active-checks',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './active-checks.component.html',
styleUrls: ['./active-checks.component.scss']
})
export class ActiveChecksComponent implements OnInit {
checks = signal<FastCheck[]>([]);
isLoading = signal<boolean>(true);
error = signal<string>('');
constructor(private fastCheckService: FastCheckService) {}
ngOnInit(): void {
this.loadActiveChecks();
}
loadActiveChecks(): void {
this.isLoading.set(true);
this.error.set('');
this.fastCheckService.getActiveFastChecks().subscribe({
next: (response) => {
this.checks.set(response.checks);
this.isLoading.set(false);
},
error: (err) => {
this.error.set('Failed to load active checks');
this.isLoading.set(false);
console.error('Load error:', err);
}
});
}
formatAmount(amount: number): string {
return new Intl.NumberFormat('ru-RU').format(amount);
}
copyToClipboard(text: string, type: string): void {
navigator.clipboard.writeText(text).then(() => {
alert(`${type} copied to clipboard!`);
});
}
}

View File

@@ -1,142 +0,0 @@
<div class="dashboard-container">
<!-- Header -->
<header class="header">
<div class="logo">FastCheck</div>
<nav class="nav">
<a routerLink="/dashboard" class="nav-link active">Dashboard</a>
<a routerLink="/active-checks" class="nav-link">Active Checks</a>
<a routerLink="/history" class="nav-link">History</a>
<button (click)="logout()" class="btn-logout">Logout</button>
</nav>
</header>
<div class="content">
<!-- Balance Card -->
<div class="balance-card">
@if (isLoadingBalance()) {
<div class="loading-small">
<div class="spinner-small"></div>
</div>
} @else if (balance()) {
<div class="balance-info">
<span class="balance-label">Current Balance</span>
<h2 class="balance-amount">{{ formatAmount(balance()!.balance) }} ₽</h2>
<button (click)="topUpBalance()" class="btn-topup">+ Top Up Balance</button>
</div>
}
</div>
<div class="actions-grid">
<!-- Create FastCheck -->
<div class="card">
<h3 class="card-title">Create New FastCheck</h3>
<div class="form-group">
<label>Amount (RUB)</label>
<input
type="number"
[(ngModel)]="createAmount"
placeholder="Enter amount"
class="input"
[disabled]="isCreating()">
</div>
@if (createError()) {
<div class="error-message">{{ createError() }}</div>
}
<button
(click)="createFastCheck()"
[disabled]="isCreating() || !createAmount()"
class="btn-primary">
@if (isCreating()) {
<span>Creating...</span>
} @else {
<span>Create FastCheck</span>
}
</button>
</div>
<!-- Accept FastCheck -->
<div class="card">
<h3 class="card-title">Accept FastCheck</h3>
<div class="form-group">
<label>FastCheck Number</label>
<input
type="text"
[value]="acceptNumber()"
(input)="onFastCheckNumberInput($event)"
placeholder="xxxx-xxxx-xxxx"
maxlength="14"
class="input"
[disabled]="isAccepting()">
</div>
<div class="form-group">
<label>Code</label>
<input
type="text"
[(ngModel)]="acceptCode"
placeholder="Enter 4-digit code"
maxlength="4"
class="input"
[disabled]="isAccepting()">
</div>
@if (acceptError()) {
<div class="error-message">{{ acceptError() }}</div>
}
@if (acceptSuccess()) {
<div class="success-message">FastCheck accepted successfully!</div>
}
<button
(click)="acceptFastCheck()"
[disabled]="isAccepting() || !acceptNumber() || !acceptCode()"
class="btn-primary">
@if (isAccepting()) {
<span>Accepting...</span>
} @else {
<span>Accept FastCheck</span>
}
</button>
</div>
</div>
</div>
</div>
<!-- Created Check Modal -->
@if (createdCheck()) {
<div class="modal-overlay" (click)="closeCreatedCheckModal()">
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>FastCheck Created!</h3>
<button class="close-btn" (click)="closeCreatedCheckModal()">×</button>
</div>
<div class="modal-body">
<div class="check-details">
<div class="detail-row">
<span class="detail-label">FastCheck Number:</span>
<span class="detail-value">{{ createdCheck()!.fastcheck }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Code:</span>
<span class="detail-value code">{{ createdCheck()!.code }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Expires:</span>
<span class="detail-value">{{ createdCheck()!.expiration | date:'short' }}</span>
</div>
</div>
<div class="modal-note">
<p>⚠️ Save this information securely. Anyone with the number and code can claim this FastCheck.</p>
</div>
</div>
<div class="modal-footer">
<button (click)="closeCreatedCheckModal()" class="btn-primary">Close</button>
</div>
</div>
</div>
}

View File

@@ -1,363 +0,0 @@
.dashboard-container {
min-height: 100vh;
background: #f5f7fa;
}
.header {
background: white;
padding: 20px 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
@media (max-width: 768px) {
padding: 15px 20px;
flex-direction: column;
gap: 15px;
}
}
.logo {
font-size: 24px;
font-weight: 700;
color: #667eea;
}
.nav {
display: flex;
gap: 30px;
align-items: center;
@media (max-width: 768px) {
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
}
.nav-link {
text-decoration: none;
color: #666;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s;
&:hover {
color: #667eea;
background: #f0f0f0;
}
&.active {
color: #667eea;
background: #e8ebff;
}
}
.btn-logout {
background: #ff4d4f;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
&:hover {
background: #ff7875;
transform: translateY(-2px);
}
}
.content {
padding: 40px;
max-width: 1200px;
margin: 0 auto;
@media (max-width: 768px) {
padding: 20px;
}
}
.balance-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px;
padding: 40px;
color: white;
margin-bottom: 40px;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
@media (max-width: 768px) {
padding: 30px 20px;
margin-bottom: 20px;
}
}
.balance-info {
text-align: center;
}
.balance-label {
font-size: 16px;
opacity: 0.9;
display: block;
margin-bottom: 10px;
}
.balance-amount {
font-size: 48px;
font-weight: 700;
margin: 10px 0 30px;
@media (max-width: 768px) {
font-size: 36px;
margin: 10px 0 20px;
}
}
.btn-topup {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid white;
padding: 12px 30px;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s;
&:hover {
background: white;
color: #667eea;
}
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 20px;
}
}
.card {
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.card-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 25px;
color: #333;
}
.form-group {
margin-bottom: 20px;
label {
display: block;
font-size: 14px;
font-weight: 500;
color: #666;
margin-bottom: 8px;
}
}
.input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s;
box-sizing: border-box;
&:focus {
outline: none;
border-color: #667eea;
}
&:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
}
.btn-primary {
width: 100%;
background: #667eea;
color: white;
border: none;
padding: 14px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s;
margin-top: 10px;
&:hover:not(:disabled) {
background: #764ba2;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
&:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
}
.error-message {
background: #fee;
color: #c33;
padding: 12px;
border-radius: 8px;
font-size: 14px;
margin: 15px 0;
}
.success-message {
background: #efe;
color: #3c3;
padding: 12px;
border-radius: 8px;
font-size: 14px;
margin: 15px 0;
}
.loading-small {
text-align: center;
padding: 20px;
.spinner-small {
width: 30px;
height: 30px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Modal
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 20px;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
padding: 30px 30px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
color: #333;
}
}
.close-btn {
background: none;
border: none;
font-size: 32px;
cursor: pointer;
color: #999;
line-height: 1;
padding: 0;
width: 32px;
height: 32px;
&:hover {
color: #333;
}
}
.modal-body {
padding: 30px;
}
.check-details {
background: #f9f9f9;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #e0e0e0;
&:last-child {
border-bottom: none;
}
}
.detail-label {
color: #666;
font-size: 14px;
}
.detail-value {
font-weight: 600;
color: #333;
&.code {
font-size: 20px;
color: #667eea;
}
}
.modal-note {
background: #fffbe6;
border-left: 4px solid #faad14;
padding: 15px;
border-radius: 8px;
p {
margin: 0;
font-size: 14px;
color: #666;
line-height: 1.6;
}
}
.modal-footer {
padding: 20px 30px 30px;
text-align: center;
}

View File

@@ -1,169 +0,0 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { FastCheckService } from '../../services/fastcheck.service';
import { AuthService } from '../../services/auth.service';
import { Balance, CreateFastCheckResponse } from '../../models/fastcheck.model';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
balance = signal<Balance | null>(null);
isLoadingBalance = signal<boolean>(true);
// Create FastCheck
createAmount = signal<number>(0);
isCreating = signal<boolean>(false);
createdCheck = signal<CreateFastCheckResponse | null>(null);
createError = signal<string>('');
// Accept FastCheck
acceptNumber = signal<string>('');
acceptCode = signal<string>('');
isAccepting = signal<boolean>(false);
acceptSuccess = signal<boolean>(false);
acceptError = signal<string>('');
constructor(
private fastCheckService: FastCheckService,
private authService: AuthService,
private router: Router
) {}
ngOnInit(): void {
this.loadBalance();
}
loadBalance(): void {
this.isLoadingBalance.set(true);
this.fastCheckService.getBalance().subscribe({
next: (balance) => {
this.balance.set(balance);
this.isLoadingBalance.set(false);
},
error: (err) => {
console.error('Failed to load balance:', err);
this.isLoadingBalance.set(false);
}
});
}
createFastCheck(): void {
const amount = this.createAmount();
if (!amount || amount <= 0) {
this.createError.set('Please enter a valid amount');
return;
}
const currentBalance = this.balance();
if (currentBalance && amount > currentBalance.balance) {
this.createError.set('Insufficient balance');
return;
}
this.isCreating.set(true);
this.createError.set('');
this.createdCheck.set(null);
this.fastCheckService.createFastCheck({
amount: amount,
currency: 'RUB'
}).subscribe({
next: (response) => {
this.createdCheck.set(response);
this.isCreating.set(false);
this.createAmount.set(0);
this.loadBalance(); // Refresh balance
},
error: (err) => {
this.createError.set('Failed to create FastCheck. Please try again.');
this.isCreating.set(false);
console.error('Create error:', err);
}
});
}
acceptFastCheck(): void {
const number = this.acceptNumber().trim();
const code = this.acceptCode().trim();
if (!number || !code) {
this.acceptError.set('Please enter both FastCheck number and code');
return;
}
this.isAccepting.set(true);
this.acceptError.set('');
this.acceptSuccess.set(false);
this.fastCheckService.acceptFastCheck({
fastcheck: number,
code: code
}).subscribe({
next: () => {
this.acceptSuccess.set(true);
this.isAccepting.set(false);
this.acceptNumber.set('');
this.acceptCode.set('');
this.loadBalance(); // Refresh balance
setTimeout(() => {
this.acceptSuccess.set(false);
}, 3000);
},
error: (err) => {
this.acceptError.set('Failed to accept FastCheck. Check your credentials.');
this.isAccepting.set(false);
console.error('Accept error:', err);
}
});
}
formatAmount(amount: number): string {
return new Intl.NumberFormat('ru-RU').format(amount);
}
formatFastCheckNumber(input: string): string {
const cleaned = input.replace(/\D/g, '');
const formatted = cleaned.match(/.{1,4}/g)?.join('-') || '';
return formatted.slice(0, 14); // xxxx-xxxx-xxxx
}
onFastCheckNumberInput(event: Event): void {
const input = event.target as HTMLInputElement;
const formatted = this.formatFastCheckNumber(input.value);
this.acceptNumber.set(formatted);
}
closeCreatedCheckModal(): void {
this.createdCheck.set(null);
}
logout(): void {
const sessionId = this.authService.getSessionId();
if (sessionId) {
this.authService.deleteWebSession(sessionId).subscribe({
next: () => {
this.router.navigate(['/login']);
},
error: (err) => {
console.error('Logout error:', err);
this.authService.clearAuthentication();
this.router.navigate(['/login']);
}
});
}
}
topUpBalance(): void {
// TODO: Implement bank integration
alert('Bank integration will be implemented. You will be redirected to bank payment page.');
}
}

View File

@@ -1,86 +0,0 @@
<div class="page-container">
<header class="header">
<div class="logo">FastCheck</div>
<nav class="nav">
<a routerLink="/dashboard" class="nav-link">Dashboard</a>
<a routerLink="/active-checks" class="nav-link">Active Checks</a>
<a routerLink="/history" class="nav-link active">History</a>
</nav>
</header>
<div class="content">
<div class="page-header">
<h1>Transaction History</h1>
<p>View all used and expired FastChecks</p>
</div>
@if (isLoading()) {
<div class="loading">
<div class="spinner"></div>
<p>Loading history...</p>
</div>
}
@if (error()) {
<div class="error-card">
<p>{{ error() }}</p>
<button (click)="loadHistory()" class="btn-retry">Retry</button>
</div>
}
@if (!isLoading() && !error()) {
@if (checks().length === 0) {
<div class="empty-state">
<div class="empty-icon">📜</div>
<h3>No History</h3>
<p>Your transaction history will appear here.</p>
<a routerLink="/dashboard" class="btn-primary">Go to Dashboard</a>
</div>
} @else {
<div class="history-list">
@for (check of checks(); track check.fastcheck) {
<div class="history-item">
<div class="item-header">
<div class="item-info">
<span [class]="'type-badge ' + getTypeClass(check.type)">
{{ getTypeLabel(check.type) }}
</span>
<span class="item-number">{{ check.fastcheck }}</span>
</div>
<span class="item-amount">{{ formatAmount(check.amount) }} ₽</span>
</div>
<div class="item-details">
@if (check.createdAt) {
<div class="detail">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ check.createdAt | date:'short' }}</span>
</div>
}
@if (check.usedAt) {
<div class="detail">
<span class="detail-label">Used:</span>
<span class="detail-value">{{ check.usedAt | date:'short' }}</span>
</div>
}
@if (check.acceptedAt) {
<div class="detail">
<span class="detail-label">Accepted:</span>
<span class="detail-value">{{ check.acceptedAt | date:'short' }}</span>
</div>
}
<div class="detail">
<span class="detail-label">Status:</span>
<span class="status-badge">{{ check.status }}</span>
</div>
</div>
</div>
}
</div>
}
}
</div>
</div>

View File

@@ -1,270 +0,0 @@
.page-container {
min-height: 100vh;
background: #f5f7fa;
}
.header {
background: white;
padding: 20px 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 24px;
font-weight: 700;
color: #667eea;
}
.nav {
display: flex;
gap: 30px;
}
.nav-link {
text-decoration: none;
color: #666;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s;
&:hover {
color: #667eea;
background: #f0f0f0;
}
&.active {
color: #667eea;
background: #e8ebff;
}
}
.content {
padding: 40px;
max-width: 1000px;
margin: 0 auto;
@media (max-width: 768px) {
padding: 20px;
}
}
.page-header {
margin-bottom: 40px;
h1 {
font-size: 32px;
color: #333;
margin-bottom: 10px;
}
p {
color: #666;
font-size: 16px;
}
}
.loading {
text-align: center;
padding: 60px 20px;
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
p {
color: #666;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-card {
background: white;
border-radius: 15px;
padding: 40px;
text-align: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
p {
color: #c33;
margin-bottom: 20px;
}
}
.btn-retry {
background: #667eea;
color: white;
border: none;
padding: 12px 30px;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
&:hover {
background: #764ba2;
}
}
.empty-state {
background: white;
border-radius: 20px;
padding: 60px 40px;
text-align: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
.empty-icon {
font-size: 64px;
margin-bottom: 20px;
}
h3 {
font-size: 24px;
color: #333;
margin-bottom: 10px;
}
p {
color: #666;
margin-bottom: 30px;
}
}
.btn-primary {
display: inline-block;
background: #667eea;
color: white;
text-decoration: none;
padding: 14px 30px;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
&:hover {
background: #764ba2;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
}
.history-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.history-item {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
@media (max-width: 768px) {
padding: 20px;
}
&:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.item-info {
display: flex;
align-items: center;
gap: 15px;
}
.type-badge {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
&.type-created {
background: #e8ebff;
color: #667eea;
}
&.type-accepted {
background: #e6f7ff;
color: #1890ff;
}
}
.item-number {
font-family: monospace;
font-size: 16px;
color: #666;
font-weight: 500;
}
.item-amount {
font-size: 24px;
font-weight: 700;
color: #333;
}
.item-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 10px;
}
}
.detail {
display: flex;
flex-direction: column;
gap: 5px;
}
.detail-label {
font-size: 12px;
color: #999;
text-transform: uppercase;
font-weight: 600;
}
.detail-value {
font-size: 14px;
color: #333;
font-weight: 500;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
background: #f5f5f5;
color: #999;
}

View File

@@ -1,53 +0,0 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FastCheckService } from '../../services/fastcheck.service';
import { FastCheck } from '../../models/fastcheck.model';
@Component({
selector: 'app-history',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './history.component.html',
styleUrls: ['./history.component.scss']
})
export class HistoryComponent implements OnInit {
checks = signal<FastCheck[]>([]);
isLoading = signal<boolean>(true);
error = signal<string>('');
constructor(private fastCheckService: FastCheckService) {}
ngOnInit(): void {
this.loadHistory();
}
loadHistory(): void {
this.isLoading.set(true);
this.error.set('');
this.fastCheckService.getFastCheckHistory().subscribe({
next: (response) => {
this.checks.set(response.checks);
this.isLoading.set(false);
},
error: (err) => {
this.error.set('Failed to load history');
this.isLoading.set(false);
console.error('Load error:', err);
}
});
}
formatAmount(amount: number): string {
return new Intl.NumberFormat('ru-RU').format(amount);
}
getTypeLabel(type?: string): string {
return type === 'created' ? 'Created' : 'Accepted';
}
getTypeClass(type?: string): string {
return type === 'created' ? 'type-created' : 'type-accepted';
}
}

View File

@@ -1,39 +0,0 @@
<div class="login-container">
<div class="login-card">
<h1 class="title">FastCheck</h1>
<p class="subtitle">Scan QR code to login</p>
@if (isLoading()) {
<div class="loading">
<div class="spinner"></div>
<p>Generating QR code...</p>
</div>
}
@if (error()) {
<div class="error-message">
<p>{{ error() }}</p>
<button (click)="refreshQR()" class="btn-secondary">Try Again</button>
</div>
}
@if (qrData() && !isLoading()) {
<div class="qr-section">
<div class="qr-wrapper">
<qrcode
[qrdata]="qrData()"
[width]="250"
[errorCorrectionLevel]="'M'">
</qrcode>
</div>
<div class="status-indicator">
<div class="pulse"></div>
<span>Waiting for scan...</span>
</div>
<button (click)="refreshQR()" class="btn-link">Refresh QR Code</button>
</div>
}
</div>
</div>

View File

@@ -1,177 +0,0 @@
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
overflow: hidden;
}
.login-card {
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 100%;
text-align: center;
@media (max-width: 768px) {
padding: 30px 20px;
border-radius: 15px;
max-width: 100%;
}
}
.title {
font-size: 32px;
font-weight: 700;
color: #333;
margin-bottom: 10px;
@media (max-width: 768px) {
font-size: 28px;
}
}
.subtitle {
font-size: 16px;
color: #666;
margin-bottom: 30px;
@media (max-width: 768px) {
font-size: 14px;
margin-bottom: 20px;
}
}
.loading {
padding: 40px 0;
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
p {
color: #666;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
padding: 20px;
background: #fee;
border-radius: 10px;
color: #c33;
margin: 20px 0;
p {
margin-bottom: 15px;
}
}
.qr-section {
margin: 30px 0;
}
.qr-wrapper {
display: inline-block;
padding: 20px;
background: white;
border-radius: 15px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
@media (max-width: 768px) {
padding: 15px;
}
::ng-deep canvas {
max-width: 100%;
height: auto !important;
}
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #667eea;
font-weight: 500;
margin: 20px 0;
.pulse {
width: 10px;
height: 10px;
background: #667eea;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
.btn-link {
background: none;
border: none;
color: #667eea;
cursor: pointer;
text-decoration: underline;
font-size: 14px;
padding: 10px;
&:hover {
color: #764ba2;
}
}
.btn-secondary {
background: #667eea;
color: white;
border: none;
padding: 12px 30px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: all 0.3s;
&:hover {
background: #764ba2;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
}
.info {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
p {
font-size: 14px;
color: #999;
line-height: 1.6;
}
}

View File

@@ -1,73 +0,0 @@
import { Component, OnInit, OnDestroy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { Subscription } from 'rxjs';
import { QRCodeComponent } from 'angularx-qrcode';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, QRCodeComponent],
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit, OnDestroy {
qrData = signal<string>('');
sessionId = signal<string>('');
isLoading = signal<boolean>(true);
error = signal<string>('');
private pollSubscription?: Subscription;
constructor(
private authService: AuthService,
private router: Router
) {}
ngOnInit(): void {
this.createSession();
}
ngOnDestroy(): void {
this.pollSubscription?.unsubscribe();
}
createSession(): void {
this.isLoading.set(true);
this.error.set('');
this.authService.createWebSession().subscribe({
next: (session) => {
this.sessionId.set(session.sessionId);
this.qrData.set(`fastcheck://login?session=${session.sessionId}`);
this.isLoading.set(false);
this.startPolling(session.sessionId);
},
error: (err) => {
this.error.set('Failed to create session. Please try again.');
this.isLoading.set(false);
console.error('Session creation error:', err);
}
});
}
private startPolling(sessionId: string): void {
this.pollSubscription = this.authService.startPolling(sessionId).subscribe({
next: (session) => {
if (session.Status) {
this.router.navigate(['/dashboard']);
}
},
error: (err) => {
this.error.set('Authentication failed. Please try again.');
console.error('Polling error:', err);
}
});
}
refreshQR(): void {
this.pollSubscription?.unsubscribe();
this.createSession();
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable, signal } from '@angular/core';
export interface FastcheckData {
fastcheck: string;
amount: number | null;
code: string;
expiration?: string;
}
/**
* Shared state between the home (Fastcheck) page and the create-new page.
* When a new fastcheck is created via POST /fastcheck, the create page stores
* the returned data here and the home page reads it to autofill its fields.
*/
@Injectable({ providedIn: 'root' })
export class FastcheckService {
readonly created = signal<FastcheckData | null>(null);
setCreated(data: FastcheckData): void {
this.created.set(data);
}
consume(): FastcheckData | null {
const value = this.created();
this.created.set(null);
return value;
}
}

View File

@@ -1,27 +0,0 @@
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated().isAuthenticated) {
return true;
}
router.navigate(['/login']);
return false;
};
export const loginGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isAuthenticated().isAuthenticated) {
return true;
}
router.navigate(['/dashboard']);
return false;
};

View File

@@ -1,9 +0,0 @@
export interface ApiResponse<T = any> {
data?: T;
message?: string;
error?: string;
}
export interface PingResponse {
message: string;
}

View File

@@ -1,44 +0,0 @@
export interface FastCheck {
fastcheck: string;
amount: number;
currency: string;
code?: string;
expiration: string;
status: 'active' | 'used' | 'expired';
createdAt?: string;
usedAt?: string;
acceptedAt?: string;
type?: 'created' | 'accepted';
}
export interface CreateFastCheckRequest {
amount: number;
currency: string;
}
export interface CreateFastCheckResponse {
fastcheck: string;
expiration: string;
code: string;
Status: boolean;
}
export interface AcceptFastCheckRequest {
fastcheck: string;
code: string;
}
export interface CheckStatusResponse {
fastcheck: string;
expiration: string;
Status: boolean;
}
export interface Balance {
balance: number;
currency: string;
}
export interface FastCheckListResponse {
checks: FastCheck[];
}

View File

@@ -1,13 +0,0 @@
export interface WebSession {
sessionId: string;
userId: string;
expires: string;
userSessionId: string;
Status: boolean;
}
export interface AuthState {
isAuthenticated: boolean;
sessionId: string | null;
userSessionId: string | null;
}

View File

@@ -0,0 +1,40 @@
<div class="info-page">
<div class="info-page__hero">
<h1 class="info-page__title">{{ 'about.title' | translate }}</h1>
<p class="info-page__lead">{{ 'about.lead' | translate }}</p>
</div>
<div class="info-page__body">
<section class="info-section">
<h2 class="info-section__title">{{ 'about.what_title' | translate }}</h2>
<p class="info-section__text">{{ 'about.what_text' | translate }}</p>
</section>
<section class="info-section">
<h2 class="info-section__title">{{ 'about.how_title' | translate }}</h2>
<ol class="info-section__steps">
<li>{{ 'about.step1' | translate }}</li>
<li>{{ 'about.step2' | translate }}</li>
<li>{{ 'about.step3' | translate }}</li>
<li>{{ 'about.step4' | translate }}</li>
</ol>
</section>
<section class="info-section">
<h2 class="info-section__title">{{ 'about.why_title' | translate }}</h2>
<ul class="info-section__list">
<li>{{ 'about.why1' | translate }}</li>
<li>{{ 'about.why2' | translate }}</li>
<li>{{ 'about.why3' | translate }}</li>
<li>{{ 'about.why4' | translate }}</li>
</ul>
</section>
<section class="info-section">
<h2 class="info-section__title">{{ 'about.company_title' | translate }}</h2>
<p class="info-section__text">{{ 'about.company_text' | translate }}</p>
</section>
</div>
</div>

View File

@@ -0,0 +1,75 @@
:host {
display: block;
background: #f8fafc;
min-height: 100vh;
}
// Shared info page layout — used by AboutPage and ContactsPage
.info-page {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px 72px;
@media (max-width: 600px) {
padding: 32px 16px 56px;
}
&__hero {
margin-bottom: 48px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 32px;
}
&__title {
font-size: 32px;
font-weight: 800;
color: #0f172a;
margin: 0 0 12px;
letter-spacing: -0.5px;
@media (max-width: 600px) { font-size: 26px; }
}
&__lead {
font-size: 17px;
line-height: 1.7;
color: #475569;
margin: 0;
}
&__body {
display: flex;
flex-direction: column;
gap: 48px;
}
}
.info-section {
&__title {
font-size: 20px;
font-weight: 700;
color: #1e293b;
margin: 0 0 14px;
}
&__text {
font-size: 15.5px;
line-height: 1.75;
color: #475569;
margin: 0;
}
&__steps, &__list {
padding-left: 22px;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
li {
font-size: 15.5px;
line-height: 1.65;
color: #475569;
}
}
}

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { TranslatePipe } from '../../translate/translate.pipe';
@Component({
selector: 'app-about-page',
imports: [TranslatePipe],
templateUrl: './about-page.html',
styleUrl: './about-page.scss'
})
export class AboutPage {}

View File

@@ -0,0 +1,66 @@
<div class="info-page">
<div class="info-page__hero">
<h1 class="info-page__title">{{ 'contacts.title' | translate }}</h1>
<p class="info-page__lead">{{ 'contacts.lead' | translate }}</p>
</div>
<div class="info-page__body">
<div class="contacts-grid">
<!-- Phone Russia -->
<a class="contact-card" href="tel:+79299037443">
<div class="contact-card__icon">🇷🇺</div>
<div class="contact-card__body">
<span class="contact-card__label">{{ 'contacts.ru_label' | translate }}</span>
<span class="contact-card__value">+7 (929) 903-74-43</span>
</div>
</a>
<!-- Phone Armenia -->
<a class="contact-card" href="tel:+37498632421">
<div class="contact-card__icon">🇦🇲</div>
<div class="contact-card__body">
<span class="contact-card__label">{{ 'contacts.am_label' | translate }}</span>
<span class="contact-card__value">+374 98 632421</span>
</div>
</a>
<!-- Email -->
<a class="contact-card" href="mailto:info@viaexport.store">
<div class="contact-card__icon">✉️</div>
<div class="contact-card__body">
<span class="contact-card__label">{{ 'contacts.email_label' | translate }}</span>
<span class="contact-card__value">info@viaexport.store</span>
</div>
</a>
<!-- Telegram -->
<a class="contact-card" href="https://t.me/DexarSupport_bot" target="_blank" rel="noopener">
<div class="contact-card__icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="#2b9fd0"><path d="M9.04 15.65l-.36 4.06c.51 0 .73-.22.99-.48l2.38-2.27 4.93 3.6c.9.5 1.55.24 1.79-.83l3.24-15.18h.01c.29-1.34-.48-1.86-1.36-1.54L1.13 9.66c-1.32.5-1.3 1.23-.22 1.56l4.92 1.53L17.27 5.6c.54-.34 1.03-.15.62.19"/></svg>
</div>
<div class="contact-card__body">
<span class="contact-card__label">{{ 'contacts.tg_label' | translate }}</span>
<span class="contact-card__value">@DexarSupport_bot</span>
</div>
</a>
</div>
<section class="info-section">
<h2 class="info-section__title">{{ 'contacts.hours_title' | translate }}</h2>
<div class="hours-table">
<div class="hours-row">
<span class="hours-row__label">{{ 'footer.support_label' | translate }}</span>
<span class="hours-row__value hours-row__value--green">24/7</span>
</div>
<div class="hours-row">
<span class="hours-row__label">{{ 'footer.questions_label' | translate }}</span>
<span class="hours-row__value">10:0019:00 МСК</span>
</div>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,146 @@
:host {
display: block;
background: #f8fafc;
min-height: 100vh;
}
.info-page {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px 72px;
@media (max-width: 600px) {
padding: 32px 16px 56px;
}
&__hero {
margin-bottom: 48px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 32px;
}
&__title {
font-size: 32px;
font-weight: 800;
color: #0f172a;
margin: 0 0 12px;
letter-spacing: -0.5px;
@media (max-width: 600px) { font-size: 26px; }
}
&__lead {
font-size: 17px;
line-height: 1.7;
color: #475569;
margin: 0;
}
&__body {
display: flex;
flex-direction: column;
gap: 48px;
}
}
.info-section {
&__title {
font-size: 20px;
font-weight: 700;
color: #1e293b;
margin: 0 0 14px;
}
}
.contacts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
}
.contact-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
border: 1px solid #e2e8f0;
background: #fff;
text-decoration: none;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
&:hover {
border-color: #93c5fd;
box-shadow: 0 4px 16px rgba(30, 64, 175, 0.08);
background: #f8fbff;
}
&__icon {
font-size: 28px;
line-height: 1;
flex-shrink: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
&__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
&__label {
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
}
&__value {
font-size: 14.5px;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.hours-table {
display: flex;
flex-direction: column;
gap: 12px;
}
.hours-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
&__label {
font-size: 14px;
color: #475569;
font-weight: 500;
}
&__value {
font-size: 14px;
font-weight: 700;
color: #0f172a;
&--green { color: #16a34a; }
}
}

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { TranslatePipe } from '../../translate/translate.pipe';
@Component({
selector: 'app-contacts-page',
imports: [TranslatePipe],
templateUrl: './contacts-page.html',
styleUrl: './contacts-page.scss'
})
export class ContactsPage {}

View File

@@ -0,0 +1,158 @@
<div class="page">
<div class="card">
<div class="card__header">
<a class="back" routerLink="/" [attr.aria-label]="'create.back_label' | translate">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6" />
</svg>
</a>
<h1 class="card__title">
{{ 'create.title' | translate }}&nbsp;
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
</h1>
<p class="card__subtitle">{{ 'create.subtitle' | translate }}</p>
</div>
<div class="card__body">
<!-- Payment methods -->
<div class="field">
<span class="field__label">{{ 'create.payment_label' | translate }}</span>
<div class="methods">
<button type="button" class="method" [class.method--active]="payment() === 'sbp'"
(click)="selectPayment('sbp', true)" aria-label="СБП">
<img class="method__logo"
src="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg"
alt="СБП" />
</button>
<button type="button" class="method method--disabled" disabled aria-label="WeChat Pay">
<img class="method__logo" src="/wechat-pay.svg" alt="WeChat Pay" />
</button>
<button type="button" class="method method--disabled" disabled aria-label="Alipay">
<img class="method__logo" src="/alipay.svg" alt="Alipay" />
</button>
<button type="button" class="method method--disabled" disabled aria-label="Visa">
<img class="method__logo" src="/visa.svg" alt="Visa" />
</button>
<button type="button" class="method method--disabled" disabled aria-label="MasterCard">
<img class="method__logo" src="/mastercard.svg" alt="Mastercard" />
</button>
</div>
</div>
<!-- Currencies -->
<div class="field">
<span class="field__label">{{ 'create.currency_label' | translate }}</span>
<div class="currencies">
<button type="button" class="chip" [class.chip--active]="currency() === 'RUB'"
(click)="selectCurrency('RUB', true)">
<span class="chip__sign"></span>
<span class="chip__code">RUB</span>
</button>
<button type="button" class="chip chip--disabled" disabled>
<span class="chip__sign">¥</span>
<span class="chip__code">CNY</span>
</button>
<button type="button" class="chip chip--disabled" disabled>
<span class="chip__sign">$</span>
<span class="chip__code">USD</span>
</button>
<button type="button" class="chip chip--disabled" disabled>
<span class="chip__sign"></span>
<span class="chip__code">EUR</span>
</button>
<button type="button" class="chip chip--disabled" disabled>
<span class="chip__sign">֏</span>
<span class="chip__code">AMD</span>
</button>
</div>
</div>
<div class="field">
<label class="field__label" for="amount">{{ 'create.amount_label' | translate }}</label>
<div class="input-wrap" [class.input-wrap--error]="error()">
<span class="input-wrap__prefix"></span>
<input
id="amount"
type="number"
class="input-wrap__input"
[ngModel]="amount()"
(ngModelChange)="onAmountChange($event)"
[min]="minAmount()"
[max]="maxAmount()"
step="1"
inputmode="numeric"
placeholder="0"
autofocus
/>
</div>
<span class="field__hint">{{ 'create.amount_hint' | translate }} {{ minAmount() }}{{ maxAmount().toLocaleString('ru') }} ₽</span>
@if (error()) {
<span class="field__error">{{ error() }}</span>
}
</div>
<div class="field">
<label class="field__label" for="note">{{ 'create.note_label' | translate }}</label>
<textarea
id="note"
class="note-input"
[ngModel]="note()"
(ngModelChange)="onNoteChange($event)"
[placeholder]="'create.note_placeholder' | translate"
rows="3"
maxlength="500"
></textarea>
</div>
<button class="pay-btn" type="button" (click)="createCheck()" [disabled]="loading() || qrImageUrl() !== null">
<span class="pay-btn__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
</span>
@if (loading()) {
{{ 'create.creating' | translate }}
} @else {
{{ 'create.create_btn' | translate }}&nbsp;
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
}
</button>
<!-- QR popup overlay -->
@if (qrImageUrl()) {
<div class="qr-overlay" (click)="closeQr()">
<div class="qr-modal" (click)="$event.stopPropagation()">
<button class="qr-modal__close" type="button" (click)="closeQr()" aria-label="Close">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<p class="qr-modal__label">{{ 'create.qr_label' | translate }}</p>
<img class="qr-modal__img" [src]="qrImageUrl()!" width="260" height="260" alt="QR" />
@if (qrStatus()) {
<span class="qr-modal__status">{{ qrStatus() }}</span>
}
@if (qrPolling()) {
<p class="qr-modal__hint">{{ 'create.qr_waiting' | translate }}</p>
}
</div>
</div>
}
</div>
<div class="card__footer">
<span class="secure-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
{{ 'common.secure' | translate }}
</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,266 @@
@use './../../../shared' as *;
.card__header {
position: relative;
}
.back {
position: absolute;
top: 14px;
left: 14px;
width: 44px;
height: 44px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #475569;
background: #f1f5f9;
border: 1px solid #e2e8f0;
text-decoration: none;
transition: background 0.15s, color 0.15s;
z-index: 1;
&:hover { background: #e2e8f0; color: #0f172a; }
&:active { background: #cbd5e1; }
}
.currency-badge {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 18px;
&__flag { font-size: 22px; line-height: 1; }
&__code { font-size: 15px; font-weight: 700; color: #0f172a; }
&__name { font-size: 13px; color: #64748b; margin-left: auto; }
}
// ─── Methods row ────────────────────────────────────────────────────────────
.methods {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
@media (max-width: 360px) {
gap: 6px;
}
}
.method {
display: flex;
align-items: center;
justify-content: center;
height: 56px;
padding: 8px;
border-radius: 12px;
border: 2px solid #e2e8f0;
background: #fff;
cursor: pointer;
transition: border-color .15s, background .15s, transform .1s, box-shadow .15s;
-webkit-appearance: none;
font-family: inherit;
@media (max-width: 360px) {
height: 52px;
padding: 6px;
}
&__logo {
max-width: 100%;
max-height: 28px;
object-fit: contain;
display: block;
pointer-events: none;
}
&:hover:not(:disabled):not(.method--disabled) {
border-color: #cbd5e1;
}
&:active:not(:disabled) { transform: scale(.97); }
&--active {
border-color: #2563eb;
background: rgba(37, 99, 235, .06);
box-shadow: 0 0 0 3px rgba(37, 99, 235, .1);
}
&--disabled,
&:disabled {
cursor: not-allowed;
background: #f8fafc;
.method__logo {
filter: grayscale(1);
opacity: .45;
}
}
}
// ─── Currency chips ─────────────────────────────────────────────────────────
.currencies {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 14px;
height: 44px;
border-radius: 999px;
border: 2px solid #e2e8f0;
background: #f8fafc;
color: #475569;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: border-color .15s, background .15s, color .15s;
-webkit-appearance: none;
&__flag { font-size: 16px; line-height: 1; }
&__sign {
font-size: 15px;
font-weight: 800;
color: #1e40af;
line-height: 1;
}
&__code { letter-spacing: .3px; }
&--active {
border-color: #2563eb;
background: rgba(37, 99, 235, .08);
color: #1e40af;
}
&--disabled,
&:disabled {
opacity: .45;
cursor: not-allowed;
color: #94a3b8;
.chip__sign { color: #94a3b8; }
}
}
.note-input {
width: 100%;
border: 2px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
padding: 14px 16px;
font-size: 15px;
font-weight: 500;
color: #0f172a;
font-family: inherit;
resize: vertical;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
line-height: 1.5;
&::placeholder { color: #cbd5e1; font-weight: 400; }
&:focus {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
background: #fff;
}
}
// ─── QR section ─────────────────────────────────────────────────────────────
// ─── QR popup ───────────────────────────────────────────────────────────────
.qr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: overlay-in 0.2s ease;
}
.qr-modal {
position: relative;
background: #fff;
border-radius: 20px;
padding: 32px 28px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
animation: modal-in 0.22s cubic-bezier(.34,1.56,.64,1);
max-width: 340px;
width: 90vw;
&__close {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #475569;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
&:hover { background: #e2e8f0; }
}
&__label {
font-size: 13px;
font-weight: 600;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__img {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
&__hint {
font-size: 13px;
color: #64748b;
animation: pulse 1.6s ease-in-out infinite;
}
&__status {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 10px;
border-radius: 20px;
background: #f1f5f9;
color: #475569;
}
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}

View File

@@ -0,0 +1,274 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { FastcheckService } from '../../fastcheck.service';
import { FASTCHECK_API, QR_VITANOVA_API } from '../../api';
import { TranslatePipe } from '../../translate/translate.pipe';
import { TranslationService } from '../../translate/translation.service';
type PaymentMethod = 'sbp';
type Currency = 'RUB';
interface SettingsResponse {
minAmount?: number;
maxAmount?: number;
[key: string]: unknown;
}
interface CreateQrResponse {
qrId?: string;
nspkID?: string;
Payload?: string; // per API doc (capital P)
nspkurl?: string; // actual field name in real responses
qrUrl?: string;
status?: string; // e.g. "REGISTERED"
[key: string]: unknown;
}
interface QrStatusResponse {
status?: string; // "REGISTERED" | "NEW" | "APPROVED" | "REJECTED" | "COMPLETED"
nspkurl?: string;
nspkID?: string;
[key: string]: unknown;
}
interface CreateFastcheckResponse {
id?: string; // real field name from server
fastcheck?: string; // per API doc fallback
expiration?: string;
code?: string;
amount?: number;
Status?: boolean;
}
/** Generate a v4-like UUID without crypto dependency. */
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
@Component({
selector: 'app-create-page',
imports: [FormsModule, RouterLink, TranslatePipe],
templateUrl: './create-page.html',
styleUrl: './create-page.scss'
})
export class CreatePage {
private http = inject(HttpClient);
private store = inject(FastcheckService);
private router = inject(Router);
private i18n = inject(TranslationService);
private t(key: string): string { return this.i18n.translate(key); }
// Limits updated from settings API on init.
minAmount = signal<number>(30);
maxAmount = signal<number>(200_000);
amount = signal<number | null>(null);
note = signal<string>('');
error = signal<string>('');
loading = signal<boolean>(false);
settingsLoaded = signal<boolean>(false);
currency = signal<Currency>('RUB');
payment = signal<PaymentMethod>('sbp');
selectPayment(method: PaymentMethod, enabled: boolean): void {
if (!enabled) return;
this.payment.set(method);
}
selectCurrency(c: Currency, enabled: boolean): void {
if (!enabled) return;
this.currency.set(c);
}
// QR display state
qrImageUrl = signal<string | null>(null);
qrPolling = signal<boolean>(false);
qrStatus = signal<string>('');
private pollHandle: ReturnType<typeof setInterval> | null = null;
private activeQrId = '';
/** Auth credentials passed by the host page as URL params. */
private get authKey(): string {
return new URLSearchParams(window.location.search).get('authorization-key') ?? '';
}
private get userId(): string {
return new URLSearchParams(window.location.search).get('userid-value') ?? '';
}
private get sessionId(): string {
return new URLSearchParams(window.location.search).get('session') ?? '';
}
private get reference(): string {
return new URLSearchParams(window.location.search).get('ref') ?? window.location.hostname;
}
get isMobile(): boolean {
return window.innerWidth < 768;
}
constructor() {
this.loadSettings();
}
private loadSettings(): void {
this.http.get<SettingsResponse>(`${QR_VITANOVA_API}/settings`).subscribe({
next: (s) => {
if (typeof s?.minAmount === 'number') this.minAmount.set(s.minAmount);
if (typeof s?.maxAmount === 'number') this.maxAmount.set(s.maxAmount);
this.settingsLoaded.set(true);
},
error: () => this.settingsLoaded.set(true) // proceed with defaults
});
}
createCheck(): void {
const val = this.amount();
if (val !== null && val < this.minAmount()) {
this.error.set(`${this.t('errors.invalid_amount')} (мин. ${this.minAmount()} ₽)`);
return;
}
if (val !== null && val > this.maxAmount()) {
this.error.set(`${this.t('errors.invalid_amount')} (макс. ${this.maxAmount().toLocaleString('ru')} ₽)`);
return;
}
this.error.set('');
this.loading.set(true);
const headers: Record<string, string> = {};
if (this.authKey) headers['authorization-key'] = this.authKey;
if (this.userId) headers['userid-value'] = this.userId;
const partnerqrID = generateUUID();
this.http
.post<CreateQrResponse>(
`${QR_VITANOVA_API}/qr`,
{
qrtype: 'QRDynamic',
...(val !== null ? { amount: val } : {}),
currency: this.currency(),
partnerqrID,
qrDescription: this.note().trim(),
Userid: this.userId,
Reference: this.reference
},
{ headers }
)
.subscribe({
next: (res) => {
this.loading.set(false);
const qrId = res?.qrId ?? res?.nspkID ?? '';
// Real API uses 'nspkurl'; doc says 'Payload' — try both
const nspkUrl = res?.nspkurl ?? res?.Payload;
this.qrStatus.set(res?.status ?? '');
if (nspkUrl && this.isMobile) {
window.location.href = nspkUrl;
return;
}
if (qrId || nspkUrl) {
this.activeQrId = qrId;
const qrData = nspkUrl
? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(nspkUrl)}`
: (res.qrUrl ?? null);
this.qrImageUrl.set(qrData);
if (qrId) this.startPolling(qrId);
} else {
this.error.set(this.t('errors.payment_failed'));
}
},
error: (err) => {
this.loading.set(false);
const msg: string | undefined = err?.error?.message;
this.error.set(msg ?? this.t('errors.lookup_failed'));
}
});
}
private startPolling(qrId: string): void {
this.stopPolling();
this.qrPolling.set(true);
this.pollHandle = setInterval(() => {
this.http.get<QrStatusResponse>(`${QR_VITANOVA_API}/qr/dynamic/${qrId}`)
.subscribe({
next: (res) => {
const st = res?.status ?? '';
this.qrStatus.set(st);
if (st === 'COMPLETED' || st === 'APPROVED') {
this.stopPolling();
this.createFastcheck();
} else if (st === 'REJECTED') {
this.stopPolling();
this.error.set(this.t('errors.payment_failed'));
this.qrImageUrl.set(null);
}
// REGISTERED / NEW / '' — keep polling
},
error: () => undefined
});
}, 5000);
}
private stopPolling(): void {
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
}
this.qrPolling.set(false);
}
private createFastcheck(): void {
const headers: Record<string, string> = {};
if (this.sessionId) headers['Authorization'] = JSON.stringify({ sessionID: this.sessionId });
this.http
.post<CreateFastcheckResponse>(
`${FASTCHECK_API}/fastcheck`,
{ amount: this.amount(), currency: this.currency() },
{ headers }
)
.subscribe({
next: (res) => {
const fcNumber = res?.id ?? res?.fastcheck ?? '';
const payload = {
fastcheck: fcNumber,
code: res?.code ?? '',
amount: res?.amount ?? this.amount() ?? null,
expiration: res?.expiration
};
if (fcNumber) {
this.store.setCreated(payload);
}
this.router.navigate(['/'], { state: fcNumber ? payload : {} });
},
error: () => this.router.navigate(['/'])
});
}
onAmountChange(value: number | null): void {
this.amount.set(value || null);
if (value && value > 0) this.error.set('');
}
onNoteChange(value: string): void {
this.note.set(value);
}
closeQr(): void {
this.qrImageUrl.set(null);
this.qrPolling.set(false);
this.qrStatus.set('');
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
}
}
}

View File

@@ -0,0 +1,177 @@
<div class="page">
<div class="card">
<div class="card__header">
<img class="card__brand" src="/logo_big.png"
alt="fastCHECK" width="220" height="60" />
<p class="card__subtitle">
{{ 'fastcheck.subtitle' | translate }}
</p>
</div>
<div class="card__body">
<!-- Fastcheck number + new -->
<div class="field">
<label class="field__label" for="fcNumber">
{{ 'fastcheck.number_label' | translate }}
</label>
<div class="row">
<input
id="fcNumber"
type="text"
class="input"
[ngModel]="fastcheckNumber()"
(ngModelChange)="onNumberChange($event)"
[placeholder]="'fastcheck.number_placeholder' | translate"
inputmode="numeric"
autocomplete="off"
maxlength="20"
/>
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый fastCHECK">{{ 'fastcheck.number_new' | translate }}</a>
</div>
</div>
<!-- Amount -->
<div class="field">
<label class="field__label" for="fcAmount">{{ 'fastcheck.amount_label' | translate }}</label>
<div class="input-wrap">
<span class="input-wrap__prefix"></span>
<input
id="fcAmount"
type="number"
class="input-wrap__input"
[ngModel]="fastcheckAmount()"
(ngModelChange)="onAmountChange($event)"
min="1"
step="1"
inputmode="numeric"
placeholder="0"
[disabled]="true"
/>
</div>
@if (amountLoading()) {
<span class="field__hint">{{ 'fastcheck.amount_checking' | translate }}</span>
}
</div>
<!-- Share row — always visible, enabled once amount is known -->
<div class="share-row">
<!-- <button type="button" class="share-btn share-btn--email" (click)="shareByEmail()"
[disabled]="fastcheckAmount() === null || amountLoading()"
[title]="'fastcheck.share_email' | translate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M2 7l10 7 10-7"/>
</svg>
{{ 'fastcheck.share_email' | translate }}
</button> -->
<button type="button" class="share-btn share-btn--tg" (click)="shareByTelegram()"
[disabled]="fastcheckAmount() === null || amountLoading()"
[title]="'fastcheck.share_tg' | translate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.04 15.65l-.36 4.06c.51 0 .73-.22.99-.48l2.38-2.27 4.93 3.6c.9.5 1.55.24 1.79-.83l3.24-15.18h.01c.29-1.34-.48-1.86-1.36-1.54L1.13 9.66c-1.32.5-1.3 1.23-.22 1.56l4.92 1.53L17.27 5.6c.54-.34 1.03-.15.62.19"/>
</svg>
{{ 'fastcheck.share_tg' | translate }}
</button>
</div>
<!-- Code -->
<div class="field">
<label class="field__label" for="fcCode">{{ 'fastcheck.code_label' | translate }}</label>
<input
id="fcCode"
type="text"
class="input"
[ngModel]="fastcheckCode()"
(ngModelChange)="onCodeChange($event)"
[placeholder]="'fastcheck.code_placeholder' | translate"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
[disabled]="!codeEnabled()"
/>
@if (error()) {
<span class="field__error">{{ error() }}</span>
}
</div>
<button class="pay-btn" type="button" (click)="pay()" [disabled]="!canPay()">
<span class="pay-btn__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</span>
{{ 'fastcheck.pay_btn' | translate }}
</button>
</div>
<div class="card__footer">
<span class="secure-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
{{ 'common.secure' | translate }}
</span>
</div>
</div>
</div>
<!-- Telegram sign-in popup -->
@if (popupOpen()) {
<div class="modal" (click)="closePopup()">
<div class="modal__card" (click)="$event.stopPropagation()">
<button class="modal__close" type="button" (click)="closePopup()" aria-label="Закрыть">×</button>
@if (paid()) {
<div class="modal__success">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#16a34a"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
<h2 class="modal__title">{{ 'fastcheck.modal_paid_title' | translate }}</h2>
<p class="modal__sub">
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
{{ 'fastcheck.modal_paid_sub' | translate }}
</p>
</div>
} @else {
<img class="brand-logo brand-logo--small" src="/logo_small.png"
alt="fastCHECK" width="32" height="32" />
<h2 class="modal__title">{{ 'fastcheck.modal_title' | translate }}</h2>
<p class="modal__sub">{{ 'fastcheck.modal_sub' | translate }}</p>
@if (popupLoading() && !webSessionId()) {
<div class="qr__placeholder">{{ 'fastcheck.modal_loading' | translate }}</div>
}
@if (webSessionId() && !isMobile) {
<img [src]="qrUrl()" width="240" height="240" alt="QR Telegram" style="border-radius:12px;display:block;margin:0 auto 12px;" />
}
@if (webSessionId()) {
<a class="tg-link" [href]="telegramLink()" target="_blank" rel="noopener">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.04 15.65l-.36 4.06c.51 0 .73-.22.99-.48l2.38-2.27 4.93 3.6c.9.5 1.55.24 1.79-.83l3.24-15.18h.01c.29-1.34-.48-1.86-1.36-1.54L1.13 9.66c-1.32.5-1.3 1.23-.22 1.56l4.92 1.53L17.27 5.6c.54-.34 1.03-.15.62.19" />
</svg>
{{ 'fastcheck.modal_open_tg' | translate }}
</a>
}
@if (popupLoading() && webSessionId()) {
<p class="modal__hint">{{ 'fastcheck.modal_confirming' | translate }}</p>
} @else if (webSessionId()) {
<p class="modal__hint">{{ 'fastcheck.modal_waiting' | translate }}</p>
}
@if (popupError()) {
<p class="modal__error">{{ popupError() }}</p>
}
}
</div>
</div>
}

View File

@@ -0,0 +1,260 @@
@use './../../../shared' as *;
.row {
display: flex;
gap: 8px;
align-items: stretch;
.input { flex: 1; min-width: 0; }
}
.share-row {
display: flex;
gap: 8px;
}
.share-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 40px;
border-radius: 10px;
border: 1.5px solid #e2e8f0;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background .15s, border-color .15s;
&--email {
background: #f8fafc;
color: #475569;
&:hover { background: #e2e8f0; border-color: #cbd5e1; }
}
&--tg {
background: #e7f3fe;
color: #0088cc;
border-color: #bfdbfe;
&:hover { background: #dbeafe; border-color: #93c5fd; }
}
&:disabled {
opacity: .4;
cursor: not-allowed;
pointer-events: none;
}
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 16px;
height: 48px;
min-width: 64px;
border-radius: 12px;
font-size: 14px;
font-weight: 700;
text-decoration: none;
border: 2px solid transparent;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: opacity .15s, transform .1s, background .15s;
-webkit-appearance: none;
&--ghost {
background: #f1f5f9;
color: #2563eb;
border-color: #e2e8f0;
&:hover { background: #e2e8f0; }
&:active { transform: scale(.97); }
}
}
.input {
width: 100%;
border: 2px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
padding: 0 14px;
height: 48px;
font-size: 16px;
font-weight: 600;
color: #0f172a;
font-family: inherit;
outline: none;
transition: border-color .2s, box-shadow .2s, background .2s;
&::placeholder { color: #cbd5e1; font-weight: 500; }
&:focus {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37,99,235,.12);
background: #fff;
}
}
// ─── Modal (Telegram QR popup) ──────────────────────────────────────────────
.modal {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(15, 23, 42, .55);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
animation: fade-in .15s ease-out;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
@media (max-width: 480px) {
align-items: stretch;
padding: 0;
}
&__card {
position: relative;
background: #fff;
border-radius: 24px;
width: 100%;
max-width: 360px;
padding: 28px 24px 24px;
text-align: center;
box-shadow: 0 24px 60px rgba(0,0,0,.25);
animation: pop-in .2s ease-out;
margin: auto;
@media (max-width: 480px) {
max-width: 100%;
border-radius: 0;
box-shadow: none;
padding: calc(28px + env(safe-area-inset-top)) 20px calc(28px + env(safe-area-inset-bottom));
margin: 0;
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
}
}
&__close {
position: absolute;
top: 8px;
right: 8px;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #475569;
font-size: 24px;
line-height: 1;
cursor: pointer;
font-family: inherit;
transition: background .15s;
-webkit-appearance: none;
&:hover { background: #e2e8f0; }
}
&__title {
font-size: 20px;
font-weight: 700;
color: #0f172a;
margin: 4px 0 6px;
}
&__sub {
font-size: 14px;
color: #64748b;
margin: 0 0 18px;
}
&__hint {
font-size: 13px;
color: #94a3b8;
margin: 14px 0 0;
}
&__error {
font-size: 13px;
color: #ef4444;
font-weight: 500;
margin: 12px 0 0;
}
&__success {
padding: 12px 0 4px;
svg { display: block; margin: 0 auto 10px; }
}
}
.qr {
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 16px;
padding: 12px;
width: 264px;
height: 264px;
max-width: 100%;
margin: 0 auto;
@media (max-width: 380px) {
width: min(264px, 70vw);
height: auto;
aspect-ratio: 1;
}
&__placeholder {
color: #94a3b8;
font-size: 14px;
}
img {
width: 100%;
height: auto;
max-width: 240px;
display: block;
}
}
.tg-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 16px;
padding: 14px 22px;
min-height: 48px;
border-radius: 12px;
background: #229ED9;
color: #fff;
font-size: 15px;
font-weight: 700;
text-decoration: none;
transition: opacity .15s;
&:hover { opacity: .9; }
&:active { transform: scale(.97); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pop-in {
from { transform: translateY(12px) scale(.98); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}

View File

@@ -0,0 +1,293 @@
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { FastcheckService } from '../../fastcheck.service';
import { FASTCHECK_API } from '../../api';
import { TranslatePipe } from '../../translate/translate.pipe';
import { TranslationService } from '../../translate/translation.service';
interface WebSessionResponse {
sessionId: string;
userId: string;
expires: string;
userSessionId: string;
Status: boolean;
}
interface CheckFastcheckResponse {
id: string;
code: string;
owneID: string;
amount: number;
currency: string;
createdAt: string;
creattransactionID: string;
firedAT: string;
firetransactionID: string;
}
@Component({
selector: 'app-fastcheck-page',
imports: [FormsModule, RouterLink, TranslatePipe],
templateUrl: './fastcheck-page.html',
styleUrl: './fastcheck-page.scss'
})
export class FastcheckPage {
private http = inject(HttpClient);
private store = inject(FastcheckService);
private router = inject(Router);
private i18n = inject(TranslationService);
private t(key: string): string { return this.i18n.translate(key); }
// Telegram bot used for the sign-in deep link.
private readonly telegramBot = 'DexarSupport_bot';
fastcheckNumber = signal<string>('');
fastcheckAmount = signal<number | null>(null);
fastcheckCode = signal<string>('');
codeEnabled = signal<boolean>(false);
error = signal<string>('');
amountLoading = signal<boolean>(false);
popupOpen = signal<boolean>(false);
popupLoading = signal<boolean>(false);
popupError = signal<string>('');
webSessionId = signal<string>('');
paid = signal<boolean>(false);
private pollHandle: ReturnType<typeof setInterval> | null = null;
private lastLookedUpNumber = '';
canPay = computed(() => {
const digits = this.fastcheckNumber().replace(/\D/g, '');
const codeDigits = this.fastcheckCode().replace(/\D/g, '');
return digits.length === 18 && codeDigits.length === 6
&& this.codeEnabled() && !this.amountLoading();
});
telegramLink = computed(() => {
const sid = this.webSessionId();
return sid
? `https://t.me/${this.telegramBot}?start=${encodeURIComponent(sid)}`
: `https://t.me/${this.telegramBot}`;
});
qrUrl = computed(() => {
const link = this.telegramLink();
return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=${encodeURIComponent(link)}`;
});
get isMobile(): boolean {
return typeof window !== 'undefined' && window.innerWidth < 768;
}
constructor() {
// Pull autofill data: prefer router navigation state, fall back to service.
const navState = typeof window !== 'undefined' ? (window.history?.state ?? {}) : {};
const created = (navState?.fastcheck)
? { fastcheck: navState.fastcheck, code: navState.code ?? '', amount: navState.amount ?? null, expiration: navState.expiration }
: this.store.consume();
if (created) {
this.fastcheckNumber.set(created.fastcheck);
this.fastcheckAmount.set(created.amount);
this.fastcheckCode.set(created.code);
this.codeEnabled.set(true);
}
// ?iid=xxxxxx-xxxxxx-xxxxxx — auto-fill and trigger lookup
const iidParam = new URLSearchParams(window.location.search).get('iid') ?? '';
if (iidParam && !created) {
const digits = iidParam.replace(/\D/g, '').slice(0, 18);
const groups: string[] = [];
for (let i = 0; i < digits.length; i += 6) groups.push(digits.slice(i, i + 6));
const masked = groups.join('-');
this.fastcheckNumber.set(masked);
if (digits.length === 18) this.lookupFastcheck(masked);
}
}
pay(): void {
if (!this.canPay()) {
return;
}
this.error.set('');
this.openPopup();
}
private openPopup(): void {
this.popupOpen.set(true);
this.popupError.set('');
this.paid.set(false);
this.popupLoading.set(true);
this.http.get<WebSessionResponse>(`${FASTCHECK_API}/websession`).subscribe({
next: (res) => {
this.popupLoading.set(false);
this.webSessionId.set(res.sessionId);
if (this.isMobile) {
window.location.href = `https://t.me/${this.telegramBot}?start=${encodeURIComponent(res.sessionId)}`;
} else {
this.startPolling(res.sessionId);
}
},
error: () => {
this.popupLoading.set(false);
this.popupError.set(this.t('errors.session_failed'));
}
});
}
closePopup(): void {
this.popupOpen.set(false);
this.stopPolling();
if (this.webSessionId()) {
// Best-effort logout; ignore errors.
this.http
.request('DELETE', `${FASTCHECK_API}/websession/${this.webSessionId()}`, {
body: { sessionId: this.webSessionId() }
})
.subscribe({ error: () => undefined });
}
this.webSessionId.set('');
}
private startPolling(sessionId: string): void {
this.stopPolling();
this.pollHandle = setInterval(() => {
this.http
.get<WebSessionResponse>(`${FASTCHECK_API}/websession/${sessionId}`)
.subscribe({
next: (res) => {
if (res?.Status) {
this.stopPolling();
this.acceptFastcheck(sessionId);
}
},
error: () => undefined
});
}, 3000);
}
private stopPolling(): void {
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
}
}
private acceptFastcheck(sessionId: string): void {
this.popupLoading.set(true);
this.http
.post(
`${FASTCHECK_API}/fastcheck`,
{ fastcheck: this.fastcheckNumber().trim(), code: this.fastcheckCode().trim() },
{ headers: { Authorization: JSON.stringify({ sessionID: sessionId }) } }
)
.subscribe({
next: () => {
this.popupLoading.set(false);
this.paid.set(true);
// Fire DELETE to mark fastcheck as consumed on the merchant side.
this.http
.delete(`${FASTCHECK_API}/fastcheck/${encodeURIComponent(this.fastcheckNumber())}`)
.subscribe({ error: () => undefined });
this.fireMerchantCallback();
},
error: () => {
this.popupLoading.set(false);
this.popupError.set(this.t('errors.payment_failed'));
}
});
}
private fireMerchantCallback(): void {
const params = new URLSearchParams(window.location.search);
const returnUrl = params.get('return_url');
if (returnUrl) {
setTimeout(() => {
window.location.href = `${returnUrl}${returnUrl.includes('?') ? '&' : '?'}fastcheck=${encodeURIComponent(
this.fastcheckNumber()
)}&status=ok`;
}, 1500);
}
}
onAmountChange(value: number | null): void {
this.fastcheckAmount.set(value);
}
/** Mask fastcheck number as XXXXXX-XXXXXX-XXXXXX, allow only digits. */
onNumberChange(raw: string): void {
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 18);
const groups: string[] = [];
for (let i = 0; i < digits.length; i += 6) {
groups.push(digits.slice(i, i + 6));
}
const masked = groups.join('-');
this.fastcheckNumber.set(masked);
this.error.set('');
if (digits.length < 18 && this.lastLookedUpNumber) {
this.fastcheckAmount.set(null);
this.codeEnabled.set(false);
this.lastLookedUpNumber = '';
}
if (digits.length === 18 && masked !== this.lastLookedUpNumber) {
this.lookupFastcheck(masked);
}
}
/** Allow only digits, max 6, in the code field. */
onCodeChange(raw: string): void {
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 6);
this.fastcheckCode.set(digits);
this.error.set('');
}
private lookupFastcheck(number: string): void {
this.lastLookedUpNumber = number;
this.amountLoading.set(true);
this.fastcheckAmount.set(null);
this.codeEnabled.set(false);
// API doc: GET /fastcheck/<id>
this.http
.get<CheckFastcheckResponse>(`${FASTCHECK_API}/fastcheck/${number}`)
.subscribe({
next: (res) => {
this.amountLoading.set(false);
if (res?.id) {
this.fastcheckAmount.set(typeof res.amount === 'number' ? res.amount : null);
this.codeEnabled.set(true);
} else {
this.error.set(this.t('errors.not_found'));
this.lastLookedUpNumber = '';
}
},
error: (err) => {
this.amountLoading.set(false);
const serverMsg: string | undefined = err?.error?.message;
this.error.set(serverMsg ?? this.t('errors.lookup_failed'));
this.lastLookedUpNumber = '';
}
});
}
shareByEmail(): void {
const num = this.fastcheckNumber();
const amount = this.fastcheckAmount();
const subject = encodeURIComponent('fastCHECK');
const body = encodeURIComponent(`Номер: ${num}\nСумма: ${amount}\nhttps://qr.vitanova.network/`);
window.open(`mailto:?subject=${subject}&body=${body}`, '_blank');
}
shareByTelegram(): void {
const num = this.fastcheckNumber();
const amount = this.fastcheckAmount();
const text = encodeURIComponent(`fastCHECK: ${num}${amount}`);
window.open(`https://t.me/share/url?url=https%3A%2F%2Fqr.vitanova.network%2F&text=${text}`, '_blank');
}
}

View File

@@ -0,0 +1,93 @@
<div class="page">
<div class="card">
<div class="card__header">
<div class="sbp-logo">
<img src="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg"
alt="СБП" />
</div>
<h1 class="card__title">{{ 'sbp.title' | translate }}</h1>
<p class="card__subtitle">{{ 'sbp.subtitle' | translate }}</p>
</div>
<div class="card__body">
<div class="field">
<label class="field__label" for="amount">{{ 'sbp.amount_label' | translate }}</label>
<div class="input-wrap" [class.input-wrap--error]="error()">
<span class="input-wrap__prefix"></span>
<input
id="amount"
type="number"
class="input-wrap__input"
[ngModel]="amount()"
(ngModelChange)="onAmountChange($event)"
min="1"
step="1"
inputmode="numeric"
placeholder="0"
autofocus
/>
</div>
@if (error()) {
<span class="field__error">{{ error() }}</span>
}
</div>
<div class="currency-badge">
<span class="currency-badge__flag">🇷🇺</span>
<span class="currency-badge__code">RUB</span>
<span class="currency-badge__name">{{ 'sbp.currency_name' | translate }}</span>
</div>
<div class="field">
<label class="field__label" for="note">{{ 'sbp.note_label' | translate }}</label>
<textarea
id="note"
class="note-input"
[ngModel]="note()"
(ngModelChange)="onNoteChange($event)"
[placeholder]="'sbp.note_placeholder' | translate"
rows="3"
maxlength="500"
></textarea>
</div>
@if (nspkUrl()) {
<div class="qr-pay">
<img
[src]="'https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=' + nspkUrl()"
width="240" height="240"
alt="SBP QR"
/>
<p class="qr-pay__hint">Отсканируйте QR-код в приложении вашего банка</p>
</div>
}
<button class="pay-btn" type="button" (click)="pay()" [disabled]="loading() || !!nspkUrl()">
<span class="pay-btn__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</span>
@if (loading()) {
{{ 'sbp.pay_loading' | translate }}
} @else {
{{ 'sbp.pay_btn' | translate }}
}
</button>
</div>
<div class="card__footer">
<span class="secure-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
{{ 'common.secure' | translate }}
</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,81 @@
@use './../../../shared' as *;
.sbp-logo {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
border-radius: 16px;
padding: 12px 20px;
border: 1px solid rgba(255, 255, 255, 0.25);
margin-bottom: 14px;
img {
height: 40px;
display: block;
@media (max-width: 480px) {
height: 34px;
}
}
}
.currency-badge {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 18px;
&__flag { font-size: 22px; line-height: 1; }
&__code { font-size: 15px; font-weight: 700; color: #0f172a; }
&__name { font-size: 13px; color: #64748b; margin-left: auto; }
}
.qr-pay {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-bottom: 20px;
img {
border-radius: 12px;
border: 1px solid #e2e8f0;
display: block;
}
&__hint {
font-size: 13px;
color: #64748b;
text-align: center;
margin: 0;
}
}
.note-input {
width: 100%;
border: 2px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
padding: 14px 16px;
font-size: 15px;
font-weight: 500;
color: #0f172a;
font-family: inherit;
resize: vertical;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
line-height: 1.5;
&::placeholder { color: #cbd5e1; font-weight: 400; }
&:focus {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
background: #fff;
}
}

View File

@@ -0,0 +1,107 @@
import { Component, computed, inject, isDevMode, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { TranslatePipe } from '../../translate/translate.pipe';
import { TranslationService } from '../../translate/translation.service';
interface LegacyPayResponse {
nspkurl?: string;
}
/**
* Legacy SBP merchant payment flow.
* Activated when the root URL has `?id=<orderId>`.
* Mirrors public/payment.html behaviour:
* POST https://qr.vitanova.network:567/qr
* { payment, amount, currency, id, note } -> { payload: '<sbp-deep-link>' }
* then window.location.href = payload.
*/
@Component({
selector: 'app-legacy-pay-page',
imports: [FormsModule, TranslatePipe],
templateUrl: './legacy-pay-page.html',
styleUrl: './legacy-pay-page.scss'
})
export class LegacyPayPage {
private http = inject(HttpClient);
private route = inject(ActivatedRoute);
private i18n = inject(TranslationService);
private t(key: string): string { return this.i18n.translate(key); }
private readonly LEGACY_API = isDevMode()
? '/proxy/legacy-qr/qr'
: 'https://qr.vitanova.network:567/qr';
amount = signal<number | null>(null);
note = signal<string>('');
error = signal<string>('');
loading = signal<boolean>(false);
nspkUrl = signal<string>('');
get isMobile(): boolean {
return window.innerWidth < 768;
}
paymentId = signal<string>('');
canPay = computed(() => {
const a = this.amount();
return !!this.paymentId() && a !== null && a > 0 && !this.loading();
});
constructor() {
const id = this.route.snapshot.queryParamMap.get('id') ?? '';
this.paymentId.set(id);
}
onAmountChange(value: number | null): void {
this.amount.set(value);
if (this.error()) this.error.set('');
}
onNoteChange(value: string): void {
this.note.set(value);
}
pay(): void {
if (!this.canPay()) {
if (!this.paymentId()) {
this.error.set(this.t('errors.not_found'));
} else {
this.error.set(this.t('errors.invalid_amount'));
}
return;
}
this.error.set('');
this.loading.set(true);
const body = {
qrtype: 'QRDynamic',
amount: this.amount(),
currency: 'RUB',
partnerqrID: this.paymentId(),
qrDescription: this.note().trim()
};
this.http.post<LegacyPayResponse>(this.LEGACY_API, body).subscribe({
next: (res) => {
this.loading.set(false);
if (res?.nspkurl) {
if (this.isMobile) {
window.location.href = res.nspkurl;
} else {
this.nspkUrl.set(res.nspkurl);
}
} else {
this.error.set(this.t('errors.payment_failed'));
}
},
error: () => {
this.loading.set(false);
this.error.set(this.t('errors.lookup_failed'));
}
});
}
}

View File

@@ -0,0 +1,26 @@
<div class="info-page">
<div class="info-page__hero">
<h1 class="info-page__title">{{ 'partners.title' | translate }}</h1>
<p class="info-page__lead">{{ 'partners.lead' | translate }}</p>
</div>
<div class="partners-grid">
@for (p of partners; track p.name) {
<div class="partner-card">
<div class="partner-card__logo">{{ p.logo }}</div>
<div class="partner-card__body">
<span class="partner-card__cat">{{ p.category | translate }}</span>
<h3 class="partner-card__name">{{ p.name }}</h3>
<p class="partner-card__city">📍 {{ p.city }}</p>
<p class="partner-card__desc">{{ p.desc | translate }}</p>
</div>
</div>
}
</div>
<div class="partners-cta">
<h2 class="partners-cta__title">{{ 'partners.cta_title' | translate }}</h2>
<p class="partners-cta__text">{{ 'partners.cta_text' | translate }}</p>
<a class="partners-cta__btn" routerLink="/contacts">{{ 'partners.cta_btn' | translate }}</a>
</div>
</div>

View File

@@ -0,0 +1,146 @@
:host {
display: block;
background: #f8fafc;
min-height: 100vh;
}
.info-page {
max-width: 900px;
margin: 0 auto;
padding: 48px 24px 72px;
@media (max-width: 600px) {
padding: 32px 16px 56px;
}
&__hero {
margin-bottom: 40px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 32px;
}
&__title {
font-size: 32px;
font-weight: 800;
color: #0f172a;
margin: 0 0 12px;
letter-spacing: -0.5px;
@media (max-width: 600px) { font-size: 26px; }
}
&__lead {
font-size: 17px;
line-height: 1.7;
color: #475569;
margin: 0;
}
}
.partners-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 56px;
}
.partner-card {
display: flex;
gap: 16px;
padding: 22px 20px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
transition: border-color 0.15s, box-shadow 0.15s;
&:hover {
border-color: #93c5fd;
box-shadow: 0 4px 16px rgba(30, 64, 175, 0.08);
}
&__logo {
font-size: 36px;
line-height: 1;
flex-shrink: 0;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
border-radius: 12px;
}
&__body {
display: flex;
flex-direction: column;
gap: 4px;
}
&__cat {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #1e40af;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
&__city {
font-size: 13px;
color: #64748b;
margin: 0;
}
&__desc {
font-size: 13.5px;
line-height: 1.6;
color: #475569;
margin: 4px 0 0;
}
}
.partners-cta {
text-align: center;
padding: 40px 24px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border-radius: 20px;
border: 1px solid #bfdbfe;
&__title {
font-size: 22px;
font-weight: 800;
color: #1e3a8a;
margin: 0 0 12px;
}
&__text {
font-size: 15px;
line-height: 1.7;
color: #3b5998;
margin: 0 0 24px;
max-width: 480px;
margin-inline: auto;
margin-bottom: 24px;
}
&__btn {
display: inline-block;
padding: 12px 28px;
background: #1e40af;
color: #fff;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
text-decoration: none;
transition: background 0.15s;
&:hover { background: #1d3a9f; }
}
}

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '../../translate/translate.pipe';
interface Partner {
name: string;
category: string;
city: string;
logo: string; // emoji placeholder until real logos are provided
desc: string;
}
@Component({
selector: 'app-partners-page',
imports: [RouterLink, TranslatePipe],
templateUrl: './partners-page.html',
styleUrl: './partners-page.scss'
})
export class PartnersPage {
partners: Partner[] = [
{ name: 'Vitanova Exchange', category: 'partners.cat_finance', city: 'Ереван', logo: '🏦', desc: 'partners.p1_desc' },
{ name: 'ForEx.am', category: 'partners.cat_finance', city: 'Ереван', logo: '💱', desc: 'partners.p2_desc' },
{ name: 'Dexar Market', category: 'partners.cat_retail', city: 'Москва', logo: '🛒', desc: 'partners.p3_desc' },
{ name: 'City Hotel Yerevan', category: 'partners.cat_hotels', city: 'Ереван', logo: '🏨', desc: 'partners.p4_desc' },
];
}

View File

@@ -1,39 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private readonly API_URL = 'https://api.fastcheck.store';
constructor(private http: HttpClient) {}
ping(): Observable<{ message: string }> {
return this.http.get<{ message: string }>(`${this.API_URL}/ping`);
}
get<T>(path: string, sessionId?: string): Observable<T> {
const headers = sessionId ? this.createAuthHeaders(sessionId) : undefined;
return this.http.get<T>(`${this.API_URL}${path}`, { headers });
}
post<T>(path: string, body: any, sessionId?: string): Observable<T> {
const headers = sessionId ? this.createAuthHeaders(sessionId) : undefined;
return this.http.post<T>(`${this.API_URL}${path}`, body, { headers });
}
delete<T>(path: string, sessionId?: string): Observable<T> {
const headers = sessionId ? this.createAuthHeaders(sessionId) : undefined;
return this.http.delete<T>(`${this.API_URL}${path}`, { headers });
}
private createAuthHeaders(sessionId: string): HttpHeaders {
return new HttpHeaders({
'Authorization': JSON.stringify({ sessionID: sessionId }),
'Content-Type': 'application/json'
});
}
}

View File

@@ -1,77 +0,0 @@
import { Injectable, signal } from '@angular/core';
import { Observable, interval, switchMap, takeWhile, tap } from 'rxjs';
import { ApiService } from './api.service';
import { WebSession, AuthState } from '../models/session.model';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private authState = signal<AuthState>({
isAuthenticated: false,
sessionId: null,
userSessionId: null
});
readonly isAuthenticated = this.authState.asReadonly();
constructor(private apiService: ApiService) {
this.loadSessionFromStorage();
}
createWebSession(): Observable<WebSession> {
return this.apiService.get<WebSession>('/websession');
}
checkWebSessionStatus(sessionId: string): Observable<WebSession> {
return this.apiService.get<WebSession>(`/websession/${sessionId}`);
}
startPolling(sessionId: string): Observable<WebSession> {
return interval(2000).pipe(
switchMap(() => this.checkWebSessionStatus(sessionId)),
tap(session => {
if (session.Status) {
this.setAuthenticated(session);
}
}),
takeWhile(session => !session.Status, true)
);
}
deleteWebSession(sessionId: string): Observable<any> {
return this.apiService.delete(`/websession/${sessionId}`, sessionId).pipe(
tap(() => this.clearAuthentication())
);
}
private setAuthenticated(session: WebSession): void {
const state = {
isAuthenticated: true,
sessionId: session.sessionId,
userSessionId: session.userSessionId
};
this.authState.set(state);
sessionStorage.setItem('authState', JSON.stringify(state));
}
private loadSessionFromStorage(): void {
const stored = sessionStorage.getItem('authState');
if (stored) {
this.authState.set(JSON.parse(stored));
}
}
clearAuthentication(): void {
this.authState.set({
isAuthenticated: false,
sessionId: null,
userSessionId: null
});
sessionStorage.removeItem('authState');
}
getSessionId(): string | null {
return this.authState().sessionId;
}
}

View File

@@ -1,142 +0,0 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { ApiService } from './api.service';
import { AuthService } from './auth.service';
import {
FastCheck,
CreateFastCheckRequest,
CreateFastCheckResponse,
AcceptFastCheckRequest,
CheckStatusResponse,
Balance,
FastCheckListResponse
} from '../models/fastcheck.model';
@Injectable({
providedIn: 'root'
})
export class FastCheckService {
constructor(
private apiService: ApiService,
private authService: AuthService
) {}
checkStatus(fastcheckNumber: string): Observable<CheckStatusResponse> {
return this.apiService.post<CheckStatusResponse>(
'/fastcheck',
{ fastcheck: fastcheckNumber }
);
}
createFastCheck(request: CreateFastCheckRequest): Observable<CreateFastCheckResponse> {
const sessionId = this.authService.getSessionId();
if (!sessionId) {
throw new Error('Not authenticated');
}
return this.apiService.post<CreateFastCheckResponse>(
'/fastcheck',
request,
sessionId
);
}
acceptFastCheck(request: AcceptFastCheckRequest): Observable<{ message: string }> {
const sessionId = this.authService.getSessionId();
if (!sessionId) {
throw new Error('Not authenticated');
}
return this.apiService.post<{ message: string }>(
'/fastcheck',
request,
sessionId
);
}
// MOCKED - Backend needs to implement
getBalance(): Observable<Balance> {
const sessionId = this.authService.getSessionId();
if (!sessionId) {
throw new Error('Not authenticated');
}
// TODO: Replace with real API call
// return this.apiService.get<Balance>('/balance', sessionId);
// MOCK DATA
return of({
balance: 150000,
currency: 'RUB'
});
}
// MOCKED - Backend needs to implement
getActiveFastChecks(): Observable<FastCheckListResponse> {
const sessionId = this.authService.getSessionId();
if (!sessionId) {
throw new Error('Not authenticated');
}
// TODO: Replace with real API call
// return this.apiService.get<FastCheckListResponse>('/fastcheck/active', sessionId);
// MOCK DATA
return of({
checks: [
{
fastcheck: '4568-1109-3402',
amount: 15000,
currency: 'RUB',
code: '5568',
expiration: '2026-01-26T09:08:18Z',
status: 'active',
createdAt: '2026-01-19T09:08:18Z'
},
{
fastcheck: '7890-2234-5566',
amount: 25000,
currency: 'RUB',
code: '1234',
expiration: '2026-01-26T10:15:30Z',
status: 'active',
createdAt: '2026-01-19T10:15:30Z'
}
]
});
}
// MOCKED - Backend needs to implement
getFastCheckHistory(): Observable<FastCheckListResponse> {
const sessionId = this.authService.getSessionId();
if (!sessionId) {
throw new Error('Not authenticated');
}
// TODO: Replace with real API call
// return this.apiService.get<FastCheckListResponse>('/fastcheck/history', sessionId);
// MOCK DATA
return of({
checks: [
{
fastcheck: '1234-5678-0003',
amount: 5000,
currency: 'RUB',
type: 'created',
createdAt: '2026-01-15T09:08:18Z',
usedAt: '2026-01-15T10:20:00Z',
status: 'used',
expiration: '2026-01-22T09:08:18Z'
},
{
fastcheck: '9876-5432-0100',
amount: 10000,
currency: 'RUB',
type: 'accepted',
acceptedAt: '2026-01-14T14:30:00Z',
status: 'used',
expiration: '2026-01-21T14:30:00Z'
}
]
});
}
}

View File

@@ -0,0 +1,57 @@
<footer class="site-footer">
<div class="site-footer__inner">
<!-- Brand + about -->
<div class="site-footer__col site-footer__col--brand">
<a class="site-footer__brand" href="/">
<!-- <img src="/logo_big.png" alt="fastCHECK" width="28" height="28" /> -->
<span class="site-footer__wordmark">
<span class="wm-fast">fast</span><span class="wm-check">CHECK</span>
</span>
</a>
<p class="site-footer__desc" id="about">{{ 'footer.desc' | translate }}</p>
</div>
<!-- Contacts -->
<div class="site-footer__col" id="contacts">
<h3 class="site-footer__heading">{{ 'footer.contacts_heading' | translate }}</h3>
<ul class="site-footer__list">
<li>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.07 10.5a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 16.92z"/></svg>
<a href="tel:+79299037443">+7 (929) 903-74-43</a> <span class="site-footer__note">{{ 'footer.russia' | translate }}</span>
</li>
<li>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.07 10.5a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 16.92z"/></svg>
<a href="tel:+37498632421">+374 98 632421</a> <span class="site-footer__note">{{ 'footer.armenia' | translate }}</span>
</li>
<li>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
<a href="mailto:info@viaexport.store">info@viaexport.store</a>
</li>
</ul>
<div class="site-footer__hours">
<p><strong>{{ 'footer.support_label' | translate }}:</strong> {{ 'footer.support_hours' | translate }}</p>
<p><strong>{{ 'footer.questions_label' | translate }}:</strong> {{ 'footer.questions_hours' | translate }}</p>
</div>
</div>
<!-- Legal -->
<div class="site-footer__col">
<h3 class="site-footer__heading">{{ 'footer.legal_heading' | translate }}</h3>
<ul class="site-footer__list site-footer__list--legal">
<li>{{ 'footer.legal_company' | translate }}</li>
<li>{{ 'footer.legal_inn_ru' | translate }}</li>
<li>{{ 'footer.legal_inn_am' | translate }}</li>
<li>{{ 'footer.legal_kpp' | translate }}</li>
<li>{{ 'footer.legal_ogrn' | translate }}</li>
<li class="site-footer__address">{{ 'footer.legal_address' | translate }}</li>
</ul>
</div>
</div>
<div class="site-footer__bottom">
<p>© {{ year }} {{ 'footer.rights' | translate }}</p>
<p>{{ 'footer.director' | translate }}</p>
</div>
</footer>

View File

@@ -0,0 +1,156 @@
:host { display: block; }
.site-footer {
background: #0f172a;
color: #94a3b8;
&__inner {
max-width: 1100px;
margin: 0 auto;
padding: 48px 24px 32px;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 40px;
@media (max-width: 860px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 560px) {
grid-template-columns: 1fr;
gap: 32px;
padding: 36px 20px 24px;
}
}
&__col {
&--brand {
@media (max-width: 860px) {
grid-column: 1 / -1;
}
}
}
&__brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
margin-bottom: 14px;
img {
width: 28px;
height: 28px;
object-fit: contain;
filter: brightness(0) invert(1);
opacity: 0.9;
}
}
&__wordmark {
font-size: 18px;
letter-spacing: -0.02em;
line-height: 1;
}
&__desc {
font-size: 13.5px;
line-height: 1.65;
color: #64748b;
max-width: 380px;
}
&__heading {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #e2e8f0;
margin-bottom: 16px;
}
&__list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 18px;
li {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
svg { flex-shrink: 0; opacity: 0.5; }
}
a {
color: #94a3b8;
text-decoration: none;
transition: color 0.15s;
&:hover { color: #e2e8f0; }
}
&--legal {
li {
display: block;
font-size: 12.5px;
color: #64748b;
gap: 0;
}
}
}
&__note {
font-size: 11px;
color: #475569;
margin-left: 4px;
}
&__hours {
font-size: 12.5px;
color: #64748b;
line-height: 1.7;
}
&__address {
color: #475569;
font-size: 12px !important;
line-height: 1.5;
margin-top: 4px;
}
&__bottom {
border-top: 1px solid #1e293b;
max-width: 1100px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
justify-content: space-between;
font-size: 12px;
color: #475569;
@media (max-width: 560px) {
flex-direction: column;
padding: 14px 20px;
}
}
}
.wm-fast {
font-weight: 400;
font-size: 0.72em;
color: #64748b;
margin-right: 0.04em;
}
.wm-check {
font-weight: 700;
font-size: 1em;
color: #93c5fd;
text-transform: uppercase;
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { TranslatePipe } from '../translate/translate.pipe';
@Component({
selector: 'app-site-footer',
imports: [TranslatePipe],
templateUrl: './site-footer.html',
styleUrl: './site-footer.scss'
})
export class SiteFooter {
year = new Date().getFullYear();
}

View File

@@ -0,0 +1,98 @@
<header class="site-header">
<div class="site-header__inner">
<!-- Brand -->
<a class="site-header__brand" routerLink="/" (click)="closeMenu()">
<img src="/logo_small.png" alt="fastCHECK" width="32" height="32" />
<span class="site-header__wordmark">
<span class="wm-fast">fast</span><span class="wm-check">CHECK</span>
</span>
</a>
<!-- Desktop nav -->
<nav class="site-header__nav" [attr.aria-label]="'header.aria_nav' | translate">
<a class="site-header__link" routerLink="/about">{{ 'header.nav_about' | translate }}</a>
<a class="site-header__link" routerLink="/partners">{{ 'header.nav_partners' | translate }}</a>
<a class="site-header__link" routerLink="/contacts">{{ 'header.nav_contacts' | translate }}</a>
<a class="site-header__link" href="mailto:info@viaexport.store">{{ 'header.nav_support' | translate }}</a>
</nav>
<!-- Language dropdown -->
<div class="lang-select" [class.lang-select--open]="langOpen()">
<button type="button" class="lang-select__trigger" (click)="toggleLang()">
<img class="lang-select__flag" [src]="activeLang.flag" [alt]="activeLang.label" width="20" height="20" />
<span class="lang-select__code">{{ activeLang.code | uppercase }}</span>
<svg class="lang-select__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
@if (langOpen()) {
<div class="lang-select__dropdown">
@for (lang of langs; track lang.code) {
<button type="button" class="lang-select__option"
[class.lang-select__option--active]="currentLang() === lang.code"
(click)="setLang(lang.code)">
<img class="lang-select__flag" [src]="lang.flag" [alt]="lang.label" width="20" height="20" />
<span class="lang-select__name">{{ lang.label }}</span>
@if (currentLang() === lang.code) {
<svg class="lang-select__check" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M20 6L9 17l-5-5"/>
</svg>
}
</button>
}
</div>
}
</div>
<!-- Mobile hamburger -->
<button class="site-header__burger" type="button"
[attr.aria-expanded]="menuOpen()"
[attr.aria-label]="'header.aria_burger' | translate"
(click)="toggleMenu()">
@if (menuOpen()) {
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
} @else {
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round">
<line x1="3" y1="7" x2="21" y2="7"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="17" x2="21" y2="17"/>
</svg>
}
</button>
</div>
<!-- Mobile overlay + drawer -->
@if (menuOpen()) {
<div class="mobile-overlay" (click)="closeMenu()">
<nav class="mobile-panel" (click)="$event.stopPropagation()" [attr.aria-label]="'header.aria_menu' | translate">
<div class="mobile-panel__header">
<span class="mobile-panel__title">fastCHECK</span>
<button type="button" class="mobile-panel__close" (click)="closeMenu()" [attr.aria-label]="'header.aria_close' | translate">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<a class="mobile-panel__link" routerLink="/about" (click)="closeMenu()">{{ 'header.nav_about' | translate }}</a>
<a class="mobile-panel__link" routerLink="/partners" (click)="closeMenu()">{{ 'header.nav_partners' | translate }}</a>
<a class="mobile-panel__link" routerLink="/contacts" (click)="closeMenu()">{{ 'header.nav_contacts' | translate }}</a>
<a class="mobile-panel__link" href="mailto:info@viaexport.store" (click)="closeMenu()">{{ 'header.nav_support' | translate }}</a>
<div class="mobile-panel__langs">
@for (lang of langs; track lang.code) {
<button type="button" class="site-header__lang"
[class.site-header__lang--active]="currentLang() === lang.code"
(click)="setLang(lang.code); closeMenu()">
<img [src]="lang.flag" [alt]="lang.label" width="20" height="20" />
<span>{{ lang.code | uppercase }}</span>
</button>
}
</div>
</nav>
</div>
}
</header>

View File

@@ -0,0 +1,324 @@
:host {
display: block;
position: sticky;
top: 0;
z-index: 900;
}
.site-header {
background: #fff;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.06);
&__inner {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
gap: 32px;
@media (max-width: 600px) {
padding: 0 16px;
}
}
&__brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__wordmark {
font-size: 18px;
letter-spacing: -0.02em;
white-space: nowrap;
line-height: 1;
}
&__nav {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
@media (max-width: 600px) {
display: none;
}
}
&__link {
padding: 8px 14px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: #475569;
text-decoration: none;
transition: background 0.15s, color 0.15s;
&:hover {
background: #f1f5f9;
color: #0f172a;
}
}
&__lang {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 6px;
border: none;
background: transparent;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: #94a3b8;
cursor: pointer;
transition: background 0.15s, color 0.15s;
font-family: inherit;
&:hover { background: #f1f5f9; color: #475569; }
&--active {
background: #eff6ff;
color: #1e40af;
}
}
&__mobile-langs {
display: flex;
gap: 4px;
padding: 8px 14px 4px;
border-top: 1px solid #f1f5f9;
margin-top: 4px;
}
&__burger {
display: none;
margin-left: auto;
width: 40px;
height: 40px;
border-radius: 8px;
border: none;
background: transparent;
color: #475569;
cursor: pointer;
align-items: center;
justify-content: center;
transition: background 0.15s;
-webkit-appearance: none;
font-family: inherit;
&:hover { background: #f1f5f9; }
@media (max-width: 600px) {
display: inline-flex;
}
}
&__mobile-menu { display: none; } // replaced by .mobile-overlay / .mobile-panel
&__mobile-link { display: none; }
}
// Wordmark colours
.wm-fast {
font-weight: 400;
font-size: 0.72em;
color: #64748b;
margin-right: 0.04em;
}
.wm-check {
font-weight: 700;
font-size: 1em;
color: #1e40af;
text-transform: uppercase;
letter-spacing: 0.03em;
}
// Language dropdown
.lang-select {
position: relative;
flex-shrink: 0;
@media (max-width: 600px) {
display: none;
}
&__trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #334155;
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
&:hover { background: #f8fafc; border-color: #cbd5e1; }
}
&--open &__trigger {
background: #f8fafc;
border-color: #94a3b8;
}
&__flag { width: 20px; height: 20px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
&__code { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; }
&__chevron {
color: #94a3b8;
transition: transform 0.2s;
}
&--open &__chevron { transform: rotate(180deg); }
&__dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 160px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 1000;
animation: dropdown-in 0.12s ease;
}
&__option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 14px;
border: none;
background: transparent;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: #334155;
cursor: pointer;
text-align: left;
transition: background 0.12s;
&:hover { background: #f8fafc; }
&--active { color: #1e40af; background: #eff6ff; }
}
&__name { flex: 1; }
&__check { color: #1e40af; margin-left: auto; flex-shrink: 0; }
}
@keyframes dropdown-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
// ── Mobile overlay + drawer ──────────────────────────────────────
.mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 998;
animation: overlay-in 0.2s ease;
}
.mobile-panel {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: min(300px, 85vw);
background: #fff;
z-index: 999;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
animation: panel-in 0.22s cubic-bezier(0.4, 0, 0.2, 1);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 16px;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
&__title {
font-size: 16px;
font-weight: 700;
color: #1e40af;
letter-spacing: 0.02em;
}
&__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background 0.15s, color 0.15s;
font-family: inherit;
&:hover { background: #f1f5f9; color: #0f172a; }
}
&__link {
display: block;
padding: 14px 20px;
font-size: 15px;
font-weight: 500;
color: #0f172a;
text-decoration: none;
transition: background 0.12s;
border-radius: 0;
&:hover { background: #f8fafc; }
}
&__langs {
display: flex;
gap: 6px;
padding: 12px 20px 16px;
border-top: 1px solid #f1f5f9;
margin-top: auto;
}
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes panel-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}

View File

@@ -0,0 +1,48 @@
import { UpperCasePipe } from '@angular/common';
import { Component, HostListener, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '../translate/translate.pipe';
import { TranslationService, Lang } from '../translate/translation.service';
interface LangOption { code: Lang; label: string; flag: string; }
@Component({
selector: 'app-site-header',
imports: [RouterLink, TranslatePipe, UpperCasePipe],
templateUrl: './site-header.html',
styleUrl: './site-header.scss'
})
export class SiteHeader {
private i18n = inject(TranslationService);
menuOpen = signal(false);
langOpen = signal(false);
currentLang = this.i18n.currentLang;
langs: LangOption[] = [
{ code: 'ru', label: 'Русский', flag: '/flags/ru.svg' },
{ code: 'en', label: 'English', flag: '/flags/en.svg' },
{ code: 'hy', label: 'Հայերեն', flag: '/flags/arm.svg' },
];
get activeLang(): LangOption {
return this.langs.find(l => l.code === this.currentLang()) ?? this.langs[0];
}
toggleMenu(): void { this.menuOpen.update(v => !v); }
closeMenu(): void { this.menuOpen.set(false); }
toggleLang(): void { this.langOpen.update(v => !v); }
closeLang(): void { this.langOpen.set(false); }
setLang(lang: Lang): void {
this.i18n.setLanguage(lang);
this.langOpen.set(false);
}
@HostListener('document:click', ['$event.target'])
onDocClick(target: EventTarget | null): void {
if (!(target instanceof HTMLElement) || !target.closest('.lang-select')) {
this.langOpen.set(false);
}
}
}

View File

@@ -0,0 +1,11 @@
import { Pipe, PipeTransform, inject } from '@angular/core';
import { TranslationService } from './translation.service';
@Pipe({ name: 'translate', pure: false, standalone: true })
export class TranslatePipe implements PipeTransform {
private svc = inject(TranslationService);
transform(key: string): string {
return this.svc.translate(key);
}
}

View File

@@ -0,0 +1,36 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export type Lang = 'ru' | 'en' | 'hy';
type Translations = Record<string, Record<string, string>>;
@Injectable({ providedIn: 'root' })
export class TranslationService {
private http = inject(HttpClient);
currentLang = signal<Lang>('ru');
private translations = signal<Translations>({});
constructor() {
this.load('ru');
}
setLanguage(lang: Lang): void {
this.currentLang.set(lang);
this.load(lang);
}
private load(lang: Lang): void {
this.http.get<Translations>(`/i18n/${lang}.json`).subscribe({
next: data => this.translations.set(data),
});
}
translate(key: string): string {
const dot = key.indexOf('.');
if (dot === -1) return key;
const section = key.slice(0, dot);
const k = key.slice(dot + 1);
return this.translations()[section]?.[k] ?? key;
}
}

View File

@@ -1,4 +0,0 @@
export const environment = {
production: false,
apiUrl: 'https://api.fastcheck.store'
};

View File

@@ -1,11 +1,16 @@
<!doctype html>
<html lang="en">
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>FastCheck</title>
<title>fastCHECK</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="icon" type="image/png" href="logo_small.png">
<link rel="apple-touch-icon" href="logo_small.png">
</head>
<body>
<app-root></app-root>

View File

@@ -1,4 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';

262
src/shared.scss Normal file
View File

@@ -0,0 +1,262 @@
// Shared page-level styles for the Fastcheck and Create pages.
// Imported via @use './../../../shared' as *;
.page {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
padding-top: max(16px, env(safe-area-inset-top));
padding-bottom: max(16px, env(safe-area-inset-bottom));
background: linear-gradient(135deg, #1e40af 0%, #2563eb 40%, #0ea5e9 100%);
@media (max-width: 480px) {
align-items: stretch;
padding: 0;
}
}
.card {
background: #ffffff;
border-radius: 24px;
width: 100%;
max-width: 440px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
overflow: hidden;
@media (max-width: 480px) {
border-radius: 0;
max-width: 100%;
box-shadow: none;
flex: 1;
display: flex;
flex-direction: column;
}
&__header {
background: #ffffff;
padding: 28px 24px 20px;
text-align: center;
border-bottom: 1px solid #e2e8f0;
@media (max-width: 480px) {
padding-top: calc(28px + env(safe-area-inset-top));
}
}
&__title {
color: #0f172a;
font-size: 22px;
font-weight: 700;
margin: 0 0 4px;
letter-spacing: -0.3px;
}
&__subtitle {
color: #64748b;
font-size: 13px;
margin: 0;
}
&__brand {
display: block;
margin: 0 auto 10px;
max-width: 220px;
height: auto;
object-fit: contain;
@media (max-width: 480px) {
max-width: 200px;
}
}
&__body {
padding: 24px 22px 18px;
@media (max-width: 480px) {
padding: 22px 18px 16px;
flex: 1;
}
@media (max-width: 360px) {
padding: 18px 14px 12px;
}
}
&__footer {
padding: 0 24px 22px;
display: flex;
justify-content: center;
@media (max-width: 480px) {
padding: 0 18px calc(22px + env(safe-area-inset-bottom));
}
}
}
.field {
margin-bottom: 16px;
&__label {
display: block;
font-size: 12px;
font-weight: 700;
color: #64748b;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
&__error {
display: block;
margin-top: 6px;
font-size: 13px;
color: #ef4444;
font-weight: 500;
}
&__hint {
display: block;
margin-top: 6px;
font-size: 13px;
color: #64748b;
font-weight: 500;
}
}
.input-wrap {
display: flex;
align-items: center;
border: 2px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
&:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
background: #fff;
}
&--error {
border-color: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.1);
}
&__prefix {
padding: 0 4px 0 18px;
font-size: 24px;
font-weight: 700;
color: #2563eb;
user-select: none;
line-height: 1;
}
&__input {
flex: 1;
border: none;
background: transparent;
padding: 14px 14px 14px 8px;
font-size: 28px;
font-weight: 700;
color: #0f172a;
outline: none;
min-width: 0;
font-family: inherit;
appearance: textfield;
-moz-appearance: textfield;
&::placeholder { color: #cbd5e1; }
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
@media (max-width: 480px) {
font-size: 26px;
padding: 12px 12px 12px 6px;
}
}
}
.pay-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px 24px;
min-height: 52px;
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
color: #fff;
border: none;
border-radius: 14px;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.2px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.38);
font-family: inherit;
appearance: none;
-webkit-appearance: none;
&:hover { opacity: 0.92; box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45); }
&:active { transform: scale(0.98); opacity: 0.88; }
&:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
&__icon { display: flex; align-items: center; }
}
.secure-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #94a3b8;
font-weight: 500;
svg { flex-shrink: 0; }
}
// ─── Brand wordmark: "fastCHECK" inline ─────────────────────────────────────
// "fast" sits a bit smaller and lighter than "CHECK".
.brand {
display: inline-flex;
align-items: baseline;
font-weight: inherit;
letter-spacing: -0.02em;
white-space: nowrap;
font-size: calc(1em + 3px);
&__fast {
font-size: 0.72em;
font-weight: 400;
text-transform: lowercase;
margin-right: 0.05em;
opacity: 0.85;
}
&__check {
font-size: 1em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
}
}
// Standalone logo image (used inside modal/header)
.brand-logo {
display: block;
height: auto;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
&--small {
max-height: 32px;
margin: 0 auto 8px;
}
}

View File

@@ -1,21 +1,37 @@
/* You can add global styles to this file, and also import other style files */
* {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
html {
// Prevent iOS rubber-band overscroll showing white background
background: #1e40af;
// Prevent iOS auto-zoom on form fields with small text
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #1e40af;
// Avoid iOS overscroll bounce leaking other pages on PWA
overscroll-behavior-y: none;
}
// Disable long-press image saving / callout on payment-method logos
img {
-webkit-touch-callout: none;
user-select: none;
}
// Inputs: ensure ≥16px font-size to prevent iOS Safari from auto-zooming on focus
input, textarea, select, button {
font-size: 16px;
}

View File

@@ -7,8 +7,7 @@
"types": []
},
"include": [
"src/**/*.ts",
"src/**/*.html"
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"