refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
+71
View File
@@ -0,0 +1,71 @@
export function structuredCloneSafe(value) {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value));
}
export function pretty(value) {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "Да" : "Нет";
return String(value);
}
export function fmtDate(value) {
if (!value) return "—";
try {
return new Date(value).toLocaleString("ru-RU");
} catch {
return String(value);
}
}
export function fmtDateShort(value) {
if (!value) return "—";
try {
return new Date(value).toLocaleDateString("ru-RU");
} catch {
return String(value);
}
}
export function fmtMoney(amount, currency) {
const sym = currency === "RUB" ? "₽" : currency || "";
const num = Number(amount || 0);
return `${num.toFixed(2)} ${sym}`.trim();
}
export function fmtTrafficBytes(value) {
const bytes = Number(value || 0);
if (!bytes || bytes <= 0) return "0 GB";
const gb = bytes / 1073741824;
const formatted = gb >= 10 ? gb.toFixed(1) : gb.toFixed(2);
return `${formatted.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")} GB`;
}
export function trafficPercentValue(used, limit) {
const usedBytes = Number(used || 0);
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((usedBytes / limitBytes) * 100)));
}
export function trafficLeftLabel(used, limit) {
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return "Без лимита";
return fmtTrafficBytes(Math.max(0, limitBytes - Number(used || 0)));
}
export function trafficOfLabel(used, limit) {
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return `${fmtTrafficBytes(used)} / без лимита`;
return `${fmtTrafficBytes(used)} / ${fmtTrafficBytes(limit)}`;
}
export function paymentStatusVariant(status) {
if (status === "succeeded") return "success";
if (typeof status === "string" && status.startsWith("pending")) return "warning";
return "danger";
}
export function optionLabel(options, value) {
return options.find((option) => option.value === value)?.label || value;
}
+121
View File
@@ -0,0 +1,121 @@
/** @typedef {{ date: string, amount: number }} RevenuePoint */
/**
* @param {string} iso
* @returns {number} UTC ms at noon (stable day bucket)
*/
function noonUtcMs(iso) {
const s = String(iso || "");
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
return Number.isFinite(t) ? t : 0;
}
/**
* @param {number} t
* @returns {string} YYYY-MM-DD UTC
*/
function isoUtcDateFromMs(t) {
const d = new Date(t);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* Monday 00:00 UTC for the week containing `iso` (date-only).
* @param {string} iso
*/
export function utcWeekStartMs(iso) {
const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`);
const dow = d.getUTCDay();
const offset = (dow + 6) % 7;
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - offset);
}
/**
* First day of month (UTC) containing `iso`.
* @param {string} iso
*/
export function utcMonthStartMs(iso) {
const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`);
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
}
/**
* @param {RevenuePoint[]} points sorted ascending by `date`
* @param {string} fromIso YYYY-MM-DD inclusive
* @param {string} toIso YYYY-MM-DD inclusive
* @returns {RevenuePoint[]}
*/
export function filterDailyByIsoRange(points, fromIso, toIso) {
if (!fromIso || !toIso) return [];
return points.filter((p) => p.date >= fromIso && p.date <= toIso);
}
/**
* @param {RevenuePoint[]} points sorted ascending
* @param {number} n
*/
export function sliceLastDays(points, n) {
if (!points?.length || n <= 0) return [];
const take = Math.min(n, points.length);
return points.slice(-take);
}
/**
* @param {RevenuePoint[]} daily sorted ascending, day granularity
* @returns {RevenuePoint[]}
*/
function bucketWeeks(daily) {
/** @type {Map<number, number>} */
const sums = new Map();
for (const p of daily) {
const k = utcWeekStartMs(p.date);
const amt = Number(p.amount) || 0;
sums.set(k, (sums.get(k) || 0) + amt);
}
return [...sums.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount }));
}
/**
* @param {RevenuePoint[]} daily sorted ascending
* @returns {RevenuePoint[]}
*/
function bucketMonths(daily) {
/** @type {Map<number, number>} */
const sums = new Map();
for (const p of daily) {
const k = utcMonthStartMs(p.date);
const amt = Number(p.amount) || 0;
sums.set(k, (sums.get(k) || 0) + amt);
}
return [...sums.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount }));
}
/**
* @param {RevenuePoint[]} dailySorted ascending by date, consecutive calendar days
* @param {"day" | "week" | "month"} granularity
*/
export function aggregateRevenueSeries(dailySorted, granularity) {
if (!dailySorted?.length) return [];
if (granularity === "week") return bucketWeeks(dailySorted);
if (granularity === "month") return bucketMonths(dailySorted);
return dailySorted.map((p) => ({ date: p.date, amount: Number(p.amount) || 0 }));
}
/**
* For chart hint: calendar span of inclusive range.
* @param {string} fromIso
* @param {string} toIso
*/
export function inclusiveDaySpan(fromIso, toIso) {
const a = noonUtcMs(fromIso);
const b = noonUtcMs(toIso);
if (!a || !b) return 0;
return Math.max(1, Math.round((b - a) / 86400000) + 1);
}
+101
View File
@@ -0,0 +1,101 @@
import { writable } from "svelte/store";
export function createAdsStore({ api, onToast, at }) {
const state = writable({
ads: [],
adsTotals: null,
adsLoading: false,
adCreateOpen: false,
adDraft: { source: "", start_param: "", cost: 0 },
});
async function loadAds() {
state.update((s) => ({ ...s, adsLoading: true }));
try {
const data = await api("/admin/ads");
if (data?.ok) {
state.update((s) => ({
...s,
ads: data.campaigns || [],
adsTotals: data.totals || {},
}));
}
} finally {
state.update((s) => ({ ...s, adsLoading: false }));
}
}
async function createAd() {
let draft = null;
state.update((s) => {
draft = s.adDraft;
return s;
});
if (!draft.source.trim() || !draft.start_param.trim()) return;
const res = await api("/admin/ads", {
method: "POST",
body: JSON.stringify(draft),
});
if (res?.ok) {
onToast(at("ad_created", {}, "Кампания создана"));
state.update((s) => ({
...s,
adCreateOpen: false,
adDraft: { source: "", start_param: "", cost: 0 },
}));
await loadAds();
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
async function toggleAd(ad) {
const res = await api(`/admin/ads/${ad.id}/toggle`, {
method: "POST",
body: JSON.stringify({ is_active: !ad.is_active }),
});
if (res?.ok) {
state.update((s) => ({
...s,
ads: s.ads.map((c) => (c.id === ad.id ? { ...c, is_active: !ad.is_active } : c)),
}));
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
async function deleteAd(ad) {
const res = await api(`/admin/ads/${ad.id}`, { method: "DELETE" });
if (res?.ok) {
state.update((s) => ({
...s,
ads: s.ads.filter((c) => c.id !== ad.id),
}));
onToast(at("ad_deleted", {}, "Кампания удалена"));
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
function setCreateOpen(open) {
state.update((s) => ({ ...s, adCreateOpen: open }));
}
function updateDraft(fields) {
state.update((s) => ({ ...s, adDraft: { ...s.adDraft, ...fields } }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadAds,
createAd,
toggleAd,
deleteAd,
setCreateOpen,
updateDraft,
};
}
@@ -0,0 +1,60 @@
import { writable } from "svelte/store";
export function createBroadcastStore({ api, onToast, at }) {
const state = writable({
broadcastTarget: "all",
broadcastText: "",
broadcastBusy: false,
broadcastResult: null,
});
const BROADCAST_TARGET_OPTIONS = [
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
];
async function runBroadcast() {
let text = "";
let target = "";
state.update((s) => {
text = s.broadcastText;
target = s.broadcastTarget;
s.broadcastBusy = true;
s.broadcastResult = null;
return s;
});
try {
const res = await api("/admin/broadcast", {
method: "POST",
body: JSON.stringify({ target, text }),
});
if (res?.ok) {
state.update((s) => ({
...s,
broadcastText: "",
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
}));
onToast(at("broadcast_started", {}, "Рассылка запущена"));
} else {
onToast(res?.error || at("broadcast_failed", {}, "Ошибка рассылки"));
}
} finally {
state.update((s) => ({ ...s, broadcastBusy: false }));
}
}
function updateField(fields) {
state.update((s) => ({ ...s, ...fields }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
runBroadcast,
updateField,
BROADCAST_TARGET_OPTIONS,
};
}
@@ -0,0 +1,59 @@
import { writable } from "svelte/store";
export function createLogsStore({ api }) {
const state = writable({
logs: [],
logsTotal: 0,
logsPage: 0,
logsUserFilter: "",
logsLoading: false,
});
const LOGS_PAGE_SIZE = 50;
async function loadLogs() {
state.update((s) => ({ ...s, logsLoading: true }));
let currentPage = 0;
let filter = "";
state.update((s) => {
currentPage = s.logsPage;
filter = s.logsUserFilter;
return s;
});
try {
let q = `/admin/logs?page=${currentPage}&page_size=${LOGS_PAGE_SIZE}`;
if (filter.trim()) {
q += `&user_id=${encodeURIComponent(filter.trim())}`;
}
const data = await api(q);
if (data?.ok) {
state.update((s) => ({
...s,
logs: data.logs || [],
logsTotal: data.total || 0,
}));
}
} finally {
state.update((s) => ({ ...s, logsLoading: false }));
}
}
function setPage(page) {
state.update((s) => ({ ...s, logsPage: page }));
loadLogs();
}
function setFilter(filter) {
state.update((s) => ({ ...s, logsUserFilter: filter }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadLogs,
setPage,
setFilter,
};
}
@@ -0,0 +1,47 @@
import { writable } from "svelte/store";
export function createPaymentsStore({ api }) {
const state = writable({
payments: [],
paymentsTotal: 0,
paymentsPage: 0,
paymentsLoading: false,
});
const PAYMENTS_PAGE_SIZE = 25;
async function loadPayments() {
state.update((s) => ({ ...s, paymentsLoading: true }));
let currentPage = 0;
state.update((s) => {
currentPage = s.paymentsPage;
return s;
});
try {
const data = await api(`/admin/payments?page=${currentPage}&page_size=${PAYMENTS_PAGE_SIZE}`);
if (data?.ok) {
state.update((s) => ({
...s,
payments: data.payments || [],
paymentsTotal: data.total || 0,
}));
}
} finally {
state.update((s) => ({ ...s, paymentsLoading: false }));
}
}
function setPage(page) {
state.update((s) => ({ ...s, paymentsPage: page }));
loadPayments();
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadPayments,
setPage,
};
}
@@ -0,0 +1,112 @@
import { writable } from "svelte/store";
export function createPromosStore({ api, onToast }) {
const state = writable({
promos: [],
promosTotal: 0,
promosPage: 0,
promosLoading: false,
promoCreateOpen: false,
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
});
const PROMOS_PAGE_SIZE = 25;
async function loadPromos() {
state.update((s) => ({ ...s, promosLoading: true }));
let currentPage = 0;
state.update((s) => {
currentPage = s.promosPage;
return s;
});
try {
const data = await api(`/admin/promos?page=${currentPage}&page_size=${PROMOS_PAGE_SIZE}`);
if (data?.ok) {
state.update((s) => ({ ...s, promos: data.promos || [], promosTotal: data.total || 0 }));
}
} finally {
state.update((s) => ({ ...s, promosLoading: false }));
}
}
async function createPromo() {
let draft = null;
state.update((s) => {
draft = s.promoDraft;
return s;
});
if (!draft.code.trim()) return;
const res = await api("/admin/promos", {
method: "POST",
body: JSON.stringify(draft),
});
if (res?.ok) {
onToast("Промокод создан");
state.update((s) => ({
...s,
promoCreateOpen: false,
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
}));
await loadPromos();
} else {
onToast(res?.error || "Ошибка");
}
}
async function togglePromo(promo) {
const res = await api(`/admin/promos/${promo.id}`, {
method: "PATCH",
body: JSON.stringify({ is_active: !promo.is_active }),
});
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.map((p) => (p.id === promo.id ? res.promo : p)),
}));
} else {
onToast(res?.error || "Ошибка");
}
}
async function deletePromo(promo) {
const res = await api(`/admin/promos/${promo.id}`, { method: "DELETE" });
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.filter((p) => p.id !== promo.id),
}));
onToast("Промокод удалён");
} else {
onToast(res?.error || "Ошибка");
}
}
function setPage(page) {
state.update((s) => ({ ...s, promosPage: page }));
loadPromos();
}
function setCreateOpen(open) {
state.update((s) => ({ ...s, promoCreateOpen: open }));
}
function updateDraft(fields) {
state.update((s) => ({ ...s, promoDraft: { ...s.promoDraft, ...fields } }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadPromos,
createPromo,
togglePromo,
deletePromo,
setPage,
setCreateOpen,
updateDraft,
PROMOS_PAGE_SIZE,
};
}
@@ -0,0 +1,117 @@
import { writable } from "svelte/store";
export function createSettingsStore({ api, onToast, at }) {
const state = writable({
settingsSections: [],
settingsLoading: false,
settingsDirty: {},
settingsSaving: false,
});
async function loadSettings() {
state.update((s) => ({ ...s, settingsLoading: true, settingsDirty: {} }));
try {
const data = await api("/admin/settings");
if (data?.ok) {
state.update((s) => ({
...s,
settingsSections: data.sections || [],
}));
}
} finally {
state.update((s) => ({ ...s, settingsLoading: false }));
}
}
function markDirty(key, value, deleted = false) {
state.update((s) => ({
...s,
settingsDirty: { ...s.settingsDirty, [key]: { value, deleted } },
}));
}
function clearDirty(key) {
state.update((s) => {
const next = { ...s.settingsDirty };
delete next[key];
return { ...s, settingsDirty: next };
});
}
function setFieldValue(key, value) {
state.update((s) => {
const nextDirty = { ...s.settingsDirty };
delete nextDirty[key];
return {
...s,
settingsDirty: nextDirty,
settingsSections: (s.settingsSections || []).map((section) => ({
...section,
fields: (section.fields || []).map((field) =>
field.key === key ? { ...field, value, overridden: true } : field
),
})),
};
});
}
async function saveSettings(onSettingsSaved) {
let dirty = {};
state.update((s) => {
dirty = s.settingsDirty;
return s;
});
if (!Object.keys(dirty).length) return true;
state.update((s) => ({ ...s, settingsSaving: true }));
try {
const updates = {};
const deletes = [];
for (const [key, change] of Object.entries(dirty)) {
if (change.deleted) deletes.push(key);
else updates[key] = change.value;
}
const res = await api("/admin/settings", {
method: "PATCH",
body: JSON.stringify({ updates, deletes }),
});
if (res?.ok) {
onToast(at("settings_saved", {}, "Настройки сохранены"));
state.update((s) => ({ ...s, settingsDirty: {} }));
if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
await loadSettings();
return true;
} else if (res?.errors) {
const summary = Object.entries(res.errors)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
onToast(`Ошибки: ${summary}`);
} else {
onToast(res?.error || "Ошибка");
}
return false;
} finally {
state.update((s) => ({ ...s, settingsSaving: false }));
}
}
function resetField(field) {
if (field.overridden) {
markDirty(field.key, "", true);
} else {
clearDirty(field.key);
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadSettings,
markDirty,
clearDirty,
setFieldValue,
resetField,
saveSettings,
};
}
@@ -0,0 +1,56 @@
import { writable } from "svelte/store";
export function createStatsStore({ api, onToast, at }) {
const state = writable({
stats: null,
statsLoading: false,
statsError: "",
syncBusy: false,
});
async function loadStats() {
state.update((s) => ({ ...s, statsLoading: true, statsError: "" }));
try {
const data = await api("/admin/stats");
if (!data?.ok) {
state.update((s) => ({ ...s, statsError: data?.error || "load_failed" }));
} else {
state.update((s) => ({ ...s, stats: data }));
}
} catch (e) {
state.update((s) => ({ ...s, statsError: e?.message || String(e) }));
} finally {
state.update((s) => ({ ...s, statsLoading: false }));
}
}
async function triggerSync() {
let busy = false;
state.update((s) => {
busy = s.syncBusy;
return s;
});
if (busy) return;
state.update((s) => ({ ...s, syncBusy: true }));
try {
const res = await api("/admin/sync", { method: "POST" });
if (res?.ok) {
onToast(at("sync_started", {}, "Синхронизация запущена"));
await loadStats();
} else {
onToast(res?.error || at("sync_error", {}, "Ошибка синхронизации"));
}
} finally {
state.update((s) => ({ ...s, syncBusy: false }));
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadStats,
triggerSync,
};
}
@@ -0,0 +1,302 @@
import { writable } from "svelte/store";
import {
emptyTariffDraft,
cloneCatalog,
draftFromTariff,
tariffFromDraft as tariffFromDraftFn,
normalizeUuidList,
} from "../tariffDraft.js";
export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
const state = writable({
tariffsCatalog: {
default_tariff: "",
topup_packages_default: { rub: [], stars: [] },
tariffs: [],
},
tariffsPath: "",
tariffsLoading: false,
tariffsSaving: false,
tariffEditorOpen: false,
tariffEditingKey: "",
tariffDeleteOpen: false,
tariffDeleteTarget: null,
tariffDraft: emptyTariffDraft(),
panelSquads: [],
panelSquadsLoading: false,
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorTab: "general",
});
const tariffFromDraft = (draft) => tariffFromDraftFn(draft);
async function loadTariffs() {
state.update((s) => ({ ...s, tariffsLoading: true }));
try {
loadPanelSquads();
const data = await api("/admin/tariffs");
if (data?.ok) {
state.update((s) => ({
...s,
tariffsCatalog: cloneCatalog(data.catalog),
tariffsPath: data.path || "",
}));
} else {
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
}
} finally {
state.update((s) => ({ ...s, tariffsLoading: false }));
}
}
async function loadPanelSquads() {
let loading = false;
state.update((s) => {
loading = s.panelSquadsLoading;
return s;
});
if (loading) return;
state.update((s) => ({ ...s, panelSquadsLoading: true }));
try {
const data = await api("/admin/panel/internal-squads");
if (data?.ok) state.update((s) => ({ ...s, panelSquads: data.squads || [] }));
} catch (_error) {
void _error;
state.update((s) => ({ ...s, panelSquads: [] }));
} finally {
state.update((s) => ({ ...s, panelSquadsLoading: false }));
}
}
function squadLabel(uuid) {
let squads = [];
state.update((s) => {
squads = s.panelSquads;
return s;
});
const squad = squads.find((item) => item.uuid === uuid);
return squad ? `${squad.name} · ${uuid.slice(0, 8)}` : uuid;
}
function addSquadToDraft(field, uuid) {
if (!uuid) return;
state.update((s) => {
const current = normalizeUuidList(s.tariffDraft[field]);
if (current.includes(uuid)) return s;
return { ...s, tariffDraft: { ...s.tariffDraft, [field]: [...current, uuid] } };
});
}
function removeSquadFromDraft(field, uuid) {
state.update((s) => {
return {
...s,
tariffDraft: {
...s.tariffDraft,
[field]: normalizeUuidList(s.tariffDraft[field]).filter((item) => item !== uuid),
},
};
});
}
async function persistTariffs(nextCatalog, successText) {
state.update((s) => ({ ...s, tariffsSaving: true }));
let currentPath = "";
state.update((s) => {
currentPath = s.tariffsPath;
return s;
});
try {
const res = await api("/admin/tariffs", {
method: "PUT",
body: JSON.stringify({ catalog: nextCatalog }),
});
if (res?.ok) {
state.update((s) => ({
...s,
tariffsCatalog: cloneCatalog(res.catalog),
tariffsPath: res.path || currentPath,
tariffEditorOpen: false,
tariffDeleteOpen: false,
tariffDeleteTarget: null,
}));
if (onTariffsSaved) await onTariffsSaved(res.catalog);
flash(successText || at("tariffs_saved", {}, "Тарифы сохранены"));
} else {
flash(
res?.message || res?.error || at("tariffs_save_failed", {}, "Ошибка сохранения тарифов")
);
}
} finally {
state.update((s) => ({ ...s, tariffsSaving: false }));
}
}
function openCreateTariff() {
state.update((s) => ({
...s,
tariffEditingKey: "",
tariffDraft: emptyTariffDraft(),
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorOpen: true,
}));
}
function openEditTariff(tariff) {
state.update((s) => ({
...s,
tariffEditingKey: tariff.key,
tariffDraft: draftFromTariff(tariff),
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorOpen: true,
}));
}
async function saveTariffDraft() {
let s;
state.update((st) => {
s = st;
return st;
});
const tariff = tariffFromDraft(s.tariffDraft);
if (!tariff.key) {
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
return;
}
const existing = (s.tariffsCatalog.tariffs || []).find(
(item) => item.key === tariff.key && item.key !== s.tariffEditingKey
);
if (existing) {
flash(at("tariff_error_key_exists", {}, "Тариф с таким ключом уже есть"));
return;
}
const current = s.tariffsCatalog.tariffs || [];
const tariffs = s.tariffEditingKey
? current.map((item) => (item.key === s.tariffEditingKey ? tariff : item))
: [...current, tariff];
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(at("tariff_error_min_enabled", {}, "Должен быть хотя бы один включённый тариф"));
return;
}
const currentDefault =
s.tariffsCatalog.default_tariff === s.tariffEditingKey
? tariff.key
: s.tariffsCatalog.default_tariff;
const defaultTariff = enabledKeys.includes(currentDefault) ? currentDefault : enabledKeys[0];
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
at("tariff_saved", {}, "Тариф сохранён")
);
}
async function toggleTariffEnabled(tariff) {
let s;
state.update((st) => {
s = st;
return st;
});
const tariffs = (s.tariffsCatalog.tariffs || []).map((item) =>
item.key === tariff.key ? { ...item, enabled: item.enabled === false } : item
);
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(at("tariff_error_min_enabled", {}, "Должен остаться хотя бы один включённый тариф"));
return;
}
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff)
? s.tariffsCatalog.default_tariff
: enabledKeys[0];
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
at("tariff_status_updated", {}, "Статус тарифа обновлён")
);
}
async function setDefaultTariff(key) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!key || key === s.tariffsCatalog.default_tariff) return;
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: key },
at("tariff_default_updated", {}, "Тариф по умолчанию обновлён")
);
}
async function deleteTariff() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.tariffDeleteTarget) return;
const tariffs = (s.tariffsCatalog.tariffs || []).filter(
(item) => item.key !== s.tariffDeleteTarget.key
);
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(
at("tariff_error_delete_last_enabled", {}, "Нельзя удалить последний включённый тариф")
);
return;
}
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff)
? s.tariffsCatalog.default_tariff
: enabledKeys[0];
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
at("tariff_deleted", {}, "Тариф удалён")
);
}
function addDraftRow(field, row) {
state.update((s) => ({
...s,
tariffDraft: { ...s.tariffDraft, [field]: [...(s.tariffDraft[field] || []), row] },
}));
}
function removeDraftRow(field, index) {
state.update((s) => ({
...s,
tariffDraft: {
...s.tariffDraft,
[field]: (s.tariffDraft[field] || []).filter((_, idx) => idx !== index),
},
}));
}
function updateState(updates) {
state.update((s) => ({ ...s, ...updates }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
updateState,
loadTariffs,
loadPanelSquads,
squadLabel,
addSquadToDraft,
removeSquadFromDraft,
openCreateTariff,
openEditTariff,
saveTariffDraft,
toggleTariffEnabled,
setDefaultTariff,
deleteTariff,
addDraftRow,
removeDraftRow,
};
}
@@ -0,0 +1,280 @@
function cloneCatalog(catalog) {
return JSON.parse(JSON.stringify(catalog || { default_theme: "dark", themes: [] }));
}
import { writable } from "svelte/store";
export function createThemesStore({ api, onThemesSaved, flash, at }) {
const state = writable({
themesCatalog: { default_theme: "dark", themes: [] },
themesDir: "",
themesLoading: false,
themesSaving: false,
});
async function loadThemes() {
state.update((s) => ({ ...s, themesLoading: true }));
try {
const data = await api("/admin/themes");
if (data?.ok) {
state.update((s) => ({
...s,
themesCatalog: cloneCatalog(data.catalog),
themesDir: data.themes_dir || "",
}));
} else {
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить темы"));
}
} finally {
state.update((s) => ({ ...s, themesLoading: false }));
}
}
async function saveThemes(options = {}) {
const silent = Boolean(options.silent);
let catalog = null;
state.update((s) => {
catalog = cloneCatalog(s.themesCatalog);
return { ...s, themesSaving: true };
});
try {
const data = await api("/admin/themes", {
method: "PUT",
body: JSON.stringify({ catalog }),
});
if (data?.ok) {
state.update((s) => ({
...s,
themesCatalog: cloneCatalog(data.catalog),
themesDir: data.themes_dir || s.themesDir,
}));
if (!silent) flash(at("themes_saved", {}, "Темы сохранены"));
if (typeof onThemesSaved === "function") onThemesSaved();
} else {
flash(data?.message || data?.error || at("themes_save_failed", {}, "Не удалось сохранить"));
}
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
async function uploadLogoFile(file) {
if (!file) return null;
state.update((s) => ({ ...s, themesSaving: true }));
try {
const body = new FormData();
body.append("file", file);
const data = await api("/admin/appearance/logo", {
method: "POST",
body,
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
data?.message ||
data?.error ||
at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип")
);
return null;
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
async function uploadLogoUrl(url) {
const sourceUrl = String(url || "").trim();
if (!sourceUrl) return null;
state.update((s) => ({ ...s, themesSaving: true }));
try {
const data = await api("/admin/appearance/logo", {
method: "POST",
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
data?.message ||
data?.error ||
at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип")
);
return null;
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
async function uploadFaviconFile(file) {
if (!file) return null;
state.update((s) => ({ ...s, themesSaving: true }));
try {
const body = new FormData();
body.append("file", file);
const data = await api("/admin/appearance/favicon", {
method: "POST",
body,
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
data?.message ||
data?.error ||
at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon")
);
return null;
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
async function uploadFaviconUrl(url) {
const sourceUrl = String(url || "").trim();
if (!sourceUrl) return null;
state.update((s) => ({ ...s, themesSaving: true }));
try {
const data = await api("/admin/appearance/favicon", {
method: "POST",
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
data?.message ||
data?.error ||
at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon")
);
return null;
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
function setCurrentTheme(key) {
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
default_theme: key,
themes: (s.themesCatalog.themes || []).map((theme) => ({
...theme,
default: theme.key === key,
})),
},
}));
}
function togglePrimaryAccent(key, enabled) {
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
themes: (s.themesCatalog.themes || []).map((theme) =>
theme.key === key ? { ...theme, use_primary_accent: Boolean(enabled) } : theme
),
},
}));
}
function toggleAdminUse(key, enabled) {
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
themes: (s.themesCatalog.themes || []).map((theme) =>
theme.key === key ? { ...theme, use_in_admin: Boolean(enabled) } : theme
),
},
}));
}
function setThemeAccent(key, accent) {
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
themes: (s.themesCatalog.themes || []).map((theme) =>
theme.key === key
? {
...theme,
tokens: {
...(theme.tokens || {}),
accent: String(accent || "").trim() || null,
},
}
: theme
),
},
}));
}
function setThemeHomeLogoScale(key, scale) {
if (String(scale ?? "").trim() === "") scale = 100;
const numeric = Number(scale);
const nextScale = Number.isFinite(numeric)
? Math.min(300, Math.max(50, Math.round(numeric)))
: 100;
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
themes: (s.themesCatalog.themes || []).map((theme) =>
theme.key === key
? {
...theme,
tokens: {
...(theme.tokens || {}),
home_logo_scale: nextScale === 100 ? null : nextScale,
},
}
: theme
),
},
}));
}
return {
subscribe: state.subscribe,
loadThemes,
saveThemes,
setCurrentTheme,
setThemeAccent,
setThemeHomeLogoScale,
togglePrimaryAccent,
toggleAdminUse,
uploadLogoFile,
uploadLogoUrl,
uploadFaviconFile,
uploadFaviconUrl,
};
}
+522
View File
@@ -0,0 +1,522 @@
import { writable } from "svelte/store";
export function createUsersStore({ api, onToast, at }) {
const USERS_PAGE_SIZE = 25;
const USER_LOGS_PAGE_SIZE = 20;
const state = writable({
users: [],
usersTotal: 0,
usersPage: 0,
usersQuery: "",
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
usersSort: "registered_desc",
usersLoading: false,
openedUser: null,
openedUserDetail: null,
userDetailLoading: false,
userMessageDraft: "",
userExtendDays: 30,
userActionBusy: false,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userDetailTab: "profile",
premiumUnlimitedDraft: false,
premiumBonusGbDraft: "",
regularUnlimitedDraft: false,
regularBonusGbDraft: "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
userLogsPageSize: USER_LOGS_PAGE_SIZE,
});
let _activeRef = "stats"; // fallback if active isn't tracked
function setActive(active) {
_activeRef = active;
}
function _pushUserPath(userId) {
if (typeof window === "undefined") return;
if (window.location.protocol === "file:") return;
if (_activeRef !== "users") return;
const target = userId ? `/admin/users/${userId}` : `/admin/users`;
if (window.location.pathname === target) return;
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
}
async function loadUsers() {
state.update((s) => ({ ...s, usersLoading: true }));
let s;
state.update((st) => {
s = st;
return st;
});
try {
const params = new URLSearchParams({
page: String(s.usersPage),
page_size: String(USERS_PAGE_SIZE),
});
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter);
if (s.usersPanelStatus && s.usersPanelStatus !== "all")
params.set("panel_status", s.usersPanelStatus);
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
params.set("premium_traffic", s.usersPremiumTraffic);
}
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
const data = await api(`/admin/users?${params.toString()}`);
if (data?.ok) {
state.update((st) => ({
...st,
users: data.users || [],
usersTotal: data.total || (data.users || []).length,
}));
}
} finally {
state.update((st) => ({ ...st, usersLoading: false }));
}
}
async function openUser(userOrId, opts = {}) {
const userId =
typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
if (!userId) return;
state.update((s) => ({
...s,
openedUser:
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId },
openedUserDetail: null,
userMessageDraft: "",
userMessageConfirmOpen: false,
userExtendDays: 30,
userDetailLoading: true,
userDetailTab: "subscription",
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: userId,
}));
if (!opts.skipPush) _pushUserPath(userId);
try {
const res = await api(`/admin/users/${userId}`);
if (res?.ok) {
const sub = res.active_subscription || null;
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
state.update((s) => ({
...s,
openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "",
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
regularBonusGbDraft:
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
}));
} else {
onToast(res?.error || "load_failed");
state.update((s) => ({ ...s, openedUser: null }));
if (!opts.skipPush) _pushUserPath(null);
}
} finally {
state.update((s) => ({ ...s, userDetailLoading: false }));
}
}
function closeUser(opts = {}) {
let wasOpen = false;
state.update((s) => {
wasOpen = Boolean(s.openedUser);
return {
...s,
openedUser: null,
openedUserDetail: null,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
};
});
if (wasOpen && !opts.skipPush) _pushUserPath(null);
}
async function loadUserLogs(page) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const userId = s.openedUser.user_id;
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : s.userLogsPage || 0;
state.update((st) => ({
...st,
userLogsLoading: true,
userLogsPage: targetPage,
userLogsUserId: userId,
}));
try {
const params = new URLSearchParams({
page: String(targetPage),
page_size: String(USER_LOGS_PAGE_SIZE),
user_id: String(userId),
});
const data = await api(`/admin/logs?${params.toString()}`);
if (data?.ok) {
state.update((st) => {
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
return {
...st,
userLogs: data.logs || [],
userLogsTotal: Number(data.total || 0),
userLogsLoaded: true,
};
});
} else if (data?.error) {
onToast(data.error);
}
} finally {
state.update((st) => ({ ...st, userLogsLoading: false }));
}
}
function setUserLogsPage(page) {
loadUserLogs(page);
}
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
if (!text) return;
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(
() => onToast(successMessage),
() => onToast(text)
);
} else {
onToast(text);
}
}
function requestBanToggle() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
if (s.openedUser.is_banned) {
applyBanToggle(false);
} else {
state.update((st) => ({ ...st, userBanConfirmOpen: true }));
}
}
async function applyBanToggle(banned) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/ban`, {
method: "POST",
body: JSON.stringify({ banned }),
});
if (res?.ok) {
state.update((st) => {
const updatedUser = { ...st.openedUser, is_banned: banned };
return {
...st,
openedUser: updatedUser,
users: st.users.map((u) => (u.user_id === updatedUser.user_id ? updatedUser : u)),
userBanConfirmOpen: false,
};
});
onToast(
banned ? at("user_banned", {}, "Заблокирован") : at("user_unbanned", {}, "Разблокирован")
);
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function sendUserMessage() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message`, {
method: "POST",
body: JSON.stringify({ text: s.userMessageDraft }),
});
if (res?.ok) {
onToast(at("message_sent", {}, "Отправлено"));
state.update((st) => ({
...st,
userMessageDraft: "",
userMessageConfirmOpen: false,
}));
} else onToast(res?.error || at("message_send_failed", {}, "Ошибка отправки"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
function requestSendUserMessage() {
state.update((s) => {
if (!s.openedUser || !s.userMessageDraft.trim()) return s;
return { ...s, userMessageConfirmOpen: true };
});
}
async function previewUserMessage() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message/preview`, {
method: "POST",
body: JSON.stringify({ text: s.userMessageDraft }),
});
if (res?.ok) onToast(at("message_preview_sent", {}, "Превью отправлено в Telegram"));
else onToast(res?.error || at("message_preview_failed", {}, "Ошибка отправки превью"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function extendUser() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const days = Number(s.userExtendDays);
if (!days || days <= 0) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, {
method: "POST",
body: JSON.stringify({ days }),
});
if (res?.ok) {
onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`));
await openUser(s.openedUser, { skipPush: true });
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function resetTrialUser() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен"));
else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function savePremiumTrafficOverride() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const bonusGbRaw = s.premiumBonusGbDraft;
const bonusGb =
bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
? 0
: Number(bonusGbRaw);
if (Number.isNaN(bonusGb) || bonusGb < 0) {
onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.premiumUnlimitedDraft),
bonus_gb: bonusGb,
}),
});
if (res?.ok) {
onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function saveRegularTrafficOverride() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const regGbRaw = s.regularBonusGbDraft;
const regularGb =
regGbRaw === "" || regGbRaw === null || regGbRaw === undefined ? 0 : Number(regGbRaw);
if (Number.isNaN(regularGb) || regularGb < 0) {
onToast(
at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика")
);
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.regularUnlimitedDraft),
regular_bonus_gb: regularGb,
}),
});
if (res?.ok) {
onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function grantTraffic() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const gbRaw = s.grantTrafficGbDraft;
const gb = Number(gbRaw);
if (!gbRaw || Number.isNaN(gb) || gb <= 0) {
onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB"));
return;
}
const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
method: "POST",
body: JSON.stringify({ kind, gb }),
});
if (res?.ok) {
onToast(
kind === "premium"
? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
: at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
);
state.update((st) => ({ ...st, grantTrafficGbDraft: "" }));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function deleteUser() {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}`, { method: "DELETE" });
if (res?.ok) {
onToast(at("user_deleted", {}, "Удален"));
state.update((st) => ({
...st,
users: st.users.filter((u) => u.user_id !== st.openedUser.user_id),
}));
closeUser();
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
function updateState(updates) {
state.update((s) => ({ ...s, ...updates }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
updateState,
setActive,
loadUsers,
openUser,
closeUser,
copyToClipboard,
requestBanToggle,
applyBanToggle,
sendUserMessage,
requestSendUserMessage,
previewUserMessage,
extendUser,
resetTrialUser,
deleteUser,
savePremiumTrafficOverride,
saveRegularTrafficOverride,
grantTraffic,
loadUserLogs,
setUserLogsPage,
};
}
+205
View File
@@ -0,0 +1,205 @@
import { structuredCloneSafe } from "./format.js";
export function emptyTariffDraft() {
return {
key: "",
nameRu: "",
nameEn: "",
descriptionRu: "",
descriptionEn: "",
premiumNameRu: "",
premiumNameEn: "",
squadUuids: [],
premiumSquadUuids: [],
billing_model: "period",
enabled: true,
monthly_gb: 500,
premium_monthly_gb: "",
hwid_device_limit: "",
conversion_rate_rub_per_gb: "",
periodRows: [
{ months: 1, rub: 150, stars: "" },
{ months: 3, rub: 400, stars: "" },
{ months: 6, rub: 750, stars: "" },
{ months: 12, rub: 1400, stars: "" },
],
topupRubRows: [],
topupStarsRows: [],
premiumTopupRubRows: [],
premiumTopupStarsRows: [],
trafficRubRows: [
{ gb: 10, price: 199 },
{ gb: 50, price: 799 },
],
trafficStarsRows: [],
hwidRubRows: [],
hwidStarsRows: [],
};
}
export function cloneCatalog(catalog) {
return structuredCloneSafe({
default_tariff: catalog?.default_tariff || "",
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
tariffs: catalog?.tariffs || [],
});
}
export function rowsFromPackages(packageSet, currency, valueKey) {
return (packageSet?.[currency] || []).map((pkg) => ({
[valueKey]: pkg[valueKey],
price: pkg.price,
}));
}
export function draftFromTariff(tariff) {
const months = new Set([
...(tariff.enabled_periods || []),
...Object.keys(tariff.prices_rub || {}).map(Number),
...Object.keys(tariff.prices_stars || {}).map(Number),
]);
const periodRows = [...months]
.filter((month) => Number.isFinite(month) && month > 0)
.sort((a, b) => a - b)
.map((month) => ({
months: month,
rub: tariff.prices_rub?.[String(month)] ?? "",
stars: tariff.prices_stars?.[String(month)] ?? "",
}));
return {
...emptyTariffDraft(),
key: tariff.key || "",
nameRu: tariff.names?.ru || "",
nameEn: tariff.names?.en || "",
descriptionRu: tariff.descriptions?.ru || "",
descriptionEn: tariff.descriptions?.en || "",
premiumNameRu: tariff.premium_names?.ru || "",
premiumNameEn: tariff.premium_names?.en || "",
squadUuids: tariff.squad_uuids || [],
premiumSquadUuids: tariff.premium_squad_uuids || [],
billing_model: tariff.billing_model || "period",
enabled: tariff.enabled !== false,
monthly_gb: tariff.monthly_gb ?? "",
premium_monthly_gb: tariff.premium_monthly_gb ?? "",
hwid_device_limit: tariff.hwid_device_limit ?? "",
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"),
topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"),
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"),
premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"),
trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"),
trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"),
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"),
hwidStarsRows: rowsFromPackages(tariff.hwid_device_packages, "stars", "count"),
};
}
export function parseNumber(value, fallback = null) {
if (value === "" || value === null || value === undefined) return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
export function parseIntNumber(value, fallback = null) {
const num = parseNumber(value, fallback);
return num === null ? fallback : Math.trunc(num);
}
export function compactMap(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([, value]) => value !== "" && value !== null && value !== undefined)
);
}
export function packagesFromRows(rows, valueKey) {
return (rows || [])
.map((row) => ({
[valueKey]: parseNumber(row[valueKey]),
price: parseNumber(row.price),
}))
.filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
}
export function packageSetFromRows(rubRows, starsRows, valueKey) {
const rub = packagesFromRows(rubRows, valueKey);
const stars = packagesFromRows(starsRows, valueKey);
return rub.length || stars.length ? { rub, stars } : null;
}
export function normalizeUuidList(value) {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
return String(value || "")
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function tariffFromDraft(draft) {
const key = draft.key.trim();
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
const descriptions = compactMap({
ru: draft.descriptionRu.trim(),
en: draft.descriptionEn.trim(),
});
const premiumNames = compactMap({
ru: draft.premiumNameRu.trim(),
en: draft.premiumNameEn.trim(),
});
const tariff = {
key,
names,
descriptions,
premium_names: premiumNames,
squad_uuids: normalizeUuidList(draft.squadUuids),
premium_squad_uuids: normalizeUuidList(draft.premiumSquadUuids),
billing_model: draft.billing_model,
enabled: Boolean(draft.enabled),
};
const hwidLimit = parseIntNumber(draft.hwid_device_limit);
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
const hwidPackages = packageSetFromRows(draft.hwidRubRows, draft.hwidStarsRows, "count");
if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
const premiumTopupPackages = packageSetFromRows(
draft.premiumTopupRubRows,
draft.premiumTopupStarsRows,
"gb"
);
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
if (tariff.billing_model === "period") {
const seenMonths = new Set();
const rows = (draft.periodRows || [])
.map((row) => ({
months: parseIntNumber(row.months),
rub: parseNumber(row.rub, 0),
stars: parseNumber(row.stars, 0),
}))
.filter((row) => row.months > 0)
.filter((row) => {
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);
tariff.prices_rub = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
tariff.prices_stars = Object.fromEntries(
rows.map((row) => [String(row.months), row.stars || 0])
);
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
if (topupPackages) tariff.topup_packages = topupPackages;
} else {
const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb");
if (trafficPackages) tariff.traffic_packages = trafficPackages;
const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
}
return tariff;
}
+60
View File
@@ -0,0 +1,60 @@
export function userDisplayName(user) {
const full = [user?.first_name, user?.last_name].filter(Boolean).join(" ").trim();
return (
full || (user?.username ? `@${user.username}` : user?.email || `User #${user?.user_id || "—"}`)
);
}
export function userSecondaryName(user) {
if (user?.username && userDisplayName(user) !== `@${user.username}`) return `@${user.username}`;
if (user?.email && userDisplayName(user) !== user.email) return user.email;
return `ID ${user?.user_id || "—"}`;
}
export function userInitials(user) {
const source = userDisplayName(user).replace(/^@/, "").trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (source.slice(0, 2) || "U").toUpperCase();
}
export function userAvatarUrl(user) {
const cached = String(user?.avatar_url || "").trim();
if (cached) return cached;
const value = String(user?.telegram_photo_url || "").trim();
return value && !value.startsWith("/api/account/avatar") ? value : "";
}
export function createGravatarCache(onResolved = () => {}) {
const cache = new Map();
const pending = new Map();
async function sha256Hex(value) {
const buf = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function gravatarUrl(email) {
const key = String(email || "")
.trim()
.toLowerCase();
if (!key) return "";
if (cache.has(key)) return cache.get(key);
if (pending.has(key)) return "";
pending.set(
key,
sha256Hex(key)
.then((h) => {
cache.set(key, `https://gravatar.com/avatar/${h}?d=identicon&s=80`);
onResolved();
})
.catch(() => pending.delete(key))
);
return "";
}
return { gravatarUrl };
}