{devicesStatus}
+diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index f37c9aa..8f786a0 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -18,6 +18,7 @@ Mail, RefreshCw, Send, + Smartphone, TriangleAlert, Settings as SettingsIcon, Shield, @@ -57,6 +58,7 @@ const APP_SECTION_PATHS = { home: "/home", invite: "/invite", + devices: "/devices", settings: "/settings", }; @@ -101,6 +103,46 @@ traffic_limit: "100 GB", traffic_used_bytes: 19756849561, traffic_limit_bytes: 107374182400, + max_devices: 5, + }, + devices: { + ok: true, + enabled: true, + current_devices: 3, + max_devices: 5, + max_devices_label: "5", + devices: [ + { + index: 1, + display_name: "iPhone 15 Pro", + platform_label: "iOS 18.4", + user_agent: "Streisand/1.6 CFNetwork", + created_at_text: "28.04.2026 16:12", + hwid_short: "A1B2C3D4...98FA01", + token: "preview-device-1", + can_disconnect: true, + }, + { + index: 2, + display_name: "MacBook Air", + platform_label: "macOS 15.4", + user_agent: "Happ/3.1.0", + created_at_text: "29.04.2026 09:40", + hwid_short: "F0E1D2C3...44AB22", + token: "preview-device-2", + can_disconnect: true, + }, + { + index: 3, + display_name: "Android Phone", + platform_label: "Android 15", + user_agent: "v2rayNG/1.9.35", + created_at_text: "30.04.2026 07:55", + hwid_short: "778899AA...BCDD10", + token: "preview-device-3", + can_disconnect: true, + }, + ], }, plans: [ { months: 1, price: 290, currency: "RUB", title: "1 месяц" }, @@ -132,6 +174,8 @@ settings: { support_url: "https://t.me/support", traffic_mode: false, + my_devices_enabled: false, + user_hwid_device_limit: 5, trial_enabled: true, trial_available: true, trial_duration_days: 5, @@ -182,6 +226,14 @@ let promoStatus = ""; let promoIsError = false; let promoFieldError = ""; + let devicesData = DEV_MOCK.data.devices; + let devicesLoaded = false; + let devicesBusy = false; + let devicesStatus = ""; + let devicesIsError = false; + let deviceConfirmOpen = false; + let deviceToDisconnect = null; + let deviceDisconnectBusy = false; let toastText = ""; let toastTimer = null; let authStatus = ""; @@ -228,6 +280,13 @@ { months: 100, traffic_gb: 100, price: 1390, currency: "RUB", title: "100 GB", sale_mode: "traffic" }, { months: 300, traffic_gb: 300, price: 3490, currency: "RUB", title: "300 GB", sale_mode: "traffic" }, ]; + } else if (mode === "devices") { + DEV_MOCK.data.settings.my_devices_enabled = true; + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + active: true, + max_devices: 5, + }; } else if (mode === "trial") { DEV_MOCK.data.settings.traffic_mode = false; DEV_MOCK.data.settings.trial_enabled = true; @@ -257,6 +316,7 @@ $: methods = data?.payment_methods?.length ? data.payment_methods : []; $: appSettings = data?.settings || DEV_MOCK.data.settings; $: trafficMode = Boolean(appSettings?.traffic_mode); + $: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: subscription = data?.subscription || DEV_MOCK.data.subscription; $: user = data?.user || {}; $: referral = data?.referral || DEV_MOCK.data.referral; @@ -307,8 +367,10 @@ const onPopState = () => { const section = sectionFromPath(window.location.pathname); if (mode === "app") { - activeTab = section; - screen = section; + const nextSection = section === "devices" && !devicesEnabled ? "home" : section; + activeTab = nextSection; + screen = nextSection; + if (nextSection === "devices") loadDevices(); } }; window.addEventListener("popstate", onPopState); @@ -413,7 +475,7 @@ function normalizeSection(value) { const section = String(value || "").trim().toLowerCase(); - return section === "invite" || section === "settings" ? section : "home"; + return section === "invite" || section === "devices" || section === "settings" ? section : "home"; } function sectionFromPath(pathname) { @@ -484,11 +546,15 @@ data = payload; selectedPlan = payload.plans?.[Math.min(1, payload.plans.length - 1)] || payload.plans?.[0] || null; selectedMethod = payload.payment_methods?.[0]?.id || ""; - const section = sectionFromPath(window.location.pathname); + let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname); + if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home"; activeTab = section; screen = section; mode = "app"; syncSectionPath(section, true); + if (section === "devices" && payload.settings?.my_devices_enabled) { + await loadDevices(); + } } function showLogin() { @@ -539,6 +605,16 @@ return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; } if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; + if (path === "/devices") return structuredCloneSafe(DEV_MOCK.data.devices); + if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") { + let payload = {}; + try { + payload = options?.body ? JSON.parse(String(options.body)) : {}; + } catch {} + DEV_MOCK.data.devices.devices = DEV_MOCK.data.devices.devices.filter((device) => device.token !== payload.token); + DEV_MOCK.data.devices.current_devices = DEV_MOCK.data.devices.devices.length; + return { ok: true }; + } if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") { DEV_MOCK.data.subscription = { ...DEV_MOCK.data.subscription, @@ -947,7 +1023,7 @@ if (!response?.ok) throw response; linkEmailPending = normalized; linkEmailCode = ""; - setLinkEmailStatus(t("wa_email_sent_to", { email: normalized })); + setLinkEmailStatus(""); startCooldownTimer("link_email", 60); } catch (error) { setLinkEmailStatus(emailError(error, t("wa_auth_send_code_failed")), true); @@ -1179,6 +1255,58 @@ } } + async function loadDevices(force = false) { + if (!devicesEnabled || devicesBusy || (devicesLoaded && !force)) return; + devicesBusy = true; + devicesStatus = ""; + devicesIsError = false; + try { + const response = await api("/devices"); + if (!response?.ok) throw response; + devicesData = response; + devicesLoaded = true; + } catch (error) { + devicesStatus = error?.message || t("wa_devices_load_failed"); + devicesIsError = true; + devicesLoaded = true; + } finally { + devicesBusy = false; + } + } + + function openDeviceDisconnectDialog(device) { + deviceToDisconnect = device; + deviceConfirmOpen = true; + } + + function closeDeviceDisconnectDialog() { + if (deviceDisconnectBusy) return; + deviceConfirmOpen = false; + deviceToDisconnect = null; + } + + async function disconnectDevice() { + const token = String(deviceToDisconnect?.token || "").trim(); + if (!token || deviceDisconnectBusy) return; + deviceDisconnectBusy = true; + try { + const response = await api("/devices/disconnect", { + method: "POST", + body: JSON.stringify({ token }), + }); + if (!response?.ok) throw response; + showToast(t("wa_device_disconnected")); + deviceConfirmOpen = false; + deviceToDisconnect = null; + devicesLoaded = false; + await loadDevices(true); + } catch (error) { + showToast(error?.message || t("wa_device_disconnect_failed")); + } finally { + deviceDisconnectBusy = false; + } + } + async function logout() { markManualLogout(); clearToken(); @@ -1210,6 +1338,15 @@ syncSectionPath("invite"); } + function goDevices() { + if (!devicesEnabled) return; + paymentModalOpen = false; + activeTab = "devices"; + screen = "devices"; + syncSectionPath("devices"); + loadDevices(); + } + function goSettings() { paymentModalOpen = false; activeTab = "settings"; @@ -1355,6 +1492,24 @@ return limit > 0 ? formatTrafficGb(limit) : t("wa_unlimited_traffic"); } + function devicesLimitLabel(value = devicesData?.max_devices) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited"); + return String(Math.trunc(numeric)); + } + + function devicesCountLabel() { + const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0); + return t("wa_devices_count", { current, max: devicesLimitLabel() }); + } + + function devicesPercent() { + const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0); + const max = Number(devicesData?.max_devices || 0); + if (!max || max <= 0) return 100; + return Math.max(0, Math.min(100, Math.round((current / max) * 100))); + } + function activeSubscriptionTermLabel(sub) { const forever = isForeverSubscription(sub); if (forever) return t("wa_sub_term_forever"); @@ -1652,7 +1807,7 @@ {:else}
{devicesStatus}
+