refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user