rebasing
4
.gitignore
vendored
@@ -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
|
||||
|
||||
4
.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
.vscode/launch.json
vendored
@@ -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
@@ -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
@@ -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 сессии**: рекомендуем 5–10 минут. По истечении `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-код.
|
||||
- [ ] Сессии экспайрятся (5–10 мин для 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
@@ -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 на разные пути
|
||||
@@ -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
@@ -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`).
|
||||
@@ -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
@@ -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.
|
||||
|
||||
15
angular.json
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
142
public/Fastcheck_API (1).txt
Normal 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
@@ -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 partner’s 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
@@ -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
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
1
public/flags/arm.svg
Normal 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
@@ -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
@@ -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
@@ -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:00–19: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
@@ -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:00–19: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
@@ -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:00–19: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
|
After Width: | Height: | Size: 375 KiB |
BIN
public/logo_small.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
1
public/mastercard.svg
Normal 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 |
@@ -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
@@ -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
|
After Width: | Height: | Size: 5.7 KiB |
21
src/app/api.ts
Normal 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';
|
||||
@@ -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()
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
<router-outlet></router-outlet>
|
||||
<app-site-header />
|
||||
<main class="app-main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
<app-site-footer />
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
component: LoginComponent,
|
||||
canActivate: [loginGuard]
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: DashboardComponent,
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{
|
||||
path: 'active-checks',
|
||||
component: ActiveChecksComponent,
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
component: HistoryComponent,
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: '/login'
|
||||
loadComponent: () => {
|
||||
// Branch: ?id=<orderId> means legacy SBP merchant flow.
|
||||
const hasLegacyId = typeof window !== 'undefined'
|
||||
&& new URLSearchParams(window.location.search).has('id');
|
||||
return hasLegacyId
|
||||
? import('./pages/legacy-pay-page/legacy-pay-page').then((m) => m.LegacyPayPage)
|
||||
: import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage);
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
loadComponent: () =>
|
||||
import('./pages/create-page/create-page').then((m) => m.CreatePage)
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () =>
|
||||
import('./pages/about-page/about-page').then((m) => m.AboutPage)
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
loadComponent: () =>
|
||||
import('./pages/contacts-page/contacts-page').then((m) => m.ContactsPage)
|
||||
},
|
||||
{
|
||||
path: 'partners',
|
||||
loadComponent: () =>
|
||||
import('./pages/partners-page/partners-page').then((m) => m.PartnersPage)
|
||||
},
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
28
src/app/fastcheck.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PingResponse {
|
||||
message: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
40
src/app/pages/about-page/about-page.html
Normal 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>
|
||||
75
src/app/pages/about-page/about-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/app/pages/about-page/about-page.ts
Normal 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 {}
|
||||
66
src/app/pages/contacts-page/contacts-page.html
Normal 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:00–19:00 МСК</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
146
src/app/pages/contacts-page/contacts-page.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
10
src/app/pages/contacts-page/contacts-page.ts
Normal 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 {}
|
||||
158
src/app/pages/create-page/create-page.html
Normal 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 }}
|
||||
<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 }}
|
||||
<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>
|
||||
266
src/app/pages/create-page/create-page.scss
Normal 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; }
|
||||
}
|
||||
274
src/app/pages/create-page/create-page.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/app/pages/fastcheck-page/fastcheck-page.html
Normal 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>
|
||||
}
|
||||
260
src/app/pages/fastcheck-page/fastcheck-page.scss
Normal 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; }
|
||||
}
|
||||
293
src/app/pages/fastcheck-page/fastcheck-page.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
93
src/app/pages/legacy-pay-page/legacy-pay-page.html
Normal 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>
|
||||
81
src/app/pages/legacy-pay-page/legacy-pay-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
107
src/app/pages/legacy-pay-page/legacy-pay-page.ts
Normal 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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
26
src/app/pages/partners-page/partners-page.html
Normal 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>
|
||||
146
src/app/pages/partners-page/partners-page.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
26
src/app/pages/partners-page/partners-page.ts
Normal 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' },
|
||||
];
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
57
src/app/site-footer/site-footer.html
Normal 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>
|
||||
156
src/app/site-footer/site-footer.scss
Normal 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;
|
||||
}
|
||||
12
src/app/site-footer/site-footer.ts
Normal 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();
|
||||
}
|
||||
98
src/app/site-header/site-header.html
Normal 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>
|
||||
324
src/app/site-header/site-header.scss
Normal 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); }
|
||||
}
|
||||
48
src/app/site-header/site-header.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/app/translate/translate.pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
src/app/translate/translation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'https://api.fastcheck.store'
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
|
||||