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,71 @@
export function structuredCloneSafe(value) {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value));
}
export function pretty(value) {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "Да" : "Нет";
return String(value);
}
export function fmtDate(value) {
if (!value) return "—";
try {
return new Date(value).toLocaleString("ru-RU");
} catch {
return String(value);
}
}
export function fmtDateShort(value) {
if (!value) return "—";
try {
return new Date(value).toLocaleDateString("ru-RU");
} catch {
return String(value);
}
}
export function fmtMoney(amount, currency) {
const sym = currency === "RUB" ? "₽" : currency || "";
const num = Number(amount || 0);
return `${num.toFixed(2)} ${sym}`.trim();
}
export function fmtTrafficBytes(value) {
const bytes = Number(value || 0);
if (!bytes || bytes <= 0) return "0 GB";
const gb = bytes / 1073741824;
const formatted = gb >= 10 ? gb.toFixed(1) : gb.toFixed(2);
return `${formatted.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")} GB`;
}
export function trafficPercentValue(used, limit) {
const usedBytes = Number(used || 0);
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((usedBytes / limitBytes) * 100)));
}
export function trafficLeftLabel(used, limit) {
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return "Без лимита";
return fmtTrafficBytes(Math.max(0, limitBytes - Number(used || 0)));
}
export function trafficOfLabel(used, limit) {
const limitBytes = Number(limit || 0);
if (!limitBytes || limitBytes <= 0) return `${fmtTrafficBytes(used)} / без лимита`;
return `${fmtTrafficBytes(used)} / ${fmtTrafficBytes(limit)}`;
}
export function paymentStatusVariant(status) {
if (status === "succeeded") return "success";
if (typeof status === "string" && status.startsWith("pending")) return "warning";
return "danger";
}
export function optionLabel(options, value) {
return options.find((option) => option.value === value)?.label || value;
}
@@ -0,0 +1,200 @@
import { structuredCloneSafe } from "./format.js";
export function emptyTariffDraft() {
return {
key: "",
nameRu: "",
nameEn: "",
descriptionRu: "",
descriptionEn: "",
premiumNameRu: "",
premiumNameEn: "",
squadUuids: [],
premiumSquadUuids: [],
billing_model: "period",
enabled: true,
monthly_gb: 500,
premium_monthly_gb: "",
hwid_device_limit: "",
conversion_rate_rub_per_gb: "",
periodRows: [
{ months: 1, rub: 150, stars: "" },
{ months: 3, rub: 400, stars: "" },
{ months: 6, rub: 750, stars: "" },
{ months: 12, rub: 1400, stars: "" },
],
topupRubRows: [],
topupStarsRows: [],
premiumTopupRubRows: [],
premiumTopupStarsRows: [],
trafficRubRows: [
{ gb: 10, price: 199 },
{ gb: 50, price: 799 },
],
trafficStarsRows: [],
hwidRubRows: [],
hwidStarsRows: [],
};
}
export function cloneCatalog(catalog) {
return structuredCloneSafe({
default_tariff: catalog?.default_tariff || "",
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
tariffs: catalog?.tariffs || [],
});
}
export function rowsFromPackages(packageSet, currency, valueKey) {
return (packageSet?.[currency] || []).map((pkg) => ({
[valueKey]: pkg[valueKey],
price: pkg.price,
}));
}
export function draftFromTariff(tariff) {
const months = new Set([
...(tariff.enabled_periods || []),
...Object.keys(tariff.prices_rub || {}).map(Number),
...Object.keys(tariff.prices_stars || {}).map(Number),
]);
const periodRows = [...months]
.filter((month) => Number.isFinite(month) && month > 0)
.sort((a, b) => a - b)
.map((month) => ({
months: month,
rub: tariff.prices_rub?.[String(month)] ?? "",
stars: tariff.prices_stars?.[String(month)] ?? "",
}));
return {
...emptyTariffDraft(),
key: tariff.key || "",
nameRu: tariff.names?.ru || "",
nameEn: tariff.names?.en || "",
descriptionRu: tariff.descriptions?.ru || "",
descriptionEn: tariff.descriptions?.en || "",
premiumNameRu: tariff.premium_names?.ru || "",
premiumNameEn: tariff.premium_names?.en || "",
squadUuids: tariff.squad_uuids || [],
premiumSquadUuids: tariff.premium_squad_uuids || [],
billing_model: tariff.billing_model || "period",
enabled: tariff.enabled !== false,
monthly_gb: tariff.monthly_gb ?? "",
premium_monthly_gb: tariff.premium_monthly_gb ?? "",
hwid_device_limit: tariff.hwid_device_limit ?? "",
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"),
topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"),
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"),
premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"),
trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"),
trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"),
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"),
hwidStarsRows: rowsFromPackages(tariff.hwid_device_packages, "stars", "count"),
};
}
export function parseNumber(value, fallback = null) {
if (value === "" || value === null || value === undefined) return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
export function parseIntNumber(value, fallback = null) {
const num = parseNumber(value, fallback);
return num === null ? fallback : Math.trunc(num);
}
export function compactMap(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([, value]) => value !== "" && value !== null && value !== undefined),
);
}
export function packagesFromRows(rows, valueKey) {
return (rows || [])
.map((row) => ({
[valueKey]: parseNumber(row[valueKey]),
price: parseNumber(row.price),
}))
.filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
}
export function packageSetFromRows(rubRows, starsRows, valueKey) {
const rub = packagesFromRows(rubRows, valueKey);
const stars = packagesFromRows(starsRows, valueKey);
return rub.length || stars.length ? { rub, stars } : null;
}
export function normalizeUuidList(value) {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
return String(value || "")
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function tariffFromDraft(draft) {
const key = draft.key.trim();
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
const descriptions = compactMap({ ru: draft.descriptionRu.trim(), en: draft.descriptionEn.trim() });
const premiumNames = compactMap({
ru: draft.premiumNameRu.trim(),
en: draft.premiumNameEn.trim(),
});
const tariff = {
key,
names,
descriptions,
premium_names: premiumNames,
squad_uuids: normalizeUuidList(draft.squadUuids),
premium_squad_uuids: normalizeUuidList(draft.premiumSquadUuids),
billing_model: draft.billing_model,
enabled: Boolean(draft.enabled),
};
const hwidLimit = parseIntNumber(draft.hwid_device_limit);
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
const hwidPackages = packageSetFromRows(draft.hwidRubRows, draft.hwidStarsRows, "count");
if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
const premiumTopupPackages = packageSetFromRows(
draft.premiumTopupRubRows,
draft.premiumTopupStarsRows,
"gb",
);
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
if (tariff.billing_model === "period") {
const seenMonths = new Set();
const rows = (draft.periodRows || [])
.map((row) => ({
months: parseIntNumber(row.months),
rub: parseNumber(row.rub, 0),
stars: parseNumber(row.stars, 0),
}))
.filter((row) => row.months > 0)
.filter((row) => {
if (seenMonths.has(row.months)) return false;
seenMonths.add(row.months);
return true;
})
.sort((a, b) => a.months - b.months);
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
tariff.enabled_periods = rows.map((row) => row.months);
tariff.prices_rub = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
tariff.prices_stars = Object.fromEntries(rows.map((row) => [String(row.months), row.stars || 0]));
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
if (topupPackages) tariff.topup_packages = topupPackages;
} else {
const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb");
if (trafficPackages) tariff.traffic_packages = trafficPackages;
const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
}
return tariff;
}
@@ -0,0 +1,56 @@
export function userDisplayName(user) {
const full = [user?.first_name, user?.last_name].filter(Boolean).join(" ").trim();
return full || (user?.username ? `@${user.username}` : user?.email || `User #${user?.user_id || "—"}`);
}
export function userSecondaryName(user) {
if (user?.username && userDisplayName(user) !== `@${user.username}`) return `@${user.username}`;
if (user?.email && userDisplayName(user) !== user.email) return user.email;
return `ID ${user?.user_id || "—"}`;
}
export function userInitials(user) {
const source = userDisplayName(user).replace(/^@/, "").trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (source.slice(0, 2) || "U").toUpperCase();
}
export function userAvatarUrl(user) {
const cached = String(user?.avatar_url || "").trim();
if (cached) return cached;
const value = String(user?.telegram_photo_url || "").trim();
return value && !value.startsWith("/api/account/avatar") ? value : "";
}
export function createGravatarCache(onResolved = () => {}) {
const cache = new Map();
const pending = new Map();
async function sha256Hex(value) {
const buf = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function gravatarUrl(email) {
const key = String(email || "").trim().toLowerCase();
if (!key) return "";
if (cache.has(key)) return cache.get(key);
if (pending.has(key)) return "";
pending.set(
key,
sha256Hex(key)
.then((h) => {
cache.set(key, `https://gravatar.com/avatar/${h}?d=identicon&s=80`);
onResolved();
})
.catch(() => pending.delete(key)),
);
return "";
}
return { gravatarUrl };
}
@@ -0,0 +1,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"),
});
}