refactor: slice web app wip
This commit is contained in:
@@ -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, at }) {
|
||||
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, at }) {
|
||||
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,92 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createSettingsStore({ api, onToast, at }) {
|
||||
const state = writable({
|
||||
settingsSections: [],
|
||||
settingsLoading: false,
|
||||
settingsDirty: {},
|
||||
settingsSaving: false,
|
||||
});
|
||||
|
||||
async function loadSettings(isCompact = false) {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSettings(onSettingsSaved) {
|
||||
let dirty = {};
|
||||
state.update(s => { dirty = s.settingsDirty; return s; });
|
||||
if (!Object.keys(dirty).length) return;
|
||||
|
||||
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();
|
||||
} else if (res?.errors) {
|
||||
const summary = Object.entries(res.errors).map(([k, v]) => `${k}: ${v}`).join("; ");
|
||||
onToast(`Ошибки: ${summary}`);
|
||||
} else {
|
||||
onToast(res?.error || "Ошибка");
|
||||
}
|
||||
} 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,
|
||||
resetField,
|
||||
saveSettings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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,255 @@
|
||||
import { writable } from "svelte/store";
|
||||
import {
|
||||
emptyTariffDraft,
|
||||
cloneCatalog,
|
||||
draftFromTariff,
|
||||
tariffFromDraft as tariffFromDraftFn,
|
||||
normalizeUuidList,
|
||||
} from "../tariffDraft.js";
|
||||
|
||||
export function createTariffsStore({ api, onToast, 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 (e) {
|
||||
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,295 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createUsersStore({ api, onToast, at }) {
|
||||
const USERS_PAGE_SIZE = 25;
|
||||
|
||||
const state = writable({
|
||||
users: [],
|
||||
usersTotal: 0,
|
||||
usersPage: 0,
|
||||
usersQuery: "",
|
||||
usersFilter: "all",
|
||||
usersPanelStatus: "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",
|
||||
});
|
||||
|
||||
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.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",
|
||||
}));
|
||||
|
||||
if (!opts.skipPush) _pushUserPath(userId);
|
||||
try {
|
||||
const res = await api(`/admin/users/${userId}`);
|
||||
if (res?.ok) {
|
||||
state.update(s => ({
|
||||
...s,
|
||||
openedUserDetail: res,
|
||||
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser
|
||||
}));
|
||||
} 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,
|
||||
};
|
||||
});
|
||||
if (wasOpen && !opts.skipPush) _pushUserPath(null);
|
||||
}
|
||||
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
@@ -28,11 +28,11 @@
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}>
|
||||
<a class={cn(variants[variant], sizes[size], className)} {href} onclick={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}>
|
||||
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} onclick={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
{#if open}
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button>
|
||||
<button class="dialog-backdrop" type="button" aria-label={closeLabel} onclick={onclose}></button>
|
||||
<section class={cn("dialog-card", className)}>
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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?.token) setToken(response.token, 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?.token) setToken(response.token, 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 {}
|
||||
showLogin();
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
openLinkEmailDialog,
|
||||
closeLinkEmailDialog,
|
||||
requestLinkEmailCode,
|
||||
verifyLinkEmailCode,
|
||||
linkTelegramAccount,
|
||||
updateAccountLanguage,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import {
|
||||
readReferralParam,
|
||||
readTelegramAuthStatus,
|
||||
readMagicLoginToken,
|
||||
readTelegramLoginWidgetAuthData,
|
||||
clearAuthQuery,
|
||||
buildTelegramOAuthStartUrl,
|
||||
emailError,
|
||||
} from "../authHelpers.js";
|
||||
|
||||
export function createAuthStore({
|
||||
publicApi,
|
||||
setToken,
|
||||
loadData,
|
||||
telegramSdk,
|
||||
getTg,
|
||||
t,
|
||||
currentLang,
|
||||
clearManualLogoutFlag
|
||||
}) {
|
||||
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.token) {
|
||||
setToken(response.token, 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.token) {
|
||||
setToken(response.token, 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.token) throw response;
|
||||
setToken(response.token, 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,
|
||||
setAuthStatus
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
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 = s.paymentStep;
|
||||
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,100 @@
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
function devicesLimitLabel() {
|
||||
const s = get(state);
|
||||
const value = s.devicesData?.max_devices;
|
||||
const numeric = Number(value ?? 0);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
|
||||
return String(Math.trunc(numeric));
|
||||
}
|
||||
|
||||
function devicesCountLabel() {
|
||||
const s = get(state);
|
||||
const current = Number(s.devicesData?.current_devices ?? s.devicesData?.devices?.length ?? 0);
|
||||
return t("wa_devices_count", { current, max: devicesLimitLabel() });
|
||||
}
|
||||
|
||||
function devicesPercent() {
|
||||
const s = get(state);
|
||||
const current = Number(s.devicesData?.current_devices ?? s.devicesData?.devices?.length ?? 0);
|
||||
const max = Number(s.devicesData?.max_devices || 0);
|
||||
if (!max || max <= 0) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadDevices,
|
||||
openDeviceDisconnectDialog,
|
||||
closeDeviceDisconnectDialog,
|
||||
disconnectDevice,
|
||||
devicesLimitLabel,
|
||||
devicesCountLabel,
|
||||
devicesPercent,
|
||||
};
|
||||
}
|
||||
@@ -71,11 +71,10 @@ export function actionKey(action) {
|
||||
return `${action?.mode || ""}:${action?.months || ""}:${action?.traffic_gb || ""}:${action?.price || ""}`;
|
||||
}
|
||||
|
||||
function formatMonthsForClient(value, lang) {
|
||||
function formatMonthsForClient(value, { t, termUnitLabel }) {
|
||||
const months = Number(value || 0);
|
||||
if (months === 1) return lang === "en" ? "1 month" : "1 месяц";
|
||||
if (months === 12) return lang === "en" ? "1 year" : "1 год";
|
||||
return lang === "en" ? `${months} months` : `${months} мес.`;
|
||||
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 }) {
|
||||
@@ -90,7 +89,7 @@ export function planDisplayTitle(plan, { trafficMode, t }) {
|
||||
return plan?.title || "";
|
||||
}
|
||||
|
||||
export function planSubtitle(plan, { lang }) {
|
||||
export function planSubtitle(plan, { t, termUnitLabel }) {
|
||||
if (!plan?.tariff_key) return "";
|
||||
if (plan?.subtitle) return plan.subtitle;
|
||||
if (
|
||||
@@ -101,7 +100,7 @@ export function planSubtitle(plan, { lang }) {
|
||||
) {
|
||||
return formatTrafficGb(plan?.traffic_gb || plan?.months);
|
||||
}
|
||||
return formatMonthsForClient(plan?.months, lang);
|
||||
return formatMonthsForClient(plan?.months, { t, termUnitLabel });
|
||||
}
|
||||
|
||||
export function planUnitHint(plan, { trafficMode, selectedMethod, t }) {
|
||||
|
||||
Reference in New Issue
Block a user