refactor: slice web app wip

This commit is contained in:
3252a8
2026-05-11 08:31:07 +03:00
parent 763c4ad1bb
commit 7d8bbbc904
19 changed files with 1619 additions and 1271 deletions
@@ -0,0 +1,111 @@
import { rememberReferral, readReferral } from "./session.js";
export function readReferralParam(tg) {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get("ref") || params.get("start") || params.get("start_param") || "";
const fromTelegram = tg?.initDataUnsafe?.start_param || "";
const value = String(fromTelegram || fromQuery || "").trim();
return value ? rememberReferral(value) : readReferral();
}
export function readTelegramAuthStatus() {
const params = new URLSearchParams(window.location.search);
return (params.get("telegram_auth") || "").trim().toLowerCase() || null;
}
export function readMagicLoginToken() {
const params = new URLSearchParams(window.location.search);
return (params.get("login_token") || "").trim() || null;
}
export function readTelegramLoginWidgetAuthData() {
const params = new URLSearchParams(window.location.search);
const keys = ["id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"];
const authData = {};
let hasAuthValue = false;
keys.forEach((key) => {
if (!params.has(key)) return;
authData[key] = params.get(key) || "";
hasAuthValue = true;
});
if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) return null;
return authData;
}
export function clearAuthQuery() {
const url = new URL(window.location.href);
[
"login_token",
"login_purpose",
"telegram_auth",
"id",
"first_name",
"last_name",
"username",
"photo_url",
"auth_date",
"hash",
].forEach((key) => url.searchParams.delete(key));
window.history?.replaceState?.({}, document.title, url.pathname + url.search + url.hash);
}
export function buildTelegramOAuthStartUrl(purpose = "login", tg = null) {
const url = new URL("/auth/telegram/start", window.location.origin);
url.searchParams.set("purpose", purpose);
const referralParam = readReferralParam(tg);
if (referralParam) url.searchParams.set("referral_code", referralParam);
return url.toString();
}
export function emailError(error, fallback, t) {
if (error?.error === "rate_limited")
return t("wa_auth_resend_wait", { seconds: error.retry_after || 60 });
if (error?.error === "invalid_email") return t("wa_auth_invalid_email");
if (error?.error === "expired_code") return t("wa_auth_code_expired");
if (error?.error === "invalid_code" || error?.error === "too_many_attempts")
return t("wa_auth_invalid_code");
return fallback;
}
export function createCooldownTimer() {
let timer = null;
let cooldown = 0;
const listeners = new Set();
function notify() {
for (const fn of listeners) fn(cooldown);
}
function clear() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
function start(seconds = 60) {
clear();
cooldown = Math.max(0, Number(seconds || 60));
notify();
timer = window.setInterval(() => {
if (cooldown <= 1) {
cooldown = 0;
clear();
notify();
return;
}
cooldown -= 1;
notify();
}, 1000);
}
function subscribe(listener) {
listeners.add(listener);
listener(cooldown);
return () => listeners.delete(listener);
}
return {
start,
clear,
subscribe,
get value() {
return cooldown;
},
};
}
@@ -0,0 +1,89 @@
export function createBillingActions({ api, t }) {
async function fetchTopupOptions(kind) {
return api(`/tariffs/topup-options?kind=${encodeURIComponent(kind)}`);
}
async function fetchDeviceTopupOptions() {
return api("/devices/topup-options");
}
async function fetchTariffChangeOptions() {
return api("/tariffs/change-options");
}
async function postPayment(body) {
return api("/payments", { method: "POST", body: JSON.stringify(body) });
}
async function postTariffChange(body) {
return api("/tariffs/change", { method: "POST", body: JSON.stringify(body) });
}
async function postTariffChangePayment(body) {
return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) });
}
function planPaymentBody(plan, method) {
return {
months: plan.months,
traffic_gb: plan.traffic_gb,
device_count: plan.device_count,
tariff_key: plan.tariff_key,
sale_mode: plan.sale_mode,
method,
};
}
function topupPaymentBody(plan, method, fallbackTariffKey) {
return {
months: plan.months,
traffic_gb: plan.traffic_gb,
tariff_key: plan.tariff_key || fallbackTariffKey,
sale_mode: plan.sale_mode || "topup",
method,
};
}
function deviceTopupPaymentBody(plan, method, fallbackTariffKey) {
return {
months: plan.device_count || plan.months,
device_count: plan.device_count || plan.months,
tariff_key: plan.tariff_key || fallbackTariffKey,
sale_mode: "hwid_devices",
method,
};
}
function changePaymentBody(action, target, method) {
if (action.mode === "buy_package") {
return {
tariff_key: target.tariff_key,
traffic_gb: action.traffic_gb,
months: action.traffic_gb,
sale_mode: "topup",
method,
};
}
if (action.mode === "buy_period") {
return {
tariff_key: target.tariff_key,
months: action.months,
method,
};
}
return { tariff_key: target.tariff_key, method };
}
return {
fetchTopupOptions,
fetchDeviceTopupOptions,
fetchTariffChangeOptions,
postPayment,
postTariffChange,
postTariffChangePayment,
planPaymentBody,
topupPaymentBody,
deviceTopupPaymentBody,
changePaymentBody,
};
}
@@ -0,0 +1,54 @@
export function formatTemplate(template, params = {}) {
const text = String(template ?? "");
return text.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
export function formatMoney(value, currency = "RUB") {
const numeric = Number(value || 0);
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
const symbol = currency === "RUB" ? "₽" : currency;
return `${formatted} ${symbol}`;
}
export function formatTrafficGb(value) {
const numeric = Number(value || 0);
const formatted = Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
return `${formatted} GB`;
}
export function formatTrafficBytes(value) {
const gb = Number(value || 0) / 1073741824;
return formatTrafficGb(gb);
}
export function formatCompactNumber(value) {
const numeric = Number(value || 0);
return Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
}
export function roundToHalf(value) {
return Math.round(Number(value || 0) * 2) / 2;
}
export function formatFraction(value) {
const n = Number(value || 0);
if (Number.isInteger(n)) return String(n);
return n.toFixed(1);
}
export function normalizedEmail(value) {
return String(value || "").trim().toLowerCase();
}
export function telegramName(profile, fallback) {
const first = String(profile?.first_name || "").trim();
const last = String(profile?.last_name || "").trim();
if (first || last) return `${first} ${last}`.trim();
const username = String(profile?.username || "").trim();
if (username) return `@${username}`;
return fallback;
}
@@ -0,0 +1,19 @@
function bytesToHex(buffer) {
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function sha256Hex(value) {
const data = new TextEncoder().encode(value);
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
return bytesToHex(hashBuffer);
}
export async function buildGravatarUrl(emailValue) {
if (!emailValue || !window.crypto?.subtle) return "";
try {
const hash = await sha256Hex(emailValue);
return `https://www.gravatar.com/avatar/${hash}?d=mp&s=160`;
} catch {
return "";
}
}
@@ -0,0 +1,47 @@
import { LANGUAGE_LABELS } from "./constants.js";
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
import { unitPluralBucket } from "./plurals.js";
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
function normalizeLangCode(lang) {
const key = String(lang || "").trim().toLowerCase();
if (!key) return defaultLang;
const base = key.split("-")[0];
if (LANGUAGE_LABELS[base]) return base;
if (messages[base]) return base;
if (messages[key]) return key;
return defaultLang;
}
function currentLang() {
return normalizeLangCode(typeof getLang === "function" ? getLang() : defaultLang);
}
function t(key, params = {}, fallback = "") {
const lang = currentLang();
const variants = [
messages?.[lang]?.[key],
messages?.en?.[key],
messages?.ru?.[key],
fallback,
key,
];
const raw = variants.find((value) => typeof value === "string" && value.length);
return formatTemplate(raw, params);
}
function languageName(code) {
const key = String(code || "").trim().toLowerCase();
if (!key) return t("wa_language_default");
return LANGUAGE_LABELS[key] || key.toUpperCase();
}
function termUnitLabel(value, unit) {
const bucket = unitPluralBucket(value, currentLang());
return t(`wa_sub_term_${unit}_${bucket}`);
}
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
}
export { formatFraction, roundToHalf };
@@ -0,0 +1,33 @@
export function ruPlural(value, one, few, many) {
const n = Math.abs(Number(value || 0));
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few;
return many;
}
export function ruFractionAware(value, one, few, many) {
const n = Number(value || 0);
if (!Number.isInteger(n)) return few;
return ruPlural(n, one, few, many);
}
export function unitPluralBucket(value, lang) {
if (String(lang || "").toLowerCase() === "ru") {
const n = Number(value || 0);
if (!Number.isInteger(n)) {
const base = Math.floor(Math.abs(n));
const mod10 = base % 10;
const mod100 = base % 100;
return mod10 >= 1 && mod10 <= 4 && (mod100 < 11 || mod100 > 14) ? "few" : "many";
}
const abs = Math.abs(n);
const mod10 = abs % 10;
const mod100 = abs % 100;
if (mod10 === 1 && mod100 !== 11) return "one";
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
return "many";
}
return Number(value) === 1 ? "one" : "many";
}
@@ -0,0 +1,128 @@
import { formatMoney, formatTrafficGb } from "./formatters.js";
export function planKey(plan) {
return (
plan?.id ||
`${plan?.tariff_key || "legacy"}:${plan?.sale_mode || "subscription"}:${plan?.months || plan?.traffic_gb || ""}`
);
}
export function buildTariffCatalog(planList) {
const byKey = new Map();
for (const plan of planList || []) {
const key = String(plan?.tariff_key || planKey(plan) || "").trim();
if (!key) continue;
const entry = byKey.get(key) || {
key,
title: plan?.tariff_name || plan?.title || key,
description: plan?.description || "",
billing_model:
plan?.billing_model ||
(plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic"
? "traffic"
: "period"),
monthly_gb: Number(plan?.monthly_gb || 0),
traffic_packages: [],
plans_count: 0,
};
if (!entry.description && plan?.description) entry.description = plan.description;
if (!entry.monthly_gb && Number(plan?.monthly_gb || 0) > 0)
entry.monthly_gb = Number(plan.monthly_gb);
const trafficGb = Number(plan?.traffic_gb || 0);
if (trafficGb > 0) entry.traffic_packages.push(trafficGb);
entry.plans_count += 1;
byKey.set(key, entry);
}
return Array.from(byKey.values());
}
export function activeTariffName(sub, planList) {
const direct = String(sub?.tariff_name || "").trim();
if (direct) return direct;
const key = String(sub?.tariff_key || "").trim();
if (!key) return "";
const plan = (planList || []).find((item) => item?.tariff_key === key);
return String(plan?.tariff_name || plan?.title || key).trim();
}
export function priceLabel(plan, methodId = "") {
if (String(methodId || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
return `${Number(plan.stars_price)}`;
}
return formatMoney(plan?.price || 0, plan?.currency);
}
export function tariffLimitLabel(tariff, { t }) {
if (!tariff) return "";
if (String(tariff.billing_model || "") === "traffic") {
const values = (tariff.traffic_packages || [])
.filter((value) => Number(value) > 0)
.sort((a, b) => a - b);
if (!values.length) return t("wa_tariff_model_traffic");
const min = values[0];
const max = values[values.length - 1];
return min === max ? formatTrafficGb(min) : `${formatTrafficGb(min)} - ${formatTrafficGb(max)}`;
}
if (Number(tariff.monthly_gb || 0) > 0) return formatTrafficGb(tariff.monthly_gb);
return t("wa_unlimited_traffic");
}
export function actionKey(action) {
return `${action?.mode || ""}:${action?.months || ""}:${action?.traffic_gb || ""}:${action?.price || ""}`;
}
function formatMonthsForClient(value, lang) {
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} мес.`;
}
export function planDisplayTitle(plan, { trafficMode, t }) {
if (plan?.tariff_key) {
return plan?.tariff_name || plan?.title || plan?.tariff_key;
}
if (trafficMode || plan?.sale_mode === "traffic") {
return plan?.title || formatTrafficGb(plan?.traffic_gb || plan?.months);
}
const months = Number(plan?.months || 0);
if (months === 12) return t("wa_plan_one_year");
return plan?.title || "";
}
export function planSubtitle(plan, { lang }) {
if (!plan?.tariff_key) return "";
if (plan?.subtitle) return plan.subtitle;
if (
plan?.sale_mode === "traffic_package" ||
plan?.sale_mode === "topup" ||
plan?.sale_mode === "premium_topup" ||
plan?.billing_model === "traffic"
) {
return formatTrafficGb(plan?.traffic_gb || plan?.months);
}
return formatMonthsForClient(plan?.months, lang);
}
export function planUnitHint(plan, { trafficMode, selectedMethod, t }) {
if (
trafficMode ||
plan?.sale_mode === "traffic" ||
plan?.sale_mode === "traffic_package" ||
plan?.sale_mode === "topup" ||
plan?.sale_mode === "premium_topup"
) {
const gb = Number(plan?.traffic_gb || plan?.months || 0);
if (!gb) return "";
if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
return `${Number(plan.stars_price / gb).toFixed(0)}${t("wa_per_gb_short")}`;
}
return `${formatMoney(Number(plan?.price || 0) / gb, plan?.currency)}${t("wa_per_gb_short")}`;
}
const months = Number(plan?.months || 0);
if (!months || months <= 1) return "";
if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
return `${Number(plan.stars_price / months).toFixed(0)}${t("wa_per_month_short")}`;
}
return `${formatMoney(Number(plan?.price || 0) / months, plan?.currency)}${t("wa_per_month_short")}`;
}
@@ -0,0 +1,94 @@
import { formatTrafficBytes, formatFraction, roundToHalf } from "./formatters.js";
export function trafficPercent(sub) {
const used = Number(sub?.traffic_used_bytes || 0);
const limit = Number(sub?.traffic_limit_bytes || 0);
if (!limit || limit <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
}
export function trafficLabel(sub, t) {
if (!sub?.traffic_limit_bytes || Number(sub.traffic_limit_bytes) <= 0) return t("wa_unlimited_traffic");
return t("wa_traffic_of", { used: sub.traffic_used || "0 GB", limit: sub.traffic_limit || "0 GB" });
}
export function trafficResetLabel(sub, t) {
const strategy = String(sub?.traffic_limit_strategy || "").trim().toUpperCase();
if (!strategy || strategy.includes("NO_RESET")) return t("wa_traffic_reset_none");
if (strategy.includes("MONTH")) return t("wa_traffic_reset_monthly");
if (strategy.includes("WEEK")) return t("wa_traffic_reset_weekly");
if (strategy.includes("DAY")) return t("wa_traffic_reset_daily");
if (strategy.includes("YEAR")) return t("wa_traffic_reset_yearly");
return t("wa_traffic_reset_policy");
}
export function premiumTrafficPercent(sub) {
const used = Number(sub?.premium_used_bytes || 0);
const limit = Number(sub?.premium_limit_bytes || 0);
if (!limit || limit <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
}
export function premiumTrafficLabel(sub, t) {
return t("wa_traffic_of", { used: sub?.premium_used || "0 GB", limit: sub?.premium_limit || "0 GB" });
}
export function premiumTitle(sub, t) {
return String(sub?.premium_title || "").trim() || t("wa_premium_traffic_title", {}, "Premium-серверы");
}
export function premiumTrafficLeftLabel(sub) {
const left = Math.max(0, Number(sub?.premium_limit_bytes || 0) - Number(sub?.premium_used_bytes || 0));
return formatTrafficBytes(left);
}
export function premiumTopupBalanceLabel(sub) {
return formatTrafficBytes(Number(sub?.premium_topup_balance_bytes || 0));
}
export function premiumServerLabels(sub) {
const labels =
Array.isArray(sub?.premium_node_labels) && sub.premium_node_labels.length
? sub.premium_node_labels
: sub?.premium_squad_labels || [];
return labels.map((label) => String(label || "").trim()).filter(Boolean);
}
function extractYear(text) {
const iso = String(text || "").match(/\b(\d{4})-\d{1,2}-\d{1,2}\b/);
if (iso) return Number(iso[1] || 0);
const dmy = String(text || "").match(/\b\d{1,2}\.\d{1,2}\.(\d{4})\b/);
if (dmy) return Number(dmy[1] || 0);
const any4 = String(text || "").match(/\b(\d{4})\b/);
if (any4) return Number(any4[1] || 0);
return 0;
}
export function isForeverSubscription(sub) {
const raw = String(sub?.end_date_text || "").trim();
if (!raw) return false;
return extractYear(raw) >= 2099;
}
export function activeSubscriptionTermLabel(sub, { t, termUnitLabel }) {
if (isForeverSubscription(sub)) return t("wa_sub_term_forever");
const days = Math.max(0, Number(sub?.days_left || 0));
if (!days) return t("wa_sub_term_value_unit", { value: "0", unit: termUnitLabel(0, "day") });
if (days < 30) {
return t("wa_sub_term_value_unit", { value: String(days), unit: termUnitLabel(days, "day") });
}
if (days < 365) {
const months = roundToHalf(days / 30);
return t("wa_sub_term_value_unit", {
value: formatFraction(months),
unit: termUnitLabel(months, "month"),
});
}
const years = roundToHalf(days / 365);
return t("wa_sub_term_value_unit", {
value: formatFraction(years),
unit: termUnitLabel(years, "year"),
});
}