feat(tariffs): configurable purchase order for periods and packages
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.
This commit is contained in:
@@ -495,7 +495,10 @@ def _serialize_plans(
|
|||||||
else [],
|
else [],
|
||||||
}
|
}
|
||||||
if tariff.billing_model == "period":
|
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)
|
price = tariff.period_price(int(months), default_currency)
|
||||||
stars_price = tariff.period_price(int(months), "stars")
|
stars_price = tariff.period_price(int(months), "stars")
|
||||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
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 []
|
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)
|
price = currency_packages.get(traffic_gb)
|
||||||
stars_price = stars_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):
|
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||||
|
|||||||
@@ -140,14 +140,14 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
|
|||||||
| `prices_stars` | Цены периодов в Telegram Stars. |
|
| `prices_stars` | Цены периодов в Telegram Stars. |
|
||||||
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||||
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||||
| `enabled_periods` | Периоды, доступные для покупки. |
|
| `enabled_periods` | Периоды, доступные для покупки. Порядок элементов в массиве задаёт порядок периодов на витрине (в Telegram-боте и Web App) — отсортируйте их так, как нужно показывать. В веб-админке этот порядок меняется перетаскиванием строк периодов. |
|
||||||
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
|
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
|
||||||
|
|
||||||
Для `traffic`-тарифа используются:
|
Для `traffic`-тарифа используются:
|
||||||
|
|
||||||
| Поле | Назначение |
|
| Поле | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. |
|
| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. Порядок пакетов в списке задаёт порядок на витрине (в Telegram-боте и Web App): сначала идут пакеты валюты каталога, затем пакеты, доступные только за Stars. В веб-админке порядок меняется перетаскиванием строк. |
|
||||||
| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. |
|
| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. |
|
||||||
| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. |
|
| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Input } from "$components/ui/index.js";
|
import { Input, Sortable } from "$components/ui/index.js";
|
||||||
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
|
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
|
||||||
import Dialog from "$components/ui/dialog.svelte";
|
import Dialog from "$components/ui/dialog.svelte";
|
||||||
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
||||||
@@ -552,7 +552,8 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="admin-row-editor">
|
<div class="admin-row-editor">
|
||||||
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-period admin-row-editor-header">
|
||||||
|
<span></span>
|
||||||
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
|
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
|
||||||
<span>{currencyPriceColumnLabel}</span>
|
<span>{currencyPriceColumnLabel}</span>
|
||||||
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
|
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
|
||||||
@@ -560,62 +561,67 @@
|
|||||||
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
|
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{#each tariffDraft.periodRows as row, index}
|
<Sortable
|
||||||
<div class="admin-row-editor-line admin-row-editor-6">
|
items={tariffDraft.periodRows}
|
||||||
<Input
|
class="admin-row-editor-line admin-row-editor-period"
|
||||||
class="input"
|
handleLabel={at("tariff_period_reorder", {}, "Перетащите, чтобы изменить порядок")}
|
||||||
type="number"
|
onReorder={(from, to) => tariffsStore.moveDraftRow("periodRows", from, to)}
|
||||||
min="1"
|
let:item={row}
|
||||||
placeholder="1"
|
let:index
|
||||||
bind:value={row.months}
|
>
|
||||||
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
|
<Input
|
||||||
/>
|
class="input"
|
||||||
<Input
|
type="number"
|
||||||
class="input"
|
min="1"
|
||||||
type="number"
|
placeholder="1"
|
||||||
min="0"
|
bind:value={row.months}
|
||||||
step="0.01"
|
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
|
||||||
placeholder="299"
|
/>
|
||||||
bind:value={row.rub}
|
<Input
|
||||||
aria-label={currencyPriceAriaLabel}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<Input
|
min="0"
|
||||||
class="input"
|
step="0.01"
|
||||||
type="number"
|
placeholder="299"
|
||||||
min="0"
|
bind:value={row.rub}
|
||||||
step="1"
|
aria-label={currencyPriceAriaLabel}
|
||||||
placeholder="150"
|
/>
|
||||||
bind:value={row.stars}
|
<Input
|
||||||
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<Input
|
min="0"
|
||||||
class="input"
|
step="1"
|
||||||
type="number"
|
placeholder="150"
|
||||||
min="0"
|
bind:value={row.stars}
|
||||||
step="1"
|
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
|
||||||
placeholder="3"
|
/>
|
||||||
bind:value={row.referral_inviter}
|
<Input
|
||||||
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<Input
|
min="0"
|
||||||
class="input"
|
step="1"
|
||||||
type="number"
|
placeholder="3"
|
||||||
min="0"
|
bind:value={row.referral_inviter}
|
||||||
step="1"
|
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
|
||||||
placeholder="1"
|
/>
|
||||||
bind:value={row.referral_referee}
|
<Input
|
||||||
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<AdminButton
|
min="0"
|
||||||
size="sm"
|
step="1"
|
||||||
variant="danger"
|
placeholder="1"
|
||||||
onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
|
bind:value={row.referral_referee}
|
||||||
aria-label={at("btn_delete", {}, "Удалить")}
|
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
|
||||||
>
|
/>
|
||||||
<Trash2 size={13} />
|
<AdminButton
|
||||||
</AdminButton>
|
size="sm"
|
||||||
</div>
|
variant="danger"
|
||||||
{/each}
|
onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
|
||||||
|
aria-label={at("btn_delete", {}, "Удалить")}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</AdminButton>
|
||||||
|
</Sortable>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -649,80 +655,92 @@
|
|||||||
<div class="admin-row-editor">
|
<div class="admin-row-editor">
|
||||||
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
|
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
|
||||||
{#if tariffDraft.trafficRubRows.length}
|
{#if tariffDraft.trafficRubRows.length}
|
||||||
<div class="admin-row-editor-line admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
|
||||||
|
<span></span>
|
||||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||||
<span>{currencyPriceColumnLabel}</span>
|
<span>{currencyPriceColumnLabel}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.trafficRubRows as row, index}
|
<Sortable
|
||||||
<div class="admin-row-editor-line">
|
items={tariffDraft.trafficRubRows}
|
||||||
<Input
|
class="admin-row-editor-line admin-row-editor-drag"
|
||||||
class="input"
|
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
|
||||||
type="number"
|
onReorder={(from, to) => tariffsStore.moveDraftRow("trafficRubRows", from, to)}
|
||||||
min="0.1"
|
let:item={row}
|
||||||
step="0.1"
|
let:index
|
||||||
placeholder="50"
|
>
|
||||||
bind:value={row.gb}
|
<Input
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<Input
|
min="0.1"
|
||||||
class="input"
|
step="0.1"
|
||||||
type="number"
|
placeholder="50"
|
||||||
min="0"
|
bind:value={row.gb}
|
||||||
step="0.01"
|
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||||
placeholder="299"
|
/>
|
||||||
bind:value={row.price}
|
<Input
|
||||||
aria-label={currencyPriceAriaLabel}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<AdminButton
|
min="0"
|
||||||
size="sm"
|
step="0.01"
|
||||||
variant="danger"
|
placeholder="299"
|
||||||
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
|
bind:value={row.price}
|
||||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
aria-label={currencyPriceAriaLabel}
|
||||||
>
|
/>
|
||||||
</div>
|
<AdminButton
|
||||||
{/each}
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
|
||||||
|
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||||
|
>
|
||||||
|
</Sortable>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-row-editor">
|
<div class="admin-row-editor">
|
||||||
<span class="admin-row-editor-caption"
|
<span class="admin-row-editor-caption"
|
||||||
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
|
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
|
||||||
>
|
>
|
||||||
{#if tariffDraft.trafficStarsRows.length}
|
{#if tariffDraft.trafficStarsRows.length}
|
||||||
<div class="admin-row-editor-line admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-drag admin-row-editor-header">
|
||||||
|
<span></span>
|
||||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||||
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
|
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each tariffDraft.trafficStarsRows as row, index}
|
<Sortable
|
||||||
<div class="admin-row-editor-line">
|
items={tariffDraft.trafficStarsRows}
|
||||||
<Input
|
class="admin-row-editor-line admin-row-editor-drag"
|
||||||
class="input"
|
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
|
||||||
type="number"
|
onReorder={(from, to) => tariffsStore.moveDraftRow("trafficStarsRows", from, to)}
|
||||||
min="0.1"
|
let:item={row}
|
||||||
step="0.1"
|
let:index
|
||||||
placeholder="50"
|
>
|
||||||
bind:value={row.gb}
|
<Input
|
||||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<Input
|
min="0.1"
|
||||||
class="input"
|
step="0.1"
|
||||||
type="number"
|
placeholder="50"
|
||||||
min="0"
|
bind:value={row.gb}
|
||||||
step="1"
|
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||||
placeholder="150"
|
/>
|
||||||
bind:value={row.price}
|
<Input
|
||||||
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
|
class="input"
|
||||||
/>
|
type="number"
|
||||||
<AdminButton
|
min="0"
|
||||||
size="sm"
|
step="1"
|
||||||
variant="danger"
|
placeholder="150"
|
||||||
onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)}
|
bind:value={row.price}
|
||||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
|
||||||
>
|
/>
|
||||||
</div>
|
<AdminButton
|
||||||
{/each}
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)}
|
||||||
|
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||||
|
>
|
||||||
|
</Sortable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
|
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
|
||||||
: at("tariff_traffic_packages", {}, "Пакеты трафика");
|
: at("tariff_traffic_packages", {}, "Пакеты трафика");
|
||||||
}
|
}
|
||||||
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
|
const months = [...(tariff.enabled_periods || [])];
|
||||||
return months
|
return months
|
||||||
.map((month) => {
|
.map((month) => {
|
||||||
const rub =
|
const rub =
|
||||||
|
|||||||
@@ -303,6 +303,24 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveDraftRow(field, fromIndex, toIndex) {
|
||||||
|
state.update((s) => {
|
||||||
|
const rows = [...(s.tariffDraft[field] || [])];
|
||||||
|
if (
|
||||||
|
fromIndex === toIndex ||
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= rows.length ||
|
||||||
|
toIndex >= rows.length
|
||||||
|
) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const [moved] = rows.splice(fromIndex, 1);
|
||||||
|
rows.splice(toIndex, 0, moved);
|
||||||
|
return { ...s, tariffDraft: { ...s.tariffDraft, [field]: rows } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateState(updates) {
|
function updateState(updates) {
|
||||||
state.update((s) => ({ ...s, ...updates }));
|
state.update((s) => ({ ...s, ...updates }));
|
||||||
}
|
}
|
||||||
@@ -326,5 +344,6 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
deleteTariff,
|
deleteTariff,
|
||||||
addDraftRow,
|
addDraftRow,
|
||||||
removeDraftRow,
|
removeDraftRow,
|
||||||
|
moveDraftRow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export function rowsFromPackages(packageSet, currency, valueKey) {
|
|||||||
export function draftFromTariff(tariff, defaultCurrency = "rub") {
|
export function draftFromTariff(tariff, defaultCurrency = "rub") {
|
||||||
const currency = normalizeCurrencyKey(defaultCurrency);
|
const currency = normalizeCurrencyKey(defaultCurrency);
|
||||||
const defaultPrices = tariff.prices?.[currency] || {};
|
const defaultPrices = tariff.prices?.[currency] || {};
|
||||||
|
// enabled_periods comes first so its order (the configured purchase order)
|
||||||
|
// is preserved; any extra price-only months are appended afterwards.
|
||||||
const months = new Set([
|
const months = new Set([
|
||||||
...(tariff.enabled_periods || []),
|
...(tariff.enabled_periods || []),
|
||||||
...Object.keys(defaultPrices).map(Number),
|
...Object.keys(defaultPrices).map(Number),
|
||||||
@@ -77,7 +79,6 @@ export function draftFromTariff(tariff, defaultCurrency = "rub") {
|
|||||||
]);
|
]);
|
||||||
const periodRows = [...months]
|
const periodRows = [...months]
|
||||||
.filter((month) => Number.isFinite(month) && month > 0)
|
.filter((month) => Number.isFinite(month) && month > 0)
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.map((month) => ({
|
.map((month) => ({
|
||||||
months: month,
|
months: month,
|
||||||
rub:
|
rub:
|
||||||
@@ -231,8 +232,7 @@ export function tariffFromDraft(draft, fallbackCurrency = "rub") {
|
|||||||
if (seenMonths.has(row.months)) return false;
|
if (seenMonths.has(row.months)) return false;
|
||||||
seenMonths.add(row.months);
|
seenMonths.add(row.months);
|
||||||
return true;
|
return true;
|
||||||
})
|
});
|
||||||
.sort((a, b) => a.months - b.months);
|
|
||||||
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
|
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
|
||||||
tariff.enabled_periods = rows.map((row) => row.months);
|
tariff.enabled_periods = rows.map((row) => row.months);
|
||||||
const defaultPrices = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
|
const defaultPrices = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export {
|
|||||||
FileText,
|
FileText,
|
||||||
Gift,
|
Gift,
|
||||||
Globe2,
|
Globe2,
|
||||||
|
GripVertical,
|
||||||
Home,
|
Home,
|
||||||
Info,
|
Info,
|
||||||
Key,
|
Key,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { default as RadioGroup } from "./radio-group.svelte";
|
|||||||
export { default as RadioGroupItem } from "./radio-group-item.svelte";
|
export { default as RadioGroupItem } from "./radio-group-item.svelte";
|
||||||
export { default as RangeInput } from "./range-input.svelte";
|
export { default as RangeInput } from "./range-input.svelte";
|
||||||
export { default as Skeleton } from "./skeleton.svelte";
|
export { default as Skeleton } from "./skeleton.svelte";
|
||||||
|
export { default as Sortable } from "./sortable.svelte";
|
||||||
export { default as Spinner } from "./spinner.svelte";
|
export { default as Spinner } from "./spinner.svelte";
|
||||||
export { default as ScrollArea } from "./scroll-area.svelte";
|
export { default as ScrollArea } from "./scroll-area.svelte";
|
||||||
export { default as Textarea } from "./textarea.svelte";
|
export { default as Textarea } from "./textarea.svelte";
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script>
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { GripVertical } from "./icons.js";
|
||||||
|
|
||||||
|
// Reusable drag-to-reorder list. bits-ui / shadcn-svelte have no sortable
|
||||||
|
// primitive, so this wraps native HTML5 drag & drop with a grip handle.
|
||||||
|
// Each item is rendered through the default (scoped) slot, which receives
|
||||||
|
// `item`, `index` and `dragging`. The slot content fills the row alongside
|
||||||
|
// the leading drag handle, so pass a grid `class` whose first column matches
|
||||||
|
// the handle width.
|
||||||
|
export let items = [];
|
||||||
|
export let onReorder = () => {};
|
||||||
|
export let getKey = (item) => item;
|
||||||
|
export let handleLabel = "Drag to reorder";
|
||||||
|
export let disabled = false;
|
||||||
|
let className = "";
|
||||||
|
export { className as class };
|
||||||
|
export let containerClass = "";
|
||||||
|
|
||||||
|
let dragIndex = null;
|
||||||
|
let dropIndex = null;
|
||||||
|
|
||||||
|
function handleDragStart(event, index) {
|
||||||
|
if (disabled) return;
|
||||||
|
dragIndex = index;
|
||||||
|
dropIndex = index;
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
// Firefox requires data to be set for a drag to start.
|
||||||
|
event.dataTransfer.setData("text/plain", String(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event, index) {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||||
|
dropIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event, index) {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (dragIndex !== index) onReorder(dragIndex, index);
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("ui-sortable", containerClass)} role="list">
|
||||||
|
{#each items as item, index (getKey(item, index))}
|
||||||
|
<div
|
||||||
|
class={cn("ui-sortable-item", className)}
|
||||||
|
class:is-dragging={dragIndex === index}
|
||||||
|
class:is-drop-target={dropIndex === index && dragIndex !== index}
|
||||||
|
role="listitem"
|
||||||
|
on:dragover={(event) => handleDragOver(event, index)}
|
||||||
|
on:drop={(event) => handleDrop(event, index)}
|
||||||
|
on:dragend={reset}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui-sortable-handle"
|
||||||
|
draggable={!disabled}
|
||||||
|
aria-label={handleLabel}
|
||||||
|
title={handleLabel}
|
||||||
|
on:dragstart={(event) => handleDragStart(event, index)}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</button>
|
||||||
|
<slot {item} {index} dragging={dragIndex === index} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ui-sortable {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-item.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-item.is-drop-target {
|
||||||
|
outline: 2px dashed var(--admin-accent, #4f8cff);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--admin-muted, inherit);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-handle:hover {
|
||||||
|
color: var(--admin-text, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3572,6 +3572,18 @@
|
|||||||
minmax(120px, 1fr) 32px;
|
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 {
|
.admin-package-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -3790,7 +3802,9 @@
|
|||||||
.admin-package-columns,
|
.admin-package-columns,
|
||||||
.admin-row-editor-line,
|
.admin-row-editor-line,
|
||||||
.admin-row-editor-line.admin-row-editor-4,
|
.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;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -2074,9 +2074,11 @@
|
|||||||
"admin_tariff_provider_any_currency": "Any",
|
"admin_tariff_provider_any_currency": "Any",
|
||||||
"admin_tariff_provider_not_declared": "Not declared",
|
"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_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_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_pricing_traffic_title": "Traffic packages",
|
||||||
"admin_tariff_saved": "Tariff saved",
|
"admin_tariff_saved": "Tariff saved",
|
||||||
"admin_tariff_status_updated": "Tariff status updated",
|
"admin_tariff_status_updated": "Tariff status updated",
|
||||||
|
|||||||
+4
-2
@@ -2074,9 +2074,11 @@
|
|||||||
"admin_tariff_provider_any_currency": "Любая",
|
"admin_tariff_provider_any_currency": "Любая",
|
||||||
"admin_tariff_provider_not_declared": "Не задано",
|
"admin_tariff_provider_not_declared": "Не задано",
|
||||||
"admin_tariff_pricing_empty": "Добавьте хотя бы один период — без него тариф не появится на витрине.",
|
"admin_tariff_pricing_empty": "Добавьте хотя бы один период — без него тариф не появится на витрине.",
|
||||||
"admin_tariff_pricing_period_subtitle": "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит",
|
"admin_tariff_pricing_period_subtitle": "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит. Перетаскивайте строки за рукоятку, чтобы задать порядок периодов в боте и веб-приложении",
|
||||||
"admin_tariff_pricing_period_title": "Периоды подписки и цены",
|
"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_pricing_traffic_title": "Пакеты трафика",
|
||||||
"admin_tariff_saved": "Тариф сохранён",
|
"admin_tariff_saved": "Тариф сохранён",
|
||||||
"admin_tariff_status_updated": "Статус тарифа обновлён",
|
"admin_tariff_status_updated": "Статус тарифа обновлён",
|
||||||
|
|||||||
@@ -85,6 +85,87 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(plans[1]["traffic_gb"], 50.0)
|
self.assertEqual(plans[1]["traffic_gb"], 50.0)
|
||||||
self.assertEqual(plans[1]["stars_price"], 2500)
|
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):
|
def test_referral_bonus_details_use_custom_tariff_periods(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
path = Path(tmpdir) / "tariffs.json"
|
path = Path(tmpdir) / "tariffs.json"
|
||||||
|
|||||||
Reference in New Issue
Block a user