docs: refresh docs demo data

This commit is contained in:
3252a8
2026-05-28 13:10:07 +03:00
parent 749693078b
commit 92f0277dad
5 changed files with 140869 additions and 143 deletions
+77 -42
View File
@@ -1,37 +1,54 @@
import { spawn } from 'node:child_process';
import { access, copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from "node:child_process";
import {
access,
copyFile,
mkdir,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const siteRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const repoRoot = path.resolve(siteRoot, '..');
const frontendRoot = path.join(repoRoot, 'frontend');
const runtimeDir = path.join(siteRoot, 'public', 'demo', 'runtime');
const templatesDir = path.join(repoRoot, 'backend', 'bot', 'app', 'web', 'templates');
const themesDir = path.join(repoRoot, 'backend', 'bot', 'app', 'web', 'themes');
const localesDir = path.join(repoRoot, 'locales');
const runtimeBase = '/demo/runtime';
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const repoRoot = path.resolve(siteRoot, "..");
const frontendRoot = path.join(repoRoot, "frontend");
const runtimeDir = path.join(siteRoot, "public", "demo", "runtime");
const templatesDir = path.join(
repoRoot,
"backend",
"bot",
"app",
"web",
"templates",
);
const themesDir = path.join(repoRoot, "backend", "bot", "app", "web", "themes");
const localesDir = path.join(repoRoot, "locales");
const runtimeBase = "/demo/runtime";
const installGuidesConfigUrl =
'https://raw.githubusercontent.com/legiz-ru/my-remnawave/main/sub-page/subpage-config/multiapp.json';
"https://raw.githubusercontent.com/legiz-ru/my-remnawave/main/sub-page/subpage-config/multiapp.json";
const installGuidesConfigRetries = 3;
const isWindows = process.platform === 'win32';
const npmExecPath = process.env.npm_execpath || '';
const isWindows = process.platform === "win32";
const npmExecPath = process.env.npm_execpath || "";
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: repoRoot,
stdio: 'inherit',
stdio: "inherit",
shell: false,
...options,
});
child.on('error', reject);
child.on('exit', (code) => {
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
reject(
new Error(`${command} ${args.join(" ")} exited with code ${code}`),
);
});
});
}
@@ -40,7 +57,7 @@ function runNpm(args) {
if (npmExecPath) {
return run(process.execPath, [npmExecPath, ...args]);
}
return run(isWindows ? 'npm.cmd' : 'npm', args, { shell: isWindows });
return run(isWindows ? "npm.cmd" : "npm", args, { shell: isWindows });
}
async function pathExists(targetPath) {
@@ -54,10 +71,10 @@ async function pathExists(targetPath) {
async function ensureFrontendDependencies() {
const viteBin = isWindows
? path.join(frontendRoot, 'node_modules', '.bin', 'vite.cmd')
: path.join(frontendRoot, 'node_modules', '.bin', 'vite');
? path.join(frontendRoot, "node_modules", ".bin", "vite.cmd")
: path.join(frontendRoot, "node_modules", ".bin", "vite");
if (await pathExists(viteBin)) return;
await runNpm(['--prefix', frontendRoot, 'ci']);
await runNpm(["--prefix", frontendRoot, "ci"]);
}
async function copyDirectory(sourceDir, targetDir, transform = null) {
@@ -81,11 +98,14 @@ async function copyDirectory(sourceDir, targetDir, transform = null) {
}
async function copyThemeFile(sourcePath, targetPath) {
if (path.extname(sourcePath).toLowerCase() !== '.css') return false;
const css = await readFile(sourcePath, 'utf8');
const rewritten = css.replace(/\/webapp-theme-assets\//g, `${runtimeBase}/themes/`);
if (path.extname(sourcePath).toLowerCase() !== ".css") return false;
const css = await readFile(sourcePath, "utf8");
const rewritten = css.replace(
/\/webapp-theme-assets\//g,
`${runtimeBase}/themes/`,
);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, rewritten, 'utf8');
await writeFile(targetPath, rewritten, "utf8");
return true;
}
@@ -98,13 +118,13 @@ function wait(ms) {
}
function jsonScriptPayload(value) {
return JSON.stringify(value).replace(/</g, '\\u003c');
return JSON.stringify(value).replace(/</g, "\\u003c");
}
async function demoI18nPayload() {
const [ru, en] = await Promise.all([
readFile(path.join(localesDir, 'ru.json'), 'utf8'),
readFile(path.join(localesDir, 'en.json'), 'utf8'),
readFile(path.join(localesDir, "ru.json"), "utf8"),
readFile(path.join(localesDir, "en.json"), "utf8"),
]);
return jsonScriptPayload({ ru: JSON.parse(ru), en: JSON.parse(en) });
}
@@ -115,7 +135,7 @@ async function installGuidesConfigPayload() {
for (let attempt = 1; attempt <= installGuidesConfigRetries; attempt += 1) {
try {
const response = await fetch(installGuidesConfigUrl, {
headers: { accept: 'application/json' },
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Error(
@@ -146,7 +166,13 @@ async function appHtml() {
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" />
<title>Remnawave Minishop Demo</title>
<title>remnawave-minishop demo</title>
<link
id="app-favicon"
rel="icon"
href="${runtimeBase}/default-brand/favicons/19b2a242e5b7bc2d/icon-180.png"
sizes="180x180"
/>
<link rel="stylesheet" href="${runtimeBase}/subscription_webapp_docs_demo.css" />
</head>
<body>
@@ -161,7 +187,7 @@ async function appHtml() {
}
await ensureFrontendDependencies();
await runNpm(['--prefix', frontendRoot, 'run', 'build:docs-demo']);
await runNpm(["--prefix", frontendRoot, "run", "build:docs-demo"]);
await rm(runtimeDir, { recursive: true, force: true });
await mkdir(runtimeDir, { recursive: true });
@@ -169,16 +195,25 @@ await mkdir(runtimeDir, { recursive: true });
const html = await appHtml();
await Promise.all([
copyRuntimeAsset('subscription_webapp_docs_demo.js'),
copyRuntimeAsset('subscription_webapp_docs_demo.css'),
copyRuntimeAsset('subscription_webapp_admin.js'),
copyRuntimeAsset('subscription_webapp_admin.css'),
copyDirectory(path.join(templatesDir, 'default-brand'), path.join(runtimeDir, 'default-brand')),
copyDirectory(themesDir, path.join(runtimeDir, 'themes'), copyThemeFile),
writeFile(path.join(runtimeDir, 'app.html'), html, 'utf8'),
copyRuntimeAsset("subscription_webapp_docs_demo.js"),
copyRuntimeAsset("subscription_webapp_docs_demo.css"),
copyRuntimeAsset("subscription_webapp_admin.js"),
copyRuntimeAsset("subscription_webapp_admin.css"),
copyDirectory(
path.join(templatesDir, "default-brand"),
path.join(runtimeDir, "default-brand"),
),
copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile),
writeFile(path.join(runtimeDir, "app.html"), html, "utf8"),
installGuidesConfigPayload().then((payload) =>
writeFile(path.join(runtimeDir, 'subscription-guides-config.json'), payload, 'utf8'),
writeFile(
path.join(runtimeDir, "subscription-guides-config.json"),
payload,
"utf8",
),
),
]);
console.log(`Built static docs demo runtime at ${path.relative(repoRoot, runtimeDir)}`);
console.log(
`Built static docs demo runtime at ${path.relative(repoRoot, runtimeDir)}`,
);
+3 -3
View File
@@ -13,7 +13,7 @@ const docsHref = '/getting-started/demo/';
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05080f" />
<title>Remnawave Minishop Demo</title>
<title>remnawave-minishop demo</title>
<style>
:root {
color-scheme: dark;
@@ -227,7 +227,7 @@ const docsHref = '/getting-started/demo/';
</button>
<div class="demo-topbar__panel" id="demo-topbar-panel">
<div class="demo-topbar__title">
<strong>Remnawave Minishop demo</strong>
<strong>remnawave-minishop demo</strong>
</div>
<div class="demo-topbar__actions">
<a class="demo-topbar__back" href={docsHref}>К документации</a>
@@ -240,7 +240,7 @@ const docsHref = '/getting-started/demo/';
<iframe
id="demo-frame"
class="demo-frame"
title="Remnawave Minishop static demo"
title="remnawave-minishop static demo"
src={defaultDemoSrc}
loading="eager"
></iframe>
File diff suppressed because it is too large Load Diff
+619
View File
@@ -1,4 +1,7 @@
import { DEV_MOCK } from "./previewMock.js";
import { DEMO_DATASET } from "./demoDataset.js";
const DEMO_LANGUAGE_STORAGE_KEY = "rw_minishop_demo_language";
function defaultClone(value) {
try {
@@ -8,6 +11,616 @@ function defaultClone(value) {
}
}
let demoPromosState = null;
let demoAdsState = null;
let demoSupportTicketsState = null;
let demoSupportMessagesState = null;
let demoTariffsState = null;
const demoSettingsChanges = new Map();
function demoPromos() {
if (!demoPromosState) demoPromosState = defaultClone(DEMO_DATASET.promos || []);
return demoPromosState;
}
function demoAds() {
if (!demoAdsState) demoAdsState = defaultClone(DEMO_DATASET.ads || []);
return demoAdsState;
}
function demoSupportTickets() {
if (!demoSupportTicketsState) {
demoSupportTicketsState = defaultClone(DEMO_DATASET.supportTickets || []);
}
return demoSupportTicketsState;
}
function demoSupportMessages() {
if (!demoSupportMessagesState) {
demoSupportMessagesState = defaultClone(DEMO_DATASET.supportMessages || {});
}
return demoSupportMessagesState;
}
function demoTariffs() {
if (!demoTariffsState) {
demoTariffsState = defaultClone(
DEMO_DATASET.tariffsCatalog || {
default_tariff: "",
topup_packages_default: { rub: [], stars: [] },
tariffs: [],
}
);
}
return demoTariffsState;
}
function queryParams(path) {
return new URLSearchParams(String(path || "").split("?")[1] || "");
}
function jsonBody(options) {
try {
return options?.body ? JSON.parse(String(options.body)) : {};
} catch {
return {};
}
}
function writeDemoLanguage(language) {
if (typeof window === "undefined") return;
try {
window.localStorage?.setItem(DEMO_LANGUAGE_STORAGE_KEY, language);
} catch {
// Demo storage can be unavailable in private contexts; the in-memory mock still updates.
}
}
function paged(items, params, fallbackSize = 25) {
const total = items.length;
if (params.has("limit") || params.has("offset")) {
const limit = Math.max(1, Number(params.get("limit") || fallbackSize));
const offset = Math.max(0, Number(params.get("offset") || 0));
return {
items: items.slice(offset, offset + limit),
total,
page: Math.floor(offset / limit),
pageSize: limit,
};
}
const page = Math.max(0, Number(params.get("page") || 0));
const pageSize = Math.max(1, Number(params.get("page_size") || fallbackSize));
const start = page * pageSize;
return { items: items.slice(start, start + pageSize), total, page, pageSize };
}
function stringDate(value) {
const time = Date.parse(value || "");
return Number.isFinite(time) ? time : 0;
}
function userName(user) {
return (
[user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() ||
user?.username ||
user?.email ||
String(user?.user_id || "")
);
}
function filterDemoUsers(params) {
let out = [...(DEMO_DATASET.adminUsers || [])];
const q = (params.get("q") || params.get("search") || "").trim().toLowerCase();
if (q) {
out = out.filter((user) =>
[
user.user_id,
user.telegram_id,
user.username,
user.first_name,
user.last_name,
user.email,
user.panel_user_uuid,
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(q))
);
}
const filter = params.get("filter") || "all";
if (filter === "active") out = out.filter((user) => !user.is_banned);
else if (filter === "banned") out = out.filter((user) => user.is_banned);
else if (filter === "tg_linked") out = out.filter((user) => user.telegram_linked);
else if (filter === "no_tg") out = out.filter((user) => !user.telegram_linked);
else if (filter === "email_linked") out = out.filter((user) => Boolean(user.email));
else if (filter === "no_email") out = out.filter((user) => !user.email);
else if (filter === "panel_linked") out = out.filter((user) => Boolean(user.panel_user_uuid));
const panelStatus = params.get("panel_status") || "all";
if (panelStatus !== "all") out = out.filter((user) => user.panel_status === panelStatus);
const premiumTraffic = params.get("premium_traffic") || "all";
if (premiumTraffic !== "all") {
out = out.filter((user) => (user.premium_traffic?.state || "none") === premiumTraffic);
}
const sort = params.get("sort") || "registered_desc";
out.sort((a, b) => {
if (sort === "registered_asc")
return stringDate(a.registration_date) - stringDate(b.registration_date);
if (sort === "name_asc") return userName(a).localeCompare(userName(b));
if (sort === "name_desc") return userName(b).localeCompare(userName(a));
if (sort === "id_asc") return Number(a.user_id || 0) - Number(b.user_id || 0);
if (sort === "id_desc") return Number(b.user_id || 0) - Number(a.user_id || 0);
if (sort === "premium_ratio_asc")
return Number(a.premium_traffic?.percent ?? -1) - Number(b.premium_traffic?.percent ?? -1);
if (sort === "premium_ratio_desc")
return Number(b.premium_traffic?.percent ?? -1) - Number(a.premium_traffic?.percent ?? -1);
return stringDate(b.registration_date) - stringDate(a.registration_date);
});
return out;
}
function demoSupportCounts(items = demoSupportTickets()) {
const byStatus = { open: 0, awaiting_admin: 0, awaiting_user: 0, resolved: 0, closed: 0 };
for (const item of items) byStatus[item.status] = (byStatus[item.status] || 0) + 1;
const closed = (byStatus.closed || 0) + (byStatus.resolved || 0);
return {
...byStatus,
active: items.length - closed,
closed,
total: items.length,
total_unread_admin: items.reduce((sum, item) => sum + Number(item.unread_admin_count || 0), 0),
};
}
function filterDemoSupportTickets(items, params) {
let out = [...items];
const status = params.get("status");
if (status === "active")
out = out.filter((item) => !["closed", "resolved"].includes(item.status));
else if (status === "closed")
out = out.filter((item) => ["closed", "resolved"].includes(item.status));
else if (status) out = out.filter((item) => item.status === status);
const priority = params.get("priority");
if (priority) out = out.filter((item) => item.priority === priority);
const category = params.get("category");
if (category) out = out.filter((item) => item.category === category);
const search = (params.get("search") || "").trim().toLowerCase();
if (search) {
out = out.filter((item) =>
[item.subject, item.user?.username, item.user?.email, item.ticket_id]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(search))
);
}
const priorityRank = { urgent: 4, high: 3, normal: 2, low: 1 };
const sort = params.get("sort") || "updated_desc";
out.sort((a, b) => {
if (sort === "importance_desc") {
return (
(priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0) ||
stringDate(b.last_message_at || b.created_at) -
stringDate(a.last_message_at || a.created_at)
);
}
if (sort === "updated_asc") {
return (
stringDate(a.last_message_at || a.created_at) -
stringDate(b.last_message_at || b.created_at)
);
}
if (sort === "created_desc") return stringDate(b.created_at) - stringDate(a.created_at);
if (sort === "created_asc") return stringDate(a.created_at) - stringDate(b.created_at);
return (
stringDate(b.last_message_at || b.created_at) - stringDate(a.last_message_at || a.created_at)
);
});
return out;
}
function demoSettingsSections(clone) {
const sections = clone(DEMO_DATASET.settingsSections || []);
for (const section of sections) {
for (const field of section.fields || []) {
if (!demoSettingsChanges.has(field.key)) continue;
const change = demoSettingsChanges.get(field.key);
if (change.deleted) {
field.value = field.default ?? "";
field.overridden = false;
} else {
field.value = change.value;
field.overridden = true;
}
}
}
return sections;
}
function applyDemoSettingToMock(key, value) {
if (key === "WEBAPP_TITLE") DEV_MOCK.config.title = value || "";
if (key === "WEBAPP_LOGO_URL") DEV_MOCK.config.logoUrl = value || "";
if (key === "WEBAPP_LOGO_USE_EMOJI") DEV_MOCK.config.logoUseEmoji = Boolean(value);
if (key === "WEBAPP_LOGO_EMOJI") DEV_MOCK.config.logoEmoji = value || "";
if (key === "WEBAPP_LOGO_EMOJI_FONT") DEV_MOCK.config.logoEmojiFont = value || "system";
if (key === "WEBAPP_FAVICON_URL" || key === "WEBAPP_LOGO_FAVICON_URL") {
DEV_MOCK.config.faviconUrl = value || DEV_MOCK.config.faviconUrl || "";
}
if (key === "WEBAPP_FAVICON_USE_CUSTOM") DEV_MOCK.config.faviconUseCustom = Boolean(value);
}
function userSnapshotForTicket(ticket) {
const detail = DEMO_DATASET.adminUserDetails?.[String(ticket?.user_id)] || {};
const user = detail.user || ticket?.user || {};
const sub = detail.active_subscription || {};
return {
user_id: user.user_id,
name: userName(user) || user.username || user.email || String(user.user_id || ""),
username: user.username || "",
email: user.email || "",
tariff: sub.tariff_name || sub.tariff_key || "Demo",
panel_status: user.panel_status || sub.status_from_panel || "",
remaining: sub.end_date || "",
regular_traffic: `${sub.traffic_used_bytes || 0} / ${sub.traffic_limit_bytes || 0}`,
premium_traffic: `${sub.premium_used_bytes || 0} / ${sub.premium_limit_bytes || 0}`,
};
}
function demoApiResponse(path, cleanPath, options, context) {
const { clone, currentLang = "ru", normalizeLangCode = (value) => value || "ru" } = context;
const method = String(options.method || "GET").toUpperCase();
const params = queryParams(path);
if (cleanPath === "/admin/stats") return clone(DEMO_DATASET.stats);
if (cleanPath === "/admin/sync") return { ok: true, status: "queued" };
if (cleanPath === "/admin/payments") {
const page = paged(DEMO_DATASET.adminPayments || [], params, 25);
return {
ok: true,
payments: clone(page.items),
total: page.total,
page: page.page,
page_size: page.pageSize,
};
}
if (/^\/admin\/payments\/\d+$/.test(cleanPath)) {
const id = Number(cleanPath.split("/").pop());
const payment = (DEMO_DATASET.adminPayments || []).find((item) => item.payment_id === id);
return payment ? { ok: true, payment: clone(payment) } : { ok: false, error: "not_found" };
}
if (cleanPath === "/admin/users") {
const filtered = filterDemoUsers(params);
const page = paged(filtered, params, 25);
return {
ok: true,
users: clone(page.items),
total: page.total,
page: page.page,
page_size: page.pageSize,
};
}
if (cleanPath.startsWith("/admin/users/")) {
const parts = cleanPath.split("/");
const id = Number(parts[3]);
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
if (!detail) return { ok: false, error: "not_found" };
if (parts[4]) {
if (parts[4] === "telegram-profile-link") {
return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` };
}
if (parts[4] === "message" && parts[5] === "preview") {
return { ok: true, text: "Demo broadcast preview for the selected account." };
}
return { ok: true, user: clone(detail.user), detail: clone(detail) };
}
return clone(detail);
}
if (cleanPath === "/admin/logs") {
let logs = [...(DEMO_DATASET.adminLogs || [])];
const userId = params.get("user_id");
if (userId) {
logs = logs.filter(
(item) =>
String(item.user_id || "") === userId || String(item.target_user_id || "") === userId
);
}
const page = paged(logs, params, 50);
return {
ok: true,
logs: clone(page.items),
total: page.total,
page: page.page,
page_size: page.pageSize,
};
}
if (cleanPath === "/admin/promos") {
if (method === "POST") {
const body = jsonBody(options);
demoPromos().unshift({
id: 3900 + demoPromos().length + 1,
code: body.code || "DEMO",
bonus_days: Number(body.bonus_days || 7),
max_activations: Number(body.max_activations || 1),
current_activations: 0,
is_active: true,
valid_until: new Date(Date.now() + Number(body.valid_days || 30) * 86400000).toISOString(),
created_at: new Date().toISOString(),
created_by_admin_id: DEV_MOCK.data.user?.id || DEV_MOCK.data.user?.user_id,
});
return { ok: true, promo: clone(demoPromos()[0]) };
}
const page = paged(demoPromos(), params, 25);
return {
ok: true,
promos: clone(page.items),
total: page.total,
page: page.page,
page_size: page.pageSize,
};
}
if (cleanPath.startsWith("/admin/promos/")) {
const id = Number(cleanPath.split("/").pop());
const promo = demoPromos().find((item) => item.id === id);
if (!promo) return { ok: false, error: "not_found" };
if (method === "DELETE") {
demoPromosState = demoPromos().filter((item) => item.id !== id);
return { ok: true };
}
Object.assign(promo, jsonBody(options));
return { ok: true, promo: clone(promo) };
}
if (cleanPath === "/admin/ads") {
if (method === "POST") {
const body = jsonBody(options);
demoAds().unshift({
id: 900 + demoAds().length + 1,
source: body.source || "demo",
start_param: body.start_param || "demo_campaign",
cost: Number(body.cost || 0),
is_active: true,
created_at: new Date().toISOString(),
stats: { users: 0, trial_activations: 0, payments: 0, revenue: 0 },
});
return { ok: true, campaign: clone(demoAds()[0]) };
}
return { ok: true, campaigns: clone(demoAds()), totals: clone(DEMO_DATASET.adsTotals || {}) };
}
if (cleanPath.startsWith("/admin/ads/")) {
const parts = cleanPath.split("/");
const id = Number(parts[3]);
const campaign = demoAds().find((item) => item.id === id);
if (!campaign) return { ok: false, error: "not_found" };
if (parts[4] === "toggle") {
campaign.is_active = !campaign.is_active;
return { ok: true, campaign: clone(campaign) };
}
if (method === "DELETE") {
demoAdsState = demoAds().filter((item) => item.id !== id);
return { ok: true };
}
return { ok: true, campaign: clone(campaign) };
}
if (cleanPath === "/admin/backups") return clone(DEMO_DATASET.backups);
if (cleanPath === "/admin/backups/create") {
const archive = clone(DEMO_DATASET.backups?.archives?.[0] || {});
archive.name = `minishop-demo-${Date.now()}.zip`;
archive.created_at = new Date().toISOString();
return {
ok: true,
archive,
result: { archive_name: archive.name, completed_at: archive.created_at, warnings: [] },
};
}
if (cleanPath === "/admin/backups/upload") {
return { ok: true, archive: clone(DEMO_DATASET.backups?.archives?.[0] || {}) };
}
if (cleanPath === "/admin/backups/restore") {
return {
ok: true,
result: {
archive_name: DEMO_DATASET.backups?.archives?.[0]?.name || "demo.zip",
database_restored: true,
warnings: [],
},
};
}
if (cleanPath === "/admin/settings" && method === "PATCH") {
const body = jsonBody(options);
for (const key of body.deletes || []) demoSettingsChanges.set(key, { deleted: true });
for (const [key, value] of Object.entries(body.updates || {})) {
demoSettingsChanges.set(key, { value, deleted: false });
applyDemoSettingToMock(key, value);
}
return {
ok: true,
applied: Object.keys(body.updates || {}).length,
reverted: (body.deletes || []).length,
};
}
if (cleanPath === "/admin/settings") return { ok: true, sections: demoSettingsSections(clone) };
if (cleanPath === "/admin/tariffs") {
if (method === "PUT") {
const body = jsonBody(options);
const catalog = body.catalog || body;
demoTariffsState = defaultClone(catalog);
}
return {
ok: true,
path: "data/tariffs.json",
catalog: clone(demoTariffs()),
};
}
if (cleanPath === "/admin/panel/internal-squads") {
return {
ok: true,
squads: clone(
DEMO_DATASET.panelSquads || [
{ uuid: "db786ee8-816b-4760-80aa-1fc7a3669ff2", name: "Base RU" },
{ uuid: "5f29045a-5e8b-4b06-a7b1-29abf0ad3a54", name: "Base EU" },
{ uuid: "2f2f6e0a-1f2d-4e80-a33b-0ebf3a409012", name: "Premium EU" },
]
),
};
}
if (cleanPath === "/admin/translations" && method === "PATCH") {
return { ok: true, applied: 1, reverted: 0, file_written: false };
}
if (cleanPath === "/admin/translations") {
return { ok: true, ...clone(DEMO_DATASET.translations) };
}
if (cleanPath === "/admin/support/stats") return { ok: true, stats: demoSupportCounts() };
if (cleanPath === "/admin/support/tickets") {
const tickets = filterDemoSupportTickets(demoSupportTickets(), params);
const page = paged(tickets, params, 50);
return { ok: true, tickets: clone(page.items), total: page.total };
}
if (cleanPath.startsWith("/admin/support/tickets/")) {
const parts = cleanPath.split("/");
const ticketId = Number(parts[4]);
const ticket = demoSupportTickets().find((item) => item.ticket_id === ticketId);
if (!ticket) return { ok: false, error: "not_found" };
const messages = demoSupportMessages()[String(ticketId)] || [];
if (parts[5] === "read") {
ticket.unread_admin_count = 0;
return { ok: true };
}
if (parts[5] === "messages") {
const body = jsonBody(options);
const message = {
message_id: Date.now(),
ticket_id: ticketId,
author_role: "admin",
author_user_id: DEV_MOCK.data.user?.id || DEV_MOCK.data.user?.user_id,
author_name: "Поддержка",
body: body.body || "",
is_internal_note: Boolean(body.is_internal_note),
created_at: new Date().toISOString(),
};
messages.push(message);
demoSupportMessages()[String(ticketId)] = messages;
ticket.last_message_at = message.created_at;
ticket.last_message_role = "admin";
ticket.status = "awaiting_user";
return { ok: true, ticket: clone(ticket), message: clone(message) };
}
if (method === "PATCH") {
Object.assign(ticket, jsonBody(options));
return { ok: true, ticket: clone(ticket) };
}
return {
ok: true,
ticket: clone(ticket),
messages: clone(messages),
user_snapshot: userSnapshotForTicket(ticket),
};
}
if (cleanPath === "/support/tickets" && method === "POST") {
const body = jsonBody(options);
const user = DEV_MOCK.data.user || {};
const ticket = {
ticket_id: 4900 + demoSupportTickets().length + 1,
user_id: user.user_id || user.id,
subject: body.subject || "Новое обращение в поддержку",
category: body.category || "other",
priority: body.priority || "normal",
status: "awaiting_admin",
unread_user_count: 0,
unread_admin_count: 1,
last_message_at: new Date().toISOString(),
created_at: new Date().toISOString(),
user,
};
demoSupportTickets().unshift(ticket);
demoSupportMessages()[String(ticket.ticket_id)] = [
{
message_id: Date.now(),
ticket_id: ticket.ticket_id,
author_role: "user",
author_user_id: ticket.user_id,
author_name: userName(user) || user.username || "Демо-пользователь",
body: body.body || "",
created_at: ticket.created_at,
},
];
return { ok: true, ticket: clone(ticket) };
}
if (cleanPath === "/support/tickets") {
const tickets = filterDemoSupportTickets(demoSupportTickets(), params);
const page = paged(tickets, params, 50);
return {
ok: true,
tickets: clone(page.items),
total: page.total,
counts: demoSupportCounts(demoSupportTickets()),
};
}
if (cleanPath.startsWith("/support/tickets/")) {
const parts = cleanPath.split("/");
const ticketId = Number(parts[3]);
const ticket = demoSupportTickets().find((item) => item.ticket_id === ticketId);
if (!ticket) return { ok: false, error: "not_found" };
const messages = demoSupportMessages()[String(ticketId)] || [];
if (parts[4] === "read") {
ticket.unread_user_count = 0;
return { ok: true };
}
if (parts[4] === "messages") {
const body = jsonBody(options);
const user = DEV_MOCK.data.user || {};
const message = {
message_id: Date.now(),
ticket_id: ticketId,
author_role: "user",
author_user_id: user.user_id || user.id,
author_name: userName(user) || user.username || "Демо-пользователь",
body: body.body || "",
created_at: new Date().toISOString(),
};
messages.push(message);
demoSupportMessages()[String(ticketId)] = messages;
ticket.last_message_at = message.created_at;
ticket.last_message_role = "user";
ticket.status = "awaiting_admin";
return { ok: true, ticket: clone(ticket), message: clone(message) };
}
return { ok: true, ticket: clone(ticket), messages: clone(messages) };
}
if (cleanPath === "/support/unread") {
return {
ok: true,
unread: demoSupportTickets().reduce(
(sum, item) => sum + Number(item.unread_user_count || 0),
0
),
};
}
if (cleanPath === "/account/language" && method === "POST") {
const language = normalizeLangCode(jsonBody(options).language || currentLang);
DEV_MOCK.data.user.language_code = language;
DEV_MOCK.config.language = language;
writeDemoLanguage(language);
return { ok: true, language };
}
return undefined;
}
export async function mockApi(path, options = {}, context = {}) {
const {
currentLang = "ru",
@@ -16,6 +629,12 @@ export async function mockApi(path, options = {}, context = {}) {
} = context;
await new Promise((resolve) => window.setTimeout(resolve, 120));
const cleanPath = String(path || "").split("?")[0];
const demoResponse = demoApiResponse(path, cleanPath, options, {
clone,
currentLang,
normalizeLangCode,
});
if (demoResponse !== undefined) return demoResponse;
const adminUsers = [
{
user_id: 100200300,
+139 -98
View File
@@ -1,3 +1,16 @@
import { DEMO_DATASET } from "./demoDataset.js";
const DEMO_LANGUAGE_STORAGE_KEY = "rw_minishop_demo_language";
function readStoredDemoLanguage() {
if (typeof window === "undefined") return "";
try {
return window.localStorage?.getItem(DEMO_LANGUAGE_STORAGE_KEY) || "";
} catch {
return "";
}
}
const WINDOWS_95_THEME = {
key: "windows95",
names: { ru: "Windows 95", en: "Windows 95" },
@@ -426,6 +439,55 @@ export const DEV_MOCK = {
},
};
function applyDemoDataset() {
const storedLanguage = readStoredDemoLanguage();
const demoUser = {
...(DEMO_DATASET.currentUser || {}),
id: DEMO_DATASET.currentUser?.id ?? DEMO_DATASET.currentUser?.user_id,
language_code: storedLanguage || DEMO_DATASET.currentUser?.language_code || "ru",
};
Object.assign(DEV_MOCK.config, DEMO_DATASET.config || {});
DEV_MOCK.config.language = demoUser.language_code || "ru";
Object.assign(DEV_MOCK.data, {
user: demoUser,
subscription: DEMO_DATASET.currentSubscription || DEV_MOCK.data.subscription,
devices: DEMO_DATASET.devices || DEV_MOCK.data.devices,
plans: DEMO_DATASET.plans || DEV_MOCK.data.plans,
payment_methods: DEMO_DATASET.paymentMethods || DEV_MOCK.data.payment_methods,
referral: DEMO_DATASET.referral || DEV_MOCK.data.referral,
tariff_change_options:
DEMO_DATASET.tariff_change_options || DEV_MOCK.data.tariff_change_options,
topup_options: DEMO_DATASET.topup_options || DEV_MOCK.data.topup_options,
device_topup_options: DEMO_DATASET.device_topup_options || DEV_MOCK.data.device_topup_options,
settings: {
...DEV_MOCK.data.settings,
...(DEMO_DATASET.webappSettings || {}),
},
});
}
applyDemoDataset();
function applyDemoTariffScenario(subscriptionPatch = {}) {
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
...(DEMO_DATASET.currentSubscription || {}),
...subscriptionPatch,
traffic_limit_strategy:
subscriptionPatch.traffic_limit_strategy ||
DEMO_DATASET.currentSubscription?.traffic_limit_strategy ||
DEV_MOCK.data.subscription.traffic_limit_strategy ||
"MONTH",
};
DEV_MOCK.data.plans = DEMO_DATASET.plans;
DEV_MOCK.data.tariff_change_options =
DEMO_DATASET.tariff_change_options || DEV_MOCK.data.tariff_change_options;
DEV_MOCK.data.topup_options = DEMO_DATASET.topup_options || DEV_MOCK.data.topup_options;
DEV_MOCK.data.device_topup_options =
DEMO_DATASET.device_topup_options || DEV_MOCK.data.device_topup_options;
}
export function applyPreviewMock(kind) {
const mode = String(kind || "")
.trim()
@@ -506,6 +568,10 @@ export function applyPreviewMock(kind) {
];
} else if (mode === "tariffs") {
DEV_MOCK.data.settings.traffic_mode = false;
if (DEMO_DATASET.plans?.length) {
applyDemoTariffScenario();
return;
}
DEV_MOCK.data.subscription = {
...DEV_MOCK.data.subscription,
tariff_key: "standard",
@@ -519,57 +585,40 @@ export function applyPreviewMock(kind) {
id: "standard:period:1",
tariff_key: "standard",
tariff_name: "Стандарт",
tariff_description: "100 GB каждый месяц",
billing_model: "period",
sale_mode: "subscription",
months: 1,
price: 150,
currency: "RUB",
title: "Стандарт",
subtitle: "1 месяц",
description: "100 GB каждый месяц",
monthly_gb: 100,
sale_mode: "subscription",
},
{
id: "standard:period:3",
tariff_key: "standard",
tariff_name: "Стандарт",
tariff_description: "100 GB каждый месяц",
billing_model: "period",
sale_mode: "subscription",
months: 3,
price: 400,
currency: "RUB",
title: "Стандарт",
subtitle: "3 месяца",
description: "100 GB каждый месяц",
monthly_gb: 100,
sale_mode: "subscription",
},
{
id: "business:period:1",
tariff_key: "business",
tariff_name: "Бизнес",
tariff_name: "Business",
tariff_description: "500 GB и premium-серверы",
billing_model: "period",
sale_mode: "subscription",
months: 1,
price: 350,
price: 690,
currency: "RUB",
title: "Бизнес",
title: "Business",
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: "Пакет без срока действия",
sale_mode: "subscription",
},
];
DEV_MOCK.data.tariff_change_options = {
@@ -579,44 +628,31 @@ export function applyPreviewMock(kind) {
title: "Стандарт",
description: "100 GB каждый месяц",
billing_model: "period",
monthly_gb: 100,
expires_at: "31.05.2026",
},
targets: [
{
tariff_key: "business",
title: "Бизнес",
description: "300 GB и приоритетные серверы",
title: "Business",
description: "500 GB и premium-серверы",
billing_model: "period",
monthly_gb: 300,
monthly_gb: 500,
price: 690,
currency: "RUB",
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,
title: "Пересчитать дни",
days_after: 12,
remaining_days: 28,
},
{
mode: "buy_package",
mode: "paid_diff",
kind: "payment",
title: "+50 GB",
traffic_gb: 50,
price: 799,
title: "Доплатить разницу",
price: 240,
currency: "RUB",
},
],
@@ -625,10 +661,29 @@ export function applyPreviewMock(kind) {
};
DEV_MOCK.data.topup_options = {
ok: true,
topup_kind: "regular",
traffic_mode: false,
tariff_key: "standard",
tariff_name: "Стандарт",
traffic_percent: 86,
warning_levels: [85, 90, 95],
regular: {
can_topup: true,
monthly_limit_gb: 100,
used_gb: 86,
available_gb: 14,
packages: [
{ gb: 10, price: 99, currency: "RUB" },
{ gb: 50, price: 399, currency: "RUB" },
],
},
premium: {
can_topup: true,
monthly_limit_gb: 25,
used_gb: 24,
available_gb: 1,
packages: [
{ gb: 10, price: 190, currency: "RUB" },
{ gb: 25, price: 390, currency: "RUB" },
],
},
plans: [
{
id: "standard:topup:10",
@@ -643,67 +698,53 @@ export function applyPreviewMock(kind) {
subtitle: "Стандарт",
},
{
id: "standard:topup:50",
id: "standard:premium_topup:10",
tariff_key: "standard",
tariff_name: "Стандарт",
sale_mode: "topup",
traffic_gb: 50,
months: 50,
price: 399,
sale_mode: "premium_topup",
traffic_gb: 10,
months: 10,
price: 190,
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",
title: "Premium 10 GB",
subtitle: "Стандарт",
},
],
};
DEV_MOCK.data.device_topup_options = {
ok: true,
tariff_key: "standard",
tariff_name: "Стандарт",
current_limit: 5,
extra_hwid_devices: 2,
extra_hwid_devices_valid_until_text: "01.06.2026 12:00",
renewal_available: false,
renewal_recommended_count: 0,
current_devices: 1,
max_devices: 2,
available_extra_devices: 3,
packages: [
{ count: 1, price: 120, currency: "RUB" },
{ count: 3, price: 290, currency: "RUB" },
],
plans: [
{
id: "standard:hwid:1",
tariff_key: "standard",
tariff_name: "Стандарт",
sale_mode: "hwid_devices",
device_count: 1,
months: 1,
price: 99,
sale_mode: "hwid_device",
purchased_hwid_devices: 1,
price: 120,
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: "Стандарт",
title: "+1 устройство",
},
],
};
} else if (mode === "depleted") {
DEV_MOCK.data.settings.traffic_mode = false;
DEV_MOCK.data.settings.trial_available = false;
if (DEMO_DATASET.plans?.length) {
const limitBytes = Number(DEMO_DATASET.currentSubscription?.traffic_limit_bytes || 0);
applyDemoTariffScenario({
traffic_used: DEMO_DATASET.currentSubscription?.traffic_limit || "150 GB",
traffic_used_bytes: limitBytes,
});
return;
}
return;
} else if (mode === "devices") {
DEV_MOCK.data.settings.my_devices_enabled = true;
DEV_MOCK.data.subscription = {