From ed78bb603b073b72ab65bc0c88fc674e38d2a78b Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Thu, 30 Apr 2026 01:17:17 +0400 Subject: [PATCH] changes --- BACKEND.md | 214 ++++++++++++++ DEPLOY.md | 153 ++++++++++ angular.json | 4 +- payment.html | 29 +- public/mastercard.svg | 1 + public/visa.svg | 1 + public/wechat-pay.svg | 1 + src/app/api.ts | 8 + src/app/app.config.ts | 2 +- src/app/app.html | 68 +---- src/app/app.routes.ts | 16 +- src/app/app.scss | 270 +----------------- src/app/app.spec.ts | 11 +- src/app/app.ts | 62 +--- src/app/fastcheck.service.ts | 28 ++ src/app/pages/create-page/create-page.html | 128 +++++++++ src/app/pages/create-page/create-page.scss | 163 +++++++++++ src/app/pages/create-page/create-page.ts | 103 +++++++ .../pages/fastcheck-page/fastcheck-page.html | 139 +++++++++ .../pages/fastcheck-page/fastcheck-page.scss | 204 +++++++++++++ .../pages/fastcheck-page/fastcheck-page.ts | 171 +++++++++++ src/index.html | 2 +- src/main.ts | 2 +- src/shared.scss | 184 ++++++++++++ 24 files changed, 1554 insertions(+), 410 deletions(-) create mode 100644 BACKEND.md create mode 100644 DEPLOY.md create mode 100644 public/mastercard.svg create mode 100644 public/visa.svg create mode 100644 public/wechat-pay.svg create mode 100644 src/app/api.ts create mode 100644 src/app/fastcheck.service.ts create mode 100644 src/app/pages/create-page/create-page.html create mode 100644 src/app/pages/create-page/create-page.scss create mode 100644 src/app/pages/create-page/create-page.ts create mode 100644 src/app/pages/fastcheck-page/fastcheck-page.html create mode 100644 src/app/pages/fastcheck-page/fastcheck-page.scss create mode 100644 src/app/pages/fastcheck-page/fastcheck-page.ts create mode 100644 src/shared.scss diff --git a/BACKEND.md b/BACKEND.md new file mode 100644 index 0000000..bfdba2f --- /dev/null +++ b/BACKEND.md @@ -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=` + +**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=`. +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** — сколько живёт? diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..f13696d --- /dev/null +++ b/DEPLOY.md @@ -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 + + + + + + + + + + + + + + + + +``` + +## 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=` — необязательный, передаётся на `/new`, чтобы вставить `Authorization: {"sessionID": ...}` при `POST /fastcheck`. +- `?return_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`). diff --git a/angular.json b/angular.json index 8233534..e7c126d 100644 --- a/angular.json +++ b/angular.json @@ -47,8 +47,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumWarning": "10kB", + "maximumError": "16kB" } ], "outputHashing": "all" diff --git a/payment.html b/payment.html index 4209242..184d9bb 100644 --- a/payment.html +++ b/payment.html @@ -124,6 +124,21 @@ 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; + } @@ -166,6 +181,17 @@ Российский рубль +
+ + +
+ - - - - - - - + \ No newline at end of file diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dc39edb..783e2be 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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: '' } +]; diff --git a/src/app/app.scss b/src/app/app.scss index 2c67870..e40dd26 100644 --- a/src/app/app.scss +++ b/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; - } -} diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 6943eb5..652e94a 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -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, qr_vitanova'); - }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 6e72563..2639d94 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,65 +1,11 @@ -import { Component, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { HttpClient } from '@angular/common/http'; - -const API_URL = 'https://qr.vitanova.network:567/qr'; +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', - imports: [FormsModule], + imports: [RouterOutlet], templateUrl: './app.html', styleUrl: './app.scss' }) -export class App { - private http = inject(HttpClient); - - amount = signal(10); - error = signal(''); - loading = signal(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(''); - } -} +export class App {} diff --git a/src/app/fastcheck.service.ts b/src/app/fastcheck.service.ts new file mode 100644 index 0000000..8e7d199 --- /dev/null +++ b/src/app/fastcheck.service.ts @@ -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(null); + + setCreated(data: FastcheckData): void { + this.created.set(data); + } + + consume(): FastcheckData | null { + const value = this.created(); + this.created.set(null); + return value; + } +} diff --git a/src/app/pages/create-page/create-page.html b/src/app/pages/create-page/create-page.html new file mode 100644 index 0000000..66688f4 --- /dev/null +++ b/src/app/pages/create-page/create-page.html @@ -0,0 +1,128 @@ +
+
+ +
+ + + + + +

