refactor: slice web app wip

This commit is contained in:
3252a8
2026-05-11 07:57:34 +03:00
parent 5243cc4284
commit 763c4ad1bb
28 changed files with 8567 additions and 7738 deletions
@@ -0,0 +1,44 @@
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function applyFavicon(logoUrl, emoji) {
if (typeof document === 'undefined') return;
const favicon = document.getElementById('app-favicon');
if (!favicon) return;
const normalizedLogoUrl = String(logoUrl || '').trim();
if (normalizedLogoUrl) {
favicon.setAttribute('href', normalizedLogoUrl);
return;
}
const normalizedEmoji = String(emoji || '????').trim() || '????';
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(normalizedEmoji)}</text></svg>`;
const encoded = encodeURIComponent(svg);
favicon.setAttribute('href', `data:image/svg+xml,${encoded}`);
}
@@ -0,0 +1,42 @@
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",
"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,203 @@
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,
},
{
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,
},
{
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,
},
];
if (path === "/admin/stats") {
return {
ok: true,
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
financial: { total_revenue: 186240, successful_payments_count: 934 },
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: "config/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/settings") return { ok: true, sections: [] };
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, token: "local-preview", csrf_token: "local-preview-csrf" };
}
if (path === "/auth/token") {
return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" };
}
if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" };
if (path === "/devices") return 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 {}
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 {}
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, token: "local-preview", csrf_token: "local-preview-csrf" };
}
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, token: "local-preview", 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,314 @@
export const DEV_MOCK = {
config: {
title: "/minishop",
primaryColor: "#00fe7a",
logoUrl: "",
logoEmoji: "🫥",
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",
},
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 },
],
},
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();
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,46 @@
import { readCookie } from "./session.js";
export function createApiClient({
apiBase = "",
csrfCookieName = "rw_webapp_csrf",
getToken = () => "",
getCsrfToken = () => "",
onUnauthorized = () => {},
mockApi = null,
getMockContext = () => ({}),
} = {}) {
async function api(path, options = {}) {
if (mockApi) return mockApi(path, options, getMockContext());
const method = String(options.method || "GET").toUpperCase();
const headers = { ...(options.headers || {}) };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const csrf = getCsrfToken() || readCookie(csrfCookieName) || "";
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
headers["X-CSRF-Token"] = csrf;
}
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const response = await fetch(`${apiBase}${path}`, { ...options, headers });
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,
});
return response.json();
}
return { api, publicApi };
}
@@ -0,0 +1,47 @@
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";
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 readStoredToken(storageKey = TOKEN_STORAGE_KEY) {
if (typeof localStorage === "undefined") return "";
return localStorage.getItem(storageKey) || "";
}
export function persistToken(token, storageKey = TOKEN_STORAGE_KEY) {
if (!token || typeof localStorage === "undefined") return;
localStorage.setItem(storageKey, token);
}
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 {}
}
export function clearManualLogoutFlag(flagKey) {
try {
localStorage.removeItem(flagKey);
} catch {}
}
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 {}
return normalized;
}
export function readReferral() {
try {
return localStorage.getItem(REFERRAL_STORAGE_KEY) || "";
} catch {
return "";
}
}
@@ -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,
};
}