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