From 58de153370121717365c7301ae3c19957a6ff656 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Wed, 3 Jun 2026 10:14:40 +0300
Subject: [PATCH] feat(tariffs): configurable purchase order for periods and
packages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The order of enabled_periods (period tariffs) and traffic_packages
(traffic tariffs) is now the storefront order everywhere — both the
Telegram keyboard and the web app. Only new tariffs-config tariffs are
affected; legacy subscription/traffic options are untouched.
- Stop sorting periods and traffic packages in the web app plans
serializer so it follows the configured order, matching the bot
keyboards that already iterate the lists as-is.
- Preserve the row order through the admin draft (load and save) instead
of sorting by months.
- Add a reusable Sortable component to the UI library (native HTML5
drag & drop with a grip handle; bits-ui/shadcn have no such primitive)
and use it to reorder period rows and traffic package rows in the
tariff editor.
---
backend/bot/app/web/webapp/serializers.py | 14 +-
docs/features/tariffs.md | 4 +-
.../admin/sections/TariffEditorModal.svelte | 250 ++++++++++--------
.../src/admin/sections/TariffsSection.svelte | 2 +-
frontend/src/lib/admin/stores/tariffsStore.js | 19 ++
frontend/src/lib/admin/tariffDraft.js | 6 +-
frontend/src/lib/components/ui/icons.js | 1 +
frontend/src/lib/components/ui/index.js | 1 +
.../src/lib/components/ui/sortable.svelte | 119 +++++++++
frontend/src/styles/admin.css | 16 +-
locales/en.json | 6 +-
locales/ru.json | 6 +-
tests/test_webapp_assets.py | 81 ++++++
13 files changed, 396 insertions(+), 129 deletions(-)
create mode 100644 frontend/src/lib/components/ui/sortable.svelte
diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py
index 075daed..3deb31f 100644
--- a/backend/bot/app/web/webapp/serializers.py
+++ b/backend/bot/app/web/webapp/serializers.py
@@ -495,7 +495,10 @@ def _serialize_plans(
else [],
}
if tariff.billing_model == "period":
- for months in sorted(tariff.enabled_periods):
+ # Render periods in the configured order (enabled_periods is the
+ # source of truth for purchase-period ordering, matching the bot
+ # keyboards). Do not sort so admins can reorder via drag & drop.
+ for months in tariff.enabled_periods:
price = tariff.period_price(int(months), default_currency)
stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0):
@@ -528,7 +531,14 @@ def _serialize_plans(
tariff.traffic_packages.stars if tariff.traffic_packages else []
)
}
- for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
+ # Preserve the configured package order (default-currency list first,
+ # then any Stars-only volumes) so admins can reorder via drag & drop.
+ # Matches the bot keyboard, which iterates the package list as-is.
+ ordered_gb: List[float] = []
+ for traffic_gb in list(currency_packages) + list(stars_packages):
+ if traffic_gb not in ordered_gb:
+ ordered_gb.append(traffic_gb)
+ for traffic_gb in ordered_gb:
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
diff --git a/docs/features/tariffs.md b/docs/features/tariffs.md
index 6fcf5cb..c0cbd9d 100644
--- a/docs/features/tariffs.md
+++ b/docs/features/tariffs.md
@@ -140,14 +140,14 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
| `prices_stars` | Цены периодов в Telegram Stars. |
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
-| `enabled_periods` | Периоды, доступные для покупки. |
+| `enabled_periods` | Периоды, доступные для покупки. Порядок элементов в массиве задаёт порядок периодов на витрине (в Telegram-боте и Web App) — отсортируйте их так, как нужно показывать. В веб-админке этот порядок меняется перетаскиванием строк периодов. |
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
Для `traffic`-тарифа используются:
| Поле | Назначение |
| --- | --- |
-| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. |
+| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. Порядок пакетов в списке задаёт порядок на витрине (в Telegram-боте и Web App): сначала идут пакеты валюты каталога, затем пакеты, доступные только за Stars. В веб-админке порядок меняется перетаскиванием строк. |
| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. |
| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. |
diff --git a/frontend/src/admin/sections/TariffEditorModal.svelte b/frontend/src/admin/sections/TariffEditorModal.svelte
index 1e1e1c9..8f37859 100644
--- a/frontend/src/admin/sections/TariffEditorModal.svelte
+++ b/frontend/src/admin/sections/TariffEditorModal.svelte
@@ -1,5 +1,5 @@
+
+
+ {#each items as item, index (getKey(item, index))}
+
+
+
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css
index b33de09..346f67a 100644
--- a/frontend/src/styles/admin.css
+++ b/frontend/src/styles/admin.css
@@ -3572,6 +3572,18 @@
minmax(120px, 1fr) 32px;
}
+/* Rows with a leading drag handle (Sortable) so the purchase order can be
+ reordered. The 24px column lines up with the handle the Sortable renders. */
+.admin-row-editor-line.admin-row-editor-period {
+ grid-template-columns:
+ 24px minmax(72px, 0.8fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(120px, 1fr)
+ minmax(120px, 1fr) 32px;
+}
+
+.admin-row-editor-line.admin-row-editor-drag {
+ grid-template-columns: 24px minmax(90px, 1fr) minmax(110px, 1fr) 32px;
+}
+
.admin-package-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3790,7 +3802,9 @@
.admin-package-columns,
.admin-row-editor-line,
.admin-row-editor-line.admin-row-editor-4,
- .admin-row-editor-line.admin-row-editor-6 {
+ .admin-row-editor-line.admin-row-editor-6,
+ .admin-row-editor-line.admin-row-editor-period,
+ .admin-row-editor-line.admin-row-editor-drag {
grid-template-columns: 1fr;
}
diff --git a/locales/en.json b/locales/en.json
index d952774..26a886d 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -2074,9 +2074,11 @@
"admin_tariff_provider_any_currency": "Any",
"admin_tariff_provider_not_declared": "Not declared",
"admin_tariff_pricing_empty": "Add at least one period so the tariff appears in the storefront.",
- "admin_tariff_pricing_period_subtitle": "Each row is a separate storefront option: how many months the user pays for and how much it costs",
+ "admin_tariff_pricing_period_subtitle": "Each row is a separate storefront option: how many months the user pays for and how much it costs. Drag rows by the handle to set the period order in the bot and the web app",
"admin_tariff_pricing_period_title": "Subscription periods and prices",
- "admin_tariff_pricing_traffic_subtitle": "Base storefront for the traffic model. Each row is an \"N gigabytes for N currency units\" package",
+ "admin_tariff_period_reorder": "Drag to reorder",
+ "admin_tariff_package_reorder": "Drag to reorder",
+ "admin_tariff_pricing_traffic_subtitle": "Base storefront for the traffic model. Each row is an \"N gigabytes for N currency units\" package. Drag rows by the handle to set the package order in the bot and the web app",
"admin_tariff_pricing_traffic_title": "Traffic packages",
"admin_tariff_saved": "Tariff saved",
"admin_tariff_status_updated": "Tariff status updated",
diff --git a/locales/ru.json b/locales/ru.json
index 2620ce6..0835d68 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -2074,9 +2074,11 @@
"admin_tariff_provider_any_currency": "Любая",
"admin_tariff_provider_not_declared": "Не задано",
"admin_tariff_pricing_empty": "Добавьте хотя бы один период — без него тариф не появится на витрине.",
- "admin_tariff_pricing_period_subtitle": "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит",
+ "admin_tariff_pricing_period_subtitle": "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит. Перетаскивайте строки за рукоятку, чтобы задать порядок периодов в боте и веб-приложении",
"admin_tariff_pricing_period_title": "Периоды подписки и цены",
- "admin_tariff_pricing_traffic_subtitle": "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»",
+ "admin_tariff_period_reorder": "Перетащите, чтобы изменить порядок",
+ "admin_tariff_package_reorder": "Перетащите, чтобы изменить порядок",
+ "admin_tariff_pricing_traffic_subtitle": "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты». Перетаскивайте строки за рукоятку, чтобы задать порядок пакетов в боте и веб-приложении",
"admin_tariff_pricing_traffic_title": "Пакеты трафика",
"admin_tariff_saved": "Тариф сохранён",
"admin_tariff_status_updated": "Статус тарифа обновлён",
diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py
index 7365058..8c92a7d 100644
--- a/tests/test_webapp_assets.py
+++ b/tests/test_webapp_assets.py
@@ -85,6 +85,87 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(plans[1]["traffic_gb"], 50.0)
self.assertEqual(plans[1]["stars_price"], 2500)
+ def test_serialize_plans_preserves_enabled_period_order(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "tariffs.json"
+ path.write_text(
+ json.dumps(
+ {
+ "default_tariff": "standard",
+ "tariffs": [
+ {
+ "key": "standard",
+ "names": {"en": "Standard"},
+ "descriptions": {"en": "Custom order"},
+ "squad_uuids": ["uuid"],
+ "billing_model": "period",
+ "monthly_gb": 100,
+ "prices_rub": {"1": 150, "3": 400, "6": 700, "12": 1200},
+ "prices_stars": {},
+ # Deliberately unsorted: the storefront must follow this order.
+ "enabled_periods": [12, 1, 6, 3],
+ "enabled": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ settings = Settings(
+ _env_file=None,
+ BOT_TOKEN="token",
+ POSTGRES_USER="app_user",
+ POSTGRES_PASSWORD="app_password",
+ TARIFFS_CONFIG_PATH=str(path),
+ )
+
+ plans = subscription_webapp._serialize_plans(settings, "en")
+
+ self.assertEqual([plan["months"] for plan in plans], [12, 1, 6, 3])
+
+ def test_serialize_plans_preserves_traffic_package_order(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "tariffs.json"
+ path.write_text(
+ json.dumps(
+ {
+ "default_tariff": "traffic",
+ "tariffs": [
+ {
+ "key": "traffic",
+ "names": {"en": "Traffic"},
+ "descriptions": {"en": "Pay as you go"},
+ "squad_uuids": ["uuid"],
+ "billing_model": "traffic",
+ "traffic_packages": {
+ # Deliberately unsorted by volume.
+ "rub": [
+ {"gb": 100, "price": 999},
+ {"gb": 10, "price": 199},
+ {"gb": 50, "price": 599},
+ ],
+ "stars": [{"gb": 250, "price": 2500}],
+ },
+ "enabled": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ settings = Settings(
+ _env_file=None,
+ BOT_TOKEN="token",
+ POSTGRES_USER="app_user",
+ POSTGRES_PASSWORD="app_password",
+ TARIFFS_CONFIG_PATH=str(path),
+ )
+
+ plans = subscription_webapp._serialize_plans(settings, "en")
+
+ # default-currency order first, then Stars-only volumes appended.
+ self.assertEqual([plan["traffic_gb"] for plan in plans], [100.0, 10.0, 50.0, 250.0])
+
def test_referral_bonus_details_use_custom_tariff_periods(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "tariffs.json"