Compare commits

..
420 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
kavoreandGitHub a7ec55d741 Merge pull request #152 from VAQYBIN/main
Add step-by-step install into README.md
2026-01-18 23:09:37 +03:00
VAQYBIN 0701af0f35 feat(docs): Добавлена пошаговая инструкция в README.md по установке бота 2026-01-19 01:06:44 +05:00
kavoreandGitHub c0851c5339 Merge pull request #151 from kavore/dev
bugfix
2026-01-17 23:55:25 +03:00
kavore 785b6c2d41 merge 2026-01-17 23:54:58 +03:00
kavore cce3fd4f58 bugfix #4 2026-01-17 23:23:06 +03:00
kavore 0dd6beebb2 remove nalogo and use custom client 2026-01-17 21:58:12 +03:00
kavore 8fb2a76698 bugfix 2026-01-17 21:25:02 +03:00
kavoreandGitHub 3d6713caa9 Merge pull request #150 from kavore/dev
bugfix and nalogo custom label
2026-01-17 21:11:41 +03:00
kavore 8d8fdce519 bugfix 2026-01-17 21:10:46 +03:00
kavore c385a1466c added custom label 2026-01-17 21:06:58 +03:00
kavoreandGitHub 8e1fdb33d2 Merge pull request #149 from kavore/dev
added nalogo
2026-01-17 16:18:38 +03:00
machka paslaandkavore 29222be1fa .env.example and docker-compose upd 2026-01-17 16:01:29 +03:00
machka pasla 08aca3b28d Add LOG_LEVEL env config and fix Nalogo receipt trigger 2026-01-14 16:03:21 +03:00
machka paslaandGitHub 658139d607 Merge pull request #148 from machka-pasla/lknpd
Lknpd
2026-01-14 14:54:31 +03:00
machka paslaandGitHub 1d4abf7977 Merge branch 'dev' into lknpd 2026-01-14 14:54:11 +03:00
3252a8 90ab186a7c Add platega payment request logging 2026-01-07 22:42:00 +03:00
machka pasla 62e8bc08d5 version update 2026-01-06 12:22:30 +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
machka pasla 475a30caf4 added my nalog management 2026-01-05 19:43:40 +03:00
machka paslaandGitHub c1593d7868 Merge pull request #145 from tdlcompiler/crypt4-fix 2025-12-30 07:00:41 +03:00
Denis Kartsev 0dbcfe3770 removed crypt4 prefix
panel api already returns a link with the prefix
2025-12-30 04:58:34 +03:00
machka pasla e3ffb87cfc updated crypt 2025-12-29 14:51:50 +03:00
machka pasla b4218ec6a7 Идемпотентная обработка вебхуков YooKassa 2025-12-26 13:44:51 +03:00
machka paslaandGitHub 48f8656ef8 Merge pull request #143 from machka-pasla/dev
bug fix in platega
2025-12-21 11:56:12 +03:00
machka pasla cee8491b3d bug fix 2025-12-21 11:55:49 +03:00
machka paslaandGitHub 4bc3f3b4b3 Merge pull request #142 from machka-pasla/dev
added severpay, platega, happ crypt4 support
2025-12-20 17:42:07 +03:00
machka pasla adb1105621 bug fix 2025-12-20 14:40:47 +03:00
machka pasla c8fd79e4ba shorten hwid 2025-12-20 09:41:10 +03:00
machka pasla 52bc9f71cc bug fix 2025-12-11 22:28:51 +03:00
machka pasla a1d3db0e16 added happ crypt4 links 2025-12-11 22:17:43 +03:00
machka pasla d0e34126e1 upd 2025-12-11 16:13:22 +03:00
machka pasla c96ebea077 upd 2025-12-11 13:51:54 +03:00
machka pasla f00a72ad15 yookassa fix 2025-12-11 12:59:39 +03:00
machka pasla 22171eb66f added gb packets selling 2025-12-11 12:47:32 +03:00
machka pasla 061fdeb72b removed tribute 2025-12-11 09:49:31 +03:00
machka pasla 8eba23574b some yookassa changes 2025-12-09 22:32:10 +03:00
machka pasla 7b650a74a6 severpay fix 2025-12-09 20:48:16 +03:00
machka pasla 805bd4cf60 readme upd 2025-12-09 20:21:46 +03:00
machka pasla 56a32d493f refactor 2025-12-09 20:17:46 +03:00
machka pasla b24a685066 added severpay.io 2025-12-09 19:50:14 +03:00
machka pasla fb06fbd0e1 added platega.io 2025-12-07 20:24:24 +03:00
machka pasla 719369057f fix privacy error 2025-12-04 12:13:17 +03:00
machka paslaandGitHub cea9d98155 Update and rename docker-compose-removed-server.yml to docker-compose-remote-server.yml 2025-11-28 13:16:02 +03:00
machka paslaandGitHub 6cf15e0698 Merge pull request #139
added external squad
2025-11-19 11:30:56 +03:00
machka pasla 2971b9ad2e added external squad 2025-11-19 11:30:34 +03:00
machka paslaandGitHub 3e86fc76b6 Merge pull request #135 from machka-pasla/dev
upd
2025-11-13 23:04:43 +03:00
machka paslaandGitHub 829a18715a Merge pull request #134 from machka-pasla/revert-133-revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность""
2025-11-13 23:04:13 +03:00
machka paslaandGitHub 844c8e12a7 Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"" 2025-11-13 23:03:56 +03:00
machka paslaandGitHub ef75f905f4 Merge pull request #133 from machka-pasla/revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"
2025-11-13 23:01:58 +03:00
machka paslaandGitHub cd816cbe5c Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность" 2025-11-13 23:01:46 +03:00
machka paslaandGitHub 2b0fa3a314 Merge pull request #132 from orryxvpn/fix/sync-admin-duplicate-subscriptions
Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность
2025-11-13 23:01:30 +03:00
machka paslaandGitHub ead990e61c Merge pull request #129 from 3252a8/feature/ref-link
Ref link improvement
2025-11-13 23:00:43 +03:00
orryxvpnandGitHub eb6e343e4c Refactor perform_sync function for readability 2025-11-13 21:30:15 +05:00
3252a8 caf5b88eef Use random string in ref link instead of tg ID
- Enable old links with tg id by default in .env with LEGACY_REFS=true
2025-11-11 22:32:30 +03:00
machka paslaandGitHub 183bef070d Merge pull request #124 from machka-pasla/dev
yookassa bug fix and other
2025-11-10 15:49:45 +03:00
machka pasla 643bf5fabb fix bug 2025-11-07 20:47:09 +03:00
machka pasla 7d446d5f64 upd promo bug 2025-11-06 19:03:34 +03:00
machka paslaandGitHub 73ce689d5e Merge pull request #120 from streletskiy/feature/user-profile-links
Feature/user profile links
2025-11-03 17:27:49 +03:00
Bogdan Strielecki 5791f2e58c Add user profile links
User profile buttons quickly opens user's telegram profile

