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:
3252a8
2026-06-03 10:14:40 +03:00
parent 0a294b8bf8
commit 58de153370
13 changed files with 396 additions and 129 deletions
@@ -1,5 +1,5 @@
<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 Dialog from "$components/ui/dialog.svelte";
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
@@ -552,7 +552,8 @@
</p>
{:else}
<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>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
@@ -560,62 +561,67 @@
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
<span></span>
</div>
{#each tariffDraft.periodRows as row, index}
<div class="admin-row-editor-line admin-row-editor-6">
<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={currencyPriceAriaLabel}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="150"
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="3"
bind:value={row.referral_inviter}
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="1"
bind:value={row.referral_referee}
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}
>
<Trash2 size={13} />
</AdminButton>
</div>
{/each}
<Sortable
items={tariffDraft.periodRows}
class="admin-row-editor-line admin-row-editor-period"
handleLabel={at("tariff_period_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("periodRows", from, to)}
let:item={row}
let:index
>
<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={currencyPriceAriaLabel}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="150"
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="3"
bind:value={row.referral_inviter}
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
/>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="1"
bind:value={row.referral_referee}
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}
>
<Trash2 size={13} />
</AdminButton>
</Sortable>
</div>
{/if}
</section>
@@ -649,80 +655,92 @@
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#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>{currencyPriceColumnLabel}</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={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
<Sortable
items={tariffDraft.trafficRubRows}
class="admin-row-editor-line admin-row-editor-drag"
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("trafficRubRows", from, to)}
let:item={row}
let:index
>
<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={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</Sortable>
</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">
<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_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}
<Sortable
items={tariffDraft.trafficStarsRows}
class="admin-row-editor-line admin-row-editor-drag"
handleLabel={at("tariff_package_reorder", {}, "Перетащите, чтобы изменить порядок")}
onReorder={(from, to) => tariffsStore.moveDraftRow("trafficStarsRows", from, to)}
let:item={row}
let:index
>
<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
>
</Sortable>
</div>
</div>
</section>
@@ -129,7 +129,7 @@
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
: at("tariff_traffic_packages", {}, "Пакеты трафика");
}
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
const months = [...(tariff.enabled_periods || [])];
return months
.map((month) => {
const rub =