Новый Фастчек

+

Укажите сумму для пополнения

+
+ +
+ + +
+ Способ оплаты +
+ + + + +
+
+ + +
+ Валюта +
+ + + + + +
+
+ +
+ +
+ + +
+ @if (error()) { + {{ error() }} + } +
+ +
+ + +
+ + +
+ + +
+
diff --git a/src/app/pages/create-page/create-page.scss b/src/app/pages/create-page/create-page.scss new file mode 100644 index 0000000..28efa9c --- /dev/null +++ b/src/app/pages/create-page/create-page.scss @@ -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; + } +} diff --git a/src/app/pages/create-page/create-page.ts b/src/app/pages/create-page/create-page.ts new file mode 100644 index 0000000..4d1e390 --- /dev/null +++ b/src/app/pages/create-page/create-page.ts @@ -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(10); + note = signal(''); + error = signal(''); + loading = signal(false); + + payment = signal('sbp'); + currency = signal('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 = {}; + if (this.sessionId) { + headers['Authorization'] = JSON.stringify({ sessionID: this.sessionId }); + } + + this.http + .post( + `${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); + } +} diff --git a/src/app/pages/fastcheck-page/fastcheck-page.html b/src/app/pages/fastcheck-page/fastcheck-page.html new file mode 100644 index 0000000..e30ad52 --- /dev/null +++ b/src/app/pages/fastcheck-page/fastcheck-page.html @@ -0,0 +1,139 @@ +
+
+ +
+

Оплата Фастчеком

+

Введите данные Фастчека или создайте новый

+
+ +
+ + +
+ +
+ + Новый +
+
+ + +
+ +
+ + +
+
+ + +
+ + + @if (error()) { + {{ error() }} + } +
+ + +
+ + +
+
+ + +@if (popupOpen()) { + +} diff --git a/src/app/pages/fastcheck-page/fastcheck-page.scss b/src/app/pages/fastcheck-page/fastcheck-page.scss new file mode 100644 index 0000000..17294fb --- /dev/null +++ b/src/app/pages/fastcheck-page/fastcheck-page.scss @@ -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; } +} diff --git a/src/app/pages/fastcheck-page/fastcheck-page.ts b/src/app/pages/fastcheck-page/fastcheck-page.ts new file mode 100644 index 0000000..4472174 --- /dev/null +++ b/src/app/pages/fastcheck-page/fastcheck-page.ts @@ -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(''); + fastcheckAmount = signal(null); + fastcheckCode = signal(''); + error = signal(''); + + popupOpen = signal(false); + popupLoading = signal(false); + popupError = signal(''); + webSessionId = signal(''); + paid = signal(false); + private pollHandle: ReturnType | 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(`${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(`${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); + } +} diff --git a/src/index.html b/src/index.html index 09c19bc..ec6b43a 100644 --- a/src/index.html +++ b/src/index.html @@ -1,4 +1,4 @@ - + diff --git a/src/main.ts b/src/main.ts index 5df75f9..8648ba6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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'; diff --git a/src/shared.scss b/src/shared.scss new file mode 100644 index 0000000..65285f2 --- /dev/null +++ b/src/shared.scss @@ -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; } +}