refactor: slice web app wip

This commit is contained in:
3252a8
2026-05-11 21:41:31 +03:00
parent 7d8bbbc904
commit ed6b214b29
48 changed files with 5367 additions and 4106 deletions
@@ -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 }) {