Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46520fb37c | ||
|
|
18ba00c98a | ||
|
|
e065c85a3e | ||
|
|
7401649841 | ||
|
|
a96007d48a | ||
|
|
2169fd8b50 | ||
|
|
2b7d1f0be8 | ||
|
|
eec5635541 | ||
|
|
e21a0758c3 | ||
|
|
defbac43d7 | ||
|
|
94612e11f2 | ||
|
|
11187487b4 | ||
|
|
f31540afdb | ||
|
|
6afbb72a34 | ||
|
|
af66103111 | ||
|
|
92277b2e27 | ||
|
|
475a1953d1 | ||
|
|
8af59750c6 | ||
|
|
00a0792c35 | ||
|
|
b9c0dfb9a1 | ||
|
|
9679190709 | ||
|
|
c8d4b3565c | ||
|
|
fe976022ec | ||
|
|
6a367c1dec | ||
|
|
9f338c3416 | ||
|
|
a575cb057b | ||
|
|
116b0a5c08 | ||
|
|
ed6b214b29 | ||
|
|
7d8bbbc904 | ||
|
|
763c4ad1bb | ||
|
|
5243cc4284 | ||
|
|
5a015fe8b4 | ||
|
|
b759ddb0f5 | ||
|
|
57821ba2a9 | ||
|
|
410cb89f3e | ||
|
|
e01f56bc34 | ||
|
|
ff59eda3d7 | ||
|
|
b7e497e241 | ||
|
|
7a81a2f20b | ||
|
|
990eba1e1e | ||
|
|
6f9a87cf98 | ||
|
|
0a9d929181 | ||
|
|
7dc4806be7 | ||
|
|
8783129c16 | ||
|
|
50d78c8746 | ||
|
|
63784bc036 | ||
|
|
aa5a9496ed | ||
|
|
00f8e5fb20 | ||
|
|
ca8df10eab | ||
|
|
b461838c0d | ||
|
|
50857d5283 | ||
|
|
449bc0c780 | ||
|
|
e1223f8c57 | ||
|
|
7822ec1b09 | ||
|
|
81f5168726 | ||
|
|
77f2420ae3 | ||
|
|
4de730bf5a | ||
|
|
77da0d97c0 | ||
|
|
e8b395cf2f | ||
|
|
d15b1be3b9 | ||
|
|
8a6b2704d4 | ||
|
|
ce610b4804 | ||
|
|
4d0241a0ea | ||
|
|
2c1e86863d | ||
|
|
16b11327ee | ||
|
|
3d5998b8fb | ||
|
|
2df4d2d770 | ||
|
|
4ce78f277e | ||
|
|
d7d5c2b1ef | ||
|
|
4958e1fdfd | ||
|
|
258b2f4dec | ||
|
|
338008bbd0 | ||
|
|
829758dc44 | ||
|
|
9deb98bbe7 | ||
|
|
26af92b5ac | ||
|
|
87b2443a06 | ||
|
|
aabc0e312d | ||
|
|
d15d58df36 | ||
|
|
21f2ac4534 | ||
|
|
dd0e6e75af | ||
|
|
0251f939d7 | ||
|
|
a00bc0f345 | ||
|
|
ae4ce43e1c | ||
|
|
19ed8916f4 | ||
|
|
f7de08bfd7 | ||
|
|
6e42f67931 | ||
|
|
b87b6cd378 | ||
|
|
b39fb73c30 | ||
|
|
3120884ed5 | ||
|
|
c927244b1a | ||
|
|
e049f38c7c | ||
|
|
ddc6ee0024 | ||
|
|
fa5f1460ab | ||
|
|
9f556f3e04 | ||
|
|
70b618a94b | ||
|
|
0610156f80 | ||
|
|
587b39de1d | ||
|
|
e35885b54b | ||
|
|
c1b7ace876 | ||
|
|
f416b0aed1 | ||
|
|
c8192e4427 | ||
|
|
dae1a6889b | ||
|
|
4740666d63 | ||
|
|
d6b703debb | ||
|
|
f1113eb80a | ||
|
|
604f0d9656 | ||
|
|
8ce5a92625 | ||
|
|
72b6e93c94 | ||
|
|
d60952718e | ||
|
|
53dcc59770 | ||
|
|
5ec179b6d6 | ||
|
|
250df445f0 | ||
|
|
12376e5070 | ||
|
|
c2afc6107a | ||
|
|
77370eb963 | ||
|
|
94b0787cad | ||
|
|
9c499fe3c2 | ||
|
|
7e26f9da9b | ||
|
|
778615a97f | ||
|
|
651572f15f | ||
|
|
5ccb8ddabe | ||
|
|
86f944e544 | ||
|
|
bc29f5ebd6 | ||
|
|
9a84be85c6 | ||
|
|
c08ac854b2 | ||
|
|
807a8933b9 | ||
|
|
b9cb1fec06 | ||
|
|
eab803652b | ||
|
|
259d0646bc | ||
|
|
4f1b7d0832 | ||
|
|
1aa529ab23 | ||
|
|
a46502380c | ||
|
|
85c276bcf5 | ||
|
|
55f8db11ce | ||
|
|
79af61a48e | ||
|
|
69d2e2a899 | ||
|
|
069ad967b5 | ||
|
|
18a6b3e18d | ||
|
|
3b9043332c | ||
|
|
d94b57bd0b | ||
|
|
5247092ca2 | ||
|
|
049789c9c5 | ||
|
|
6b74ae4a8e | ||
|
|
c13c01ea43 | ||
|
|
48e7d3569f | ||
|
|
4f3b45cbd8 | ||
|
|
a7ec55d741 | ||
|
|
0701af0f35 | ||
|
|
c0851c5339 | ||
|
|
785b6c2d41 | ||
|
|
cce3fd4f58 | ||
|
|
0dd6beebb2 | ||
|
|
8fb2a76698 | ||
|
|
3d6713caa9 | ||
|
|
8d8fdce519 | ||
|
|
c385a1466c | ||
|
|
8e1fdb33d2 | ||
|
|
29222be1fa | ||
|
|
08aca3b28d | ||
|
|
658139d607 | ||
|
|
1d4abf7977 | ||
|
|
90ab186a7c | ||
|
|
62e8bc08d5 | ||
|
|
dc725ccd68 | ||
|
|
d88aee03bb | ||
|
|
475a30caf4 | ||
|
|
c1593d7868 | ||
|
|
0dbcfe3770 | ||
|
|
e3ffb87cfc | ||
|
|
b4218ec6a7 |
+17
-1
@@ -1,10 +1,20 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
LICENSE
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
scratch/
|
||||
.claude/
|
||||
*.local.*
|
||||
node_modules/
|
||||
docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
|
||||
|
||||
# CI
|
||||
@@ -17,6 +27,12 @@ docker-compose.yml
|
||||
Dockerfile
|
||||
.docker
|
||||
.dockerignore
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
+63
-17
@@ -3,9 +3,9 @@ BOT_TOKEN=your_bot_token_here #
|
||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||
|
||||
# PostgreSQL Database Connection Settings
|
||||
POSTGRES_USER=postgres # Database user name
|
||||
POSTGRES_PASSWORD=postgres # Database password
|
||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||
POSTGRES_USER= # Required: database user name
|
||||
POSTGRES_PASSWORD= # Required: database password
|
||||
POSTGRES_HOST=remnawave-minishop-db # Database container name
|
||||
POSTGRES_PORT=5432 # Port
|
||||
POSTGRES_DB=postgres # Database name
|
||||
|
||||
@@ -17,7 +17,9 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
|
||||
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
|
||||
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
|
||||
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
@@ -29,17 +31,52 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||
|
||||
# Subscription Mini App (same container, separate port)
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
|
||||
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
|
||||
|
||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
||||
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
|
||||
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
|
||||
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
|
||||
SMTP_USERNAME= # Brevo SMTP login
|
||||
SMTP_PASSWORD= # Brevo SMTP key/password
|
||||
SMTP_FROM_EMAIL= # Verified sender email
|
||||
SMTP_FROM_NAME= # Optional sender name
|
||||
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
|
||||
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
|
||||
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
||||
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
||||
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
||||
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
|
||||
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
|
||||
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
|
||||
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
@@ -50,12 +87,19 @@ YOOKASSA_VAT_CODE=1 #
|
||||
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
|
||||
@@ -67,7 +111,11 @@ CRYPTOPAY_ASSET=RUB #
|
||||
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
|
||||
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
|
||||
PLATEGA_SECRET= # API secret from Platega
|
||||
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
|
||||
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
|
||||
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
|
||||
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
|
||||
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
|
||||
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
|
||||
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
|
||||
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
|
||||
|
||||
@@ -78,35 +126,30 @@ SEVERPAY_TOKEN= #
|
||||
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
|
||||
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
|
||||
|
||||
# Tribute Payment Gateway Configuration
|
||||
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
|
||||
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
|
||||
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
|
||||
NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id
|
||||
NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret
|
||||
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
|
||||
@@ -116,6 +159,7 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 #
|
||||
|
||||
|
||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
|
||||
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||
# Referral Bonus Days
|
||||
# Bonus for the inviting user
|
||||
@@ -160,6 +204,7 @@ WEB_SERVER_PORT=8080
|
||||
|
||||
# Admin Panel Log Pagination
|
||||
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
|
||||
@@ -169,6 +214,7 @@ LOG_PAYMENTS=True #
|
||||
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
|
||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
||||
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
|
||||
|
||||
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
|
||||
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
name: Build and Push Dev Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Build and Publish multi-arch Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU (для эмуляции arm64 на x86)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push multi-arch Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GHCR_IMAGE }}:latest
|
||||
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
|
||||
${{ env.DOCKERHUB_IMAGE }}:latest
|
||||
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
|
||||
+16
@@ -3,6 +3,19 @@ bot_database.sqlite3
|
||||
|
||||
# Игнорировать файлы окружения
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
scratch_*.py
|
||||
*.local.*
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
tmp
|
||||
.claude
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
@@ -17,3 +30,6 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
docker-compose-dev.yml
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
bot/app/web/templates/
|
||||
**/*.min.js
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -1,216 +1,109 @@
|
||||
# Telegram-бот для продажи подписок Remnawave
|
||||
# Remnawave Minishop
|
||||
|
||||
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
## ✨ Ключевые возможности
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
|
||||
## Возможности
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
|
||||
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
|
||||
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
|
||||
- **Управление промокодами:** Создание и просмотр промокодов.
|
||||
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
|
||||
- **Логи действий:** Просмотр логов всех действий пользователей.
|
||||
Для пользователей:
|
||||
|
||||
## 🚀 Технологии
|
||||
- регистрация с выбором русского или английского языка;
|
||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
- **Python 3.11**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
Для администраторов:
|
||||
|
||||
## ⚙️ Установка и запуск
|
||||
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
### Предварительные требования
|
||||
## Документация
|
||||
|
||||
- Установленные Docker и Docker Compose.
|
||||
- Рабочая панель Remnawave.
|
||||
- Токен Telegram-бота.
|
||||
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
|
||||
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||
|
||||
### Шаги установки
|
||||
## Совместимость
|
||||
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
|
||||
|
||||
2. **Создайте и настройте файл `.env`:**
|
||||
Скопируйте `env.example` в `.env` и заполните своими данными.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
Ниже перечислены ключевые переменные.
|
||||
## Стек
|
||||
|
||||
<details>
|
||||
<summary><b>Основные настройки</b></summary>
|
||||
Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**.
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
| --- | --- | --- |
|
||||
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
|
||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||
</details>
|
||||
| Слой | Технологии |
|
||||
| --- | --- |
|
||||
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
|
||||
| Данные | **PostgreSQL** **17** (сервис `remnawave-minishop-db` в Compose) |
|
||||
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` |
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки платежей и вебхуков</b></summary>
|
||||
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
||||
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
|
||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
|
||||
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
|
||||
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
|
||||
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
|
||||
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
|
||||
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
|
||||
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
|
||||
| `SEVERPAY_MID` | MID магазина в SeverPay. |
|
||||
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
|
||||
| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. |
|
||||
| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). |
|
||||
| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
|
||||
</details>
|
||||
## Быстрый старт
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
Требования:
|
||||
|
||||
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
</details>
|
||||
- Docker и Docker Compose;
|
||||
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||
- токен Telegram-бота;
|
||||
- параметры хотя бы одного платежного провайдера.
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки панели Remnawave</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_EXTERNAL_SQUAD_UUID` | Опционально. UUID внешнего отряда (External Squad) из [документации Remnawave](https://docs.rw/api), куда автоматически добавляются новые пользователи. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||
|
||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
|
||||
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
|
||||
</details>
|
||||
|
||||
3. **Запустите контейнеры:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
|
||||
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
5. **Просмотр логов:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
|
||||
│ ├── services/ # Бизнес-логика (платежи, API панели)
|
||||
│ ├── states/ # Состояния FSM
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
├── config/
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
├── db/
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru, en)
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Dockerfile # Инструкции для сборки Docker-образа
|
||||
├── docker-compose.yml # Файл для оркестрации контейнеров
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
```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
|
||||
```
|
||||
|
||||
## 🔮 Планы на будущее
|
||||
Минимально заполните в `.env`:
|
||||
|
||||
- Расширенные типы промокодов (например, скидки в процентах).
|
||||
- `BOT_TOKEN` - токен Telegram-бота;
|
||||
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
|
||||
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
|
||||
- настройки платежного провайдера;
|
||||
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
|
||||
|
||||
## ❤️ Поддержка
|
||||
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/webapp-logo data/webapp-emoji
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Локальная сборка и запуск
|
||||
docker compose up -d --build
|
||||
|
||||
# Логи приложения
|
||||
docker compose logs -f remnawave-minishop
|
||||
|
||||
# Запуск с Caddy
|
||||
docker compose -f docker-compose-caddy.yml up -d --build
|
||||
|
||||
# Запуск из готового образа
|
||||
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
|
||||
```
|
||||
|
||||
## Поддержка
|
||||
|
||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
||||
def build_dispatcher(
|
||||
settings: Settings, async_session_factory: sessionmaker
|
||||
) -> tuple[Dispatcher, Bot, Dict]:
|
||||
storage = MemoryStorage()
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
|
||||
@@ -32,8 +33,9 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) ->
|
||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(
|
||||
ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)
|
||||
)
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
||||
|
||||
return dp, bot, {"i18n_instance": i18n_instance}
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.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(
|
||||
@@ -64,7 +65,9 @@ def build_core_services(
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings.YOOKASSA_SECRET_KEY,
|
||||
@@ -72,6 +75,11 @@ def build_core_services(
|
||||
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:
|
||||
@@ -92,6 +100,7 @@ def build_core_services(
|
||||
"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,2 +1 @@
|
||||
|
||||
|
||||
|
||||
@@ -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("__"))
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain modules for the admin Mini App API."""
|
||||
@@ -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("__")]
|
||||
@@ -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({})
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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({})
|
||||
@@ -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)
|
||||
@@ -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)})
|
||||
@@ -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)
|
||||
@@ -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 {}})
|
||||
@@ -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)})
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
+145
@@ -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
Reference in New Issue
Block a user