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))} +
handleDragOver(event, index)} + on:drop={(event) => handleDrop(event, index)} + on:dragend={reset} + > + + +
+ {/each} +
+ + 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"