refactor: slice web app wip
This commit is contained in:
@@ -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"),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user