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 =
@@ -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) {
state.update((s) => ({ ...s, ...updates }));
}
@@ -326,5 +344,6 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
deleteTariff,
addDraftRow,
removeDraftRow,
moveDraftRow,
};
}
+3 -3
View File
@@ -69,6 +69,8 @@ export function rowsFromPackages(packageSet, currency, valueKey) {
export function draftFromTariff(tariff, defaultCurrency = "rub") {
const currency = normalizeCurrencyKey(defaultCurrency);
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([
...(tariff.enabled_periods || []),
...Object.keys(defaultPrices).map(Number),
@@ -77,7 +79,6 @@ export function draftFromTariff(tariff, defaultCurrency = "rub") {
]);
const periodRows = [...months]
.filter((month) => Number.isFinite(month) && month > 0)
.sort((a, b) => a - b)
.map((month) => ({
months: month,
rub:
@@ -231,8 +232,7 @@ export function tariffFromDraft(draft, fallbackCurrency = "rub") {
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);
const defaultPrices = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
+1
View File
@@ -29,6 +29,7 @@ export {
FileText,
Gift,
Globe2,
GripVertical,
Home,
Info,
Key,
+1
View File
@@ -11,6 +11,7 @@ export { default as RadioGroup } from "./radio-group.svelte";
export { default as RadioGroupItem } from "./radio-group-item.svelte";
export { default as RangeInput } from "./range-input.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 ScrollArea } from "./scroll-area.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>
+15 -1
View File
@@ -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;
}