Compare commits

...
149 Commits
Author SHA1 Message Date
3252a8 46520fb37c Merge branch 'dev' 2026-05-13 23:07:39 +03:00
3252a8 18ba00c98a feat: show user logs in admin panel user card 2026-05-13 22:51:04 +03:00
3252a8 e065c85a3e docs: update docs 2026-05-13 22:34:24 +03:00
3252a8 7401649841 refactor: split backend domains and add API behavior coverage 2026-05-13 18:38:10 +03:00
3252a8 a96007d48a fix: avoid redundant premium squad restore updates 2026-05-13 15:11:56 +03:00
3252a8 2169fd8b50 chore: harden webapp auth and update deps 2026-05-13 15:05:30 +03:00
3252a8 2b7d1f0be8 chore: fix lint warnings 2026-05-13 14:13:39 +03:00
3252a8 eec5635541 refactor: brand logo customization and emoji font selection 2026-05-13 14:02:35 +03:00
3252a8 e21a0758c3 fix: user card open in payments list for email only users 2026-05-12 23:53:35 +03:00
3252a8 defbac43d7 feat: tune admin panel visual, add animations, expand dashboard 2026-05-12 23:40:41 +03:00
3252a8 94612e11f2 fix: fix unlimited premium squads applying via admin panel 2026-05-12 22:01:11 +03:00
3252a8 11187487b4 chore: run lint and prettifier 2026-05-12 21:54:12 +03:00
3252a8 f31540afdb fix: dedupe traffic billing warnings when panel limit drifts 2026-05-12 21:32:47 +03:00
3252a8 6afbb72a34 feat: lock traffic topup when under 80% of traffic used 2026-05-12 21:27:37 +03:00
3252a8 af66103111 docs: update docs 2026-05-12 16:34:23 +03:00
3252a8 92277b2e27 feat: show and filter premium squad status in admin panel users list 2026-05-12 16:14:11 +03:00
3252a8 475a1953d1 feat: tune visual of web app admin panel, tune limit messages and etc 2026-05-12 15:42:17 +03:00
3252a8 8af59750c6 fix(tariff-worker): match premium bandwidth stats by username, not uuid 2026-05-12 11:20:23 +03:00
3252a8 00a0792c35 fix: reconcile legacy database schema for admin user detail API 2026-05-12 11:07:44 +03:00
3252a8 b9c0dfb9a1 fix: locales in admin panel settings page 2026-05-12 10:31:21 +03:00
3252a8 9679190709 feat: ability to turn off logs for admins users 2026-05-12 10:16:59 +03:00
3252a8 c8d4b3565c feat: tune visual of web app admin panel 2026-05-12 09:59:15 +03:00
3252a8 fe976022ec refactor: migrate ui elements to lib 2026-05-12 09:05:22 +03:00
3252a8 6a367c1dec refactor: consolidate web app UI foundation 2026-05-11 23:25:34 +03:00
3252a8 9f338c3416 feat: tune visual of web app admin panel 2026-05-11 23:06:16 +03:00
3252a8 a575cb057b refactor: slice web app wip 2026-05-11 22:32:23 +03:00
3252a8 116b0a5c08 fix: tune tariffs folder 2026-05-11 22:12:19 +03:00
3252a8 ed6b214b29 refactor: slice web app wip 2026-05-11 21:41:31 +03:00
3252a8 7d8bbbc904 refactor: slice web app wip 2026-05-11 08:31:07 +03:00
3252a8 763c4ad1bb refactor: slice web app wip 2026-05-11 07:57:34 +03:00
3252a8 5243cc4284 docs: update docs 2026-05-11 06:34:45 +03:00
3252a8 5a015fe8b4 Merge branch 'feature/new-tariffs' into dev 2026-05-11 06:10:36 +03:00
3252a8 b759ddb0f5 feat: tune premium squads visual in web app 2026-05-11 00:15:29 +03:00
3252a8 57821ba2a9 feat: tune visual of web app admin panel 2026-05-10 23:36:05 +03:00
3252a8 410cb89f3e fix: do not change squad for referrer 2026-05-10 21:57:36 +03:00
3252a8 e01f56bc34 feat: tune visual of web app admin panel 2026-05-10 20:19:43 +03:00
3252a8 ff59eda3d7 feat: web app setting reload without container reloading 2026-05-10 18:07:20 +03:00
3252a8 b7e497e241 Merge branch 'dev' into feature/new-tariffs 2026-05-10 13:18:39 +03:00
3252a8 7a81a2f20b fix: telegram oauth 2026-05-10 13:17:21 +03:00
3252a8 990eba1e1e feat: premium squads inside one tariff 2026-05-10 12:51:53 +03:00
3252a8 6f9a87cf98 feat: admin dashboard 2026-05-09 22:32:44 +03:00
3252a8 0a9d929181 docs: update docs 2026-05-08 18:14:30 +03:00
3252a8 7dc4806be7 Merge branch 'dev' into feature/new-tariffs 2026-05-07 22:23:04 +03:00
3252a8 8783129c16 fix: webapp accesability when tg is unreachable 2026-05-06 21:39:11 +03:00
3252a8 50d78c8746 feat: hide tariff options and etc when only one exist 2026-05-04 21:50:44 +03:00
3252a8 63784bc036 fix: use limited tag for mark exhausted traffic users 2026-05-03 22:46:12 +03:00
3252a8 aa5a9496ed feat: hwid limits and topups 2026-05-03 22:36:33 +03:00
3252a8 00f8e5fb20 Merge branch 'dev' into feature/new-tariffs
# Conflicts:
#	bot/services/stars_service.py
2026-05-03 22:19:04 +03:00
3252a8 ca8df10eab fix: telegram stars payment in web app 2026-05-03 18:10:20 +03:00
3252a8 b461838c0d Merge branch 'dev' into feature/new-tariffs 2026-05-02 13:21:11 +03:00
3252a8 50857d5283 fix: lang select dropdown wasn't work on android 2026-05-02 13:20:42 +03:00
3252a8 449bc0c780 Merge branch 'dev' into feature/new-tariffs 2026-05-01 22:51:36 +03:00
3252a8 e1223f8c57 fix: connect button disappeard in web app 2026-05-01 22:48:27 +03:00
3252a8 7822ec1b09 Merge branch 'dev' into feature/new-tariffs 2026-05-01 22:37:58 +03:00
3252a8 81f5168726 chore: improve telegram bot menu navigation and referrals 2026-05-01 22:16:00 +03:00
3252a8 77f2420ae3 Merge branch 'dev' into feature/new-tariffs 2026-04-30 22:58:12 +03:00
3252a8 4de730bf5a feat: add Telegram OAuth login flow 2026-04-30 19:50:17 +03:00
3252a8 77da0d97c0 chore: tariffs 2026-04-30 18:57:10 +03:00
3252a8 e8b395cf2f Merge branch 'dev' into feature/new-tariffs 2026-04-30 14:05:19 +03:00
3252a8 d15b1be3b9 fix: click on another button when switching lang 2026-04-30 14:05:01 +03:00
3252a8 8a6b2704d4 feat: new tariffs web app and bot interactions 2026-04-30 13:48:32 +03:00
3252a8 ce610b4804 Merge branch 'dev' into feature/new-tariffs
# Conflicts:
#	.gitignore
#	bot/app/web/subscription_webapp.py
#	tests/test_settings.py
2026-04-30 10:52:20 +03:00
3252a8 4d0241a0ea fix: remove logo bg in email templates 2026-04-30 10:29:01 +03:00
3252a8 2c1e86863d feat: support hwid in web app 2026-04-30 09:25:43 +03:00
3252a8 16b11327ee fix: traffic strategy 2026-04-30 07:57:14 +03:00
3252a8 3d5998b8fb feat: support traffic and trial flow in web app 2026-04-30 07:34:29 +03:00
3252a8 2df4d2d770 feat: tune webapp visual 2026-04-30 00:06:31 +03:00
3252a8 4ce78f277e feat: migrate to svelte initial 2026-04-29 11:10:12 +03:00
3252a8 d7d5c2b1ef refactor: remove local storing tg js 2026-04-28 23:33:24 +03:00
3252a8 4958e1fdfd fix: delegate tariff resets to panel 2026-04-28 15:36:13 +03:00
3252a8 258b2f4dec feat: align tariff resets to calendar month 2026-04-28 15:25:46 +03:00
3252a8 338008bbd0 docs: document tariff catalog 2026-04-28 14:34:25 +03:00
3252a8 829758dc44 feat: update tariff UI and mini app 2026-04-28 14:34:12 +03:00
3252a8 9deb98bbe7 feat: add traffic periods topups and throttling 2026-04-28 14:34:00 +03:00
3252a8 26af92b5ac feat: route purchases through tariff catalog 2026-04-28 14:33:52 +03:00
3252a8 87b2443a06 feat: add tariff config and database schema 2026-04-28 14:33:38 +03:00
3252a8 aabc0e312d feat: log email and tg linking 2026-04-28 13:54:05 +03:00
3252a8 d15d58df36 docs: tariffs documentation 2026-04-28 10:47:59 +03:00
3252a8 21f2ac4534 fix: show red traffic bar in web app when sub is expired 2026-04-28 10:32:56 +03:00
3252a8 dd0e6e75af feat: add magic login link to email with login codes 2026-04-28 09:50:17 +03:00
3252a8 0251f939d7 feat: log users when reg via email 2026-04-27 23:29:28 +03:00
3252a8 a00bc0f345 feat: design for emails 2026-04-27 23:05:14 +03:00
3252a8 ae4ce43e1c chore: tune default web app logo 2026-04-27 22:28:34 +03:00
3252a8 19ed8916f4 fix: migration and docker image name/tag 2026-04-27 21:31:06 +03:00
3252a8 f7de08bfd7 docs: update readme 2026-04-27 21:06:21 +03:00
3252a8 6e42f67931 docs: donation wallet 2026-04-27 21:02:49 +03:00
3252a8 b87b6cd378 fix: webapp session 2026-04-27 20:50:24 +03:00
3252a8 b39fb73c30 docs: project name change, migration script 2026-04-27 16:11:06 +03:00
3252a8 3120884ed5 docs: update readme 2026-04-27 15:15:25 +03:00
3252a8 c927244b1a docs: update readme 2026-04-27 15:13:00 +03:00
3252a8 e049f38c7c fix: readme format 2026-04-27 15:08:29 +03:00
3252a8 ddc6ee0024 feat: build webapp when docker build 2026-04-27 14:59:35 +03:00
3252a8 fa5f1460ab fix: separate platega buttons not showed up in webapp 2026-04-27 14:57:49 +03:00
3252a8 9f556f3e04 fix: add platega debug 2026-04-27 14:48:58 +03:00
3252a8 70b618a94b feat: use web page for cryptopay in web app 2026-04-27 14:38:09 +03:00
3252a8 0610156f80 feat: add multiple platega buttons 2026-04-27 14:34:41 +03:00
3252a8 587b39de1d fix(bot): connect button opens subscription page instead of mini app
Previously the connect button in post-payment messages and in 'My subscription' section opened the mini app (personal cabinet) when SUBSCRIPTION_MINI_APP_URL was set, ignoring the actual subscription URL. Now the real subscription URL is preferred, with the mini app used only as a fallback.
2026-04-27 11:06:32 +03:00
3252a8 e35885b54b docs: update readme 2026-04-27 09:44:28 +03:00
3252a8 c1b7ace876 fix(webapp): merge bundled FALLBACK_I18N with server locales
The bot's locales/*.json files do not contain the webapp-specific keys
(connect, extend_subscription, loading, etc.), so applyI18n was
overwriting the localized HTML defaults with raw key names.