Add user profile link button to log messages
Add user profile link button to user card in admin panel
Add referrer profile link button to log messages
Add referrer profile link button to user card in admin panel
2025-11-03 16:00:50 +03:00
machka pasla 69cc2cbe29 upd 2025-11-02 15:16:27 +03:00
machka pasla 7df2e127a4 fix 2025-10-31 22:41:48 +03:00
machka pasla d503cf7f7f YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING param added 2025-10-31 21:38:41 +03:00
machka paslaandGitHub c0d70030c3 Merge pull request #117 from machka-pasla/dev
Dev
2025-10-30 20:46:31 +03:00
machka paslaandGitHub 4e7c36dbf7 Merge pull request #115 from streletskiy/feature/paged-users-list
Paged list of all users
2025-10-30 17:57:00 +03:00
Bogdan Strielecki 3b0b88e9a0 Add admin button for delete user from bot and panel 2025-10-30 17:55:08 +03:00
Bogdan Strielecki 207e7751cd Add paged list of all users to admin panel 2025-10-28 15:13:19 +03:00
machka paslaandGitHub c65b608f0b Merge pull request #114 from machka-pasla/dev
more payment info in admin, docker compose for other servers, fixed channel sub check
2025-10-28 14:40:59 +03:00
machka pasla cff8368b7c docker hub push 2025-10-28 14:39:47 +03:00
machka paslaandGitHub 2f367bbd6b Merge pull request #112 from streletskiy/feature/display-user-income
Additional payment info in admin panel user card
2025-10-28 14:31:47 +03:00
machka pasla c3d6324ec6 fixed channel sub check 2025-10-27 15:58:59 +03:00
Bogdan Strielecki 4800a2da80 Additional payment info in admin panel user card
- total income
- total income from all user referrals
2025-10-27 13:38:37 +03:00
machka paslaandGitHub 70a0cd44a1 Merge pull request #111 from freize/main
An example Docker file for deploying a bot on a separate server
2025-10-25 20:02:43 +03:00
machka paslaandGitHub 545c9c7d07 Merge pull request #109 from Snowy-Fluffy/custom-payment-id
Добавление возможности выставлять ID способа оплаты для FreeKassa в env
2025-10-25 20:02:14 +03:00
FreizeandGitHub cd60f496fa Update docker-compose-removed-server.yml 2025-10-25 19:27:57 +03:00
FreizeandGitHub f883065bb9 Add Docker Compose configuration for removed server
This configuration file was created as an example showing the installation of the bot on a separate server.
2025-10-25 19:24:50 +03:00
Snowy-Fluffy b71bd71d8d allows you to set custom payment id for fk 2025-10-24 20:45:43 +03:00
Snowy-Fluffy 0524d24b13 set custom payment id for fk 2025-10-24 20:21:35 +03:00
machka paslaandGitHub cdfd94c814 Merge pull request #108 from machka-pasla/dev
fixed locales and traffic after adding days
2025-10-24 10:34:25 +03:00
machka pasla 6513681125 fixed locales and traffic after adding days 2025-10-24 10:33:10 +03:00
machka paslaandGitHub f321fd04cf Merge pull request #107
fixed ad
2025-10-21 09:45:32 +03:00
machka pasla baede17adf fixed ad 2025-10-21 09:44:21 +03:00
machka paslaandGitHub 17bf8720a3 Merge pull request #105 from machka-pasla/dev
fixed my devices keyboard
2025-10-20 22:28:36 +03:00
machka pasla 280aced20e fixed my devices keyboard 2025-10-20 22:26:50 +03:00
machka paslaandGitHub d672032201 Merge pull request #104 from machka-pasla/dev
Added freekassa, HWID Devices managment, mandatory sub to channel
2025-10-18 22:45:02 +03:00
machka pasla e826c4309d locales 2025-10-18 22:43:38 +03:00
machka pasla 9f525dc7da hwid devices integration 2025-10-18 22:39:56 +03:00
machka paslaandGitHub b880032b9b Merge pull request #103 from gldkru/feature/sub-devices-managment
Add 'My Devices' feature to user subscription management
2025-10-18 21:32:48 +03:00
Kirill Gladkikh 8f9484b6ec Add 'My Devices' feature to user subscription management
- Implemented a new command handler for displaying user devices.
- Added functionality to disconnect devices from the user's account.
- Updated subscription service to retrieve and manage device information.
- Enhanced inline keyboard to include device management options.
- Added new translations for device-related messages in English and Russian locales.
2025-10-18 02:27:19 +03:00
machka pasla c3622d9c2b channel require and db update 2025-10-17 09:36:25 +03:00
machka pasla 51ffbbfa1d pay from saved card 2025-10-16 22:04:04 +03:00
machka paslaandGitHub 394e8dcc6c Merge pull request #100 from zerodata731/freekassa-codex
Freekassa codex
2025-10-14 09:38:47 +03:00
machka paslaandGitHub 5f3fc13c2b Merge branch 'dev' into freekassa-codex 2025-10-14 09:38:39 +03:00
raufakchurin 4d43c9cf0f ORDER ID added to SUccess info 2025-10-14 11:28:41 +05:00
raufakchurin c891122064 before review 2025-10-14 10:31:55 +05:00
raufakchurin 59d07314e2 Удалили все личшние кроме СБП 2025-10-14 09:44:04 +05:00
machka pasla c438672ced fix 2025-10-13 10:16:30 +03:00
machka pasla 49c532e4db bruh 2025-10-13 10:06:58 +03:00
machka paslaandGitHub d50324d098 Merge pull request #99 from zerodata731/freekassa-codex
v_0.0
2025-10-13 10:05:45 +03:00
raufakchurin 52893a0629 v0.1 2025-10-12 15:27:48 +05:00
machka pasla 664cea447c errors when broadcast fix 2025-10-11 16:59:44 +03:00
machka pasla 5f50cfeaee broadcast real time logs 2025-10-11 16:53:26 +03:00
raufakchurin bdade47758 v_0.0 2025-10-11 18:06:49 +05:00
machka paslaandGitHub a3406451cb Merge pull request #98 from machka-pasla/dev
auto renew message with trial fix
2025-10-10 20:05:26 +03:00
machka pasla 9938671af9 auto renew message fix 2025-10-10 20:04:50 +03:00
machka paslaandGitHub 1d57c9be21 Merge pull request #96 from machka-pasla/dev
name filtration
2025-10-09 21:11:17 +03:00
machka pasla fde0f1b135 username filtration 2025-10-09 21:08:01 +03:00
Machka PaslaandGitHub 67df164498 Merge pull request #94 from machka-pasla/dev
tribute grace fix
2025-09-28 19:24:45 +03:00
machka-pasla 48e666f605 grace fix 2025-09-28 23:24:16 +07:00
machka-pasla 3cb15fa254 Add recent actions title to user management translations in English and Russian locales. 2025-09-16 15:19:36 +03:00
machka-pasla 2100949470 Add referral statistics to user card formatting. Updated user management to include referral service and added new translations for invited friends and purchased subscriptions in English and Russian locales. 2025-09-16 15:14:22 +03:00
Machka PaslaandGitHub c2a475b062 Merge pull request #90 from machka-pasla/dev
Bugfixes
2025-09-12 13:15:04 +03:00
machka-pasla 585cbe8fb6 Refactor subscription retrieval logic to limit results to the most recent active subscription. Updated query execution to return the first scalar result for improved performance. 2025-09-12 12:57:35 +03:00
machka-pasla a243ddcac8 Refactor ads deletion callback data parsing to improve clarity and maintainability. Updated the way campaign ID and back page are extracted from the callback data. 2025-09-11 23:05:57 +03:00
machka-pasla 357d7caf26 Refactor i18n handling in ads deletion prompt to use direct gettext calls. This improves language support and error messaging consistency. 2025-09-11 23:03:41 +03:00
machka-pasla 2713b3bae5 Add ad campaign deletion functionality with confirmation prompts and error handling. Updated inline keyboards and translations for delete actions. Enhanced subscription command handler to include fallback for config link URL. 2025-09-11 22:58:12 +03:00
Machka PaslaandGitHub 2215ee8ad2 Merge pull request #89 from machka-pasla/dev
Автопродление подписки при платеже через ЮКассу, микрофиксы и прочее-прочее
2025-09-11 09:58:05 +03:00
Machka PaslaandGitHub 1b6acff6d4 Merge pull request #88 from ProtoPes/add-dockerignore
Добавление файла .dockerignore
2025-09-08 22:36:03 +05:00
Machka PaslaandGitHub 25d9cc7446 Update .dockerignore
оставил локали, потому что не всегда идет прокидывание локалей через докер вольюм
2025-09-08 22:35:43 +05:00
ProtoPes e1e73ec736 Added ".dockerignore" to reduce the size of a docker image 2025-09-08 19:07:46 +03:00
Machka PaslaandGitHub cc21089f18 Merge pull request #84 from freize/patch-1
Correction of translation into Russian.
2025-09-07 23:10:20 +05:00
Machka PaslaandGitHub 46120210f1 Merge pull request #85 from freize/patch-2
Correction of the English translation.
2025-09-07 23:09:40 +05:00
Machka PaslaandGitHub df43283971 Update .env.example 2025-09-07 23:08:10 +05:00
Machka PaslaandGitHub a51b20ee54 Merge pull request #86 from freize/patch-3
Update .env.example
2025-09-07 23:06:26 +05:00
Machka PaslaandGitHub 0915022b52 Merge branch 'dev' into patch-3 2025-09-07 23:05:49 +05:00
FreizeandGitHub 11f5ec4a5b Update .env.example
Comments for bot configuration. Just in case.
2025-09-07 20:58:50 +03:00
FreizeandGitHub d187a79be6 Correction of the English translation. 2025-09-07 20:09:22 +03:00
FreizeandGitHub 7b05ad4cb1 Correction of translation into Russian. 2025-09-07 20:07:45 +03:00
machka-pasla 79ac5cb797 Enhance error handling and logging in CryptoPayService payment processing
- Added error handling for payment record creation and updates, ensuring database transactions are rolled back on failure.
- Implemented logging for errors during payment record creation and updates, improving traceability and debugging capabilities.
- Committed changes to ensure that payment records are properly persisted or rolled back in case of exceptions.
2025-09-07 12:55:54 +05:00
machka-pasla c208541525 Add autorenew cancellation handler and logging for subscription service
- Implemented a new callback handler to allow users to cancel auto-renewal of their subscriptions via a webhook button.
- Added logging to track subscription status and auto-renew settings during webhook checks, improving monitoring and debugging capabilities.
- Enhanced user feedback with appropriate alerts for unsupported actions and confirmation of updates to subscription settings.
2025-09-05 22:10:56 +03:00
machka-pasla 2b592e8abe Implement auto-renew confirmation and cancellation features in subscription module
- Added confirmation popups for enabling and disabling auto-renewal, enhancing user interaction and clarity.
- Introduced inline keyboard options for auto-renewal management, allowing users to confirm their choices easily.
- Updated notification messages to inform users about upcoming automatic charges and provide cancellation options.
- Enhanced localization files to include new strings for auto-renewal features in both English and Russian.
2025-09-05 22:02:52 +03:00
machka-pasla 43de4cc08b Add cryptocurrency and stars payment handlers to subscription module
- Implemented new callback handlers for cryptocurrency and stars payments, enhancing payment options for users.
- Integrated error handling and user feedback mechanisms for payment processes, improving overall user experience.
- Updated the pre-checkout and successful payment handling to accommodate new payment methods, ensuring seamless transaction processing.
- Organized imports for better modularity and clarity in the payments module.
2025-09-05 15:46:50 +03:00
machka-pasla 9a6ff6f636 Refactor pagination logic in admin ads list keyboard
- Updated the pagination row to only display when multiple pages are available, improving the user interface for ad navigation.
- Enhanced the inline keyboard structure for better clarity and usability in the admin panel's ads management section.
2025-09-05 15:17:01 +03:00
machka-pasla 8113908a98 Implement pagination and detailed views for ad campaigns in admin panel
- Added pagination support for the ads list, allowing admins to navigate through multiple pages of campaigns.
- Introduced detailed views for individual ad campaigns, displaying comprehensive statistics and information.
- Enhanced the database access layer with new methods for counting and listing campaigns with pagination.
- Updated localization files to include new strings for the ads overview and campaign details.
- Improved the inline keyboard structure for better navigation within the ads management interface.
2025-09-05 15:08:52 +03:00
machka-pasla 81155c9151 Add ads creation action to admin panel
- Introduced a new action for starting the ad creation process in the admin panel, enhancing ad management capabilities.
- Organized imports related to ad handling for better modularity and clarity in the codebase.
2025-09-05 15:00:47 +03:00
machka-pasla 7fa670c1e8 Add ads menu handler to admin panel actions
- Introduced a new action for displaying the ads menu in the admin panel, enhancing the admin interface for ad management.
- Improved the organization of imports related to ad handling, ensuring better modularity in the codebase.
2025-09-05 14:58:20 +03:00
machka-pasla d94430c602 Enhance ad creation flow with state-based filtering
- Introduced StateFilter to the ads creation flow, ensuring that messages are processed only when the bot is in specific admin states.
- Improved the organization of imports by removing redundant imports and clarifying state management for ad-related interactions.
2025-09-05 14:55:54 +03:00
machka-pasla c6292a1c8e Add ad campaign management features and enhance user attribution
- Introduced ad campaign management functionality, including the ability to create campaigns and attribute users based on ad parameters.
- Updated user command handlers to process new ad-related parameters and log user interactions with ad campaigns.
- Enhanced admin interfaces with new keyboard options for managing ads and displaying campaign information.
- Added database models for ad campaigns and attributions, improving data management for advertising features.
- Updated localization files to support new ad-related strings in both English and Russian.
2025-09-05 14:53:32 +03:00
machka-pasla d7d8409919 Refactor subscription command handler to improve user experience
- Updated the subscription command handler to dynamically adjust inline keyboard options based on user subscription status.
- Enhanced the mini-app connect button functionality, allowing access to additional features when the URL is configured.
- Improved the layout of inline keyboard buttons to prioritize new options, streamlining user interaction in subscription management.
2025-09-04 20:46:20 +03:00
machka-pasla 8f603ad51c Enhance subscription command handler with dynamic inline keyboard options
- Added support for a mini-app connect button in the subscription command handler, allowing users to access additional features if the URL is configured.
- Implemented an auto-renew toggle button and payment methods management button, improving user interaction based on subscription status and settings.
- Refactored inline keyboard construction to prepend new buttons above the existing markup, enhancing the user experience in subscription management.
2025-09-04 20:20:27 +03:00
machka-pasla 6f3b727cc7 Update .env.example to include YooKassa autopayments configuration
- Added new environment variables for enabling/disabling YooKassa autopayments, enhancing payment method management.
- Updated comments to clarify the relationship between autopayments and receipt fields, improving documentation for developers.
2025-09-04 20:08:37 +03:00
machka-pasla b7a723a088 Implement YooKassa autopayments feature toggle across payment and subscription handlers
- Introduced a global setting for enabling or disabling YooKassa autopayments, allowing for better control over payment method management and subscription renewals.
- Updated various handlers to check the autopayments setting before executing payment-related logic, ensuring that features are only available when enabled.
- Enhanced error handling to notify users when autopayments are disabled, improving user experience and clarity in payment operations.
- Refactored receipt generation logic to derive fields based on the autopayments setting, streamlining configuration management.
2025-09-04 20:03:47 +03:00
machka-pasla 807e3dadd3 Update database setup to include simple migrations and enhance .gitignore
- Added the `run_simple_migrations` function call in the database initialization process to ensure any missing columns are added during setup.
- Updated the .gitignore file to exclude the `models_old.py` file, improving project cleanliness.
2025-09-04 19:52:57 +03:00
machka-pasla f11ec6a676 Add re-exports for backward compatibility in subscription module
- Re-exported commonly used entrypoints from the core module to maintain backward compatibility.
- Ensured that existing functionality remains accessible after recent refactoring efforts in the subscription handling codebase.
2025-09-04 19:29:13 +03:00
machka-pasla d83f64c5fe Refactor subscription handling and payment method management
- Updated the user router to import the subscription router directly, preparing for future package separation.
- Removed the outdated subscription handler file, streamlining the codebase and improving maintainability.
- Introduced a new method for deleting user payment methods by provider ID, enhancing flexibility in payment management.
- Improved error handling and clarity in payment method operations, ensuring a smoother user experience.
2025-09-04 19:21:14 +03:00
machka-pasla 72eaed991a Improve payment method filtering logic in subscription history handler
- Added support for distinguishing between internal method IDs and direct provider payment method IDs, enhancing the accuracy of payment method filtering.
- Introduced a flag to track if a payment method filter was requested, allowing for clearer handling of cases where the method cannot be resolved.
- Updated error handling to ensure that empty payment histories are displayed appropriately when a filter is requested but cannot be matched.
2025-09-04 19:06:20 +03:00
machka-pasla 868c48ccb9 Remove outdated comments and fallback logic in payment method history handler
- Eliminated unnecessary comments regarding auto-renewal payment methods to enhance code clarity.
- Removed fallback logic for including auto-renew entries, streamlining the filtering process for user payments.
2025-09-04 19:00:03 +03:00
machka-pasla 2b855ed11b Refactor payment method ID handling in subscription history
- Updated the payment method history handler to improve the extraction of payment method IDs from callback data, enhancing reliability in filtering and navigation.
- Changed variable names for clarity, ensuring better readability and maintainability of the code.
- Improved error handling during ID extraction to prevent potential issues when no payment history is available.
2025-09-04 18:53:58 +03:00
machka-pasla 2bc7bea29d Enhance payment method history filtering and YooKassa integration
- Updated the payment method history handler to support filtering by specific saved payment methods, improving user experience when viewing payment logs.
- Increased the limit of recent payment logs retrieved from 10 to 30 for better visibility.
- Enhanced the YooKassa service to include detailed payment method information, including card details and last four digits, improving clarity in payment history.
- Refactored error handling in the YooKassa service to ensure robust fetching of payment information.
2025-09-04 18:49:31 +03:00
machka-pasla b6a2bf2329 Implement YooMoney payment method enhancements and localization updates
- Added functionality to save multiple YooMoney payment methods, marking the first entry as default.
- Refactored payment method display logic to standardize the presentation of YooMoney wallet details across various handlers.
- Introduced new localization strings for improved clarity in user-facing messages regarding payment methods.
- Enhanced error handling during payment method operations to ensure smoother user experience.
2025-09-04 18:42:48 +03:00
machka-pasla ba8be7c3f9 Standardize YooMoney wallet display across payment handlers and localization
- Updated the display logic for YooMoney wallet in payment and subscription handlers to ensure consistent naming and avoid leaking sensitive account information.
- Introduced a new localization string for wallet display, enhancing clarity in user-facing messages.
- Refactored relevant sections to improve overall code maintainability and user experience.
2025-09-04 18:26:15 +03:00
machka-pasla cb464059d9 Enhance payment method display for YooMoney integration
- Updated the logic for displaying payment method details, specifically for YooMoney, to include account number handling and last four digits extraction.
- Improved the consistency of payment method display across different handlers, ensuring a clearer user experience.
- Refactored relevant sections in both payment and subscription handlers to accommodate the new display logic.
2025-09-04 18:18:36 +03:00
machka-pasla a5aecb0f86 Enhance payment method display logic and localization updates
- Improved the logic for displaying payment method details, including card type and last four digits, to provide a clearer user experience.
- Updated localization strings to reflect changes in payment method terminology, ensuring consistency across user-facing messages.
- Refactored payment method handlers to utilize the new display logic, enhancing the overall management of payment methods.
2025-09-04 18:14:32 +03:00
machka-pasla cf7f0eae51 Refactor payment method ID extraction for improved reliability
- Updated the logic for extracting payment method IDs from callback data to handle cases with multiple segments, ensuring more robust parsing.
- Enhanced the consistency of ID retrieval across different payment method handlers, improving overall code reliability and user experience.
2025-09-04 18:00:04 +03:00
machka-pasla 850f135bf8 Implement multi-card deletion for payment methods with enhanced error handling
- Added functionality to delete specific payment methods based on their ID, supporting multi-card management.
- Improved error handling during deletion processes, including rollback mechanisms for database transactions.
- Updated user notifications to reflect the success or failure of deletion attempts, ensuring a consistent user experience.
- Refactored the response messages to include updated lists of remaining payment methods after deletion.
2025-09-04 17:52:21 +03:00
machka-pasla 793412c472 Enhance payment method history handling and navigation
- Added a new keyboard function to facilitate navigation back to specific payment method details.
- Updated the payment method history handler to utilize the new back navigation option, improving user experience when no payment history is available.
- Enhanced error handling for extracting payment method IDs from callback data to ensure smoother navigation.
2025-09-04 17:44:31 +03:00
machka-pasla a510ab1021 Refactor payment methods management to utilize a paginated list view
- Updated the payment methods management handler to directly build and display a paginated list of user payment methods, enhancing user experience.
- Removed legacy support for single card checks and deprecated keyboard functions in favor of the new list view.
- Improved localization handling for payment method titles and added fallback options for missing data.
2025-09-04 17:32:27 +03:00
machka-pasla 50cea15c92 Enhance localization for user-facing messages across payment and subscription services
- Updated various services to utilize the user's database language for all user-facing messages, improving the localization experience.
- Refactored message retrieval logic in payment processing, subscription management, and notification handling to ensure consistency in language usage.
- Added comments to clarify the purpose of language settings in relevant sections of the code.
2025-09-04 17:24:24 +03:00
machka-pasla 6b5828ea51 Implement multi-card payment method management and enhance user notifications
- Added support for saving multiple payment methods, allowing users to bind and manage their cards effectively.
- Introduced a new paginated list view for displaying saved payment methods, improving user experience.
- Enhanced user notifications for successful binding of payment methods, including localized messages.
- Updated the database models and data access layer to accommodate multi-card functionality.
- Refactored existing payment method handlers to integrate with the new multi-card system.
2025-09-04 17:11:18 +03:00
machka-pasla 257597ccb7 Enhance payment processing and user detail updates in YooKassa integration
- Added handling for 'waiting_for_capture' event in the YooKassa webhook to manage bind-only payment flows, including saving payment methods and canceling authorizations.
- Introduced a new method in the YooKassaService to cancel payments, improving error handling and logging for payment cancellations.
- Updated user detail synchronization to conditionally update descriptions only when they differ from the current panel state, enhancing efficiency.
2025-09-04 16:30:45 +03:00
Machka PaslaandGitHub dba82cfb97 Create LICENSE
MIT
2025-09-04 15:04:53 +03:00
machka-pasla e0e2cde9a7 Remove unused import for PaymentMethodType in yookassa_service.py to clean up code and improve readability. 2025-09-03 19:56:31 +03:00
machka-pasla 5b98e1a40c Implement payment methods management and binding functionality
- Added new handlers for managing payment methods, including viewing, binding, and deleting payment methods.
- Introduced new inline keyboard options for payment method management in user interactions.
- Enhanced the YooKassa service to support card binding with minimal payment requirements.
- Updated localization files to include new messages related to payment methods and their management.
2025-09-03 18:07:18 +03:00
machka-pasla 0f69823192 Refactor subscription fetching logic in toggle_autorenew_handler
- Updated the subscription retrieval process to directly fetch the Subscription model by ID, improving clarity and reducing potential import cycle issues.
- Enhanced code readability by adding a comment to clarify the purpose of the subscription fetch operation.
2025-09-03 16:32:18 +03:00
machka-pasla 38f3b1ad74 Enhance localization and fallback logic in i18n middleware
- Improved the effective language determination process by adding robust fallbacks to ensure a valid language is always used.
- Updated the gettext method to include explicit fallback to English if the requested language data is unavailable.
- Corrected a typo in the Russian localization for auto-renewal messaging to ensure clarity in user notifications.
2025-09-03 16:28:02 +03:00
machka-pasla 9b4dab84ad Enhance auto-renewal messaging and payment processing logic
- Introduced a new flag for auto-renew subscriptions to streamline messaging and avoid redundant configuration links.
- Updated localization files to include a new message for auto-renewal notifications in both English and Russian.
- Improved error handling and logging in the payment processing flow to ensure clarity and reliability during subscription renewals.
2025-09-03 10:44:20 +03:00
machka-pasla c607b6ef85 Enhance payment processing for auto-renew subscriptions
- Updated the payment processing logic to handle cases where payment_db_id may be absent for auto-renewal scenarios, ensuring idempotent creation of payment records using provider payment IDs.
- Implemented error handling for ensuring payment records and backfilling yookassa_payment_id, improving reliability in processing auto-renewal webhooks.
- Enhanced logging for better traceability of payment record creation and updates.
2025-09-03 10:32:39 +03:00
machka-pasla 09f0de78c6 Refactor subscription renewal process to utilize panel webhook
- Removed the recurring billing task from the bot, shifting the auto-renew functionality to the panel webhook service, which now triggers renewals 24 hours before expiry.
- Updated service dependencies to wire the subscription service with the panel webhook for seamless renewal handling.
- Adjusted the Subscription model to enable auto-renew by default, enhancing subscription management.
2025-09-03 10:22:02 +03:00
machka-pasla 6a3b4a6947 Safely extract and serialize payment method details in YooKassa webhook handling
- Enhanced the yookassa_webhook_route to safely extract payment method details, including card information, from the payment notification.
- Implemented error handling to log exceptions during serialization, improving reliability and debugging capabilities.
- Updated the payment processing dictionary to use the newly structured payment method data.
2025-09-03 10:12:15 +03:00
machka-pasla d6f707d386 Implement auto-renew subscription feature and enhance payment method handling
- Added a recurring billing task to automatically charge users one day before subscription expiry, improving subscription management.
- Introduced a new UserBilling model to store saved payment methods for off-session charges, enhancing user experience.
- Updated YooKassa service to support saving payment methods and capturing payments for auto-renewals.
- Enhanced subscription handling to toggle auto-renew settings and provide user feedback through localized messages.
- Improved error handling and logging for payment method persistence and subscription renewal processes.
2025-09-03 09:40:42 +03:00
Machka PaslaandGitHub dace4c2938 Merge pull request #82 from machka-pasla/dev
I HATE tribute
2025-09-01 21:26:30 +03:00
machka-pasla b69c2ab18d Enhance PanelWebhookService to support panel expiry updates and auto-renew payments
- Updated the PanelWebhookService to accept a new PanelApiService dependency for managing panel user details.
- Implemented functionality to update panel expiry upon subscription renewal, ensuring users maintain access to services.
- Added error handling and logging for both panel expiry updates and auto-renew payment record creation, improving reliability and user feedback.
2025-09-01 21:23:55 +03:00
machka-pasla 56fc88c10e Refactor trial confirmation and activation handlers to utilize new keyboard options
- Updated the trial confirmation and activation handlers to use a dynamic reply markup based on activation status, improving user interaction.
- Enhanced localization for trial activation details in both English and Russian, providing clearer messaging about trial status and connection instructions.
- Ensured consistency in the user experience across different scenarios by integrating the new keyboard options.
2025-08-30 13:29:42 +03:00
machka-pasla e74f801aec Add new error messages and subscription details in English and Russian localization
- Introduced additional error messages for improved user feedback, including service unavailability and payment gateway errors.
- Added subscription details localization to enhance user experience, providing information on subscription status and traffic usage.
- Ensured consistency in messaging across both English and Russian locales.
2025-08-29 13:09:01 +03:00
machka-pasla acf5f060a6 Enhance error handling in subscription response methods
- Implemented try-except blocks around callback answer methods to prevent exceptions from disrupting user interactions.
- Ensured consistent use of the `answer` method across various subscription-related handlers, improving reliability in user notifications.
2025-08-29 12:27:13 +03:00
machka-pasla fb2672732d Improve error handling in user interaction responses
- Refactored the handling of callback answers and message responses to include try-except blocks, ensuring that exceptions are caught and logged without disrupting the user experience.
- Updated message sending methods to utilize the `answer` method consistently, enhancing the reliability of user notifications in various scenarios.
2025-08-29 12:10:00 +03:00
machka-pasla f4730c3ceb Enhance user description synchronization with Telegram profile
- Added functionality to update user descriptions on the panel during synchronization, incorporating Telegram fields such as username, first name, and last name.
- Implemented error handling and logging for failed description updates in both admin sync and profile sync processes.
- Updated user creation and details update methods in the subscription service to include user descriptions, improving consistency across user data management.
2025-08-29 11:05:21 +03:00
machka-pasla e6b3a7c66f Add trial activation details in English and Russian localization
- Introduced a new message for trial activation details, providing users with information about the duration, validity, and configuration link.
- Enhanced user experience by ensuring clarity and consistency in both English and Russian locales.
2025-08-29 10:51:40 +03:00
machka-pasla 24cf193f3c Refactor broadcast and user logs messages for localization support
- Updated the broadcast confirmation message to utilize localization for dynamic content, enhancing multilingual support.
- Modified user logs display to incorporate localization for the title, improving consistency across languages.
- Added new localization entries in both English and Russian for better user experience and clarity.
2025-08-28 22:03:48 +03:00
machka-pasla 85bf0fb6fd Enhance promo export functionality with English localization
- Added support for exporting promo activations and all promo codes with captions and CSV headers in English.
- Updated the promo export handlers to ensure consistent English messaging for user notifications and CSV content.
- Introduced new localization entries for English in the locales files to support the changes.
2025-08-28 21:52:36 +03:00
machka-pasla b576d04501 Add welcome messages in English and Russian localization
- Introduced a new "welcome" message for users in both English and Russian, enhancing the user experience with personalized greetings.
2025-08-28 21:46:59 +03:00
machka-pasla 97268b9906 Add subscription expiration notifications in English and Russian
- Introduced new localized messages for notifying users about upcoming subscription expirations at 72, 48, and 24 hours.
- Added notifications for expired subscriptions and a message for cancelled Tribute subscriptions, enhancing user communication regarding their subscription status.
2025-08-28 21:41:40 +03:00
machka-pasla 1091b7b846 Update bot functionality and localization improvements
- Added new entries to .gitignore for backup JSON files.
- Enhanced user statistics in inline mode to include active users today.
- Improved message handling in broadcast functionality to differentiate between text and media messages, ensuring proper parameter usage.
- Updated payment handling to reflect pagination information in English and Russian.
- Refactored promo management to utilize localized button texts for better user experience.
2025-08-28 21:24:31 +03:00
Machka PaslaandGitHub f191c1813e Merge pull request #79 from ProtoPes/add-referral-check
Added check for referral user, small refactoring
2025-08-26 20:38:45 +03:00
ProtoPes e78f9bf291 Added check for referral user, small refactoring
With regular expressions we can be sure that the data is already valid
2025-08-26 20:28:50 +03:00
Machka PaslaandGitHub 5c74a08ed8 Merge pull request #78 from machka-pasla/dev
Implement user creation and synchronization enhancements in admin syn…
2025-08-26 18:14:40 +03:00
machka-pasla 14950fd559 Implement user creation and synchronization enhancements in admin sync handler
- Added functionality to create new users during synchronization if they are not found in the local database and have a valid Telegram ID.
- Introduced logging for newly created users to improve tracking and debugging.
- Enhanced synchronization statistics to include the count of newly created users, with localization support for both English and Russian.
- Updated the details of synchronization status to reflect additional statistics, improving clarity in admin reports.
2025-08-26 18:11:52 +03:00
Machka PaslaandGitHub 711b9a2487 Merge pull request #77 from machka-pasla/dev
Update broadcast and some other small bugs 🪲
2025-08-25 14:13:27 +03:00
machka-pasla 6e7eb6acfd Enhance message sending functionality to filter unsupported parameters
- Introduced a `SUPPORTED_PARAMS` dictionary to define valid parameters for each message type.
- Added a `filter_kwargs` utility function to filter out unsupported parameters based on the content type.
- Updated `send_message_by_type`, `send_message_via_queue`, and `send_direct_message` functions to utilize the new filtering logic, ensuring only valid parameters are passed during message sending.
- Improved handling for unknown content types by sending a default text message.
2025-08-25 13:49:31 +03:00
machka-pasla b42fae8772 Refactor message handling in broadcast and user management to utilize new utility functions
- Introduced `get_message_content` and `send_message_by_type` utility functions to streamline content type handling and message sending for various media types.
- Updated `process_broadcast_message_handler` and `process_direct_message_handler` to leverage these new functions, reducing code duplication and improving maintainability.
- Enhanced error handling for empty messages and improved message formatting with admin signatures.
2025-08-25 13:44:37 +03:00
machka-pasla f707662125 Refactor bot initialization and routing logic to enforce webhook mode requirement
- Updated the bot's startup logic to require a configured WEBHOOK_BASE_URL, exiting if not set, and logging appropriate error messages.
- Simplified the decision-making process for running the AIOHTTP server, ensuring it only runs in webhook mode.
- Enhanced the router configuration to filter updates for private chats, improving message handling security.
2025-08-25 13:24:30 +03:00
machka-pasla 60c6e0e961 Refactor subscription duration calculation in PanelWebhookService and SubscriptionService
- Introduced a utility function `add_months` to handle subscription duration calculations based on calendar months instead of a fixed 30-day period.
- Updated the auto-renewal logic in `PanelWebhookService` to use the new function for extending subscription end dates.
- Adjusted the duration calculation in `SubscriptionService` to derive the end date after a specified number of months, improving accuracy in subscription management.
2025-08-25 12:30:44 +03:00
machka-pasla b23b75b72e Refactor username update logic in SubscriptionService to prevent overwriting Telegram usernames
- Removed the conditional logic that updated the local user's username with the panel username, ensuring that the Telegram username remains unchanged.
- Added a comment to clarify the purpose of the update, focusing on maintaining the linkage to the panel UUID.
2025-08-25 12:24:30 +03:00
machka-pasla c69f02f7c0 Enhance direct message handling in user management to support multiple content types
- Updated the process_direct_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and send them accordingly to the target user.
- Added error handling for empty messages and invalid HTML content, improving user feedback.
- Included admin signature in messages, ensuring consistent formatting across different content types.
2025-08-20 17:12:40 +03:00
machka-pasla f22e359684 Refactor user creation logic in DAL to support race-safe inserts and return creation status
- Updated the create_user function to use PostgreSQL upsert for concurrent user creation, preventing IntegrityError.
- Modified the function to return a tuple containing the user object and a boolean indicating if the user was newly created.
- Adjusted the start_command_handler to log user registration only if a new user was created, improving logging clarity.
2025-08-20 15:49:12 +03:00
machka-pasla d2402fea77 Add preview message functionality for multiple content types in broadcast handler
- Implemented preview message sending for various content types (text, photo, video, animation, document, audio, voice, sticker, video_note) in the process_broadcast_message_handler.
- Added error handling for invalid HTML content in broadcast messages, improving user feedback and experience.
- Enhanced the confirmation prompt to provide a concise message preview without duplicating text.
2025-08-20 15:39:22 +03:00
machka-pasla 9b8ddb39da Enhance broadcast message handling to support multiple content types
- Updated the process_broadcast_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and store relevant data in the state.
- Implemented new methods in MessageQueueManager for queuing various media types, improving the flexibility of the broadcast system.
- Adjusted confirmation prompts to provide a concise message preview, enhancing user experience.
- Added localization for the new confirmation prompt in both English and Russian.
2025-08-20 15:30:06 +03:00
Machka PaslaandGitHub 459b655ae3 Merge pull request #76 from machka-pasla/dev
add new referral param and broadcast for groups of users
2025-08-19 14:54:30 +03:00
machka-pasla d81ab4137d Update payment processing logic to disable skipping active subscriptions
- Modified the payment processing functions across multiple services to set skip_if_active_before_payment to False, ensuring that active subscriptions are not skipped during payment processing.
- This change enhances the handling of user subscriptions and improves overall payment logic consistency.
2025-08-19 12:26:56 +03:00
Machka PaslaandGitHub cb6ae1e052 Update README.md 2025-08-18 15:22:29 +03:00
machka-pasla f4e2ae5fbd Enhance expired subscription handling in PanelWebhookService
- Updated the _handle_expired_subscription method to return a boolean indicating whether an auto-renewal was performed.
- Adjusted the handle_event method to suppress expiration notifications if an auto-renewal occurs, improving user experience and notification management.
2025-08-17 18:18:31 +03:00
machka-pasla 157c3a7c61 Add referral bonus configuration and enhance payment processing logic
- Introduced a new environment variable REFERRAL_ONE_BONUS_PER_REFEREE to control referral bonus application.
- Updated referral bonus application logic to skip bonuses for users with active subscriptions at the time of payment.
- Enhanced payment processing functions across multiple services to include current payment ID and skip logic for active users.
- Added a new database method to count succeeded payments for users, improving referral bonus eligibility checks.
2025-08-17 17:27:33 +03:00
machka-pasla ef9ebc1918 Refactor startup and shutdown handlers in bot initialization for compatibility with aiogram event signature
- Wrapped the startup and shutdown handlers to ensure they conform to the aiogram event signature, allowing for proper argument handling.
- Updated the web server to access dispatcher workflow data directly, preventing sequence protocol issues and enhancing stability.
2025-08-17 12:13:08 +03:00
machka-pasla 87664a7735 Remove unused router inclusion from dispatcher setup to streamline bot initialization 2025-08-17 12:08:42 +03:00
machka-pasla 3d58f60a4d Refactor bot initialization and service registration for improved modularity
- Integrated new service building functions to streamline bot initialization, enhancing maintainability and clarity.
- Updated the dispatcher setup to include core services dynamically, reducing redundancy in service registration.
- Added support for multiple broadcast targets in the admin handler, allowing for more flexible message distribution.
- Enhanced localization for new broadcast target options, improving user experience in both English and Russian.
- Implemented new database access methods to retrieve user IDs based on subscription status, optimizing broadcast logic.
2025-08-17 12:05:06 +03:00
machka-pasla a75a1f483c Add ProfileSyncMiddleware to keep user profile data updated in the database
- Integrated ProfileSyncMiddleware to ensure that user profile information (username, first_name, last_name) remains current in the database.
- Updated get_enhanced_user_statistics to use timezone-aware datetime for accurate SQL queries, preventing naive/aware comparison issues.
- Clarified comments in the user statistics function for better understanding of active user metrics and subscription handling.
2025-08-15 23:40:23 +03:00
Machka PaslaandGitHub 70d472e71c Merge pull request #71 from machka-pasla/dev
i hate tribute
2025-08-09 22:32:58 +03:00
machka-pasla 13a9e58e27 Refactor response handling in TributeService for improved clarity and consistency
- Introduced helper functions for generating standardized JSON responses, enhancing readability and maintainability.
- Updated response handling for various error conditions to return structured JSON instead of plain text.
- Acknowledged missing user ID with an "ignored" status to prevent unnecessary retries, improving webhook processing reliability.
2025-08-09 22:18:06 +03:00
machka-pasla 7219a6ac30 Refactor provider payment ID generation in TributeService for improved uniqueness
- Updated the logic to generate a unique, idempotent provider payment ID based on explicit event/payment identifiers or a combination of subscription ID and a hash of the raw payload.
- This change ensures better handling of webhook events and prevents potential conflicts in payment identification.
2025-08-09 21:29:04 +03:00
Machka PaslaandGitHub dbb27ee9ca Merge pull request #70 from machka-pasla/dev
Broadcast fix
2025-08-09 10:00:30 +03:00
machka-pasla 4e58fda4a5 Update broadcast message handler to enforce HTML parsing and disable web page previews
- Changed the parse_mode to "HTML" for broadcast messages to ensure proper formatting.
- Added disable_web_page_preview option to enhance message presentation and control over content display.
2025-08-09 09:51:21 +03:00
machka-pasla 8eb5daada5 Add HTML validation for broadcast messages in admin handler
- Implemented a preliminary check for HTML validity in broadcast messages by attempting to send a test message before processing.
- Added error handling for invalid HTML, providing user feedback in both English and Russian.
- Updated localization files to include new error messages for invalid HTML input.
2025-08-09 09:46:33 +03:00
machka-pasla 4c28d3868c Implement validation for broadcast message input in admin handler
- Updated the broadcast message handler to trim whitespace from the input text and added a check for empty messages.
- If the message is empty, an error response is sent to prompt the user for valid input, enhancing user experience and preventing empty broadcasts.
2025-08-09 09:43:02 +03:00
Machka PaslaandGitHub 08810eb2e0 Merge pull request #69 from machka-pasla/dev
Update promo_edit_field_handler to clarify callback data structure
2025-08-07 23:57:59 +03:00
machka-pasla b5d3b7b9c7 Update promo_edit_field_handler to clarify callback data structure
- Changed the variable name from 'i18n' to 'action' for better readability and understanding of the callback data being processed.
- Ensured that the promo ID and field to edit are still correctly extracted and updated in the state management.
2025-08-07 23:55:43 +03:00
Machka PaslaandGitHub 6330d57c60 Merge pull request #68 from machka-pasla/dev
Tribute hotfix, payments logs
2025-08-07 23:51:55 +03:00
machka-pasla 149ff057a9 Refactor subscription synchronization logic for improved handling
- Enhanced the subscription syncing process to prioritize concrete subscription UUIDs for updates and creations, ensuring idempotency.
- Implemented atomic updates for existing subscriptions and streamlined the creation of new subscriptions when a UUID is available.
- Improved logging for subscription updates and creations to provide clearer feedback on sync actions.
- Added handling for cases where no subscription UUID is present, avoiding unnecessary record creation.
2025-08-07 23:47:19 +03:00
machka-pasla bb7641bb74 Improve amount conversion in TributeService to handle minor units
- Updated the amount handling logic to convert minor currency units (kopecks/cents) to major units before persisting.
- Added error handling for invalid amount inputs to ensure robustness in processing payment data.
- Ensured that the amount is rounded to two decimal places for accurate representation in the system.
2025-08-07 23:39:48 +03:00
machka-pasla 18f65ea493 Refactor notification handling and streamline router registration
- Replaced legacy notification functions with a unified NotificationService for better maintainability and clarity.
- Updated the main bot router registration to utilize a root router, simplifying the inclusion of user and admin routes.
- Removed unused middleware and helper functions to enhance code cleanliness and focus on essential components.
- Improved localization by adding new error messages for user interactions.
2025-08-07 23:30:32 +03:00
machka-pasla 5853b9da63 Enhance payment identifier normalization in TributeService
- Updated the TributeService to normalize provider payment identifiers, prioritizing true payment identifiers over subscription IDs for better uniqueness.
- Implemented fallback logic to append timestamps to subscription IDs, preventing deduplication of renewals.
- Expanded the list of successful charge events to include various payment-related events, improving event handling consistency.
2025-08-07 23:02:23 +03:00
machka-pasla 91cfe0baf3 Add payments feature to admin panel
- Integrated payments functionality into the admin panel by adding a new payments router and corresponding handlers.
- Updated the admin panel actions to include a view payments option, enhancing admin capabilities.
- Implemented new database functions to retrieve successful payment counts and details for export.
- Enhanced localization with new strings for payments management in both English and Russian.
2025-08-07 22:48:09 +03:00
machka-pasla 194f1b9e49 Enhance recent payment log retrieval to filter by succeeded status
- Updated the `get_recent_payment_logs_with_user` function to include a filter for payments with a 'succeeded' status, improving the relevance of retrieved payment logs.
- Adjusted the query structure for better readability and maintainability.
2025-08-07 19:00:27 +03:00
machka-pasla df15cfd25e Refactor promo handler functions to include session management
- Updated the promo_delete_handler, promo_edit_select_handler, and promo_edit_field_handler functions to accept an AsyncSession parameter, improving database interaction consistency.
- Enhanced the handling of expired subscriptions in the PanelWebhookService by modifying notification logic to only send messages if enabled, ensuring better control over user notifications.
- Added an import for the 'and_' function in payment_dal.py to support more complex query conditions.
2025-08-07 18:46:19 +03:00
machka-pasla 57e693fa37 Refactor synchronization messaging for clarity and simplicity
- Updated synchronization messages to provide simpler, more concise feedback to admins during the sync process.
- Replaced detailed sync status messages with straightforward notifications for success, failure, and errors.
- Enhanced localization for new message formats to improve user experience across languages.
2025-08-06 22:26:07 +03:00
Machka PaslaandGitHub 60ea6fff0d Merge pull request #67 from machka-pasla/dev
fix bug
2025-08-06 20:22:20 +03:00
machka-pasla 3cee4b243a Refactor details string handling in statistics display
- Simplified the logic for displaying synchronization details by removing the character limit, ensuring full visibility of the details or defaulting to "N/A" when not available.
- Improved code readability by streamlining the assignment of the details string.
2025-08-06 20:19:45 +03:00
machka-pasla a42f80160b Refactor broadcast confirmation prompt and enhance sync notification handling
- Updated the broadcast confirmation prompt to display the full message instead of a truncated preview.
- Improved error handling in the sync process by removing character limits on error details and ensuring comprehensive logging.
- Added a notification feature to inform admins about the panel synchronization status, including success and failure details.
- Enhanced localization for the broadcast confirmation prompt and added new log messages for sync notifications.
2025-08-06 19:04:03 +03:00
Machka PaslaandGitHub 74548527f1 Merge pull request #66 from machka-pasla/dev
autosync and bug fixes
2025-08-06 18:51:27 +03:00
Machka PaslaandGitHub f8e3bee52a Update .env.example 2025-08-06 18:49:21 +03:00
machka-pasla 649528e165 Refactor bot username retrieval in bulk promo code creation
- Updated the logic to retrieve the bot username dynamically, enhancing the accuracy of CSV links for promo codes.
- Improved error handling to log failures in fetching the bot username, ensuring better traceability.
- Adjusted the promo code service initialization in the start command handler to use the message context, improving consistency in bot interactions.
2025-08-06 18:03:14 +03:00
machka-pasla 859263dc2d Enhance bulk promo code creation with CSV export functionality
- Added the ability to generate and send a CSV file containing created promo codes, improving data accessibility for admins.
- Updated success messages to inform users about the CSV file and the number of created promo codes.
- Refactored the promo code creation logic to include detailed validity information and links for activation in the CSV output.
- Improved localization for bulk promo creation messages to enhance user experience.
2025-08-06 17:58:44 +03:00
machka-pasla a126c05365 Refactor variable assignment in promo detail retrieval
- Updated the variable assignment for the status emoji in the `get_promo_detail_text_and_keyboard` function to improve clarity and consistency in the code.
- Enhanced readability by using more descriptive variable names, aligning with recent refactoring efforts in the promo management handler.
2025-08-06 17:49:06 +03:00
machka-pasla d0c09f9e06 Enhance promo code creation and management logging
- Added logging for successful promo code creation, improving traceability of actions.
- Updated success message formatting to use a new variable for validity display, enhancing clarity for users.
- Refactored variable names in the promo management handler for better readability and consistency.
2025-08-06 17:41:58 +03:00
machka-pasla 9f7171a5c3 Refactor admin sync process for enhanced user and subscription tracking
- Updated the `perform_sync` function to improve tracking of users and subscriptions during synchronization.
- Introduced additional counters for detailed logging, including users without Telegram IDs and those not found in the database.
- Enhanced error handling and logging to provide clearer insights into the synchronization process and outcomes.
- Improved the summary details returned after synchronization, offering a comprehensive overview of the sync results.
2025-08-06 17:21:25 +03:00
machka-pasla 990b08cfdc Enhance subscription handling in admin sync process
- Added logic to retrieve the subscription UUID from the panel user data, improving the accuracy of subscription records.
- Updated the subscription creation process to use the actual subscription UUID when available, with fallback to the user UUID.
- Enhanced logging to provide clearer information on which UUID is being used for each user during synchronization.
2025-08-06 17:00:44 +03:00
machka-pasla 359a3c46a4 Implement panel synchronization functionality in admin handler
- Added a new `perform_sync` function to handle the synchronization of users and subscriptions from the admin panel.
- Enhanced error handling and logging during the sync process, providing detailed feedback on the synchronization status.
- Updated the `sync_command_handler` to utilize the new `perform_sync` function, improving code organization and readability.
- Improved messaging for sync results, including success and error details, to enhance admin user experience.
2025-08-06 16:46:52 +03:00
machka-pasla 00ffec13d8 Implement message queue management and automatic sync on bot startup
- Added initialization of the message queue manager during bot startup, enhancing message handling capabilities.
- Implemented automatic synchronization of the admin panel on startup, providing real-time updates and improved reliability.
- Updated admin handlers to utilize the message queue for broadcasting messages, improving efficiency and error handling.
- Introduced a new command for admins to check the status of message queues, enhancing monitoring and management capabilities.
- Enhanced localization for new features and messages related to queue management and synchronization.
2025-08-06 16:39:35 +03:00
Machka PaslaandGitHub 5c8099168d Merge pull request #63 from machka-pasla/dev
Dev
2025-08-06 01:24:06 +03:00
machka-pasla 4675141974 Enhance promo code management in admin panel with detailed views and export functionality
- Introduced a new method to retrieve detailed promo code information, improving the clarity of promo management.
- Added functionality to export promo code activations to CSV, enhancing data accessibility for admins.
- Streamlined the handling of promo code edits and improved user prompts for better interaction.
- Updated localization files to reflect new messages and ensure consistency across the admin panel.
2025-08-06 01:21:47 +03:00
machka-pasla 67f31d62e4 Refactor promo code management in admin panel for improved clarity and localization
- Simplified the handling of promo codes by consolidating text messages and improving localization for better user experience.
- Updated the structure of promo details and management views, enhancing readability and consistency in the admin panel.
- Removed deprecated text and streamlined the activation status messages, ensuring accurate and clear communication for admins.
2025-08-06 01:14:26 +03:00
Machka PaslaandGitHub c969d52ce1 Update README.md 2025-08-05 23:50:11 +03:00
machka-pasla 3bd29511e1 Refactor README.md for improved clarity and localization
- Translated the README.md content from English to Russian, enhancing accessibility for Russian-speaking users.
- Streamlined the structure of the README to better highlight key features, setup instructions, and project organization.
- Updated sections on user and admin functionalities, payment methods, and project technologies to reflect recent changes and improve user understanding.
2025-08-05 23:49:08 +03:00
Machka PaslaandGitHub 0c31256fd1 Merge pull request #62 from machka-pasla/dev
2.0.0 update
2025-08-05 23:36:31 +03:00
machka-pasla 04e45096be Update .env.example and settings.py for tribute payment configuration
- Revised the .env.example file to include updated settings for Telegram bot, database, and payment gateways, enhancing clarity for configuration.
- Added new fields for managing tribute payment notifications in settings.py, allowing for better control over cancellation notifications.
- Improved organization of payment method settings and localization options for a more streamlined user experience.
2025-08-05 23:33:46 +03:00
machka-pasla e537203209 Add auto-renewal and cancellation handling for tribute subscriptions
- Implemented a new method to handle expired tribute subscriptions, automatically renewing them if no cancellation was received.
- Added functionality to manage tribute subscription cancellations, setting a grace period and notifying users accordingly.
- Updated localization files to include messages for auto-renewal and cancellation notifications in both English and Russian, enhancing user communication.
2025-08-05 23:21:12 +03:00
machka-pasla 283fc6bad4 Update localization for user ban and unban messages in English and Russian
- Added new messages for successful ban and unban actions, as well as error messages for banning and unbanning users.
- Included a warning message for when a user is not banned, enhancing clarity in the admin panel's user management features.
2025-08-05 15:35:55 +03:00
machka-pasla a46e5a43d6 Add user ban and unban functionality with prompts in admin panel
- Implemented handlers for banning and unbanning users, allowing admins to input user IDs or usernames.
- Added prompts for user input during ban and unban processes, enhancing user experience and clarity.
- Updated localization for ban and unban messages in both English and Russian, ensuring consistency and improved communication.
- Refactored user display logic in the banned users list to use updated attributes, enhancing data accuracy.
2025-08-05 15:31:03 +03:00
machka-pasla 9cf5a93625 Refactor promo code handling in admin panel and clean up unused methods
- Replaced the promo_codes module with a new promo management structure, enhancing organization and clarity in the admin panel.
- Updated admin panel handlers to utilize the new promo management methods, improving the flow of promo code creation and management.
- Removed deprecated methods from payment and subscription data access layers, streamlining the codebase and improving maintainability.
- Ensured localization files are updated to reflect changes in promo management messaging, enhancing user experience.
2025-08-05 14:55:55 +03:00
machka-pasla c5f41a261b Update promo code success message and enhance subscription service settings
- Revised the success message for promo code creation to include updated variable names for clarity in the response.
- Added a new setting to control notification behavior for Tribute payments, improving customization options for subscription management.
- Updated Russian localization for admin panel buttons to enhance clarity and consistency in user interface elements.
2025-08-05 14:43:19 +03:00
machka-pasla 74719669fb Fix activation date reference and update localization for promo code toggle success message
- Changed the reference from `created_at` to `activated_at` in the promo activations handler to accurately reflect the activation date.
- Updated the localization files for both English and Russian to remove HTML tags from the promo code toggle success message, enhancing clarity.
2025-08-04 18:16:59 +03:00
machka-pasla c8831718ed Add promo code toggle and activations handling in admin panel
- Implemented a new handler to toggle the active status of promo codes, allowing admins to activate or deactivate codes easily.
- Added functionality to display activations for specific promo codes, enhancing visibility into their usage.
- Updated localization files to include new messages related to promo status changes and activations in both English and Russian, improving admin experience.
2025-08-04 18:10:33 +03:00
machka-pasla ad8f05e20b Refactor notification service to streamline admin notifications
- Removed the functionality to send suspicious promo attempt notifications to admins, simplifying the notification process.
- Updated comments in the code to reflect the changes made in the notification handling logic.
2025-08-04 14:32:53 +03:00
machka-pasla b53e6a2cc3 Update .env.example and enhance notification service for suspicious promo attempts
- Revised the .env.example file to include new configuration options for Telegram bot settings, database credentials, and payment gateways, ensuring clarity and ease of use.
- Implemented a new method in the NotificationService to notify admins of suspicious promo code attempts, enhancing security and monitoring capabilities.
- Updated localization files to include messages related to suspicious promo code attempts in both English and Russian, improving admin awareness and response.
2025-08-04 14:29:01 +03:00
machka-pasla bcd95fb56c Update localization files for admin panel buttons and sections
- Revised button labels in English and Russian localization files for improved clarity and conciseness, including changes to promo management, user management, and navigation buttons.
- Ensured consistency in terminology across both languages to enhance user experience in the admin panel.
2025-08-03 22:39:15 +03:00
machka-pasla ce041758f1 Enhance referral program features and localization updates
- Implemented referral statistics retrieval to provide users with insights on invited friends and subscription purchases.
- Updated referral command handler to include new statistics in the response message, improving user engagement.
- Revised localization files for both English and Russian to reflect changes in the referral program messaging, enhancing clarity and user experience.
2025-08-03 22:27:41 +03:00
machka-pasla b13883c039 Enhance promo code editing functionality in admin panel
- Implemented a four-step process for editing promo codes, allowing admins to update code, bonus days, max activations, and validity period with improved user prompts.
- Added new states to manage each step of the editing process, ensuring better flow and validation.
- Updated localization files to include new messages and prompts for the editing steps in both English and Russian, enhancing user experience.
2025-08-03 22:12:49 +03:00
machka-pasla fc48cd7a09 Update inline system statistics localization for clarity and consistency
- Revised the inline system statistics description in both English and Russian to include updated terminology and additional metrics, enhancing user understanding.
- Ensured consistency across localization files by aligning the descriptions in both languages.
2025-08-03 22:06:22 +03:00
machka-pasla 9688b0f773 Update localization files for enhanced system and user statistics
- Revised inline system statistics message to reflect updated terminology and additional metrics, including active and disabled users, memory usage, and traffic statistics.
- Enhanced inline user statistics message for clarity and added a new description field for better user understanding.
- Updated Russian localization to match the changes made in the English version, ensuring consistency across languages.
2025-08-03 22:00:24 +03:00
machka-pasla 52c4f83dd1 Enhance inline and admin statistics features
- Updated inline user statistics message to provide clearer information about bot usage and user status.
- Refactored system statistics retrieval to utilize the PanelApiService for improved data accuracy and detail, including memory usage and bandwidth statistics.
- Simplified the success message for bulk promo code creation by removing the codes list from the message, ensuring clarity.
- Enhanced localization files to reflect changes in success messages and added new labels for better user experience.
2025-08-03 21:50:44 +03:00
machka-pasla 23aa3934e4 Enhance inline query handling and admin statistics retrieval
- Updated inline query handler to include settings for thumbnail URLs, improving the customization of inline results.
- Refactored admin statistics retrieval to utilize new data structures for user status and bandwidth statistics, enhancing clarity and accuracy in reporting.
- Removed deprecated help result creation code to streamline the inline query functionality.
- Added new settings for inline mode thumbnail URLs to improve maintainability and flexibility in the codebase.
2025-08-03 21:37:39 +03:00
machka-pasla ebca408227 Add promo management action to admin panel and remove panel statistics retrieval
- Introduced a new action for managing promo codes within the admin panel, enhancing admin capabilities.
- Removed the panel statistics retrieval code from the statistics handler to streamline functionality and reduce complexity.
- Updated localization files to reflect changes related to promo management.
2025-08-03 21:27:46 +03:00
machka-pasla a5b408d204 Enhance promo code management and user statistics features
- Introduced a unified promo code management system, allowing admins to view and manage both active and inactive promo codes with detailed information.
- Updated the promo code detail view to include status indicators and management options for editing, toggling status, and viewing activation history.
- Enhanced user statistics display by restructuring the information layout and adding new labels for clarity.
- Improved localization files to support new messages and labels related to promo code management and user statistics.
- Implemented error handling for suspicious promo code attempts, integrating a notification system for better admin awareness.
2025-08-03 21:09:03 +03:00
machka-pasla aa97618165 Refactor notification service and enhance user management features
- Replaced legacy payment notification functions with a new NotificationService for improved code organization and maintainability.
- Updated user management handlers to utilize the new notification system for payment confirmations.
- Enhanced user card formatting with localized labels for better clarity and user experience.
- Introduced context management for the PanelApiService to ensure proper session handling.
- Updated localization files to include new labels and messages for user management and notifications.
2025-08-03 18:58:50 +03:00
machka-pasla 973e97c54e Refactor promo code creation process and remove unnecessary handlers
- Updated the promo code creation flow to a four-step process, enhancing user guidance and validation.
- Removed the optional description step from the promo code creation, streamlining the process.
- Updated localization files to reflect changes in the promo code creation steps and success messages.
- Enhanced error handling and logging for panel statistics retrieval in the admin statistics handler.
2025-08-03 18:44:12 +03:00
machka-pasla 4f6822f8e4 Enhance promo code management and add bulk creation functionality
- Refactored the promo code creation process into a step-by-step flow, improving user guidance and validation.
- Introduced bulk promo code creation with a structured multi-step process, allowing admins to create multiple codes efficiently.
- Added new states for managing the promo code creation process and bulk operations.
- Implemented panel statistics retrieval to provide admins with insights into system performance and user activity.
- Updated localization files to support new messages and prompts related to promo code management and statistics.
2025-08-03 18:20:47 +03:00
machka-pasla 0f26c9bdba Implement user subscription deletion and banned users management
- Replaced the trial reset functionality to delete all user subscriptions instead of deactivating them, ensuring a complete reset of trial eligibility.
- Added a new method to retrieve banned users from the database, enhancing the admin's ability to manage user bans.
- Updated user management handlers to utilize the new subscription deletion and banned users retrieval features.
- Enhanced localization files to support new messages related to banned users.
2025-08-03 17:51:37 +03:00
machka-pasla 39ba57951c Add field validation for optional integer settings
- Introduced a field validator for LOG_CHAT_ID and LOG_THREAD_ID to convert empty strings to None, ensuring proper handling of optional integer fields.
- This enhancement improves data integrity and validation within the settings configuration.
2025-08-03 17:37:29 +03:00
machka-pasla ffa61f673d Add Docker Buildx setup to development workflow
- Integrated Docker Buildx setup step in the GitHub Actions workflow for enhanced multi-platform builds.
- This addition streamlines the Docker image building process, improving efficiency and flexibility.
2025-08-03 17:32:03 +03:00
machka-pasla f0bbe90f80 Add Docker build workflow and enhance admin functionalities
- Introduced a new GitHub Actions workflow for building and pushing the development Docker image.
- Added inline mode handling for user interactions, allowing users to share referral links and view statistics.
- Enhanced admin functionalities with new sections for user management, statistics, and promo code management.
- Implemented CSV export for logs and improved notification services for various events, including new user registrations and payment notifications.
- Updated localization files to support new features and commands.
2025-08-03 15:59:37 +03:00
Machka PaslaandGitHub 35d798a429 Merge pull request #59 from machka-pasla/e4i3yn-codex/update-api-service-and-.env-variables
Update API service for squads
2025-07-29 15:31:49 +03:00
Machka Pasla b34eb733b9 Update API service for new squads API 2025-07-29 15:31:26 +03:00
Machka PaslaandGitHub fcd456dce9 Merge pull request #58 from machka-pasla/7was2g-codex/check-aiohttp-and-asyncio-interactions
Close services and update event loop usage
2025-07-29 14:45:50 +03:00
Machka Pasla 8246e9a590 Close sessions for all services on shutdown 2025-07-29 14:44:55 +03:00
Machka PaslaandGitHub 14f244ab79 tribute logger 2025-07-27 21:30:21 +03:00
309 changed files with 69647 additions and 5950 deletions
+108
View File
@@ -0,0 +1,108 @@
# 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
.codeclimate.yml
.travis.yml
.taskcluster.yml
# Docker
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__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
.env*
.venv/
venv/
# PyCharm
.idea
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# VS Code
.vscode/
+168 -54
View File
@@ -1,109 +1,223 @@
# Telegram Bot Token and Admin IDs
BOT_TOKEN=your_bot_token_here
ADMIN_IDS=comma_separated_admin_ids
BOT_TOKEN=your_bot_token_here # Telegram bot token
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=remnawave-tg-shop-db
POSTGRES_PORT=5432
POSTGRES_DB=postgres
# PostgreSQL Database Connection Settings
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
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
# External Links
SUPPORT_LINK=https://t.me/your_support_link
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
TERMS_OF_SERVICE_URL=https://example.com/tos
SUBSCRIPTION_MINI_APP_URL=
START_COMMAND_DESCRIPTION=
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
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
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
# Required channel subscription
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
# 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
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id
YOOKASSA_SECRET_KEY=your_secret_key
YOOKASSA_RETURN_URL=https://t.me/your_bot
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com
YOOKASSA_VAT_CODE=1
YOOKASSA_PAYMENT_MODE=full_prepayment
YOOKASSA_PAYMENT_SUBJECT=payment
# 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
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN=
CRYPTOPAY_NETWORK=mainnet
CRYPTOPAY_CURRENCY_TYPE=fiat
CRYPTOPAY_ASSET=RUB
# API key for verifying Tribute webhook signatures
TRIBUTE_API_KEY=
# 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
STARS_ENABLED=True
TRIBUTE_ENABLED=True
CRYPTOPAY_ENABLED=True
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS
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, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# Subscription Options
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
YOOKASSA_VAT_CODE=1 # VAT code
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
# Nalogo (self-employed receipts)
NALOGO_INN=your_inn # INN for nalog.ru
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
# FreeKassa Payment Gateway Configuration
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
FREEKASSA_API_KEY=your_api_key # API key for REST requests
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
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
# Platega Payment Gateway Configuration
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 # 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)
# SeverPay Payment Gateway Configuration
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
SEVERPAY_MID= # Your MID from SeverPay
SEVERPAY_TOKEN= # API token/secret for signing requests
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)
# 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
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
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
REFERRAL_BONUS_DAYS_1_MONTH=3
REFERRAL_BONUS_DAYS_3_MONTHS=7
REFERRAL_BONUS_DAYS_6_MONTHS=15
REFERRAL_BONUS_DAYS_12_MONTHS=30
# Invited User Bonus
REFEREE_BONUS_DAYS_1_MONTH=1
REFEREE_BONUS_DAYS_3_MONTHS=3
REFEREE_BONUS_DAYS_6_MONTHS=7
REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api
PANEL_API_KEY=your_panel_api_key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
PANEL_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
# User traffic limits (applied for all users)
# 0 means unlimited
USER_TRAFFIC_LIMIT_GB=0
USER_TRAFFIC_STRATEGY="NO_RESET"
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
# Default Inbounds for Users (Optional, comma-separated UUIDs)
USER_INBOUND_UUIDS=uuid1,uuid2,uuid3
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Default External Squad for Users (Optional, single UUID)
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
# Trial Settings
TRIAL_ENABLED=True
TRIAL_DURATION_DAYS=5
TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited
TRIAL_TRAFFIC_STRATEGY="NO_RESET"
TRIAL_ENABLED=True # Enable the trial period
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
# Connection link handling (happ crypt4)
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
# Web Server Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0"
WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10
LOGS_PAGE_SIZE=10 # Number of events in the log
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments
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
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
-44
View File
@@ -1,44 +0,0 @@
name: Build and Publish multi-arch Docker Image
on:
push:
branches:
- main
tags:
- 'v*.*.*'
paths-ignore:
- 'README.md'
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: 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: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
+19
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__/
@@ -14,3 +27,9 @@ __pycache__/
# Игнорировать Docker артефакты (если вдруг)
*.log
*.pid
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}
}
+81 -5
View File
@@ -1,4 +1,4 @@
FROM python:3.11-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 python:3.11-slim
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.11/site-packages /usr/local/lib/python3.11/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"]
+7
View File
@@ -0,0 +1,7 @@
Copyright 2025 machka-pasla
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+78 -214
View File
@@ -1,245 +1,109 @@
# Remnawave Minishop
# Remnawave Subscription Sales Telegram Bot
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
This Telegram bot is designed to automate the sale and management of subscriptions for a **Remnawave panel**. It integrates with the Remnawave API for user and subscription management and uses YooKassa for processing payments.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
## ✨ Features
## Возможности
* **User Interaction:**
* User registration with language selection (English/Russian).
* Display of main menu with available actions via inline keyboards.
* Ability for users to view their current subscription status, expiration date, and configuration link.
* Trial subscription system for new users (configurable, manual activation via button).
* Promo code system for users to apply discounts or bonuses.
* Referral program for users to earn bonus subscription days.
* **Subscription Management:**
* Handles subscription purchases for various periods (1, 3, 6, 12 months).
* Integrates with **YooKassa** for payment processing, including fiscal receipt data.
* Supports **Crypto Pay** for payments with fiat currency (RUB by default).
* Automatic subscription activation/extension upon successful payment.
* Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID.
* Updates user status, expiration dates, traffic limits, and inbounds on the Remnawave panel.
* **Admin Panel:**
* Protected by `ADMIN_IDS` (supports multiple administrators).
* **Statistics:** View bot usage (total users, banned, active subscriptions), recent payments, and panel sync status.
* **User Management:**
* Ban/Unban users by Telegram ID or @username (updates local DB and panel).
* View a paginated list of banned users.
* View a "user card" with detailed information and unban option.
* **Broadcast:** Send messages to all users, users with active subscriptions, or users with expired subscriptions.
* **Promo Codes:** Create and view promo codes (bonus days, activation limits, validity).
* **Panel Sync:** Manually trigger synchronization of users and subscriptions from the Remnawave panel to the bot's database, matching by Telegram ID.
* **Activity Logs:** View a paginated list of all user actions (messages, commands, callbacks) or logs for a specific user.
* **Notifications:**
* Automated daily notifications to users about expiring subscriptions (via APScheduler).
* Notifications to users and inviters upon successful referral bonus application.
* Notifications to admin(s) about suspicious promo code input attempts.
* **Security & Technical:**
* Uses parameterized queries to prevent SQL injection.
* Proactive check for suspicious input in promo code field (notifies admin).
* Middleware for checking banned users on every interaction.
* Middleware for logging user actions.
* Webhook support for Telegram and YooKassa for efficient updates.
* Configurable via `.env` file.
* Dockerized for easy deployment.
Для пользователей:
## 🚀 Technologies Used
- регистрация с выбором русского или английского языка;
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
* **Python 3.11**
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
* **aiohttp:** For running the webhook server
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
* **YooKassa SDK:** For payment processing
* **APScheduler:** For scheduled tasks (e.g., notifications)
* **Pydantic:** For settings management (loading from `.env`)
* **Docker & Docker Compose:** For containerization and deployment
Для администраторов:
## ⚙️ Setup and Configuration
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- ручная синхронизация пользователей и подписок с панелью.
### Prerequisites
## Документация
* Docker and Docker Compose installed.
* A running instance of a Remnawave panel.
* A Telegram Bot Token.
* A YooKassa Shop ID and Secret Key.
- [Настройка окружения](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) - перенос данных из прежнего стека.
### Configuration Steps
## Совместимость
1. **Clone the Repository:**
```bash
git clone https://github.com/machka-pasla/remnawave-tg-shop
cd remnawave-tg-shop
```
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
2. **Create an `.env` File:**
Copy the `env.example` file to `.env` and fill in your specific values:
```bash
cp .env.example .env
nano .env
```
Key variables to configure in `.env`:
* `BOT_TOKEN`: Your Telegram Bot Token from BotFather.
* `ADMIN_IDS`: Comma-separated list of your Telegram User IDs for admin access (e.g., `12345678,98765432`). **Crucial for bot management.**
* `DEFAULT_LANGUAGE`: Default language for new users (e.g., `ru` or `en`).
* `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`.
* `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`).
* `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma).
* `SUBSCRIPTION_MINI_APP_URL`: (Optional) URL of the Telegram mini app for viewing subscription details. If set, the "My Subscription" button will open this mini app and the bot will register it automatically via API.
* `START_COMMAND_DESCRIPTION`: (Optional) Description for the `/start` command shown in the bot's menu.
* **YooKassa Settings:**
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
* `WEBHOOK_BASE_URL`: Base URL for all webhooks (Telegram, YooKassa, Crypto Pay). Example: `https://webhooks.yourdomain.com`.
* `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`.
* `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts.
* `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor).
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
* **Crypto Pay Settings:** `CRYPTOPAY_TOKEN`, `CRYPTOPAY_NETWORK` (`mainnet` or `testnet`), `CRYPTOPAY_CURRENCY_TYPE` (`fiat` or `crypto`), `CRYPTOPAY_ASSET` (e.g., `RUB`). Enable with `CRYPTOPAY_ENABLED`.
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`, `CRYPTOPAY_ENABLED`.
* **Subscription Options:** For each duration you can use variables like
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
* **Panel API Settings:**
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
* `PANEL_WEBHOOK_SECRET`: Secret key for verifying webhooks from the Remnawave panel.
* `USER_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
* `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel.
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
* `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server.
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
## Стек
3. **Locales:**
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**.
4. **Run with Docker Compose:**
```bash
docker compose up -d
```
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
| Слой | Технологии |
| --- | --- |
| 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/` |
5. **Webhook Setup (Important if using webhooks):**
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`).
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/cryptopay` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/cryptopay`.
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/tribute` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/tribute`.
* If using Telegram webhooks, forward requests for `https://{WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
6. **Database:**
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
## Быстрый старт
7. **Viewing Logs:**
```bash
docker compose logs -f remnawave-tg-shop
```
Требования:
## 🐳 Docker Setup
### `Dockerfile`
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
# Consider adding build arguments for proxy if needed in your environment
# ARG HTTP_PROXY
# ARG HTTPS_PROXY
# ENV http_proxy=$HTTP_PROXY
# ENV https_proxy=$HTTPS_PROXY
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Ensure main.py is executable if needed, though python command handles it
# RUN chmod +x main.py
CMD ["python", "main.py"]
- Docker и Docker Compose;
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
- токен Telegram-бота;
- параметры хотя бы одного платежного провайдера.
```bash
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
```
### `docker-compose.yml`
```
services:
remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
# build: .
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file:
- .env
# networks:
# - remnawave-network
# volumes:
# - ./locales:/app/locales
restart: unless-stopped
Минимально заполните в `.env`:
postgres:
image: postgres:17
container_name: remnawave-tg-shop-db
env_file:
- .env
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
# networks:
# - remnawave-network
restart: unless-stopped
- `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.
# networks:
# remnawave-network:
# external: true
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
```
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
## Полезные команды
## 🛠️ Project Structure (Overview)
```bash
# Локальная сборка и запуск
docker compose up -d --build
```
.
├── bot/
│ ├── filters/ # Custom Aiogram filters (e.g., AdminFilter)
│ ├── handlers/ # Message and callback query handlers (admin and user)
│ ├── keyboards/ # Inline and reply keyboard generators
│ ├── middlewares/ # Custom Aiogram middlewares (i18n, ban check, logger)
│ ├── services/ # Business logic (payments, subscriptions, panel API interaction)
│ ├── states/ # FSM states
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
├── config/
│ └── settings.py # Pydantic settings and config parser
├── db/
│ ├── dal/ # Data Access Layer (queries, transactions)
│ ├── database_setup.py # DB connection/init setup
│ └── models.py # ORM models (e.g., SQLAlchemy)
├── locales/ # Localization files
│ ├── en.json # English locale
│ └── ru.json # Russian locale
├── .env.example # Example environment variables for local setup
├── .env # Actual environment variables (ignored by Git)
├── Dockerfile # Docker image build instructions
├── docker-compose.yml # Docker Compose orchestration config
├── requirements.txt # List of Python dependencies
├── README.md # Project documentation
└── main.py # Entry point to launch the bot
# Логи приложения
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
```
## 🤝 Contributing
## Поддержка
Contributions are welcome!
## 🔮 Future Enhancements
- More detailed analytics for admin.
- Support for different payment methods.
- Advanced promo code types (e.g., percentage discounts).
## Donations (pls)
- Russian and international cards [LINK](https://t.me/tribute/app?startapp=dqdg)
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,41 @@
from typing import Dict
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from sqlalchemy.orm import sessionmaker
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
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]:
storage = MemoryStorage()
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
dp = Dispatcher(storage=storage, settings=settings, bot_instance=bot)
i18n_instance = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE)
dp["i18n_instance"] = i18n_instance
dp["async_session_factory"] = async_session_factory
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
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(ActionLoggerMiddleware(settings=settings))
return dp, bot, {"i18n_instance": i18n_instance}
+1
View File
@@ -0,0 +1 @@
+106
View File
@@ -0,0 +1,106 @@
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.freekassa_service import FreeKassaService
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(
settings: Settings,
bot: Bot,
async_session_factory: sessionmaker,
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = PanelApiService(settings)
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
cryptopay_service = CryptoPayService(
settings.CRYPTOPAY_TOKEN,
settings.CRYPTOPAY_NETWORK,
bot,
settings,
i18n,
async_session_factory,
subscription_service,
referral_service,
)
freekassa_service = FreeKassaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
)
platega_service = PlategaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
severpay_service = SeverPayService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
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,
configured_return_url=settings.YOOKASSA_RETURN_URL,
bot_username_for_default_return=bot_username_for_default_return,
settings_obj=settings,
)
lknpd_service = LknpdService(
settings.LKNPD_INN,
settings.LKNPD_PASSWORD,
api_url=settings.LKNPD_API_URL,
)
# Wire services that depend on each other
try:
# Attach YooKassa to subscription service for auto-renew charges
setattr(subscription_service, "yookassa_service", yookassa_service)
# Allow panel webhook to trigger renewals through subscription service
setattr(panel_webhook_service, "subscription_service", subscription_service)
except Exception:
pass
return {
"panel_service": panel_service,
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
"stars_service": stars_service,
"cryptopay_service": cryptopay_service,
"freekassa_service": freekassa_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
"lknpd_service": lknpd_service,
"platega_service": platega_service,
"severpay_service": severpay_service,
}
+1
View File
@@ -0,0 +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