changes
This commit is contained in:
214
BACKEND.md
Normal file
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** — сколько живёт?
|
||||||
153
DEPLOY.md
Normal file
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`).
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "10kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "16kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
29
payment.html
29
payment.html
@@ -124,6 +124,21 @@
|
|||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
font-size: 12px; color: #94a3b8; font-weight: 500;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -166,6 +181,17 @@
|
|||||||
<span class="currency-badge__name">Российский рубль</span>
|
<span class="currency-badge__name">Российский рубль</span>
|
||||||
</div>
|
</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()">
|
<button class="pay-btn" id="payBtn" onclick="goToPayment()">
|
||||||
<span class="pay-btn__icon">
|
<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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -194,6 +220,7 @@
|
|||||||
const API_URL = 'https://qr.vitanova.network:567/qr';
|
const API_URL = 'https://qr.vitanova.network:567/qr';
|
||||||
|
|
||||||
const amountInput = document.getElementById('amount');
|
const amountInput = document.getElementById('amount');
|
||||||
|
const noteInput = document.getElementById('note');
|
||||||
const errorEl = document.getElementById('error');
|
const errorEl = document.getElementById('error');
|
||||||
const payBtn = document.getElementById('payBtn');
|
const payBtn = document.getElementById('payBtn');
|
||||||
const btnText = document.getElementById('btnText');
|
const btnText = document.getElementById('btnText');
|
||||||
@@ -241,7 +268,7 @@
|
|||||||
fetch(API_URL, {
|
fetch(API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ payment: 'sbp', amount, currency: 'rub', id })
|
body: JSON.stringify({ payment: 'sbp', amount, currency: 'rub', id, note: noteInput.value.trim() })
|
||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
|||||||
1
public/mastercard.svg
Normal file
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
public/visa.svg
Normal file
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
1
public/wechat-pay.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.7 KiB |
8
src/app/api.ts
Normal file
8
src/app/api.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Endpoint constants for the Fastcheck backend (see public/api.txt).
|
||||||
|
* Centralised so they can be swapped in one place.
|
||||||
|
*/
|
||||||
|
export const FASTCHECK_API = 'https://api.fastcheck.store';
|
||||||
|
|
||||||
|
// Legacy QR endpoint kept for the SBP amount → payload redirect flow.
|
||||||
|
export const QR_API = 'https://qr.vitanova.network:567/qr';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1 @@
|
|||||||
<div class="page">
|
<router-outlet />
|
||||||
<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" [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">Российский рубль</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="pay-btn" (click)="goToPayment()" [disabled]="loading()">
|
|
||||||
<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>
|
|
||||||
{{ loading() ? 'Подождите...' : 'Перейти к оплате' }}
|
|
||||||
</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>
|
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
export const routes: Routes = [];
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/create-page/create-page').then((m) => m.CreatePage)
|
||||||
|
},
|
||||||
|
{ path: '**', redirectTo: '' }
|
||||||
|
];
|
||||||
|
|||||||
270
src/app/app.scss
270
src/app/app.scss
@@ -1,270 +1,2 @@
|
|||||||
// ─── Page shell ─────────────────────────────────────────────────────────────
|
:host { display: block; min-height: 100dvh; }
|
||||||
|
|
||||||
.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) {
|
|
||||||
align-items: flex-end;
|
|
||||||
padding: 0;
|
|
||||||
min-height: 50dvh;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Card ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 24px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
border-radius: 24px 24px 0 0;
|
|
||||||
max-width: 100%;
|
|
||||||
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
|
|
||||||
padding: 32px 28px 28px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
padding: 28px 24px 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 14px 0 4px;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__subtitle {
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-size: 13px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__body {
|
|
||||||
padding: 28px 28px 20px;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
padding: 24px 20px 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer {
|
|
||||||
padding: 0 28px 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
padding: 0 20px 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Logo ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.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);
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 40px;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
height: 34px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Field ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Amount input ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.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: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
border-color: #ef4444;
|
|
||||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__prefix {
|
|
||||||
padding: 0 4px 0 18px;
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #2563eb;
|
|
||||||
user-select: none;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__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;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove number spinners
|
|
||||||
&::-webkit-outer-spin-button,
|
|
||||||
&::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
appearance: textfield;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
font-size: 28px;
|
|
||||||
padding: 14px 14px 14px 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Currency badge ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.currency-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
&__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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pay button ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.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: #ffffff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: 17px;
|
|
||||||
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;
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
padding: 16px 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.92;
|
|
||||||
box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Secure badge ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.secure-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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';
|
import { App } from './app';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
|
providers: [provideRouter([])]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -13,11 +15,4 @@ describe('App', () => {
|
|||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
expect(app).toBeTruthy();
|
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, qr_vitanova');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,65 +1,11 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
|
|
||||||
const API_URL = 'https://qr.vitanova.network:567/qr';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [FormsModule],
|
imports: [RouterOutlet],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {}
|
||||||
private http = inject(HttpClient);
|
|
||||||
|
|
||||||
amount = signal<number>(10);
|
|
||||||
error = signal<string>('');
|
|
||||||
loading = signal<boolean>(false);
|
|
||||||
|
|
||||||
private get paymentId(): string | null {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
return params.get('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
goToPayment(): void {
|
|
||||||
const val = this.amount();
|
|
||||||
if (!val || val <= 0) {
|
|
||||||
this.error.set('Введите корректную сумму');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = this.paymentId;
|
|
||||||
if (id === null) {
|
|
||||||
this.error.set('Не указан идентификатор платежа (параметр id)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.error.set('');
|
|
||||||
this.loading.set(true);
|
|
||||||
|
|
||||||
this.http.post<{ qrId: string; qrStatus: string; qrExpirationDate: string; payload: string; qrUrl: string }>(API_URL, {
|
|
||||||
payment: 'sbp',
|
|
||||||
amount: val,
|
|
||||||
currency: 'rub',
|
|
||||||
id
|
|
||||||
}).subscribe({
|
|
||||||
next: (res) => {
|
|
||||||
this.loading.set(false);
|
|
||||||
if (res?.payload) {
|
|
||||||
window.location.href = res.payload;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.loading.set(false);
|
|
||||||
this.error.set('Ошибка при создании платежа. Попробуйте ещё раз.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onAmountChange(value: number): void {
|
|
||||||
this.amount.set(value);
|
|
||||||
if (value > 0) this.error.set('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
28
src/app/fastcheck.service.ts
Normal file
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/app/pages/create-page/create-page.html
Normal file
128
src/app/pages/create-page/create-page.html
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<div class="card__header">
|
||||||
|
<a class="back" routerLink="/" aria-label="Назад">
|
||||||
|
<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">Новый Фастчек</h1>
|
||||||
|
<p class="card__subtitle">Укажите сумму для пополнения</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card__body">
|
||||||
|
|
||||||
|
<!-- Payment methods -->
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Способ оплаты</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="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">Валюта</span>
|
||||||
|
<div class="currencies">
|
||||||
|
<button type="button" class="chip" [class.chip--active]="currency() === 'RUB'"
|
||||||
|
(click)="selectCurrency('RUB', true)">
|
||||||
|
<!-- <span class="chip__flag">🇷🇺</span> -->
|
||||||
|
<span class="chip__sign">₽</span>
|
||||||
|
<span class="chip__code">RUB</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="chip chip--disabled" disabled>
|
||||||
|
<!-- <span class="chip__flag">🇨🇳</span> -->
|
||||||
|
<span class="chip__sign">¥</span>
|
||||||
|
<span class="chip__code">CNY</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="chip chip--disabled" disabled>
|
||||||
|
<!-- <span class="chip__flag">🇺🇸</span> -->
|
||||||
|
<span class="chip__sign">$</span>
|
||||||
|
<span class="chip__code">USD</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="chip chip--disabled" disabled>
|
||||||
|
<!-- <span class="chip__flag">🇪🇺</span> -->
|
||||||
|
<span class="chip__sign">€</span>
|
||||||
|
<span class="chip__code">EUR</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="chip chip--disabled" disabled>
|
||||||
|
<!-- <span class="chip__flag">🇦🇲</span> -->
|
||||||
|
<span class="chip__sign">֏</span>
|
||||||
|
<span class="chip__code">AMD</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="amount">Сумма платежа</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="field">
|
||||||
|
<label class="field__label" for="note">Примечание</label>
|
||||||
|
<textarea
|
||||||
|
id="note"
|
||||||
|
class="note-input"
|
||||||
|
[ngModel]="note()"
|
||||||
|
(ngModelChange)="onNoteChange($event)"
|
||||||
|
placeholder="Причина платежа..."
|
||||||
|
rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="pay-btn" type="button" (click)="createCheck()" [disabled]="loading()">
|
||||||
|
<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>
|
||||||
|
{{ loading() ? 'Создание…' : 'Создать Фастчек' }}
|
||||||
|
</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>
|
||||||
163
src/app/pages/create-page/create-page.scss
Normal file
163
src/app/pages/create-page/create-page.scss
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
@use './../../../shared' as *;
|
||||||
|
|
||||||
|
.card__header {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 18px;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&:hover { background: rgba(255, 255, 255, 0.28); }
|
||||||
|
&:active { background: rgba(255, 255, 255, 0.36); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&__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: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, background .15s, color .15s;
|
||||||
|
|
||||||
|
&__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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/app/pages/create-page/create-page.ts
Normal file
103
src/app/pages/create-page/create-page.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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 } from '../../api';
|
||||||
|
|
||||||
|
interface CreateFastcheckResponse {
|
||||||
|
fastcheck: string;
|
||||||
|
expiration: string;
|
||||||
|
code: string;
|
||||||
|
Status: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaymentMethod = 'sbp' | 'wechat' | 'visa' | 'master';
|
||||||
|
type Currency = 'RUB' | 'CNY' | 'USD' | 'EUR' | 'AMD';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-create-page',
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
templateUrl: './create-page.html',
|
||||||
|
styleUrl: './create-page.scss'
|
||||||
|
})
|
||||||
|
export class CreatePage {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private store = inject(FastcheckService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
amount = signal<number>(10);
|
||||||
|
note = signal<string>('');
|
||||||
|
error = signal<string>('');
|
||||||
|
loading = signal<boolean>(false);
|
||||||
|
|
||||||
|
payment = signal<PaymentMethod>('sbp');
|
||||||
|
currency = signal<Currency>('RUB');
|
||||||
|
|
||||||
|
/** sessionID for the Authorization header. Comes from ?session=... or websession. */
|
||||||
|
private get sessionId(): string {
|
||||||
|
return new URLSearchParams(window.location.search).get('session') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCheck(): void {
|
||||||
|
const val = this.amount();
|
||||||
|
if (!val || val <= 0) {
|
||||||
|
this.error.set('Введите корректную сумму');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set('');
|
||||||
|
this.loading.set(true);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (this.sessionId) {
|
||||||
|
headers['Authorization'] = JSON.stringify({ sessionID: this.sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.http
|
||||||
|
.post<CreateFastcheckResponse>(
|
||||||
|
`${FASTCHECK_API}/fastcheck`,
|
||||||
|
{ amount: val, currency: this.currency() },
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
if (res?.fastcheck) {
|
||||||
|
this.store.setCreated({
|
||||||
|
fastcheck: res.fastcheck,
|
||||||
|
code: res.code,
|
||||||
|
amount: val,
|
||||||
|
expiration: res.expiration
|
||||||
|
});
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
} else {
|
||||||
|
this.error.set('Не удалось создать Фастчек.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set('Ошибка при создании Фастчека. Попробуйте ещё раз.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onAmountChange(value: number): void {
|
||||||
|
this.amount.set(value);
|
||||||
|
if (value > 0) this.error.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onNoteChange(value: string): void {
|
||||||
|
this.note.set(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/app/pages/fastcheck-page/fastcheck-page.html
Normal file
139
src/app/pages/fastcheck-page/fastcheck-page.html
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<div class="card__header">
|
||||||
|
<h1 class="card__title">Оплата Фастчеком</h1>
|
||||||
|
<p class="card__subtitle">Введите данные Фастчека или создайте новый</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card__body">
|
||||||
|
|
||||||
|
<!-- Fastcheck number + new -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="fcNumber">Номер Фастчека</label>
|
||||||
|
<div class="row">
|
||||||
|
<input
|
||||||
|
id="fcNumber"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
[ngModel]="fastcheckNumber()"
|
||||||
|
(ngModelChange)="fastcheckNumber.set($event)"
|
||||||
|
placeholder="1234-5678-0001"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый Фастчек">Новый</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Amount -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="fcAmount">Сумма</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="fcCode">Код</label>
|
||||||
|
<input
|
||||||
|
id="fcCode"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
[ngModel]="fastcheckCode()"
|
||||||
|
(ngModelChange)="fastcheckCode.set($event)"
|
||||||
|
placeholder="0000"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="8"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
/>
|
||||||
|
@if (error()) {
|
||||||
|
<span class="field__error">{{ error() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="pay-btn" type="button" (click)="pay()">
|
||||||
|
<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>
|
||||||
|
Оплатить
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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">Оплачено</h2>
|
||||||
|
<p class="modal__sub">Фастчек успешно принят.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<h2 class="modal__title">Войти через Telegram</h2>
|
||||||
|
<p class="modal__sub">Отсканируйте QR или откройте ссылку</p>
|
||||||
|
|
||||||
|
<div class="qr">
|
||||||
|
@if (popupLoading() && !webSessionId()) {
|
||||||
|
<div class="qr__placeholder">Загрузка…</div>
|
||||||
|
} @else if (webSessionId()) {
|
||||||
|
<img [src]="qrUrl()" width="240" height="240" alt="QR Telegram" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (webSessionId()) {
|
||||||
|
<a class="tg-link" [href]="telegramLink()" target="_blank" rel="noopener">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9.04 15.65l-.36 4.06c.51 0 .73-.22.99-.48l2.38-2.27 4.93 3.6c.9.5 1.55.24 1.79-.83l3.24-15.18h.01c.29-1.34-.48-1.86-1.36-1.54L1.13 9.66c-1.32.5-1.3 1.23-.22 1.56l4.92 1.53L17.27 5.6c.54-.34 1.03-.15.62.19" />
|
||||||
|
</svg>
|
||||||
|
Открыть в Telegram
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (popupLoading() && webSessionId()) {
|
||||||
|
<p class="modal__hint">Подтверждение оплаты…</p>
|
||||||
|
} @else if (webSessionId()) {
|
||||||
|
<p class="modal__hint">Ожидание входа…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (popupError()) {
|
||||||
|
<p class="modal__error">{{ popupError() }}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
204
src/app/pages/fastcheck-page/fastcheck-page.scss
Normal file
204
src/app/pages/fastcheck-page/fastcheck-page.scss
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
@use './../../../shared' as *;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
.input { flex: 1; min-width: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 48px;
|
||||||
|
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;
|
||||||
|
|
||||||
|
&--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;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
align-items: flex-end;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 24px 24px 0 0;
|
||||||
|
padding: 24px 20px 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 12px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background .15s;
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #229ED9;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity .15s;
|
||||||
|
|
||||||
|
&:hover { opacity: .9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
171
src/app/pages/fastcheck-page/fastcheck-page.ts
Normal file
171
src/app/pages/fastcheck-page/fastcheck-page.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface WebSessionResponse {
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
expires: string;
|
||||||
|
userSessionId: string;
|
||||||
|
Status: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-fastcheck-page',
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
templateUrl: './fastcheck-page.html',
|
||||||
|
styleUrl: './fastcheck-page.scss'
|
||||||
|
})
|
||||||
|
export class FastcheckPage {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private store = inject(FastcheckService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
// 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>('');
|
||||||
|
error = signal<string>('');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
telegramLink = computed(() => {
|
||||||
|
const sid = this.webSessionId();
|
||||||
|
return sid
|
||||||
|
? `https://t.me/${this.telegramBot}?start=${encodeURIComponent(sid)}`
|
||||||
|
: `https://t.me/${this.telegramBot}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
qrUrl = computed(() => {
|
||||||
|
const link = this.telegramLink();
|
||||||
|
return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=${encodeURIComponent(link)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Pull autofill data left over by the create page.
|
||||||
|
const created = this.store.consume();
|
||||||
|
if (created) {
|
||||||
|
this.fastcheckNumber.set(created.fastcheck);
|
||||||
|
this.fastcheckAmount.set(created.amount);
|
||||||
|
this.fastcheckCode.set(created.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pay(): void {
|
||||||
|
if (!this.fastcheckNumber().trim()) {
|
||||||
|
this.error.set('Введите номер Фастчека');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.fastcheckCode().trim()) {
|
||||||
|
this.error.set('Введите код Фастчека');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.error.set('');
|
||||||
|
this.openPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openPopup(): void {
|
||||||
|
this.popupOpen.set(true);
|
||||||
|
this.popupError.set('');
|
||||||
|
this.paid.set(false);
|
||||||
|
this.popupLoading.set(true);
|
||||||
|
|
||||||
|
this.http.get<WebSessionResponse>(`${FASTCHECK_API}/websession`).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.popupLoading.set(false);
|
||||||
|
this.webSessionId.set(res.sessionId);
|
||||||
|
this.startPolling(res.sessionId);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.popupLoading.set(false);
|
||||||
|
this.popupError.set('Не удалось создать сессию. Попробуйте ещё раз.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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-and-forget merchant callback if a return_url is on the page.
|
||||||
|
this.fireMerchantCallback();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.popupLoading.set(false);
|
||||||
|
this.popupError.set('Не удалось принять Фастчек.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
|||||||
184
src/shared.scss
Normal file
184
src/shared.scss
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// Shared page-level styles for the Fastcheck and Create pages.
|
||||||
|
// Imported via @use './../../../shared' as *;
|
||||||
|
|
||||||
|
.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) {
|
||||||
|
align-items: flex-end;
|
||||||
|
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: 24px 24px 0 0;
|
||||||
|
max-width: 100%;
|
||||||
|
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 24px 22px 18px;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 22px 18px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 0 24px 22px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 0 18px 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
&: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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user