Merge the bundled FALLBACK_I18N table with whatever the server provides
per language, letting server values override but falling back to the
in-bundle translations for keys the bot does not ship.
2026-04-27 09:42:18 +03:00
3252a8 f416b0aed1 fix(webapp): allow oauth.telegram.org iframe in CSP
The Telegram Login Widget embeds oauth.telegram.org in an iframe; without
an explicit frame-src directive it fell back to default-src 'self' and was
blocked. Add frame-src https://oauth.telegram.org so the login flow loads.
2026-04-27 09:34:09 +03:00
3252a8 c8192e4427 fix(webapp): allow inline JSON config and Telegram widget eval under CSP
Add a per-request nonce to inline <script type="application/json"> blocks
(webapp-config, i18n) so they survive script-src 'self' and locales/config
actually load in the browser. Also add 'unsafe-eval' to script-src so the
vendored telegram-widget.js (which uses eval/new Function) can initialise.
2026-04-27 09:31:52 +03:00
3252a8 dae1a6889b feat(ops): add /healthz endpoint and container healthcheck
Expose a lightweight /healthz route on the main aiohttp server and
wire docker-compose healthcheck so orchestrators can detect failures.
2026-04-27 08:43:58 +03:00
3252a8 4740666d63 chore: annotate XTR provider_token and add missing return type 2026-04-27 08:43:54 +03:00
3252a8 d6b703debb refactor(logging): use logger.exception to preserve stack traces
Unify error logging across services: replace logger.error(f"...{e}")
and logger.error(..., exc_info=True) with logger.exception() so the
stack trace is consistently captured.
2026-04-27 08:43:48 +03:00
3252a8 f1113eb80a fix(webhooks): return 5xx on YooKassa processing errors
Replace 200-on-error with 500 so the payment provider retries transient
failures instead of treating them as successfully processed.
2026-04-27 08:43:44 +03:00
3252a8 604f0d9656 security: audit inline Mini App handlers 2026-04-26 20:34:02 +03:00
3252a8 8ce5a92625 chore: harden ignore rules 2026-04-26 20:33:59 +03:00
3252a8 72b6e93c94 docs: add Telegram clock-skew note for auth_date 2026-04-26 20:33:57 +03:00
3252a8 d60952718e security: harden Mini App auth, CSRF, and validation 2026-04-26 20:33:54 +03:00
3252a8 53dcc59770 security: double-check CryptoPay webhook signatures 2026-04-26 20:33:49 +03:00
3252a8 5ec179b6d6 webapp: add hashed minified asset pipeline 2026-04-26 20:04:30 +03:00
3252a8 250df445f0 docker: run app as non-root user 2026-04-26 19:47:56 +03:00
3252a8 12376e5070 db: add composite indexes and merge-user optimizations 2026-04-26 19:47:14 +03:00
3252a8 c2afc6107a webapp: harden mini app rendering and controls 2026-04-26 19:47:06 +03:00
3252a8 77370eb963 security: harden webhooks and session secrets 2026-04-26 19:46:57 +03:00
3252a8 94b0787cad feat: use i18n 2026-04-26 12:34:16 +03:00
3252a8 9c499fe3c2 feat: add caddy docker compose example 2026-04-24 23:16:31 +03:00
3252a8 7e26f9da9b feat: automatic merge two paid subs (email and tg) 2026-04-24 23:07:57 +03:00
3252a8 778615a97f feat: tune web app visual 2026-04-24 22:35:55 +03:00
3252a8 651572f15f refactor: show tg avatar in detached browser 2026-04-24 21:31:58 +03:00
3252a8 5ccb8ddabe refactor: promo and email bruteforce defence 2026-04-24 21:18:29 +03:00
3252a8 86f944e544 feat: tune web app visual 2026-04-24 21:04:53 +03:00
3252a8 bc29f5ebd6 feat: promocode and ref in web app 2026-04-24 13:52:53 +03:00
3252a8 9a84be85c6 feat: move tg bot interaction keyboard to separate command, show only web app and support buttons by default 2026-04-24 12:43:21 +03:00
3252a8 c08ac854b2 fix: get email from panel if exist, tune webapp visual 2026-04-24 11:14:50 +03:00
3252a8 807a8933b9 feat: email login, smtp codes 2026-04-23 21:20:32 +03:00
3252a8 b9cb1fec06 feat: tune webapp visual, use telegram widget for login 2026-04-23 14:06:21 +03:00
3252a8 eab803652b fix: tg bot wont start 2026-04-22 22:44:19 +03:00
3252a8 259d0646bc fix: tune webapp visual and fix some errors 2026-04-22 22:35:10 +03:00
3252a8 4f1b7d0832 feat: web app 2026-04-22 16:00:55 +03:00
3252a8 1aa529ab23 Remove GitHub Actions and stale GHCR defaults 2026-04-22 15:19:54 +03:00
3252a8 a46502380c Remove locales volume from bot 2026-04-03 20:49:14 +03:00
3252a8 85c276bcf5 Setup ghcr 2026-04-03 20:36:34 +03:00
3252a8 55f8db11ce Add setup instructions 2026-02-15 18:58:52 +03:00
3252a8 79af61a48e Add configurable welcome bonus days for referred signups
- add REFERRAL_WELCOME_BONUS_DAYS setting (default 3) in settings.py
- document REFERRAL_WELCOME_BONUS_DAYS in .env.example
- apply welcome bonus on first /start only for newly created users with referred_by_id
- replace hardcoded 3 days with settings.REFERRAL_WELCOME_BONUS_DAYS
- skip bonus flow when value is 0 or less
- send user notification after successful bonus application
- add i18n key referral_welcome_bonus_applied to ru.json and en.json
2026-02-11 13:26:56 +03:00
3252a8 69d2e2a899 Fix promo failed message when using deeplink 2026-02-10 19:53:55 +03:00
3252a8 069ad967b5 Add user ref page deeplink 2026-02-10 19:36:29 +03:00
3252a8 18a6b3e18d Tune users rating 2026-02-09 10:29:40 +03:00
3252a8 3b9043332c Move user links to id's from buttons 2026-02-09 10:22:06 +03:00
3252a8 d94b57bd0b User card links in ratings 2026-02-09 10:09:16 +03:00
3252a8 5247092ca2 Add users rating feature 2026-02-09 10:03:55 +03:00
3252a8 049789c9c5 Tune invite buttons 2026-02-06 12:48:37 +03:00
3252a8 6b74ae4a8e Update locals 2026-01-25 10:20:54 +03:00
3252a8 c13c01ea43 Update traffic limit info in user profile 2026-01-25 10:16:00 +03:00
3252a8 48e7d3569f Fix used traffic display 2026-01-25 10:11:16 +03:00
3252a8 4f3b45cbd8 Merge branch 'main' into fork-new
# Conflicts:
#	docker-compose.yml
2026-01-25 09:57:57 +03:00
3252a8 90ab186a7c Add platega payment request logging 2026-01-07 22:42:00 +03:00
3252a8 dc725ccd68 Update locales 2026-01-05 23:51:33 +03:00
3252a8 d88aee03bb Add user agreement and privacy policy links 2026-01-05 23:42:51 +03:00
305 changed files with 56824 additions and 6608 deletions
+17 -1
View File
@@ -1,10 +1,20 @@
# Git
.git
.gitignore
.github
.gitattributes
LICENSE
README.md
.env
.env.*
!.env.example
scratch_*.py
scratch/
.claude/
*.local.*
node_modules/
docker-compose-dev.yml
data/*
!data/tariffs.example.json
# CI
@@ -17,6 +27,12 @@ docker-compose.yml
Dockerfile
.docker
.dockerignore
tmp/
# WebApp build artifacts (regenerated inside Docker)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
# Byte-compiled / optimized / DLL files
**/__pycache__/
+56 -17
View File
@@ -3,9 +3,9 @@ BOT_TOKEN=your_bot_token_here #
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
# PostgreSQL Database Connection Settings
POSTGRES_USER=postgres # Database user name
POSTGRES_PASSWORD=postgres # Database password
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
POSTGRES_USER= # Required: database user name
POSTGRES_PASSWORD= # Required: database password
POSTGRES_HOST=remnawave-minishop-db # Database container name
POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name
@@ -17,7 +17,9 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
@@ -29,17 +31,52 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
# Subscription Mini App (same container, separate port)
WEBAPP_ENABLED=True # Run Mini App HTTP server
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
# Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS
TRIBUTE_ENABLED=True # Turn on TRIBUTE
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
@@ -62,6 +99,7 @@ FREEKASSA_API_KEY=your_api_key #
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
@@ -73,7 +111,11 @@ CRYPTOPAY_ASSET=RUB #
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
@@ -84,35 +126,30 @@ SEVERPAY_TOKEN= #
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
# Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
TRIBUTE_LINK_1_MONTH=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS=
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS=
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id
NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
# Subscription Notifications
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
@@ -122,6 +159,7 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 #
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days
# Bonus for the inviting user
@@ -176,6 +214,7 @@ LOG_PAYMENTS=True #
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
-68
View File
@@ -1,68 +0,0 @@
name: Build and Push Dev Docker Image
on:
push:
branches:
- dev
pull_request:
branches:
- dev
env:
GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
flavor: |
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
-56
View File
@@ -1,56 +0,0 @@
name: Build and Publish multi-arch Docker Image
on:
push:
branches:
- main
tags:
- 'v*.*.*'
paths-ignore:
- 'README.md'
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.GHCR_IMAGE }}:latest
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+16
View File
@@ -3,6 +3,19 @@ bot_database.sqlite3
# Игнорировать файлы окружения
.env
.env.*
!.env.example
scratch_*.py
*.local.*
node_modules/
.git/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
tmp
.claude
# Игнорировать кэш Python
__pycache__/
@@ -17,3 +30,6 @@ __pycache__/
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
docker-compose-dev.yml
+5
View File
@@ -0,0 +1,5 @@
node_modules/
package-lock.json
bot/app/web/templates/
**/*.min.js
.pytest_cache/
+9
View File
@@ -0,0 +1,9 @@
{
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"useTabs": false
}
+10
View File
@@ -0,0 +1,10 @@
# Replace the example domains below with your real webhook and Mini App hostnames.
webhook.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
}
app.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
}
+80 -4
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim AS builder
FROM python:3.12-slim AS python-builder
WORKDIR /app
@@ -7,14 +7,90 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM node:22-slim AS webapp-builder
WORKDIR /webapp
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY bot/app/web/frontend ./bot/app/web/frontend
COPY bot/app/web/templates ./bot/app/web/templates
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
RUN npm run build:webapp
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
ARG APP_VERSION=""
ARG APP_REVISION=""
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${APP_REVISION}"
RUN useradd -u 10001 -m appuser
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends git
COPY . .
RUN rm -rf /root/.cache
# Replace template assets with freshly built ones
RUN rm -f bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.min.*.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.css
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
bot/app/web/templates/
RUN set -eux; \
if [ -n "$APP_VERSION" ]; then \
printf '%s\n' "$APP_VERSION" > .build-version; \
elif [ -d .git ]; then \
tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"; \
sha="$(git rev-parse --short HEAD 2>/dev/null || true)"; \
dirty=""; \
if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then dirty="-dirty"; fi; \
if [ -n "$tag" ] && [ -n "$sha" ]; then \
count="$(git rev-list "${tag}..HEAD" --count 2>/dev/null || true)"; \
if [ -n "$count" ] && [ "$count" != "0" ]; then \
printf '%s+%s.g%s%s\n' "$tag" "$count" "$sha" "$dirty" > .build-version; \
else \
printf '%s%s\n' "$tag" "$dirty" > .build-version; \
fi; \
elif [ -n "$sha" ]; then \
printf 'dev+g%s%s\n' "$sha" "$dirty" > .build-version; \
else \
printf 'dev+container\n' > .build-version; \
fi; \
else \
printf 'dev+container\n' > .build-version; \
fi; \
if [ -n "$APP_REVISION" ]; then \
printf '%s\n' "$APP_REVISION" > .build-revision; \
elif [ -d .git ]; then \
git rev-parse HEAD > .build-revision 2>/dev/null || printf 'unknown\n' > .build-revision; \
else \
printf 'unknown\n' > .build-revision; \
fi; \
apt-get purge -y --auto-remove git; \
rm -rf .git /root/.cache
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
USER appuser
CMD ["python", "main.py"]
+78 -333
View File
@@ -1,364 +1,109 @@
# Telegram-бот для продажи подписок Remnawave
# Remnawave Minishop
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
## ✨ Ключевые возможности
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
## Возможности
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
- **Управление промокодами:** Создание и просмотр промокодов.
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
- **Логи действий:** Просмотр логов всех действий пользователей.
Для пользователей:
## 🚀 Технологии
- регистрация с выбором русского или английского языка;
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
- **Python 3.12**
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
- **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
Для администраторов:
## ⚙️ Установка и запуск
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- ручная синхронизация пользователей и подписок с панелью.
### Предварительные требования
## Документация
- Установленные Docker и Docker Compose.
- Рабочая панель Remnawave.
- Токен Telegram-бота.
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
### Шаги установки
## Совместимость
1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/kavore/remnawave-tg-shop
cd remnawave-tg-shop
```
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
2. **Создайте и настройте файл `.env`:**
Скопируйте `env.example` в `.env` и заполните своими данными.
```bash
cp .env.example .env
nano .env
```
Ниже перечислены ключевые переменные.
## Стек
<details>
<summary><b>Основные настройки</b></summary>
Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**.
| Переменная | Описание | Пример |
| --- | --- | --- |
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
</details>
| Слой | Технологии |
| --- | --- |
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
| Данные | **PostgreSQL** **17** (сервис `remnawave-minishop-db` в Compose) |
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` |
<details>
<summary><b>Настройки платежей и вебхуков</b></summary>
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
| Переменная | Описание |
| --- | --- |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
| `NALOGO_PASSWORD` | Пароль для авторизации в nalog.ru (самозанятый). |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
| `SEVERPAY_MID` | MID магазина в SeverPay. |
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
</details>
## Быстрый старт
<details>
<summary><b>Настройки подписок</b></summary>
Требования:
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details>
<details>
<summary><b>Настройки панели Remnawave</b></summary>
| Переменная | Описание |
| --- | --- |
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
| `PANEL_API_KEY` | API ключ для доступа к панели. |
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
</details>
<details>
<summary><b>Настройки пробного периода</b></summary>
| Переменная | Описание |
| --- | --- |
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details>
3. **Запустите контейнеры:**
```bash
docker compose up -d
```
Эта команда скачает образ и запустит сервис в фоновом режиме.
4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
5. **Просмотр логов:**
```bash
docker compose logs -f remnawave-tg-shop
```
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория
- Docker и Docker Compose;
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
- токен Telegram-бота;
- параметры хотя бы одного платежного провайдера.
```bash
git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop
git clone https://github.com/3252a8/remnawave-minishop
cd remnawave-minishop
cp .env.example .env
nano .env
docker compose up -d --build
docker compose logs -f remnawave-minishop
```
### 2. Настройка переменных окружения
Минимально заполните в `.env`:
- `BOT_TOKEN` - токен Telegram-бота;
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
- настройки платежного провайдера;
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
```bash
cp .env.example .env && nano .env
mkdir -p data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
```
**Обязательные поля для заполнения:**
- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`
- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов)
- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com`
- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`)
- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели)
- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели)
- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей
### 3. Настройка Reverse Proxy (Nginx)
Перейдите в директорию конфигурации Nginx панели Remnawave:
## Полезные команды
```bash
cd /opt/remnawave/nginx && nano nginx.conf
# Локальная сборка и запуск
docker compose up -d --build
# Логи приложения
docker compose logs -f remnawave-minishop
# Запуск с Caddy
docker compose -f docker-compose-caddy.yml up -d --build
# Запуск из готового образа
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
```
Добавьте в `nginx.conf` следующую конфигурацию:
## Поддержка
```nginx
upstream remnawave-tg-shop {
server remnawave-tg-shop:8080;
}
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
server_name webhook.domain.com; # Домен для отправки Webhook'ов
listen 443 ssl;
http2 on;
ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key";
ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem";
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_intercept_errors on;
error_page 400 404 500 502 @redirect;
location / {
proxy_pass http://remnawave-tg-shop$request_uri;
}
location @redirect {
return 404;
}
}
```
### 4. Выпуск SSL-сертификата для домена webhook
Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт:
```bash
sudo apt-get install cron socat
curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc
ufw allow 80/tcp && ufw reload
```
Выпустите сертификат:
```bash
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue --standalone -d 'webhook.domain.com' \
--key-file /opt/remnawave/nginx/webhook_privkey.key \
--fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem
```
### 5. Добавление сертификатов в Docker Compose Nginx
Отредактируйте `docker-compose.yml` панели Nginx:
```bash
cd /opt/remnawave/nginx && nano docker-compose.yml
```
Добавьте две строки в секцию `volumes`:
```yaml
services:
remnawave-nginx:
image: nginx:1.26
container_name: remnawave-nginx
hostname: remnawave-nginx
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro
- ./privkey.key:/etc/nginx/ssl/privkey.key:ro
- ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro
- ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro
- ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку
- ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку
restart: always
ports:
- '0.0.0.0:443:443'
networks:
- remnawave-network
networks:
remnawave-network:
name: remnawave-network
driver: bridge
external: true
```
### 6. Запуск бота и перезапуск Nginx
Запустите бота:
```bash
cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
```
Перезапустите Nginx:
```bash
cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t
```
## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
## 📁 Структура проекта
```
.
├── bot/
│ ├── filters/ # Пользовательские фильтры Aiogram
│ ├── handlers/ # Обработчики сообщений и колбэков
│ ├── keyboards/ # Клавиатуры
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
│ ├── services/ # Бизнес-логика (платежи, API панели)
│ ├── states/ # Состояния FSM
│ └── main_bot.py # Основная логика бота
├── config/
│ └── settings.py # Настройки Pydantic
├── db/
│ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # Настройка БД
│ └── models.py # Модели SQLAlchemy
├── locales/ # Файлы локализации (ru, en)
├── .env.example # Пример файла с переменными окружения
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Файл для оркестрации контейнеров
├── requirements.txt # Зависимости Python
└── main.py # Точка входа в приложение
```
## 🔮 Планы на будущее
- Расширенные типы промокодов (например, скидки в процентах).
## ❤️ Поддержка
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
-1
View File
@@ -1,2 +1 @@
+12 -10
View File
@@ -1,22 +1,23 @@
import logging
from typing import Dict
from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.db_session import DBSessionMiddleware
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
from bot.middlewares.profile_sync import ProfileSyncMiddleware
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
from bot.middlewares.db_session import DBSessionMiddleware
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
from bot.middlewares.profile_sync import ProfileSyncMiddleware
from config.settings import Settings
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
def build_dispatcher(
settings: Settings, async_session_factory: sessionmaker
) -> tuple[Dispatcher, Bot, Dict]:
storage = MemoryStorage()
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
@@ -32,8 +33,9 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) ->
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
dp.update.outer_middleware(ProfileSyncMiddleware())
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
dp.update.outer_middleware(
ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)
)
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
return dp, bot, {"i18n_instance": i18n_instance}
-1
View File
@@ -1,2 +1 @@
+13 -11
View File
@@ -1,20 +1,20 @@
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.severpay_service import SeverPayService
from bot.services.lknpd_service import LknpdService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.platega_service import PlategaService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.stars_service import StarsService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
def build_core_services(
@@ -65,7 +65,9 @@ def build_core_services(
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
yookassa_service = YooKassaService(
shop_id=settings.YOOKASSA_SHOP_ID,
secret_key=settings.YOOKASSA_SECRET_KEY,
-1
View File
@@ -1,2 +1 @@
+56
View File
@@ -0,0 +1,56 @@
"""Compatibility facade for the admin Mini App API."""
# ruff: noqa: I001
from bot.app.web.admin_api_impl import (
_runtime as _runtime,
ads as _ads,
auth as _auth,
broadcast as _broadcast,
common as _common,
logs as _logs,
panel as _panel,
payments as _payments,
promos as _promos,
routes as _routes,
settings as _settings,
stats as _stats,
sync as _sync,
tariffs as _tariffs,
users as _users,
)
_MODULES = (
_runtime,
_auth,
_common,
_stats,
_users,
_payments,
_promos,
_logs,
_broadcast,
_sync,
_ads,
_settings,
_tariffs,
_panel,
_routes,
)
_NAMESPACE = {}
for _module in _MODULES:
_NAMESPACE.update(
{
_name: _value
for _name, _value in vars(_module).items()
if not _name.startswith("__") and _name != "annotations"
}
)
for _module in _MODULES:
vars(_module).update(_NAMESPACE)
globals().update(_NAMESPACE)
__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__"))
+1
View File
@@ -0,0 +1 @@
"""Domain modules for the admin Mini App API."""
+65
View File
@@ -0,0 +1,65 @@
# ruff: noqa: F401,F403,F405,I001
"""HTTP API powering the admin section of the subscription Mini App.
All routes require an authenticated webapp session (cookie or Bearer
token) AND the resolved Telegram user id must appear in
``settings.ADMIN_IDS``. Authorization is enforced via the
``_require_admin_user_id`` helper, never trusted from the client.
"""
from __future__ import annotations
import csv
import io
import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlsplit, urlunsplit
from aiohttp import web
from pydantic import ValidationError
from sqlalchemy import Float, and_, case, cast, or_, select
from sqlalchemy import func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_settings_manifest import (
manifest_payload,
)
from bot.services.referral_service import ReferralService
from bot.services.settings_override_service import (
current_value,
update_overrides,
)
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from db.dal import (
ad_dal,
app_settings_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
promo_code_dal,
subscription_dal,
user_dal,
)
from db.models import (
AdCampaign,
MessageLog,
Payment,
PromoCode,
Subscription,
User,
UserTelegramAvatar,
)
logger = logging.getLogger(__name__)
# ─── Auth ──────────────────────────────────────────────────────────
__all__ = [name for name in globals() if not name.startswith("__")]
+69
View File
@@ -0,0 +1,69 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_ads_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
campaigns = await ad_dal.list_campaigns(session)
totals = await ad_dal.get_totals(session)
results = []
for campaign in campaigns:
try:
stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id)
except Exception:
stats = {}
results.append(_serialize_ad(campaign, stats))
return _ok({"campaigns": results, "totals": totals})
async def admin_ad_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
payload = await _read_json(request)
source = str(payload.get("source") or "").strip()
start_param = str(payload.get("start_param") or "").strip()
cost = float(payload.get("cost") or 0.0)
if not source or not start_param:
return _error(400, "invalid_payload")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await ad_dal.get_campaign_by_start_param(session, start_param)
if existing:
return _error(409, "duplicate_start_param")
campaign = await ad_dal.create_campaign(
session,
source=source,
start_param=start_param,
cost=cost,
)
await session.commit()
await session.refresh(campaign)
return _ok({"campaign": _serialize_ad(campaign)})
async def admin_ad_toggle_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
payload = await _read_json(request)
is_active = bool(payload.get("is_active", True))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
async def admin_ad_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.delete_campaign(session, campaign_id)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
+58
View File
@@ -0,0 +1,58 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _require_admin_user_id(request: web.Request) -> int:
"""Return the authenticated user id, or raise 401/403 for non-admins."""
from bot.app.web.session import extract_authenticated_user_id
settings: Settings = request.app["settings"]
user_id = extract_authenticated_user_id(request)
if not user_id:
raise web.HTTPUnauthorized(
text=json.dumps({"ok": False, "error": "unauthorized"}),
content_type="application/json",
)
admin_ids = settings.ADMIN_IDS or []
db_user_telegram_id = request.get("admin_telegram_id")
if db_user_telegram_id is None:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
if int(db_user_telegram_id) not in {int(x) for x in admin_ids}:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
return int(user_id)
@web.middleware
async def admin_auth_middleware(request: web.Request, handler):
"""Resolve the Telegram id of the current user and stash it on the request.
Doing this once per request lets every admin route call
``_require_admin_user_id`` without re-querying the DB.
"""
if not request.path.startswith("/api/admin"):
return await handler(request)
from bot.app.web.session import extract_authenticated_user_id
user_id = extract_authenticated_user_id(request)
if user_id:
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.telegram_id:
request["admin_telegram_id"] = int(db_user.telegram_id)
elif db_user:
# No telegram_id yet (email-only user) — can't be an admin
request["admin_telegram_id"] = None
return await handler(request)
+54
View File
@@ -0,0 +1,54 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_broadcast_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in {"all", "active", "inactive"}:
target = "all"
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
if target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent = 0
failed = 0
for uid in user_ids:
try:
await send_message_via_queue(
queue_manager,
int(uid),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
sent += 1
except Exception as exc:
failed += 1
logger.debug("Broadcast queue failed for %s: %s", uid, exc)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_broadcast_webapp",
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
"is_admin_event": True,
},
)
return _ok({"queued": sent, "failed": failed, "target": target})
+367
View File
@@ -0,0 +1,367 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _ok(payload: Dict[str, Any], **extra) -> web.Response:
body = {"ok": True, **payload, **extra}
return web.json_response(body)
def _error(status: int, code: str, message: str = "") -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message or code},
status=status,
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _serialize_user(user: User) -> Dict[str, Any]:
return {
"user_id": int(user.user_id),
"telegram_id": int(user.telegram_id) if user.telegram_id else None,
"telegram_photo_url": user.telegram_photo_url,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"language_code": user.language_code,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
"panel_user_uuid": user.panel_user_uuid,
"referral_code": user.referral_code,
"referred_by_id": int(user.referred_by_id) if user.referred_by_id else None,
}
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
return (
int(sub.premium_baseline_bytes or 0)
+ int(sub.premium_topup_balance_bytes or 0)
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ premium_bonus_bytes
)
def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]:
"""Premium traffic column when subscription has a finite premium quota (bytes > 0).
Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel
routing, not 'tariff includes premium traffic' — do not use it here.
"""
if sub is None:
return {"state": "none"}
if bool(getattr(sub, "premium_unlimited_override", False)):
return {
"state": "unlimited",
"unlimited": True,
"used_bytes": int(sub.premium_used_bytes or 0),
"limit_bytes": None,
"percent": None,
}
limit_bytes = _premium_limit_bytes_from_subscription(sub)
if limit_bytes <= 0:
return {"state": "none"}
used_bytes = int(sub.premium_used_bytes or 0)
ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0
pct = int(max(0, min(100, round(ratio * 100))))
if ratio >= 1.0:
state = "critical"
elif ratio >= 0.85:
state = "warn"
else:
state = "good"
return {
"state": state,
"unlimited": False,
"used_bytes": used_bytes,
"limit_bytes": limit_bytes,
"percent": pct,
}
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
return {
"subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid,
"panel_subscription_uuid": sub.panel_subscription_uuid,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"duration_months": sub.duration_months,
"is_active": bool(sub.is_active),
"status_from_panel": sub.status_from_panel,
"traffic_limit_bytes": sub.traffic_limit_bytes,
"traffic_used_bytes": sub.traffic_used_bytes,
"tier_baseline_bytes": sub.tier_baseline_bytes,
"topup_balance_bytes": sub.topup_balance_bytes,
"premium_used_bytes": sub.premium_used_bytes,
"premium_limit_bytes": premium_limit_bytes,
"premium_baseline_bytes": sub.premium_baseline_bytes,
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
"premium_bonus_bytes": premium_bonus_bytes,
"regular_bonus_bytes": regular_bonus_bytes,
"regular_unlimited_override": regular_unlimited_override,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"is_throttled": bool(sub.is_throttled),
}
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
if payment.purchased_gb is None:
return None, None
try:
gb = float(payment.purchased_gb)
except (TypeError, ValueError):
return None, None
sm = (payment.sale_mode or "").strip()
if not sm:
return None, None
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
if base == "premium_topup":
return None, gb
if base in {"traffic", "traffic_package", "topup"}:
return gb, None
return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
if loaded_user is None:
return str(payment_user_id)
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".strip()
if full:
return full
un = (getattr(loaded_user, "username", None) or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
return str(payment_user_id)
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
return str(payment_user_id)
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
# Some admin routes serialize payments after the session scope is closed.
telegram_id = None
loaded_user = payment.__dict__.get("user")
user_label = _payment_user_display_label(loaded_user, int(payment.user_id))
if loaded_user is not None:
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
try:
telegram_id = int(tid)
except (TypeError, ValueError):
telegram_id = None
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
return {
"payment_id": int(payment.payment_id),
"user_id": int(payment.user_id),
"user_label": user_label,
"telegram_id": telegram_id,
"traffic_regular_gb": reg_gb,
"traffic_premium_gb": prem_gb,
"provider": payment.provider,
"provider_payment_id": payment.provider_payment_id,
"amount": float(payment.amount),
"currency": payment.currency,
"status": payment.status,
"description": payment.description,
"subscription_duration_months": payment.subscription_duration_months,
"sale_mode": payment.sale_mode,
"tariff_key": payment.tariff_key,
"purchased_gb": payment.purchased_gb,
"purchased_hwid_devices": payment.purchased_hwid_devices,
"created_at": payment.created_at.isoformat() if payment.created_at else None,
}
def _serialize_promo(promo: PromoCode) -> Dict[str, Any]:
return {
"id": int(promo.promo_code_id),
"code": promo.code,
"bonus_days": int(promo.bonus_days),
"max_activations": int(promo.max_activations),
"current_activations": int(promo.current_activations or 0),
"is_active": bool(promo.is_active),
"valid_until": promo.valid_until.isoformat() if promo.valid_until else None,
"created_at": promo.created_at.isoformat() if promo.created_at else None,
"created_by_admin_id": int(promo.created_by_admin_id)
if promo.created_by_admin_id
else None,
}
def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {
"id": int(campaign.ad_campaign_id),
"source": campaign.source,
"start_param": campaign.start_param,
"cost": float(campaign.cost or 0),
"is_active": bool(campaign.is_active),
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"stats": totals or {},
}
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
def _tariffs_config_path(settings: Settings) -> Path:
return Path(settings.TARIFFS_CONFIG_PATH).expanduser()
def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
data = _tariffs_config_payload(config)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
# A docker-compose single-file bind mount can make /app/config
# unwritable while the mounted tariffs.json itself is writable.
# Fall back to updating the existing file in-place.
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]:
uo = node.get("usersOnline")
if uo is None:
uo = node.get("users_online")
if uo is None:
uo = node.get("onlineUsers") or node.get("online_users")
if uo is None:
mg = node.get("metricGroups")
if isinstance(mg, dict):
uo = mg.get("onlineUsers") or mg.get("online_users")
if uo is None:
return None
try:
return int(uo)
except (TypeError, ValueError):
return None
def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]:
"""Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload."""
out: Dict[str, int] = {}
raw_list: Optional[List[Any]] = None
if isinstance(nodes_payload, list):
raw_list = nodes_payload
elif isinstance(nodes_payload, dict):
raw_list = nodes_payload.get("nodes")
if raw_list is None:
raw_list = nodes_payload.get("items") or nodes_payload.get("data")
if not isinstance(raw_list, list):
return out
for n in raw_list:
if not isinstance(n, dict):
continue
key = _panel_node_uuid_key(n)
if not key:
continue
online = _panel_node_users_online(n)
if online is not None:
out[key] = online
return out
def _enrich_bandwidth_nodes_with_online(
bw: Any,
online_by_uuid: Dict[str, int],
online_by_name: Optional[Dict[str, int]] = None,
) -> None:
"""Attach usersOnline to topNodes/series (UUID and optional node name)."""
if not isinstance(bw, dict):
return
if not online_by_uuid and not online_by_name:
return
for key in ("topNodes", "series"):
arr = bw.get(key)
if not isinstance(arr, list):
continue
for item in arr:
if not isinstance(item, dict):
continue
if item.get("usersOnline") is not None:
continue
uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid")
if uid and online_by_uuid:
hit = online_by_uuid.get(str(uid).strip().lower())
if hit is not None:
item["usersOnline"] = hit
continue
if online_by_name:
nm = item.get("name")
if nm and isinstance(nm, str):
hitn = online_by_name.get(nm.strip().lower())
if hitn is not None:
item["usersOnline"] = hitn
def _build_admin_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
"""Mirror of ``subscription_webapp._build_webapp_referral_link``.
Kept local to avoid a cross-module import cycle (subscription_webapp
imports admin_api).
"""
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
new_query = "&".join(f"{k}={v}" for k, v in query.items())
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
+36
View File
@@ -0,0 +1,36 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_logs_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50)))
user_filter = request.query.get("user_id")
async with async_session_factory() as session:
if user_filter:
try:
user_id = int(user_filter)
except (TypeError, ValueError):
return _error(400, "invalid_user_id")
entries = await message_log_dal.get_user_message_logs(
session, user_id, page_size, page * page_size
)
total = await message_log_dal.count_user_message_logs(session, user_id)
else:
entries = await message_log_dal.get_all_message_logs(
session, page_size, page * page_size
)
total = await message_log_dal.count_all_message_logs(session)
return _ok(
{
"logs": [_serialize_log(entry) for entry in entries],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
+35
View File
@@ -0,0 +1,35 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
panel_service = request.app.get("panel_service")
if panel_service is None:
return _error(503, "panel_unavailable", "Panel service unavailable")
try:
squads = await panel_service.get_internal_squads()
except Exception as exc:
logger.exception("Failed to load internal squads from panel")
return _error(502, "panel_request_failed", str(exc))
if squads is None:
return _error(502, "panel_request_failed", "Unable to load internal squads")
items = []
for squad in squads:
if not isinstance(squad, dict):
continue
uuid = squad.get("uuid") or squad.get("id")
if not uuid:
continue
items.append(
{
"uuid": str(uuid),
"name": squad.get("name") or squad.get("title") or str(uuid),
"members_count": squad.get("membersCount")
or squad.get("usersCount")
or squad.get("members_count"),
"active_inbounds_count": squad.get("activeInboundsCount")
or squad.get("active_inbounds_count"),
}
)
return _ok({"squads": items})
+95
View File
@@ -0,0 +1,95 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_payments_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.offset(page * page_size)
.limit(page_size)
)
rows = (await session.execute(stmt)).scalars().all()
total = await payment_dal.get_payments_count(session)
return _ok(
{
"payments": [_serialize_payment(p) for p in rows],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
async def admin_payments_export_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.limit(10000)
)
rows = (await session.execute(stmt)).scalars().all()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"payment_id",
"user_id",
"user_label",
"provider",
"provider_payment_id",
"amount",
"currency",
"status",
"description",
"duration_months",
"sale_mode",
"tariff_key",
"created_at",
]
)
for p in rows:
label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id)
writer.writerow(
[
p.payment_id,
p.user_id,
label,
p.provider,
p.provider_payment_id or "",
p.amount,
p.currency,
p.status,
p.description or "",
p.subscription_duration_months or "",
p.sale_mode or "",
p.tariff_key or "",
p.created_at.isoformat() if p.created_at else "",
]
)
response = web.Response(
body=buffer.getvalue().encode("utf-8-sig"),
content_type="text/csv",
charset="utf-8",
)
response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"'
return response
+96
View File
@@ -0,0 +1,96 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_promos_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async with async_session_factory() as session:
promos = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=page_size, offset=page * page_size
)
total = await promo_code_dal.get_promo_codes_count(session)
return _ok(
{
"promos": [_serialize_promo(p) for p in promos],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
async def admin_promo_create_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
code = str(payload.get("code") or "").strip().upper()
bonus_days = int(payload.get("bonus_days") or 0)
max_activations = int(payload.get("max_activations") or 0)
valid_days = payload.get("valid_days")
if not code or bonus_days <= 0 or max_activations <= 0:
return _error(400, "invalid_payload")
valid_until = None
if valid_days:
try:
valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days))
except (TypeError, ValueError):
return _error(400, "invalid_valid_days")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await promo_code_dal.get_promo_code_by_code(session, code)
if existing:
return _error(409, "duplicate_code")
promo = await promo_code_dal.create_promo_code(
session,
{
"code": code,
"bonus_days": bonus_days,
"max_activations": max_activations,
"valid_until": valid_until,
"created_by_admin_id": actor_id,
"is_active": True,
},
)
await session.commit()
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_update_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
payload = await _read_json(request)
update_data: Dict[str, Any] = {}
if "is_active" in payload:
update_data["is_active"] = bool(payload["is_active"])
if "bonus_days" in payload and payload["bonus_days"] is not None:
update_data["bonus_days"] = int(payload["bonus_days"])
if "max_activations" in payload and payload["max_activations"] is not None:
update_data["max_activations"] = int(payload["max_activations"])
if not update_data:
return _error(400, "no_changes")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.update_promo_code(session, promo_id, update_data)
if not promo:
return _error(404, "not_found")
await session.commit()
await session.refresh(promo)
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.delete_promo_code(session, promo_id)
if not promo:
return _error(404, "not_found")
await session.commit()
return _ok({})
+57
View File
@@ -0,0 +1,57 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def setup_admin_routes(app: web.Application) -> None:
router = app.router
router.add_get("/api/admin/me", admin_me_route)
router.add_get("/api/admin/stats", admin_stats_route)
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/premium-override",
admin_user_premium_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
admin_user_regular_traffic_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route,
)
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
router.add_get("/api/admin/payments", admin_payments_list_route)
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
router.add_get("/api/admin/promos", admin_promos_list_route)
router.add_post("/api/admin/promos", admin_promo_create_route)
router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route)
router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route)
router.add_get("/api/admin/logs", admin_logs_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
router.add_get("/api/admin/ads", admin_ads_list_route)
router.add_post("/api/admin/ads", admin_ad_create_route)
router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route)
router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route)
router.add_get("/api/admin/settings", admin_settings_get_route)
router.add_patch("/api/admin/settings", admin_settings_patch_route)
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+74
View File
@@ -0,0 +1,74 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_settings_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
overrides = await app_settings_dal.get_overrides_with_meta(session)
overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload()
sections: Dict[str, Dict[str, Any]] = {}
for field in fields:
key = field["key"]
section_id = field["section"]
if section_id not in sections:
sections[section_id] = {
"id": section_id,
"order": field["section_order"],
"fields": [],
}
override = overrides_by_key.get(key)
value = current_value(settings, key)
is_secret = bool(field.get("secret"))
response_field = {
**field,
"value": "" if is_secret else value,
"overridden": bool(override),
"updated_at": override.get("updated_at") if override else None,
}
if is_secret:
response_field["has_value"] = bool(value)
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
return _ok({"sections": ordered_sections})
async def admin_settings_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = await _read_json(request)
updates = payload.get("updates") or {}
deletes = payload.get("deletes") or []
if not isinstance(updates, dict):
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
result = await update_overrides(
settings,
async_session_factory,
updates=updates,
deletes=deletes,
actor_id=actor_id,
)
if not result.get("ok"):
return web.json_response(
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
status=400,
)
# Bust the public webapp settings cache so users see new values immediately.
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+89
View File
@@ -0,0 +1,89 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_me_route(request: web.Request) -> web.Response:
user_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
async def admin_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user_stats = await user_dal.get_enhanced_user_statistics(session)
financial_stats = await payment_dal.get_financial_statistics(session)
sync_status = await panel_sync_dal.get_panel_sync_status(session)
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
payload = {
"users": user_stats,
"financial": financial_stats,
"panel_sync": {
"status": sync_status.status if sync_status else "never_run",
"last_sync_time": sync_status.last_sync_time.isoformat()
if sync_status and sync_status.last_sync_time
else None,
"details": sync_status.details if sync_status else None,
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
},
"recent_payments": [_serialize_payment(p) for p in recent_payments],
}
panel_service = request.app.get("panel_service")
if panel_service is not None:
try:
system = await panel_service.get_system_stats()
bandwidth = await panel_service.get_bandwidth_stats()
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
}
try:
nodes = await panel_service.get_nodes_statistics()
panel_body["nodes"] = nodes or {}
except Exception as exc_nodes: # pragma: no cover - optional endpoint
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
panel_body["nodes"] = {}
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
)
panel_body["nodes_bandwidth"] = nodes_bw or {}
except Exception as exc_nb: # pragma: no cover - optional endpoint
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
panel_body["nodes_bandwidth"] = {}
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
lookups = await panel_service.get_nodes_online_lookups()
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
payload["panel"] = panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
payload["panel"] = {"error": "unavailable"}
queue_manager = get_queue_manager()
if queue_manager:
try:
payload["queue"] = queue_manager.get_queue_stats()
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
return _ok(payload)
+23
View File
@@ -0,0 +1,23 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_sync_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
panel_service = request.app.get("panel_service")
if panel_service is None:
return _error(503, "panel_unavailable")
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
i18n = request.app.get("i18n")
from bot.handlers.admin.sync_admin import perform_sync
async with async_session_factory() as session:
result = await perform_sync(
panel_service=panel_service,
session=session,
settings=settings,
i18n_instance=i18n,
)
return _ok({"result": result or {}})
+63
View File
@@ -0,0 +1,63 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
path = _tariffs_config_path(settings)
try:
config = settings.tariffs_config
except Exception as exc:
logger.warning("Invalid tariffs config requested from admin UI: %s", exc)
return _error(400, "invalid_tariffs_config", str(exc))
if config is None:
return _ok(
{
"exists": path.exists(),
"path": str(path),
"catalog": {
"default_tariff": "",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
}
)
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
}
)
async def admin_tariffs_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = TariffsConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_tariffs_config", str(exc))
path = _tariffs_config_path(settings)
try:
_write_tariffs_config_file(path, config)
except OSError as exc:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
+925
View File
@@ -0,0 +1,925 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_users_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
query = (request.query.get("q") or "").strip()
filter_value = (request.query.get("filter") or "all").lower()
panel_status = (request.query.get("panel_status") or "all").lower()
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
sort_value = (request.query.get("sort") or "registered_desc").lower()
async with async_session_factory() as session:
users, total = await _filter_and_sort_users(
session,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
page=page,
page_size=page_size,
)
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
serialized = []
for user in users:
payload = _serialize_user(user)
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
if user.user_id in cached_avatar_ids
else None
)
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
serialized.append(payload)
return _ok(
{
"users": serialized,
"page": page,
"page_size": page_size,
"total": total,
}
)
async def _bulk_user_statuses(
session: AsyncSession, user_ids: List[int]
) -> Dict[int, Dict[str, Optional[str]]]:
"""Return active subscription status for a batch of users.
Returns the panel status (active/expired/limited/disabled) when an active
subscription exists, otherwise ``"bot_only"`` when the user is in the bot
but has no panel subscription.
"""
if not user_ids:
return {}
stmt = (
select(
Subscription.user_id,
Subscription.status_from_panel,
Subscription.is_active,
Subscription.end_date,
)
.where(Subscription.user_id.in_(user_ids))
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
)
rows = (await session.execute(stmt)).all()
out: Dict[int, Dict[str, Optional[str]]] = {}
for uid, panel_status, is_active, end_date in rows:
if uid in out:
continue
if is_active:
status = (panel_status or "active").lower()
else:
status = (panel_status or "expired").lower()
out[uid] = {
"status": status,
"end_date": end_date.isoformat() if end_date else None,
}
for uid in user_ids:
if uid not in out:
out[uid] = {"status": "bot_only", "end_date": None}
return out
async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]:
"""Return ``{user_id: cache_key}`` for users with a cached Telegram avatar.
The cache key is the row's ``updated_at`` timestamp — used as a
cache-buster query param so the browser refetches when the avatar
changes.
"""
if not user_ids:
return {}
stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where(
UserTelegramAvatar.user_id.in_(user_ids)
)
rows = (await session.execute(stmt)).all()
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL
and uses admin auth. Only the cached blob from
``user_telegram_avatars`` is served — refreshing from Telegram is the
job of the user-facing endpoint, so the admin list never blocks on a
Telegram round-trip.
"""
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
avatar = await session.get(UserTelegramAvatar, target_id)
if not avatar:
raise web.HTTPNotFound(text="avatar_not_cached")
etag = (
f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"'
if avatar.updated_at
else None
)
if etag and request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers={"ETag": etag})
response = web.Response(
body=bytes(avatar.image_bytes),
content_type=avatar.content_type or "image/jpeg",
)
response.headers["Cache-Control"] = "private, max-age=3600"
if etag:
response.headers["ETag"] = etag
return response
def _ranked_active_subscriptions_sq(now: datetime):
"""Latest active subscription per user (same ordering as subscription_dal)."""
rn = sa_func.row_number().over(
partition_by=Subscription.user_id,
order_by=(
Subscription.end_date.desc(),
Subscription.subscription_id.desc(),
),
)
inner = (
select(
Subscription.user_id,
Subscription.subscription_id,
Subscription.premium_used_bytes,
Subscription.premium_baseline_bytes,
Subscription.premium_topup_balance_bytes,
Subscription.premium_topup_used_bytes,
Subscription.premium_bonus_bytes,
Subscription.premium_unlimited_override,
rn.label("rn"),
)
.where(
Subscription.is_active.is_(True),
Subscription.end_date > now,
)
.subquery()
)
return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub")
async def _bulk_active_subscriptions_for_users(
session: AsyncSession, user_ids: List[int]
) -> Dict[int, Subscription]:
"""Active subscription row per user (for admin list premium traffic column)."""
if not user_ids:
return {}
now = datetime.now(timezone.utc)
stmt = (
select(Subscription)
.where(
Subscription.user_id.in_(user_ids),
Subscription.is_active.is_(True),
Subscription.end_date > now,
)
.order_by(
Subscription.user_id.asc(),
Subscription.end_date.desc(),
Subscription.subscription_id.desc(),
)
)
rows = (await session.execute(stmt)).scalars().all()
out: Dict[int, Subscription] = {}
for sub in rows:
uid = int(sub.user_id)
if uid not in out:
out[uid] = sub
return out
async def _filter_and_sort_users(
session: AsyncSession,
*,
query: str = "",
filter_value: str,
panel_status: str = "all",
premium_traffic: str = "all",
sort_value: str,
page: int,
page_size: int,
) -> tuple[List[User], int]:
"""Return paginated users with optional search, filter and sort applied."""
now = datetime.now(timezone.utc)
sort_key = (sort_value or "registered_desc").lower()
pt_filter = (premium_traffic or "all").lower()
needs_premium_sq = pt_filter != "all" or sort_key in {
"premium_ratio_asc",
"premium_ratio_desc",
}
stmt = select(User)
count_stmt = select(sa_func.count(User.user_id))
sq = None
ratio_expr = None
plim_expr = None
pu_expr = None
if needs_premium_sq:
sq = _ranked_active_subscriptions_sq(now)
stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id)
count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id)
pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0)
plim_expr = (
sa_func.coalesce(sq.c.premium_baseline_bytes, 0)
+ sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0)
+ sa_func.coalesce(sq.c.premium_topup_used_bytes, 0)
+ pb
)
pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0)
ratio_expr = case(
(sq.c.user_id.is_(None), None),
(sq.c.premium_unlimited_override.is_(True), None),
(plim_expr <= 0, None),
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
)
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
count_stmt = count_stmt.where(search_cond)
f = (filter_value or "all").lower()
if f == "banned":
cond = User.is_banned.is_(True)
elif f == "active":
cond = User.is_banned.is_(False)
elif f == "tg_linked":
cond = User.telegram_id.is_not(None)
elif f == "no_tg":
cond = User.telegram_id.is_(None)
elif f == "email_linked":
cond = User.email.is_not(None)
elif f == "no_email":
cond = User.email.is_(None)
elif f == "panel_linked":
cond = User.panel_user_uuid.is_not(None)
else:
cond = None
if cond is not None:
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
panel_cond = _user_panel_status_condition(panel_status)
if panel_cond is not None:
stmt = stmt.where(panel_cond)
count_stmt = count_stmt.where(panel_cond)
if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None:
if pt_filter == "none":
premium_cond = or_(
sq.c.user_id.is_(None),
and_(
sq.c.premium_unlimited_override.is_(False),
plim_expr <= 0,
),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "unlimited":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(True),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "good":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
(100 * pu_expr) < (85 * plim_expr),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "warn":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
(100 * pu_expr) >= (85 * plim_expr),
pu_expr < plim_expr,
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "critical":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
pu_expr >= plim_expr,
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
sort_map = {
"registered_desc": User.registration_date.desc().nullslast(),
"registered_asc": User.registration_date.asc().nullslast(),
"name_asc": (
sa_func.coalesce(User.first_name, User.username, User.email).asc(),
User.user_id.asc(),
),
"name_desc": (
sa_func.coalesce(User.first_name, User.username, User.email).desc(),
User.user_id.desc(),
),
"id_asc": User.user_id.asc(),
"id_desc": User.user_id.desc(),
}
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
else:
order = sort_map.get(sort_key, sort_map["registered_desc"])
if isinstance(order, tuple):
stmt = stmt.order_by(*order)
else:
stmt = stmt.order_by(order)
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
users = (await session.execute(stmt)).scalars().all()
total = (await session.execute(count_stmt)).scalar_one()
return users, int(total)
def _user_panel_status_condition(panel_status: str):
status = (panel_status or "all").lower()
if status not in {"active", "expired", "limited"}:
return None
normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
blank_status = or_(
Subscription.status_from_panel.is_(None), Subscription.status_from_panel == ""
)
if status == "active":
status_cond = or_(
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
)
elif status == "expired":
status_cond = or_(
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
)
else:
status_cond = normalized_status == "limited"
return (
select(Subscription.subscription_id)
.where(Subscription.user_id == User.user_id, status_cond)
.exists()
)
def _user_search_condition(query: str):
raw = (query or "").strip().lstrip("@")
if not raw:
return None
like = f"%{raw}%"
conditions = [
User.username.ilike(like),
User.first_name.ilike(like),
User.last_name.ilike(like),
User.email.ilike(like),
]
if raw.isdigit():
numeric = int(raw)
conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
return or_(*conditions)
async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
settings: Settings = request.app["settings"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found", "User not found")
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
latest_subs_stmt = (
select(Subscription)
.where(Subscription.user_id == target_id)
.order_by(Subscription.start_date.desc().nullslast())
.limit(20)
)
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
total_paid = await payment_dal.get_user_total_paid(session, target_id)
recent_payments_stmt = (
select(Payment)
.where(Payment.user_id == target_id)
.order_by(Payment.created_at.desc())
.limit(20)
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
try:
referral_code = await user_dal.ensure_referral_code(session, user)
await session.commit()
except Exception as exc_ref: # pragma: no cover — defensive
logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref)
await session.rollback()
referral_service: Optional[ReferralService] = request.app.get("referral_service")
bot_username = request.app.get("bot_username") or ""
referral_bot_link: Optional[str] = None
if referral_service and bot_username and referral_code:
try:
async with async_session_factory() as session:
referral_bot_link = await referral_service.generate_referral_link(
session, bot_username, target_id
)
except Exception as exc_link: # pragma: no cover
logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link)
referral_webapp_link = _build_admin_webapp_referral_link(
getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None),
referral_code,
)
# Subscription page URL — the raw panel `subscriptionUrl` that the user
# imports into their VPN client. May be missing if the user has never
# been provisioned on the panel.
subscription_url: Optional[str] = None
panel_uuid = getattr(user, "panel_user_uuid", None)
if panel_uuid:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
if panel_service is not None:
try:
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
if panel_data:
subscription_url = panel_data.get("subscriptionUrl") or None
except Exception as exc_panel: # pragma: no cover
logger.warning(
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
target_id,
panel_uuid,
exc_panel,
)
serialized_user = _serialize_user(user)
serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
if target_id in avatar_keys
else None
)
return _ok(
{
"user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0),
"subscription_url": subscription_url,
"referral": {
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
},
}
)
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
desired = bool(payload.get("banned"))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
user.is_banned = bool(desired)
await session.commit()
await session.refresh(user)
return _ok({"user": _serialize_user(user)})
async def admin_user_message_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
if not text:
return _error(400, "empty_text")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
target_user = await user_dal.get_user_by_id(session, target_id)
if not target_user or not target_user.telegram_id:
return _error(404, "no_telegram_account")
try:
await send_message_via_queue(
queue_manager,
int(target_user.telegram_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Admin direct message failed: %s", exc)
return _error(502, "send_failed", str(exc))
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_direct_message_webapp",
"content": text[:4000],
"is_admin_event": True,
"target_user_id": target_id,
},
)
return _ok({})
async def admin_user_message_preview_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
admin_telegram_id = request.get("admin_telegram_id")
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
if not text:
return _error(400, "empty_text")
if not admin_telegram_id:
return _error(403, "admin_telegram_unavailable")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
try:
await send_message_via_queue(
queue_manager,
int(admin_telegram_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Admin direct message preview failed: %s", exc)
return _error(502, "preview_failed", str(exc))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_direct_message_preview_webapp",
"content": text[:4000],
"is_admin_event": True,
"target_user_id": target_id,
},
)
return _ok({})
async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_delete_user_webapp",
"content": f"Deleted user_id={target_id}",
"is_admin_event": True,
},
)
await session.commit()
return _ok({})
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if active:
await session.delete(active)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
return _ok({})
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
subscription_service = request.app.get("subscription_service")
unlimited = bool(payload.get("unlimited"))
bonus_bytes_raw = payload.get("bonus_bytes")
bonus_gb_raw = payload.get("bonus_gb")
if bonus_bytes_raw is None and bonus_gb_raw is None:
bonus_bytes = 0
elif bonus_bytes_raw is not None:
try:
bonus_bytes = int(bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
else:
try:
bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_gb must be a number")
if bonus_bytes < 0:
return _error(400, "invalid_bonus", "bonus must be non-negative")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.premium_unlimited_override = bool(unlimited)
active.premium_bonus_bytes = int(bonus_bytes)
if active.premium_unlimited_override:
active.premium_is_limited = False
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_premium_override_webapp",
"content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
if subscription_service is not None:
await subscription_service.sync_premium_squad_access_to_panel(session, target_id)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
unlimited = bool(payload.get("unlimited"))
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
regular_bonus_bytes = 0
elif regular_bonus_bytes_raw is not None:
try:
regular_bonus_bytes = int(regular_bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
else:
try:
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
if regular_bonus_bytes < 0:
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
subscription_service = request.app.get("subscription_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.regular_unlimited_override = bool(unlimited)
active.regular_bonus_bytes = int(regular_bonus_bytes)
if subscription_service is not None:
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_regular_traffic_override_webapp",
"content": (
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
"""Credit regular or premium traffic to a user without a payment.
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
the chosen balance grows, panel limit/squads are refreshed, and an entry
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
"""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
kind = str(payload.get("kind") or "regular").strip().lower()
if kind not in {"regular", "premium"}:
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
bytes_raw = payload.get("bytes")
gb_raw = payload.get("gb")
if bytes_raw is None and gb_raw is None:
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
try:
if bytes_raw is not None:
grant_bytes = int(bytes_raw)
gb_value = grant_bytes / (1024**3)
else:
gb_value = float(gb_raw)
grant_bytes = int(round(gb_value * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_amount", "amount must be a positive number")
if gb_value <= 0 or grant_bytes <= 0:
return _error(400, "invalid_amount", "amount must be positive")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
if kind == "regular":
result = await subscription_service.admin_grant_topup(session, target_id, gb_value)
else:
result = await subscription_service.admin_grant_premium_topup(
session, target_id, gb_value
)
if not result:
await session.rollback()
return _error(
422,
"grant_failed",
"Unable to credit traffic (missing tariff/squads or panel error)",
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_traffic_grant_webapp",
"content": f"kind={kind} bytes={grant_bytes}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
"grant": {
"kind": kind,
"granted_bytes": grant_bytes,
"granted_gb": gb_value,
},
}
)
async def admin_user_extend_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
try:
days = int(payload.get("days") or 0)
except (TypeError, ValueError):
return _error(400, "invalid_days")
if days <= 0:
return _error(400, "invalid_days")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
new_end = await subscription_service.extend_active_subscription_days(
session,
target_id,
days,
"admin_extend_subscription_webapp",
)
if not new_end:
await session.rollback()
return _error(500, "extend_failed")
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_extend_subscription_webapp",
"content": f"+{days}d -> {new_end.isoformat()}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
}
)
+527
View File
@@ -0,0 +1,527 @@
"""Manifest of settings editable from the admin web app.
Each entry describes a single overridable attribute on the global
``Settings`` instance. The manifest is the only contract between the
admin UI and the backend: keys not listed here cannot be changed via
the API, even by an admin.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
section: str
label: str
description: str = ""
placeholder: str = ""
optional: bool = True
secret: bool = False
min: Optional[float] = None
max: Optional[float] = None
choices: Optional[Tuple[Tuple[str, str], ...]] = None
subsection: Optional[str] = None # group label inside a section
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
"DEFAULT_LANGUAGE",
"string",
"general",
"Язык по умолчанию",
"Используется для приветственных сообщений и публичных страниц.",
),
SettingField(
"DEFAULT_CURRENCY_SYMBOL",
"string",
"general",
"Валюта",
"Например, RUB, USD, EUR.",
placeholder="RUB",
),
SettingField(
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
),
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
SettingField(
"START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""
),
SettingField(
"REQUIRED_CHANNEL_ID",
"int",
"general",
"ID обязательного канала",
"Telegram ID канала, в котором нужно состоять.",
),
SettingField(
"REQUIRED_CHANNEL_LINK",
"string",
"general",
"Ссылка на канал",
"Имя пользователя или invite-link.",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
),
SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField(
"WEBAPP_LOGO_EMOJI_FONT",
"string",
"appearance",
"Шрифт эмоджи-логотипа",
"Выберите шрифт для отображения эмодзи-логотипа",
choices=(
("system", "Системный (по умолчанию)"),
("noto-color", "Noto Color Emoji"),
("noto-color-animated", "Noto Color Emoji Animated"),
("noto-emoji", "Noto Emoji"),
("twemoji", "Twitter Emoji"),
("openmoji", "OpenMoji"),
("apple", "Apple Color Emoji (local)"),
("segoe", "Segoe UI Emoji (local)"),
("noto-local", "Noto Emoji (local)"),
),
),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
# ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"),
SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"),
SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"),
SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"),
SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"),
SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"),
SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"),
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
SettingField(
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"pricing",
"Порядок методов оплаты",
"Через запятую, например: severpay,freekassa,yookassa",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"payments",
"Порядок методов оплаты",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
subsection="Общие",
),
# YooKassa
SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"),
SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"),
SettingField(
"YOOKASSA_SECRET_KEY",
"string",
"payments",
"Secret key",
subsection="YooKassa",
secret=True,
),
SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"),
SettingField(
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
"string",
"payments",
"Email для чека по умолчанию",
subsection="YooKassa",
),
SettingField(
"YOOKASSA_VAT_CODE",
"int",
"payments",
"VAT code",
"1..6 в зависимости от системы налогообложения",
subsection="YooKassa",
min=1,
max=6,
),
SettingField(
"YOOKASSA_AUTOPAYMENTS_ENABLED",
"bool",
"payments",
"Автоплатежи (recurring)",
subsection="YooKassa",
),
SettingField(
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
"bool",
"payments",
"Принудительная привязка карты",
subsection="YooKassa",
),
# FreeKassa
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
SettingField(
"FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"
),
SettingField(
"FREEKASSA_FIRST_SECRET",
"string",
"payments",
"First secret",
subsection="FreeKassa",
secret=True,
),
SettingField(
"FREEKASSA_SECOND_SECRET",
"string",
"payments",
"Second secret",
"Используется для проверки подписи входящих уведомлений",
subsection="FreeKassa",
secret=True,
),
SettingField(
"FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True
),
SettingField(
"FREEKASSA_PAYMENT_URL",
"url",
"payments",
"Payment URL",
placeholder="https://pay.freekassa.ru/",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_PAYMENT_METHOD_ID",
"int",
"payments",
"Метод оплаты по умолчанию",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_PAYMENT_IP",
"string",
"payments",
"IP сервера",
"Передаётся в подпись запроса при создании платежа",
subsection="FreeKassa",
),
SettingField(
"FREEKASSA_TRUSTED_IPS",
"string",
"payments",
"Доверенные IP",
"Через запятую — IP-адреса, с которых принимаются нотификации",
subsection="FreeKassa",
),
# Platega
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
SettingField(
"PLATEGA_BASE_URL",
"url",
"payments",
"Base URL",
placeholder="https://app.platega.io",
subsection="Platega",
),
SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"),
SettingField(
"PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True
),
SettingField(
"PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"
),
SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"),
SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"),
SettingField(
"PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"
),
SettingField(
"PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"
),
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
# SeverPay
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
SettingField(
"SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True
),
SettingField(
"SEVERPAY_BASE_URL",
"url",
"payments",
"Base URL",
placeholder="https://severpay.io/api/merchant",
subsection="SeverPay",
),
SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"),
SettingField(
"SEVERPAY_LIFETIME_MINUTES",
"int",
"payments",
"Срок жизни ссылки (мин)",
"30..4320; пусто — значение провайдера",
subsection="SeverPay",
min=30,
max=4320,
),
# CryptoPay
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
SettingField(
"CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True
),
SettingField(
"CRYPTOPAY_NETWORK",
"string",
"payments",
"Network",
"mainnet или testnet",
subsection="CryptoPay",
),
SettingField(
"CRYPTOPAY_CURRENCY_TYPE",
"string",
"payments",
"Currency type",
"fiat или crypto",
subsection="CryptoPay",
),
SettingField(
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
),
SettingField(
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"int",
"referral",
"Бонус приглашающему: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"int",
"referral",
"Бонус приглашающему: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"int",
"referral",
"Бонус приглашающему: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"int",
"referral",
"Бонус приглашающему: 12 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
"int",
"referral",
"Бонус приглашённому: 1 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
"int",
"referral",
"Бонус приглашённому: 3 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
"int",
"referral",
"Бонус приглашённому: 6 мес.",
min=0,
),
SettingField(
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
"int",
"referral",
"Бонус приглашённому: 12 мес.",
min=0,
),
# ─── Notifications ─────────────────────────────────────────────
SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
"bool",
"notifications",
"Включены уведомления о подписке",
),
SettingField(
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
),
SettingField(
"SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"
),
SettingField(
"SUBSCRIPTION_NOTIFY_DAYS_BEFORE",
"int",
"notifications",
"За сколько дней предупреждать",
min=0,
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField(
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
),
SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"),
SettingField(
"LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"
),
SettingField(
"LOG_ADMIN_ACTIONS",
"bool",
"notifications",
"Логировать действия администраторов",
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
i18n_label_key="settings_field_log_admin_actions_label",
i18n_description_key="settings_field_log_admin_actions_description",
),
SettingField(
"LOG_LEVEL",
"string",
"notifications",
"Глобальный уровень логов",
"DEBUG / INFO / WARNING / ERROR",
),
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
# ─── Devices ───────────────────────────────────────────────────
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
SettingField(
"USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0
),
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
]
def get_field_by_key(key: str) -> Optional[SettingField]:
for field in SETTINGS_MANIFEST:
if field.key == key:
return field
return None
def manifest_keys() -> List[str]:
return [f.key for f in SETTINGS_MANIFEST]
def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return None
if field.type == "bool":
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return bool(raw)
if field.type == "int":
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: integer expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if field.type == "float":
try:
value = float(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: number expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if isinstance(raw, str):
return raw.strip()
return str(raw)
def manifest_payload() -> List[dict]:
"""Serialize the manifest for the admin UI."""
sections_order = {
"general": 1,
"appearance": 2,
"pricing": 3,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"devices": 8,
}
items: List[dict] = []
for field in SETTINGS_MANIFEST:
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
item = {
"key": field.key,
"type": field.type,
"section": field.section,
"section_order": sections_order.get(field.section, 99),
"subsection": field.subsection,
"label": field.label,
"description": field.description,
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
"i18n_description_key": field.i18n_description_key
or (auto_description_i18n_key if field.description else None),
"placeholder": field.placeholder,
"optional": field.optional,
"secret": field.secret,
}
if field.choices:
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
items.append(item)
return items
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"$lib/*": ["src/lib/*"],
"$components/*": ["src/lib/components/*"]
}
},
"include": ["src/**/*"]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,280 @@
<script>
import {
ArrowRight,
CheckCircle2,
Circle,
Copy,
Crown,
Database,
Download,
Gift,
Globe2,
LockKeyhole,
Mail,
RefreshCw,
Repeat2,
Send,
Ticket,
UserRound,
Zap,
} from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { LinearProgress } from "$components/patterns/webapp/index.js";
import BackTitle from "./preview/BackTitle.svelte";
import PhoneFrame from "./preview/PhoneFrame.svelte";
import PreviewMethods from "./preview/PreviewMethods.svelte";
import PreviewNav from "./preview/PreviewNav.svelte";
export let config = {};
export let mockData = {};
const title = config.title || "/minishop";
const logoEmoji = config.logoEmoji || "🫥";
const plans = mockData.plans || [];
const sub = mockData.subscription || {};
const methods = mockData.payment_methods || [];
const user = mockData.user || {};
const tariffs = [
[
"subscription",
"Подписка",
"Безлимитный трафик",
"Идеально для постоянного использования",
Zap,
],
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
];
const traffic = [
[20, 290],
[50, 590],
[100, 990],
[300, 2190],
];
const settingsRows = [
[Globe2, "Язык интерфейса", "Русский"],
[
Send,
"Привязка Telegram",
user.telegram_linked ? `@${user.username || "username"}` : "Не привязан",
],
[Mail, "Привязка почты", user.email || "Не привязана"],
[UserRound, "Выйти", "Завершить сессию"],
];
const previewTelegramName =
user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
const previewEmail = user.email || "Почта не привязана";
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
const previewAvatar = user.telegram_photo_url || "";
function money(value) {
return `${value} ₽`;
}
</script>
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
<PhoneFrame number="1" label="Главный экран">
<main class="home-layout">
<div class="login-brand home-brand">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1>
</div>
<div class="home-bottom">
<Card class="status-card">
<div class="sub-status">
<CheckCircle2 size={23} />
<div>
<h2>Подписка активна</h2>
<p>до {sub.end_date_text}</p>
</div>
</div>
</Card>
<Card>
<div class="traffic-top">
<span>Использовано трафика</span><strong
>{sub.traffic_used} из {sub.traffic_limit}</strong
>
</div>
<LinearProgress value={18} />
<div class="traffic-percent">18%</div>
</Card>
<div class="action-stack">
<Button class="wide"><Download size={17} />Установить и настроить</Button>
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
</div>
</div>
<PreviewNav active="home" />
</main>
</PhoneFrame>
<PhoneFrame number="2" label="Выбор тарифа">
<div class="preview-header">
<div class="brand-row">
<div class="brand-mark"><span>{logoEmoji}</span></div>
<strong>{title}</strong>
</div>
</div>
<div class="tariff-list">
{#each tariffs as tariff, index}
<div class:active={index === 0} class="select-card">
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
</div>
{/each}
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
</PhoneFrame>
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
<div class="period-grid">
{#each plans as plan, index}
<div class:active={index === 1} class="period-card">
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small
>{money(Math.round(plan.price / plan.months))}/мес</small
>
{#if index === 1}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"
><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card
>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
<div class="period-grid">
{#each traffic as pack, index}
<div class:active={index === 2} class="period-card">
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small
>{money(Math.round(pack[1] / pack[0]))}/ГБ</small
>
{#if index === 2}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"
><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card
>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="5" label="Смена тарифа">
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
<div class="tariff-list compact">
<div class="select-card">
<span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em
><Circle size={20} />
</div>
<div class="select-card active">
<span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em
>Доплата не требуется</em
><CheckCircle2 size={20} />
</div>
<div class="select-card">
<span><strong>Премиум</strong><small>Максимальная скорость</small></span><em
>Доплата 390 ₽</em
><Circle size={20} />
</div>
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
<div class="preview-modal">
<Repeat2 size={30} />
<strong>Сменить тариф без доплаты?</strong>
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
<Button>Да, сменить</Button>
<Button variant="secondary">Отмена</Button>
</div>
</PhoneFrame>
<PhoneFrame number="6" label="Пригласить друга">
<div class="preview-header">
<div class="brand-row">
<div class="brand-mark"><span>{logoEmoji}</span></div>
<strong>{title}</strong>
</div>
</div>
<Card>
<div class="card-label">Ваша реферальная ссылка</div>
<div class="copy-row">
<code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button>
</div>
</Card>
<Card class="bonus-card">
<Gift size={42} />
<div>
<span>Ваш бонус</span><strong>+7 дней за каждого друга</strong>
<p>Друг получит +3 дня к подписке.</p>
</div>
</Card>
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
</PhoneFrame>
<PhoneFrame number="7" label="Настройки">
<div class="preview-header">
<div class="brand-row">
<div class="brand-mark"><span>{logoEmoji}</span></div>
<strong>{title}</strong>
</div>
</div>
<Card class="settings-profile">
<div class="settings-avatar">
{#if previewAvatar}
<img src={previewAvatar} alt="Аватар пользователя" />
{:else}
<UserRound size={27} />
{/if}
</div>
<div class="settings-profile-meta">
<strong>{previewTelegramName}</strong>
<small>{previewEmail}</small>
<small>{previewTelegramId}</small>
</div>
</Card>
<div class="settings-list">
{#each settingsRows as row}
<div class="settings-row">
<svelte:component this={row[0]} size={20} />
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
<ArrowRight size={16} />
</div>
{/each}
</div>
</PhoneFrame>
<PhoneFrame number="8" label="Логин" wide>
<div class="login-brand small">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1>
<p>Войдите в свой аккаунт</p>
</div>
<Card class="auth-card">
<div class="field-label">Вход по email</div>
<div class="auth-email-stack">
<div class="input muted">Email</div>
<Button class="wide"><Mail size={17} />Войти по почте</Button>
</div>
<div class="or-line"><span></span>или<span></span></div>
<Button variant="telegram" class="wide telegram-login-button">
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
</Button>
</Card>
</PhoneFrame>
<PhoneFrame number="9" label="Подтверждение по коду" wide>
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
<div class="otp-slots static">
{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}
</div>
<Button class="wide bottom-action">Подтвердить</Button>
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
</PhoneFrame>
</div>
@@ -0,0 +1,662 @@
<script>
import {
ArrowLeft,
Check,
ChevronsUpDown,
Coins,
CreditCard,
Download,
FileText,
Globe2,
LayoutDashboard,
Megaphone,
Menu,
Plus,
RefreshCw,
Save,
Sliders,
Sparkles,
Tag,
UsersRound,
} from "$components/ui/icons.js";
import { onMount, setContext } from "svelte";
import { fade } from "svelte/transition";
import { Select } from "$components/ui/primitives.js";
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import AdsSection from "./sections/AdsSection.svelte";
import BroadcastSection from "./sections/BroadcastSection.svelte";
import LogsSection from "./sections/LogsSection.svelte";
import PaymentsSection from "./sections/PaymentsSection.svelte";
import PromosSection from "./sections/PromosSection.svelte";
import SettingsSection from "./sections/SettingsSection.svelte";
import StatsSection from "./sections/StatsSection.svelte";
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
import TariffsSection from "./sections/TariffsSection.svelte";
import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
import {
fmtDate,
fmtDateShort,
fmtMoney,
paymentStatusVariant,
trafficLeftLabel,
trafficOfLabel,
trafficPercentValue,
} from "../lib/admin/format.js";
import {
createGravatarCache,
userAvatarUrl,
userDisplayName,
userInitials,
userSecondaryName,
} from "../lib/admin/users.js";
export let api;
export let onClose = () => {};
export let onToast = () => {};
export let initialSection = "stats";
export let initialUserId = null;
export let onSectionChange = () => {};
export let onSettingsSaved = () => {};
export let onTariffsSaved = () => {};
export let brand = {};
export let brandTitle = "/minishop";
export let appVersion = "dev+local";
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
export let currentLang = "ru";
export let languageOptions = [];
export let languageBusy = false;
export let onLanguageChange = () => {};
export let t = (key, _params = {}, fallback = "") => fallback || key;
const at = (key, params = {}, fallback = "") => t(`admin_${key}`, params, fallback || key);
$: NAV_GROUPS = [
{
id: "overview",
label: at("nav_overview", {}, "Обзор"),
items: [{ id: "stats", label: at("nav_dashboard", {}, "Дашборд"), icon: LayoutDashboard }],
},
{
id: "operations",
label: at("nav_operations", {}, "Управление"),
items: [
{ id: "users", label: at("nav_users", {}, "Пользователи"), icon: UsersRound },
{ id: "payments", label: at("nav_payments", {}, "Платежи"), icon: CreditCard },
{ id: "promos", label: at("nav_promos", {}, "Промокоды"), icon: Tag },
{ id: "ads", label: at("nav_ads", {}, "Реклама"), icon: Sparkles },
],
},
{
id: "communication",
label: at("nav_communication", {}, "Коммуникации"),
items: [
{ id: "broadcast", label: at("nav_broadcast", {}, "Рассылка"), icon: Megaphone },
{ id: "logs", label: at("nav_logs", {}, "Логи"), icon: FileText },
],
},
{
id: "system",
label: at("nav_system", {}, "Система"),
items: [
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
],
},
];
$: SECTION_META = {
stats: {
title: at("section_stats_title", {}, "Дашборд"),
subtitle: at(
"section_stats_subtitle",
{},
"Аудитория, доходы, панель Remnawave и последние платежи"
),
},
users: {
title: at("section_users_title", {}, "Пользователи"),
subtitle: at("section_users_subtitle", {}, "Поиск, баны и действия над аккаунтами"),
},
payments: {
title: at("section_payments_title", {}, "Платежи"),
subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт"),
},
promos: {
title: at("section_promos_title", {}, "Промокоды"),
subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами"),
},
ads: {
title: at("section_ads_title", {}, "Рекламные кампании"),
subtitle: at("section_ads_subtitle", {}, "UTM-источники и атрибуция"),
},
broadcast: {
title: at("section_broadcast_title", {}, "Рассылка"),
subtitle: at("section_broadcast_subtitle", {}, "Массовая отправка сообщений в Telegram"),
},
logs: {
title: at("section_logs_title", {}, "Логи активности"),
subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия"),
},
tariffs: {
title: at("section_tariffs_title", {}, "Тарифы"),
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
},
settings: {
title: at("section_settings_title", {}, "Настройки приложения"),
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
},
};
$: VALID_SECTIONS = (NAV_GROUPS || []).flatMap((group) =>
(group.items || []).map((item) => item.id)
);
const normalizeSection = (value) => ((VALID_SECTIONS || []).includes(value) ? value : "stats");
let active = normalizeSection(initialSection);
$: if (initialSection) {
active = normalizeSection(initialSection);
}
let sidebarOpen = false;
let isCompact = false;
let adminLanguageMenuOpen = false;
let adminLanguageClickGuard = false;
let adminLanguageClickGuardArmed = false;
let adminLanguageClickGuardTimer = null;
let adminLanguageClickGuardArmTimer = null;
function readReduceMotion() {
return (
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
let reduceMotion = readReduceMotion();
function flash(text) {
onToast(text);
}
const adsStore = createAdsStore({ api, onToast: flash, at });
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
const logsStore = createLogsStore({ api, at });
const paymentsStore = createPaymentsStore({ api, at });
const promosStore = createPromosStore({ api, onToast: flash, at });
const settingsStore = createSettingsStore({ api, onToast: flash, at });
const statsStore = createStatsStore({ api, onToast: flash, at });
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
const usersStore = createUsersStore({ api, onToast: flash, at });
setContext("promosStore", promosStore);
setContext("adsStore", adsStore);
setContext("broadcastStore", broadcastStore);
setContext("logsStore", logsStore);
setContext("paymentsStore", paymentsStore);
setContext("statsStore", statsStore);
setContext("settingsStore", settingsStore);
setContext("usersStore", usersStore);
setContext("tariffsStore", tariffsStore);
$: usersStore.setActive(active);
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
$: syncBusy = $statsStore.syncBusy;
$: settingsSaving = $settingsStore.settingsSaving;
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
$: currentLanguageOption =
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
const gravatarCache = createGravatarCache(() => usersStore.updateState({}));
function setActive(id) {
const next = normalizeSection(id);
sidebarOpen = false;
if (active === next) return;
active = next;
usersStore.closeUser();
onSectionChange(next);
}
function readSectionFromPath() {
if (typeof window === "undefined") return "stats";
const match = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/i);
return normalizeSection(match ? match[1].toLowerCase() : "stats");
}
function readUserIdFromPath() {
if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/^\/admin\/users\/(-?\d+)$/);
return match ? Number(match[1]) : null;
}
function onPopState() {
active = readSectionFromPath();
sidebarOpen = false;
const userId = readUserIdFromPath();
if (userId) {
if (!$usersStore.openedUser || $usersStore.openedUser.user_id !== userId) {
usersStore.openUser(userId, { skipPush: true });
}
} else if ($usersStore.openedUser) {
usersStore.closeUser({ skipPush: true });
}
}
function exportPayments() {
if (typeof window === "undefined") return;
window.open("/api/admin/payments/export.csv", "_blank", "noopener");
}
function openPaymentUserCard(userId) {
const uid = Number(userId);
// Synthetic email-only users use negative user_id; still a valid admin target.
if (!Number.isFinite(uid) || uid === 0) return;
const next = normalizeSection("users");
sidebarOpen = false;
if (active !== next) {
active = next;
usersStore.closeUser();
onSectionChange(next);
}
usersStore.openUser(uid);
}
function resolvedAvatarUrl(user) {
return (
userAvatarUrl(user) ||
(!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "")
);
}
function panelStatusBadge(user) {
const status = String(user?.panel_status || "").toLowerCase();
if (user?.is_banned) return { label: at("status_banned", {}, "Бан"), variant: "danger" };
switch (status) {
case "active":
return { label: at("status_active", {}, "Active"), variant: "success" };
case "expired":
return {
label: user?.panel_status_expired_at
? at(
"expired_badge",
{ date: fmtDateShort(user.panel_status_expired_at) },
`Expired ${fmtDateShort(user.panel_status_expired_at)}`
)
: at("status_expired", {}, "Expired"),
variant: "warning",
};
case "limited":
return { label: at("status_limited", {}, "Limited"), variant: "warning" };
case "disabled":
return { label: at("status_disabled", {}, "Disabled"), variant: "muted" };
case "bot_only":
return { label: at("status_bot_only", {}, "Только бот"), variant: "muted" };
default:
return { label: status || "—", variant: "muted" };
}
}
let compactMql = null;
function onCompactChange(event) {
isCompact = Boolean(event?.matches);
}
function clearAdminLanguageClickGuard() {
if (adminLanguageClickGuardTimer) {
window.clearTimeout(adminLanguageClickGuardTimer);
adminLanguageClickGuardTimer = null;
}
if (adminLanguageClickGuardArmTimer) {
window.clearTimeout(adminLanguageClickGuardArmTimer);
adminLanguageClickGuardArmTimer = null;
}
adminLanguageClickGuard = false;
adminLanguageClickGuardArmed = false;
}
function setAdminLanguageMenuOpen(open) {
adminLanguageMenuOpen = Boolean(open);
clearAdminLanguageClickGuard();
// Desktop doesn't need the click-guard overlay and it can block
// option clicks in portaled select content.
if (!isCompact) return;
if (adminLanguageMenuOpen) {
adminLanguageClickGuard = true;
adminLanguageClickGuardArmTimer = window.setTimeout(() => {
adminLanguageClickGuardArmed = true;
adminLanguageClickGuardArmTimer = null;
}, 220);
return;
}
adminLanguageClickGuard = true;
adminLanguageClickGuardArmed = false;
adminLanguageClickGuardTimer = window.setTimeout(() => {
adminLanguageClickGuard = false;
adminLanguageClickGuardTimer = null;
}, 260);
}
function closeAdminLanguageFromGuard(event) {
event.preventDefault();
event.stopPropagation();
if (adminLanguageClickGuardArmed) setAdminLanguageMenuOpen(false);
}
onMount(() => {
reduceMotion = readReduceMotion();
let motionMql = null;
const onMotionChange = () => {
reduceMotion = readReduceMotion();
};
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
motionMql = window.matchMedia("(prefers-reduced-motion: reduce)");
reduceMotion = motionMql.matches;
motionMql.addEventListener("change", onMotionChange);
}
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
compactMql = window.matchMedia("(max-width: 720px)");
isCompact = compactMql.matches;
if (compactMql.addEventListener) compactMql.addEventListener("change", onCompactChange);
else if (compactMql.addListener) compactMql.addListener(onCompactChange);
}
if (typeof window !== "undefined") {
window.addEventListener("popstate", onPopState);
}
return () => {
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
if (compactMql) {
if (compactMql.removeEventListener)
compactMql.removeEventListener("change", onCompactChange);
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
}
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
clearAdminLanguageClickGuard();
};
});
$: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 };
$: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 };
$: if (
active === "users" &&
initialUserId &&
(!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)
) {
usersStore.openUser(initialUserId, { skipPush: true });
}
</script>
<div class="admin-screen-wrap" class:is-sidebar-open={sidebarOpen}>
{#if sidebarOpen}
<button
type="button"
class="admin-sidebar-backdrop"
aria-label={at("close_menu", {}, "Закрыть меню")}
in:fade={sidebarBackdropFade}
out:fade={sidebarBackdropFade}
on:click={() => (sidebarOpen = false)}
></button>
{/if}
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
<button
class="language-select-guard"
class:language-select-guard--armed={adminLanguageClickGuardArmed}
type="button"
aria-label={t("wa_close", {}, at("close", {}, "Закрыть"))}
on:pointerdown={closeAdminLanguageFromGuard}
on:click={closeAdminLanguageFromGuard}
></button>
{/if}
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
<div class="admin-sidebar-brand">
<BrandMark class="admin-brand-mark" {brand} />
<div>
<strong class="admin-brand-title">{brandTitle}</strong>
<small>{at("panel_title", {}, "Админ-панель")}</small>
</div>
<AdminButton
variant="ghost"
size="icon"
onclick={onClose}
aria-label={at("exit", {}, "Выйти")}
>
<ArrowLeft size={16} />
</AdminButton>
</div>
{#each NAV_GROUPS as group}
<div class="admin-sidebar-section-label">{group.label}</div>
<nav class="admin-nav" aria-label={group.label}>
{#each group.items as item}
<button
type="button"
class="admin-nav-item"
class:active={active === item.id}
on:click={() => setActive(item.id)}
>
<svelte:component this={item.icon} size={16} />
<span>{item.label}</span>
<span></span>
</button>
{/each}
</nav>
{/each}
<div class="admin-sidebar-footer">
{#if languageOptions.length}
<div class="admin-language-switch">
<Globe2 size={16} />
<Select.Root
type="single"
bind:open={adminLanguageMenuOpen}
value={currentLang}
items={languageOptions}
disabled={languageBusy}
onOpenChange={setAdminLanguageMenuOpen}
onValueChange={onLanguageChange}
>
<Select.Trigger
class="admin-language-trigger"
aria-label={t("wa_settings_language", {}, at("language", {}, "Язык"))}
>
<span>
<strong>{t("wa_settings_language", {}, at("language", {}, "Язык"))}</strong>
<small>
<span class="emoji-flag" aria-hidden="true"
>{currentLanguageOption?.flag || "🏳️"}</span
>
{currentLanguageOption?.label || currentLang}
</small>
</span>
<ChevronsUpDown size={14} />
</Select.Trigger>
<Select.Content class="language-select-content" side="top" align="start" sideOffset={8}>
<Select.Viewport class="language-select-viewport">
{#each languageOptions as option (option.value)}
<Select.Item
value={option.value}
label={option.label}
class="language-select-item"
>
<span class="language-select-item-main">
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
<span>{option.label}</span>
</span>
<Check size={15} class="language-select-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</div>
{/if}
<a
class="admin-version-link"
href={appRepositoryUrl}
target="_blank"
rel="noopener noreferrer"
title="GitHub"
>
<span>remnawave-minishop</span>
<span>{appVersion || "dev+local"}</span>
</a>
</div>
</aside>
<section class="admin-content">
<header class="admin-header">
<div style="display:flex; align-items:center; gap:12px; min-width:0;">
<button
type="button"
class="admin-mobile-toggle"
on:click={() => (sidebarOpen = !sidebarOpen)}
aria-label={at("menu", {}, "Меню")}
>
<Menu size={18} />
</button>
<div class="admin-header-title">
<h2>{meta.title}</h2>
{#if meta.subtitle}<small>{meta.subtitle}</small>{/if}
</div>
</div>
<div class="admin-header-actions">
{#if active === "stats"}
<AdminButton onclick={statsStore.triggerSync} disabled={syncBusy}>
<RefreshCw size={14} />
{syncBusy
? at("btn_syncing", {}, "Синхронизация...")
: at("btn_sync", {}, "Синхронизировать")}
</AdminButton>
{/if}
{#if active === "payments"}
<AdminButton onclick={exportPayments}>
<Download size={14} /> CSV
</AdminButton>
{/if}
{#if active === "promos"}
<AdminButton variant="primary" onclick={() => promosStore.setCreateOpen(true)}>
<Plus size={14} />
{at("btn_create", {}, "Создать")}
</AdminButton>
{/if}
{#if active === "ads"}
<AdminButton variant="primary" onclick={() => adsStore.setCreateOpen(true)}>
<Plus size={14} />
{at("btn_campaign", {}, "Кампания")}
</AdminButton>
{/if}
{#if active === "tariffs"}
<AdminButton variant="primary" onclick={tariffsStore.openCreateTariff}>
<Plus size={14} />
{at("btn_tariff", {}, "Тариф")}
</AdminButton>
{/if}
{#if active === "settings"}
{#if dirtyCount}
<AdminBadge variant="warning"
>{at(
"settings_dirty_count",
{ count: dirtyCount },
"Изменений: " + dirtyCount
)}</AdminBadge
>
{/if}
<AdminButton
variant="primary"
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
disabled={!dirtyCount || settingsSaving}
>
<Save size={14} />
{settingsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</header>
<main class="admin-main">
{#key active}
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
{#if active === "stats"}
<StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
{/if}
{#if active === "users"}
<UsersSection
{at}
{fmtDateShort}
{panelStatusBadge}
{resolvedAvatarUrl}
{userDisplayName}
{userInitials}
{userSecondaryName}
/>
{/if}
{#if active === "payments"}
<PaymentsSection
{at}
{fmtDate}
{fmtMoney}
{paymentStatusVariant}
onOpenUserCard={openPaymentUserCard}
/>
{/if}
{#if active === "promos"}
<PromosSection {at} {fmtDateShort} />
{/if}
{#if active === "ads"}
<AdsSection {at} {fmtMoney} />
{/if}
{#if active === "broadcast"}
<BroadcastSection {at} />
{/if}
{#if active === "logs"}
<LogsSection {at} {fmtDate} />
{/if}
{#if active === "tariffs"}
<TariffsSection {at} {fmtMoney} />
{/if}
{#if active === "settings"}
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
{/if}
</div>
{/key}
</main>
</section>
</div>
<TariffEditorModal {at} />
<UserDetailModal
{at}
{fmtDate}
{fmtDateShort}
{fmtMoney}
{resolvedAvatarUrl}
{userDisplayName}
{userSecondaryName}
{userInitials}
{paymentStatusVariant}
{trafficPercentValue}
{trafficLeftLabel}
{trafficOfLabel}
/>
@@ -0,0 +1,155 @@
<script>
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminField,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtMoney;
const adsStore = getContext("adsStore");
$: ({ ads, adsLoading, adCreateOpen, adDraft } = $adsStore);
$: adHeaders = [
at("id", {}, "ID"),
at("ads_col_source", {}, "Источник"),
at("ads_col_param", {}, "Параметр"),
at("ads_col_cost", {}, "Стоимость"),
at("ads_col_registrations", {}, "Регистрации"),
at("ads_col_conversions", {}, "Конверсии"),
at("ads_col_status", {}, "Статус"),
at("actions", {}, "Действия"),
];
onMount(() => {
adsStore.loadAds();
});
</script>
<div class="admin-table-wrap">
{#if adsLoading}
<AdminTableSkeleton
headers={adHeaders}
rows={6}
actionColumn
widths={["44px", "96px", "110px", "70px", "54px", "54px", "72px", "92px"]}
/>
{:else if !ads.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("ads_empty", {}, "Кампаний нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("id", {}, "ID")}</th>
<th>{at("ads_col_source", {}, "Источник")}</th>
<th>{at("ads_col_param", {}, "Параметр")}</th>
<th>{at("ads_col_cost", {}, "Стоимость")}</th>
<th>{at("ads_col_registrations", {}, "Регистрации")}</th>
<th>{at("ads_col_conversions", {}, "Конверсии")}</th>
<th>{at("ads_col_status", {}, "Статус")}</th>
<th class="admin-cell-actions">{at("actions", {}, "Действия")}</th>
</tr>
</thead>
<tbody>
{#each ads as ad}
<tr>
<td class="admin-cell-id" data-label={at("id", {}, "ID")}>#{ad.id}</td>
<td data-label={at("ads_col_source", {}, "Источник")}>{ad.source}</td>
<td class="admin-cell-mono" data-label={at("ads_col_param", {}, "Параметр")}
>{ad.start_param}</td
>
<td data-label={at("ads_col_cost", {}, "Стоимость")}>{fmtMoney(ad.cost)}</td>
<td data-label={at("ads_col_registrations", {}, "Регистрации")}
>{ad.stats?.registrations ?? 0}</td
>
<td data-label={at("ads_col_conversions", {}, "Конверсии")}
>{ad.stats?.conversions ?? 0}</td
>
<td data-label={at("ads_col_status", {}, "Статус")}>
{#if ad.is_active}
<AdminBadge variant="success">{at("status_active", {}, "Активна")}</AdminBadge>
{:else}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключена")}</AdminBadge>
{/if}
</td>
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
<AdminButton size="sm" onclick={() => adsStore.toggleAd(ad)}>
{ad.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
</AdminButton>
<AdminButton size="sm" variant="danger" onclick={() => adsStore.deleteAd(ad)}>
<Trash2 size={13} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
<Dialog
open={adCreateOpen}
title={at("ad_create_title", {}, "Новая кампания")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => adsStore.setCreateOpen(false)}
class="admin-dialog admin-dialog-compact"
>
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<AdminField label={at("ad_label_source", {}, "Источник")}>
<input
class="input"
type="text"
placeholder="telegram_ads"
value={adDraft.source}
on:input={(e) => adsStore.updateDraft({ source: e.target.value })}
/>
</AdminField>
<AdminField
label={at("ad_label_param", {}, "start-параметр")}
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
>
<input
class="input"
type="text"
placeholder="ads_summer25"
value={adDraft.start_param}
on:input={(e) => adsStore.updateDraft({ start_param: e.target.value })}
/>
</AdminField>
</div>
<div class="admin-dialog-form-section">
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
<input
class="input"
type="number"
step="0.01"
min="0"
value={adDraft.cost}
on:input={(e) => adsStore.updateDraft({ cost: Number(e.target.value) })}
/>
</AdminField>
</div>
<div class="admin-dialog-actions">
<AdminButton onclick={() => adsStore.setCreateOpen(false)}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton
variant="primary"
onclick={adsStore.createAd}
disabled={!adDraft.source.trim() || !adDraft.start_param.trim()}
>
{at("btn_create", {}, "Создать")}
</AdminButton>
</div>
</div>
</Dialog>
@@ -0,0 +1,64 @@
<script>
import { Send } from "$components/ui/icons.js";
import { getContext } from "svelte";
import { Label } from "$components/ui/primitives.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
export let at;
const broadcastStore = getContext("broadcastStore");
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
</script>
<div class="admin-card">
<header class="admin-card-head">
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
</header>
<div class="admin-card-body">
<div class="admin-form">
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<AdminSelect
value={broadcastTarget}
items={BROADCAST_TARGET_OPTIONS}
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
<textarea
class="admin-textarea"
rows="6"
value={broadcastText}
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
></textarea>
</Label.Root>
<div style="display:flex; gap:8px; align-items:center;">
<AdminButton
variant="primary"
onclick={broadcastStore.runBroadcast}
disabled={broadcastBusy || !broadcastText.trim()}
>
<Send size={14} />
{broadcastBusy
? at("btn_sending", {}, "Отправка...")
: at("btn_queue", {}, "Поставить в очередь")}
</AdminButton>
{#if broadcastResult}
<span class="admin-muted"
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
"broadcast_stat_failed",
{},
"Неудач"
)}: {broadcastResult.failed}</span
>
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,119 @@
<script>
import { getContext, onMount } from "svelte";
import {
AdminButton,
AdminEmptyState,
AdminPagination,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtDate;
const logsStore = getContext("logsStore");
$: ({ logs, logsTotal, logsPage, logsUserFilter, logsLoading } = $logsStore);
$: logsHasMore = logs.length > 0 && logsTotal > (logsPage + 1) * 50; // 50 is LOGS_PAGE_SIZE
$: logHeaders = [
at("date", {}, "Дата"),
at("event", {}, "Событие"),
at("user_short", {}, "User"),
at("target_short", {}, "Target"),
at("content", {}, "Контент"),
];
onMount(() => {
logsStore.loadLogs();
});
</script>
<div class="admin-toolbar admin-toolbar-card">
<div class="admin-toolbar-search admin-toolbar-search-actions">
<input
type="search"
class="input"
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
value={logsUserFilter}
on:input={(e) => logsStore.setFilter(e.target.value)}
on:keydown={(e) => e.key === "Enter" && logsStore.setPage(0)}
/>
<AdminButton
variant="primary"
onclick={() => {
logsStore.setPage(0);
}}>{at("apply", {}, "Применить")}</AdminButton
>
<AdminButton
variant="ghost"
onclick={() => {
logsStore.setFilter("");
logsStore.setPage(0);
}}>{at("reset", {}, "Сбросить")}</AdminButton
>
</div>
<div class="admin-toolbar-summary">
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
<strong>{logsTotal}</strong>
</div>
</div>
<div class="admin-table-wrap">
{#if logsLoading}
<AdminTableSkeleton
headers={logHeaders}
rows={10}
widths={["120px", "120px", "58px", "58px", "220px"]}
/>
{:else if !logs.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("date", {}, "Дата")}</th>
<th>{at("event", {}, "Событие")}</th>
<th>{at("user_short", {}, "User")}</th>
<th>{at("target_short", {}, "Target")}</th>
<th>{at("content", {}, "Контент")}</th>
</tr>
</thead>
<tbody>
{#each logs as entry}
<tr>
<td data-label={at("date", {}, "Дата")}>{fmtDate(entry.timestamp)}</td>
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
>{entry.event_type}</td
>
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")}
>{entry.user_id || "—"}</td
>
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")}
>{entry.target_user_id || "—"}</td
>
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
>{entry.content || ""}</td
>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${logsPage + 1}`}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={logsPage === 0}
nextDisabled={!logsHasMore}
onPrev={() => {
logsStore.setPage(Math.max(0, logsPage - 1));
}}
onNext={() => {
logsStore.setPage(logsPage + 1);
}}
/>
@@ -0,0 +1,216 @@
<script>
import { getContext, onMount } from "svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminPagination,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let at = (key) => key;
export let fmtDate = (value) => value;
export let fmtMoney = (value) => value;
export let paymentStatusVariant = () => "muted";
export let onOpenUserCard = () => {};
const paymentsStore = getContext("paymentsStore");
$: ({ payments, paymentsTotal, paymentsPage, paymentsLoading } = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
/** @param {number|null|undefined} v */
function formatTrafficGbCell(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
let s;
if (Math.abs(n - Math.round(n)) < 1e-9) {
s = String(Math.round(n));
} else {
s = String(Math.round(n * 100) / 100);
}
return `${s} GB`;
}
/** @param {number|null|undefined} v */
function formatGbAmountPlain(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (Number.isNaN(n)) return "";
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return String(Math.round(n * 100) / 100);
}
/** @param {Record<string, unknown>} p */
function paymentDescriptionDisplay(p) {
const r = p.traffic_regular_gb;
const pr = p.traffic_premium_gb;
if (r != null && pr == null) {
const gb = formatGbAmountPlain(r);
return at(
"payments_desc_traffic_package_regular",
{ gb },
`Пакет трафика ${gb} ГБ (обычный)`
);
}
if (pr != null && r == null) {
const gb = formatGbAmountPlain(pr);
return at(
"payments_desc_traffic_package_premium",
{ gb },
`Пакет трафика ${gb} ГБ (премиум)`
);
}
const raw = p.description && String(p.description).trim();
return raw || "—";
}
$: paymentHeaders = [
at("id", {}, "ID"),
at("user", {}, "Пользователь"),
at("payments_col_user_id", {}, "ID"),
at("payments_col_traffic_regular", {}, "Основной трафик"),
at("payments_col_traffic_premium", {}, "Премиум"),
at("amount", {}, "Сумма"),
at("provider", {}, "Провайдер"),
at("description", {}, "Описание"),
at("status", {}, "Статус"),
at("date", {}, "Дата"),
];
onMount(() => {
paymentsStore.loadPayments();
});
</script>
<div class="admin-table-wrap">
{#if paymentsLoading}
<AdminTableSkeleton
headers={paymentHeaders}
rows={8}
widths={["48px", "148px", "88px", "72px", "72px", "78px", "82px", "140px", "72px", "96px"]}
/>
{:else if !payments.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("payments_col_user_id", {}, "ID")}</th>
<th>{at("payments_col_traffic_regular", {}, "Основной трафик")}</th>
<th>{at("payments_col_traffic_premium", {}, "Премиум")}</th>
<th>{at("amount", {}, "Сумма")}</th>
<th>{at("provider", {}, "Провайдер")}</th>
<th>{at("description", {}, "Описание")}</th>
<th>{at("status", {}, "Статус")}</th>
<th>{at("date", {}, "Дата")}</th>
</tr>
</thead>
<tbody>
{#each payments as p}
<tr>
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
<td class="admin-cell-user-with-action" data-label={at("user", {}, "Пользователь")}>
<span class="admin-payments-user-cell">
<AdminButton
class="admin-payments-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Открыть карточку пользователя")}
aria-label={at("payments_open_user", {}, "Открыть карточку пользователя")}
onclick={() => onOpenUserCard(p.user_id)}
>
<User size={14} />
</AdminButton>
<span class="admin-payments-user-name">{p.user_label || p.user_id}</span>
</span>
</td>
<td class="admin-cell-mono" data-label={at("payments_col_user_id", {}, "ID")}>
{p.user_id != null && p.user_id !== "" ? p.user_id : "—"}
</td>
<td
class="admin-cell-traffic-gb"
data-label={at("payments_col_traffic_regular", {}, "Основной трафик")}
>
{formatTrafficGbCell(p.traffic_regular_gb)}
</td>
<td
class="admin-cell-traffic-gb"
data-label={at("payments_col_traffic_premium", {}, "Премиум")}
>
{formatTrafficGbCell(p.traffic_premium_gb)}
</td>
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}
>{paymentDescriptionDisplay(p)}</td
>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
</td>
<td data-label={at("date", {}, "Дата")}>{fmtDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${paymentsPage + 1} · ${at("total", {}, "Всего")} ${paymentsTotal}`}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={paymentsPage === 0}
nextDisabled={!paymentsHasMore}
onPrev={() => {
paymentsStore.setPage(Math.max(0, paymentsPage - 1));
}}
onNext={() => {
paymentsStore.setPage(paymentsPage + 1);
}}
/>
<style>
.admin-payments-user-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-payments-user-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn.admin-btn) {
width: 30px;
height: 30px;
min-width: 30px;
min-height: 30px;
flex-shrink: 0;
padding: 0;
border-radius: 7px;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn svg) {
width: 14px;
height: 14px;
}
.admin-cell-traffic-gb {
font-size: 12px;
white-space: nowrap;
color: var(--admin-muted);
}
</style>
@@ -0,0 +1,166 @@
<script>
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminField,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtDateShort;
const promosStore = getContext("promosStore");
$: ({ promos, promosTotal, promosPage, promosLoading, promoCreateOpen, promoDraft } =
$promosStore);
$: promosHasMore = promos.length < promosTotal;
$: promoHeaders = [
at("promo_col_code", {}, "Код"),
at("promo_col_bonus", {}, "Бонус"),
at("promo_col_activations", {}, "Активаций"),
at("promo_col_valid_until", {}, "Действует до"),
at("promo_col_status", {}, "Статус"),
at("actions", {}, "Действия"),
];
onMount(() => {
promosStore.loadPromos();
});
</script>
<div class="admin-table-wrap">
{#if promosLoading}
<AdminTableSkeleton
headers={promoHeaders}
rows={6}
actionColumn
widths={["92px", "52px", "64px", "96px", "72px", "92px"]}
/>
{:else if !promos.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("promos_empty", {}, "Промокодов нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("promo_col_code", {}, "Код")}</th>
<th>{at("promo_col_bonus", {}, "Бонус")}</th>
<th>{at("promo_col_activations", {}, "Активаций")}</th>
<th>{at("promo_col_valid_until", {}, "Действует до")}</th>
<th>{at("promo_col_status", {}, "Статус")}</th>
<th class="admin-cell-actions">{at("actions", {}, "Действия")}</th>
</tr>
</thead>
<tbody>
{#each promos as p}
<tr>
<td class="admin-cell-mono" data-label={at("promo_col_code", {}, "Код")}>{p.code}</td>
<td data-label={at("promo_col_bonus", {}, "Бонус")}
>+{p.bonus_days} {at("days_short", {}, "дн.")}</td
>
<td data-label={at("promo_col_activations", {}, "Активаций")}
>{p.current_activations}/{p.max_activations}</td
>
<td data-label={at("promo_col_valid_until", {}, "Действует до")}
>{p.valid_until ? fmtDateShort(p.valid_until) : "∞"}</td
>
<td data-label={at("promo_col_status", {}, "Статус")}>
{#if p.is_active}
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{:else}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{/if}
</td>
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
<AdminButton size="sm" onclick={() => promosStore.togglePromo(p)}>
{p.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
</AdminButton>
<AdminButton size="sm" variant="danger" onclick={() => promosStore.deletePromo(p)}>
<Trash2 size={13} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
{#if promosHasMore}
<div style="padding: 12px; text-align: center;">
<AdminButton onclick={() => promosStore.setPage(promosPage + 1)}
>{at("btn_show_more", {}, "Показать еще")}</AdminButton
>
</div>
{/if}
</div>
<Dialog
open={promoCreateOpen}
title={at("promo_create_title", {}, "Создать промокод")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => promosStore.setCreateOpen(false)}
class="admin-dialog admin-dialog-compact"
>
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<AdminField label={at("promo_label_code", {}, "Код")}>
<input
type="text"
class="input"
value={promoDraft.code}
on:input={(e) => promosStore.updateDraft({ code: e.target.value })}
placeholder="FREE-7-DAYS"
/>
</AdminField>
</div>
<div class="admin-dialog-form-section">
<div class="admin-form-row-2">
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
<input
type="number"
class="input"
min="1"
value={promoDraft.bonus_days}
on:input={(e) => promosStore.updateDraft({ bonus_days: Number(e.target.value) })}
/>
</AdminField>
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
<input
type="number"
class="input"
min="1"
value={promoDraft.max_activations}
on:input={(e) => promosStore.updateDraft({ max_activations: Number(e.target.value) })}
/>
</AdminField>
</div>
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
<input
type="number"
class="input"
min="1"
value={promoDraft.valid_days}
on:input={(e) => promosStore.updateDraft({ valid_days: Number(e.target.value) })}
/>
</AdminField>
</div>
<div class="admin-dialog-actions">
<AdminButton onclick={() => promosStore.setCreateOpen(false)}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton
variant="primary"
onclick={promosStore.createPromo}
disabled={!promoDraft.code.trim()}
>
{at("btn_create", {}, "Создать")}
</AdminButton>
</div>
</div>
</Dialog>
@@ -0,0 +1,358 @@
<script>
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
import { Accordion, Switch } from "$components/ui/primitives.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminSelect,
} from "$components/patterns/admin/index.js";
import { getContext, onMount } from "svelte";
export let at;
export let onSettingsSaved;
export let isCompact = false;
export let currentLang = "ru";
const settingsStore = getContext("settingsStore");
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
let settingsOpenSections = [];
let settingsOpenSubsections = {};
let revealedSecrets = new Set();
$: settingsAllOpen =
settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length;
onMount(() => {
settingsStore.loadSettings().then(() => {
if ($settingsStore.settingsSections.length) {
const ids = $settingsStore.settingsSections.map((s) => s.id);
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
}
});
});
function toggleAllSections() {
if (settingsOpenSections.length === settingsSections.length) {
settingsOpenSections = [];
} else {
settingsOpenSections = settingsSections.map((s) => s.id);
}
}
function valueFor(field) {
if (settingsDirty[field.key]?.deleted) return "";
if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) {
return settingsDirty[field.key].value;
}
return field.value ?? "";
}
function isOverridden(field) {
return Boolean(field.overridden) && !settingsDirty[field.key]?.deleted;
}
function isSecretRevealed(key) {
return revealedSecrets.has(key);
}
function toggleSecretReveal(key) {
const next = new Set(revealedSecrets);
if (next.has(key)) next.delete(key);
else next.add(key);
revealedSecrets = next;
}
function secretPlaceholder(field) {
if (settingsDirty[field.key]?.deleted) return field.placeholder || "••••••••";
if (field.has_value) return at("settings_secret_configured", {}, "Secret is set");
return field.placeholder || at("settings_secret_empty", {}, "Not set");
}
function groupSectionFields(section) {
const groups = new Map();
for (const field of section.fields || []) {
const key = field.subsection || "_root";
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(field);
}
return Array.from(groups.entries()).map(([id, fields]) => ({
id,
label: id === "_root" ? null : id,
fields,
}));
}
function sectionTitle(id) {
const map = {
general: at("settings_section_general", {}, "Общие"),
appearance: at("settings_section_appearance", {}, "Внешний вид"),
pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
payments: at("settings_section_payments", {}, "Платёжные системы"),
trial: at("settings_section_trial", {}, "Триал"),
referral: at("settings_section_referral", {}, "Реферальная программа"),
notifications: at("settings_section_notifications", {}, "Уведомления"),
devices: at("settings_section_devices", {}, "Устройства"),
};
return map[id] || id;
}
function englishFieldLabelFallback(key, originalLabel) {
if (!key) return originalLabel || "";
return String(key)
.toLowerCase()
.split("_")
.filter(Boolean)
.map((part) => {
if (part === "id") return "ID";
if (part === "url") return "URL";
if (part === "api") return "API";
if (part === "tg") return "TG";
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join(" ");
}
function fieldLabelText(field) {
const isEnglish = String(currentLang || "")
.toLowerCase()
.startsWith("en");
const fallback = isEnglish ? englishFieldLabelFallback(field.key, field.label) : field.label;
return field.i18n_label_key ? at(field.i18n_label_key, {}, fallback) : fallback;
}
</script>
{#snippet renderField(field)}
{@const revealed = isSecretRevealed(field.key)}
<div class="admin-setting" class:is-overridden={isOverridden(field)}>
<div class="admin-setting-meta">
<strong>
{fieldLabelText(field)}
{#if field.secret}
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
{/if}
{#if isOverridden(field)}
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
{/if}
</strong>
<code>{field.key}</code>
{#if field.description}
<small
>{field.i18n_description_key
? at(field.i18n_description_key, {}, field.description)
: field.description}</small
>
{/if}
</div>
<div class="admin-setting-control">
{#if field.type === "bool"}
<div class="admin-setting-switch">
<Switch.Root
checked={Boolean(valueFor(field))}
onCheckedChange={(checked) => settingsStore.markDirty(field.key, checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{valueFor(field)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{:else if field.type === "color"}
<input
class="admin-color"
type="color"
value={valueFor(field) || "#00fe7a"}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<input
class="input"
type="text"
value={valueFor(field) || ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.choices && field.choices.length > 0}
<AdminSelect
class="admin-setting-select"
value={valueFor(field) || ""}
items={field.choices}
ariaLabel={fieldLabelText(field)}
placeholder={field.placeholder || fieldLabelText(field)}
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
/>
{:else if field.type === "int" || field.type === "float"}
<input
class="input"
type="number"
step={field.type === "float" ? "0.1" : "1"}
placeholder={field.placeholder}
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.secret}
<input
class="input"
type={revealed ? "text" : "password"}
placeholder={secretPlaceholder(field)}
autocomplete="off"
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<AdminButton
size="sm"
variant="ghost"
aria-label={revealed ? at("hide", {}, "Скрыть") : at("show", {}, "Показать")}
onclick={() => toggleSecretReveal(field.key)}
>
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
</AdminButton>
{:else}
<input
class="input"
type="text"
placeholder={field.placeholder}
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{/if}
{#if isOverridden(field) || settingsDirty[field.key]}
<AdminButton size="sm" variant="ghost" onclick={() => settingsStore.resetField(field)}>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
{/snippet}
{#if settingsLoading || !settingsSections.length}
<AdminEmptyState
>{settingsLoading
? at("loading", {}, "Загрузка…")
: at("no_data", {}, "Нет данных")}</AdminEmptyState
>
{:else}
<div
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;"
>
<p class="admin-muted" style="margin:0;">
{at(
"settings_hint",
{},
"Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения."
)}
</p>
<div style="display:flex; gap:8px;">
<AdminButton size="sm" variant="ghost" onclick={toggleAllSections}>
{settingsAllOpen
? at("collapse_all", {}, "Свернуть всё")
: at("expand_all", {}, "Развернуть всё")}
</AdminButton>
{#if Object.keys(settingsDirty).length > 0}
<AdminButton
size="sm"
variant="primary"
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
disabled={settingsSaving}
>
{settingsSaving ? at("saving", {}, "Сохранение...") : at("save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</div>
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
{#each settingsSections as section}
{@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length}
{@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={section.id} class="admin-accordion-item admin-card">
<Accordion.Header class="admin-accordion-header">
<Accordion.Trigger class="admin-accordion-trigger">
<span class="admin-accordion-title">{sectionTitle(section.id)}</span>
<span class="admin-accordion-meta">
{at(
"settings_params_count",
{ count: section.fields.length },
`${section.fields.length} параметров`
)}{#if overriddenInSection}
· {at(
"settings_overridden_count",
{ count: overriddenInSection },
`${overriddenInSection} override`
)}{/if}{#if dirtyInSection}
· {at(
"settings_dirty_count",
{ count: dirtyInSection },
`${dirtyInSection} изм.`
)}{/if}
</span>
<ChevronRight size={16} class="admin-accordion-chev" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="admin-accordion-content">
{@const groups = groupSectionFields(section)}
{@const rootGroup = groups.find((g) => !g.label)}
{@const labelGroups = groups.filter((g) => g.label)}
<div class="admin-settings-fields">
{#if rootGroup}
{#each rootGroup.fields as field}
{@render renderField(field)}
{/each}
{/if}
{#if labelGroups.length}
<Accordion.Root
type="multiple"
value={settingsOpenSubsections[section.id] || []}
onValueChange={(v) =>
(settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })}
class="admin-subsection-accordion"
>
{#each labelGroups as group}
{@const subDirty = group.fields.filter((f) =>
Boolean(settingsDirty[f.key])
).length}
{@const subOverridden = group.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={group.id} class="admin-settings-subsection">
<Accordion.Header class="admin-accordion-header">
<Accordion.Trigger class="admin-settings-subsection-trigger">
<strong>{group.label}</strong>
<span class="admin-settings-subsection-meta">
{at(
"settings_fields_count",
{ count: group.fields.length },
`${group.fields.length} полей`
)}{#if subOverridden}
· {at(
"settings_overridden_count",
{ count: subOverridden },
`${subOverridden} override`
)}{/if}{#if subDirty}
· {at(
"settings_dirty_count",
{ count: subDirty },
`${subDirty} изм.`
)}{/if}
</span>
<ChevronRight size={14} class="admin-accordion-chev" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="admin-accordion-content">
<div class="admin-settings-subsection-body">
{#each group.fields as field}
{@render renderField(field)}
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
{/if}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,964 @@
<script>
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { getContext } from "svelte";
import { normalizeUuidList } from "../../lib/admin/tariffDraft.js";
export let at;
const tariffsStore = getContext("tariffsStore");
$: ({
tariffEditorOpen,
tariffEditingKey,
tariffDraft,
tariffsSaving,
tariffDeleteOpen,
tariffDeleteTarget,
panelSquadsLoading,
panelSquads,
} = $tariffsStore);
$: billingModelOptions = [
{ value: "period", label: at("tariff_model_period_label", {}, "Период") },
{ value: "traffic", label: at("tariff_model_traffic_label", {}, "Трафик") },
];
$: panelSquadOptions = (panelSquads || []).map((squad) => ({
value: squad.uuid,
label: squad.name,
}));
</script>
<Dialog
open={tariffEditorOpen}
title={tariffEditingKey
? at("tariff_edit_title", {}, "Настройка тарифа")
: at("tariff_create_title", {}, "Новый тариф")}
description={tariffEditingKey ||
at("tariff_create_subtitle", {}, "Каталог будет сохранён в JSON после подтверждения")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => tariffsStore.updateState({ tariffEditorOpen: false })}
class="admin-dialog admin-tariff-dialog"
>
<Tabs.Root bind:value={$tariffsStore.tariffEditorTab} class="admin-tabs-root">
<Tabs.List class="admin-tabs-list">
<Tabs.Trigger value="general" class="admin-tabs-trigger"
>{at("tariff_tab_general", {}, "Основное")}</Tabs.Trigger
>
<Tabs.Trigger value="pricing" class="admin-tabs-trigger"
>{at("tariff_tab_pricing", {}, "Цены")}</Tabs.Trigger
>
<Tabs.Trigger value="topup" class="admin-tabs-trigger"
>{at("tariff_tab_topup", {}, "Докупки")}</Tabs.Trigger
>
<Tabs.Trigger value="premium" class="admin-tabs-trigger"
>{at("tariff_tab_premium", {}, "Premium")}</Tabs.Trigger
>
<Tabs.Trigger value="hwid" class="admin-tabs-trigger"
>{at("tariff_tab_hwid", {}, "Устройства")}</Tabs.Trigger
>
</Tabs.List>
<Tabs.Content value="general" class="admin-tabs-content">
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_key", {}, "Ключ тарифа")}</span>
<small
>{at(
"tariff_hint_key",
{},
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
)}</small
>
<input
class="input"
type="text"
placeholder="standard"
bind:value={$tariffsStore.tariffDraft.key}
/>
</Label.Root>
<div class="admin-field-label">
<span>{at("tariff_label_model", {}, "Модель тарификации")}</span>
<small
><b>{at("tariff_model_period_label", {}, "Период")}</b>{at(
"tariff_model_period_desc",
{},
"пользователь покупает фиксированный срок (1/3/12 мес. и т.д.)"
)}. <b>{at("tariff_model_traffic_label", {}, "Трафик")}</b>{at(
"tariff_model_traffic_desc",
{},
"пользователь покупает пакеты гигабайт по фиксированной цене за GB"
)}</small
>
<AdminSelect
bind:value={$tariffsStore.tariffDraft.billing_model}
items={billingModelOptions}
ariaLabel={at("tariff_label_model", {}, "Модель")}
/>
</div>
</div>
<div class="admin-action-row admin-action-row-bordered">
<Switch.Root
checked={tariffDraft.enabled}
onCheckedChange={(v) => (tariffDraft.enabled = v)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<Label.Root class="admin-action-label">
<strong
>{tariffDraft.enabled
? at("tariff_visible", {}, "Тариф виден на витрине")
: at("tariff_hidden", {}, "Тариф скрыт от пользователей")}</strong
>
<small
>{at(
"tariff_enabled_hint",
{},
"Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать"
)}</small
>
</Label.Root>
</div>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
bind:value={$tariffsStore.tariffDraft.nameRu}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
bind:value={$tariffsStore.tariffDraft.nameEn}
/>
</Label.Root>
</div>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
bind:value={$tariffsStore.tariffDraft.descriptionRu}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
bind:value={$tariffsStore.tariffDraft.descriptionEn}
/>
</Label.Root>
</div>
<div class="admin-field-label">
<span>{at("tariff_label_squads", {}, "Базовые Internal Squads")}</span>
<small
>{panelSquadsLoading
? at("loading_squads", {}, "Загружаю список из панели…")
: at(
"tariff_hint_squads",
{},
"Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"
)}</small
>
<AdminSelect
bind:value={$tariffsStore.selectedBaseSquad}
items={panelSquadOptions}
placeholder={at("btn_add_squad", {}, "Добавить сквад")}
ariaLabel={at("btn_add_squad", {}, "Добавить основной сквад")}
onValueChange={(value) => {
tariffsStore.addSquadToDraft("squadUuids", value);
tariffsStore.update((s) => ({ ...s, selectedBaseSquad: "" }));
}}
/>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid}
<button
type="button"
class="admin-chip"
on:click={() => tariffsStore.removeSquadFromDraft("squadUuids", uuid)}
>
{tariffsStore.squadLabel(uuid)}
<X size={12} />
</button>
{/each}
</div>
</div>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_hwid", {}, "Лимит устройств (HWID)")}</span>
<small
>{at(
"tariff_hint_hwid",
{},
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
)}</small
>
<input
class="input"
type="number"
min="0"
placeholder="5"
bind:value={$tariffsStore.tariffDraft.hwid_device_limit}
/>
</Label.Root>
{#if tariffDraft.billing_model === "period"}
<Label.Root class="admin-field-label">
<span>{at("tariff_label_traffic_limit", {}, "Месячный лимит трафика, GB")}</span>
<small
>{at(
"tariff_hint_traffic_limit",
{},
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
)}</small
>
<input
class="input"
type="number"
min="0"
step="0.1"
placeholder="100"
bind:value={$tariffsStore.tariffDraft.monthly_gb}
/>
</Label.Root>
{:else}
<Label.Root class="admin-field-label">
<span>{at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")}</span>
<small
>{at(
"tariff_hint_conversion",
{},
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
)}</small
>
<input
class="input"
type="number"
min="0"
step="0.01"
placeholder="20"
bind:value={$tariffsStore.tariffDraft.conversion_rate_rub_per_gb}
/>
</Label.Root>
{/if}
</div>
</Tabs.Content>
<Tabs.Content value="premium" class="admin-tabs-content">
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong
>{at("tariff_premium_head", {}, "Premium-доступ и отдельный счётчик трафика")}</strong
>
<small
>{at(
"tariff_premium_subhead",
{},
"Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно"
)}</small
>
</div>
</header>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_premium_name_ru", {}, "Название premium-раздела, RU")}</span>
<small
>{at(
"tariff_hint_premium_name_ru",
{},
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
)}</small
>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
bind:value={$tariffsStore.tariffDraft.premiumNameRu}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_premium_name_en", {}, "Название premium-раздела, EN")}</span>
<small
>{at(
"tariff_hint_premium_name_en",
{},
"Опционально для английского интерфейса."
)}</small
>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
bind:value={$tariffsStore.tariffDraft.premiumNameEn}
/>
</Label.Root>
</div>
<div class="admin-form-row admin-form-row-2">
<div class="admin-field-label">
<span>{at("tariff_label_premium_squads", {}, "Premium Internal Squads")}</span>
<small
>{at(
"tariff_hint_premium_squads",
{},
"Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes"
)}</small
>
<AdminSelect
bind:value={$tariffsStore.selectedPremiumSquad}
items={panelSquadOptions}
placeholder={at("btn_add_premium_squad", {}, "Добавить premium-сквад")}
ariaLabel={at("btn_add_premium_squad", {}, "Добавить premium-сквад")}
onValueChange={(value) => {
tariffsStore.addSquadToDraft("premiumSquadUuids", value);
tariffsStore.update((s) => ({ ...s, selectedPremiumSquad: "" }));
}}
/>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid}
<button
type="button"
class="admin-chip"
on:click={() => tariffsStore.removeSquadFromDraft("premiumSquadUuids", uuid)}
>
{tariffsStore.squadLabel(uuid)}
<X size={12} />
</button>
{/each}
</div>
</div>
<Label.Root class="admin-field-label">
<span
>{at(
"tariff_label_premium_traffic_limit",
{},
"Месячный лимит premium-трафика, GB"
)}</span
>
<small
>{at(
"tariff_hint_premium_traffic_limit",
{},
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
)}</small
>
<input
class="input"
type="number"
min="0"
step="0.1"
placeholder="50"
bind:value={$tariffsStore.tariffDraft.premium_monthly_gb}
/>
</Label.Root>
</div>
</section>
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong>{at("tariff_premium_topup_title", {}, "Докупка premium-трафика")}</strong>
<small
>{at(
"tariff_premium_topup_subtitle",
{},
"Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
>
<AdminButton
size="sm"
onclick={() =>
tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
{#if tariffDraft.premiumTopupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupRubRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="0.1"
step="0.1"
placeholder="10"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
/>
<input
class="input"
type="number"
min="0"
step="0.01"
placeholder="199"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена premium-пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("premiumTopupRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.premiumTopupStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupStarsRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="0.1"
step="0.1"
placeholder="10"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
/>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="100"
bind:value={row.price}
aria-label={at(
"tariff_label_price_stars",
{},
"Цена premium-пакета в Telegram Stars"
)}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("premiumTopupStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
</Tabs.Content>
<Tabs.Content value="pricing" class="admin-tabs-content">
{#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong>{at("tariff_pricing_period_title", {}, "Периоды подписки и цены")}</strong>
<small
>{at(
"tariff_pricing_period_subtitle",
{},
"Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит"
)}</small
>
</div>
<AdminButton
size="sm"
onclick={() =>
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}
>
<Plus size={13} />
{at("tariff_btn_period", {}, "Период")}
</AdminButton>
</header>
{#if !tariffDraft.periodRows.length}
<p class="admin-muted">
{at(
"tariff_pricing_empty",
{},
"Добавьте хотя бы один период — без него тариф не появится на витрине."
)}
</p>
{:else}
<div class="admin-row-editor">
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span>
</div>
{#each tariffDraft.periodRows as row, index}
<div class="admin-row-editor-line admin-row-editor-4">
<input
class="input"
type="number"
min="1"
placeholder="1"
bind:value={row.months}
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
/>
<input
class="input"
type="number"
min="0"
step="0.01"
placeholder="299"
bind:value={row.rub}
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
/>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="150"
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}
>
<Trash2 size={13} />
</AdminButton>
</div>
{/each}
</div>
{/if}
</section>
{:else}
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong>{at("tariff_pricing_traffic_title", {}, "Пакеты трафика")}</strong>
<small
>{at(
"tariff_pricing_traffic_subtitle",
{},
"Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
>
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
>
{#if tariffDraft.trafficRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficRubRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="0.1"
step="0.1"
placeholder="50"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
class="input"
type="number"
min="0"
step="0.01"
placeholder="299"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.trafficStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficStarsRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="0.1"
step="0.1"
placeholder="50"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="150"
bind:value={row.price}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
{/if}
</Tabs.Content>
<Tabs.Content value="topup" class="admin-tabs-content">
{#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong
>{at("tariff_topup_title", {}, "Докупка трафика поверх месячного лимита")}</strong
>
<small
>{at(
"tariff_topup_subtitle",
{},
"Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
>
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
>
{#if tariffDraft.topupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupRubRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="0.1"
step="0.1"
placeholder="20"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
class="input"
type="number"
min="0"
step="0.01"
placeholder="149"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("topupRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.topupStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupStarsRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="0.1"
step="0.1"
placeholder="20"
bind:value={row.gb}
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
/>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="75"
bind:value={row.price}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("topupStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
{:else}
<p class="admin-muted">
{at(
"tariff_topup_traffic_hint",
{},
"Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания."
)}
</p>
{/if}
</Tabs.Content>
<Tabs.Content value="hwid" class="admin-tabs-content">
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong
>{at(
"tariff_hwid_packages_title",
{},
"Пакеты дополнительных устройств (HWID)"
)}</strong
>
<small
>{at(
"tariff_hwid_packages_subtitle",
{},
"Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
>
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
>
</div>
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
{#if tariffDraft.hwidRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidRubRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="1"
step="1"
placeholder="1"
bind:value={row.count}
aria-label={at(
"tariff_label_hwid_count_full",
{},
"Сколько устройств добавляет пакет"
)}
/>
<input
class="input"
type="number"
min="0"
step="0.01"
placeholder="99"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("hwidRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.hwidStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidStarsRows as row, index}
<div class="admin-row-editor-line">
<input
class="input"
type="number"
min="1"
step="1"
placeholder="1"
bind:value={row.count}
aria-label={at(
"tariff_label_hwid_count_full",
{},
"Сколько устройств добавляет пакет"
)}
/>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="50"
bind:value={row.price}
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("hwidStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
</Tabs.Content>
</Tabs.Root>
<div class="admin-dialog-actions">
<AdminButton onclick={() => tariffsStore.updateState({ tariffEditorOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton
variant="primary"
onclick={tariffsStore.saveTariffDraft}
disabled={tariffsSaving || !tariffDraft.key.trim()}
>
<Save size={14} />
{tariffsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save_tariff", {}, "Сохранить тариф")}
</AdminButton>
</div>
</Dialog>
<Dialog
open={tariffDeleteOpen}
title={at("tariff_delete_title", {}, "Удалить тариф?")}
description={tariffDeleteTarget
? at(
"tariff_delete_subtitle",
{ key: tariffDeleteTarget.key },
`Тариф ${tariffDeleteTarget.key} исчезнет из каталога продаж.`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => tariffsStore.updateState({ tariffDeleteOpen: false })}
class="admin-dialog"
>
<div class="admin-form-row">
<AdminButton onclick={() => tariffsStore.updateState({ tariffDeleteOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton variant="danger" onclick={tariffsStore.deleteTariff} disabled={tariffsSaving}>
<Trash2 size={14} />
{at("btn_confirm_delete", {}, "Подтвердить удаление")}
</AdminButton>
</div>
</Dialog>
@@ -0,0 +1,195 @@
<script>
import { RefreshCw, Trash2, Plus } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
export let at;
export let fmtMoney;
const tariffsStore = getContext("tariffsStore");
$: ({ tariffsCatalog, tariffsLoading, tariffsPath, tariffsSaving } = $tariffsStore);
$: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false);
$: disabledTariffs = Math.max(0, (tariffsCatalog.tariffs || []).length - enabledTariffs.length);
function tariffName(tariff) {
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
}
function tariffPriceSummary(tariff) {
if (tariff.billing_model === "traffic") {
const rub = tariff.traffic_packages?.rub || [];
const first = rub[0];
return first
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}`
: at("tariff_traffic_packages", {}, "Пакеты трафика");
}
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
return months
.map((month) => {
const rub = tariff.prices_rub?.[String(month)];
const stars = tariff.prices_stars?.[String(month)];
if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, "RUB")}`;
if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`;
return `${month} ${at("months_short", {}, "мес.")}`;
})
.join(" · ");
}
onMount(() => {
tariffsStore.loadTariffs();
});
</script>
{#if tariffsLoading}
<AdminEmptyState>{at("loading", {}, "Загрузка…")}</AdminEmptyState>
{:else}
<div class="admin-stat-grid">
<div class="admin-stat-card">
<span class="admin-stat-label">{at("tariffs_stat_total", {}, "Всего тарифов")}</span>
<strong class="admin-stat-value">{tariffsCatalog.tariffs.length}</strong>
<span class="admin-stat-trend"
>{at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length}</span
>
</div>
<div class="admin-stat-card">
<span class="admin-stat-label">{at("tariffs_stat_default", {}, "По умолчанию")}</span>
<strong class="admin-stat-value">{tariffsCatalog.default_tariff || "—"}</strong>
<span class="admin-stat-trend"
>{at("tariffs_stat_default_hint", {}, "Используется для новых подписок")}</span
>
</div>
<div class="admin-stat-card">
<span class="admin-stat-label">{at("tariffs_stat_disabled", {}, "Отключено")}</span>
<strong class="admin-stat-value">{disabledTariffs}</strong>
<span class="admin-stat-trend"
>{at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}</span
>
</div>
</div>
<article class="admin-card">
<header class="admin-card-head">
<div>
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
<small>{tariffsPath || "data/tariffs.json"}</small>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={tariffsStore.loadTariffs}
disabled={tariffsLoading || tariffsSaving}
>
<RefreshCw size={13} />
{at("btn_refresh", {}, "Обновить")}
</AdminButton>
<AdminButton
size="sm"
variant="primary"
onclick={tariffsStore.openCreateTariff}
disabled={tariffsLoading || tariffsSaving}
>
<Plus size={13} />
{at("btn_create_tariff", {}, "Создать тариф")}
</AdminButton>
</div>
</header>
<div class="admin-card-body">
{#if !tariffsCatalog.tariffs.length}
<AdminEmptyState>
{at(
"tariffs_catalog_empty",
{},
"Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога."
)}
</AdminEmptyState>
{:else}
<div class="admin-tariff-grid">
{#each tariffsCatalog.tariffs as tariff}
<article class="admin-tariff-card" class:is-disabled={tariff.enabled === false}>
<div class="admin-tariff-top">
<div>
<div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong>
{#if tariff.key === tariffsCatalog.default_tariff}
<AdminBadge variant="success"
>{at("status_default", {}, "Default")}</AdminBadge
>
{/if}
</div>
<code>{tariff.key}</code>
</div>
{#if tariff.enabled === false}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{:else}
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{/if}
</div>
<p>
{tariff.descriptions?.ru ||
tariff.descriptions?.en ||
at("no_description", {}, "Без описания")}
</p>
<div class="admin-tariff-facts">
<span
>{tariff.billing_model === "traffic"
? at("tariff_model_traffic", {}, "Трафик")
: at("tariff_model_periods", {}, "Периоды")}</span
>
<span>{tariffPriceSummary(tariff)}</span>
<span>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span
>
<span
>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length
? `${tariff.premium_monthly_gb || 0} GB`
: "—"}</span
>
<span
>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ??
"env"}</span
>
</div>
<div class="admin-tariff-actions">
<AdminButton size="sm" onclick={() => tariffsStore.openEditTariff(tariff)}>
{at("btn_configure", {}, "Настроить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.toggleTariffEnabled(tariff)}
disabled={tariffsSaving}
>
{tariff.enabled === false
? at("btn_enable", {}, "Включить")
: at("btn_disable", {}, "Выключить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving ||
tariff.enabled === false ||
tariff.key === tariffsCatalog.default_tariff}
>
{at("btn_set_default", {}, "По умолчанию")}
</AdminButton>
<AdminButton
size="sm"
variant="danger"
onclick={() =>
tariffsStore.updateState({
tariffDeleteTarget: tariff,
tariffDeleteOpen: true,
})}
disabled={tariffsSaving}
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
<Trash2 size={13} />
</AdminButton>
</div>
</article>
{/each}
</div>
{/if}
</div>
</article>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,365 @@
<script>
import { Label } from "$components/ui/primitives.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminPagination,
AdminSelect,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { getContext, onMount } from "svelte";
import { trafficOfLabel } from "../../lib/admin/format.js";
export let at = (key) => key;
export let fmtDateShort = (value) => value;
export let panelStatusBadge = () => ({});
export let resolvedAvatarUrl = () => "";
export let userDisplayName = () => "";
export let userInitials = () => "";
export let userSecondaryName = () => "";
const usersStore = getContext("usersStore");
$: ({
users,
usersTotal,
usersPage,
usersQuery,
usersFilter,
usersPanelStatus,
usersPremiumTraffic,
usersSort,
usersLoading,
} = $usersStore);
const USERS_PAGE_SIZE = 25;
$: usersHasMore = users.length === USERS_PAGE_SIZE;
const USERS_FILTER_OPTIONS = [
{ value: "all", label: at("filter_all", {}, "Все") },
{ value: "active", label: at("filter_not_banned", {}, "Не забанены") },
{ value: "banned", label: at("filter_banned", {}, "Забанены") },
{ value: "tg_linked", label: at("filter_tg_linked", {}, "С Telegram") },
{ value: "no_tg", label: at("filter_no_tg", {}, "Без Telegram") },
{ value: "email_linked", label: at("filter_email_linked", {}, "С email") },
{ value: "no_email", label: at("filter_no_email", {}, "Без email") },
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
];
const USERS_SORT_OPTIONS = [
{ value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") },
{ value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") },
{ value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") },
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") },
];
const USERS_PANEL_STATUS_OPTIONS = [
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
{ value: "active", label: at("status_active", {}, "active") },
{ value: "expired", label: at("status_expired", {}, "expired") },
{ value: "limited", label: at("status_limited", {}, "limited") },
];
const USERS_PREMIUM_TRAFFIC_OPTIONS = [
{ value: "all", label: at("premium_traffic_filter_all", {}, "Все (премиум)") },
{ value: "none", label: at("premium_traffic_filter_none", {}, "Без лимита в тарифе") },
{
value: "unlimited",
label: at("premium_traffic_filter_unlimited", {}, "Безлимит (оверрайд)"),
},
{ value: "good", label: at("premium_traffic_filter_good", {}, "Премиум: норма") },
{ value: "warn", label: at("premium_traffic_filter_warn", {}, "Премиум: мало") },
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
];
/** @param {Record<string, unknown> | null | undefined} pt */
function premiumTrafficBadgeVariant(pt) {
if (!pt || pt.state === "none") return "muted";
if (pt.state === "unlimited" || pt.state === "good") return "success";
if (pt.state === "warn") return "warning";
return "danger";
}
/** @param {Record<string, unknown> | null | undefined} pt */
function premiumTrafficBadgeText(pt) {
if (!pt || pt.state === "none") return "";
if (pt.state === "unlimited") return trafficOfLabel(pt.used_bytes, 0);
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
}
$: userTableHeaders = [
at("user", {}, "Пользователь"),
at("premium_traffic_filter_label", {}, "Премиум трафик"),
at("status", {}, "Статус"),
at("users_col_registration", {}, "Регистрация"),
];
onMount(() => {
usersStore.loadUsers();
});
</script>
<div class="admin-toolbar admin-toolbar-users">
<div class="admin-toolbar-search">
<input
type="search"
class="input"
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
value={usersQuery}
on:input={(e) => usersStore.updateState({ usersQuery: e.target.value })}
on:keydown={(e) =>
e.key === "Enter" && (usersStore.updateState({ usersPage: 0 }), usersStore.loadUsers())}
/>
<AdminButton
variant="primary"
onclick={() => {
usersStore.updateState({ usersPage: 0 });
usersStore.loadUsers();
}}>{at("find", {}, "Найти")}</AdminButton
>
</div>
<div class="admin-toolbar-controls">
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("filter", {}, "Фильтр")}</span>
<AdminSelect
value={usersFilter}
items={USERS_FILTER_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("filter", {}, "Фильтр")}
onValueChange={(value) => {
usersStore.updateState({ usersFilter: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
<AdminSelect
value={usersPanelStatus}
items={USERS_PANEL_STATUS_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("panel_status", {}, "Статус панели")}
onValueChange={(value) => {
usersStore.updateState({ usersPanelStatus: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label"
>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span
>
<AdminSelect
value={usersPremiumTraffic}
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
onValueChange={(value) => {
usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
<AdminSelect
value={usersSort}
items={USERS_SORT_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("sort", {}, "Сортировка")}
onValueChange={(value) => {
usersStore.updateState({ usersSort: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<div class="admin-toolbar-summary">
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
<strong>{usersTotal}</strong>
</div>
</div>
</div>
<div class="admin-table-wrap admin-users-table-wrap">
{#if usersLoading}
<AdminTableSkeleton
headers={userTableHeaders}
rows={USERS_PAGE_SIZE}
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
/>
{:else if !users.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span
></AdminEmptyState
>
{:else}
<AdminTable class="admin-users-table">
<thead>
<tr>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
<th>{at("status", {}, "Статус")}</th>
<th>{at("users_col_registration", {}, "Регистрация")}</th>
</tr>
</thead>
<tbody>
{#each users as user}
{@const avatar = resolvedAvatarUrl(user)}
{@const badge = panelStatusBadge(user)}
<tr
class="is-clickable"
role="button"
tabindex="0"
data-user-id={user.user_id}
on:click={() => usersStore.openUser(user)}
on:keydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
usersStore.openUser(user);
}
}}
>
<td class="admin-users-cell-user" data-label={at("user", {}, "Пользователь")}>
<div class="admin-users-cell-user-inner">
<span class="admin-avatar admin-avatar-sm">
{#if avatar}
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
<span>{userInitials(user)}</span>
{/if}
</span>
<div class="admin-users-cell-user-text">
<span class="admin-users-cell-name">{userDisplayName(user)}</span>
<span class="admin-users-cell-secondary">{userSecondaryName(user)}</span>
<span class="admin-users-cell-id">#{user.user_id}</span>
</div>
</div>
</td>
<td
class="admin-users-cell-premium"
data-label={at("premium_traffic_filter_label", {}, "Премиум трафик")}
>
{#if user.premium_traffic && user.premium_traffic.state !== "none"}
<AdminBadge
variant={premiumTrafficBadgeVariant(user.premium_traffic)}
class="admin-user-premium-badge"
>
{premiumTrafficBadgeText(user.premium_traffic)}
</AdminBadge>
{:else}
<span class="admin-user-premium-placeholder"
>{at("premium_traffic_na", {}, "—")}</span
>
{/if}
</td>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
</td>
<td
class="admin-users-cell-date admin-cell-mono"
data-label={at("users_col_registration", {}, "Регистрация")}
>
{fmtDateShort(user.registration_date)}
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
<AdminPagination
meta={`${at("page", {}, "Страница")} ${usersPage + 1}`}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={usersPage === 0}
nextDisabled={!usersHasMore}
onPrev={() => {
usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) });
usersStore.loadUsers();
}}
onNext={() => {
usersStore.updateState({ usersPage: usersPage + 1 });
usersStore.loadUsers();
}}
/>
<style>
.admin-users-cell-user-inner {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.admin-users-cell-user-text {
display: grid;
gap: 2px;
min-width: 0;
text-align: left;
}
.admin-users-cell-name {
font-weight: 650;
font-size: 13px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-users-cell-secondary {
font-size: 11px;
color: var(--admin-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-users-cell-id {
font-family: var(--font-mono);
font-size: 11px;
color: var(--admin-muted);
}
.admin-users-cell-premium {
white-space: nowrap;
}
.admin-users-cell-premium :global(.admin-user-premium-badge) {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.admin-user-premium-placeholder {
color: var(--admin-dim);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.admin-users-cell-date {
white-space: nowrap;
font-size: 12px;
color: var(--admin-muted);
}
.admin-users-table-wrap :global(.admin-users-table tbody tr.is-clickable:focus-visible) {
outline: 2px solid var(--admin-ring);
outline-offset: -2px;
}
</style>
@@ -0,0 +1,71 @@
export function structuredCloneSafe(value) {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value));
}
export function pretty(value) {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "Да" : "Нет";
return String(value);
}
export function fmtDate(value) {
if (!value) return "—";
try {
return new Date(value).toLocaleString("ru-RU");
} catch {
return String(value);
}
}
export function fmtDateShort(value) {
if (!value) return "—";
try {
return new Date(value).toLocaleDateString("ru-RU");
} catch {
return String(value);
}
}
export function fmtMoney(amount, currency) {
const sym = currency === "RUB" ? "₽" : currency || "";
const num = Number(amount || 0);
return `${num.toFixed(2)} ${sym}`.trim();
}
export function fmtTrafficBytes(value) {
const bytes = Number(value || 0);
if (!bytes || bytes <= 0) return "0 GB";
const gb = bytes / 1073741824;
const formatted = gb >= 10 ? gb.toFixed(1) : gb.toFixed(2);
return `${formatted.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")} GB`;
}
export function trafficPercentValue(used, limit) {
const usedBytes = Number(used || 0);
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((usedBytes / limitBytes) * 100)));
}
export function trafficLeftLabel(used, limit) {
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return "Без лимита";
return fmtTrafficBytes(Math.max(0, limitBytes - Number(used || 0)));
}
export function trafficOfLabel(used, limit) {
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return `${fmtTrafficBytes(used)} / без лимита`;
return `${fmtTrafficBytes(used)} / ${fmtTrafficBytes(limit)}`;
}
export function paymentStatusVariant(status) {
if (status === "succeeded") return "success";
if (typeof status === "string" && status.startsWith("pending")) return "warning";
return "danger";
}
export function optionLabel(options, value) {
return options.find((option) => option.value === value)?.label || value;
}
@@ -0,0 +1,121 @@
/** @typedef {{ date: string, amount: number }} RevenuePoint */
/**
* @param {string} iso
* @returns {number} UTC ms at noon (stable day bucket)
*/
function noonUtcMs(iso) {
const s = String(iso || "");
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
return Number.isFinite(t) ? t : 0;
}
/**
* @param {number} t
* @returns {string} YYYY-MM-DD UTC
*/
function isoUtcDateFromMs(t) {
const d = new Date(t);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* Monday 00:00 UTC for the week containing `iso` (date-only).
* @param {string} iso
*/
export function utcWeekStartMs(iso) {
const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`);
const dow = d.getUTCDay();
const offset = (dow + 6) % 7;
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - offset);
}
/**
* First day of month (UTC) containing `iso`.
* @param {string} iso
*/
export function utcMonthStartMs(iso) {
const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`);
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
}
/**
* @param {RevenuePoint[]} points sorted ascending by `date`
* @param {string} fromIso YYYY-MM-DD inclusive
* @param {string} toIso YYYY-MM-DD inclusive
* @returns {RevenuePoint[]}
*/
export function filterDailyByIsoRange(points, fromIso, toIso) {
if (!fromIso || !toIso) return [];
return points.filter((p) => p.date >= fromIso && p.date <= toIso);
}
/**
* @param {RevenuePoint[]} points sorted ascending
* @param {number} n
*/
export function sliceLastDays(points, n) {
if (!points?.length || n <= 0) return [];
const take = Math.min(n, points.length);
return points.slice(-take);
}
/**
* @param {RevenuePoint[]} daily sorted ascending, day granularity
* @returns {RevenuePoint[]}
*/
function bucketWeeks(daily) {
/** @type {Map<number, number>} */
const sums = new Map();
for (const p of daily) {
const k = utcWeekStartMs(p.date);
const amt = Number(p.amount) || 0;
sums.set(k, (sums.get(k) || 0) + amt);
}
return [...sums.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount }));
}
/**
* @param {RevenuePoint[]} daily sorted ascending
* @returns {RevenuePoint[]}
*/
function bucketMonths(daily) {
/** @type {Map<number, number>} */
const sums = new Map();
for (const p of daily) {
const k = utcMonthStartMs(p.date);
const amt = Number(p.amount) || 0;
sums.set(k, (sums.get(k) || 0) + amt);
}
return [...sums.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount }));
}
/**
* @param {RevenuePoint[]} dailySorted ascending by date, consecutive calendar days
* @param {"day" | "week" | "month"} granularity
*/
export function aggregateRevenueSeries(dailySorted, granularity) {
if (!dailySorted?.length) return [];
if (granularity === "week") return bucketWeeks(dailySorted);
if (granularity === "month") return bucketMonths(dailySorted);
return dailySorted.map((p) => ({ date: p.date, amount: Number(p.amount) || 0 }));
}
/**
* For chart hint: calendar span of inclusive range.
* @param {string} fromIso
* @param {string} toIso
*/
export function inclusiveDaySpan(fromIso, toIso) {
const a = noonUtcMs(fromIso);
const b = noonUtcMs(toIso);
if (!a || !b) return 0;
return Math.max(1, Math.round((b - a) / 86400000) + 1);
}
@@ -0,0 +1,101 @@
import { writable } from "svelte/store";
export function createAdsStore({ api, onToast, at }) {
const state = writable({
ads: [],
adsTotals: null,
adsLoading: false,
adCreateOpen: false,
adDraft: { source: "", start_param: "", cost: 0 },
});
async function loadAds() {
state.update((s) => ({ ...s, adsLoading: true }));
try {
const data = await api("/admin/ads");
if (data?.ok) {
state.update((s) => ({
...s,
ads: data.campaigns || [],
adsTotals: data.totals || {},
}));
}
} finally {
state.update((s) => ({ ...s, adsLoading: false }));
}
}
async function createAd() {
let draft = null;
state.update((s) => {
draft = s.adDraft;
return s;
});
if (!draft.source.trim() || !draft.start_param.trim()) return;
const res = await api("/admin/ads", {
method: "POST",
body: JSON.stringify(draft),
});
if (res?.ok) {
onToast(at("ad_created", {}, "Кампания создана"));
state.update((s) => ({
...s,
adCreateOpen: false,
adDraft: { source: "", start_param: "", cost: 0 },
}));
await loadAds();
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
async function toggleAd(ad) {
const res = await api(`/admin/ads/${ad.id}/toggle`, {
method: "POST",
body: JSON.stringify({ is_active: !ad.is_active }),
});
if (res?.ok) {
state.update((s) => ({
...s,
ads: s.ads.map((c) => (c.id === ad.id ? { ...c, is_active: !ad.is_active } : c)),
}));
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
async function deleteAd(ad) {
const res = await api(`/admin/ads/${ad.id}`, { method: "DELETE" });
if (res?.ok) {
state.update((s) => ({
...s,
ads: s.ads.filter((c) => c.id !== ad.id),
}));
onToast(at("ad_deleted", {}, "Кампания удалена"));
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
function setCreateOpen(open) {
state.update((s) => ({ ...s, adCreateOpen: open }));
}
function updateDraft(fields) {
state.update((s) => ({ ...s, adDraft: { ...s.adDraft, ...fields } }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadAds,
createAd,
toggleAd,
deleteAd,
setCreateOpen,
updateDraft,
};
}
@@ -0,0 +1,60 @@
import { writable } from "svelte/store";
export function createBroadcastStore({ api, onToast, at }) {
const state = writable({
broadcastTarget: "all",
broadcastText: "",
broadcastBusy: false,
broadcastResult: null,
});
const BROADCAST_TARGET_OPTIONS = [
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
];
async function runBroadcast() {
let text = "";
let target = "";
state.update((s) => {
text = s.broadcastText;
target = s.broadcastTarget;
s.broadcastBusy = true;
s.broadcastResult = null;
return s;
});
try {
const res = await api("/admin/broadcast", {
method: "POST",
body: JSON.stringify({ target, text }),
});
if (res?.ok) {
state.update((s) => ({
...s,
broadcastText: "",
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
}));
onToast(at("broadcast_started", {}, "Рассылка запущена"));
} else {
onToast(res?.error || at("broadcast_failed", {}, "Ошибка рассылки"));
}
} finally {
state.update((s) => ({ ...s, broadcastBusy: false }));
}
}
function updateField(fields) {
state.update((s) => ({ ...s, ...fields }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
runBroadcast,
updateField,
BROADCAST_TARGET_OPTIONS,
};
}
@@ -0,0 +1,59 @@
import { writable } from "svelte/store";
export function createLogsStore({ api }) {
const state = writable({
logs: [],
logsTotal: 0,
logsPage: 0,
logsUserFilter: "",
logsLoading: false,
});
const LOGS_PAGE_SIZE = 50;
async function loadLogs() {
state.update((s) => ({ ...s, logsLoading: true }));
let currentPage = 0;
let filter = "";
state.update((s) => {
currentPage = s.logsPage;
filter = s.logsUserFilter;
return s;
});
try {
let q = `/admin/logs?page=${currentPage}&page_size=${LOGS_PAGE_SIZE}`;
if (filter.trim()) {
q += `&user_id=${encodeURIComponent(filter.trim())}`;
}
const data = await api(q);
if (data?.ok) {
state.update((s) => ({
...s,
logs: data.logs || [],
logsTotal: data.total || 0,
}));
}
} finally {
state.update((s) => ({ ...s, logsLoading: false }));
}
}
function setPage(page) {
state.update((s) => ({ ...s, logsPage: page }));
loadLogs();
}
function setFilter(filter) {
state.update((s) => ({ ...s, logsUserFilter: filter }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadLogs,
setPage,
setFilter,
};
}
@@ -0,0 +1,47 @@
import { writable } from "svelte/store";
export function createPaymentsStore({ api }) {
const state = writable({
payments: [],
paymentsTotal: 0,
paymentsPage: 0,
paymentsLoading: false,
});
const PAYMENTS_PAGE_SIZE = 25;
async function loadPayments() {
state.update((s) => ({ ...s, paymentsLoading: true }));
let currentPage = 0;
state.update((s) => {
currentPage = s.paymentsPage;
return s;
});
try {
const data = await api(`/admin/payments?page=${currentPage}&page_size=${PAYMENTS_PAGE_SIZE}`);
if (data?.ok) {
state.update((s) => ({
...s,
payments: data.payments || [],
paymentsTotal: data.total || 0,
}));
}
} finally {
state.update((s) => ({ ...s, paymentsLoading: false }));
}
}
function setPage(page) {
state.update((s) => ({ ...s, paymentsPage: page }));
loadPayments();
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadPayments,
setPage,
};
}
@@ -0,0 +1,112 @@
import { writable } from "svelte/store";
export function createPromosStore({ api, onToast }) {
const state = writable({
promos: [],
promosTotal: 0,
promosPage: 0,
promosLoading: false,
promoCreateOpen: false,
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
});
const PROMOS_PAGE_SIZE = 25;
async function loadPromos() {
state.update((s) => ({ ...s, promosLoading: true }));
let currentPage = 0;
state.update((s) => {
currentPage = s.promosPage;
return s;
});
try {
const data = await api(`/admin/promos?page=${currentPage}&page_size=${PROMOS_PAGE_SIZE}`);
if (data?.ok) {
state.update((s) => ({ ...s, promos: data.promos || [], promosTotal: data.total || 0 }));
}
} finally {
state.update((s) => ({ ...s, promosLoading: false }));
}
}
async function createPromo() {
let draft = null;
state.update((s) => {
draft = s.promoDraft;
return s;
});
if (!draft.code.trim()) return;
const res = await api("/admin/promos", {
method: "POST",
body: JSON.stringify(draft),
});
if (res?.ok) {
onToast("Промокод создан");
state.update((s) => ({
...s,
promoCreateOpen: false,
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
}));
await loadPromos();
} else {
onToast(res?.error || "Ошибка");
}
}
async function togglePromo(promo) {
const res = await api(`/admin/promos/${promo.id}`, {
method: "PATCH",
body: JSON.stringify({ is_active: !promo.is_active }),
});
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.map((p) => (p.id === promo.id ? res.promo : p)),
}));
} else {
onToast(res?.error || "Ошибка");
}
}
async function deletePromo(promo) {
const res = await api(`/admin/promos/${promo.id}`, { method: "DELETE" });
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.filter((p) => p.id !== promo.id),
}));
onToast("Промокод удалён");
} else {
onToast(res?.error || "Ошибка");
}
}
function setPage(page) {
state.update((s) => ({ ...s, promosPage: page }));
loadPromos();
}
function setCreateOpen(open) {
state.update((s) => ({ ...s, promoCreateOpen: open }));
}
function updateDraft(fields) {
state.update((s) => ({ ...s, promoDraft: { ...s.promoDraft, ...fields } }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadPromos,
createPromo,
togglePromo,
deletePromo,
setPage,
setCreateOpen,
updateDraft,
PROMOS_PAGE_SIZE,
};
}
@@ -0,0 +1,97 @@
import { writable } from "svelte/store";
export function createSettingsStore({ api, onToast, at }) {
const state = writable({
settingsSections: [],
settingsLoading: false,
settingsDirty: {},
settingsSaving: false,
});
async function loadSettings() {
state.update((s) => ({ ...s, settingsLoading: true, settingsDirty: {} }));
try {
const data = await api("/admin/settings");
if (data?.ok) {
state.update((s) => ({
...s,
settingsSections: data.sections || [],
}));
}
} finally {
state.update((s) => ({ ...s, settingsLoading: false }));
}
}
function markDirty(key, value, deleted = false) {
state.update((s) => ({
...s,
settingsDirty: { ...s.settingsDirty, [key]: { value, deleted } },
}));
}
function clearDirty(key) {
state.update((s) => {
const next = { ...s.settingsDirty };
delete next[key];
return { ...s, settingsDirty: next };
});
}
async function saveSettings(onSettingsSaved) {
let dirty = {};
state.update((s) => {
dirty = s.settingsDirty;
return s;
});
if (!Object.keys(dirty).length) return;
state.update((s) => ({ ...s, settingsSaving: true }));
try {
const updates = {};
const deletes = [];
for (const [key, change] of Object.entries(dirty)) {
if (change.deleted) deletes.push(key);
else updates[key] = change.value;
}
const res = await api("/admin/settings", {
method: "PATCH",
body: JSON.stringify({ updates, deletes }),
});
if (res?.ok) {
onToast(at("settings_saved", {}, "Настройки сохранены"));
state.update((s) => ({ ...s, settingsDirty: {} }));
if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
await loadSettings();
} else if (res?.errors) {
const summary = Object.entries(res.errors)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
onToast(`Ошибки: ${summary}`);
} else {
onToast(res?.error || "Ошибка");
}
} finally {
state.update((s) => ({ ...s, settingsSaving: false }));
}
}
function resetField(field) {
if (field.overridden) {
markDirty(field.key, "", true);
} else {
clearDirty(field.key);
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadSettings,
markDirty,
clearDirty,
resetField,
saveSettings,
};
}
@@ -0,0 +1,56 @@
import { writable } from "svelte/store";
export function createStatsStore({ api, onToast, at }) {
const state = writable({
stats: null,
statsLoading: false,
statsError: "",
syncBusy: false,
});
async function loadStats() {
state.update((s) => ({ ...s, statsLoading: true, statsError: "" }));
try {
const data = await api("/admin/stats");
if (!data?.ok) {
state.update((s) => ({ ...s, statsError: data?.error || "load_failed" }));
} else {
state.update((s) => ({ ...s, stats: data }));
}
} catch (e) {
state.update((s) => ({ ...s, statsError: e?.message || String(e) }));
} finally {
state.update((s) => ({ ...s, statsLoading: false }));
}
}
async function triggerSync() {
let busy = false;
state.update((s) => {
busy = s.syncBusy;
return s;
});
if (busy) return;
state.update((s) => ({ ...s, syncBusy: true }));
try {
const res = await api("/admin/sync", { method: "POST" });
if (res?.ok) {
onToast(at("sync_started", {}, "Синхронизация запущена"));
await loadStats();
} else {
onToast(res?.error || at("sync_error", {}, "Ошибка синхронизации"));
}
} finally {
state.update((s) => ({ ...s, syncBusy: false }));
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadStats,
triggerSync,
};
}
@@ -0,0 +1,302 @@
import { writable } from "svelte/store";
import {
emptyTariffDraft,
cloneCatalog,
draftFromTariff,
tariffFromDraft as tariffFromDraftFn,
normalizeUuidList,
} from "../tariffDraft.js";
export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
const state = writable({
tariffsCatalog: {
default_tariff: "",
topup_packages_default: { rub: [], stars: [] },
tariffs: [],
},
tariffsPath: "",
tariffsLoading: false,
tariffsSaving: false,
tariffEditorOpen: false,
tariffEditingKey: "",
tariffDeleteOpen: false,
tariffDeleteTarget: null,
tariffDraft: emptyTariffDraft(),
panelSquads: [],
panelSquadsLoading: false,
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorTab: "general",
});
const tariffFromDraft = (draft) => tariffFromDraftFn(draft);
async function loadTariffs() {
state.update((s) => ({ ...s, tariffsLoading: true }));
try {
loadPanelSquads();
const data = await api("/admin/tariffs");
if (data?.ok) {
state.update((s) => ({
...s,
tariffsCatalog: cloneCatalog(data.catalog),
tariffsPath: data.path || "",
}));
} else {
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
}
} finally {
state.update((s) => ({ ...s, tariffsLoading: false }));
}
}
async function loadPanelSquads() {
let loading = false;
state.update((s) => {
loading = s.panelSquadsLoading;
return s;
});
if (loading) return;
state.update((s) => ({ ...s, panelSquadsLoading: true }));
try {
const data = await api("/admin/panel/internal-squads");
if (data?.ok) state.update((s) => ({ ...s, panelSquads: data.squads || [] }));
} catch (_error) {
void _error;
state.update((s) => ({ ...s, panelSquads: [] }));
} finally {
state.update((s) => ({ ...s, panelSquadsLoading: false }));
}
}
function squadLabel(uuid) {
let squads = [];
state.update((s) => {
squads = s.panelSquads;
return s;
});
const squad = squads.find((item) => item.uuid === uuid);
return squad ? `${squad.name} · ${uuid.slice(0, 8)}` : uuid;
}
function addSquadToDraft(field, uuid) {
if (!uuid) return;
state.update((s) => {
const current = normalizeUuidList(s.tariffDraft[field]);
if (current.includes(uuid)) return s;
return { ...s, tariffDraft: { ...s.tariffDraft, [field]: [...current, uuid] } };
});
}
function removeSquadFromDraft(field, uuid) {
state.update((s) => {
return {
...s,
tariffDraft: {
...s.tariffDraft,
[field]: normalizeUuidList(s.tariffDraft[field]).filter((item) => item !== uuid),
},
};
});
}
async function persistTariffs(nextCatalog, successText) {
state.update((s) => ({ ...s, tariffsSaving: true }));
let currentPath = "";
state.update((s) => {
currentPath = s.tariffsPath;
return s;
});
try {
const res = await api("/admin/tariffs", {
method: "PUT",
body: JSON.stringify({ catalog: nextCatalog }),
});
if (res?.ok) {
state.update((s) => ({
...s,
tariffsCatalog: cloneCatalog(res.catalog),
tariffsPath: res.path || currentPath,
tariffEditorOpen: false,
tariffDeleteOpen: false,
tariffDeleteTarget: null,
}));
if (onTariffsSaved) await onTariffsSaved(res.catalog);
flash(successText || at("tariffs_saved", {}, "Тарифы сохранены"));
} else {
flash(
res?.message || res?.error || at("tariffs_save_failed", {}, "Ошибка сохранения тарифов")
);
}
} finally {
state.update((s) => ({ ...s, tariffsSaving: false }));
}
}
function openCreateTariff() {
state.update((s) => ({
...s,
tariffEditingKey: "",
tariffDraft: emptyTariffDraft(),
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorOpen: true,
}));
}
function openEditTariff(tariff) {
state.update((s) => ({
...s,
tariffEditingKey: tariff.key,
tariffDraft: draftFromTariff(tariff),
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorOpen: true,
}));
}
async function saveTariffDraft() {
let s;
state.update((st) => {
s = st;
return st;
});
const tariff = tariffFromDraft(s.tariffDraft);
if (!tariff.key) {
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
return;
}
const existing = (s.tariffsCatalog.tariffs || []).find(
(item) => item.key === tariff.key && item.key !== s.tariffEditingKey
);
if (existing) {
flash(at("tariff_error_key_exists", {}, "Тариф с таким ключом уже есть"));
return;
}
const current = s.tariffsCatalog.tariffs || [];
const tariffs = s.tariffEditingKey
? current.map((item) => (item.key === s.tariffEditingKey ? tariff : item))
: [...current, tariff];
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(at("tariff_error_min_enabled", {}, "Должен быть хотя бы один включённый тариф"));
return;
}
const currentDefault =
s.tariffsCatalog.default_tariff === s.tariffEditingKey
? tariff.key
: s.tariffsCatalog.default_tariff;
const defaultTariff = enabledKeys.includes(currentDefault) ? currentDefault : enabledKeys[0];
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
at("tariff_saved", {}, "Тариф сохранён")
);
}
async function toggleTariffEnabled(tariff) {
let s;
state.update((st) => {
s = st;
return st;
});
const tariffs = (s.tariffsCatalog.tariffs || []).map((item) =>
item.key === tariff.key ? { ...item, enabled: item.enabled === false } : item
);
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(at("tariff_error_min_enabled", {}, "Должен остаться хотя бы один включённый тариф"));
return;
}
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff)
? s.tariffsCatalog.default_tariff
: enabledKeys[0];
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
at("tariff_status_updated", {}, "Статус тарифа обновлён")
);
}
async function setDefaultTariff(key) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!key || key === s.tariffsCatalog.default_tariff) return;
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: key },
at("tariff_default_updated", {}, "Тариф по умолчанию обновлён")
);
}
async function deleteTariff() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.tariffDeleteTarget) return;
const tariffs = (s.tariffsCatalog.tariffs || []).filter(
(item) => item.key !== s.tariffDeleteTarget.key
);
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(
at("tariff_error_delete_last_enabled", {}, "Нельзя удалить последний включённый тариф")
);
return;
}
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff)
? s.tariffsCatalog.default_tariff
: enabledKeys[0];
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
at("tariff_deleted", {}, "Тариф удалён")
);
}
function addDraftRow(field, row) {
state.update((s) => ({
...s,
tariffDraft: { ...s.tariffDraft, [field]: [...(s.tariffDraft[field] || []), row] },
}));
}
function removeDraftRow(field, index) {
state.update((s) => ({
...s,
tariffDraft: {
...s.tariffDraft,
[field]: (s.tariffDraft[field] || []).filter((_, idx) => idx !== index),
},
}));
}
function updateState(updates) {
state.update((s) => ({ ...s, ...updates }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
updateState,
loadTariffs,
loadPanelSquads,
squadLabel,
addSquadToDraft,
removeSquadFromDraft,
openCreateTariff,
openEditTariff,
saveTariffDraft,
toggleTariffEnabled,
setDefaultTariff,
deleteTariff,
addDraftRow,
removeDraftRow,
};
}
@@ -0,0 +1,522 @@
import { writable } from "svelte/store";
export function createUsersStore({ api, onToast, at }) {
const USERS_PAGE_SIZE = 25;
const USER_LOGS_PAGE_SIZE = 20;
const state = writable({
users: [],
usersTotal: 0,
usersPage: 0,
usersQuery: "",
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
usersSort: "registered_desc",
usersLoading: false,
openedUser: null,
openedUserDetail: null,
userDetailLoading: false,
userMessageDraft: "",
userExtendDays: 30,
userActionBusy: false,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userDetailTab: "profile",
premiumUnlimitedDraft: false,
premiumBonusGbDraft: "",
regularUnlimitedDraft: false,
regularBonusGbDraft: "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
userLogsPageSize: USER_LOGS_PAGE_SIZE,
});
let _activeRef = "stats"; // fallback if active isn't tracked
function setActive(active) {
_activeRef = active;
}
function _pushUserPath(userId) {
if (typeof window === "undefined") return;
if (window.location.protocol === "file:") return;
if (_activeRef !== "users") return;
const target = userId ? `/admin/users/${userId}` : `/admin/users`;
if (window.location.pathname === target) return;
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
}
async function loadUsers() {
state.update((s) => ({ ...s, usersLoading: true }));
let s;
state.update((st) => {
s = st;
return st;
});
try {
const params = new URLSearchParams({
page: String(s.usersPage),
page_size: String(USERS_PAGE_SIZE),
});
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter);
if (s.usersPanelStatus && s.usersPanelStatus !== "all")
params.set("panel_status", s.usersPanelStatus);
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
params.set("premium_traffic", s.usersPremiumTraffic);
}
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
const data = await api(`/admin/users?${params.toString()}`);
if (data?.ok) {
state.update((st) => ({
...st,
users: data.users || [],
usersTotal: data.total || (data.users || []).length,
}));
}
} finally {
state.update((st) => ({ ...st, usersLoading: false }));
}
}
async function openUser(userOrId, opts = {}) {
const userId =
typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
if (!userId) return;
state.update((s) => ({
...s,
openedUser:
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId },
openedUserDetail: null,
userMessageDraft: "",
userMessageConfirmOpen: false,
userExtendDays: 30,
userDetailLoading: true,
userDetailTab: "subscription",
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: userId,
}));
if (!opts.skipPush) _pushUserPath(userId);
try {
const res = await api(`/admin/users/${userId}`);
if (res?.ok) {
const sub = res.active_subscription || null;
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
state.update((s) => ({
...s,
openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "",
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
regularBonusGbDraft:
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
}));
} else {
onToast(res?.error || "load_failed");
state.update((s) => ({ ...s, openedUser: null }));
if (!opts.skipPush) _pushUserPath(null);
}
} finally {
state.update((s) => ({ ...s, userDetailLoading: false }));
}
}
function closeUser(opts = {}) {
let wasOpen = false;
state.update((s) => {
wasOpen = Boolean(s.openedUser);
return {
...s,
openedUser: null,
openedUserDetail: null,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
};
});
if (wasOpen && !opts.skipPush) _pushUserPath(null);
}
async function loadUserLogs(page) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const userId = s.openedUser.user_id;
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : s.userLogsPage || 0;
state.update((st) => ({
...st,
userLogsLoading: true,
userLogsPage: targetPage,
userLogsUserId: userId,
}));
try {
const params = new URLSearchParams({
page: String(targetPage),
page_size: String(USER_LOGS_PAGE_SIZE),
user_id: String(userId),
});
const data = await api(`/admin/logs?${params.toString()}`);
if (data?.ok) {
state.update((st) => {
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
return {
...st,
userLogs: data.logs || [],
userLogsTotal: Number(data.total || 0),
userLogsLoaded: true,
};
});
} else if (data?.error) {
onToast(data.error);
}
} finally {
state.update((st) => ({ ...st, userLogsLoading: false }));
}
}
function setUserLogsPage(page) {
loadUserLogs(page);
}
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
if (!text) return;
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(
() => onToast(successMessage),
() => onToast(text)
);
} else {
onToast(text);
}
}
function requestBanToggle() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
if (s.openedUser.is_banned) {
applyBanToggle(false);
} else {
state.update((st) => ({ ...st, userBanConfirmOpen: true }));
}
}
async function applyBanToggle(banned) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/ban`, {
method: "POST",
body: JSON.stringify({ banned }),
});
if (res?.ok) {
state.update((st) => {
const updatedUser = { ...st.openedUser, is_banned: banned };
return {
...st,
openedUser: updatedUser,
users: st.users.map((u) => (u.user_id === updatedUser.user_id ? updatedUser : u)),
userBanConfirmOpen: false,
};
});
onToast(
banned ? at("user_banned", {}, "Заблокирован") : at("user_unbanned", {}, "Разблокирован")
);
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function sendUserMessage() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message`, {
method: "POST",
body: JSON.stringify({ text: s.userMessageDraft }),
});
if (res?.ok) {
onToast(at("message_sent", {}, "Отправлено"));
state.update((st) => ({
...st,
userMessageDraft: "",
userMessageConfirmOpen: false,
}));
} else onToast(res?.error || at("message_send_failed", {}, "Ошибка отправки"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
function requestSendUserMessage() {
state.update((s) => {
if (!s.openedUser || !s.userMessageDraft.trim()) return s;
return { ...s, userMessageConfirmOpen: true };
});
}
async function previewUserMessage() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message/preview`, {
method: "POST",
body: JSON.stringify({ text: s.userMessageDraft }),
});
if (res?.ok) onToast(at("message_preview_sent", {}, "Превью отправлено в Telegram"));
else onToast(res?.error || at("message_preview_failed", {}, "Ошибка отправки превью"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function extendUser() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const days = Number(s.userExtendDays);
if (!days || days <= 0) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, {
method: "POST",
body: JSON.stringify({ days }),
});
if (res?.ok) {
onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`));
await openUser(s.openedUser, { skipPush: true });
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function resetTrialUser() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен"));
else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function savePremiumTrafficOverride() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const bonusGbRaw = s.premiumBonusGbDraft;
const bonusGb =
bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
? 0
: Number(bonusGbRaw);
if (Number.isNaN(bonusGb) || bonusGb < 0) {
onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.premiumUnlimitedDraft),
bonus_gb: bonusGb,
}),
});
if (res?.ok) {
onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function saveRegularTrafficOverride() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const regGbRaw = s.regularBonusGbDraft;
const regularGb =
regGbRaw === "" || regGbRaw === null || regGbRaw === undefined ? 0 : Number(regGbRaw);
if (Number.isNaN(regularGb) || regularGb < 0) {
onToast(
at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика")
);
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.regularUnlimitedDraft),
regular_bonus_gb: regularGb,
}),
});
if (res?.ok) {
onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function grantTraffic() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const gbRaw = s.grantTrafficGbDraft;
const gb = Number(gbRaw);
if (!gbRaw || Number.isNaN(gb) || gb <= 0) {
onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB"));
return;
}
const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
method: "POST",
body: JSON.stringify({ kind, gb }),
});
if (res?.ok) {
onToast(
kind === "premium"
? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
: at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
);
state.update((st) => ({ ...st, grantTrafficGbDraft: "" }));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function deleteUser() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}`, { method: "DELETE" });
if (res?.ok) {
onToast(at("user_deleted", {}, "Удален"));
state.update((st) => ({
...st,
users: st.users.filter((u) => u.user_id !== st.openedUser.user_id),
}));
closeUser();
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
function updateState(updates) {
state.update((s) => ({ ...s, ...updates }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
updateState,
setActive,
loadUsers,
openUser,
closeUser,
copyToClipboard,
requestBanToggle,
applyBanToggle,
sendUserMessage,
requestSendUserMessage,
previewUserMessage,
extendUser,
resetTrialUser,
deleteUser,
savePremiumTrafficOverride,
saveRegularTrafficOverride,
grantTraffic,
loadUserLogs,
setUserLogsPage,
};
}
@@ -0,0 +1,205 @@
import { structuredCloneSafe } from "./format.js";
export function emptyTariffDraft() {
return {
key: "",
nameRu: "",
nameEn: "",
descriptionRu: "",
descriptionEn: "",
premiumNameRu: "",
premiumNameEn: "",
squadUuids: [],
premiumSquadUuids: [],
billing_model: "period",
enabled: true,
monthly_gb: 500,
premium_monthly_gb: "",
hwid_device_limit: "",
conversion_rate_rub_per_gb: "",
periodRows: [
{ months: 1, rub: 150, stars: "" },
{ months: 3, rub: 400, stars: "" },
{ months: 6, rub: 750, stars: "" },
{ months: 12, rub: 1400, stars: "" },
],
topupRubRows: [],
topupStarsRows: [],
premiumTopupRubRows: [],
premiumTopupStarsRows: [],
trafficRubRows: [
{ gb: 10, price: 199 },
{ gb: 50, price: 799 },
],
trafficStarsRows: [],
hwidRubRows: [],
hwidStarsRows: [],
};
}
export function cloneCatalog(catalog) {
return structuredCloneSafe({
default_tariff: catalog?.default_tariff || "",
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
tariffs: catalog?.tariffs || [],
});
}
export function rowsFromPackages(packageSet, currency, valueKey) {
return (packageSet?.[currency] || []).map((pkg) => ({
[valueKey]: pkg[valueKey],
price: pkg.price,
}));
}
export function draftFromTariff(tariff) {
const months = new Set([
...(tariff.enabled_periods || []),
...Object.keys(tariff.prices_rub || {}).map(Number),
...Object.keys(tariff.prices_stars || {}).map(Number),
]);
const periodRows = [...months]
.filter((month) => Number.isFinite(month) && month > 0)
.sort((a, b) => a - b)
.map((month) => ({
months: month,
rub: tariff.prices_rub?.[String(month)] ?? "",
stars: tariff.prices_stars?.[String(month)] ?? "",
}));
return {
...emptyTariffDraft(),
key: tariff.key || "",
nameRu: tariff.names?.ru || "",
nameEn: tariff.names?.en || "",
descriptionRu: tariff.descriptions?.ru || "",
descriptionEn: tariff.descriptions?.en || "",
premiumNameRu: tariff.premium_names?.ru || "",
premiumNameEn: tariff.premium_names?.en || "",
squadUuids: tariff.squad_uuids || [],
premiumSquadUuids: tariff.premium_squad_uuids || [],
billing_model: tariff.billing_model || "period",
enabled: tariff.enabled !== false,
monthly_gb: tariff.monthly_gb ?? "",
premium_monthly_gb: tariff.premium_monthly_gb ?? "",
hwid_device_limit: tariff.hwid_device_limit ?? "",
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"),
topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"),
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"),
premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"),
trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"),
trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"),
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"),
hwidStarsRows: rowsFromPackages(tariff.hwid_device_packages, "stars", "count"),
};
}
export function parseNumber(value, fallback = null) {
if (value === "" || value === null || value === undefined) return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
export function parseIntNumber(value, fallback = null) {
const num = parseNumber(value, fallback);
return num === null ? fallback : Math.trunc(num);
}
export function compactMap(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([, value]) => value !== "" && value !== null && value !== undefined)
);
}
export function packagesFromRows(rows, valueKey) {
return (rows || [])
.map((row) => ({
[valueKey]: parseNumber(row[valueKey]),
price: parseNumber(row.price),
}))
.filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
}
export function packageSetFromRows(rubRows, starsRows, valueKey) {
const rub = packagesFromRows(rubRows, valueKey);
const stars = packagesFromRows(starsRows, valueKey);
return rub.length || stars.length ? { rub, stars } : null;
}
export function normalizeUuidList(value) {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
return String(value || "")
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function tariffFromDraft(draft) {
const key = draft.key.trim();
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
const descriptions = compactMap({
ru: draft.descriptionRu.trim(),
en: draft.descriptionEn.trim(),
});
const premiumNames = compactMap({
ru: draft.premiumNameRu.trim(),
en: draft.premiumNameEn.trim(),
});
const tariff = {
key,
names,
descriptions,
premium_names: premiumNames,
squad_uuids: normalizeUuidList(draft.squadUuids),
premium_squad_uuids: normalizeUuidList(draft.premiumSquadUuids),
billing_model: draft.billing_model,
enabled: Boolean(draft.enabled),
};
const hwidLimit = parseIntNumber(draft.hwid_device_limit);
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
const hwidPackages = packageSetFromRows(draft.hwidRubRows, draft.hwidStarsRows, "count");
if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
const premiumTopupPackages = packageSetFromRows(
draft.premiumTopupRubRows,
draft.premiumTopupStarsRows,
"gb"
);
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
if (tariff.billing_model === "period") {
const seenMonths = new Set();
const rows = (draft.periodRows || [])
.map((row) => ({
months: parseIntNumber(row.months),
rub: parseNumber(row.rub, 0),
stars: parseNumber(row.stars, 0),
}))
.filter((row) => row.months > 0)
.filter((row) => {
if (seenMonths.has(row.months)) return false;
seenMonths.add(row.months);
return true;
})
.sort((a, b) => a.months - b.months);
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
tariff.enabled_periods = rows.map((row) => row.months);
tariff.prices_rub = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
tariff.prices_stars = Object.fromEntries(
rows.map((row) => [String(row.months), row.stars || 0])
);
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
if (topupPackages) tariff.topup_packages = topupPackages;
} else {
const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb");
if (trafficPackages) tariff.traffic_packages = trafficPackages;
const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
}
return tariff;
}
@@ -0,0 +1,60 @@
export function userDisplayName(user) {
const full = [user?.first_name, user?.last_name].filter(Boolean).join(" ").trim();
return (
full || (user?.username ? `@${user.username}` : user?.email || `User #${user?.user_id || "—"}`)
);
}
export function userSecondaryName(user) {
if (user?.username && userDisplayName(user) !== `@${user.username}`) return `@${user.username}`;
if (user?.email && userDisplayName(user) !== user.email) return user.email;
return `ID ${user?.user_id || "—"}`;
}
export function userInitials(user) {
const source = userDisplayName(user).replace(/^@/, "").trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (source.slice(0, 2) || "U").toUpperCase();
}
export function userAvatarUrl(user) {
const cached = String(user?.avatar_url || "").trim();
if (cached) return cached;
const value = String(user?.telegram_photo_url || "").trim();
return value && !value.startsWith("/api/account/avatar") ? value : "";
}
export function createGravatarCache(onResolved = () => {}) {
const cache = new Map();
const pending = new Map();
async function sha256Hex(value) {
const buf = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function gravatarUrl(email) {
const key = String(email || "")
.trim()
.toLowerCase();
if (!key) return "";
if (cache.has(key)) return cache.get(key);
if (pending.has(key)) return "";
pending.set(
key,
sha256Hex(key)
.then((h) => {
cache.set(key, `https://gravatar.com/avatar/${h}?d=identicon&s=80`);
onResolved();
})
.catch(() => pending.delete(key))
);
return "";
}
return { gravatarUrl };
}
@@ -0,0 +1,21 @@
<script>
import { cn } from "$lib/utils.js";
export let variant = "muted";
let className = "";
export { className as class };
</script>
<span
class={cn(
"admin-badge",
variant === "success" && "admin-badge-success",
variant === "danger" && "admin-badge-danger",
variant === "warning" && "admin-badge-warning",
variant === "muted" && "admin-badge-muted",
className
)}
{...$$restProps}
>
<slot />
</span>
@@ -0,0 +1,45 @@
<script>
import { cva } from "class-variance-authority";
import { cn } from "$lib/utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let onclick = undefined;
let className = "";
export { className as class };
const buttonVariants = cva("admin-btn", {
variants: {
variant: {
default: "",
primary: "admin-btn-primary",
ghost: "admin-btn-ghost",
danger: "admin-btn-danger",
dangerSoft: "admin-btn-danger-soft",
icon: "admin-btn-icon",
},
size: {
default: "",
sm: "admin-btn-sm",
icon: "admin-btn-icon",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
</script>
<button
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{onclick}
{...$$restProps}
>
<slot />
</button>
@@ -0,0 +1,14 @@
<script>
import { cn } from "$lib/utils.js";
export let columns = 1;
let className = "";
export { className as class };
</script>
<div
class={cn("admin-cn-dashboard-grid", columns === 3 && "admin-cn-dashboard-grid--3", className)}
{...$$restProps}
>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div class={cn("admin-cn-dashboard-stack", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,11 @@
<script>
import { cn } from "$lib/utils.js";
export let tone = "default";
let className = "";
export { className as class };
</script>
<div class={cn(tone === "card" ? "admin-card-body" : "admin-empty", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,16 @@
<script>
import { Label } from "$components/ui/primitives.js";
export let label = "";
export let hint = "";
</script>
<Label.Root class="admin-field-label">
{#if label}
<span>{label}</span>
{/if}
{#if hint}
<small>{hint}</small>
{/if}
<slot />
</Label.Root>
@@ -0,0 +1,26 @@
<script>
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import AdminButton from "./AdminButton.svelte";
export let meta = "";
export let prevLabel = "Back";
export let nextLabel = "Next";
export let prevDisabled = false;
export let nextDisabled = false;
export let onPrev = () => {};
export let onNext = () => {};
</script>
<div class="admin-pagination">
<span class="admin-pagination-meta">{meta}</span>
<div class="admin-pagination-buttons">
<AdminButton size="sm" disabled={prevDisabled} onclick={onPrev}>
<ChevronLeft size={14} />
{prevLabel}
</AdminButton>
<AdminButton size="sm" disabled={nextDisabled} onclick={onNext}>
{nextLabel}
<ChevronRight size={14} />
</AdminButton>
</div>
</div>
@@ -0,0 +1,185 @@
<script>
import { onMount, tick } from "svelte";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
/** `{ date: ISO date string, amount: number }[]` */
export let series = [];
/** Total plot height in CSS px (axes + canvas). */
export let plotHeight = 204;
export let fmtMoney = (v, _currency) => String(v);
/** @type {string} */
export let currency = "RUB";
/** uPlot live legend: column header for the time (x) series */
export let legendTimeLabel = "Time";
/** uPlot live legend: column header for the value (y) series */
export let legendValueLabel = "Value";
let hostEl;
let plot;
let resizeObserver;
let syncTimer = 0;
/** Rebuild plot when legend copy changes (language), since series labels are init-only */
let builtLegendSig = "";
function readCssColor(name, fallback) {
if (typeof document === "undefined") return fallback;
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return raw || fallback;
}
function parseDayUnix(iso) {
const s = String(iso || "");
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
if (!Number.isFinite(t)) return 0;
return Math.floor(t / 1000);
}
function toAlignedData(rows) {
if (!rows?.length) return null;
const xs = rows.map((p) => parseDayUnix(p.date));
const ys = rows.map((p) => Number(p.amount) || 0);
return [xs, ys];
}
function yAxisTickLabels(values) {
return values.map((v) => fmtMoney(Number(v), currency));
}
/** uPlot passes already-formatted tick strings; reserve enough gutter so amounts are not clipped */
function yAxisGutterWidth(_u, values) {
const pad = 14;
const charPx = 6.1;
const maxChars = (values || []).reduce((m, v) => Math.max(m, String(v ?? "").length), 0);
return Math.min(104, Math.max(58, Math.ceil(pad + maxChars * charPx)));
}
/** Axis `size`: height (x / bottom) or width (y / left) in CSS px — only customize the y gutter */
function axisBandSize(_u, values, axisIdx) {
if (axisIdx !== 1) return 32;
return yAxisGutterWidth(_u, values);
}
function buildOpts(width) {
const w = Math.max(80, Math.floor(width));
const muted = readCssColor("--admin-muted", "#9aa7a2");
const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)");
const accent = readCssColor("--accent", "#00fe7a");
const lineStroke = readCssColor("--admin-text", "#e8f0ec");
return {
width: w,
height: plotHeight,
class: "admin-uplot",
pxAlign: true,
padding: [10, 12, 12, 10],
legend: {
show: true,
live: true,
markers: { show: true, width: 10, stroke: accent, fill: accent },
},
cursor: {
drag: { x: false, y: false },
points: { size: 7, width: 1, stroke: accent },
},
scales: {
x: { time: true },
y: { range: [0, null] },
},
series: [
{ label: legendTimeLabel },
{
label: legendValueLabel,
paths: uPlot.paths.spline(),
stroke: lineStroke,
width: 2,
cap: "round",
fill: "rgba(120, 140, 132, 0.14)",
},
],
axes: [
{
stroke: muted,
gap: 8,
grid: { show: true, stroke: border, width: 1 },
ticks: { stroke: border },
font: "11px system-ui,Segoe UI,sans-serif",
},
{
stroke: muted,
size: axisBandSize,
gap: 8,
grid: { show: true, stroke: border, width: 1 },
ticks: { stroke: border },
font: "10px system-ui,Segoe UI,sans-serif",
values: (u, ticks) => yAxisTickLabels(ticks),
},
],
};
}
function syncChart() {
if (!hostEl) return;
const d = toAlignedData(series);
const legendSig = `${legendTimeLabel}\0${legendValueLabel}`;
if (!d) {
plot?.destroy();
plot = undefined;
builtLegendSig = "";
return;
}
const w = Math.max(80, Math.floor(hostEl.clientWidth));
if (plot && builtLegendSig !== legendSig) {
plot.destroy();
plot = undefined;
}
if (!plot) {
plot = new uPlot(buildOpts(w), d, hostEl);
builtLegendSig = legendSig;
return;
}
plot.setData(d, true);
plot.setSize({ width: w, height: plotHeight });
}
function scheduleSync() {
if (typeof window === "undefined") return;
clearTimeout(syncTimer);
syncTimer = window.setTimeout(() => {
syncTimer = 0;
syncChart();
}, 0);
}
let rafId = 0;
onMount(() => {
rafId = requestAnimationFrame(() => {
void tick().then(() => {
scheduleSync();
if (!hostEl || typeof ResizeObserver === "undefined") return;
resizeObserver = new ResizeObserver(() => scheduleSync());
resizeObserver.observe(hostEl);
});
});
return () => {
cancelAnimationFrame(rafId);
clearTimeout(syncTimer);
resizeObserver?.disconnect();
resizeObserver = undefined;
plot?.destroy();
plot = undefined;
builtLegendSig = "";
};
});
$: if (hostEl) {
series;
plotHeight;
legendTimeLabel;
legendValueLabel;
scheduleSync();
}
</script>
<div class="admin-revenue-uplot-host" bind:this={hostEl}></div>
@@ -0,0 +1,145 @@
<script>
import { Popover, RangeCalendar } from "bits-ui";
import { parseDate } from "@internationalized/date";
import Button from "$components/ui/button.svelte";
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import { cn } from "$lib/utils.js";
let {
open = $bindable(false),
minIso = "",
maxIso = "",
committedFrom = "",
committedTo = "",
title = "",
applyLabel = "",
triggerLabel = "",
isActive = false,
onApply = () => {},
} = $props();
let value = $state({ start: undefined, end: undefined });
let prevOpen = $state(false);
function seedFromBounds() {
if (!minIso || !maxIso) return;
const minV = parseDate(minIso);
const maxV = parseDate(maxIso);
if (
committedFrom &&
committedTo &&
committedFrom >= minIso &&
committedTo <= maxIso &&
committedFrom <= committedTo
) {
value = { start: parseDate(committedFrom), end: parseDate(committedTo) };
return;
}
let start = maxV.subtract({ days: 29 });
if (start.compare(minV) < 0) start = minV;
value = { start, end: maxV };
}
$effect(() => {
if (open && !prevOpen) seedFromBounds();
prevOpen = open;
});
function calendarDateToIso(d) {
if (!d || typeof d !== "object") return "";
const y = d.year;
const m = String(d.month).padStart(2, "0");
const day = String(d.day).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function handleApply() {
const fromIso = calendarDateToIso(value?.start);
const toIso = calendarDateToIso(value?.end);
if (!fromIso || !toIso || fromIso > toIso) return;
onApply({ fromIso, toIso });
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
type="button"
class={cn("admin-revenue-period-btn", isActive && "is-active")}
disabled={!minIso || !maxIso}
aria-pressed={isActive}
>
{triggerLabel}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
class="admin-revenue-range-popover"
side="bottom"
align="end"
sideOffset={8}
trapFocus={true}
>
{#if title}
<div class="admin-revenue-range-popover__title">{title}</div>
{/if}
{#if minIso && maxIso}
<RangeCalendar.Root
class="admin-revenue-rcal"
bind:value
minValue={parseDate(minIso)}
maxValue={parseDate(maxIso)}
weekdayFormat="short"
fixedWeeks={true}
weekStartsOn={1}
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="admin-revenue-rcal__header">
<RangeCalendar.PrevButton class="admin-revenue-rcal__nav">
<ChevronLeft />
</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="admin-revenue-rcal__heading" />
<RangeCalendar.NextButton class="admin-revenue-rcal__nav">
<ChevronRight />
</RangeCalendar.NextButton>
</RangeCalendar.Header>
<div class="admin-revenue-rcal__grids">
{#each months as month (month.value.month)}
<RangeCalendar.Grid class="admin-revenue-rcal__grid">
<RangeCalendar.GridHead>
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
{#each weekdays as wd (wd)}
<RangeCalendar.HeadCell class="admin-revenue-rcal__headcell">
{wd.slice(0, 2)}
</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as weekDates, wi (wi)}
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
{#each weekDates as cellDate, di (`${wi}-${di}-${cellDate.toString()}`)}
<RangeCalendar.Cell
date={cellDate}
month={month.value}
class="admin-revenue-rcal__cell"
>
<RangeCalendar.Day class="admin-revenue-rcal__day">
{cellDate.day}
</RangeCalendar.Day>
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
</div>
{/snippet}
</RangeCalendar.Root>
{/if}
<div class="admin-revenue-range-popover__actions">
<Button variant="default" size="sm" onclick={handleApply}>{applyLabel}</Button>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
@@ -0,0 +1,11 @@
<script>
export let title = "";
export let description = "";
</script>
<div class="admin-dashboard-section-head">
<h3>{title}</h3>
{#if description}
<small>{description}</small>
{/if}
</div>
@@ -0,0 +1,41 @@
<script>
import { Check, ChevronDown } from "$components/ui/icons.js";
import { Select } from "$components/ui/primitives.js";
export let value = "";
export let items = [];
export let ariaLabel = "";
export let placeholder = "";
export let disabled = false;
export let sideOffset = 6;
export let onValueChange = () => {};
let className = "";
export { className as class };
$: selected = items.find((item) => item.value === value);
function handleValueChange(next) {
value = next;
onValueChange(next);
}
</script>
<Select.Root type="single" {value} {items} {disabled} onValueChange={handleValueChange}>
<Select.Trigger
class={`admin-select-trigger ${className}`.trim()}
aria-label={ariaLabel || placeholder}
>
<span>{selected?.label || placeholder}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}>
{#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item">
<span>{item.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
@@ -0,0 +1,17 @@
<script>
import { cn } from "$lib/utils.js";
export let skeleton = false;
let className = "";
export { className as class };
</script>
<div class="admin-table-wrap">
<table
class={cn("admin-table", skeleton && "admin-table-skeleton", className)}
aria-hidden={skeleton ? "true" : undefined}
{...$$restProps}
>
<slot />
</table>
</div>
@@ -0,0 +1,40 @@
<script>
import Skeleton from "$components/ui/skeleton.svelte";
import AdminTable from "./AdminTable.svelte";
export let headers = [];
export let rows = 6;
export let actionColumn = false;
export let widths = [];
function widthFor(index) {
if (widths[index]) return widths[index];
if (actionColumn && index === headers.length - 1) return "92px";
if (index === 0) return "48px";
if (index === headers.length - 1) return "76px";
return index % 3 === 0 ? "56%" : "72%";
}
</script>
<AdminTable skeleton>
<thead>
<tr>
{#each headers as header}
<th class:admin-cell-actions={actionColumn && header === headers[headers.length - 1]}
>{header}</th
>
{/each}
</tr>
</thead>
<tbody>
{#each Array(rows) as _, rowIndex (rowIndex)}
<tr>
{#each headers as _header, colIndex (`${rowIndex}-${colIndex}`)}
<td>
<Skeleton variant="line" width={widthFor(colIndex)} />
</td>
{/each}
</tr>
{/each}
</tbody>
</AdminTable>
@@ -0,0 +1,40 @@
<script>
import { cn } from "$lib/utils.js";
export let title = "";
export let value = "";
export let left = "";
export let percent = 0;
export let warning = false;
export let premium = false;
export let label = "";
$: clamped = Math.max(0, Math.min(100, Number(percent) || 0));
</script>
<div
class={cn(
"admin-traffic-card",
warning && "admin-traffic-card-warning",
premium && "admin-traffic-card-premium"
)}
>
<div class="admin-traffic-head">
<span>{title}</span>
<strong>{value}</strong>
</div>
<div
class={cn("admin-traffic-bar", premium && "admin-traffic-bar-premium")}
aria-label={label || title}
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(clamped)}
>
<span style={`width: ${clamped}%`}></span>
</div>
<div class="admin-traffic-meta">
<span>{left}</span>
<span>{clamped}%</span>
</div>
</div>
@@ -0,0 +1,14 @@
export { default as AdminBadge } from "./AdminBadge.svelte";
export { default as AdminButton } from "./AdminButton.svelte";
export { default as AdminDashboardGrid } from "./AdminDashboardGrid.svelte";
export { default as AdminDashboardStack } from "./AdminDashboardStack.svelte";
export { default as AdminEmptyState } from "./AdminEmptyState.svelte";
export { default as AdminField } from "./AdminField.svelte";
export { default as AdminPagination } from "./AdminPagination.svelte";
export { default as AdminRevenueChart } from "./AdminRevenueChart.svelte";
export { default as AdminRevenueCustomRangePopover } from "./AdminRevenueCustomRangePopover.svelte";
export { default as AdminSelect } from "./AdminSelect.svelte";
export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
export { default as AdminTable } from "./AdminTable.svelte";
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
@@ -0,0 +1,67 @@
<script>
import Skeleton from "$components/ui/skeleton.svelte";
export let label = "";
export let rows = 3;
export let actions = 0;
export let methods = 2;
export let showNote = false;
export let showPayButton = true;
export let showMeta = true;
</script>
<div class="dialog-skeleton" aria-label={label}>
{#if actions}
<div class="tariff-action-list">
{#each Array(actions) as _, index (index)}
<div class="tariff-action-card skeleton-row">
<span>
<Skeleton variant="title" />
<Skeleton variant="short" />
</span>
<Skeleton class="skeleton-line-price" />
</div>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
{/if}
<div class="option-list">
{#each Array(rows) as _, index (index)}
<div class={`option-row ${showMeta ? "plan-row" : "change-action-row"} skeleton-row`}>
<span class="option-row-main">
<Skeleton variant="title" />
<Skeleton variant="short" />
</span>
{#if showMeta}
<span class="option-row-meta">
<Skeleton class="skeleton-line-price" />
<Skeleton variant="tiny" />
</span>
{/if}
</div>
{/each}
</div>
{#if showNote}
<div class="topup-carryover-note skeleton-carryover-note">
<Skeleton variant="line" />
<Skeleton variant="short" />
</div>
{/if}
{#if methods}
<div class="method-grid">
{#each Array(methods) as _, index (index)}
<div class="method-card skeleton-method">
<Skeleton variant="dot" />
<Skeleton class="skeleton-line-method" />
</div>
{/each}
</div>
{/if}
{#if showPayButton}
<Skeleton class="skeleton-pay-button" />
{/if}
</div>
@@ -0,0 +1,11 @@
<script>
import Card from "$components/ui/card.svelte";
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<Card class={cn("empty-card", className)} {...$$restProps}>
<slot />
</Card>
@@ -0,0 +1,71 @@
<script>
import { Check, ChevronsUpDown, Globe2 } from "$components/ui/icons.js";
import { Select } from "$components/ui/primitives.js";
export let open = false;
export let value = "ru";
export let currentOption = null;
export let userLanguage = "";
export let options = [];
export let disabled = false;
export let clickGuard = false;
export let clickGuardArmed = false;
export let closeLabel = "Close";
export let label = "Language";
export let onOpenChange = () => {};
export let onValueChange = () => {};
function closeFromGuard(event) {
event.preventDefault();
event.stopPropagation();
if (clickGuardArmed) onOpenChange(false);
}
</script>
{#if open || clickGuard}
<button
class="language-select-guard"
class:language-select-guard--armed={clickGuardArmed}
type="button"
aria-label={closeLabel}
onpointerdown={closeFromGuard}
onclick={closeFromGuard}
></button>
{/if}
<div class="settings-row settings-row-language">
<Globe2 size={21} />
<Select.Root
type="single"
bind:open
{value}
items={options}
{disabled}
{onOpenChange}
{onValueChange}
>
<Select.Trigger class="language-select-trigger" aria-label={label}>
<span class="language-select-copy">
<strong>{label}</strong>
<small class="language-select-current">
<span class="emoji-flag" aria-hidden="true">{currentOption?.flag || "🏳️"}</span>
{currentOption?.label || userLanguage}
</small>
</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content class="language-select-content" side="bottom" align="end" sideOffset={6}>
<Select.Viewport class="language-select-viewport">
{#each options as option (option.value)}
<Select.Item value={option.value} label={option.label} class="language-select-item">
<span class="language-select-item-main">
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
<span>{option.label}</span>
</span>
<Check size={15} class="language-select-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</div>
@@ -0,0 +1,22 @@
<script>
import { cn } from "$lib/utils.js";
export let value = 0;
export let label = "";
let className = "";
export { className as class };
$: clamped = Math.max(0, Math.min(100, Number(value) || 0));
</script>
<div
class={cn("progress", className)}
role={label ? "progressbar" : undefined}
aria-label={label || undefined}
aria-valuemin={label ? "0" : undefined}
aria-valuemax={label ? "100" : undefined}
aria-valuenow={label ? Math.round(clamped) : undefined}
{...$$restProps}
>
<span style={`width: ${clamped}%`}></span>
</div>
@@ -0,0 +1,44 @@
<script>
import { Bitcoin, CreditCard } from "$components/ui/icons.js";
export let methods = [];
export let selectedMethod = "";
export let t = (key) => key;
export let onSelect = () => {};
function methodMeta(method) {
const id = String(method?.id || "").toLowerCase();
if (id.includes("platega_sbp"))
return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
if (id.includes("platega_crypto"))
return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
if (id.includes("yookassa") || id.includes("card"))
return { title: t("pay_with_yookassa_button"), icon: null };
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
if (id.includes("cryptopay") || id.includes("crypto"))
return { title: t("pay_with_cryptopay_button"), icon: null };
if (id.includes("stars")) return { title: t("pay_with_stars_button"), icon: null };
if (id.includes("sbp")) return { title: t("pay_with_sbp_button"), icon: null };
return { title: t("wa_method_other_title"), icon: null };
}
</script>
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => onSelect(method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
</div>
@@ -0,0 +1,11 @@
<script>
import { cn } from "$lib/utils.js";
export let error = false;
let className = "";
export { className as class };
</script>
<p class={cn("status-line", error && "error", className)} {...$$restProps}>
<slot />
</p>
@@ -0,0 +1,6 @@
export { default as DialogOptionsSkeleton } from "./DialogOptionsSkeleton.svelte";
export { default as EmptyCard } from "./EmptyCard.svelte";
export { default as LinearProgress } from "./LinearProgress.svelte";
export { default as LanguageSelect } from "./LanguageSelect.svelte";
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
export { default as StatusMessage } from "./StatusMessage.svelte";
@@ -0,0 +1,24 @@
<script>
import { cn } from "$lib/utils.js";
/** @type {'default' | 'outline' | 'destructive' | 'success' | 'muted'} */
export let variant = "default";
let className = "";
export { className as class };
</script>
<span
data-slot="badge"
class={cn(
"admin-cn-badge",
variant === "outline" && "admin-cn-badge-outline",
variant === "destructive" && "admin-cn-badge-destructive",
variant === "success" && "admin-cn-badge-success",
variant === "muted" && "admin-cn-badge-muted",
className
)}
{...$$restProps}
>
<slot />
</span>
@@ -0,0 +1,52 @@
<script>
import { cva } from "class-variance-authority";
import { cn } from "$lib/utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let href = "";
export let onclick = undefined;
let className = "";
export { className as class };
const buttonVariants = cva("btn", {
variants: {
variant: {
default: "btn-primary",
secondary: "btn-secondary",
outline: "btn-outline",
ghost: "btn-ghost",
telegram: "btn-telegram",
icon: "btn-icon",
},
size: {
default: "",
sm: "btn-sm",
lg: "btn-lg",
icon: "btn-square",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
</script>
{#if href}
<a class={cn(buttonVariants({ variant, size }), className)} {href} {onclick} {...$$restProps}>
<slot />
</a>
{:else}
<button
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{onclick}
{...$$restProps}
>
<slot />
</button>
{/if}
@@ -0,0 +1,12 @@
<script>
import { cn } from "$lib/utils.js";
export let active = false;
export let compact = false;
let className = "";
export { className as class };
</script>
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
<slot />
</section>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-action" class={cn("admin-cn-card-action", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-content" class={cn("admin-cn-card-content", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<p data-slot="card-description" class={cn("admin-cn-card-description", className)} {...$$restProps}>
<slot />
</p>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-footer" class={cn("admin-cn-card-footer", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-header" class={cn("admin-cn-card-header", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-title" class={cn("admin-cn-card-title", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card" class={cn("admin-cn-card", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,9 @@
import Root from "./card.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Description from "./card-description.svelte";
import Action from "./card-action.svelte";
import Footer from "./card-footer.svelte";
import Content from "./card-content.svelte";
export { Root, Header, Title, Description, Action, Footer, Content };
@@ -0,0 +1,64 @@
<script>
import { X } from "$components/ui/icons.js";
import { cn } from "$lib/utils.js";
import { cubicOut } from "svelte/easing";
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import Button from "./button.svelte";
export let open = false;
export let title = "";
export let description = "";
export let closeLabel = "Close";
export let onclose = () => {};
let className = "";
export { className as class };
function readReduceMotion() {
return (
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
let reduceMotion = readReduceMotion();
onMount(() => {
reduceMotion = readReduceMotion();
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = () => {
reduceMotion = mq.matches;
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
});
$: backdropTransition = reduceMotion ? { duration: 0 } : { duration: 200 };
$: cardIn = reduceMotion ? { duration: 0, y: 0 } : { duration: 260, y: 16, easing: cubicOut };
$: cardOut = reduceMotion ? { duration: 0, y: 0 } : { duration: 200, y: 10, easing: cubicOut };
</script>
{#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button
class="dialog-backdrop"
type="button"
aria-label={closeLabel}
onclick={onclose}
in:fade={backdropTransition}
out:fade={backdropTransition}
></button>
<section class={cn("dialog-card", className)} in:fly={cardIn} out:fly={cardOut}>
<div class="dialog-head">
<div>
{#if title}<h2>{title}</h2>{/if}
{#if description}<p>{description}</p>{/if}
</div>
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
<X size={18} />
</Button>
</div>
<slot />
</section>
</div>
{/if}
@@ -0,0 +1,66 @@
export {
Activity,
ArrowLeft,
ArrowRight,
Bitcoin,
CalendarDays,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Circle,
CircleX,
Coins,
Copy,
CreditCard,
Crown,
Database,
Download,
ExternalLink,
Eye,
EyeOff,
FileText,
Gift,
Globe2,
Home,
Info,
Key,
LayoutDashboard,
LockKeyhole,
Mail,
Map,
Megaphone,
Menu,
MessageSquare,
MousePointerClick,
Plus,
QrCode,
Radio,
RefreshCw,
Repeat2,
Save,
Send,
Server,
Settings,
Shield,
Sliders,
Smartphone,
Sparkles,
Tag,
Ticket,
Trash2,
TrendingDown,
TrendingUp,
TriangleAlert,
User,
UserMinus,
UserPlus,
UserRound,
Users,
UsersRound,
WalletCards,
X,
Zap,
} from "lucide-svelte";
@@ -0,0 +1,10 @@
export { default as Badge } from "./badge.svelte";
export { default as Button } from "./button.svelte";
export { default as Dialog } from "./dialog.svelte";
export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte";
export { default as Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte";
export * as Icons from "./icons.js";
export * as Card from "./card/index.js";
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
@@ -0,0 +1,29 @@
<script>
import { cn } from "$lib/utils.js";
export let value = "";
export let type = "text";
export let placeholder = "";
export let inputmode = undefined;
export let maxlength = undefined;
export let autocomplete = undefined;
export let disabled = false;
let className = "";
export { className as class };
</script>
<input
bind:value
class={cn("input", className)}
on:keydown
on:input
on:focus
on:blur
{type}
{placeholder}
{inputmode}
{maxlength}
{autocomplete}
{disabled}
{...$$restProps}
/>
@@ -0,0 +1 @@
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "bits-ui";
@@ -0,0 +1,25 @@
<script>
import { cn } from "$lib/utils.js";
export let variant = "block";
export let width = "";
export let height = "";
let className = "";
export { className as class };
</script>
<span
class={cn(
"ui-skeleton",
variant === "line" && "ui-skeleton-line",
variant === "title" && "ui-skeleton-line ui-skeleton-title",
variant === "short" && "ui-skeleton-line ui-skeleton-short",
variant === "tiny" && "ui-skeleton-line ui-skeleton-tiny",
variant === "badge" && "ui-skeleton-badge",
variant === "dot" && "ui-skeleton-dot",
className
)}
style={`${width ? `width:${width};` : ""}${height ? `height:${height};` : ""}`}
aria-hidden="true"
{...$$restProps}
></span>

Some files were not shown because too many files have changed in this diff Show More