refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
<script>
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
import { cn } from "../utils.js";
|
||||
import { animatedEmojiAssetUrls, normalizeBrand } from "./browser.js";
|
||||
|
||||
const LOGO_LOAD_TIMEOUT_MS = 10000;
|
||||
|
||||
const EMOJI_FONT_OPTIONS = {
|
||||
"noto-color": {
|
||||
cssFamily: "Noto Color Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
"noto-emoji": {
|
||||
cssFamily: "Noto Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@700&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
twemoji: {
|
||||
cssFamily: "Twemoji Mozilla",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/twemoji-colr-font@15.0.3/twemoji.css",
|
||||
},
|
||||
openmoji: {
|
||||
cssFamily: "OpenMoji Color",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/@openmoji/font@15.1.0/css/openmoji-color.css",
|
||||
},
|
||||
};
|
||||
|
||||
export let brand = {};
|
||||
export let logoUrl = "";
|
||||
export let emoji = "";
|
||||
export let emojiFont = "";
|
||||
export let size = "sm";
|
||||
export let animate = false;
|
||||
export let fallbackEmoji = true;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: "",
|
||||
md: "brand-mark-lg",
|
||||
lg: "brand-mark-xl",
|
||||
xl: "brand-mark-xl",
|
||||
};
|
||||
|
||||
let loaded = false;
|
||||
let failed = false;
|
||||
let lastLogoUrl = "";
|
||||
let logoLoadTimer = null;
|
||||
let logoLoadTimerUrl = "";
|
||||
let fontLoaded = false;
|
||||
let loadedFontKey = "";
|
||||
let animatedEmojiError = false;
|
||||
let animatedEmojiStaticFallback = false;
|
||||
let lastAnimatedEmoji = "";
|
||||
|
||||
$: normalizedBrand = normalizeBrand({
|
||||
...brand,
|
||||
logoUrl: logoUrl || brand?.logoUrl,
|
||||
emoji: emoji || brand?.emoji || brand?.logoEmoji,
|
||||
emojiFont: emojiFont || brand?.emojiFont || brand?.logoEmojiFont,
|
||||
});
|
||||
$: normalizedLogoUrl = normalizedBrand.logoUrl;
|
||||
$: normalizedEmoji = normalizedBrand.emoji;
|
||||
$: normalizedEmojiFont = normalizedBrand.emojiFont;
|
||||
$: sizeClass = SIZE_CLASSES[size] || "";
|
||||
$: animatedEmojiAssets = animatedEmojiAssetUrls(normalizedEmoji);
|
||||
$: animatedEmojiSrc = animatedEmojiAssets.gif;
|
||||
$: animatedEmojiFallbackSrc = animatedEmojiAssets.webp;
|
||||
$: useAnimatedEmoji =
|
||||
!normalizedLogoUrl &&
|
||||
normalizedEmojiFont === "noto-color-animated" &&
|
||||
animatedEmojiSrc &&
|
||||
!animatedEmojiError;
|
||||
|
||||
$: if (normalizedLogoUrl !== lastLogoUrl) {
|
||||
lastLogoUrl = normalizedLogoUrl;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
}
|
||||
$: if (normalizedLogoUrl && !loaded && !failed) armLogoLoadTimeout();
|
||||
$: if (!normalizedLogoUrl || loaded || failed) clearLogoLoadTimeout();
|
||||
$: if (`${normalizedEmojiFont}:${normalizedEmoji}` !== lastAnimatedEmoji) {
|
||||
lastAnimatedEmoji = `${normalizedEmojiFont}:${normalizedEmoji}`;
|
||||
animatedEmojiError = false;
|
||||
animatedEmojiStaticFallback = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearLogoLoadTimeout();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
});
|
||||
|
||||
$: if (normalizedEmojiFont && normalizedEmoji) {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
}
|
||||
|
||||
function loadEmojiFont(font, text) {
|
||||
if (typeof document === "undefined") return;
|
||||
if (font === "system" || font === "noto-color-animated" || !font) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = "system";
|
||||
return;
|
||||
}
|
||||
|
||||
const fontOption = EMOJI_FONT_OPTIONS[font];
|
||||
if (!fontOption) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = font;
|
||||
return;
|
||||
}
|
||||
|
||||
const fontUrl = fontOption.stylesheet(text);
|
||||
const fontKey = `${font}:${text}`;
|
||||
if (loadedFontKey === fontKey) return;
|
||||
|
||||
fontLoaded = false;
|
||||
loadedFontKey = fontKey;
|
||||
|
||||
const existing = document.querySelector(`link[data-brand-emoji-font="${fontKey}"]`);
|
||||
if (existing) {
|
||||
fontLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = fontUrl;
|
||||
link.dataset.brandEmojiFont = fontKey;
|
||||
link.onload = () => {
|
||||
fontLoaded = true;
|
||||
if (document.fonts && fontOption.cssFamily) {
|
||||
document.fonts.load(`1em "${fontOption.cssFamily}"`, text).finally(() => {
|
||||
fontLoaded = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
link.onerror = () => {
|
||||
fontLoaded = true;
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function getEmojiFontClass(font) {
|
||||
if (font === "noto-color") return "emoji-font-noto-color";
|
||||
if (font === "noto-emoji") return "emoji-font-noto-emoji";
|
||||
if (font === "twemoji") return "emoji-font-twemoji";
|
||||
if (font === "openmoji") return "emoji-font-openmoji";
|
||||
if (font === "apple") return "emoji-font-apple";
|
||||
if (font === "segoe") return "emoji-font-segoe";
|
||||
if (font === "noto-local") return "emoji-font-noto-local";
|
||||
return "";
|
||||
}
|
||||
|
||||
function clearLogoLoadTimeout() {
|
||||
if (logoLoadTimer) {
|
||||
window.clearTimeout(logoLoadTimer);
|
||||
logoLoadTimer = null;
|
||||
}
|
||||
logoLoadTimerUrl = "";
|
||||
}
|
||||
|
||||
function armLogoLoadTimeout() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (logoLoadTimer && logoLoadTimerUrl === normalizedLogoUrl) return;
|
||||
clearLogoLoadTimeout();
|
||||
logoLoadTimerUrl = normalizedLogoUrl;
|
||||
logoLoadTimer = window.setTimeout(() => {
|
||||
if (logoLoadTimerUrl === normalizedLogoUrl && !loaded) failed = true;
|
||||
}, LOGO_LOAD_TIMEOUT_MS);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"brand-mark",
|
||||
sizeClass,
|
||||
animate && "brand-mark-animate",
|
||||
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
|
||||
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
|
||||
className
|
||||
)}
|
||||
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
|
||||
>
|
||||
{#if normalizedLogoUrl && !failed}
|
||||
{#if !loaded}
|
||||
<span class="brand-mark-spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<img
|
||||
class:loaded
|
||||
src={normalizedLogoUrl}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:load={() => {
|
||||
loaded = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
on:error={() => {
|
||||
failed = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
/>
|
||||
{:else if fallbackEmoji && useAnimatedEmoji}
|
||||
<img
|
||||
class="brand-mark-animated-emoji loaded"
|
||||
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:error={() => {
|
||||
if (!animatedEmojiStaticFallback && animatedEmojiFallbackSrc) {
|
||||
animatedEmojiStaticFallback = true;
|
||||
} else {
|
||||
animatedEmojiError = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if fallbackEmoji}
|
||||
<span
|
||||
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
|
||||
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
|
||||
>
|
||||
{normalizedEmoji}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
font-size: 1.625rem;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.brand-mark img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-lg {
|
||||
width: 4.125rem;
|
||||
height: 4.125rem;
|
||||
font-size: 2.875rem;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-xl {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
font-size: 4.375rem;
|
||||
}
|
||||
|
||||
.brand-mark img.brand-mark-animated-emoji {
|
||||
object-fit: contain;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-animate {
|
||||
animation: brand-mark-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-mark-spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-mark-spinner::after {
|
||||
content: "";
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid currentColor;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: brand-mark-spin 0.8s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes brand-mark-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-mark-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-mark-emoji {
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(0.02em);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.brand-mark-xl .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.brand-mark-lg .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.emoji-font-noto-color {
|
||||
font-family: "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-emoji {
|
||||
color: var(--accent);
|
||||
font-family: "Noto Emoji", sans-serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.emoji-font-twemoji {
|
||||
font-family: "Twemoji Mozilla", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-openmoji {
|
||||
font-family: "OpenMoji Color", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-apple {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-segoe {
|
||||
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-local {
|
||||
font-family: "Noto Color Emoji", "Noto Emoji", sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -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 }) {
|
||||
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,9 @@
|
||||
/** Drop cached topup / change-tariff option payloads so the next open refetches from /api. */
|
||||
export function invalidateWebappTariffOptionCaches(billingStore) {
|
||||
billingStore.update((s) => ({
|
||||
...s,
|
||||
topupOptions: null,
|
||||
deviceTopupOptions: null,
|
||||
changeOptions: null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export function readJsonScript(id) {
|
||||
const node = document.getElementById(id);
|
||||
if (!node || !node.textContent) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse JSON config from #${id}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function structuredCloneSafe(value) {
|
||||
try {
|
||||
return structuredClone(value);
|
||||
} catch {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function normalizeBrand(brand = {}) {
|
||||
return {
|
||||
title: String(brand.title || "/minishop").trim() || "/minishop",
|
||||
logoUrl: String(brand.logoUrl || "").trim(),
|
||||
emoji: String(brand.emoji || brand.logoEmoji || "🫥").trim() || "🫥",
|
||||
emojiFont: String(brand.emojiFont || brand.logoEmojiFont || "system").trim() || "system",
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiToCodepoints(value) {
|
||||
return Array.from(String(value || "").trim())
|
||||
.map((char) => char.codePointAt(0)?.toString(16))
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
}
|
||||
|
||||
export function animatedEmojiAssetUrls(emoji) {
|
||||
const codepoints = emojiToCodepoints(emoji);
|
||||
if (!codepoints) return { gif: "", webp: "" };
|
||||
return {
|
||||
gif: `/webapp-emoji/${codepoints}/512.gif`,
|
||||
webp: `/webapp-emoji/${codepoints}/512.webp`,
|
||||
};
|
||||
}
|
||||
|
||||
export function brandFaviconHref(brand = {}) {
|
||||
const normalizedBrand = normalizeBrand(brand);
|
||||
if (normalizedBrand.logoUrl) return normalizedBrand.logoUrl;
|
||||
|
||||
if (normalizedBrand.emojiFont === "noto-color-animated") {
|
||||
return animatedEmojiAssetUrls(normalizedBrand.emoji).gif;
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">${escapeHtml(normalizedBrand.emoji)}</text></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function applyFavicon(brand = {}) {
|
||||
if (typeof document === "undefined") return;
|
||||
const favicon = document.getElementById("app-favicon");
|
||||
if (!favicon) return;
|
||||
|
||||
const href = brandFaviconHref(brand);
|
||||
favicon.setAttribute("href", href);
|
||||
if (href.startsWith("data:image/svg+xml")) {
|
||||
favicon.setAttribute("type", "image/svg+xml");
|
||||
} else if (href.endsWith(".gif")) {
|
||||
favicon.setAttribute("type", "image/gif");
|
||||
} else if (href.endsWith(".webp")) {
|
||||
favicon.setAttribute("type", "image/webp");
|
||||
} else {
|
||||
favicon.removeAttribute("type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
|
||||
export const LANGUAGE_LABELS = {
|
||||
ru: "Русский",
|
||||
en: "English",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
tr: "Türkçe",
|
||||
uk: "Українська",
|
||||
};
|
||||
export const LANGUAGE_FLAGS = {
|
||||
ru: "🇷🇺",
|
||||
en: "🇬🇧",
|
||||
de: "🇩🇪",
|
||||
es: "🇪🇸",
|
||||
fr: "🇫🇷",
|
||||
tr: "🇹🇷",
|
||||
uk: "🇺🇦",
|
||||
};
|
||||
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
|
||||
export const APP_SECTION_PATHS = {
|
||||
home: "/home",
|
||||
invite: "/invite",
|
||||
devices: "/devices",
|
||||
settings: "/settings",
|
||||
admin: "/admin",
|
||||
};
|
||||
export const ADMIN_SECTIONS = new Set([
|
||||
"stats",
|
||||
"users",
|
||||
"payments",
|
||||
"promos",
|
||||
"ads",
|
||||
"broadcast",
|
||||
"logs",
|
||||
"tariffs",
|
||||
"appearance",
|
||||
"settings",
|
||||
]);
|
||||
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
|
||||
export const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900;
|
||||
export const TELEGRAM_SDK_ACTION_TIMEOUT_MS = 1800;
|
||||
export const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 15000;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pure helpers for HWID / device limits UI (used by DevicesScreen).
|
||||
* @param {Record<string, unknown>} devicesData API payload from /api/devices
|
||||
* @param {(key: string, vars?: Record<string, unknown>, fallback?: string) => string} t i18n function
|
||||
* @param {unknown} [maxDevicesOverride] optional max_devices override (defaults to devicesData.max_devices)
|
||||
*/
|
||||
export function devicesLimitLabel(devicesData, t, maxDevicesOverride) {
|
||||
const value = maxDevicesOverride !== undefined ? maxDevicesOverride : devicesData?.max_devices;
|
||||
const numeric = Number(value ?? 0);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
|
||||
return String(Math.trunc(numeric));
|
||||
}
|
||||
|
||||
export function devicesCountLabel(devicesData, t) {
|
||||
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
|
||||
return t("wa_devices_count", { current, max: devicesLimitLabel(devicesData, t) });
|
||||
}
|
||||
|
||||
export function devicesPercent(devicesData) {
|
||||
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)));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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,51 @@
|
||||
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,429 @@
|
||||
import { DEV_MOCK } from "./previewMock.js";
|
||||
|
||||
function defaultClone(value) {
|
||||
try {
|
||||
return structuredClone(value);
|
||||
} catch {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockApi(path, options = {}, context = {}) {
|
||||
const {
|
||||
currentLang = "ru",
|
||||
normalizeLangCode = (value) => value || "ru",
|
||||
clone = defaultClone,
|
||||
} = context;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||
const cleanPath = String(path || "").split("?")[0];
|
||||
const adminUsers = [
|
||||
{
|
||||
user_id: 100200300,
|
||||
telegram_id: 100200300,
|
||||
username: "anna_ops",
|
||||
first_name: "Анна",
|
||||
last_name: "Смирнова",
|
||||
email: "anna@example.com",
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-24T10:20:00Z",
|
||||
is_banned: false,
|
||||
premium_traffic: {
|
||||
state: "good",
|
||||
unlimited: false,
|
||||
used_bytes: 4 * 1073741824,
|
||||
limit_bytes: 25 * 1073741824,
|
||||
percent: 16,
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 100200301,
|
||||
telegram_id: 87543123,
|
||||
username: "client_pro",
|
||||
first_name: "Максим",
|
||||
last_name: "Котов",
|
||||
email: "",
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-26T08:15:00Z",
|
||||
is_banned: false,
|
||||
premium_traffic: {
|
||||
state: "warn",
|
||||
unlimited: false,
|
||||
used_bytes: 22 * 1073741824,
|
||||
limit_bytes: 25 * 1073741824,
|
||||
percent: 88,
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 100200302,
|
||||
telegram_id: 88440011,
|
||||
username: "",
|
||||
first_name: "Daria",
|
||||
last_name: "",
|
||||
email: "daria@example.com",
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-29T16:45:00Z",
|
||||
is_banned: true,
|
||||
premium_traffic: { state: "none" },
|
||||
},
|
||||
];
|
||||
const mockAdminDailySeries = (() => {
|
||||
const days = 730;
|
||||
const out = [];
|
||||
const now = new Date();
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
d.setUTCDate(d.getUTCDate() - (days - 1 - i));
|
||||
const iso = d.toISOString().slice(0, 10);
|
||||
const wave = Math.sin(i / 5) * 520 + 720 + ((i * 41) % 280);
|
||||
out.push({ date: iso, amount: Math.max(0, Math.round(wave)) });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
if (path === "/admin/stats") {
|
||||
return {
|
||||
ok: true,
|
||||
currency_symbol: "RUB",
|
||||
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
|
||||
financial: {
|
||||
today_revenue: 1240,
|
||||
week_revenue: 15800,
|
||||
month_revenue: 44100,
|
||||
all_time_revenue: 186240,
|
||||
today_payments_count: 4,
|
||||
daily_series: mockAdminDailySeries,
|
||||
},
|
||||
panel_sync: {
|
||||
status: "success",
|
||||
last_sync_time: new Date().toISOString(),
|
||||
users_processed: 172,
|
||||
subscriptions_synced: 168,
|
||||
},
|
||||
recent_payments: [
|
||||
{
|
||||
payment_id: 1,
|
||||
user_id: 100200300,
|
||||
user_label: "anna_ops",
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
provider: "yookassa",
|
||||
status: "succeeded",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (cleanPath === "/admin/users")
|
||||
return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 };
|
||||
if (cleanPath.startsWith("/admin/users/")) {
|
||||
const id = Number(cleanPath.split("/")[3]);
|
||||
const user = adminUsers.find((item) => item.user_id === id) || adminUsers[0];
|
||||
return {
|
||||
ok: true,
|
||||
user,
|
||||
active_subscription: {
|
||||
subscription_id: 10,
|
||||
end_date: "2026-06-08T12:00:00Z",
|
||||
tariff_key: "standard",
|
||||
auto_renew_enabled: true,
|
||||
provider: "yookassa",
|
||||
},
|
||||
subscriptions: [
|
||||
{
|
||||
subscription_id: 10,
|
||||
end_date: "2026-06-08T12:00:00Z",
|
||||
tariff_key: "standard",
|
||||
is_active: true,
|
||||
status_from_panel: "ACTIVE",
|
||||
},
|
||||
{
|
||||
subscription_id: 9,
|
||||
end_date: "2026-05-08T12:00:00Z",
|
||||
tariff_key: "standard",
|
||||
is_active: false,
|
||||
status_from_panel: "EXPIRED",
|
||||
},
|
||||
],
|
||||
total_paid: 2380,
|
||||
recent_payments: [
|
||||
{
|
||||
payment_id: 12,
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
provider: "yookassa",
|
||||
status: "succeeded",
|
||||
created_at: "2026-05-01T14:15:00Z",
|
||||
},
|
||||
{
|
||||
payment_id: 11,
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
provider: "stars",
|
||||
status: "succeeded",
|
||||
created_at: "2026-04-01T14:15:00Z",
|
||||
},
|
||||
],
|
||||
log_count: 18,
|
||||
subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP",
|
||||
referral: {
|
||||
code: "ABCD1234",
|
||||
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
|
||||
webapp_link: "https://app.example.com/?ref=uABCD1234",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/tariffs") {
|
||||
return {
|
||||
ok: true,
|
||||
path: "data/tariffs.json",
|
||||
catalog: {
|
||||
default_tariff: "standard",
|
||||
topup_packages_default: { rub: [{ gb: 10, price: 99 }], stars: [] },
|
||||
tariffs: [
|
||||
{
|
||||
key: "standard",
|
||||
names: { ru: "Стандарт", en: "Standard" },
|
||||
descriptions: { ru: "Базовый набор серверов" },
|
||||
squad_uuids: ["db786ee8-816b-4760-80aa-1fc7a3669ff2"],
|
||||
billing_model: "period",
|
||||
monthly_gb: 500,
|
||||
prices_rub: { 1: 150, 3: 400 },
|
||||
prices_stars: { 1: 0, 3: 0 },
|
||||
enabled_periods: [1, 3],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/themes") {
|
||||
if (String(options.method || "GET").toUpperCase() === "PUT") {
|
||||
try {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
const catalog = body.catalog || body;
|
||||
if (catalog?.themes) {
|
||||
DEV_MOCK.config.themesCatalog = clone(catalog);
|
||||
DEV_MOCK.data.themes_catalog = clone(catalog);
|
||||
}
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
themes_dir: "data/themes",
|
||||
catalog: clone(DEV_MOCK.config.themesCatalog),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
themes_dir: "data/themes",
|
||||
catalog: clone(DEV_MOCK.config.themesCatalog),
|
||||
};
|
||||
}
|
||||
if (path === "/admin/appearance/logo") {
|
||||
return {
|
||||
ok: true,
|
||||
logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png",
|
||||
favicon_url: "/webapp-favicon/0000000000000000/icon-180.png",
|
||||
};
|
||||
}
|
||||
if (path === "/admin/appearance/favicon") {
|
||||
return {
|
||||
ok: true,
|
||||
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
|
||||
variants: {
|
||||
"32": "/webapp-favicon/1111111111111111/icon-32.png",
|
||||
apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||
try {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
const updates = body.updates || {};
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_URL")) {
|
||||
DEV_MOCK.config.logoUrl = updates.WEBAPP_LOGO_URL || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_USE_EMOJI")) {
|
||||
DEV_MOCK.config.logoUseEmoji = Boolean(updates.WEBAPP_LOGO_USE_EMOJI);
|
||||
}
|
||||
if (updates.WEBAPP_LOGO_EMOJI) DEV_MOCK.config.logoEmoji = updates.WEBAPP_LOGO_EMOJI;
|
||||
if (updates.WEBAPP_LOGO_EMOJI_FONT) {
|
||||
DEV_MOCK.config.logoEmojiFont = updates.WEBAPP_LOGO_EMOJI_FONT;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_URL")) {
|
||||
DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) {
|
||||
DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) {
|
||||
DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM);
|
||||
}
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
return { ok: true, applied: 1, reverted: 0 };
|
||||
}
|
||||
if (path === "/admin/settings")
|
||||
return {
|
||||
ok: true,
|
||||
sections: [
|
||||
{
|
||||
id: "appearance",
|
||||
order: 2,
|
||||
fields: [
|
||||
{
|
||||
key: "WEBAPP_LOGO_USE_EMOJI",
|
||||
type: "bool",
|
||||
section: "appearance",
|
||||
label: "Emoji logo",
|
||||
value: Boolean(DEV_MOCK.config.logoUseEmoji),
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_URL",
|
||||
type: "url",
|
||||
section: "appearance",
|
||||
label: "URL логотипа",
|
||||
value: DEV_MOCK.config.logoUrl || "",
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_EMOJI",
|
||||
type: "string",
|
||||
section: "appearance",
|
||||
label: "Emoji",
|
||||
value: DEV_MOCK.config.logoEmoji || "🫥",
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_EMOJI_FONT",
|
||||
type: "string",
|
||||
section: "appearance",
|
||||
label: "Emoji font",
|
||||
value: DEV_MOCK.config.logoEmojiFont || "system",
|
||||
choices: [
|
||||
{ value: "system", label: "Системный" },
|
||||
{ value: "noto-color", label: "Noto Color Emoji" },
|
||||
{ value: "noto-color-animated", label: "Noto Color Emoji Animated" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_FAVICON_USE_CUSTOM",
|
||||
type: "bool",
|
||||
section: "appearance",
|
||||
label: "Custom favicon",
|
||||
value: Boolean(DEV_MOCK.config.faviconUseCustom),
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_FAVICON_URL",
|
||||
type: "url",
|
||||
section: "appearance",
|
||||
label: "Favicon URL",
|
||||
value: DEV_MOCK.config.faviconUrl || "",
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_FAVICON_URL",
|
||||
type: "url",
|
||||
section: "appearance",
|
||||
label: "Logo favicon URL",
|
||||
value: DEV_MOCK.config.faviconUrl || "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (cleanPath.startsWith("/admin/"))
|
||||
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
|
||||
if (path === "/me") return clone(DEV_MOCK.data);
|
||||
if (path === "/auth/email/request") return { ok: true };
|
||||
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/auth/token") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" };
|
||||
if (path === "/devices") return clone(DEV_MOCK.data.devices);
|
||||
if (path === "/devices/topup-options")
|
||||
return clone(DEV_MOCK.data.device_topup_options || { ok: true, plans: [] });
|
||||
if (cleanPath === "/tariffs/topup-options") {
|
||||
const kind =
|
||||
new URLSearchParams(String(path || "").split("?")[1] || "").get("kind") || "regular";
|
||||
const payload = clone(DEV_MOCK.data.topup_options || { ok: true, plans: [] });
|
||||
payload.topup_kind = kind;
|
||||
payload.plans = (payload.plans || []).filter((plan) =>
|
||||
kind === "premium" ? plan.sale_mode === "premium_topup" : plan.sale_mode !== "premium_topup"
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
if (path === "/tariffs/change-options")
|
||||
return clone(DEV_MOCK.data.tariff_change_options || { ok: true, targets: [] });
|
||||
if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
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,
|
||||
active: true,
|
||||
status: "TRIAL",
|
||||
remaining_text: "5 д. 0 ч.",
|
||||
end_date_text: "05.05.2026 12:00",
|
||||
days_left: 5,
|
||||
traffic_limit: "10 GB",
|
||||
traffic_limit_bytes: 10737418240,
|
||||
traffic_used: "0 B",
|
||||
traffic_used_bytes: 0,
|
||||
};
|
||||
DEV_MOCK.data.settings.trial_available = false;
|
||||
return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" };
|
||||
}
|
||||
if (path === "/auth/logout") return { ok: true };
|
||||
if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
const language = normalizeLangCode(payload?.language || currentLang);
|
||||
DEV_MOCK.data.user.language_code = language;
|
||||
return { ok: true, language };
|
||||
}
|
||||
if (path === "/account/email/request" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return {
|
||||
ok: true,
|
||||
action: "open_link",
|
||||
payment_url: "https://example.com/payment-preview",
|
||||
payment_id: 10001,
|
||||
};
|
||||
}
|
||||
if (path === "/tariffs/change" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, tariff_key: "business" };
|
||||
}
|
||||
if (path === "/tariffs/change-payment" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return {
|
||||
ok: true,
|
||||
action: "open_link",
|
||||
payment_url: "https://example.com/tariff-change-payment-preview",
|
||||
payment_id: 10002,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: "not_found" };
|
||||
}
|
||||
@@ -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,520 @@
|
||||
const WINDOWS_95_THEME = {
|
||||
key: "windows95",
|
||||
names: { ru: "Windows 95", en: "Windows 95" },
|
||||
enabled: true,
|
||||
default: false,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "light",
|
||||
style_preset: "win95",
|
||||
},
|
||||
};
|
||||
|
||||
const ASCII_THEME = {
|
||||
key: "ascii",
|
||||
names: { ru: "ASCII", en: "ASCII" },
|
||||
enabled: true,
|
||||
default: false,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "dark",
|
||||
style_preset: "ascii",
|
||||
},
|
||||
};
|
||||
|
||||
export const DEV_MOCK = {
|
||||
config: {
|
||||
title: "/minishop",
|
||||
primaryColor: "#00fe7a",
|
||||
logoUrl: "",
|
||||
logoUseEmoji: false,
|
||||
logoEmoji: "🫥",
|
||||
logoEmojiFont: "system",
|
||||
faviconUrl: "",
|
||||
faviconUseCustom: false,
|
||||
apiBase: "/api",
|
||||
supportUrl: "https://t.me/support",
|
||||
privacyPolicyUrl: "https://example.com/privacy",
|
||||
userAgreementUrl: "https://example.com/agreement",
|
||||
currency: "RUB",
|
||||
language: "ru",
|
||||
emailAuthEnabled: true,
|
||||
telegramLoginBotUsername: "preview_bot",
|
||||
telegramLoginBotId: 1234567890,
|
||||
telegramOAuthClientId: 1234567890,
|
||||
telegramOAuthRequestAccess: ["write"],
|
||||
appVersion: "dev+local",
|
||||
appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop",
|
||||
themesCatalog: {
|
||||
default_theme: "dark",
|
||||
themes: [
|
||||
{
|
||||
key: "dark",
|
||||
names: { ru: "Тёмная", en: "Dark" },
|
||||
enabled: true,
|
||||
default: true,
|
||||
tokens: {
|
||||
color_scheme: "dark",
|
||||
accent: "#00fe7a",
|
||||
bg: "#03070b",
|
||||
panel: "#111820",
|
||||
text: "#f2f7f4",
|
||||
muted: "#a9b4b0",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "light",
|
||||
names: { ru: "Светлая", en: "Light" },
|
||||
enabled: true,
|
||||
default: false,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "light",
|
||||
},
|
||||
},
|
||||
WINDOWS_95_THEME,
|
||||
ASCII_THEME,
|
||||
],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
ok: true,
|
||||
user: {
|
||||
id: 100200300,
|
||||
username: "username",
|
||||
email: "user@example.com",
|
||||
email_verified: true,
|
||||
telegram_id: 100200300,
|
||||
telegram_linked: true,
|
||||
telegram_photo_url: "",
|
||||
first_name: "Preview",
|
||||
language_code: "ru",
|
||||
is_admin: true,
|
||||
},
|
||||
subscription: {
|
||||
active: true,
|
||||
status: "ACTIVE",
|
||||
remaining_text: "25 д. 8 ч.",
|
||||
end_date_text: "24.05.2026",
|
||||
days_left: 25,
|
||||
config_link: "https://sub.example.com/sub/preview-token",
|
||||
connect_url: "https://sub.example.com/connect/preview-token",
|
||||
traffic_used: "18.4 GB",
|
||||
traffic_limit: "100 GB",
|
||||
traffic_used_bytes: 19756849561,
|
||||
traffic_limit_bytes: 107374182400,
|
||||
premium_used: "32.0 GB",
|
||||
premium_limit: "50.0 GB",
|
||||
premium_used_bytes: 34359738368,
|
||||
premium_limit_bytes: 53687091200,
|
||||
premium_baseline_bytes: 53687091200,
|
||||
premium_topup_balance_bytes: 0,
|
||||
premium_is_limited: false,
|
||||
premium_title: "Premium-серверы",
|
||||
premium_node_labels: ["Premium NL-1", "Premium DE-1"],
|
||||
can_topup_regular_traffic: true,
|
||||
can_topup_premium_traffic: true,
|
||||
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 месяц" },
|
||||
{ months: 3, price: 790, currency: "RUB", title: "3 месяца" },
|
||||
{ months: 6, price: 1490, currency: "RUB", title: "6 месяцев" },
|
||||
{ months: 12, price: 2690, currency: "RUB", title: "12 месяцев" },
|
||||
],
|
||||
payment_methods: [
|
||||
{ id: "yookassa", name: "Карта" },
|
||||
{ id: "platega_sbp", name: "Telegram Pay" },
|
||||
{ id: "cryptopay", name: "Криптовалюта" },
|
||||
{ id: "freekassa", name: "Другие способы" },
|
||||
],
|
||||
referral: {
|
||||
code: "ABCD1234",
|
||||
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
|
||||
webapp_link: "https://minishop.app/ref/ABCD1234",
|
||||
invited_count: 4,
|
||||
purchased_count: 2,
|
||||
welcome_bonus_days: 3,
|
||||
one_bonus_per_referee: false,
|
||||
bonus_details: [
|
||||
{ months: 1, title: "1 месяц", inviter_days: 14, friend_days: 7 },
|
||||
{ months: 3, title: "3 месяца", inviter_days: 21, friend_days: 14 },
|
||||
{ months: 6, title: "6 месяцев", inviter_days: 31, friend_days: 21 },
|
||||
{ months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 },
|
||||
],
|
||||
},
|
||||
themes_catalog: {
|
||||
default_theme: "dark",
|
||||
themes: [
|
||||
{
|
||||
key: "dark",
|
||||
names: { ru: "Тёмная", en: "Dark" },
|
||||
enabled: true,
|
||||
tokens: {
|
||||
color_scheme: "dark",
|
||||
accent: "#00fe7a",
|
||||
bg: "#03070b",
|
||||
panel: "#111820",
|
||||
text: "#f2f7f4",
|
||||
muted: "#a9b4b0",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "light",
|
||||
names: { ru: "Светлая", en: "Light" },
|
||||
enabled: true,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "light",
|
||||
},
|
||||
},
|
||||
WINDOWS_95_THEME,
|
||||
ASCII_THEME,
|
||||
],
|
||||
},
|
||||
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,
|
||||
trial_traffic_limit_gb: 10,
|
||||
trial_traffic_strategy: "NO_RESET",
|
||||
email_auth_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function applyPreviewMock(kind) {
|
||||
const mode = String(kind || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const themeKeys = new Set((DEV_MOCK.config.themesCatalog.themes || []).map((theme) => theme.key));
|
||||
if (themeKeys.has(mode)) {
|
||||
DEV_MOCK.config.themesCatalog.default_theme = mode;
|
||||
DEV_MOCK.data.themes_catalog.default_theme = mode;
|
||||
for (const theme of DEV_MOCK.config.themesCatalog.themes || []) {
|
||||
theme.default = theme.key === mode;
|
||||
}
|
||||
for (const theme of DEV_MOCK.data.themes_catalog.themes || []) {
|
||||
theme.default = theme.key === mode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "traffic") {
|
||||
DEV_MOCK.data.settings.traffic_mode = true;
|
||||
DEV_MOCK.data.settings.trial_available = false;
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
active: true,
|
||||
status: "ACTIVE",
|
||||
remaining_text: "Навсегда",
|
||||
end_date_text: "01.01.2099 00:00",
|
||||
days_left: 26000,
|
||||
traffic_used: "18.4 GB",
|
||||
traffic_limit: "100 GB",
|
||||
traffic_used_bytes: 19756849561,
|
||||
traffic_limit_bytes: 107374182400,
|
||||
traffic_limit_strategy: "NO_RESET",
|
||||
};
|
||||
DEV_MOCK.data.plans = [
|
||||
{
|
||||
months: 10,
|
||||
traffic_gb: 10,
|
||||
price: 199,
|
||||
currency: "RUB",
|
||||
title: "10 GB",
|
||||
sale_mode: "traffic",
|
||||
},
|
||||
{
|
||||
months: 50,
|
||||
traffic_gb: 50,
|
||||
price: 799,
|
||||
currency: "RUB",
|
||||
title: "50 GB",
|
||||
sale_mode: "traffic",
|
||||
},
|
||||
{
|
||||
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 === "tariffs") {
|
||||
DEV_MOCK.data.settings.traffic_mode = false;
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
tariff_description: "100 GB каждый месяц",
|
||||
billing_model: "period",
|
||||
traffic_limit_strategy: "MONTH",
|
||||
};
|
||||
DEV_MOCK.data.plans = [
|
||||
{
|
||||
id: "standard:period:1",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
billing_model: "period",
|
||||
sale_mode: "subscription",
|
||||
months: 1,
|
||||
price: 150,
|
||||
currency: "RUB",
|
||||
title: "Стандарт",
|
||||
subtitle: "1 месяц",
|
||||
description: "100 GB каждый месяц",
|
||||
monthly_gb: 100,
|
||||
},
|
||||
{
|
||||
id: "standard:period:3",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
billing_model: "period",
|
||||
sale_mode: "subscription",
|
||||
months: 3,
|
||||
price: 400,
|
||||
currency: "RUB",
|
||||
title: "Стандарт",
|
||||
subtitle: "3 месяца",
|
||||
description: "100 GB каждый месяц",
|
||||
monthly_gb: 100,
|
||||
},
|
||||
{
|
||||
id: "business:period:1",
|
||||
tariff_key: "business",
|
||||
tariff_name: "Бизнес",
|
||||
billing_model: "period",
|
||||
sale_mode: "subscription",
|
||||
months: 1,
|
||||
price: 350,
|
||||
currency: "RUB",
|
||||
title: "Бизнес",
|
||||
subtitle: "1 месяц",
|
||||
description: "300 GB и приоритетные серверы",
|
||||
monthly_gb: 300,
|
||||
},
|
||||
{
|
||||
id: "traffic:traffic:50",
|
||||
tariff_key: "traffic",
|
||||
tariff_name: "Трафик",
|
||||
billing_model: "traffic",
|
||||
sale_mode: "traffic_package",
|
||||
months: 50,
|
||||
traffic_gb: 50,
|
||||
price: 799,
|
||||
currency: "RUB",
|
||||
title: "Трафик",
|
||||
subtitle: "50 GB",
|
||||
description: "Пакет без срока действия",
|
||||
},
|
||||
];
|
||||
DEV_MOCK.data.tariff_change_options = {
|
||||
ok: true,
|
||||
current: {
|
||||
tariff_key: "standard",
|
||||
title: "Стандарт",
|
||||
description: "100 GB каждый месяц",
|
||||
billing_model: "period",
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
tariff_key: "business",
|
||||
title: "Бизнес",
|
||||
description: "300 GB и приоритетные серверы",
|
||||
billing_model: "period",
|
||||
monthly_gb: 300,
|
||||
actions: [
|
||||
{
|
||||
mode: "recalc_days",
|
||||
kind: "free",
|
||||
title: "recalc_days",
|
||||
days_after: 10,
|
||||
remaining_days: 25,
|
||||
},
|
||||
{ mode: "paid_diff", kind: "payment", title: "paid_diff", price: 190, currency: "RUB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
tariff_key: "traffic",
|
||||
title: "Трафик",
|
||||
description: "Пакеты без срока действия",
|
||||
billing_model: "traffic",
|
||||
actions: [
|
||||
{
|
||||
mode: "convert_days_to_gb",
|
||||
kind: "free",
|
||||
title: "convert_days_to_gb",
|
||||
converted_gb: 18,
|
||||
remaining_days: 25,
|
||||
},
|
||||
{
|
||||
mode: "buy_package",
|
||||
kind: "payment",
|
||||
title: "+50 GB",
|
||||
traffic_gb: 50,
|
||||
price: 799,
|
||||
currency: "RUB",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
DEV_MOCK.data.topup_options = {
|
||||
ok: true,
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
traffic_percent: 86,
|
||||
warning_levels: [85, 90, 95],
|
||||
plans: [
|
||||
{
|
||||
id: "standard:topup:10",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "topup",
|
||||
traffic_gb: 10,
|
||||
months: 10,
|
||||
price: 99,
|
||||
currency: "RUB",
|
||||
title: "10 GB",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
{
|
||||
id: "standard:topup:50",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "topup",
|
||||
traffic_gb: 50,
|
||||
months: 50,
|
||||
price: 399,
|
||||
currency: "RUB",
|
||||
title: "50 GB",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
{
|
||||
id: "standard:topup:200",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "topup",
|
||||
traffic_gb: 200,
|
||||
months: 200,
|
||||
price: 1299,
|
||||
currency: "RUB",
|
||||
title: "200 GB",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
],
|
||||
};
|
||||
DEV_MOCK.data.device_topup_options = {
|
||||
ok: true,
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
current_limit: 5,
|
||||
plans: [
|
||||
{
|
||||
id: "standard:hwid:1",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "hwid_devices",
|
||||
device_count: 1,
|
||||
months: 1,
|
||||
price: 99,
|
||||
currency: "RUB",
|
||||
title: "+1",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
{
|
||||
id: "standard:hwid:3",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "hwid_devices",
|
||||
device_count: 3,
|
||||
months: 3,
|
||||
price: 249,
|
||||
currency: "RUB",
|
||||
title: "+3",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
],
|
||||
};
|
||||
} 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;
|
||||
DEV_MOCK.data.settings.trial_available = true;
|
||||
DEV_MOCK.data.settings.trial_duration_days = 5;
|
||||
DEV_MOCK.data.settings.trial_traffic_limit_gb = 10;
|
||||
DEV_MOCK.data.subscription = {
|
||||
active: false,
|
||||
status: "INACTIVE",
|
||||
remaining_text: "Подписка не активна",
|
||||
end_date_text: "",
|
||||
days_left: 0,
|
||||
config_link: null,
|
||||
connect_url: null,
|
||||
traffic_used: "0 B",
|
||||
traffic_limit: "10 GB",
|
||||
traffic_used_bytes: 0,
|
||||
traffic_limit_bytes: 10737418240,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readCookie } from "./session.js";
|
||||
|
||||
export function createApiClient({
|
||||
apiBase = "",
|
||||
csrfCookieName = "rw_webapp_csrf",
|
||||
getCsrfToken = () => "",
|
||||
onUnauthorized = () => {},
|
||||
mockApi = null,
|
||||
getMockContext = () => ({}),
|
||||
} = {}) {
|
||||
const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData;
|
||||
|
||||
async function api(path, options = {}) {
|
||||
if (mockApi) return mockApi(path, options, getMockContext());
|
||||
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
const headers = { ...(options.headers || {}) };
|
||||
|
||||
const csrf = getCsrfToken() || readCookie(csrfCookieName) || "";
|
||||
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
headers["X-CSRF-Token"] = csrf;
|
||||
}
|
||||
if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) onUnauthorized();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function publicApi(path, payload = {}, options = {}) {
|
||||
if (mockApi) {
|
||||
return mockApi(path, { method: "POST", body: JSON.stringify(payload) }, getMockContext());
|
||||
}
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: options.signal,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return { api, publicApi };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ADMIN_SECTIONS, APP_SECTION_PATHS } from "./constants.js";
|
||||
|
||||
export function normalizeSection(value) {
|
||||
const section = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
section === "invite" ||
|
||||
section === "devices" ||
|
||||
section === "settings" ||
|
||||
section === "admin"
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
return "home";
|
||||
}
|
||||
|
||||
export function sectionFromPath(pathname) {
|
||||
const normalizedPath = String(pathname || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
if (!normalizedPath || normalizedPath === "/") return "home";
|
||||
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
|
||||
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
|
||||
return normalizeSection(section);
|
||||
}
|
||||
|
||||
export function adminSectionFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/);
|
||||
if (m && ADMIN_SECTIONS.has(m[1])) return m[1];
|
||||
return "stats";
|
||||
}
|
||||
|
||||
export function adminUserIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/users\/(-?\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
|
||||
if (window.location.protocol === "file:") return;
|
||||
const normalized = normalizeSection(section);
|
||||
let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
|
||||
if (normalized === "admin") {
|
||||
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
|
||||
const uid =
|
||||
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
|
||||
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
|
||||
}
|
||||
if (window.location.pathname === targetPath) return;
|
||||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export const TOKEN_STORAGE_KEY = "rw_webapp_token";
|
||||
export const CSRF_COOKIE_NAME = "rw_webapp_csrf";
|
||||
export const REFERRAL_STORAGE_KEY = "rw_webapp_referral";
|
||||
|
||||
function ignoreStorageError(error) {
|
||||
void error;
|
||||
}
|
||||
|
||||
export function readCookie(name) {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = `${name}=`;
|
||||
const cookie = document.cookie.split("; ").find((part) => part.startsWith(prefix));
|
||||
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : "";
|
||||
}
|
||||
|
||||
export function clearStoredToken(storageKey = TOKEN_STORAGE_KEY) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
|
||||
export function markManualLogout(flagKey) {
|
||||
try {
|
||||
localStorage.setItem(flagKey, "1");
|
||||
} catch (error) {
|
||||
ignoreStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearManualLogoutFlag(flagKey) {
|
||||
try {
|
||||
localStorage.removeItem(flagKey);
|
||||
} catch (error) {
|
||||
ignoreStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function isManuallyLoggedOut(flagKey) {
|
||||
try {
|
||||
return localStorage.getItem(flagKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberReferral(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return readReferral();
|
||||
try {
|
||||
localStorage.setItem(REFERRAL_STORAGE_KEY, normalized);
|
||||
} catch (error) {
|
||||
ignoreStorageError(error);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readReferral() {
|
||||
try {
|
||||
return localStorage.getItem(REFERRAL_STORAGE_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { emailError, buildTelegramOAuthStartUrl } from "../authHelpers.js";
|
||||
|
||||
export function createAccountStore({
|
||||
api,
|
||||
publicApi,
|
||||
setToken,
|
||||
loadData,
|
||||
t,
|
||||
showToast,
|
||||
clearToken,
|
||||
markManualLogout,
|
||||
showLogin,
|
||||
telegramSdk,
|
||||
getTg,
|
||||
telegramOAuthClientId,
|
||||
currentLang,
|
||||
normalizeLangCode,
|
||||
updateLocalData,
|
||||
}) {
|
||||
const state = writable({
|
||||
linkEmailOpen: false,
|
||||
linkEmailBusy: false,
|
||||
linkTelegramBusy: false,
|
||||
linkEmailValue: "",
|
||||
linkEmailPending: "",
|
||||
linkEmailCode: "",
|
||||
linkEmailStatus: "",
|
||||
linkEmailIsError: false,
|
||||
linkEmailFieldError: "",
|
||||
linkEmailResendCooldown: 0,
|
||||
languageBusy: false,
|
||||
});
|
||||
|
||||
let linkEmailResendTimer = null;
|
||||
|
||||
function setLinkEmailStatus(message, isError = false) {
|
||||
state.update((s) => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
|
||||
}
|
||||
|
||||
function clearCooldownTimer() {
|
||||
if (linkEmailResendTimer) {
|
||||
window.clearInterval(linkEmailResendTimer);
|
||||
linkEmailResendTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldownTimer(seconds = 60) {
|
||||
clearCooldownTimer();
|
||||
state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
||||
linkEmailResendTimer = window.setInterval(() => {
|
||||
const s = get(state);
|
||||
if (s.linkEmailResendCooldown <= 1) {
|
||||
state.update((s) => ({ ...s, linkEmailResendCooldown: 0 }));
|
||||
clearCooldownTimer();
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkEmailResendCooldown: s.linkEmailResendCooldown - 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openLinkEmailDialog(email) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
linkEmailOpen: true,
|
||||
linkEmailBusy: false,
|
||||
linkEmailCode: "",
|
||||
linkEmailPending: "",
|
||||
linkEmailStatus: "",
|
||||
linkEmailIsError: false,
|
||||
linkEmailFieldError: "",
|
||||
linkEmailValue: email || "",
|
||||
linkEmailResendCooldown: 0,
|
||||
}));
|
||||
clearCooldownTimer();
|
||||
}
|
||||
|
||||
function closeLinkEmailDialog() {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
linkEmailOpen: false,
|
||||
linkEmailBusy: false,
|
||||
linkEmailCode: "",
|
||||
linkEmailPending: "",
|
||||
linkEmailStatus: "",
|
||||
linkEmailIsError: false,
|
||||
linkEmailFieldError: "",
|
||||
linkEmailResendCooldown: 0,
|
||||
}));
|
||||
clearCooldownTimer();
|
||||
}
|
||||
|
||||
async function requestLinkEmailCode() {
|
||||
const s = get(state);
|
||||
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
|
||||
const normalized = String(s.linkEmailValue || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized || !normalized.includes("@")) {
|
||||
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkEmailFieldError: "", linkEmailBusy: true }));
|
||||
setLinkEmailStatus(t("wa_auth_sending_code"));
|
||||
try {
|
||||
const response = await api("/account/email/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: normalized }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({ ...s, linkEmailPending: normalized, linkEmailCode: "" }));
|
||||
setLinkEmailStatus("");
|
||||
startCooldownTimer(60);
|
||||
} catch (error) {
|
||||
setLinkEmailStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, linkEmailBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyLinkEmailCode() {
|
||||
const s = get(state);
|
||||
const code = String(s.linkEmailCode || "")
|
||||
.replace(/\\D/g, "")
|
||||
.slice(0, 6);
|
||||
if (!s.linkEmailPending) {
|
||||
setLinkEmailStatus(t("wa_auth_send_code_failed"), true);
|
||||
return;
|
||||
}
|
||||
if (code.length !== 6) {
|
||||
setLinkEmailStatus(t("wa_auth_enter_code_6digits"), true);
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkEmailBusy: true }));
|
||||
setLinkEmailStatus(t("wa_auth_checking_code"));
|
||||
try {
|
||||
const response = await api("/account/email/verify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: s.linkEmailPending, code }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (response?.csrf_token) setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
closeLinkEmailDialog();
|
||||
showToast(t("wa_settings_linked"));
|
||||
} catch (error) {
|
||||
setLinkEmailStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, linkEmailBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function linkTelegramAccountWithPayload(payload) {
|
||||
state.update((s) => ({ ...s, linkTelegramBusy: true }));
|
||||
try {
|
||||
const response = await api("/account/telegram/link", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (response?.csrf_token) setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
showToast(t("wa_settings_linked"));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, linkTelegramBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function linkTelegramAccount(getTelegramMiniAppInitData) {
|
||||
const s = get(state);
|
||||
if (s.linkTelegramBusy) return;
|
||||
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
|
||||
if (isTelegramMiniAppAttempt) {
|
||||
await telegramSdk.ensureForAction();
|
||||
}
|
||||
const initData = getTelegramMiniAppInitData();
|
||||
if (initData) {
|
||||
await linkTelegramAccountWithPayload({ init_data: initData });
|
||||
return;
|
||||
}
|
||||
if (!telegramOAuthClientId) {
|
||||
showToast(t("wa_auth_telegram_not_configured"));
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkTelegramBusy: true }));
|
||||
window.location.assign(buildTelegramOAuthStartUrl("link", getTg()));
|
||||
}
|
||||
|
||||
async function updateAccountLanguage(nextValue) {
|
||||
const s = get(state);
|
||||
const normalize = typeof normalizeLangCode === "function" ? normalizeLangCode : (v) => v;
|
||||
const language = normalize(nextValue);
|
||||
if (!language || s.languageBusy || language === currentLang()) return;
|
||||
state.update((s) => ({ ...s, languageBusy: true }));
|
||||
try {
|
||||
const response = await api("/account/language", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ language }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (typeof updateLocalData === "function") {
|
||||
updateLocalData(normalize(response.language || language));
|
||||
}
|
||||
await loadData();
|
||||
} catch {
|
||||
showToast(t("wa_settings_language_update_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, languageBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
markManualLogout();
|
||||
clearToken();
|
||||
try {
|
||||
await publicApi("/auth/logout", { keepalive: true });
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
showLogin();
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
openLinkEmailDialog,
|
||||
closeLinkEmailDialog,
|
||||
requestLinkEmailCode,
|
||||
verifyLinkEmailCode,
|
||||
linkTelegramAccount,
|
||||
updateAccountLanguage,
|
||||
logout,
|
||||
clearLinkEmailResendTimer: clearCooldownTimer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import {
|
||||
readReferralParam,
|
||||
clearAuthQuery,
|
||||
buildTelegramOAuthStartUrl,
|
||||
emailError,
|
||||
} from "../authHelpers.js";
|
||||
|
||||
export function createAuthStore({
|
||||
publicApi,
|
||||
setToken,
|
||||
loadData,
|
||||
telegramSdk,
|
||||
getTg,
|
||||
t,
|
||||
currentLang,
|
||||
}) {
|
||||
const state = writable({
|
||||
authStatus: "",
|
||||
authIsError: false,
|
||||
authBusy: false,
|
||||
telegramLoginBusy: false,
|
||||
telegramLoginAttemptId: 0,
|
||||
loginEmailFieldError: "",
|
||||
loginEmailTooltipOpen: false,
|
||||
authResendCooldown: 0,
|
||||
email: "",
|
||||
pendingEmail: "",
|
||||
emailCode: "",
|
||||
});
|
||||
|
||||
let authResendTimer = null;
|
||||
let telegramLoginWatchdogTimer = null;
|
||||
|
||||
function setAuthStatus(message, isError = false) {
|
||||
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
|
||||
}
|
||||
|
||||
function clearCooldownTimer() {
|
||||
if (authResendTimer) {
|
||||
window.clearInterval(authResendTimer);
|
||||
authResendTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldownTimer(seconds = 60) {
|
||||
clearCooldownTimer();
|
||||
state.update((s) => ({ ...s, authResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
||||
authResendTimer = window.setInterval(() => {
|
||||
const { authResendCooldown } = get(state);
|
||||
if (authResendCooldown <= 1) {
|
||||
state.update((s) => ({ ...s, authResendCooldown: 0 }));
|
||||
clearCooldownTimer();
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, authResendCooldown: authResendCooldown - 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function startTelegramLoginWatchdog() {
|
||||
const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 6000;
|
||||
stopTelegramLoginWatchdog();
|
||||
state.update((s) => ({ ...s, telegramLoginAttemptId: s.telegramLoginAttemptId + 1 }));
|
||||
const { telegramLoginAttemptId } = get(state);
|
||||
|
||||
telegramLoginWatchdogTimer = window.setTimeout(() => {
|
||||
if (get(state).telegramLoginAttemptId !== telegramLoginAttemptId) return;
|
||||
telegramLoginWatchdogTimer = null;
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: false, authBusy: false }));
|
||||
setAuthStatus(t("wa_auth_telegram_timeout"), true);
|
||||
}, TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS);
|
||||
|
||||
return telegramLoginAttemptId;
|
||||
}
|
||||
|
||||
function stopTelegramLoginWatchdog(attemptId = null) {
|
||||
if (attemptId !== null && attemptId !== get(state).telegramLoginAttemptId) return;
|
||||
if (telegramLoginWatchdogTimer) {
|
||||
window.clearTimeout(telegramLoginWatchdogTimer);
|
||||
telegramLoginWatchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveTelegramLoginAttempt(attemptId) {
|
||||
const s = get(state);
|
||||
return attemptId === s.telegramLoginAttemptId && s.telegramLoginBusy;
|
||||
}
|
||||
|
||||
async function finalizeMagicLogin(loginToken) {
|
||||
const s = get(state);
|
||||
if (s.authBusy) return false;
|
||||
state.update((s) => ({ ...s, authBusy: true }));
|
||||
setAuthStatus(t("wa_auth_checking_login"));
|
||||
try {
|
||||
const payload = { token: loginToken };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/magic", payload);
|
||||
if (response.ok && response.csrf_token) {
|
||||
setToken("", response.csrf_token);
|
||||
clearAuthQuery();
|
||||
await loadData();
|
||||
return true;
|
||||
}
|
||||
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
|
||||
} catch {
|
||||
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function finalizeTelegramAuth(authData, source = "auth_data", options = {}) {
|
||||
const s = get(state);
|
||||
if (s.authBusy) return false;
|
||||
state.update((s) => ({ ...s, authBusy: true }));
|
||||
setAuthStatus(t("wa_auth_checking_telegram"));
|
||||
try {
|
||||
const payload =
|
||||
source === "init_data"
|
||||
? { init_data: authData }
|
||||
: source === "id_token"
|
||||
? { id_token: authData.id_token, nonce: authData.nonce }
|
||||
: { auth_data: authData };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/token", payload, { signal: options.signal });
|
||||
if (response.ok && response.csrf_token) {
|
||||
setToken("", response.csrf_token);
|
||||
clearAuthQuery();
|
||||
setAuthStatus("");
|
||||
await loadData();
|
||||
return true;
|
||||
}
|
||||
setAuthStatus(
|
||||
response.error === "banned"
|
||||
? t("wa_auth_access_denied")
|
||||
: t("wa_auth_telegram_not_confirmed"),
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
setAuthStatus(
|
||||
error?.name === "AbortError"
|
||||
? t("wa_auth_telegram_timeout")
|
||||
: t("wa_auth_telegram_unavailable"),
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function requestEmailCode(changeScreen) {
|
||||
const s = get(state);
|
||||
if (s.authResendCooldown > 0 && s.pendingEmail) return;
|
||||
const normalized = s.email.trim().toLowerCase();
|
||||
if (!normalized || !normalized.includes("@")) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
loginEmailFieldError: t("wa_auth_invalid_email"),
|
||||
loginEmailTooltipOpen: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
loginEmailFieldError: "",
|
||||
loginEmailTooltipOpen: false,
|
||||
authBusy: true,
|
||||
}));
|
||||
setAuthStatus(t("wa_auth_sending_code"));
|
||||
try {
|
||||
const payload = { email: normalized, language: currentLang() };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/request", payload);
|
||||
if (!response.ok) throw response;
|
||||
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: "" }));
|
||||
changeScreen("code");
|
||||
setAuthStatus("");
|
||||
startCooldownTimer(60);
|
||||
} catch (error) {
|
||||
setAuthStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEmailCode() {
|
||||
const s = get(state);
|
||||
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
|
||||
if (code.length !== 6) {
|
||||
setAuthStatus(t("wa_auth_enter_code_6digits"), true);
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, authBusy: true }));
|
||||
setAuthStatus(t("wa_auth_checking_code"));
|
||||
try {
|
||||
const payload = { email: s.pendingEmail, code };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/verify", payload);
|
||||
if (!response.ok || !response.csrf_token) throw response;
|
||||
setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
setAuthStatus("");
|
||||
} catch (error) {
|
||||
setAuthStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function openTelegramLogin(telegramOAuthClientId, getTelegramMiniAppInitData) {
|
||||
const s = get(state);
|
||||
if (s.authBusy || s.telegramLoginBusy) return;
|
||||
setAuthStatus("");
|
||||
|
||||
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
|
||||
if (!isTelegramMiniAppAttempt && telegramOAuthClientId) {
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: true }));
|
||||
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
|
||||
window.setTimeout(() => {
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: false }));
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: true }));
|
||||
const attemptId = startTelegramLoginWatchdog();
|
||||
const loginTimeout = telegramSdk.createMiniAppAuthTimeout();
|
||||
try {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await telegramSdk.ensureForAction();
|
||||
if (!isActiveTelegramLoginAttempt(attemptId)) return;
|
||||
const initData = getTelegramMiniAppInitData();
|
||||
if (initData) {
|
||||
await finalizeTelegramAuth(initData, "init_data", { signal: loginTimeout.signal });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!telegramOAuthClientId) {
|
||||
setAuthStatus(t("wa_auth_telegram_not_configured"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
|
||||
})(),
|
||||
loginTimeout.promise,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isActiveTelegramLoginAttempt(attemptId)) return;
|
||||
if (error?.name === "AbortError") {
|
||||
setAuthStatus(t("wa_auth_telegram_timeout"), true);
|
||||
} else {
|
||||
setAuthStatus(t("wa_auth_telegram_unavailable"), true);
|
||||
}
|
||||
} finally {
|
||||
loginTimeout.clear();
|
||||
if (loginTimeout.timedOut) {
|
||||
setAuthStatus(t("wa_auth_telegram_timeout"), true);
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
if (isActiveTelegramLoginAttempt(attemptId)) {
|
||||
stopTelegramLoginWatchdog(attemptId);
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
finalizeMagicLogin,
|
||||
finalizeTelegramAuth,
|
||||
requestEmailCode,
|
||||
verifyEmailCode,
|
||||
openTelegramLogin,
|
||||
clearCooldownTimer,
|
||||
stopTelegramLoginWatchdog,
|
||||
setAuthStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
|
||||
export function createBillingStore({ billing, loadData, t, showToast, openExternalLink, tg }) {
|
||||
const state = writable({
|
||||
paymentModalOpen: false,
|
||||
paymentStep: "tariff",
|
||||
selectedTariffKey: "",
|
||||
selectedPlan: null,
|
||||
selectedMethod: "",
|
||||
topupModalOpen: false,
|
||||
topupKind: "regular",
|
||||
deviceTopupModalOpen: false,
|
||||
changeModalOpen: false,
|
||||
topupOptions: null,
|
||||
deviceTopupOptions: null,
|
||||
changeOptions: null,
|
||||
selectedTopupPlan: null,
|
||||
selectedDeviceTopupPlan: null,
|
||||
selectedChangeTarget: null,
|
||||
selectedChangeAction: null,
|
||||
changeConfirmOpen: false,
|
||||
tariffActionBusy: false,
|
||||
payBusy: false,
|
||||
});
|
||||
|
||||
let topupOptionsRequestId = 0;
|
||||
|
||||
function openPaymentModal(
|
||||
tariffMode,
|
||||
singleTariffMode,
|
||||
tariffCatalog,
|
||||
subscription,
|
||||
plans,
|
||||
defaultMethod = ""
|
||||
) {
|
||||
state.update((s) => {
|
||||
let step;
|
||||
let plan = s.selectedPlan;
|
||||
let tariffKey = s.selectedTariffKey;
|
||||
|
||||
if (tariffMode) {
|
||||
if (singleTariffMode && tariffCatalog[0]?.key) {
|
||||
tariffKey = tariffCatalog[0].key;
|
||||
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
|
||||
step = "checkout";
|
||||
} else if (
|
||||
subscription?.active &&
|
||||
subscription?.tariff_key &&
|
||||
tariffCatalog.some((t) => t.key === subscription.tariff_key)
|
||||
) {
|
||||
tariffKey = subscription.tariff_key;
|
||||
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
|
||||
step = "checkout";
|
||||
} else {
|
||||
step = "tariff";
|
||||
tariffKey = "";
|
||||
plan = null;
|
||||
}
|
||||
} else {
|
||||
step = "checkout";
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
paymentModalOpen: true,
|
||||
paymentStep: step,
|
||||
selectedTariffKey: tariffKey,
|
||||
selectedPlan: plan,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function closePaymentModal() {
|
||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||
}
|
||||
|
||||
function selectTariff(tariff, plans = []) {
|
||||
const key = String(tariff?.key || "").trim();
|
||||
if (!key) return;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
selectedTariffKey: key,
|
||||
selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function continueWithSelectedTariff(selectedTariffPlans = []) {
|
||||
state.update((s) => {
|
||||
if (!s.selectedTariffKey) return s;
|
||||
return {
|
||||
...s,
|
||||
selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null,
|
||||
paymentStep: "checkout",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function backToTariffList(subscription, tariffCatalog = []) {
|
||||
if (
|
||||
subscription?.active &&
|
||||
subscription?.tariff_key &&
|
||||
tariffCatalog.some((t) => t.key === subscription.tariff_key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, paymentStep: "tariff" }));
|
||||
}
|
||||
|
||||
function openTopupModal(kind = "regular", defaultMethod = "") {
|
||||
const normalizedKind = kind === "premium" ? "premium" : "regular";
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
topupKind: normalizedKind,
|
||||
topupModalOpen: true,
|
||||
topupOptions: s.topupOptions?.topup_kind === normalizedKind ? s.topupOptions : null,
|
||||
selectedTopupPlan: s.topupOptions?.topup_kind === normalizedKind ? s.selectedTopupPlan : null,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
}));
|
||||
loadTopupOptions(normalizedKind);
|
||||
}
|
||||
|
||||
function closeTopupModal() {
|
||||
state.update((s) => ({ ...s, topupModalOpen: false }));
|
||||
}
|
||||
|
||||
function openDeviceTopupModal(defaultMethod = "") {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
deviceTopupModalOpen: true,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
}));
|
||||
loadDeviceTopupOptions();
|
||||
}
|
||||
|
||||
function closeDeviceTopupModal() {
|
||||
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
|
||||
}
|
||||
|
||||
function openTariffChangeModal(defaultMethod = "") {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
changeModalOpen: true,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
}));
|
||||
loadTariffChangeOptions();
|
||||
}
|
||||
|
||||
function closeTariffChangeModal() {
|
||||
state.update((s) => ({ ...s, changeModalOpen: false }));
|
||||
}
|
||||
|
||||
function openTariffChangeConfirm() {
|
||||
const s = get(state);
|
||||
if (!s.selectedChangeTarget || !s.selectedChangeAction) return;
|
||||
state.update((s) => ({ ...s, changeConfirmOpen: true }));
|
||||
}
|
||||
|
||||
function closeTariffChangeConfirm() {
|
||||
state.update((s) => ({ ...s, changeConfirmOpen: false }));
|
||||
}
|
||||
|
||||
function openTelegramInvoice(url) {
|
||||
if (!url) return;
|
||||
if (tg?.openInvoice) {
|
||||
tg.openInvoice(url, (status) => {
|
||||
if (status === "paid") {
|
||||
showToast(t("wa_payment_success", {}, "Payment successful"));
|
||||
loadData();
|
||||
} else if (status === "failed") {
|
||||
showToast(t("wa_payment_create_failed"));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
openExternalLink(url);
|
||||
}
|
||||
|
||||
async function createPayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedPlan || !s.selectedMethod || s.payBusy) return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const response = await billing.postPayment(
|
||||
billing.planPaymentBody(s.selectedPlan, s.selectedMethod)
|
||||
);
|
||||
if (!response.ok) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
if (response.action === "open_invoice") {
|
||||
if (!response.payment_url) throw response;
|
||||
openTelegramInvoice(response.payment_url);
|
||||
} else if (response.action === "invoice_sent") {
|
||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||
return;
|
||||
} else {
|
||||
if (!response.payment_url) throw response;
|
||||
openExternalLink(response.payment_url);
|
||||
}
|
||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopupOptions(kind) {
|
||||
const s = get(state);
|
||||
if (s.topupOptions?.topup_kind === kind) return;
|
||||
const requestId = ++topupOptionsRequestId;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffActionBusy: true,
|
||||
topupOptions: null,
|
||||
selectedTopupPlan: null,
|
||||
}));
|
||||
try {
|
||||
const response = await billing.fetchTopupOptions(kind);
|
||||
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
topupOptions: response,
|
||||
selectedTopupPlan: response.plans?.[0] || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
|
||||
showToast(error?.message || t("wa_tariff_options_failed"));
|
||||
state.update((s) => ({ ...s, topupModalOpen: false }));
|
||||
} finally {
|
||||
if (requestId === topupOptionsRequestId) {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createTopupPayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedTopupPlan || !s.selectedMethod || s.payBusy) return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const response = await billing.postPayment(
|
||||
billing.topupPaymentBody(s.selectedTopupPlan, s.selectedMethod, s.topupOptions?.tariff_key)
|
||||
);
|
||||
if (!response.ok || !response.payment_url) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
openExternalLink(response.payment_url);
|
||||
state.update((s) => ({ ...s, topupModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTariffChangeOptions() {
|
||||
const s = get(state);
|
||||
if (s.changeOptions || s.tariffActionBusy) return;
|
||||
state.update((s) => ({ ...s, tariffActionBusy: true }));
|
||||
try {
|
||||
const response = await billing.fetchTariffChangeOptions();
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
changeOptions: response,
|
||||
selectedChangeTarget: response.targets?.[0] || null,
|
||||
selectedChangeAction: response.targets?.[0]?.actions?.[0] || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_tariff_options_failed"));
|
||||
state.update((s) => ({ ...s, changeModalOpen: false }));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTariffChange() {
|
||||
const s = get(state);
|
||||
if (!s.selectedChangeTarget || !s.selectedChangeAction || s.tariffActionBusy) return;
|
||||
if (s.selectedChangeAction.kind === "payment") {
|
||||
await createTariffChangePayment();
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, tariffActionBusy: true }));
|
||||
try {
|
||||
const response = await billing.postTariffChange({
|
||||
tariff_key: s.selectedChangeTarget.tariff_key,
|
||||
mode: s.selectedChangeAction.mode,
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
showToast(t("wa_tariff_change_applied"));
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
changeConfirmOpen: false,
|
||||
changeModalOpen: false,
|
||||
changeOptions: null,
|
||||
}));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_tariff_change_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function createTariffChangePayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedChangeTarget || !s.selectedChangeAction || !s.selectedMethod || s.payBusy)
|
||||
return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const body = billing.changePaymentBody(
|
||||
s.selectedChangeAction,
|
||||
s.selectedChangeTarget,
|
||||
s.selectedMethod
|
||||
);
|
||||
const response =
|
||||
s.selectedChangeAction.mode === "buy_package" ||
|
||||
s.selectedChangeAction.mode === "buy_period"
|
||||
? await billing.postPayment(body)
|
||||
: await billing.postTariffChangePayment(body);
|
||||
if (!response.ok || !response.payment_url) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
openExternalLink(response.payment_url);
|
||||
state.update((s) => ({ ...s, changeConfirmOpen: false, changeModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeviceTopupOptions() {
|
||||
const s = get(state);
|
||||
if (s.deviceTopupOptions || s.tariffActionBusy) return;
|
||||
state.update((s) => ({ ...s, tariffActionBusy: true }));
|
||||
try {
|
||||
const response = await billing.fetchDeviceTopupOptions();
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
deviceTopupOptions: response,
|
||||
selectedDeviceTopupPlan: response.plans?.[0] || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_device_topup_options_failed"));
|
||||
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceTopupPayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedDeviceTopupPlan || !s.selectedMethod || s.payBusy) return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const response = await billing.postPayment(
|
||||
billing.deviceTopupPaymentBody(
|
||||
s.selectedDeviceTopupPlan,
|
||||
s.selectedMethod,
|
||||
s.deviceTopupOptions?.tariff_key
|
||||
)
|
||||
);
|
||||
if (!response.ok || !response.payment_url) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
openExternalLink(response.payment_url);
|
||||
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
openPaymentModal,
|
||||
closePaymentModal,
|
||||
selectTariff,
|
||||
continueWithSelectedTariff,
|
||||
backToTariffList,
|
||||
createPayment,
|
||||
openTopupModal,
|
||||
closeTopupModal,
|
||||
loadTopupOptions,
|
||||
createTopupPayment,
|
||||
openTariffChangeModal,
|
||||
closeTariffChangeModal,
|
||||
openTariffChangeConfirm,
|
||||
closeTariffChangeConfirm,
|
||||
loadTariffChangeOptions,
|
||||
applyTariffChange,
|
||||
createTariffChangePayment,
|
||||
openDeviceTopupModal,
|
||||
closeDeviceTopupModal,
|
||||
loadDeviceTopupOptions,
|
||||
createDeviceTopupPayment,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
|
||||
export function createDevicesStore({ api, t, showToast }) {
|
||||
const state = writable({
|
||||
devicesData: null,
|
||||
devicesLoaded: false,
|
||||
devicesBusy: false,
|
||||
devicesStatus: "",
|
||||
devicesIsError: false,
|
||||
deviceConfirmOpen: false,
|
||||
deviceToDisconnect: null,
|
||||
deviceDisconnectBusy: false,
|
||||
});
|
||||
|
||||
async function loadDevices(devicesEnabled, force = false) {
|
||||
const s = get(state);
|
||||
if (!devicesEnabled || s.devicesBusy || (s.devicesLoaded && !force)) return;
|
||||
state.update((s) => ({ ...s, devicesBusy: true, devicesStatus: "", devicesIsError: false }));
|
||||
try {
|
||||
const response = await api("/devices");
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({ ...s, devicesData: response, devicesLoaded: true }));
|
||||
} catch (error) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
devicesStatus: error?.message || t("wa_devices_load_failed"),
|
||||
devicesIsError: true,
|
||||
devicesLoaded: true,
|
||||
}));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, devicesBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function openDeviceDisconnectDialog(device) {
|
||||
state.update((s) => ({ ...s, deviceToDisconnect: device, deviceConfirmOpen: true }));
|
||||
}
|
||||
|
||||
function closeDeviceDisconnectDialog() {
|
||||
const s = get(state);
|
||||
if (s.deviceDisconnectBusy) return;
|
||||
state.update((s) => ({ ...s, deviceConfirmOpen: false, deviceToDisconnect: null }));
|
||||
}
|
||||
|
||||
async function disconnectDevice(devicesEnabled) {
|
||||
const s = get(state);
|
||||
const token = String(s.deviceToDisconnect?.token || "").trim();
|
||||
if (!token || s.deviceDisconnectBusy) return;
|
||||
state.update((s) => ({ ...s, 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"));
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
deviceConfirmOpen: false,
|
||||
deviceToDisconnect: null,
|
||||
devicesLoaded: false,
|
||||
}));
|
||||
await loadDevices(devicesEnabled, true);
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_device_disconnect_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, deviceDisconnectBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadDevices,
|
||||
openDeviceDisconnectDialog,
|
||||
closeDeviceDisconnectDialog,
|
||||
disconnectDevice,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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, { t, termUnitLabel }) {
|
||||
const months = Number(value || 0);
|
||||
if (months === 12) return t("wa_plan_one_year");
|
||||
return t("wa_sub_term_value_unit", {
|
||||
value: String(months),
|
||||
unit: termUnitLabel(months, "month"),
|
||||
});
|
||||
}
|
||||
|
||||
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, { t, termUnitLabel }) {
|
||||
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, { t, termUnitLabel });
|
||||
}
|
||||
|
||||
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,169 @@
|
||||
export function readTelegramMiniAppInitDataFromLocation() {
|
||||
if (typeof window === "undefined") return "";
|
||||
const queryText = window.location.search.replace(/^\?/, "");
|
||||
const hashText = window.location.hash.replace(/^#/, "");
|
||||
for (const text of [queryText, hashText]) {
|
||||
if (!text) continue;
|
||||
const params = new URLSearchParams(text);
|
||||
const initData = params.get("tgWebAppData");
|
||||
if (initData) return initData;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function createTelegramSdk({
|
||||
scriptUrl,
|
||||
bootTimeoutMs,
|
||||
actionTimeoutMs,
|
||||
miniAppAuthTimeoutMs,
|
||||
onStatusChange = () => {},
|
||||
onInitDataChange = () => {},
|
||||
} = {}) {
|
||||
let tg = resolve();
|
||||
let sdkPromise = null;
|
||||
let launchParamsDetected = false;
|
||||
let initData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
|
||||
if (initData) launchParamsDetected = true;
|
||||
|
||||
function resolve() {
|
||||
return window.Telegram?.WebApp || null;
|
||||
}
|
||||
|
||||
function setStatus(status) {
|
||||
onStatusChange(status);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
tg = resolve();
|
||||
if (tg) setStatus("ready");
|
||||
initData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
|
||||
onInitDataChange(initData);
|
||||
if (initData) launchParamsDetected = true;
|
||||
return tg;
|
||||
}
|
||||
|
||||
function hasLaunchParams() {
|
||||
refresh();
|
||||
if (launchParamsDetected || initData) {
|
||||
launchParamsDetected = true;
|
||||
return true;
|
||||
}
|
||||
const queryText = window.location.search.replace(/^\?/, "");
|
||||
const hashText = window.location.hash.replace(/^#/, "");
|
||||
const detected = [queryText, hashText].some((text) => {
|
||||
if (!text) return false;
|
||||
const params = new URLSearchParams(text);
|
||||
return ["tgWebAppData", "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams"].some(
|
||||
(key) => params.has(key)
|
||||
);
|
||||
});
|
||||
if (detected) launchParamsDetected = true;
|
||||
return detected;
|
||||
}
|
||||
|
||||
function load(timeoutMs = bootTimeoutMs) {
|
||||
if (refresh()) return Promise.resolve(tg);
|
||||
if (sdkPromise) return sdkPromise;
|
||||
if (typeof document === "undefined") return Promise.resolve(null);
|
||||
|
||||
setStatus("loading");
|
||||
sdkPromise = new Promise((resolvePromise) => {
|
||||
const existingScript = document.querySelector("script[data-rw-telegram-web-app-sdk]");
|
||||
const script = existingScript || document.createElement("script");
|
||||
let resolved = false;
|
||||
let timeoutId = null;
|
||||
|
||||
const resolveOnce = (value) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
resolvePromise(value);
|
||||
};
|
||||
|
||||
const refreshFromScript = () => {
|
||||
tg = resolve();
|
||||
setStatus(tg ? "ready" : "unavailable");
|
||||
return tg;
|
||||
};
|
||||
|
||||
script.addEventListener("load", () => resolveOnce(refreshFromScript()), { once: true });
|
||||
script.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
setStatus("unavailable");
|
||||
resolveOnce(null);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
if (!existingScript) {
|
||||
script.src = scriptUrl;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.dataset.rwTelegramWebAppSdk = "1";
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
if (!tg) setStatus("unavailable");
|
||||
resolveOnce(tg);
|
||||
}, timeoutMs);
|
||||
}).finally(() => {
|
||||
sdkPromise = null;
|
||||
});
|
||||
return sdkPromise;
|
||||
}
|
||||
|
||||
async function ensureForAction() {
|
||||
if (refresh()) return tg;
|
||||
return await load(actionTimeoutMs);
|
||||
}
|
||||
|
||||
function createMiniAppAuthTimeout() {
|
||||
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
||||
let timedOut = false;
|
||||
let timeoutId = null;
|
||||
let timeoutPromise = new Promise(() => {});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller?.abort();
|
||||
const error = new Error("telegram_mini_app_auth_timeout");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
}, miniAppAuthTimeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
promise: timeoutPromise,
|
||||
get signal() {
|
||||
return controller?.signal;
|
||||
},
|
||||
get timedOut() {
|
||||
return timedOut;
|
||||
},
|
||||
clear() {
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get tg() {
|
||||
return tg;
|
||||
},
|
||||
get initData() {
|
||||
return initData;
|
||||
},
|
||||
refresh,
|
||||
hasLaunchParams,
|
||||
load,
|
||||
ensureForAction,
|
||||
createMiniAppAuthTimeout,
|
||||
readInitDataFromLocation: readTelegramMiniAppInitDataFromLocation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/** Maps JSON theme token keys to CSS custom properties used by the Mini App shell. */
|
||||
|
||||
const TOKEN_TO_CSS_VAR = {
|
||||
accent: "--accent",
|
||||
bg: "--bg",
|
||||
panel: "--panel",
|
||||
panel_2: "--panel-2",
|
||||
panel_3: "--panel-3",
|
||||
border: "--border",
|
||||
border_strong: "--border-strong",
|
||||
text: "--text",
|
||||
muted: "--muted",
|
||||
dim: "--dim",
|
||||
danger: "--danger",
|
||||
danger_text: "--danger-text",
|
||||
danger_soft: "--danger-soft",
|
||||
danger_border: "--danger-border",
|
||||
success: "--success",
|
||||
success_text: "--success-text",
|
||||
success_soft: "--success-soft",
|
||||
success_border: "--success-border",
|
||||
warning: "--warning",
|
||||
warning_text: "--warning-text",
|
||||
warning_soft: "--warning-soft",
|
||||
warning_border: "--warning-border",
|
||||
info: "--info",
|
||||
info_text: "--info-text",
|
||||
info_soft: "--info-soft",
|
||||
info_border: "--info-border",
|
||||
blue: "--blue",
|
||||
radius: "--radius",
|
||||
font_sans: "--font-sans",
|
||||
font_logo: "--font-logo",
|
||||
font_mono: "--font-mono",
|
||||
home_logo_scale: "--home-logo-scale",
|
||||
admin_bg: "--admin-bg",
|
||||
admin_surface: "--admin-surface",
|
||||
admin_surface_2: "--admin-surface-2",
|
||||
admin_elev: "--admin-elev",
|
||||
admin_border: "--admin-border",
|
||||
admin_border_strong: "--admin-border-strong",
|
||||
admin_text: "--admin-text",
|
||||
admin_muted: "--admin-muted",
|
||||
admin_dim: "--admin-dim",
|
||||
};
|
||||
|
||||
export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", options = {}) {
|
||||
const t = tokens && typeof tokens === "object" ? tokens : {};
|
||||
const parts = [];
|
||||
const useFallbackAccent = options.fallbackAccent !== false;
|
||||
const accent = t.accent || (useFallbackAccent ? primaryFallback || "#00fe7a" : "");
|
||||
if (accent) parts.push(`--accent:${accent}`);
|
||||
for (const [key, cssVar] of Object.entries(TOKEN_TO_CSS_VAR)) {
|
||||
if (key === "accent") continue;
|
||||
const value = t[key];
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (key === "home_logo_scale") {
|
||||
const scale = Number(value);
|
||||
if (!Number.isFinite(scale) || scale <= 0) continue;
|
||||
parts.push(`${cssVar}:${scale / 100}`);
|
||||
continue;
|
||||
}
|
||||
parts.push(`${cssVar}:${String(value)}`);
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
export function findThemeEntry(themesCatalog, key) {
|
||||
const themes = themesCatalog?.themes || [];
|
||||
return themes.find((entry) => entry && entry.key === key) || null;
|
||||
}
|
||||
|
||||
export function resolveEffectiveThemeKey(themesCatalog) {
|
||||
const themes = themesCatalog?.themes || [];
|
||||
const byKey = (k) => themes.find((entry) => entry.key === k);
|
||||
const def = themesCatalog?.default_theme || themes[0]?.key || "dark";
|
||||
return byKey(def) ? def : themes[0]?.key || "dark";
|
||||
}
|
||||
|
||||
export function themePresetClass(tokens) {
|
||||
const preset = String(tokens?.style_preset || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!preset || preset === "none") return "";
|
||||
if (preset === "win95" || preset === "windows95") return "theme-preset-win95";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function themeKeyClass(key) {
|
||||
const safe = String(key || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return safe ? `theme-key-${safe}` : "";
|
||||
}
|
||||
|
||||
export function themeCssClass(cssFile) {
|
||||
const filename = String(cssFile || "")
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
const slug = String(filename || "")
|
||||
.replace(/\.css$/i, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug ? `theme-css-${slug}` : "";
|
||||
}
|
||||
|
||||
export function themeRootClass(theme) {
|
||||
return [
|
||||
themeKeyClass(theme?.key),
|
||||
themeCssClass(theme?.css_file),
|
||||
themePresetClass(theme?.tokens),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") {
|
||||
return themeTokensToInlineStyle(theme?.tokens, primaryFallback, {
|
||||
fallbackAccent: !theme?.css_file,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeThemeCssPath(path) {
|
||||
return String(path || "")
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function themeCssHref(theme) {
|
||||
const cssFile = String(theme?.css_file || "").trim();
|
||||
if (!cssFile) return "";
|
||||
if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return "";
|
||||
if (cssFile.startsWith("/")) return cssFile;
|
||||
const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
|
||||
const key = String(theme?.key || "")
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const themedPath =
|
||||
key && normalizedCssFile.split("/")[0] !== key
|
||||
? `${key}/${normalizedCssFile}`
|
||||
: normalizedCssFile;
|
||||
const encoded = encodeThemeCssPath(themedPath);
|
||||
return encoded ? `/webapp-theme-css/${encoded}` : "";
|
||||
}
|
||||
|
||||
export function localizedThemeName(theme, lang = "en") {
|
||||
const names = theme?.names || {};
|
||||
const key = String(lang || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const base = key.split("-")[0];
|
||||
return names[key] || names[base] || names.en || theme?.key || "";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
readMagicLoginToken,
|
||||
readTelegramAuthStatus,
|
||||
readTelegramLoginWidgetAuthData,
|
||||
clearAuthQuery,
|
||||
} from "./authHelpers.js";
|
||||
import { TELEGRAM_SDK_BOOT_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
/**
|
||||
* Initial auth / session bootstrap for the subscription webapp (non-preview).
|
||||
* Keeps side effects in App (mode, tg, token) via injected callbacks.
|
||||
*/
|
||||
export async function runWebappBoot({
|
||||
MOCK,
|
||||
setMode,
|
||||
hasTelegramLaunchParams,
|
||||
loadTelegramSdk,
|
||||
prepareTelegramMiniApp,
|
||||
loadData,
|
||||
showLogin,
|
||||
clearToken,
|
||||
clearManualLogoutFlag,
|
||||
isManuallyLoggedOut,
|
||||
finalizeMagicLogin,
|
||||
finalizeTelegramAuth,
|
||||
setAuthStatus,
|
||||
t,
|
||||
getInitDataForBoot,
|
||||
getToken,
|
||||
getCsrfToken,
|
||||
}) {
|
||||
setMode("loading");
|
||||
if (hasTelegramLaunchParams()) await loadTelegramSdk(TELEGRAM_SDK_BOOT_TIMEOUT_MS);
|
||||
prepareTelegramMiniApp();
|
||||
|
||||
if (MOCK) {
|
||||
await loadData();
|
||||
return;
|
||||
}
|
||||
|
||||
const magicToken = readMagicLoginToken();
|
||||
if (magicToken && (await finalizeMagicLogin(magicToken))) return;
|
||||
|
||||
const telegramAuthStatus = readTelegramAuthStatus();
|
||||
if (telegramAuthStatus === "success") {
|
||||
clearManualLogoutFlag();
|
||||
clearAuthQuery();
|
||||
try {
|
||||
await loadData();
|
||||
return;
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
} else if (telegramAuthStatus) {
|
||||
clearAuthQuery();
|
||||
setAuthStatus(
|
||||
telegramAuthStatus === "cancelled"
|
||||
? t("wa_auth_telegram_cancelled")
|
||||
: t("wa_auth_telegram_not_confirmed"),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (isManuallyLoggedOut()) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetAuthData = readTelegramLoginWidgetAuthData();
|
||||
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
|
||||
|
||||
const initData = getInitDataForBoot();
|
||||
if (initData) {
|
||||
try {
|
||||
if (await finalizeTelegramAuth(initData, "init_data")) return;
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
}
|
||||
|
||||
if (getToken() || getCsrfToken()) {
|
||||
try {
|
||||
await loadData();
|
||||
return;
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
showLogin();
|
||||
}
|
||||
Reference in New Issue
Block a user