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 };
}
@@ -0,0 +1,21 @@
<script>
import { cn } from "$lib/utils.js";
export let variant = "muted";
let className = "";
export { className as class };
</script>
<span
class={cn(
"admin-badge",
variant === "success" && "admin-badge-success",
variant === "danger" && "admin-badge-danger",
variant === "warning" && "admin-badge-warning",
variant === "muted" && "admin-badge-muted",
className
)}
{...$$restProps}
>
<slot />
</span>
@@ -0,0 +1,45 @@
<script>
import { cva } from "class-variance-authority";
import { cn } from "$lib/utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let onclick = undefined;
let className = "";
export { className as class };
const buttonVariants = cva("admin-btn", {
variants: {
variant: {
default: "",
primary: "admin-btn-primary",
ghost: "admin-btn-ghost",
danger: "admin-btn-danger",
dangerSoft: "admin-btn-danger-soft",
icon: "admin-btn-icon",
},
size: {
default: "",
sm: "admin-btn-sm",
icon: "admin-btn-icon",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
</script>
<button
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{onclick}
{...$$restProps}
>
<slot />
</button>
@@ -0,0 +1,14 @@
<script>
import { cn } from "$lib/utils.js";
export let columns = 1;
let className = "";
export { className as class };
</script>
<div
class={cn("admin-cn-dashboard-grid", columns === 3 && "admin-cn-dashboard-grid--3", className)}
{...$$restProps}
>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div class={cn("admin-cn-dashboard-stack", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,11 @@
<script>
import { cn } from "$lib/utils.js";
export let tone = "default";
let className = "";
export { className as class };
</script>
<div class={cn(tone === "card" ? "admin-card-body" : "admin-empty", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,16 @@
<script>
import { Label } from "$components/ui/primitives.js";
export let label = "";
export let hint = "";
</script>
<Label.Root class="admin-field-label">
{#if label}
<span>{label}</span>
{/if}
{#if hint}
<small>{hint}</small>
{/if}
<slot />
</Label.Root>
@@ -0,0 +1,26 @@
<script>
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import AdminButton from "./AdminButton.svelte";
export let meta = "";
export let prevLabel = "Back";
export let nextLabel = "Next";
export let prevDisabled = false;
export let nextDisabled = false;
export let onPrev = () => {};
export let onNext = () => {};
</script>
<div class="admin-pagination">
<span class="admin-pagination-meta">{meta}</span>
<div class="admin-pagination-buttons">
<AdminButton size="sm" disabled={prevDisabled} onclick={onPrev}>
<ChevronLeft size={14} />
{prevLabel}
</AdminButton>
<AdminButton size="sm" disabled={nextDisabled} onclick={onNext}>
{nextLabel}
<ChevronRight size={14} />
</AdminButton>
</div>
</div>
@@ -0,0 +1,190 @@
<script>
import { onMount, tick } from "svelte";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
/** `{ date: ISO date string, amount: number }[]` */
export let series = [];
/** Total plot height in CSS px (axes + canvas). */
export let plotHeight = 204;
export let fmtMoney = (v, _currency) => String(v);
/** @type {string} */
export let currency = "RUB";
/** uPlot live legend: column header for the time (x) series */
export let legendTimeLabel = "Time";
/** uPlot live legend: column header for the value (y) series */
export let legendValueLabel = "Value";
let hostEl;
let plot;
let resizeObserver;
let syncTimer = 0;
/** Rebuild plot when legend copy changes (language), since series labels are init-only */
let builtLegendSig = "";
function readCssColor(name, fallback) {
if (typeof document === "undefined") return fallback;
const scope = hostEl || document.documentElement;
const raw = getComputedStyle(scope).getPropertyValue(name).trim();
return raw || fallback;
}
function parseDayUnix(iso) {
const s = String(iso || "");
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
if (!Number.isFinite(t)) return 0;
return Math.floor(t / 1000);
}
function toAlignedData(rows) {
if (!rows?.length) return null;
const xs = rows.map((p) => parseDayUnix(p.date));
const ys = rows.map((p) => Number(p.amount) || 0);
return [xs, ys];
}
function yAxisTickLabels(values) {
return values.map((v) => fmtMoney(Number(v), currency));
}
/** uPlot passes already-formatted tick strings; reserve enough gutter so amounts are not clipped */
function yAxisGutterWidth(_u, values) {
const pad = 14;
const charPx = 6.1;
const maxChars = (values || []).reduce((m, v) => Math.max(m, String(v ?? "").length), 0);
return Math.min(104, Math.max(58, Math.ceil(pad + maxChars * charPx)));
}
/** Axis `size`: height (x / bottom) or width (y / left) in CSS px — only customize the y gutter */
function axisBandSize(_u, values, axisIdx) {
if (axisIdx !== 1) return 32;
return yAxisGutterWidth(_u, values);
}
function buildOpts(width) {
const w = Math.max(80, Math.floor(width));
const muted = readCssColor("--admin-muted", "#9aa7a2");
const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)");
const accent = readCssColor("--accent", "#00fe7a");
const lineStroke = readCssColor(
"--admin-chart-stroke",
readCssColor("--admin-text", "#e8f0ec"),
);
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
return {
width: w,
height: plotHeight,
class: "admin-uplot",
pxAlign: true,
padding: [10, 12, 12, 10],
legend: {
show: true,
live: true,
markers: { show: true, width: 10, stroke: accent, fill: accent },
},
cursor: {
drag: { x: false, y: false },
points: { size: 7, width: 1, stroke: accent },
},
scales: {
x: { time: true },
y: { range: [0, null] },
},
series: [
{ label: legendTimeLabel },
{
label: legendValueLabel,
paths: uPlot.paths.spline(),
stroke: lineStroke,
width: 2,
cap: "round",
fill: lineFill,
},
],
axes: [
{
stroke: muted,
gap: 8,
grid: { show: true, stroke: border, width: 1 },
ticks: { stroke: border },
font: "11px system-ui,Segoe UI,sans-serif",
},
{
stroke: muted,
size: axisBandSize,
gap: 8,
grid: { show: true, stroke: border, width: 1 },
ticks: { stroke: border },
font: "10px system-ui,Segoe UI,sans-serif",
values: (u, ticks) => yAxisTickLabels(ticks),
},
],
};
}
function syncChart() {
if (!hostEl) return;
const d = toAlignedData(series);
const legendSig = `${legendTimeLabel}\0${legendValueLabel}`;
if (!d) {
plot?.destroy();
plot = undefined;
builtLegendSig = "";
return;
}
const w = Math.max(80, Math.floor(hostEl.clientWidth));
if (plot && builtLegendSig !== legendSig) {
plot.destroy();
plot = undefined;
}
if (!plot) {
plot = new uPlot(buildOpts(w), d, hostEl);
builtLegendSig = legendSig;
return;
}
plot.setData(d, true);
plot.setSize({ width: w, height: plotHeight });
}
function scheduleSync() {
if (typeof window === "undefined") return;
clearTimeout(syncTimer);
syncTimer = window.setTimeout(() => {
syncTimer = 0;
syncChart();
}, 0);
}
let rafId = 0;
onMount(() => {
rafId = requestAnimationFrame(() => {
void tick().then(() => {
scheduleSync();
if (!hostEl || typeof ResizeObserver === "undefined") return;
resizeObserver = new ResizeObserver(() => scheduleSync());
resizeObserver.observe(hostEl);
});
});
return () => {
cancelAnimationFrame(rafId);
clearTimeout(syncTimer);
resizeObserver?.disconnect();
resizeObserver = undefined;
plot?.destroy();
plot = undefined;
builtLegendSig = "";
};
});
$: if (hostEl) {
series;
plotHeight;
legendTimeLabel;
legendValueLabel;
scheduleSync();
}
</script>
<div class="admin-revenue-uplot-host" bind:this={hostEl}></div>
@@ -0,0 +1,145 @@
<script>
import { Popover, RangeCalendar } from "bits-ui";
import { parseDate } from "@internationalized/date";
import Button from "$components/ui/button.svelte";
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import { cn } from "$lib/utils.js";
let {
open = $bindable(false),
minIso = "",
maxIso = "",
committedFrom = "",
committedTo = "",
title = "",
applyLabel = "",
triggerLabel = "",
isActive = false,
onApply = () => {},
} = $props();
let value = $state({ start: undefined, end: undefined });
let prevOpen = $state(false);
function seedFromBounds() {
if (!minIso || !maxIso) return;
const minV = parseDate(minIso);
const maxV = parseDate(maxIso);
if (
committedFrom &&
committedTo &&
committedFrom >= minIso &&
committedTo <= maxIso &&
committedFrom <= committedTo
) {
value = { start: parseDate(committedFrom), end: parseDate(committedTo) };
return;
}
let start = maxV.subtract({ days: 29 });
if (start.compare(minV) < 0) start = minV;
value = { start, end: maxV };
}
$effect(() => {
if (open && !prevOpen) seedFromBounds();
prevOpen = open;
});
function calendarDateToIso(d) {
if (!d || typeof d !== "object") return "";
const y = d.year;
const m = String(d.month).padStart(2, "0");
const day = String(d.day).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function handleApply() {
const fromIso = calendarDateToIso(value?.start);
const toIso = calendarDateToIso(value?.end);
if (!fromIso || !toIso || fromIso > toIso) return;
onApply({ fromIso, toIso });
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
type="button"
class={cn("admin-revenue-period-btn", isActive && "is-active")}
disabled={!minIso || !maxIso}
aria-pressed={isActive}
>
{triggerLabel}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
class="admin-revenue-range-popover"
side="bottom"
align="end"
sideOffset={8}
trapFocus={true}
>
{#if title}
<div class="admin-revenue-range-popover__title">{title}</div>
{/if}
{#if minIso && maxIso}
<RangeCalendar.Root
class="admin-revenue-rcal"
bind:value
minValue={parseDate(minIso)}
maxValue={parseDate(maxIso)}
weekdayFormat="short"
fixedWeeks={true}
weekStartsOn={1}
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="admin-revenue-rcal__header">
<RangeCalendar.PrevButton class="admin-revenue-rcal__nav">
<ChevronLeft />
</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="admin-revenue-rcal__heading" />
<RangeCalendar.NextButton class="admin-revenue-rcal__nav">
<ChevronRight />
</RangeCalendar.NextButton>
</RangeCalendar.Header>
<div class="admin-revenue-rcal__grids">
{#each months as month (month.value.month)}
<RangeCalendar.Grid class="admin-revenue-rcal__grid">
<RangeCalendar.GridHead>
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
{#each weekdays as wd (wd)}
<RangeCalendar.HeadCell class="admin-revenue-rcal__headcell">
{wd.slice(0, 2)}
</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as weekDates, wi (wi)}
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
{#each weekDates as cellDate, di (`${wi}-${di}-${cellDate.toString()}`)}
<RangeCalendar.Cell
date={cellDate}
month={month.value}
class="admin-revenue-rcal__cell"
>
<RangeCalendar.Day class="admin-revenue-rcal__day">
{cellDate.day}
</RangeCalendar.Day>
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
</div>
{/snippet}
</RangeCalendar.Root>
{/if}
<div class="admin-revenue-range-popover__actions">
<Button variant="default" size="sm" onclick={handleApply}>{applyLabel}</Button>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
@@ -0,0 +1,11 @@
<script>
export let title = "";
export let description = "";
</script>
<div class="admin-dashboard-section-head">
<h3>{title}</h3>
{#if description}
<small>{description}</small>
{/if}
</div>
@@ -0,0 +1,41 @@
<script>
import { Check, ChevronDown } from "$components/ui/icons.js";
import { Select } from "$components/ui/primitives.js";
export let value = "";
export let items = [];
export let ariaLabel = "";
export let placeholder = "";
export let disabled = false;
export let sideOffset = 6;
export let onValueChange = () => {};
let className = "";
export { className as class };
$: selected = items.find((item) => item.value === value);
function handleValueChange(next) {
value = next;
onValueChange(next);
}
</script>
<Select.Root type="single" {value} {items} {disabled} onValueChange={handleValueChange}>
<Select.Trigger
class={`admin-select-trigger ${className}`.trim()}
aria-label={ariaLabel || placeholder}
>
<span>{selected?.label || placeholder}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}>
{#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item">
<span>{item.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
@@ -0,0 +1,17 @@
<script>
import { cn } from "$lib/utils.js";
export let skeleton = false;
let className = "";
export { className as class };
</script>
<div class="admin-table-wrap">
<table
class={cn("admin-table", skeleton && "admin-table-skeleton", className)}
aria-hidden={skeleton ? "true" : undefined}
{...$$restProps}
>
<slot />
</table>
</div>
@@ -0,0 +1,40 @@
<script>
import Skeleton from "$components/ui/skeleton.svelte";
import AdminTable from "./AdminTable.svelte";
export let headers = [];
export let rows = 6;
export let actionColumn = false;
export let widths = [];
function widthFor(index) {
if (widths[index]) return widths[index];
if (actionColumn && index === headers.length - 1) return "92px";
if (index === 0) return "48px";
if (index === headers.length - 1) return "76px";
return index % 3 === 0 ? "56%" : "72%";
}
</script>
<AdminTable skeleton>
<thead>
<tr>
{#each headers as header}
<th class:admin-cell-actions={actionColumn && header === headers[headers.length - 1]}
>{header}</th
>
{/each}
</tr>
</thead>
<tbody>
{#each Array(rows) as _, rowIndex (rowIndex)}
<tr>
{#each headers as _header, colIndex (`${rowIndex}-${colIndex}`)}
<td>
<Skeleton variant="line" width={widthFor(colIndex)} />
</td>
{/each}
</tr>
{/each}
</tbody>
</AdminTable>
@@ -0,0 +1,40 @@
<script>
import { cn } from "$lib/utils.js";
export let title = "";
export let value = "";
export let left = "";
export let percent = 0;
export let warning = false;
export let premium = false;
export let label = "";
$: clamped = Math.max(0, Math.min(100, Number(percent) || 0));
</script>
<div
class={cn(
"admin-traffic-card",
warning && "admin-traffic-card-warning",
premium && "admin-traffic-card-premium"
)}
>
<div class="admin-traffic-head">
<span>{title}</span>
<strong>{value}</strong>
</div>
<div
class={cn("admin-traffic-bar", premium && "admin-traffic-bar-premium")}
aria-label={label || title}
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(clamped)}
>
<span style={`width: ${clamped}%`}></span>
</div>
<div class="admin-traffic-meta">
<span>{left}</span>
<span>{clamped}%</span>
</div>
</div>
@@ -0,0 +1,14 @@
export { default as AdminBadge } from "./AdminBadge.svelte";
export { default as AdminButton } from "./AdminButton.svelte";
export { default as AdminDashboardGrid } from "./AdminDashboardGrid.svelte";
export { default as AdminDashboardStack } from "./AdminDashboardStack.svelte";
export { default as AdminEmptyState } from "./AdminEmptyState.svelte";
export { default as AdminField } from "./AdminField.svelte";
export { default as AdminPagination } from "./AdminPagination.svelte";
export { default as AdminRevenueChart } from "./AdminRevenueChart.svelte";
export { default as AdminRevenueCustomRangePopover } from "./AdminRevenueCustomRangePopover.svelte";
export { default as AdminSelect } from "./AdminSelect.svelte";
export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
export { default as AdminTable } from "./AdminTable.svelte";
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
@@ -0,0 +1,67 @@
<script>
import Skeleton from "$components/ui/skeleton.svelte";
export let label = "";
export let rows = 3;
export let actions = 0;
export let methods = 2;
export let showNote = false;
export let showPayButton = true;
export let showMeta = true;
</script>
<div class="dialog-skeleton" aria-label={label}>
{#if actions}
<div class="tariff-action-list">
{#each Array(actions) as _, index (index)}
<div class="tariff-action-card skeleton-row">
<span>
<Skeleton variant="title" />
<Skeleton variant="short" />
</span>
<Skeleton class="skeleton-line-price" />
</div>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
{/if}
<div class="option-list">
{#each Array(rows) as _, index (index)}
<div class={`option-row ${showMeta ? "plan-row" : "change-action-row"} skeleton-row`}>
<span class="option-row-main">
<Skeleton variant="title" />
<Skeleton variant="short" />
</span>
{#if showMeta}
<span class="option-row-meta">
<Skeleton class="skeleton-line-price" />
<Skeleton variant="tiny" />
</span>
{/if}
</div>
{/each}
</div>
{#if showNote}
<div class="topup-carryover-note skeleton-carryover-note">
<Skeleton variant="line" />
<Skeleton variant="short" />
</div>
{/if}
{#if methods}
<div class="method-grid">
{#each Array(methods) as _, index (index)}
<div class="method-card skeleton-method">
<Skeleton variant="dot" />
<Skeleton class="skeleton-line-method" />
</div>
{/each}
</div>
{/if}
{#if showPayButton}
<Skeleton class="skeleton-pay-button" />
{/if}
</div>
@@ -0,0 +1,11 @@
<script>
import Card from "$components/ui/card.svelte";
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<Card class={cn("empty-card", className)} {...$$restProps}>
<slot />
</Card>
@@ -0,0 +1,71 @@
<script>
import { Check, ChevronsUpDown, Globe2 } from "$components/ui/icons.js";
import { Select } from "$components/ui/primitives.js";
export let open = false;
export let value = "ru";
export let currentOption = null;
export let userLanguage = "";
export let options = [];
export let disabled = false;
export let clickGuard = false;
export let clickGuardArmed = false;
export let closeLabel = "Close";
export let label = "Language";
export let onOpenChange = () => {};
export let onValueChange = () => {};
function closeFromGuard(event) {
event.preventDefault();
event.stopPropagation();
if (clickGuardArmed) onOpenChange(false);
}
</script>
{#if open || clickGuard}
<button
class="language-select-guard"
class:language-select-guard--armed={clickGuardArmed}
type="button"
aria-label={closeLabel}
onpointerdown={closeFromGuard}
onclick={closeFromGuard}
></button>
{/if}
<div class="settings-row settings-row-language">
<Globe2 size={21} />
<Select.Root
type="single"
bind:open
{value}
items={options}
{disabled}
{onOpenChange}
{onValueChange}
>
<Select.Trigger class="language-select-trigger" aria-label={label}>
<span class="language-select-copy">
<strong>{label}</strong>
<small class="language-select-current">
<span class="emoji-flag" aria-hidden="true">{currentOption?.flag || "🏳️"}</span>
{currentOption?.label || userLanguage}
</small>
</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content class="language-select-content" side="bottom" align="end" sideOffset={6}>
<Select.Viewport class="language-select-viewport">
{#each options as option (option.value)}
<Select.Item value={option.value} label={option.label} class="language-select-item">
<span class="language-select-item-main">
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
<span>{option.label}</span>
</span>
<Check size={15} class="language-select-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</div>
@@ -0,0 +1,22 @@
<script>
import { cn } from "$lib/utils.js";
export let value = 0;
export let label = "";
let className = "";
export { className as class };
$: clamped = Math.max(0, Math.min(100, Number(value) || 0));
</script>
<div
class={cn("progress", className)}
role={label ? "progressbar" : undefined}
aria-label={label || undefined}
aria-valuemin={label ? "0" : undefined}
aria-valuemax={label ? "100" : undefined}
aria-valuenow={label ? Math.round(clamped) : undefined}
{...$$restProps}
>
<span style={`width: ${clamped}%`}></span>
</div>
@@ -0,0 +1,44 @@
<script>
import { Bitcoin, CreditCard } from "$components/ui/icons.js";
export let methods = [];
export let selectedMethod = "";
export let t = (key) => key;
export let onSelect = () => {};
function methodMeta(method) {
const id = String(method?.id || "").toLowerCase();
if (id.includes("platega_sbp"))
return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
if (id.includes("platega_crypto"))
return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
if (id.includes("yookassa") || id.includes("card"))
return { title: t("pay_with_yookassa_button"), icon: null };
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
if (id.includes("cryptopay") || id.includes("crypto"))
return { title: t("pay_with_cryptopay_button"), icon: null };
if (id.includes("stars")) return { title: t("pay_with_stars_button"), icon: null };
if (id.includes("sbp")) return { title: t("pay_with_sbp_button"), icon: null };
return { title: t("wa_method_other_title"), icon: null };
}
</script>
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => onSelect(method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
</div>
@@ -0,0 +1,11 @@
<script>
import { cn } from "$lib/utils.js";
export let error = false;
let className = "";
export { className as class };
</script>
<p class={cn("status-line", error && "error", className)} {...$$restProps}>
<slot />
</p>
@@ -0,0 +1,6 @@
export { default as DialogOptionsSkeleton } from "./DialogOptionsSkeleton.svelte";
export { default as EmptyCard } from "./EmptyCard.svelte";
export { default as LinearProgress } from "./LinearProgress.svelte";
export { default as LanguageSelect } from "./LanguageSelect.svelte";
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
export { default as StatusMessage } from "./StatusMessage.svelte";
@@ -0,0 +1,24 @@
<script>
import { cn } from "$lib/utils.js";
/** @type {'default' | 'outline' | 'destructive' | 'success' | 'muted'} */
export let variant = "default";
let className = "";
export { className as class };
</script>
<span
data-slot="badge"
class={cn(
"admin-cn-badge",
variant === "outline" && "admin-cn-badge-outline",
variant === "destructive" && "admin-cn-badge-destructive",
variant === "success" && "admin-cn-badge-success",
variant === "muted" && "admin-cn-badge-muted",
className
)}
{...$$restProps}
>
<slot />
</span>
@@ -0,0 +1,52 @@
<script>
import { cva } from "class-variance-authority";
import { cn } from "$lib/utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let href = "";
export let onclick = undefined;
let className = "";
export { className as class };
const buttonVariants = cva("btn", {
variants: {
variant: {
default: "btn-primary",
secondary: "btn-secondary",
outline: "btn-outline",
ghost: "btn-ghost",
telegram: "btn-telegram",
icon: "btn-icon",
},
size: {
default: "",
sm: "btn-sm",
lg: "btn-lg",
icon: "btn-square",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
</script>
{#if href}
<a class={cn(buttonVariants({ variant, size }), className)} {href} {onclick} {...$$restProps}>
<slot />
</a>
{:else}
<button
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{onclick}
{...$$restProps}
>
<slot />
</button>
{/if}
@@ -0,0 +1,12 @@
<script>
import { cn } from "$lib/utils.js";
export let active = false;
export let compact = false;
let className = "";
export { className as class };
</script>
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
<slot />
</section>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-action" class={cn("admin-cn-card-action", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-content" class={cn("admin-cn-card-content", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<p data-slot="card-description" class={cn("admin-cn-card-description", className)} {...$$restProps}>
<slot />
</p>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-footer" class={cn("admin-cn-card-footer", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-header" class={cn("admin-cn-card-header", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-title" class={cn("admin-cn-card-title", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card" class={cn("admin-cn-card", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,9 @@
import Root from "./card.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Description from "./card-description.svelte";
import Action from "./card-action.svelte";
import Footer from "./card-footer.svelte";
import Content from "./card-content.svelte";
export { Root, Header, Title, Description, Action, Footer, Content };
@@ -0,0 +1,64 @@
<script>
import { X } from "$components/ui/icons.js";
import { cn } from "$lib/utils.js";
import { cubicOut } from "svelte/easing";
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import Button from "./button.svelte";
export let open = false;
export let title = "";
export let description = "";
export let closeLabel = "Close";
export let onclose = () => {};
let className = "";
export { className as class };
function readReduceMotion() {
return (
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
let reduceMotion = readReduceMotion();
onMount(() => {
reduceMotion = readReduceMotion();
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = () => {
reduceMotion = mq.matches;
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
});
$: backdropTransition = reduceMotion ? { duration: 0 } : { duration: 200 };
$: cardIn = reduceMotion ? { duration: 0, y: 0 } : { duration: 260, y: 16, easing: cubicOut };
$: cardOut = reduceMotion ? { duration: 0, y: 0 } : { duration: 200, y: 10, easing: cubicOut };
</script>
{#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button
class="dialog-backdrop"
type="button"
aria-label={closeLabel}
onclick={onclose}
in:fade={backdropTransition}
out:fade={backdropTransition}
></button>
<section class={cn("dialog-card", className)} in:fly={cardIn} out:fly={cardOut}>
<div class="dialog-head">
<div>
{#if title}<h2>{title}</h2>{/if}
{#if description}<p>{description}</p>{/if}
</div>
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
<X size={18} />
</Button>
</div>
<slot />
</section>
</div>
{/if}
+67
View File
@@ -0,0 +1,67 @@
export {
Activity,
ArrowLeft,
ArrowRight,
Bitcoin,
CalendarDays,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Circle,
CircleX,
Coins,
Copy,
CreditCard,
Crown,
Database,
Download,
ExternalLink,
Eye,
EyeOff,
FileText,
Gift,
Globe2,
Home,
Info,
Key,
LayoutDashboard,
LockKeyhole,
Mail,
Map,
Megaphone,
Menu,
MessageSquare,
MousePointerClick,
Paintbrush,
Plus,
QrCode,
Radio,
RefreshCw,
Repeat2,
Save,
Send,
Server,
Settings,
Shield,
Sliders,
Smartphone,
Sparkles,
Tag,
Ticket,
Trash2,
TrendingDown,
TrendingUp,
TriangleAlert,
User,
UserMinus,
UserPlus,
UserRound,
Users,
UsersRound,
WalletCards,
X,
Zap,
} from "lucide-svelte";
+10
View File
@@ -0,0 +1,10 @@
export { default as Badge } from "./badge.svelte";
export { default as Button } from "./button.svelte";
export { default as Dialog } from "./dialog.svelte";
export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte";
export { default as Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte";
export * as Icons from "./icons.js";
export * as Card from "./card/index.js";
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
@@ -0,0 +1,29 @@
<script>
import { cn } from "$lib/utils.js";
export let value = "";
export let type = "text";
export let placeholder = "";
export let inputmode = undefined;
export let maxlength = undefined;
export let autocomplete = undefined;
export let disabled = false;
let className = "";
export { className as class };
</script>
<input
bind:value
class={cn("input", className)}
on:keydown
on:input
on:focus
on:blur
{type}
{placeholder}
{inputmode}
{maxlength}
{autocomplete}
{disabled}
{...$$restProps}
/>
@@ -0,0 +1 @@
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "bits-ui";
@@ -0,0 +1,25 @@
<script>
import { cn } from "$lib/utils.js";
export let variant = "block";
export let width = "";
export let height = "";
let className = "";
export { className as class };
</script>
<span
class={cn(
"ui-skeleton",
variant === "line" && "ui-skeleton-line",
variant === "title" && "ui-skeleton-line ui-skeleton-title",
variant === "short" && "ui-skeleton-line ui-skeleton-short",
variant === "tiny" && "ui-skeleton-line ui-skeleton-tiny",
variant === "badge" && "ui-skeleton-badge",
variant === "dot" && "ui-skeleton-dot",
className
)}
style={`${width ? `width:${width};` : ""}${height ? `height:${height};` : ""}`}
aria-hidden="true"
{...$$restProps}
></span>
@@ -0,0 +1,21 @@
<script>
import { cn } from "$lib/utils.js";
export let size = "default";
export let label = "";
let className = "";
export { className as class };
</script>
<span
class={cn(
"ui-spinner",
size === "sm" && "ui-spinner-sm",
size === "lg" && "ui-spinner-lg",
className
)}
role={label ? "status" : undefined}
aria-label={label || undefined}
aria-hidden={label ? undefined : "true"}
{...$$restProps}
></span>
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+370
View File
@@ -0,0 +1,370 @@
<script>
import { onDestroy, onMount } from "svelte";
import { cn } from "../utils.js";
import { animatedEmojiAssetUrls, normalizeBrand } from "./browser.js";
const LOGO_LOAD_TIMEOUT_MS = 10000;
const EMOJI_FONT_OPTIONS = {
"noto-color": {
cssFamily: "Noto Color Emoji",
stylesheet: (text) =>
`https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap&text=${encodeURIComponent(text)}`,
},
"noto-emoji": {
cssFamily: "Noto Emoji",
stylesheet: (text) =>
`https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@700&display=swap&text=${encodeURIComponent(text)}`,
},
twemoji: {
cssFamily: "Twemoji Mozilla",
stylesheet: () => "https://cdn.jsdelivr.net/npm/twemoji-colr-font@15.0.3/twemoji.css",
},
openmoji: {
cssFamily: "OpenMoji Color",
stylesheet: () => "https://cdn.jsdelivr.net/npm/@openmoji/font@15.1.0/css/openmoji-color.css",
},
};
export let brand = {};
export let logoUrl = "";
export let emoji = "";
export let emojiFont = "";
export let size = "sm";
export let animate = false;
export let fallbackEmoji = true;
let className = "";
export { className as class };
const SIZE_CLASSES = {
sm: "",
md: "brand-mark-lg",
lg: "brand-mark-xl",
xl: "brand-mark-xl",
};
let loaded = false;
let failed = false;
let lastLogoUrl = "";
let logoLoadTimer = null;
let logoLoadTimerUrl = "";
let fontLoaded = false;
let loadedFontKey = "";
let animatedEmojiError = false;
let animatedEmojiStaticFallback = false;
let lastAnimatedEmoji = "";
$: normalizedBrand = normalizeBrand({
...brand,
logoUrl: logoUrl || brand?.logoUrl,
emoji: emoji || brand?.emoji || brand?.logoEmoji,
emojiFont: emojiFont || brand?.emojiFont || brand?.logoEmojiFont,
});
$: normalizedLogoUrl = normalizedBrand.logoUrl;
$: normalizedEmoji = normalizedBrand.emoji;
$: normalizedEmojiFont = normalizedBrand.emojiFont;
$: sizeClass = SIZE_CLASSES[size] || "";
$: animatedEmojiAssets = animatedEmojiAssetUrls(normalizedEmoji);
$: animatedEmojiSrc = animatedEmojiAssets.gif;
$: animatedEmojiFallbackSrc = animatedEmojiAssets.webp;
$: useAnimatedEmoji =
!normalizedLogoUrl &&
normalizedEmojiFont === "noto-color-animated" &&
animatedEmojiSrc &&
!animatedEmojiError;
$: if (normalizedLogoUrl !== lastLogoUrl) {
lastLogoUrl = normalizedLogoUrl;
loaded = false;
failed = false;
}
$: if (normalizedLogoUrl && !loaded && !failed) armLogoLoadTimeout();
$: if (!normalizedLogoUrl || loaded || failed) clearLogoLoadTimeout();
$: if (`${normalizedEmojiFont}:${normalizedEmoji}` !== lastAnimatedEmoji) {
lastAnimatedEmoji = `${normalizedEmojiFont}:${normalizedEmoji}`;
animatedEmojiError = false;
animatedEmojiStaticFallback = false;
}
onDestroy(() => {
clearLogoLoadTimeout();
});
onMount(() => {
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
});
$: if (normalizedEmojiFont && normalizedEmoji) {
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
}
function loadEmojiFont(font, text) {
if (typeof document === "undefined") return;
if (font === "system" || font === "noto-color-animated" || !font) {
fontLoaded = true;
loadedFontKey = "system";
return;
}
const fontOption = EMOJI_FONT_OPTIONS[font];
if (!fontOption) {
fontLoaded = true;
loadedFontKey = font;
return;
}
const fontUrl = fontOption.stylesheet(text);
const fontKey = `${font}:${text}`;
if (loadedFontKey === fontKey) return;
fontLoaded = false;
loadedFontKey = fontKey;
const existing = document.querySelector(`link[data-brand-emoji-font="${fontKey}"]`);
if (existing) {
fontLoaded = true;
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = fontUrl;
link.dataset.brandEmojiFont = fontKey;
link.onload = () => {
fontLoaded = true;
if (document.fonts && fontOption.cssFamily) {
document.fonts.load(`1em "${fontOption.cssFamily}"`, text).finally(() => {
fontLoaded = true;
});
}
};
link.onerror = () => {
fontLoaded = true;
};
document.head.appendChild(link);
}
function getEmojiFontClass(font) {
if (font === "noto-color") return "emoji-font-noto-color";
if (font === "noto-emoji") return "emoji-font-noto-emoji";
if (font === "twemoji") return "emoji-font-twemoji";
if (font === "openmoji") return "emoji-font-openmoji";
if (font === "apple") return "emoji-font-apple";
if (font === "segoe") return "emoji-font-segoe";
if (font === "noto-local") return "emoji-font-noto-local";
return "";
}
function clearLogoLoadTimeout() {
if (logoLoadTimer) {
window.clearTimeout(logoLoadTimer);
logoLoadTimer = null;
}
logoLoadTimerUrl = "";
}
function armLogoLoadTimeout() {
if (typeof window === "undefined") return;
if (logoLoadTimer && logoLoadTimerUrl === normalizedLogoUrl) return;
clearLogoLoadTimeout();
logoLoadTimerUrl = normalizedLogoUrl;
logoLoadTimer = window.setTimeout(() => {
if (logoLoadTimerUrl === normalizedLogoUrl && !loaded) failed = true;
}, LOGO_LOAD_TIMEOUT_MS);
}
</script>
<div
class={cn(
"brand-mark",
sizeClass,
animate && "brand-mark-animate",
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
className
)}
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
>
{#if normalizedLogoUrl && !failed}
{#if !loaded}
<span class="brand-mark-spinner" aria-hidden="true"></span>
{/if}
<img
class:loaded
src={normalizedLogoUrl}
alt=""
loading="eager"
decoding="async"
fetchpriority="high"
on:load={() => {
loaded = true;
clearLogoLoadTimeout();
}}
on:error={() => {
failed = true;
clearLogoLoadTimeout();
}}
/>
{:else if fallbackEmoji && useAnimatedEmoji}
<img
class="brand-mark-animated-emoji loaded"
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
alt=""
loading="eager"
decoding="async"
fetchpriority="high"
on:error={() => {
if (!animatedEmojiStaticFallback && animatedEmojiFallbackSrc) {
animatedEmojiStaticFallback = true;
} else {
animatedEmojiError = true;
}
}}
/>
{:else if fallbackEmoji}
<span
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
>
{normalizedEmoji}
</span>
{/if}
</div>
<style>
.brand-mark {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
overflow: visible;
font-size: 1.625rem;
}
.brand-mark img {
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 0.2s ease;
}
.brand-mark img.loaded {
opacity: 1;
}
.brand-mark.brand-mark-lg {
width: 4.125rem;
height: 4.125rem;
font-size: 2.875rem;
}
.brand-mark.brand-mark-xl {
width: 6rem;
height: 6rem;
font-size: 4.375rem;
}
.brand-mark img.brand-mark-animated-emoji {
object-fit: contain;
transform: scale(1.08);
}
.brand-mark.brand-mark-animate {
animation: brand-mark-pulse 2s ease-in-out infinite;
}
.brand-mark-spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.brand-mark-spinner::after {
content: "";
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-bottom-color: transparent;
border-radius: 50%;
animation: brand-mark-spin 0.8s linear infinite;
opacity: 0.5;
}
@keyframes brand-mark-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes brand-mark-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.brand-mark-emoji {
color: inherit;
font-size: 1em;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
transform: translateY(0.02em);
transform-origin: center;
}
.brand-mark-xl .brand-mark-emoji {
font-size: 1em;
}
.brand-mark-lg .brand-mark-emoji {
font-size: 1em;
}
.emoji-font-noto-color {
font-family: "Noto Color Emoji", sans-serif;
}
.emoji-font-noto-emoji {
color: var(--accent);
font-family: "Noto Emoji", sans-serif;
font-weight: 700;
}
.emoji-font-twemoji {
font-family: "Twemoji Mozilla", sans-serif;
}
.emoji-font-openmoji {
font-family: "OpenMoji Color", sans-serif;
}
.emoji-font-apple {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.emoji-font-segoe {
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
}
.emoji-font-noto-local {
font-family: "Noto Color Emoji", "Noto Emoji", sans-serif;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
import { rememberReferral, readReferral } from "./session.js";
export function readReferralParam(tg) {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get("ref") || params.get("start") || params.get("start_param") || "";
const fromTelegram = tg?.initDataUnsafe?.start_param || "";
const value = String(fromTelegram || fromQuery || "").trim();
return value ? rememberReferral(value) : readReferral();
}
export function readTelegramAuthStatus() {
const params = new URLSearchParams(window.location.search);
return (params.get("telegram_auth") || "").trim().toLowerCase() || null;
}
export function readMagicLoginToken() {
const params = new URLSearchParams(window.location.search);
return (params.get("login_token") || "").trim() || null;
}
export function readTelegramLoginWidgetAuthData() {
const params = new URLSearchParams(window.location.search);
const keys = ["id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"];
const authData = {};
let hasAuthValue = false;
keys.forEach((key) => {
if (!params.has(key)) return;
authData[key] = params.get(key) || "";
hasAuthValue = true;
});
if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) return null;
return authData;
}
export function clearAuthQuery() {
const url = new URL(window.location.href);
[
"login_token",
"login_purpose",
"telegram_auth",
"id",
"first_name",
"last_name",
"username",
"photo_url",
"auth_date",
"hash",
].forEach((key) => url.searchParams.delete(key));
window.history?.replaceState?.({}, document.title, url.pathname + url.search + url.hash);
}
export function buildTelegramOAuthStartUrl(purpose = "login", tg = null) {
const url = new URL("/auth/telegram/start", window.location.origin);
url.searchParams.set("purpose", purpose);
const referralParam = readReferralParam(tg);
if (referralParam) url.searchParams.set("referral_code", referralParam);
return url.toString();
}
export function emailError(error, fallback, t) {
if (error?.error === "rate_limited")
return t("wa_auth_resend_wait", { seconds: error.retry_after || 60 });
if (error?.error === "invalid_email") return t("wa_auth_invalid_email");
if (error?.error === "expired_code") return t("wa_auth_code_expired");
if (error?.error === "invalid_code" || error?.error === "too_many_attempts")
return t("wa_auth_invalid_code");
return fallback;
}
export function createCooldownTimer() {
let timer = null;
let cooldown = 0;
const listeners = new Set();
function notify() {
for (const fn of listeners) fn(cooldown);
}
function clear() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
function start(seconds = 60) {
clear();
cooldown = Math.max(0, Number(seconds || 60));
notify();
timer = window.setInterval(() => {
if (cooldown <= 1) {
cooldown = 0;
clear();
notify();
return;
}
cooldown -= 1;
notify();
}, 1000);
}
function subscribe(listener) {
listeners.add(listener);
listener(cooldown);
return () => listeners.delete(listener);
}
return {
start,
clear,
subscribe,
get value() {
return cooldown;
},
};
}
+89
View File
@@ -0,0 +1,89 @@
export function createBillingActions({ api }) {
async function fetchTopupOptions(kind) {
return api(`/tariffs/topup-options?kind=${encodeURIComponent(kind)}`);
}
async function fetchDeviceTopupOptions() {
return api("/devices/topup-options");
}
async function fetchTariffChangeOptions() {
return api("/tariffs/change-options");
}
async function postPayment(body) {
return api("/payments", { method: "POST", body: JSON.stringify(body) });
}
async function postTariffChange(body) {
return api("/tariffs/change", { method: "POST", body: JSON.stringify(body) });
}
async function postTariffChangePayment(body) {
return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) });
}
function planPaymentBody(plan, method) {
return {
months: plan.months,
traffic_gb: plan.traffic_gb,
device_count: plan.device_count,
tariff_key: plan.tariff_key,
sale_mode: plan.sale_mode,
method,
};
}
function topupPaymentBody(plan, method, fallbackTariffKey) {
return {
months: plan.months,
traffic_gb: plan.traffic_gb,
tariff_key: plan.tariff_key || fallbackTariffKey,
sale_mode: plan.sale_mode || "topup",
method,
};
}
function deviceTopupPaymentBody(plan, method, fallbackTariffKey) {
return {
months: plan.device_count || plan.months,
device_count: plan.device_count || plan.months,
tariff_key: plan.tariff_key || fallbackTariffKey,
sale_mode: "hwid_devices",
method,
};
}
function changePaymentBody(action, target, method) {
if (action.mode === "buy_package") {
return {
tariff_key: target.tariff_key,
traffic_gb: action.traffic_gb,
months: action.traffic_gb,
sale_mode: "topup",
method,
};
}
if (action.mode === "buy_period") {
return {
tariff_key: target.tariff_key,
months: action.months,
method,
};
}
return { tariff_key: target.tariff_key, method };
}
return {
fetchTopupOptions,
fetchDeviceTopupOptions,
fetchTariffChangeOptions,
postPayment,
postTariffChange,
postTariffChangePayment,
planPaymentBody,
topupPaymentBody,
deviceTopupPaymentBody,
changePaymentBody,
};
}
@@ -0,0 +1,9 @@
/** Drop cached topup / change-tariff option payloads so the next open refetches from /api. */
export function invalidateWebappTariffOptionCaches(billingStore) {
billingStore.update((s) => ({
...s,
topupOptions: null,
deviceTopupOptions: null,
changeOptions: null,
}));
}
+82
View File
@@ -0,0 +1,82 @@
export function readJsonScript(id) {
const node = document.getElementById(id);
if (!node || !node.textContent) return null;
try {
return JSON.parse(node.textContent);
} catch (error) {
console.warn(`Failed to parse JSON config from #${id}`, error);
return null;
}
}
export function structuredCloneSafe(value) {
try {
return structuredClone(value);
} catch {
return JSON.parse(JSON.stringify(value));
}
}
export function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export function normalizeBrand(brand = {}) {
return {
title: String(brand.title || "/minishop").trim() || "/minishop",
logoUrl: String(brand.logoUrl || "").trim(),
emoji: String(brand.emoji || brand.logoEmoji || "🫥").trim() || "🫥",
emojiFont: String(brand.emojiFont || brand.logoEmojiFont || "system").trim() || "system",
};
}
export function emojiToCodepoints(value) {
return Array.from(String(value || "").trim())
.map((char) => char.codePointAt(0)?.toString(16))
.filter(Boolean)
.join("_");
}
export function animatedEmojiAssetUrls(emoji) {
const codepoints = emojiToCodepoints(emoji);
if (!codepoints) return { gif: "", webp: "" };
return {
gif: `/webapp-emoji/${codepoints}/512.gif`,
webp: `/webapp-emoji/${codepoints}/512.webp`,
};
}
export function brandFaviconHref(brand = {}) {
const normalizedBrand = normalizeBrand(brand);
if (normalizedBrand.logoUrl) return normalizedBrand.logoUrl;
if (normalizedBrand.emojiFont === "noto-color-animated") {
return animatedEmojiAssetUrls(normalizedBrand.emoji).gif;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">${escapeHtml(normalizedBrand.emoji)}</text></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
export function applyFavicon(brand = {}) {
if (typeof document === "undefined") return;
const favicon = document.getElementById("app-favicon");
if (!favicon) return;
const href = brandFaviconHref(brand);
favicon.setAttribute("href", href);
if (href.startsWith("data:image/svg+xml")) {
favicon.setAttribute("type", "image/svg+xml");
} else if (href.endsWith(".gif")) {
favicon.setAttribute("type", "image/gif");
} else if (href.endsWith(".webp")) {
favicon.setAttribute("type", "image/webp");
} else {
favicon.removeAttribute("type");
}
}
+43
View File
@@ -0,0 +1,43 @@
export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
export const LANGUAGE_LABELS = {
ru: "Русский",
en: "English",
de: "Deutsch",
es: "Español",
fr: "Français",
tr: "Türkçe",
uk: "Українська",
};
export const LANGUAGE_FLAGS = {
ru: "🇷🇺",
en: "🇬🇧",
de: "🇩🇪",
es: "🇪🇸",
fr: "🇫🇷",
tr: "🇹🇷",
uk: "🇺🇦",
};
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
export const APP_SECTION_PATHS = {
home: "/home",
invite: "/invite",
devices: "/devices",
settings: "/settings",
admin: "/admin",
};
export const ADMIN_SECTIONS = new Set([
"stats",
"users",
"payments",
"promos",
"ads",
"broadcast",
"logs",
"tariffs",
"appearance",
"settings",
]);
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
export const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900;
export const TELEGRAM_SDK_ACTION_TIMEOUT_MS = 1800;
export const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 15000;
+24
View File
@@ -0,0 +1,24 @@
/**
* Pure helpers for HWID / device limits UI (used by DevicesScreen).
* @param {Record<string, unknown>} devicesData API payload from /api/devices
* @param {(key: string, vars?: Record<string, unknown>, fallback?: string) => string} t i18n function
* @param {unknown} [maxDevicesOverride] optional max_devices override (defaults to devicesData.max_devices)
*/
export function devicesLimitLabel(devicesData, t, maxDevicesOverride) {
const value = maxDevicesOverride !== undefined ? maxDevicesOverride : devicesData?.max_devices;
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
return String(Math.trunc(numeric));
}
export function devicesCountLabel(devicesData, t) {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
return t("wa_devices_count", { current, max: devicesLimitLabel(devicesData, t) });
}
export function devicesPercent(devicesData) {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
const max = Number(devicesData?.max_devices || 0);
if (!max || max <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
}
+56
View File
@@ -0,0 +1,56 @@
export function formatTemplate(template, params = {}) {
const text = String(template ?? "");
return text.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
export function formatMoney(value, currency = "RUB") {
const numeric = Number(value || 0);
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
const symbol = currency === "RUB" ? "₽" : currency;
return `${formatted} ${symbol}`;
}
export function formatTrafficGb(value) {
const numeric = Number(value || 0);
const formatted = Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
return `${formatted} GB`;
}
export function formatTrafficBytes(value) {
const gb = Number(value || 0) / 1073741824;
return formatTrafficGb(gb);
}
export function formatCompactNumber(value) {
const numeric = Number(value || 0);
return Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
}
export function roundToHalf(value) {
return Math.round(Number(value || 0) * 2) / 2;
}
export function formatFraction(value) {
const n = Number(value || 0);
if (Number.isInteger(n)) return String(n);
return n.toFixed(1);
}
export function normalizedEmail(value) {
return String(value || "")
.trim()
.toLowerCase();
}
export function telegramName(profile, fallback) {
const first = String(profile?.first_name || "").trim();
const last = String(profile?.last_name || "").trim();
if (first || last) return `${first} ${last}`.trim();
const username = String(profile?.username || "").trim();
if (username) return `@${username}`;
return fallback;
}
+19
View File
@@ -0,0 +1,19 @@
function bytesToHex(buffer) {
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function sha256Hex(value) {
const data = new TextEncoder().encode(value);
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
return bytesToHex(hashBuffer);
}
export async function buildGravatarUrl(emailValue) {
if (!emailValue || !window.crypto?.subtle) return "";
try {
const hash = await sha256Hex(emailValue);
return `https://www.gravatar.com/avatar/${hash}?d=mp&s=160`;
} catch {
return "";
}
}
+51
View File
@@ -0,0 +1,51 @@
import { LANGUAGE_LABELS } from "./constants.js";
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
import { unitPluralBucket } from "./plurals.js";
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
function normalizeLangCode(lang) {
const key = String(lang || "")
.trim()
.toLowerCase();
if (!key) return defaultLang;
const base = key.split("-")[0];
if (LANGUAGE_LABELS[base]) return base;
if (messages[base]) return base;
if (messages[key]) return key;
return defaultLang;
}
function currentLang() {
return normalizeLangCode(typeof getLang === "function" ? getLang() : defaultLang);
}
function t(key, params = {}, fallback = "") {
const lang = currentLang();
const variants = [
messages?.[lang]?.[key],
messages?.en?.[key],
messages?.ru?.[key],
fallback,
key,
];
const raw = variants.find((value) => typeof value === "string" && value.length);
return formatTemplate(raw, params);
}
function languageName(code) {
const key = String(code || "")
.trim()
.toLowerCase();
if (!key) return t("wa_language_default");
return LANGUAGE_LABELS[key] || key.toUpperCase();
}
function termUnitLabel(value, unit) {
const bucket = unitPluralBucket(value, currentLang());
return t(`wa_sub_term_${unit}_${bucket}`);
}
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
}
export { formatFraction, roundToHalf };
+429
View File
@@ -0,0 +1,429 @@
import { DEV_MOCK } from "./previewMock.js";
function defaultClone(value) {
try {
return structuredClone(value);
} catch {
return JSON.parse(JSON.stringify(value));
}
}
export async function mockApi(path, options = {}, context = {}) {
const {
currentLang = "ru",
normalizeLangCode = (value) => value || "ru",
clone = defaultClone,
} = context;
await new Promise((resolve) => window.setTimeout(resolve, 120));
const cleanPath = String(path || "").split("?")[0];
const adminUsers = [
{
user_id: 100200300,
telegram_id: 100200300,
username: "anna_ops",
first_name: "Анна",
last_name: "Смирнова",
email: "anna@example.com",
telegram_photo_url: "",
registration_date: "2026-04-24T10:20:00Z",
is_banned: false,
premium_traffic: {
state: "good",
unlimited: false,
used_bytes: 4 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 16,
},
},
{
user_id: 100200301,
telegram_id: 87543123,
username: "client_pro",
first_name: "Максим",
last_name: "Котов",
email: "",
telegram_photo_url: "",
registration_date: "2026-04-26T08:15:00Z",
is_banned: false,
premium_traffic: {
state: "warn",
unlimited: false,
used_bytes: 22 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 88,
},
},
{
user_id: 100200302,
telegram_id: 88440011,
username: "",
first_name: "Daria",
last_name: "",
email: "daria@example.com",
telegram_photo_url: "",
registration_date: "2026-04-29T16:45:00Z",
is_banned: true,
premium_traffic: { state: "none" },
},
];
const mockAdminDailySeries = (() => {
const days = 730;
const out = [];
const now = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
d.setUTCDate(d.getUTCDate() - (days - 1 - i));
const iso = d.toISOString().slice(0, 10);
const wave = Math.sin(i / 5) * 520 + 720 + ((i * 41) % 280);
out.push({ date: iso, amount: Math.max(0, Math.round(wave)) });
}
return out;
})();
if (path === "/admin/stats") {
return {
ok: true,
currency_symbol: "RUB",
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
financial: {
today_revenue: 1240,
week_revenue: 15800,
month_revenue: 44100,
all_time_revenue: 186240,
today_payments_count: 4,
daily_series: mockAdminDailySeries,
},
panel_sync: {
status: "success",
last_sync_time: new Date().toISOString(),
users_processed: 172,
subscriptions_synced: 168,
},
recent_payments: [
{
payment_id: 1,
user_id: 100200300,
user_label: "anna_ops",
amount: 790,
currency: "RUB",
provider: "yookassa",
status: "succeeded",
created_at: new Date().toISOString(),
},
],
};
}
if (cleanPath === "/admin/users")
return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 };
if (cleanPath.startsWith("/admin/users/")) {
const id = Number(cleanPath.split("/")[3]);
const user = adminUsers.find((item) => item.user_id === id) || adminUsers[0];
return {
ok: true,
user,
active_subscription: {
subscription_id: 10,
end_date: "2026-06-08T12:00:00Z",
tariff_key: "standard",
auto_renew_enabled: true,
provider: "yookassa",
},
subscriptions: [
{
subscription_id: 10,
end_date: "2026-06-08T12:00:00Z",
tariff_key: "standard",
is_active: true,
status_from_panel: "ACTIVE",
},
{
subscription_id: 9,
end_date: "2026-05-08T12:00:00Z",
tariff_key: "standard",
is_active: false,
status_from_panel: "EXPIRED",
},
],
total_paid: 2380,
recent_payments: [
{
payment_id: 12,
amount: 790,
currency: "RUB",
provider: "yookassa",
status: "succeeded",
created_at: "2026-05-01T14:15:00Z",
},
{
payment_id: 11,
amount: 790,
currency: "RUB",
provider: "stars",
status: "succeeded",
created_at: "2026-04-01T14:15:00Z",
},
],
log_count: 18,
subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP",
referral: {
code: "ABCD1234",
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
webapp_link: "https://app.example.com/?ref=uABCD1234",
},
};
}
if (path === "/admin/tariffs") {
return {
ok: true,
path: "data/tariffs.json",
catalog: {
default_tariff: "standard",
topup_packages_default: { rub: [{ gb: 10, price: 99 }], stars: [] },
tariffs: [
{
key: "standard",
names: { ru: "Стандарт", en: "Standard" },
descriptions: { ru: "Базовый набор серверов" },
squad_uuids: ["db786ee8-816b-4760-80aa-1fc7a3669ff2"],
billing_model: "period",
monthly_gb: 500,
prices_rub: { 1: 150, 3: 400 },
prices_stars: { 1: 0, 3: 0 },
enabled_periods: [1, 3],
enabled: true,
},
],
},
};
}
if (path === "/admin/themes") {
if (String(options.method || "GET").toUpperCase() === "PUT") {
try {
const body = options?.body ? JSON.parse(String(options.body)) : {};
const catalog = body.catalog || body;
if (catalog?.themes) {
DEV_MOCK.config.themesCatalog = clone(catalog);
DEV_MOCK.data.themes_catalog = clone(catalog);
}
} catch (_e) {
void _e;
}
return {
ok: true,
themes_dir: "data/themes",
catalog: clone(DEV_MOCK.config.themesCatalog),
};
}
return {
ok: true,
themes_dir: "data/themes",
catalog: clone(DEV_MOCK.config.themesCatalog),
};
}
if (path === "/admin/appearance/logo") {
return {
ok: true,
logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png",
favicon_url: "/webapp-favicon/0000000000000000/icon-180.png",
};
}
if (path === "/admin/appearance/favicon") {
return {
ok: true,
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
variants: {
"32": "/webapp-favicon/1111111111111111/icon-32.png",
apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png",
},
};
}
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
try {
const body = options?.body ? JSON.parse(String(options.body)) : {};
const updates = body.updates || {};
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_URL")) {
DEV_MOCK.config.logoUrl = updates.WEBAPP_LOGO_URL || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_USE_EMOJI")) {
DEV_MOCK.config.logoUseEmoji = Boolean(updates.WEBAPP_LOGO_USE_EMOJI);
}
if (updates.WEBAPP_LOGO_EMOJI) DEV_MOCK.config.logoEmoji = updates.WEBAPP_LOGO_EMOJI;
if (updates.WEBAPP_LOGO_EMOJI_FONT) {
DEV_MOCK.config.logoEmojiFont = updates.WEBAPP_LOGO_EMOJI_FONT;
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_URL")) {
DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) {
DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) {
DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM);
}
} catch (_e) {
void _e;
}
return { ok: true, applied: 1, reverted: 0 };
}
if (path === "/admin/settings")
return {
ok: true,
sections: [
{
id: "appearance",
order: 2,
fields: [
{
key: "WEBAPP_LOGO_USE_EMOJI",
type: "bool",
section: "appearance",
label: "Emoji logo",
value: Boolean(DEV_MOCK.config.logoUseEmoji),
},
{
key: "WEBAPP_LOGO_URL",
type: "url",
section: "appearance",
label: "URL логотипа",
value: DEV_MOCK.config.logoUrl || "",
},
{
key: "WEBAPP_LOGO_EMOJI",
type: "string",
section: "appearance",
label: "Emoji",
value: DEV_MOCK.config.logoEmoji || "🫥",
},
{
key: "WEBAPP_LOGO_EMOJI_FONT",
type: "string",
section: "appearance",
label: "Emoji font",
value: DEV_MOCK.config.logoEmojiFont || "system",
choices: [
{ value: "system", label: "Системный" },
{ value: "noto-color", label: "Noto Color Emoji" },
{ value: "noto-color-animated", label: "Noto Color Emoji Animated" },
],
},
{
key: "WEBAPP_FAVICON_USE_CUSTOM",
type: "bool",
section: "appearance",
label: "Custom favicon",
value: Boolean(DEV_MOCK.config.faviconUseCustom),
},
{
key: "WEBAPP_FAVICON_URL",
type: "url",
section: "appearance",
label: "Favicon URL",
value: DEV_MOCK.config.faviconUrl || "",
},
{
key: "WEBAPP_LOGO_FAVICON_URL",
type: "url",
section: "appearance",
label: "Logo favicon URL",
value: DEV_MOCK.config.faviconUrl || "",
},
],
},
],
};
if (cleanPath.startsWith("/admin/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
if (path === "/me") return clone(DEV_MOCK.data);
if (path === "/auth/email/request") return { ok: true };
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (path === "/auth/token") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" };
if (path === "/devices") return clone(DEV_MOCK.data.devices);
if (path === "/devices/topup-options")
return clone(DEV_MOCK.data.device_topup_options || { ok: true, plans: [] });
if (cleanPath === "/tariffs/topup-options") {
const kind =
new URLSearchParams(String(path || "").split("?")[1] || "").get("kind") || "regular";
const payload = clone(DEV_MOCK.data.topup_options || { ok: true, plans: [] });
payload.topup_kind = kind;
payload.plans = (payload.plans || []).filter((plan) =>
kind === "premium" ? plan.sale_mode === "premium_topup" : plan.sale_mode !== "premium_topup"
);
return payload;
}
if (path === "/tariffs/change-options")
return clone(DEV_MOCK.data.tariff_change_options || { ok: true, targets: [] });
if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") {
let payload = {};
try {
payload = options?.body ? JSON.parse(String(options.body)) : {};
} catch (_error) {
void _error;
}
DEV_MOCK.data.devices.devices = DEV_MOCK.data.devices.devices.filter(
(device) => device.token !== payload.token
);
DEV_MOCK.data.devices.current_devices = DEV_MOCK.data.devices.devices.length;
return { ok: true };
}
if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") {
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
active: true,
status: "TRIAL",
remaining_text: "5 д. 0 ч.",
end_date_text: "05.05.2026 12:00",
days_left: 5,
traffic_limit: "10 GB",
traffic_limit_bytes: 10737418240,
traffic_used: "0 B",
traffic_used_bytes: 0,
};
DEV_MOCK.data.settings.trial_available = false;
return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" };
}
if (path === "/auth/logout") return { ok: true };
if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") {
let payload = {};
try {
payload = options?.body ? JSON.parse(String(options.body)) : {};
} catch (_error) {
void _error;
}
const language = normalizeLangCode(payload?.language || currentLang);
DEV_MOCK.data.user.language_code = language;
return { ok: true, language };
}
if (path === "/account/email/request" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true };
}
if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, csrf_token: "local-preview-csrf" };
}
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
return {
ok: true,
action: "open_link",
payment_url: "https://example.com/payment-preview",
payment_id: 10001,
};
}
if (path === "/tariffs/change" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, tariff_key: "business" };
}
if (path === "/tariffs/change-payment" && String(options.method || "").toUpperCase() === "POST") {
return {
ok: true,
action: "open_link",
payment_url: "https://example.com/tariff-change-payment-preview",
payment_id: 10002,
};
}
return { ok: false, error: "not_found" };
}
+33
View File
@@ -0,0 +1,33 @@
export function ruPlural(value, one, few, many) {
const n = Math.abs(Number(value || 0));
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few;
return many;
}
export function ruFractionAware(value, one, few, many) {
const n = Number(value || 0);
if (!Number.isInteger(n)) return few;
return ruPlural(n, one, few, many);
}
export function unitPluralBucket(value, lang) {
if (String(lang || "").toLowerCase() === "ru") {
const n = Number(value || 0);
if (!Number.isInteger(n)) {
const base = Math.floor(Math.abs(n));
const mod10 = base % 10;
const mod100 = base % 100;
return mod10 >= 1 && mod10 <= 4 && (mod100 < 11 || mod100 > 14) ? "few" : "many";
}
const abs = Math.abs(n);
const mod10 = abs % 10;
const mod100 = abs % 100;
if (mod10 === 1 && mod100 !== 11) return "one";
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
return "many";
}
return Number(value) === 1 ? "one" : "many";
}
+520
View File
@@ -0,0 +1,520 @@
const WINDOWS_95_THEME = {
key: "windows95",
names: { ru: "Windows 95", en: "Windows 95" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "light",
style_preset: "win95",
},
};
const ASCII_THEME = {
key: "ascii",
names: { ru: "ASCII", en: "ASCII" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "dark",
style_preset: "ascii",
},
};
export const DEV_MOCK = {
config: {
title: "/minishop",
primaryColor: "#00fe7a",
logoUrl: "",
logoUseEmoji: false,
logoEmoji: "🫥",
logoEmojiFont: "system",
faviconUrl: "",
faviconUseCustom: false,
apiBase: "/api",
supportUrl: "https://t.me/support",
privacyPolicyUrl: "https://example.com/privacy",
userAgreementUrl: "https://example.com/agreement",
currency: "RUB",
language: "ru",
emailAuthEnabled: true,
telegramLoginBotUsername: "preview_bot",
telegramLoginBotId: 1234567890,
telegramOAuthClientId: 1234567890,
telegramOAuthRequestAccess: ["write"],
appVersion: "dev+local",
appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop",
themesCatalog: {
default_theme: "dark",
themes: [
{
key: "dark",
names: { ru: "Тёмная", en: "Dark" },
enabled: true,
default: true,
tokens: {
color_scheme: "dark",
accent: "#00fe7a",
bg: "#03070b",
panel: "#111820",
text: "#f2f7f4",
muted: "#a9b4b0",
},
},
{
key: "light",
names: { ru: "Светлая", en: "Light" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "light",
},
},
WINDOWS_95_THEME,
ASCII_THEME,
],
},
},
data: {
ok: true,
user: {
id: 100200300,
username: "username",
email: "user@example.com",
email_verified: true,
telegram_id: 100200300,
telegram_linked: true,
telegram_photo_url: "",
first_name: "Preview",
language_code: "ru",
is_admin: true,
},
subscription: {
active: true,
status: "ACTIVE",
remaining_text: "25 д. 8 ч.",
end_date_text: "24.05.2026",
days_left: 25,
config_link: "https://sub.example.com/sub/preview-token",
connect_url: "https://sub.example.com/connect/preview-token",
traffic_used: "18.4 GB",
traffic_limit: "100 GB",
traffic_used_bytes: 19756849561,
traffic_limit_bytes: 107374182400,
premium_used: "32.0 GB",
premium_limit: "50.0 GB",
premium_used_bytes: 34359738368,
premium_limit_bytes: 53687091200,
premium_baseline_bytes: 53687091200,
premium_topup_balance_bytes: 0,
premium_is_limited: false,
premium_title: "Premium-серверы",
premium_node_labels: ["Premium NL-1", "Premium DE-1"],
can_topup_regular_traffic: true,
can_topup_premium_traffic: true,
max_devices: 5,
},
devices: {
ok: true,
enabled: true,
current_devices: 3,
max_devices: 5,
max_devices_label: "5",
devices: [
{
index: 1,
display_name: "iPhone 15 Pro",
platform_label: "iOS 18.4",
user_agent: "Streisand/1.6 CFNetwork",
created_at_text: "28.04.2026 16:12",
hwid_short: "A1B2C3D4...98FA01",
token: "preview-device-1",
can_disconnect: true,
},
{
index: 2,
display_name: "MacBook Air",
platform_label: "macOS 15.4",
user_agent: "Happ/3.1.0",
created_at_text: "29.04.2026 09:40",
hwid_short: "F0E1D2C3...44AB22",
token: "preview-device-2",
can_disconnect: true,
},
{
index: 3,
display_name: "Android Phone",
platform_label: "Android 15",
user_agent: "v2rayNG/1.9.35",
created_at_text: "30.04.2026 07:55",
hwid_short: "778899AA...BCDD10",
token: "preview-device-3",
can_disconnect: true,
},
],
},
plans: [
{ months: 1, price: 290, currency: "RUB", title: "1 месяц" },
{ months: 3, price: 790, currency: "RUB", title: "3 месяца" },
{ months: 6, price: 1490, currency: "RUB", title: "6 месяцев" },
{ months: 12, price: 2690, currency: "RUB", title: "12 месяцев" },
],
payment_methods: [
{ id: "yookassa", name: "Карта" },
{ id: "platega_sbp", name: "Telegram Pay" },
{ id: "cryptopay", name: "Криптовалюта" },
{ id: "freekassa", name: "Другие способы" },
],
referral: {
code: "ABCD1234",
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
webapp_link: "https://minishop.app/ref/ABCD1234",
invited_count: 4,
purchased_count: 2,
welcome_bonus_days: 3,
one_bonus_per_referee: false,
bonus_details: [
{ months: 1, title: "1 месяц", inviter_days: 14, friend_days: 7 },
{ months: 3, title: "3 месяца", inviter_days: 21, friend_days: 14 },
{ months: 6, title: "6 месяцев", inviter_days: 31, friend_days: 21 },
{ months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 },
],
},
themes_catalog: {
default_theme: "dark",
themes: [
{
key: "dark",
names: { ru: "Тёмная", en: "Dark" },
enabled: true,
tokens: {
color_scheme: "dark",
accent: "#00fe7a",
bg: "#03070b",
panel: "#111820",
text: "#f2f7f4",
muted: "#a9b4b0",
},
},
{
key: "light",
names: { ru: "Светлая", en: "Light" },
enabled: true,
css_file: "style.css",
tokens: {
color_scheme: "light",
},
},
WINDOWS_95_THEME,
ASCII_THEME,
],
},
settings: {
support_url: "https://t.me/support",
traffic_mode: false,
my_devices_enabled: false,
user_hwid_device_limit: 5,
trial_enabled: true,
trial_available: true,
trial_duration_days: 5,
trial_traffic_limit_gb: 10,
trial_traffic_strategy: "NO_RESET",
email_auth_enabled: true,
},
},
};
export function applyPreviewMock(kind) {
const mode = String(kind || "")
.trim()
.toLowerCase();
const themeKeys = new Set((DEV_MOCK.config.themesCatalog.themes || []).map((theme) => theme.key));
if (themeKeys.has(mode)) {
DEV_MOCK.config.themesCatalog.default_theme = mode;
DEV_MOCK.data.themes_catalog.default_theme = mode;
for (const theme of DEV_MOCK.config.themesCatalog.themes || []) {
theme.default = theme.key === mode;
}
for (const theme of DEV_MOCK.data.themes_catalog.themes || []) {
theme.default = theme.key === mode;
}
return;
}
if (mode === "traffic") {
DEV_MOCK.data.settings.traffic_mode = true;
DEV_MOCK.data.settings.trial_available = false;
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
active: true,
status: "ACTIVE",
remaining_text: "Навсегда",
end_date_text: "01.01.2099 00:00",
days_left: 26000,
traffic_used: "18.4 GB",
traffic_limit: "100 GB",
traffic_used_bytes: 19756849561,
traffic_limit_bytes: 107374182400,
traffic_limit_strategy: "NO_RESET",
};
DEV_MOCK.data.plans = [
{
months: 10,
traffic_gb: 10,
price: 199,
currency: "RUB",
title: "10 GB",
sale_mode: "traffic",
},
{
months: 50,
traffic_gb: 50,
price: 799,
currency: "RUB",
title: "50 GB",
sale_mode: "traffic",
},
{
months: 100,
traffic_gb: 100,
price: 1390,
currency: "RUB",
title: "100 GB",
sale_mode: "traffic",
},
{
months: 300,
traffic_gb: 300,
price: 3490,
currency: "RUB",
title: "300 GB",
sale_mode: "traffic",
},
];
} else if (mode === "tariffs") {
DEV_MOCK.data.settings.traffic_mode = false;
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
tariff_key: "standard",
tariff_name: "Стандарт",
tariff_description: "100 GB каждый месяц",
billing_model: "period",
traffic_limit_strategy: "MONTH",
};
DEV_MOCK.data.plans = [
{
id: "standard:period:1",
tariff_key: "standard",
tariff_name: "Стандарт",
billing_model: "period",
sale_mode: "subscription",
months: 1,
price: 150,
currency: "RUB",
title: "Стандарт",
subtitle: "1 месяц",
description: "100 GB каждый месяц",
monthly_gb: 100,
},
{
id: "standard:period:3",
tariff_key: "standard",
tariff_name: "Стандарт",
billing_model: "period",
sale_mode: "subscription",
months: 3,
price: 400,
currency: "RUB",
title: "Стандарт",
subtitle: "3 месяца",
description: "100 GB каждый месяц",
monthly_gb: 100,
},
{
id: "business:period:1",
tariff_key: "business",
tariff_name: "Бизнес",
billing_model: "period",
sale_mode: "subscription",
months: 1,
price: 350,
currency: "RUB",
title: "Бизнес",
subtitle: "1 месяц",
description: "300 GB и приоритетные серверы",
monthly_gb: 300,
},
{
id: "traffic:traffic:50",
tariff_key: "traffic",
tariff_name: "Трафик",
billing_model: "traffic",
sale_mode: "traffic_package",
months: 50,
traffic_gb: 50,
price: 799,
currency: "RUB",
title: "Трафик",
subtitle: "50 GB",
description: "Пакет без срока действия",
},
];
DEV_MOCK.data.tariff_change_options = {
ok: true,
current: {
tariff_key: "standard",
title: "Стандарт",
description: "100 GB каждый месяц",
billing_model: "period",
},
targets: [
{
tariff_key: "business",
title: "Бизнес",
description: "300 GB и приоритетные серверы",
billing_model: "period",
monthly_gb: 300,
actions: [
{
mode: "recalc_days",
kind: "free",
title: "recalc_days",
days_after: 10,
remaining_days: 25,
},
{ mode: "paid_diff", kind: "payment", title: "paid_diff", price: 190, currency: "RUB" },
],
},
{
tariff_key: "traffic",
title: "Трафик",
description: "Пакеты без срока действия",
billing_model: "traffic",
actions: [
{
mode: "convert_days_to_gb",
kind: "free",
title: "convert_days_to_gb",
converted_gb: 18,
remaining_days: 25,
},
{
mode: "buy_package",
kind: "payment",
title: "+50 GB",
traffic_gb: 50,
price: 799,
currency: "RUB",
},
],
},
],
};
DEV_MOCK.data.topup_options = {
ok: true,
tariff_key: "standard",
tariff_name: "Стандарт",
traffic_percent: 86,
warning_levels: [85, 90, 95],
plans: [
{
id: "standard:topup:10",
tariff_key: "standard",
tariff_name: "Стандарт",
sale_mode: "topup",
traffic_gb: 10,
months: 10,
price: 99,
currency: "RUB",
title: "10 GB",
subtitle: "Стандарт",
},
{
id: "standard:topup:50",
tariff_key: "standard",
tariff_name: "Стандарт",
sale_mode: "topup",
traffic_gb: 50,
months: 50,
price: 399,
currency: "RUB",
title: "50 GB",
subtitle: "Стандарт",
},
{
id: "standard:topup:200",
tariff_key: "standard",
tariff_name: "Стандарт",
sale_mode: "topup",
traffic_gb: 200,
months: 200,
price: 1299,
currency: "RUB",
title: "200 GB",
subtitle: "Стандарт",
},
],
};
DEV_MOCK.data.device_topup_options = {
ok: true,
tariff_key: "standard",
tariff_name: "Стандарт",
current_limit: 5,
plans: [
{
id: "standard:hwid:1",
tariff_key: "standard",
tariff_name: "Стандарт",
sale_mode: "hwid_devices",
device_count: 1,
months: 1,
price: 99,
currency: "RUB",
title: "+1",
subtitle: "Стандарт",
},
{
id: "standard:hwid:3",
tariff_key: "standard",
tariff_name: "Стандарт",
sale_mode: "hwid_devices",
device_count: 3,
months: 3,
price: 249,
currency: "RUB",
title: "+3",
subtitle: "Стандарт",
},
],
};
} else if (mode === "devices") {
DEV_MOCK.data.settings.my_devices_enabled = true;
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
active: true,
max_devices: 5,
};
} else if (mode === "trial") {
DEV_MOCK.data.settings.traffic_mode = false;
DEV_MOCK.data.settings.trial_enabled = true;
DEV_MOCK.data.settings.trial_available = true;
DEV_MOCK.data.settings.trial_duration_days = 5;
DEV_MOCK.data.settings.trial_traffic_limit_gb = 10;
DEV_MOCK.data.subscription = {
active: false,
status: "INACTIVE",
remaining_text: "Подписка не активна",
end_date_text: "",
days_left: 0,
config_link: null,
connect_url: null,
traffic_used: "0 B",
traffic_limit: "10 GB",
traffic_used_bytes: 0,
traffic_limit_bytes: 10737418240,
};
}
}
+52
View File
@@ -0,0 +1,52 @@
import { readCookie } from "./session.js";
export function createApiClient({
apiBase = "",
csrfCookieName = "rw_webapp_csrf",
getCsrfToken = () => "",
onUnauthorized = () => {},
mockApi = null,
getMockContext = () => ({}),
} = {}) {
const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData;
async function api(path, options = {}) {
if (mockApi) return mockApi(path, options, getMockContext());
const method = String(options.method || "GET").toUpperCase();
const headers = { ...(options.headers || {}) };
const csrf = getCsrfToken() || readCookie(csrfCookieName) || "";
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
headers["X-CSRF-Token"] = csrf;
}
if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${apiBase}${path}`, {
...options,
headers,
credentials: "same-origin",
});
const payload = await response.json().catch(() => ({}));
if (response.status === 401) onUnauthorized();
return payload;
}
async function publicApi(path, payload = {}, options = {}) {
if (mockApi) {
return mockApi(path, { method: "POST", body: JSON.stringify(payload) }, getMockContext());
}
const response = await fetch(`${apiBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: options.signal,
credentials: "same-origin",
});
return response.json();
}
return { api, publicApi };
}
+59
View File
@@ -0,0 +1,59 @@
import { ADMIN_SECTIONS, APP_SECTION_PATHS } from "./constants.js";
export function normalizeSection(value) {
const section = String(value || "")
.trim()
.toLowerCase();
if (
section === "invite" ||
section === "devices" ||
section === "settings" ||
section === "admin"
) {
return section;
}
return "home";
}
export function sectionFromPath(pathname) {
const normalizedPath = String(pathname || "")
.trim()
.toLowerCase()
.replace(/\/+$/, "");
if (!normalizedPath || normalizedPath === "/") return "home";
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
return normalizeSection(section);
}
export function adminSectionFromPath(pathname) {
const normalized = String(pathname || "")
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/);
if (m && ADMIN_SECTIONS.has(m[1])) return m[1];
return "stats";
}
export function adminUserIdFromPath(pathname) {
const normalized = String(pathname || "")
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/users\/(-?\d+)$/);
return m ? Number(m[1]) : null;
}
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
if (window.location.protocol === "file:") return;
const normalized = normalizeSection(section);
let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
if (normalized === "admin") {
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
const uid =
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
}
if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
}
+62
View File
@@ -0,0 +1,62 @@
export const TOKEN_STORAGE_KEY = "rw_webapp_token";
export const CSRF_COOKIE_NAME = "rw_webapp_csrf";
export const REFERRAL_STORAGE_KEY = "rw_webapp_referral";
function ignoreStorageError(error) {
void error;
}
export function readCookie(name) {
if (typeof document === "undefined") return "";
const prefix = `${name}=`;
const cookie = document.cookie.split("; ").find((part) => part.startsWith(prefix));
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : "";
}
export function clearStoredToken(storageKey = TOKEN_STORAGE_KEY) {
if (typeof localStorage === "undefined") return;
localStorage.removeItem(storageKey);
}
export function markManualLogout(flagKey) {
try {
localStorage.setItem(flagKey, "1");
} catch (error) {
ignoreStorageError(error);
}
}
export function clearManualLogoutFlag(flagKey) {
try {
localStorage.removeItem(flagKey);
} catch (error) {
ignoreStorageError(error);
}
}
export function isManuallyLoggedOut(flagKey) {
try {
return localStorage.getItem(flagKey) === "1";
} catch {
return false;
}
}
export function rememberReferral(value) {
const normalized = String(value || "").trim();
if (!normalized) return readReferral();
try {
localStorage.setItem(REFERRAL_STORAGE_KEY, normalized);
} catch (error) {
ignoreStorageError(error);
}
return normalized;
}
export function readReferral() {
try {
return localStorage.getItem(REFERRAL_STORAGE_KEY) || "";
} catch {
return "";
}
}
@@ -0,0 +1,238 @@
import { writable, get } from "svelte/store";
import { emailError, buildTelegramOAuthStartUrl } from "../authHelpers.js";
export function createAccountStore({
api,
publicApi,
setToken,
loadData,
t,
showToast,
clearToken,
markManualLogout,
showLogin,
telegramSdk,
getTg,
telegramOAuthClientId,
currentLang,
normalizeLangCode,
updateLocalData,
}) {
const state = writable({
linkEmailOpen: false,
linkEmailBusy: false,
linkTelegramBusy: false,
linkEmailValue: "",
linkEmailPending: "",
linkEmailCode: "",
linkEmailStatus: "",
linkEmailIsError: false,
linkEmailFieldError: "",
linkEmailResendCooldown: 0,
languageBusy: false,
});
let linkEmailResendTimer = null;
function setLinkEmailStatus(message, isError = false) {
state.update((s) => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
}
function clearCooldownTimer() {
if (linkEmailResendTimer) {
window.clearInterval(linkEmailResendTimer);
linkEmailResendTimer = null;
}
}
function startCooldownTimer(seconds = 60) {
clearCooldownTimer();
state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
linkEmailResendTimer = window.setInterval(() => {
const s = get(state);
if (s.linkEmailResendCooldown <= 1) {
state.update((s) => ({ ...s, linkEmailResendCooldown: 0 }));
clearCooldownTimer();
return;
}
state.update((s) => ({ ...s, linkEmailResendCooldown: s.linkEmailResendCooldown - 1 }));
}, 1000);
}
function openLinkEmailDialog(email) {
state.update((s) => ({
...s,
linkEmailOpen: true,
linkEmailBusy: false,
linkEmailCode: "",
linkEmailPending: "",
linkEmailStatus: "",
linkEmailIsError: false,
linkEmailFieldError: "",
linkEmailValue: email || "",
linkEmailResendCooldown: 0,
}));
clearCooldownTimer();
}
function closeLinkEmailDialog() {
state.update((s) => ({
...s,
linkEmailOpen: false,
linkEmailBusy: false,
linkEmailCode: "",
linkEmailPending: "",
linkEmailStatus: "",
linkEmailIsError: false,
linkEmailFieldError: "",
linkEmailResendCooldown: 0,
}));
clearCooldownTimer();
}
async function requestLinkEmailCode() {
const s = get(state);
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
const normalized = String(s.linkEmailValue || "")
.trim()
.toLowerCase();
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
return;
}
state.update((s) => ({ ...s, linkEmailFieldError: "", linkEmailBusy: true }));
setLinkEmailStatus(t("wa_auth_sending_code"));
try {
const response = await api("/account/email/request", {
method: "POST",
body: JSON.stringify({ email: normalized }),
});
if (!response?.ok) throw response;
state.update((s) => ({ ...s, linkEmailPending: normalized, linkEmailCode: "" }));
setLinkEmailStatus("");
startCooldownTimer(60);
} catch (error) {
setLinkEmailStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
} finally {
state.update((s) => ({ ...s, linkEmailBusy: false }));
}
}
async function verifyLinkEmailCode() {
const s = get(state);
const code = String(s.linkEmailCode || "")
.replace(/\\D/g, "")
.slice(0, 6);
if (!s.linkEmailPending) {
setLinkEmailStatus(t("wa_auth_send_code_failed"), true);
return;
}
if (code.length !== 6) {
setLinkEmailStatus(t("wa_auth_enter_code_6digits"), true);
return;
}
state.update((s) => ({ ...s, linkEmailBusy: true }));
setLinkEmailStatus(t("wa_auth_checking_code"));
try {
const response = await api("/account/email/verify", {
method: "POST",
body: JSON.stringify({ email: s.linkEmailPending, code }),
});
if (!response?.ok) throw response;
if (response?.csrf_token) setToken("", response.csrf_token);
await loadData();
closeLinkEmailDialog();
showToast(t("wa_settings_linked"));
} catch (error) {
setLinkEmailStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
} finally {
state.update((s) => ({ ...s, linkEmailBusy: false }));
}
}
async function linkTelegramAccountWithPayload(payload) {
state.update((s) => ({ ...s, linkTelegramBusy: true }));
try {
const response = await api("/account/telegram/link", {
method: "POST",
body: JSON.stringify(payload),
});
if (!response?.ok) throw response;
if (response?.csrf_token) setToken("", response.csrf_token);
await loadData();
showToast(t("wa_settings_linked"));
} catch (error) {
showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
} finally {
state.update((s) => ({ ...s, linkTelegramBusy: false }));
}
}
async function linkTelegramAccount(getTelegramMiniAppInitData) {
const s = get(state);
if (s.linkTelegramBusy) return;
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
if (isTelegramMiniAppAttempt) {
await telegramSdk.ensureForAction();
}
const initData = getTelegramMiniAppInitData();
if (initData) {
await linkTelegramAccountWithPayload({ init_data: initData });
return;
}
if (!telegramOAuthClientId) {
showToast(t("wa_auth_telegram_not_configured"));
return;
}
state.update((s) => ({ ...s, linkTelegramBusy: true }));
window.location.assign(buildTelegramOAuthStartUrl("link", getTg()));
}
async function updateAccountLanguage(nextValue) {
const s = get(state);
const normalize = typeof normalizeLangCode === "function" ? normalizeLangCode : (v) => v;
const language = normalize(nextValue);
if (!language || s.languageBusy || language === currentLang()) return;
state.update((s) => ({ ...s, languageBusy: true }));
try {
const response = await api("/account/language", {
method: "POST",
body: JSON.stringify({ language }),
});
if (!response?.ok) throw response;
if (typeof updateLocalData === "function") {
updateLocalData(normalize(response.language || language));
}
await loadData();
} catch {
showToast(t("wa_settings_language_update_failed"));
} finally {
state.update((s) => ({ ...s, languageBusy: false }));
}
}
async function logout() {
markManualLogout();
clearToken();
try {
await publicApi("/auth/logout", { keepalive: true });
} catch (_error) {
void _error;
}
showLogin();
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
openLinkEmailDialog,
closeLinkEmailDialog,
requestLinkEmailCode,
verifyLinkEmailCode,
linkTelegramAccount,
updateAccountLanguage,
logout,
clearLinkEmailResendTimer: clearCooldownTimer,
};
}
+287
View File
@@ -0,0 +1,287 @@
import { writable, get } from "svelte/store";
import {
readReferralParam,
clearAuthQuery,
buildTelegramOAuthStartUrl,
emailError,
} from "../authHelpers.js";
export function createAuthStore({
publicApi,
setToken,
loadData,
telegramSdk,
getTg,
t,
currentLang,
}) {
const state = writable({
authStatus: "",
authIsError: false,
authBusy: false,
telegramLoginBusy: false,
telegramLoginAttemptId: 0,
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
authResendCooldown: 0,
email: "",
pendingEmail: "",
emailCode: "",
});
let authResendTimer = null;
let telegramLoginWatchdogTimer = null;
function setAuthStatus(message, isError = false) {
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
}
function clearCooldownTimer() {
if (authResendTimer) {
window.clearInterval(authResendTimer);
authResendTimer = null;
}
}
function startCooldownTimer(seconds = 60) {
clearCooldownTimer();
state.update((s) => ({ ...s, authResendCooldown: Math.max(0, Number(seconds || 60)) }));
authResendTimer = window.setInterval(() => {
const { authResendCooldown } = get(state);
if (authResendCooldown <= 1) {
state.update((s) => ({ ...s, authResendCooldown: 0 }));
clearCooldownTimer();
return;
}
state.update((s) => ({ ...s, authResendCooldown: authResendCooldown - 1 }));
}, 1000);
}
function startTelegramLoginWatchdog() {
const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 6000;
stopTelegramLoginWatchdog();
state.update((s) => ({ ...s, telegramLoginAttemptId: s.telegramLoginAttemptId + 1 }));
const { telegramLoginAttemptId } = get(state);
telegramLoginWatchdogTimer = window.setTimeout(() => {
if (get(state).telegramLoginAttemptId !== telegramLoginAttemptId) return;
telegramLoginWatchdogTimer = null;
state.update((s) => ({ ...s, telegramLoginBusy: false, authBusy: false }));
setAuthStatus(t("wa_auth_telegram_timeout"), true);
}, TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS);
return telegramLoginAttemptId;
}
function stopTelegramLoginWatchdog(attemptId = null) {
if (attemptId !== null && attemptId !== get(state).telegramLoginAttemptId) return;
if (telegramLoginWatchdogTimer) {
window.clearTimeout(telegramLoginWatchdogTimer);
telegramLoginWatchdogTimer = null;
}
}
function isActiveTelegramLoginAttempt(attemptId) {
const s = get(state);
return attemptId === s.telegramLoginAttemptId && s.telegramLoginBusy;
}
async function finalizeMagicLogin(loginToken) {
const s = get(state);
if (s.authBusy) return false;
state.update((s) => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_login"));
try {
const payload = { token: loginToken };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/magic", payload);
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
clearAuthQuery();
await loadData();
return true;
}
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
} catch {
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
} finally {
state.update((s) => ({ ...s, authBusy: false }));
}
return false;
}
async function finalizeTelegramAuth(authData, source = "auth_data", options = {}) {
const s = get(state);
if (s.authBusy) return false;
state.update((s) => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_telegram"));
try {
const payload =
source === "init_data"
? { init_data: authData }
: source === "id_token"
? { id_token: authData.id_token, nonce: authData.nonce }
: { auth_data: authData };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/token", payload, { signal: options.signal });
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
clearAuthQuery();
setAuthStatus("");
await loadData();
return true;
}
setAuthStatus(
response.error === "banned"
? t("wa_auth_access_denied")
: t("wa_auth_telegram_not_confirmed"),
true
);
} catch (error) {
setAuthStatus(
error?.name === "AbortError"
? t("wa_auth_telegram_timeout")
: t("wa_auth_telegram_unavailable"),
true
);
} finally {
state.update((s) => ({ ...s, authBusy: false }));
}
return false;
}
async function requestEmailCode(changeScreen) {
const s = get(state);
if (s.authResendCooldown > 0 && s.pendingEmail) return;
const normalized = s.email.trim().toLowerCase();
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({
...s,
loginEmailFieldError: t("wa_auth_invalid_email"),
loginEmailTooltipOpen: true,
}));
return;
}
state.update((s) => ({
...s,
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
authBusy: true,
}));
setAuthStatus(t("wa_auth_sending_code"));
try {
const payload = { email: normalized, language: currentLang() };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/request", payload);
if (!response.ok) throw response;
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: "" }));
changeScreen("code");
setAuthStatus("");
startCooldownTimer(60);
} catch (error) {
setAuthStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
} finally {
state.update((s) => ({ ...s, authBusy: false }));
}
}
async function verifyEmailCode() {
const s = get(state);
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
if (code.length !== 6) {
setAuthStatus(t("wa_auth_enter_code_6digits"), true);
return;
}
state.update((s) => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_code"));
try {
const payload = { email: s.pendingEmail, code };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/verify", payload);
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
await loadData();
setAuthStatus("");
} catch (error) {
setAuthStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
} finally {
state.update((s) => ({ ...s, authBusy: false }));
}
}
async function openTelegramLogin(telegramOAuthClientId, getTelegramMiniAppInitData) {
const s = get(state);
if (s.authBusy || s.telegramLoginBusy) return;
setAuthStatus("");
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
if (!isTelegramMiniAppAttempt && telegramOAuthClientId) {
state.update((s) => ({ ...s, telegramLoginBusy: true }));
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
window.setTimeout(() => {
state.update((s) => ({ ...s, telegramLoginBusy: false }));
}, 1500);
return;
}
state.update((s) => ({ ...s, telegramLoginBusy: true }));
const attemptId = startTelegramLoginWatchdog();
const loginTimeout = telegramSdk.createMiniAppAuthTimeout();
try {
await Promise.race([
(async () => {
await telegramSdk.ensureForAction();
if (!isActiveTelegramLoginAttempt(attemptId)) return;
const initData = getTelegramMiniAppInitData();
if (initData) {
await finalizeTelegramAuth(initData, "init_data", { signal: loginTimeout.signal });
return;
}
if (!telegramOAuthClientId) {
setAuthStatus(t("wa_auth_telegram_not_configured"), true);
return;
}
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
})(),
loginTimeout.promise,
]);
} catch (error) {
if (!isActiveTelegramLoginAttempt(attemptId)) return;
if (error?.name === "AbortError") {
setAuthStatus(t("wa_auth_telegram_timeout"), true);
} else {
setAuthStatus(t("wa_auth_telegram_unavailable"), true);
}
} finally {
loginTimeout.clear();
if (loginTimeout.timedOut) {
setAuthStatus(t("wa_auth_telegram_timeout"), true);
state.update((s) => ({ ...s, authBusy: false }));
}
if (isActiveTelegramLoginAttempt(attemptId)) {
stopTelegramLoginWatchdog(attemptId);
state.update((s) => ({ ...s, telegramLoginBusy: false }));
}
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
finalizeMagicLogin,
finalizeTelegramAuth,
requestEmailCode,
verifyEmailCode,
openTelegramLogin,
clearCooldownTimer,
stopTelegramLoginWatchdog,
setAuthStatus,
};
}
@@ -0,0 +1,401 @@
import { writable, get } from "svelte/store";
export function createBillingStore({ billing, loadData, t, showToast, openExternalLink, tg }) {
const state = writable({
paymentModalOpen: false,
paymentStep: "tariff",
selectedTariffKey: "",
selectedPlan: null,
selectedMethod: "",
topupModalOpen: false,
topupKind: "regular",
deviceTopupModalOpen: false,
changeModalOpen: false,
topupOptions: null,
deviceTopupOptions: null,
changeOptions: null,
selectedTopupPlan: null,
selectedDeviceTopupPlan: null,
selectedChangeTarget: null,
selectedChangeAction: null,
changeConfirmOpen: false,
tariffActionBusy: false,
payBusy: false,
});
let topupOptionsRequestId = 0;
function openPaymentModal(
tariffMode,
singleTariffMode,
tariffCatalog,
subscription,
plans,
defaultMethod = ""
) {
state.update((s) => {
let step;
let plan = s.selectedPlan;
let tariffKey = s.selectedTariffKey;
if (tariffMode) {
if (singleTariffMode && tariffCatalog[0]?.key) {
tariffKey = tariffCatalog[0].key;
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
step = "checkout";
} else if (
subscription?.active &&
subscription?.tariff_key &&
tariffCatalog.some((t) => t.key === subscription.tariff_key)
) {
tariffKey = subscription.tariff_key;
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
step = "checkout";
} else {
step = "tariff";
tariffKey = "";
plan = null;
}
} else {
step = "checkout";
}
return {
...s,
paymentModalOpen: true,
paymentStep: step,
selectedTariffKey: tariffKey,
selectedPlan: plan,
selectedMethod: s.selectedMethod || defaultMethod,
};
});
}
function closePaymentModal() {
state.update((s) => ({ ...s, paymentModalOpen: false }));
}
function selectTariff(tariff, plans = []) {
const key = String(tariff?.key || "").trim();
if (!key) return;
state.update((s) => ({
...s,
selectedTariffKey: key,
selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null,
}));
}
function continueWithSelectedTariff(selectedTariffPlans = []) {
state.update((s) => {
if (!s.selectedTariffKey) return s;
return {
...s,
selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null,
paymentStep: "checkout",
};
});
}
function backToTariffList(subscription, tariffCatalog = []) {
if (
subscription?.active &&
subscription?.tariff_key &&
tariffCatalog.some((t) => t.key === subscription.tariff_key)
) {
return;
}
state.update((s) => ({ ...s, paymentStep: "tariff" }));
}
function openTopupModal(kind = "regular", defaultMethod = "") {
const normalizedKind = kind === "premium" ? "premium" : "regular";
state.update((s) => ({
...s,
topupKind: normalizedKind,
topupModalOpen: true,
topupOptions: s.topupOptions?.topup_kind === normalizedKind ? s.topupOptions : null,
selectedTopupPlan: s.topupOptions?.topup_kind === normalizedKind ? s.selectedTopupPlan : null,
selectedMethod: s.selectedMethod || defaultMethod,
}));
loadTopupOptions(normalizedKind);
}
function closeTopupModal() {
state.update((s) => ({ ...s, topupModalOpen: false }));
}
function openDeviceTopupModal(defaultMethod = "") {
state.update((s) => ({
...s,
deviceTopupModalOpen: true,
selectedMethod: s.selectedMethod || defaultMethod,
}));
loadDeviceTopupOptions();
}
function closeDeviceTopupModal() {
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
}
function openTariffChangeModal(defaultMethod = "") {
state.update((s) => ({
...s,
changeModalOpen: true,
selectedMethod: s.selectedMethod || defaultMethod,
}));
loadTariffChangeOptions();
}
function closeTariffChangeModal() {
state.update((s) => ({ ...s, changeModalOpen: false }));
}
function openTariffChangeConfirm() {
const s = get(state);
if (!s.selectedChangeTarget || !s.selectedChangeAction) return;
state.update((s) => ({ ...s, changeConfirmOpen: true }));
}
function closeTariffChangeConfirm() {
state.update((s) => ({ ...s, changeConfirmOpen: false }));
}
function openTelegramInvoice(url) {
if (!url) return;
if (tg?.openInvoice) {
tg.openInvoice(url, (status) => {
if (status === "paid") {
showToast(t("wa_payment_success", {}, "Payment successful"));
loadData();
} else if (status === "failed") {
showToast(t("wa_payment_create_failed"));
}
});
return;
}
openExternalLink(url);
}
async function createPayment() {
const s = get(state);
if (!s.selectedPlan || !s.selectedMethod || s.payBusy) return;
state.update((s) => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(
billing.planPaymentBody(s.selectedPlan, s.selectedMethod)
);
if (!response.ok) throw response;
showToast(t("wa_payment_created"));
if (response.action === "open_invoice") {
if (!response.payment_url) throw response;
openTelegramInvoice(response.payment_url);
} else if (response.action === "invoice_sent") {
state.update((s) => ({ ...s, paymentModalOpen: false }));
return;
} else {
if (!response.payment_url) throw response;
openExternalLink(response.payment_url);
}
state.update((s) => ({ ...s, paymentModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update((s) => ({ ...s, payBusy: false }));
}
}
async function loadTopupOptions(kind) {
const s = get(state);
if (s.topupOptions?.topup_kind === kind) return;
const requestId = ++topupOptionsRequestId;
state.update((s) => ({
...s,
tariffActionBusy: true,
topupOptions: null,
selectedTopupPlan: null,
}));
try {
const response = await billing.fetchTopupOptions(kind);
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
if (!response?.ok) throw response;
state.update((s) => ({
...s,
topupOptions: response,
selectedTopupPlan: response.plans?.[0] || null,
}));
} catch (error) {
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
showToast(error?.message || t("wa_tariff_options_failed"));
state.update((s) => ({ ...s, topupModalOpen: false }));
} finally {
if (requestId === topupOptionsRequestId) {
state.update((s) => ({ ...s, tariffActionBusy: false }));
}
}
}
async function createTopupPayment() {
const s = get(state);
if (!s.selectedTopupPlan || !s.selectedMethod || s.payBusy) return;
state.update((s) => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(
billing.topupPaymentBody(s.selectedTopupPlan, s.selectedMethod, s.topupOptions?.tariff_key)
);
if (!response.ok || !response.payment_url) throw response;
showToast(t("wa_payment_created"));
openExternalLink(response.payment_url);
state.update((s) => ({ ...s, topupModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update((s) => ({ ...s, payBusy: false }));
}
}
async function loadTariffChangeOptions() {
const s = get(state);
if (s.changeOptions || s.tariffActionBusy) return;
state.update((s) => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.fetchTariffChangeOptions();
if (!response?.ok) throw response;
state.update((s) => ({
...s,
changeOptions: response,
selectedChangeTarget: response.targets?.[0] || null,
selectedChangeAction: response.targets?.[0]?.actions?.[0] || null,
}));
} catch (error) {
showToast(error?.message || t("wa_tariff_options_failed"));
state.update((s) => ({ ...s, changeModalOpen: false }));
} finally {
state.update((s) => ({ ...s, tariffActionBusy: false }));
}
}
async function applyTariffChange() {
const s = get(state);
if (!s.selectedChangeTarget || !s.selectedChangeAction || s.tariffActionBusy) return;
if (s.selectedChangeAction.kind === "payment") {
await createTariffChangePayment();
return;
}
state.update((s) => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.postTariffChange({
tariff_key: s.selectedChangeTarget.tariff_key,
mode: s.selectedChangeAction.mode,
});
if (!response?.ok) throw response;
showToast(t("wa_tariff_change_applied"));
state.update((s) => ({
...s,
changeConfirmOpen: false,
changeModalOpen: false,
changeOptions: null,
}));
await loadData();
} catch (error) {
showToast(error?.message || t("wa_tariff_change_failed"));
} finally {
state.update((s) => ({ ...s, tariffActionBusy: false }));
}
}
async function createTariffChangePayment() {
const s = get(state);
if (!s.selectedChangeTarget || !s.selectedChangeAction || !s.selectedMethod || s.payBusy)
return;
state.update((s) => ({ ...s, payBusy: true }));
try {
const body = billing.changePaymentBody(
s.selectedChangeAction,
s.selectedChangeTarget,
s.selectedMethod
);
const response =
s.selectedChangeAction.mode === "buy_package" ||
s.selectedChangeAction.mode === "buy_period"
? await billing.postPayment(body)
: await billing.postTariffChangePayment(body);
if (!response.ok || !response.payment_url) throw response;
showToast(t("wa_payment_created"));
openExternalLink(response.payment_url);
state.update((s) => ({ ...s, changeConfirmOpen: false, changeModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update((s) => ({ ...s, payBusy: false }));
}
}
async function loadDeviceTopupOptions() {
const s = get(state);
if (s.deviceTopupOptions || s.tariffActionBusy) return;
state.update((s) => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.fetchDeviceTopupOptions();
if (!response?.ok) throw response;
state.update((s) => ({
...s,
deviceTopupOptions: response,
selectedDeviceTopupPlan: response.plans?.[0] || null,
}));
} catch (error) {
showToast(error?.message || t("wa_device_topup_options_failed"));
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
} finally {
state.update((s) => ({ ...s, tariffActionBusy: false }));
}
}
async function createDeviceTopupPayment() {
const s = get(state);
if (!s.selectedDeviceTopupPlan || !s.selectedMethod || s.payBusy) return;
state.update((s) => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(
billing.deviceTopupPaymentBody(
s.selectedDeviceTopupPlan,
s.selectedMethod,
s.deviceTopupOptions?.tariff_key
)
);
if (!response.ok || !response.payment_url) throw response;
showToast(t("wa_payment_created"));
openExternalLink(response.payment_url);
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update((s) => ({ ...s, payBusy: false }));
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
openPaymentModal,
closePaymentModal,
selectTariff,
continueWithSelectedTariff,
backToTariffList,
createPayment,
openTopupModal,
closeTopupModal,
loadTopupOptions,
createTopupPayment,
openTariffChangeModal,
closeTariffChangeModal,
openTariffChangeConfirm,
closeTariffChangeConfirm,
loadTariffChangeOptions,
applyTariffChange,
createTariffChangePayment,
openDeviceTopupModal,
closeDeviceTopupModal,
loadDeviceTopupOptions,
createDeviceTopupPayment,
};
}
@@ -0,0 +1,80 @@
import { writable, get } from "svelte/store";
export function createDevicesStore({ api, t, showToast }) {
const state = writable({
devicesData: null,
devicesLoaded: false,
devicesBusy: false,
devicesStatus: "",
devicesIsError: false,
deviceConfirmOpen: false,
deviceToDisconnect: null,
deviceDisconnectBusy: false,
});
async function loadDevices(devicesEnabled, force = false) {
const s = get(state);
if (!devicesEnabled || s.devicesBusy || (s.devicesLoaded && !force)) return;
state.update((s) => ({ ...s, devicesBusy: true, devicesStatus: "", devicesIsError: false }));
try {
const response = await api("/devices");
if (!response?.ok) throw response;
state.update((s) => ({ ...s, devicesData: response, devicesLoaded: true }));
} catch (error) {
state.update((s) => ({
...s,
devicesStatus: error?.message || t("wa_devices_load_failed"),
devicesIsError: true,
devicesLoaded: true,
}));
} finally {
state.update((s) => ({ ...s, devicesBusy: false }));
}
}
function openDeviceDisconnectDialog(device) {
state.update((s) => ({ ...s, deviceToDisconnect: device, deviceConfirmOpen: true }));
}
function closeDeviceDisconnectDialog() {
const s = get(state);
if (s.deviceDisconnectBusy) return;
state.update((s) => ({ ...s, deviceConfirmOpen: false, deviceToDisconnect: null }));
}
async function disconnectDevice(devicesEnabled) {
const s = get(state);
const token = String(s.deviceToDisconnect?.token || "").trim();
if (!token || s.deviceDisconnectBusy) return;
state.update((s) => ({ ...s, deviceDisconnectBusy: true }));
try {
const response = await api("/devices/disconnect", {
method: "POST",
body: JSON.stringify({ token }),
});
if (!response?.ok) throw response;
showToast(t("wa_device_disconnected"));
state.update((s) => ({
...s,
deviceConfirmOpen: false,
deviceToDisconnect: null,
devicesLoaded: false,
}));
await loadDevices(devicesEnabled, true);
} catch (error) {
showToast(error?.message || t("wa_device_disconnect_failed"));
} finally {
state.update((s) => ({ ...s, deviceDisconnectBusy: false }));
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadDevices,
openDeviceDisconnectDialog,
closeDeviceDisconnectDialog,
disconnectDevice,
};
}
+145
View File
@@ -0,0 +1,145 @@
import { formatMoney, formatTrafficGb } from "./formatters.js";
export function planKey(plan) {
return (
plan?.id ||
`${plan?.tariff_key || "legacy"}:${plan?.sale_mode || "subscription"}:${plan?.months || plan?.traffic_gb || ""}`
);
}
export function buildTariffCatalog(planList) {
const byKey = new Map();
for (const plan of planList || []) {
const key = String(plan?.tariff_key || planKey(plan) || "").trim();
if (!key) continue;
const entry = byKey.get(key) || {
key,
title: plan?.tariff_name || plan?.title || key,
description: plan?.description || "",
billing_model:
plan?.billing_model ||
(plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic"
? "traffic"
: "period"),
monthly_gb: Number(plan?.monthly_gb || 0),
traffic_packages: [],
plans_count: 0,
};
if (!entry.description && plan?.description) entry.description = plan.description;
if (!entry.monthly_gb && Number(plan?.monthly_gb || 0) > 0)
entry.monthly_gb = Number(plan.monthly_gb);
const trafficGb = Number(plan?.traffic_gb || 0);
if (trafficGb > 0) entry.traffic_packages.push(trafficGb);
entry.plans_count += 1;
byKey.set(key, entry);
}
return Array.from(byKey.values());
}
export function activeTariffName(sub, planList) {
const direct = String(sub?.tariff_name || "").trim();
if (direct) return direct;
const key = String(sub?.tariff_key || "").trim();
if (!key) return "";
const plan = (planList || []).find((item) => item?.tariff_key === key);
return String(plan?.tariff_name || plan?.title || key).trim();
}
export function priceLabel(plan, methodId = "") {
if (
String(methodId || "")
.toLowerCase()
.includes("stars") &&
Number(plan?.stars_price || 0) > 0
) {
return `${Number(plan.stars_price)}`;
}
return formatMoney(plan?.price || 0, plan?.currency);
}
export function tariffLimitLabel(tariff, { t }) {
if (!tariff) return "";
if (String(tariff.billing_model || "") === "traffic") {
const values = (tariff.traffic_packages || [])
.filter((value) => Number(value) > 0)
.sort((a, b) => a - b);
if (!values.length) return t("wa_tariff_model_traffic");
const min = values[0];
const max = values[values.length - 1];
return min === max ? formatTrafficGb(min) : `${formatTrafficGb(min)} - ${formatTrafficGb(max)}`;
}
if (Number(tariff.monthly_gb || 0) > 0) return formatTrafficGb(tariff.monthly_gb);
return t("wa_unlimited_traffic");
}
export function actionKey(action) {
return `${action?.mode || ""}:${action?.months || ""}:${action?.traffic_gb || ""}:${action?.price || ""}`;
}
function formatMonthsForClient(value, { t, termUnitLabel }) {
const months = Number(value || 0);
if (months === 12) return t("wa_plan_one_year");
return t("wa_sub_term_value_unit", {
value: String(months),
unit: termUnitLabel(months, "month"),
});
}
export function planDisplayTitle(plan, { trafficMode, t }) {
if (plan?.tariff_key) {
return plan?.tariff_name || plan?.title || plan?.tariff_key;
}
if (trafficMode || plan?.sale_mode === "traffic") {
return plan?.title || formatTrafficGb(plan?.traffic_gb || plan?.months);
}
const months = Number(plan?.months || 0);
if (months === 12) return t("wa_plan_one_year");
return plan?.title || "";
}
export function planSubtitle(plan, { t, termUnitLabel }) {
if (!plan?.tariff_key) return "";
if (plan?.subtitle) return plan.subtitle;
if (
plan?.sale_mode === "traffic_package" ||
plan?.sale_mode === "topup" ||
plan?.sale_mode === "premium_topup" ||
plan?.billing_model === "traffic"
) {
return formatTrafficGb(plan?.traffic_gb || plan?.months);
}
return formatMonthsForClient(plan?.months, { t, termUnitLabel });
}
export function planUnitHint(plan, { trafficMode, selectedMethod, t }) {
if (
trafficMode ||
plan?.sale_mode === "traffic" ||
plan?.sale_mode === "traffic_package" ||
plan?.sale_mode === "topup" ||
plan?.sale_mode === "premium_topup"
) {
const gb = Number(plan?.traffic_gb || plan?.months || 0);
if (!gb) return "";
if (
String(selectedMethod || "")
.toLowerCase()
.includes("stars") &&
Number(plan?.stars_price || 0) > 0
) {
return `${Number(plan.stars_price / gb).toFixed(0)}${t("wa_per_gb_short")}`;
}
return `${formatMoney(Number(plan?.price || 0) / gb, plan?.currency)}${t("wa_per_gb_short")}`;
}
const months = Number(plan?.months || 0);
if (!months || months <= 1) return "";
if (
String(selectedMethod || "")
.toLowerCase()
.includes("stars") &&
Number(plan?.stars_price || 0) > 0
) {
return `${Number(plan.stars_price / months).toFixed(0)}${t("wa_per_month_short")}`;
}
return `${formatMoney(Number(plan?.price || 0) / months, plan?.currency)}${t("wa_per_month_short")}`;
}
+169
View File
@@ -0,0 +1,169 @@
export function readTelegramMiniAppInitDataFromLocation() {
if (typeof window === "undefined") return "";
const queryText = window.location.search.replace(/^\?/, "");
const hashText = window.location.hash.replace(/^#/, "");
for (const text of [queryText, hashText]) {
if (!text) continue;
const params = new URLSearchParams(text);
const initData = params.get("tgWebAppData");
if (initData) return initData;
}
return "";
}
export function createTelegramSdk({
scriptUrl,
bootTimeoutMs,
actionTimeoutMs,
miniAppAuthTimeoutMs,
onStatusChange = () => {},
onInitDataChange = () => {},
} = {}) {
let tg = resolve();
let sdkPromise = null;
let launchParamsDetected = false;
let initData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
if (initData) launchParamsDetected = true;
function resolve() {
return window.Telegram?.WebApp || null;
}
function setStatus(status) {
onStatusChange(status);
}
function refresh() {
tg = resolve();
if (tg) setStatus("ready");
initData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
onInitDataChange(initData);
if (initData) launchParamsDetected = true;
return tg;
}
function hasLaunchParams() {
refresh();
if (launchParamsDetected || initData) {
launchParamsDetected = true;
return true;
}
const queryText = window.location.search.replace(/^\?/, "");
const hashText = window.location.hash.replace(/^#/, "");
const detected = [queryText, hashText].some((text) => {
if (!text) return false;
const params = new URLSearchParams(text);
return ["tgWebAppData", "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams"].some(
(key) => params.has(key)
);
});
if (detected) launchParamsDetected = true;
return detected;
}
function load(timeoutMs = bootTimeoutMs) {
if (refresh()) return Promise.resolve(tg);
if (sdkPromise) return sdkPromise;
if (typeof document === "undefined") return Promise.resolve(null);
setStatus("loading");
sdkPromise = new Promise((resolvePromise) => {
const existingScript = document.querySelector("script[data-rw-telegram-web-app-sdk]");
const script = existingScript || document.createElement("script");
let resolved = false;
let timeoutId = null;
const resolveOnce = (value) => {
if (resolved) return;
resolved = true;
if (timeoutId) window.clearTimeout(timeoutId);
resolvePromise(value);
};
const refreshFromScript = () => {
tg = resolve();
setStatus(tg ? "ready" : "unavailable");
return tg;
};
script.addEventListener("load", () => resolveOnce(refreshFromScript()), { once: true });
script.addEventListener(
"error",
() => {
setStatus("unavailable");
resolveOnce(null);
},
{ once: true }
);
if (!existingScript) {
script.src = scriptUrl;
script.async = true;
script.defer = true;
script.dataset.rwTelegramWebAppSdk = "1";
document.head.appendChild(script);
}
timeoutId = window.setTimeout(() => {
if (!tg) setStatus("unavailable");
resolveOnce(tg);
}, timeoutMs);
}).finally(() => {
sdkPromise = null;
});
return sdkPromise;
}
async function ensureForAction() {
if (refresh()) return tg;
return await load(actionTimeoutMs);
}
function createMiniAppAuthTimeout() {
const controller = typeof AbortController === "undefined" ? null : new AbortController();
let timedOut = false;
let timeoutId = null;
let timeoutPromise = new Promise(() => {});
if (typeof window !== "undefined") {
timeoutPromise = new Promise((_, reject) => {
timeoutId = window.setTimeout(() => {
timedOut = true;
controller?.abort();
const error = new Error("telegram_mini_app_auth_timeout");
error.name = "AbortError";
reject(error);
}, miniAppAuthTimeoutMs);
});
}
return {
promise: timeoutPromise,
get signal() {
return controller?.signal;
},
get timedOut() {
return timedOut;
},
clear() {
if (timeoutId) window.clearTimeout(timeoutId);
timeoutId = null;
},
};
}
return {
get tg() {
return tg;
},
get initData() {
return initData;
},
refresh,
hasLaunchParams,
load,
ensureForAction,
createMiniAppAuthTimeout,
readInitDataFromLocation: readTelegramMiniAppInitDataFromLocation,
};
}
+163
View File
@@ -0,0 +1,163 @@
/** Maps JSON theme token keys to CSS custom properties used by the Mini App shell. */
const TOKEN_TO_CSS_VAR = {
accent: "--accent",
bg: "--bg",
panel: "--panel",
panel_2: "--panel-2",
panel_3: "--panel-3",
border: "--border",
border_strong: "--border-strong",
text: "--text",
muted: "--muted",
dim: "--dim",
danger: "--danger",
danger_text: "--danger-text",
danger_soft: "--danger-soft",
danger_border: "--danger-border",
success: "--success",
success_text: "--success-text",
success_soft: "--success-soft",
success_border: "--success-border",
warning: "--warning",
warning_text: "--warning-text",
warning_soft: "--warning-soft",
warning_border: "--warning-border",
info: "--info",
info_text: "--info-text",
info_soft: "--info-soft",
info_border: "--info-border",
blue: "--blue",
radius: "--radius",
font_sans: "--font-sans",
font_logo: "--font-logo",
font_mono: "--font-mono",
home_logo_scale: "--home-logo-scale",
admin_bg: "--admin-bg",
admin_surface: "--admin-surface",
admin_surface_2: "--admin-surface-2",
admin_elev: "--admin-elev",
admin_border: "--admin-border",
admin_border_strong: "--admin-border-strong",
admin_text: "--admin-text",
admin_muted: "--admin-muted",
admin_dim: "--admin-dim",
};
export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", options = {}) {
const t = tokens && typeof tokens === "object" ? tokens : {};
const parts = [];
const useFallbackAccent = options.fallbackAccent !== false;
const accent = t.accent || (useFallbackAccent ? primaryFallback || "#00fe7a" : "");
if (accent) parts.push(`--accent:${accent}`);
for (const [key, cssVar] of Object.entries(TOKEN_TO_CSS_VAR)) {
if (key === "accent") continue;
const value = t[key];
if (value === undefined || value === null || value === "") continue;
if (key === "home_logo_scale") {
const scale = Number(value);
if (!Number.isFinite(scale) || scale <= 0) continue;
parts.push(`${cssVar}:${scale / 100}`);
continue;
}
parts.push(`${cssVar}:${String(value)}`);
}
return parts.join(";");
}
export function findThemeEntry(themesCatalog, key) {
const themes = themesCatalog?.themes || [];
return themes.find((entry) => entry && entry.key === key) || null;
}
export function resolveEffectiveThemeKey(themesCatalog) {
const themes = themesCatalog?.themes || [];
const byKey = (k) => themes.find((entry) => entry.key === k);
const def = themesCatalog?.default_theme || themes[0]?.key || "dark";
return byKey(def) ? def : themes[0]?.key || "dark";
}
export function themePresetClass(tokens) {
const preset = String(tokens?.style_preset || "")
.trim()
.toLowerCase();
if (!preset || preset === "none") return "";
if (preset === "win95" || preset === "windows95") return "theme-preset-win95";
return "";
}
export function themeKeyClass(key) {
const safe = String(key || "")
.trim()
.toLowerCase()
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return safe ? `theme-key-${safe}` : "";
}
export function themeCssClass(cssFile) {
const filename = String(cssFile || "")
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.pop();
const slug = String(filename || "")
.replace(/\.css$/i, "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug ? `theme-css-${slug}` : "";
}
export function themeRootClass(theme) {
return [
themeKeyClass(theme?.key),
themeCssClass(theme?.css_file),
themePresetClass(theme?.tokens),
]
.filter(Boolean)
.join(" ");
}
export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") {
return themeTokensToInlineStyle(theme?.tokens, primaryFallback, {
fallbackAccent: !theme?.css_file,
});
}
function encodeThemeCssPath(path) {
return String(path || "")
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
}
export function themeCssHref(theme) {
const cssFile = String(theme?.css_file || "").trim();
if (!cssFile) return "";
if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return "";
if (cssFile.startsWith("/")) return cssFile;
const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
const key = String(theme?.key || "")
.trim()
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
const themedPath =
key && normalizedCssFile.split("/")[0] !== key
? `${key}/${normalizedCssFile}`
: normalizedCssFile;
const encoded = encodeThemeCssPath(themedPath);
return encoded ? `/webapp-theme-css/${encoded}` : "";
}
export function localizedThemeName(theme, lang = "en") {
const names = theme?.names || {};
const key = String(lang || "")
.trim()
.toLowerCase();
const base = key.split("-")[0];
return names[key] || names[base] || names.en || theme?.key || "";
}
+108
View File
@@ -0,0 +1,108 @@
import { formatTrafficBytes, formatFraction, roundToHalf } from "./formatters.js";
export function trafficPercent(sub) {
const used = Number(sub?.traffic_used_bytes || 0);
const limit = Number(sub?.traffic_limit_bytes || 0);
if (!limit || limit <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
}
export function trafficLabel(sub, t) {
if (!sub?.traffic_limit_bytes || Number(sub.traffic_limit_bytes) <= 0)
return t("wa_unlimited_traffic");
return t("wa_traffic_of", {
used: sub.traffic_used || "0 GB",
limit: sub.traffic_limit || "0 GB",
});
}
export function trafficResetLabel(sub, t) {
const strategy = String(sub?.traffic_limit_strategy || "")
.trim()
.toUpperCase();
if (!strategy || strategy.includes("NO_RESET")) return t("wa_traffic_reset_none");
if (strategy.includes("MONTH")) return t("wa_traffic_reset_monthly");
if (strategy.includes("WEEK")) return t("wa_traffic_reset_weekly");
if (strategy.includes("DAY")) return t("wa_traffic_reset_daily");
if (strategy.includes("YEAR")) return t("wa_traffic_reset_yearly");
return t("wa_traffic_reset_policy");
}
export function premiumTrafficPercent(sub) {
const used = Number(sub?.premium_used_bytes || 0);
const limit = Number(sub?.premium_limit_bytes || 0);
if (!limit || limit <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
}
export function premiumTrafficLabel(sub, t) {
return t("wa_traffic_of", {
used: sub?.premium_used || "0 GB",
limit: sub?.premium_limit || "0 GB",
});
}
export function premiumTitle(sub, t) {
return (
String(sub?.premium_title || "").trim() || t("wa_premium_traffic_title", {}, "Premium-серверы")
);
}
export function premiumTrafficLeftLabel(sub) {
const left = Math.max(
0,
Number(sub?.premium_limit_bytes || 0) - Number(sub?.premium_used_bytes || 0)
);
return formatTrafficBytes(left);
}
export function premiumTopupBalanceLabel(sub) {
return formatTrafficBytes(Number(sub?.premium_topup_balance_bytes || 0));
}
export function premiumServerLabels(sub) {
const labels =
Array.isArray(sub?.premium_node_labels) && sub.premium_node_labels.length
? sub.premium_node_labels
: sub?.premium_squad_labels || [];
return labels.map((label) => String(label || "").trim()).filter(Boolean);
}
function extractYear(text) {
const iso = String(text || "").match(/\b(\d{4})-\d{1,2}-\d{1,2}\b/);
if (iso) return Number(iso[1] || 0);
const dmy = String(text || "").match(/\b\d{1,2}\.\d{1,2}\.(\d{4})\b/);
if (dmy) return Number(dmy[1] || 0);
const any4 = String(text || "").match(/\b(\d{4})\b/);
if (any4) return Number(any4[1] || 0);
return 0;
}
export function isForeverSubscription(sub) {
const raw = String(sub?.end_date_text || "").trim();
if (!raw) return false;
return extractYear(raw) >= 2099;
}
export function activeSubscriptionTermLabel(sub, { t, termUnitLabel }) {
if (isForeverSubscription(sub)) return t("wa_sub_term_forever");
const days = Math.max(0, Number(sub?.days_left || 0));
if (!days) return t("wa_sub_term_value_unit", { value: "0", unit: termUnitLabel(0, "day") });
if (days < 30) {
return t("wa_sub_term_value_unit", { value: String(days), unit: termUnitLabel(days, "day") });
}
if (days < 365) {
const months = roundToHalf(days / 30);
return t("wa_sub_term_value_unit", {
value: formatFraction(months),
unit: termUnitLabel(months, "month"),
});
}
const years = roundToHalf(days / 365);
return t("wa_sub_term_value_unit", {
value: formatFraction(years),
unit: termUnitLabel(years, "year"),
});
}
+91
View File
@@ -0,0 +1,91 @@
import {
readMagicLoginToken,
readTelegramAuthStatus,
readTelegramLoginWidgetAuthData,
clearAuthQuery,
} from "./authHelpers.js";
import { TELEGRAM_SDK_BOOT_TIMEOUT_MS } from "./constants.js";
/**
* Initial auth / session bootstrap for the subscription webapp (non-preview).
* Keeps side effects in App (mode, tg, token) via injected callbacks.
*/
export async function runWebappBoot({
MOCK,
setMode,
hasTelegramLaunchParams,
loadTelegramSdk,
prepareTelegramMiniApp,
loadData,
showLogin,
clearToken,
clearManualLogoutFlag,
isManuallyLoggedOut,
finalizeMagicLogin,
finalizeTelegramAuth,
setAuthStatus,
t,
getInitDataForBoot,
getToken,
getCsrfToken,
}) {
setMode("loading");
if (hasTelegramLaunchParams()) await loadTelegramSdk(TELEGRAM_SDK_BOOT_TIMEOUT_MS);
prepareTelegramMiniApp();
if (MOCK) {
await loadData();
return;
}
const magicToken = readMagicLoginToken();
if (magicToken && (await finalizeMagicLogin(magicToken))) return;
const telegramAuthStatus = readTelegramAuthStatus();
if (telegramAuthStatus === "success") {
clearManualLogoutFlag();
clearAuthQuery();
try {
await loadData();
return;
} catch {
clearToken();
}
} else if (telegramAuthStatus) {
clearAuthQuery();
setAuthStatus(
telegramAuthStatus === "cancelled"
? t("wa_auth_telegram_cancelled")
: t("wa_auth_telegram_not_confirmed"),
true
);
}
if (isManuallyLoggedOut()) {
showLogin();
return;
}
const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
const initData = getInitDataForBoot();
if (initData) {
try {
if (await finalizeTelegramAuth(initData, "init_data")) return;
} catch (_error) {
void _error;
}
}
if (getToken() || getCsrfToken()) {
try {
await loadData();
return;
} catch {
clearToken();
}
}
showLogin();
}