From 7d8bbbc904ecb5fe12b23e79ade09b358e79fee3 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 11 May 2026 08:31:07 +0300 Subject: [PATCH] refactor: slice web app wip --- bot/app/web/frontend/src/App.svelte | 904 ++++-------------- .../web/frontend/src/admin/AdminPanel.svelte | 365 +------ bot/app/web/frontend/src/lib/admin/format.js | 71 ++ .../web/frontend/src/lib/admin/tariffDraft.js | 200 ++++ bot/app/web/frontend/src/lib/admin/users.js | 56 ++ .../frontend/src/lib/webapp/authHelpers.js | 111 +++ .../frontend/src/lib/webapp/billingActions.js | 89 ++ .../web/frontend/src/lib/webapp/formatters.js | 54 ++ .../web/frontend/src/lib/webapp/gravatar.js | 19 + bot/app/web/frontend/src/lib/webapp/i18n.js | 47 + .../web/frontend/src/lib/webapp/plurals.js | 33 + .../web/frontend/src/lib/webapp/tariffs.js | 128 +++ .../web/frontend/src/lib/webapp/traffic.js | 94 ++ bot/app/web/frontend/src/styles.css | 2 +- bot/app/web/frontend/src/styles/controls.css | 105 +- bot/app/web/frontend/src/styles/dialogs.css | 142 ++- bot/app/web/frontend/src/styles/webapp.css | 237 ----- .../frontend/src/webapp/WebAppShell.svelte | 48 + .../src/webapp/auth/AuthScreen.svelte | 185 ++++ 19 files changed, 1619 insertions(+), 1271 deletions(-) create mode 100644 bot/app/web/frontend/src/lib/admin/format.js create mode 100644 bot/app/web/frontend/src/lib/admin/tariffDraft.js create mode 100644 bot/app/web/frontend/src/lib/admin/users.js create mode 100644 bot/app/web/frontend/src/lib/webapp/authHelpers.js create mode 100644 bot/app/web/frontend/src/lib/webapp/billingActions.js create mode 100644 bot/app/web/frontend/src/lib/webapp/formatters.js create mode 100644 bot/app/web/frontend/src/lib/webapp/gravatar.js create mode 100644 bot/app/web/frontend/src/lib/webapp/i18n.js create mode 100644 bot/app/web/frontend/src/lib/webapp/plurals.js create mode 100644 bot/app/web/frontend/src/lib/webapp/tariffs.js create mode 100644 bot/app/web/frontend/src/lib/webapp/traffic.js create mode 100644 bot/app/web/frontend/src/webapp/WebAppShell.svelte create mode 100644 bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index e7134ea..8dc4e3e 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -1,41 +1,13 @@ @@ -2126,155 +1770,39 @@ {t("wa_loading")} {:else if mode === "login"} - - {#if screen === "code"} - - (screen = "login")} aria-label={t("wa_back")}> - - - - {t("wa_email_verification_title")} - {t("wa_email_sent_to", { email: pendingEmail })} - - - - - - - - {#each Array.from({ length: 6 }) as _, index} - {emailCode[index] || ""} - {/each} - - - - {t("wa_confirm")} - - {#if authStatus} - {authStatus} - {/if} - 0} - > - - {authResendCooldown > 0 ? t("wa_auth_resend_wait", { seconds: authResendCooldown }) : t("wa_resend_code")} - - - {:else} - - - - {brandTitle} - - - {#if CFG.emailAuthEnabled !== false} - - - - - { - loginEmailFieldError = ""; - loginEmailTooltipOpen = false; - }} - /> - {#if loginEmailFieldError} - - - - {/if} - {#if loginEmailFieldError} - - {loginEmailFieldError} - - {/if} - - - - - {t("wa_send_code_email")} - - - - {/if} - {#if CFG.emailAuthEnabled !== false} - {t("wa_or")} - {/if} - - - - {#if telegramLoginChecking} - - {:else} - - {/if} - {telegramLoginLabel} - - - - {#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)} - - {authStatus || telegramLoginUnavailableMessage} - - {/if} - - {#if userAgreementUrl || privacyPolicyUrl} - - {t("wa_auth_legal_intro")} - - {#if privacyPolicyUrl} - openExternalLink(privacyPolicyUrl)} - > - {t("wa_auth_legal_privacy")} - - {/if} - {#if privacyPolicyUrl && userAgreementUrl} - {t("wa_auth_legal_and")} - {/if} - {#if userAgreementUrl} - openExternalLink(userAgreementUrl)} - > - {t("wa_auth_legal_agreement")} - - {/if} - - - {/if} - - {/if} - + (screen = "login")} + clearLoginEmailError={() => { + loginEmailFieldError = ""; + loginEmailTooltipOpen = false; + }} + /> {:else if screen === "admin" && isAdmin} {:else} - - {#if screen === "invite" || screen === "devices" || screen === "settings"} - - - - {brandTitle} - - - {/if} - + {#if screen === "home"} {/if} - - {#if screen === "home" || screen === "invite" || screen === "devices" || screen === "settings"} - - {/if} - + {}; export let onToast = () => {}; @@ -681,200 +709,7 @@ } // ─── Tariffs ───────────────────────────────────────────────── - 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: [], - }; - } - - function cloneCatalog(catalog) { - return structuredCloneSafe({ - default_tariff: catalog?.default_tariff || "", - topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] }, - tariffs: catalog?.tariffs || [], - }); - } - - function rowsFromPackages(packageSet, currency, valueKey) { - return (packageSet?.[currency] || []).map((pkg) => ({ - [valueKey]: pkg[valueKey], - price: pkg.price, - })); - } - - 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"), - }; - } - - function parseNumber(value, fallback = null) { - if (value === "" || value === null || value === undefined) return fallback; - const num = Number(value); - return Number.isFinite(num) ? num : fallback; - } - - function parseIntNumber(value, fallback = null) { - const num = parseNumber(value, fallback); - return num === null ? fallback : Math.trunc(num); - } - - function compactMap(obj) { - return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== "" && value !== null && value !== undefined)); - } - - 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); - } - - function packageSetFromRows(rubRows, starsRows, valueKey) { - const rub = packagesFromRows(rubRows, valueKey); - const stars = packagesFromRows(starsRows, valueKey); - return rub.length || stars.length ? { rub, stars } : null; - } - - function tariffFromDraft() { - const key = tariffDraft.key.trim(); - const names = compactMap({ - ru: tariffDraft.nameRu.trim(), - en: tariffDraft.nameEn.trim(), - }); - const descriptions = compactMap({ - ru: tariffDraft.descriptionRu.trim(), - en: tariffDraft.descriptionEn.trim(), - }); - const premiumNames = compactMap({ - ru: tariffDraft.premiumNameRu.trim(), - en: tariffDraft.premiumNameEn.trim(), - }); - const tariff = { - key, - names, - descriptions, - premium_names: premiumNames, - squad_uuids: normalizeUuidList(tariffDraft.squadUuids), - premium_squad_uuids: normalizeUuidList(tariffDraft.premiumSquadUuids), - billing_model: tariffDraft.billing_model, - enabled: Boolean(tariffDraft.enabled), - }; - - const hwidLimit = parseIntNumber(tariffDraft.hwid_device_limit); - if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit; - const hwidPackages = packageSetFromRows(tariffDraft.hwidRubRows, tariffDraft.hwidStarsRows, "count"); - if (hwidPackages) tariff.hwid_device_packages = hwidPackages; - const premiumMonthlyGb = parseNumber(tariffDraft.premium_monthly_gb); - if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb; - const premiumTopupPackages = packageSetFromRows( - tariffDraft.premiumTopupRubRows, - tariffDraft.premiumTopupStarsRows, - "gb", - ); - if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages; - - if (tariff.billing_model === "period") { - const seenMonths = new Set(); - const rows = (tariffDraft.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(tariffDraft.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(tariffDraft.topupRubRows, tariffDraft.topupStarsRows, "gb"); - if (topupPackages) tariff.topup_packages = topupPackages; - } else { - const trafficPackages = packageSetFromRows(tariffDraft.trafficRubRows, tariffDraft.trafficStarsRows, "gb"); - if (trafficPackages) tariff.traffic_packages = trafficPackages; - const conversion = parseNumber(tariffDraft.conversion_rate_rub_per_gb); - if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion; - } - - return tariff; - } + const tariffFromDraft = () => tariffFromDraftFn(tariffDraft); async function loadTariffs() { tariffsLoading = true; @@ -905,14 +740,6 @@ } } - 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); - } - function squadLabel(uuid) { const squad = panelSquads.find((item) => item.uuid === uuid); return squad ? `${squad.name} · ${uuid.slice(0, 8)}…` : uuid; @@ -1151,69 +978,6 @@ } } - // ─── Helpers ────────────────────────────────────────────────── - function structuredCloneSafe(value) { - if (typeof structuredClone === "function") return structuredClone(value); - return JSON.parse(JSON.stringify(value)); - } - - function pretty(value) { - if (value === null || value === undefined) return "—"; - if (typeof value === "boolean") return value ? "Да" : "Нет"; - return String(value); - } - - function fmtDate(value) { - if (!value) return "—"; - try { - return new Date(value).toLocaleString("ru-RU"); - } catch { - return String(value); - } - } - - function fmtDateShort(value) { - if (!value) return "—"; - try { - return new Date(value).toLocaleDateString("ru-RU"); - } catch { - return String(value); - } - } - - function fmtMoney(amount, currency) { - const sym = currency === "RUB" ? "₽" : currency || ""; - const num = Number(amount || 0); - return `${num.toFixed(2)} ${sym}`.trim(); - } - - 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`; - } - - 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))); - } - - function trafficLeftLabel(used, limit) { - const limitBytes = Number(limit || 0); - if (!limitBytes || limitBytes <= 0) return "Без лимита"; - return fmtTrafficBytes(Math.max(0, limitBytes - Number(used || 0))); - } - - function trafficOfLabel(used, limit) { - const limitBytes = Number(limit || 0); - if (!limitBytes || limitBytes <= 0) return `${fmtTrafficBytes(used)} / без лимита`; - return `${fmtTrafficBytes(used)} / ${fmtTrafficBytes(limit)}`; - } - function tariffName(tariff) { return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—"; } @@ -1245,63 +1009,12 @@ return `${first[valueKey]} ${unit}, всего ${total}`; } - 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 || "—"}`); - } - - 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 || "—"}`; - } - - 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(); - } - - function userAvatarUrl(user) { - // Prefer the admin cached endpoint (cheap, ETag'd, served from DB cache); - // fall back to the raw Telegram photo URL only if there's no cache yet. - 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 : ""; - } - - // Gravatar fallback for users with no Telegram avatar but a known email. - // Gravatar accepts SHA-256 hashes since 2024 — convenient because the - // browser SubtleCrypto can compute SHA-256 natively (no MD5 dep needed). - const _gravatarCache = new Map(); - const _gravatarPending = 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(""); - } - + const _gravatarCache = createGravatarCache(() => { + users = users; + openedUser = openedUser; + }); function userGravatarUrl(user) { - const email = String(user?.email || "").trim().toLowerCase(); - if (!email) return ""; - if (_gravatarCache.has(email)) return _gravatarCache.get(email); - if (_gravatarPending.has(email)) return ""; - _gravatarPending.set( - email, - _sha256Hex(email) - .then((h) => { - _gravatarCache.set(email, `https://gravatar.com/avatar/${h}?d=identicon&s=80`); - users = users; // trigger reactivity - openedUser = openedUser; - }) - .catch(() => _gravatarPending.delete(email)), - ); - return ""; + return _gravatarCache.gravatarUrl(user?.email); } function resolvedAvatarUrl(user) { @@ -1332,12 +1045,6 @@ } } - function paymentStatusVariant(status) { - if (status === "succeeded") return "success"; - if (typeof status === "string" && status.startsWith("pending")) return "warning"; - return "danger"; - } - function sectionTitle(id) { const map = { general: at("settings_section_general", {}, "Общие"), @@ -1352,10 +1059,6 @@ return map[id] || id; } - function optionLabel(options, value) { - return options.find((option) => option.value === value)?.label || value; - } - $: dirtyCount = Object.keys(settingsDirty).length; $: meta = SECTION_META[active] || { title: active, subtitle: "" }; $: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; diff --git a/bot/app/web/frontend/src/lib/admin/format.js b/bot/app/web/frontend/src/lib/admin/format.js new file mode 100644 index 0000000..e240023 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/format.js @@ -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; +} diff --git a/bot/app/web/frontend/src/lib/admin/tariffDraft.js b/bot/app/web/frontend/src/lib/admin/tariffDraft.js new file mode 100644 index 0000000..a562b83 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/tariffDraft.js @@ -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; +} diff --git a/bot/app/web/frontend/src/lib/admin/users.js b/bot/app/web/frontend/src/lib/admin/users.js new file mode 100644 index 0000000..8014322 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/users.js @@ -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 }; +} diff --git a/bot/app/web/frontend/src/lib/webapp/authHelpers.js b/bot/app/web/frontend/src/lib/webapp/authHelpers.js new file mode 100644 index 0000000..58d0bfe --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/authHelpers.js @@ -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; + }, + }; +} diff --git a/bot/app/web/frontend/src/lib/webapp/billingActions.js b/bot/app/web/frontend/src/lib/webapp/billingActions.js new file mode 100644 index 0000000..7a581ef --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/billingActions.js @@ -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, + }; +} diff --git a/bot/app/web/frontend/src/lib/webapp/formatters.js b/bot/app/web/frontend/src/lib/webapp/formatters.js new file mode 100644 index 0000000..9158e81 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/formatters.js @@ -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; +} diff --git a/bot/app/web/frontend/src/lib/webapp/gravatar.js b/bot/app/web/frontend/src/lib/webapp/gravatar.js new file mode 100644 index 0000000..bf7bfef --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/gravatar.js @@ -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 ""; + } +} diff --git a/bot/app/web/frontend/src/lib/webapp/i18n.js b/bot/app/web/frontend/src/lib/webapp/i18n.js new file mode 100644 index 0000000..577ca89 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/i18n.js @@ -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 }; diff --git a/bot/app/web/frontend/src/lib/webapp/plurals.js b/bot/app/web/frontend/src/lib/webapp/plurals.js new file mode 100644 index 0000000..0b94910 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/plurals.js @@ -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"; +} diff --git a/bot/app/web/frontend/src/lib/webapp/tariffs.js b/bot/app/web/frontend/src/lib/webapp/tariffs.js new file mode 100644 index 0000000..89b5ff7 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/tariffs.js @@ -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")}`; +} diff --git a/bot/app/web/frontend/src/lib/webapp/traffic.js b/bot/app/web/frontend/src/lib/webapp/traffic.js new file mode 100644 index 0000000..c20785b --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/traffic.js @@ -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"), + }); +} diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index bba0ee0..61f3a79 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -1,4 +1,4 @@ -@import "./styles/webapp.css"; @import "./styles/controls.css"; @import "./styles/dialogs.css"; +@import "./styles/webapp.css"; @import "./styles/admin.css"; diff --git a/bot/app/web/frontend/src/styles/controls.css b/bot/app/web/frontend/src/styles/controls.css index 4f1ca36..9d84e03 100644 --- a/bot/app/web/frontend/src/styles/controls.css +++ b/bot/app/web/frontend/src/styles/controls.css @@ -1,4 +1,101 @@ -/* - Shared control primitives live in webapp.css for now to preserve cascade order - while the Svelte views are being split into screen components. -*/ +/* Shared control primitives: buttons, inputs. */ + +.btn { + display: inline-flex; + min-height: 44px; + min-width: 0; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid transparent; + border-radius: var(--radius); + padding: 10px 14px; + text-align: center; + text-decoration: none; + font-size: 14px; + font-weight: 850; + line-height: 1.1; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + border-color: color-mix(in srgb, var(--accent) 72%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); + color: #031009; + box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.btn-secondary, +.btn-outline { + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.045); + color: var(--text); +} + +.btn-outline { + border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); + color: var(--accent); +} + +.btn-ghost { + background: transparent; + color: var(--muted); +} + +.btn-telegram { + border-color: #2d9cff; + background: linear-gradient(180deg, #2f9ff4, #1786df); + color: white; +} + +.btn-icon { + border-color: var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.btn-square { + width: 42px; + min-width: 42px; + height: 42px; + padding: 0; +} + +.btn-sm { + min-height: 36px; + padding: 8px 10px; + font-size: 12px; +} + +.wide { + width: 100%; +} + +.input { + width: 100%; + min-height: 44px; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(4, 8, 11, 0.48); + color: var(--text); + outline: none; + padding: 11px 12px; + font-size: 13px; + font-weight: 700; +} + +.input:focus { + border-color: color-mix(in srgb, var(--accent) 68%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.input.muted { + display: flex; + align-items: center; + color: var(--dim); +} diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/bot/app/web/frontend/src/styles/dialogs.css index 066813f..3cec9f1 100644 --- a/bot/app/web/frontend/src/styles/dialogs.css +++ b/bot/app/web/frontend/src/styles/dialogs.css @@ -1,4 +1,138 @@ -/* - Dialog-specific selectors are kept importable as a separate bundle boundary. - Existing rules remain in webapp.css to avoid visual churn during componentization. -*/ +/* Modal/dialog primitives — bottom-sheet style cards used across the webapp. */ + +.dialog { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + overflow: hidden; + padding: + 16px + var(--screen-gutter) + calc(var(--bottom-nav-height) + var(--bottom-nav-offset) + 16px + env(safe-area-inset-bottom)); +} + +.dialog-backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(2, 7, 11, 0.54); + backdrop-filter: blur(10px); + animation: fade-in 0.18s ease-out both; +} + +.dialog-card { + position: relative; + z-index: 1; + display: grid; + width: min(100%, 420px); + gap: 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--panel); + box-shadow: 0 28px 70px rgba(0, 0, 0, 0.5); + padding: 14px; + transform-origin: center; + animation: modal-enter 0.2s ease-out both; +} + +.dialog-head { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + align-items: start; + gap: 10px; +} + +.dialog-head h2 { + margin: 0; + color: var(--text); + font-size: 18px; + line-height: 1.16; + font-weight: 850; +} + +.dialog-head p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.3; +} + +.payment-dialog-card { + max-height: 100%; + overflow-y: auto; +} + +.link-email-dialog-card { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: min(100%, 560px); + max-height: 100%; + overflow: hidden; +} + +.link-email-dialog-card .dialog-head { + position: relative; + z-index: 2; +} + +.link-email-dialog-card .payment-dialog-body { + min-height: 0; +} + +.link-email-code-layout { + display: grid; + min-height: 0; + height: 100%; + grid-template-rows: minmax(0, 1fr) auto; +} + +.link-email-code-center { + align-self: center; +} + +.link-email-resend { + align-self: end; +} + +.payment-dialog-body { + display: grid; + gap: 10px; +} + +.payment-divider { + height: 1px; + width: 100%; + background: var(--border); +} + +.payment-submit-button { + border-color: color-mix(in srgb, var(--accent) 72%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); + color: #031009; + box-shadow: + 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), + 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), + 0 0 26px color-mix(in srgb, var(--accent) 42%, transparent); +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes modal-enter { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css index 27b86bb..aaa08b5 100644 --- a/bot/app/web/frontend/src/styles/webapp.css +++ b/bot/app/web/frontend/src/styles/webapp.css @@ -702,106 +702,6 @@ a { gap: 8px; } -.btn { - display: inline-flex; - min-height: 44px; - min-width: 0; - align-items: center; - justify-content: center; - gap: 8px; - border: 1px solid transparent; - border-radius: var(--radius); - padding: 10px 14px; - text-align: center; - text-decoration: none; - font-size: 14px; - font-weight: 850; - line-height: 1.1; - transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease; -} - -.btn:hover { - transform: translateY(-1px); -} - -.btn-primary { - border-color: color-mix(in srgb, var(--accent) 72%, transparent); - background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); - color: #031009; - box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 20%, transparent); -} - -.btn-secondary, -.btn-outline { - border-color: var(--border-strong); - background: rgba(255, 255, 255, 0.045); - color: var(--text); -} - -.btn-outline { - border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); - color: var(--accent); -} - -.btn-ghost { - background: transparent; - color: var(--muted); -} - -.btn-telegram { - border-color: #2d9cff; - background: linear-gradient(180deg, #2f9ff4, #1786df); - color: white; -} - -.btn-icon { - border-color: var(--border); - background: rgba(255, 255, 255, 0.04); - color: var(--text); -} - -.btn-square { - width: 42px; - min-width: 42px; - height: 42px; - padding: 0; -} - -.btn-sm { - min-height: 36px; - padding: 8px 10px; - font-size: 12px; -} - -.wide { - width: 100%; -} - -.input { - width: 100%; - min-height: 44px; - min-width: 0; - border: 1px solid var(--border); - border-radius: var(--radius); - background: rgba(4, 8, 11, 0.48); - color: var(--text); - outline: none; - padding: 11px 12px; - font-size: 13px; - font-weight: 700; -} - -.input:focus { - border-color: color-mix(in srgb, var(--accent) 68%, var(--border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); -} - -.input.muted { - display: flex; - align-items: center; - color: var(--dim); -} - .bottom-action { margin-top: 4px; } @@ -2002,123 +1902,6 @@ a { font-size: 13px; } -.dialog { - position: fixed; - inset: 0; - z-index: 70; - display: grid; - place-items: center; - overflow: hidden; - padding: - 16px - var(--screen-gutter) - calc(var(--bottom-nav-height) + var(--bottom-nav-offset) + 16px + env(safe-area-inset-bottom)); -} - -.dialog-backdrop { - position: absolute; - inset: 0; - border: 0; - background: rgba(2, 7, 11, 0.54); - backdrop-filter: blur(10px); - animation: fade-in 0.18s ease-out both; -} - -.dialog-card { - position: relative; - z-index: 1; - display: grid; - width: min(100%, 420px); - gap: 12px; - border: 1px solid var(--border-strong); - border-radius: var(--radius); - background: var(--panel); - box-shadow: 0 28px 70px rgba(0, 0, 0, 0.5); - padding: 14px; - transform-origin: center; - animation: modal-enter 0.2s ease-out both; -} - -.dialog-head { - display: grid; - grid-template-columns: minmax(0, 1fr) 42px; - align-items: start; - gap: 10px; -} - -.dialog-head h2 { - margin: 0; - color: var(--text); - font-size: 18px; - line-height: 1.16; - font-weight: 850; -} - -.dialog-head p { - margin: 4px 0 0; - color: var(--muted); - font-size: 12px; - line-height: 1.3; -} - -.payment-dialog-card { - max-height: 100%; - overflow-y: auto; -} - -.link-email-dialog-card { - display: grid; - grid-template-rows: auto minmax(0, 1fr); - height: min(100%, 560px); - max-height: 100%; - overflow: hidden; -} - -.link-email-dialog-card .dialog-head { - position: relative; - z-index: 2; -} - -.link-email-dialog-card .payment-dialog-body { - min-height: 0; -} - -.link-email-code-layout { - display: grid; - min-height: 0; - height: 100%; - grid-template-rows: minmax(0, 1fr) auto; -} - -.link-email-code-center { - align-self: center; -} - -.link-email-resend { - align-self: end; -} - -.payment-dialog-body { - display: grid; - gap: 10px; -} - -.payment-divider { - height: 1px; - width: 100%; - background: var(--border); -} - -.payment-submit-button { - border-color: color-mix(in srgb, var(--accent) 72%, transparent); - background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); - color: #031009; - box-shadow: - 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), - 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), - 0 0 26px color-mix(in srgb, var(--accent) 42%, transparent); -} - .toast { position: fixed; right: 16px; @@ -2136,15 +1919,6 @@ a { animation: toast-enter 0.22s ease-out both; } -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - @keyframes section-enter { from { opacity: 0; @@ -2167,17 +1941,6 @@ a { } } -@keyframes modal-enter { - from { - opacity: 0; - transform: translateY(10px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - @keyframes toast-enter { from { opacity: 0; diff --git a/bot/app/web/frontend/src/webapp/WebAppShell.svelte b/bot/app/web/frontend/src/webapp/WebAppShell.svelte new file mode 100644 index 0000000..939098f --- /dev/null +++ b/bot/app/web/frontend/src/webapp/WebAppShell.svelte @@ -0,0 +1,48 @@ + + + + {#if screen === "invite" || screen === "devices" || screen === "settings"} + + + + {brandTitle} + + + {/if} + + + + + diff --git a/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte b/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte new file mode 100644 index 0000000..1079321 --- /dev/null +++ b/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte @@ -0,0 +1,185 @@ + + + + {#if screen === "code"} + + + + + + {t("wa_email_verification_title")} + {t("wa_email_sent_to", { email: pendingEmail })} + + + + + + + + {#each Array.from({ length: 6 }) as _, index} + {emailCode[index] || ""} + {/each} + + + + {t("wa_confirm")} + + {#if authStatus} + {authStatus} + {/if} + 0} + > + + {authResendCooldown > 0 ? t("wa_auth_resend_wait", { seconds: authResendCooldown }) : t("wa_resend_code")} + + + {:else} + + + + {brandTitle} + + + {#if CFG.emailAuthEnabled !== false} + + + + + + {#if loginEmailFieldError} + + + + {/if} + {#if loginEmailFieldError} + + {loginEmailFieldError} + + {/if} + + + + + {t("wa_send_code_email")} + + + + {/if} + {#if CFG.emailAuthEnabled !== false} + {t("wa_or")} + {/if} + + + + {#if telegramLoginChecking} + + {:else} + + {/if} + {telegramLoginLabel} + + + + {#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)} + + {authStatus || telegramLoginUnavailableMessage} + + {/if} + + {#if userAgreementUrl || privacyPolicyUrl} + + {t("wa_auth_legal_intro")} + + {#if privacyPolicyUrl} + openExternalLink(privacyPolicyUrl)} + > + {t("wa_auth_legal_privacy")} + + {/if} + {#if privacyPolicyUrl && userAgreementUrl} + {t("wa_auth_legal_and")} + {/if} + {#if userAgreementUrl} + openExternalLink(userAgreementUrl)} + > + {t("wa_auth_legal_agreement")} + + {/if} + + + {/if} + + {/if} +
{t("wa_email_sent_to", { email: pendingEmail })}