refactor: support prefixed webapp routes

This commit is contained in:
3252a8
2026-05-28 14:59:34 +03:00
parent 92f0277dad
commit 613a9860a0
9 changed files with 224 additions and 103 deletions
+98 -49
View File
@@ -101,6 +101,7 @@
sectionFromPath, sectionFromPath,
supportTicketIdFromPath, supportTicketIdFromPath,
syncSectionPath, syncSectionPath,
withRoutePrefix,
} from "./lib/webapp/routes.js"; } from "./lib/webapp/routes.js";
export let mockRuntime = null; export let mockRuntime = null;
@@ -125,6 +126,8 @@
const MOCK_SOURCE = mockRuntime?.source || EMPTY_MOCK; const MOCK_SOURCE = mockRuntime?.source || EMPTY_MOCK;
const previewBoardComponent = mockRuntime?.PreviewBoard || null; const previewBoardComponent = mockRuntime?.PreviewBoard || null;
const isDocsDemo = mockRuntime?.docsDemo === true; const isDocsDemo = mockRuntime?.docsDemo === true;
const routePrefix = isDocsDemo ? "/demo/runtime" : "";
let docsDemoParentRouteConsumed = false;
const query = new URLSearchParams(window.location.search); const query = new URLSearchParams(window.location.search);
const isAppLaunchRoute = isExternalAppLaunchPath(window.location.pathname); const isAppLaunchRoute = isExternalAppLaunchPath(window.location.pathname);
mockRuntime?.applyPreviewMock?.(query.get("mock")); mockRuntime?.applyPreviewMock?.(query.get("mock"));
@@ -257,7 +260,7 @@
telegramSdk, telegramSdk,
}); });
const devicesStore = createDevicesStore({ api, t, showToast }); const devicesStore = createDevicesStore({ api, t, showToast });
const supportStore = createSupportStore({ api, t, showToast }); const supportStore = createSupportStore({ api, t, showToast, routePrefix });
const installGuidesStore = createInstallGuidesStore({ api, t, showToast }); const installGuidesStore = createInstallGuidesStore({ api, t, showToast });
const accountStore = createAccountStore({ const accountStore = createAccountStore({
api, api,
@@ -773,7 +776,7 @@
const section = const section =
isDocsDemo && currentQuery.get("screen") isDocsDemo && currentQuery.get("screen")
? normalizeSection(currentQuery.get("screen")) ? normalizeSection(currentQuery.get("screen"))
: sectionFromPath(window.location.pathname); : sectionFromPath(routePathnameFromLocation(), routePrefix);
if (mode === "login") { if (mode === "login") {
setPasswordLoginMode(isPasswordLoginPath(), true); setPasswordLoginMode(isPasswordLoginPath(), true);
screen = "login"; screen = "login";
@@ -783,11 +786,11 @@
if (section === "admin" && isAdmin) { if (section === "admin" && isAdmin) {
adminActiveSection = isDocsDemo adminActiveSection = isDocsDemo
? initialAdminSectionFromLocation() ? initialAdminSectionFromLocation()
: adminSectionFromPath(window.location.pathname); : adminSectionFromPath(routePathnameFromLocation(), routePrefix);
const pathAtStart = window.location.pathname; const pathAtStart = window.location.pathname;
void Promise.all([ensureI18nScope("admin"), ensureAdminBundle()]) void Promise.all([ensureI18nScope("admin"), ensureAdminBundle()])
.then(() => { .then(() => {
if (sectionFromPath(window.location.pathname) !== "admin") return; if (sectionFromPath(routePathnameFromLocation(), routePrefix) !== "admin") return;
if (window.location.pathname !== pathAtStart) return; if (window.location.pathname !== pathAtStart) return;
activeTab = "settings"; activeTab = "settings";
screen = "admin"; screen = "admin";
@@ -1038,39 +1041,95 @@
return new URLSearchParams(window.location.search); return new URLSearchParams(window.location.search);
} }
function docsDemoParentSearchParams() {
if (!isDocsDemo) return null;
try {
if (window.parent === window) return null;
return new URLSearchParams(window.parent.location.search);
} catch (_error) {
return null;
}
}
function normalizeDemoRoutePath(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
return withSlash.replace(/\/{2,}/g, "/").replace(/\/+$/, "") || "/";
}
function docsDemoRouteParams() {
if (!isDocsDemo) return null;
const currentQuery = currentSearchParams();
const currentParams = {
path: currentQuery.get("path") || "",
screen: currentQuery.get("screen") || "",
adminSection: currentQuery.get("admin_section") || "",
};
if (currentParams.path || currentParams.screen || currentParams.adminSection) {
return currentParams;
}
if (docsDemoParentRouteConsumed) return currentParams;
const parentQuery = docsDemoParentSearchParams();
return {
path: parentQuery?.get("path") || "",
screen: parentQuery?.get("screen") || "",
adminSection: parentQuery?.get("admin_section") || "",
};
}
function docsDemoRoutePathFromParams() {
const params = docsDemoRouteParams();
if (!params) return "";
const explicitPath = normalizeDemoRoutePath(params.path);
if (explicitPath) return explicitPath;
const section = normalizeSection(params.screen);
if (section === "admin") {
return `/admin/${normalizeAdminSection(params.adminSection || "stats")}`;
}
return params.screen ? `/${section}` : "";
}
function routePathnameFromLocation() {
return docsDemoRoutePathFromParams() || window.location.pathname;
}
function cleanDocsDemoRouteQuery() {
if (!isDocsDemo || window.location.protocol === "file:") return;
const url = new URL(window.location.href);
const routeKeys = ["path", "screen", "admin_section"];
const changed = routeKeys.some((key) => url.searchParams.has(key));
if (!changed) return;
for (const key of routeKeys) url.searchParams.delete(key);
const search = url.searchParams.toString();
window.history.replaceState(
null,
"",
`${url.pathname}${search ? `?${search}` : ""}${url.hash}`
);
}
function initialAdminSectionFromLocation() { function initialAdminSectionFromLocation() {
const currentQuery = currentSearchParams(); const currentQuery = currentSearchParams();
if (MOCK && currentQuery.get("admin_section")) { if (MOCK && currentQuery.get("admin_section")) {
return normalizeAdminSection(currentQuery.get("admin_section")); return normalizeAdminSection(currentQuery.get("admin_section"));
} }
return adminSectionFromPath(window.location.pathname); const demoRouteParams = docsDemoRouteParams();
if (MOCK && demoRouteParams?.adminSection) {
return normalizeAdminSection(demoRouteParams.adminSection);
}
return adminSectionFromPath(routePathnameFromLocation(), routePrefix);
} }
function syncDocsDemoSection(section, replace = false, adminSection = null) { function syncDocsDemoSection(section, replace = false, adminSection = null, adminUserId = null) {
if (!isDocsDemo || window.location.protocol === "file:") return false; if (!isDocsDemo || window.location.protocol === "file:") return false;
const normalized = normalizeSection(section); syncSectionPath(section, replace, adminSection, adminUserId, routePrefix);
const currentQuery = currentSearchParams(); cleanDocsDemoRouteQuery();
currentQuery.set("screen", normalized);
if (normalized === "admin") {
currentQuery.set(
"admin_section",
normalizeAdminSection(
adminSection || adminActiveSection || initialAdminSectionFromLocation()
)
);
} else {
currentQuery.delete("admin_section");
}
const nextSearch = currentQuery.toString();
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}${window.location.hash}`;
if (nextUrl !== `${window.location.pathname}${window.location.search}${window.location.hash}`) {
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
}
return true; return true;
} }
function syncAppSectionPath(section, replace = false, adminSection = null, adminUserId = null) { function syncAppSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
if (syncDocsDemoSection(section, replace, adminSection)) return; if (syncDocsDemoSection(section, replace, adminSection, adminUserId)) return;
syncSectionPath(section, replace, adminSection, adminUserId); syncSectionPath(section, replace, adminSection, adminUserId);
} }
@@ -1079,14 +1138,15 @@
onClose: closeAdminPanel, onClose: closeAdminPanel,
onToast: (text) => showToast(text), onToast: (text) => showToast(text),
initialSection: screen === "admin" ? adminActiveSection : initialAdminSectionFromLocation(), initialSection: screen === "admin" ? adminActiveSection : initialAdminSectionFromLocation(),
initialPaymentId: adminPaymentIdFromPath(window.location.pathname), initialPaymentId: adminPaymentIdFromPath(routePathnameFromLocation(), routePrefix),
initialPaymentUserId: adminPaymentsUserIdFromPath(window.location.pathname), initialPaymentUserId: adminPaymentsUserIdFromPath(routePathnameFromLocation(), routePrefix),
initialUserId: adminUserIdFromPath(window.location.pathname), initialUserId: adminUserIdFromPath(routePathnameFromLocation(), routePrefix),
onSectionChange: handleAdminSectionChange, onSectionChange: handleAdminSectionChange,
onSettingsSaved: handleAdminPersistedSaved, onSettingsSaved: handleAdminPersistedSaved,
onTariffsSaved: handleAdminPersistedSaved, onTariffsSaved: handleAdminPersistedSaved,
onThemesSaved: handleAdminPersistedSaved, onThemesSaved: handleAdminPersistedSaved,
onTranslationsSaved: handleAdminTranslationsSaved, onTranslationsSaved: handleAdminTranslationsSaved,
routePrefix,
brandTitle, brandTitle,
brand, brand,
appFaviconUrl: CFG.faviconUrl, appFaviconUrl: CFG.faviconUrl,
@@ -1231,7 +1291,7 @@
? preservedSection ? preservedSection
: MOCK && currentQuery.get("screen") : MOCK && currentQuery.get("screen")
? normalizeSection(currentQuery.get("screen")) ? normalizeSection(currentQuery.get("screen"))
: sectionFromPath(window.location.pathname); : sectionFromPath(routePathnameFromLocation(), routePrefix);
if (section === "admin" && !payload.user?.is_admin) section = "settings"; if (section === "admin" && !payload.user?.is_admin) section = "settings";
if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home"; if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home";
if (section === "support" && payload.settings?.support_tickets_enabled === false) { if (section === "support" && payload.settings?.support_tickets_enabled === false) {
@@ -1258,7 +1318,10 @@
} }
} }
const initialSupportTicketId = const initialSupportTicketId =
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null; section === "support"
? supportTicketIdFromPath(routePathnameFromLocation(), routePrefix)
: null;
if (isDocsDemo) docsDemoParentRouteConsumed = true;
activeTab = activeTab =
section === "admin" section === "admin"
? "settings" ? "settings"
@@ -1275,10 +1338,8 @@
} }
supportStore.startPolling({ includeList: false }); supportStore.startPolling({ includeList: false });
} }
if (isDocsDemo) { if (section === "support" && initialSupportTicketId) {
// The docs demo is a static iframe route; keep query params as its navigation contract. const targetPath = withRoutePrefix(`/support/${initialSupportTicketId}`, routePrefix);
} else if (section === "support" && initialSupportTicketId) {
const targetPath = `/support/${initialSupportTicketId}`;
if (window.location.protocol !== "file:" && window.location.pathname !== targetPath) { if (window.location.protocol !== "file:" && window.location.pathname !== targetPath) {
window.history.replaceState( window.history.replaceState(
null, null,
@@ -1286,6 +1347,7 @@
`${targetPath}${window.location.search}${window.location.hash}` `${targetPath}${window.location.search}${window.location.hash}`
); );
} }
cleanDocsDemoRouteQuery();
} else { } else {
syncAppSectionPath(section, true, initialAdminSection); syncAppSectionPath(section, true, initialAdminSection);
} }
@@ -1710,7 +1772,7 @@
clearLanguageClickGuard(); clearLanguageClickGuard();
billingStore.closePaymentModal(); billingStore.closePaymentModal();
const nextAdminSection = normalizeAdminSection( const nextAdminSection = normalizeAdminSection(
adminActiveSection || adminSectionFromPath(window.location.pathname) adminActiveSection || adminSectionFromPath(routePathnameFromLocation(), routePrefix)
); );
try { try {
await ensureI18nScope("admin"); await ensureI18nScope("admin");
@@ -1737,20 +1799,7 @@
const nextAdminSection = normalizeAdminSection(adminSection); const nextAdminSection = normalizeAdminSection(adminSection);
adminActiveSection = nextAdminSection; adminActiveSection = nextAdminSection;
if (window.location.protocol === "file:") return; if (window.location.protocol === "file:") return;
if (isDocsDemo) { syncAppSectionPath("admin", false, nextAdminSection, adminUserId);
syncDocsDemoSection("admin", false, nextAdminSection);
return;
}
const targetPath =
nextAdminSection === "users" && adminUserId
? `/admin/users/${adminUserId}`
: `/admin/${nextAdminSection}`;
if (window.location.pathname === targetPath) return;
window.history.pushState(
null,
"",
`${targetPath}${window.location.search}${window.location.hash}`
);
} }
function adminPayloadHasLogoChange(options = {}) { function adminPayloadHasLogoChange(options = {}) {
+32 -8
View File
@@ -77,6 +77,7 @@
userTelegramProfileLink, userTelegramProfileLink,
userTelegramProfileLinkKind, userTelegramProfileLinkKind,
} from "../lib/admin/users.js"; } from "../lib/admin/users.js";
import { stripRoutePrefix } from "../lib/webapp/routes.js";
export let api; export let api;
export let onClose = () => {}; export let onClose = () => {};
@@ -90,6 +91,7 @@
export let onTariffsSaved = () => {}; export let onTariffsSaved = () => {};
export let onThemesSaved = () => {}; export let onThemesSaved = () => {};
export let onTranslationsSaved = () => {}; export let onTranslationsSaved = () => {};
export let routePrefix = "";
export let brand = {}; export let brand = {};
export let brandTitle = "/minishop"; export let brandTitle = "/minishop";
export let appFaviconUrl = ""; export let appFaviconUrl = "";
@@ -243,15 +245,15 @@
const backupsStore = createBackupsStore({ api, onToast: flash, at }); const backupsStore = createBackupsStore({ api, onToast: flash, at });
const broadcastStore = createBroadcastStore({ api, onToast: flash, at }); const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
const logsStore = createLogsStore({ api, at }); const logsStore = createLogsStore({ api, at });
const paymentsStore = createPaymentsStore({ api, onToast: flash, at }); const paymentsStore = createPaymentsStore({ api, onToast: flash, at, routePrefix });
const promosStore = createPromosStore({ api, onToast: flash, at }); const promosStore = createPromosStore({ api, onToast: flash, at });
const settingsStore = createSettingsStore({ api, onToast: flash, at }); const settingsStore = createSettingsStore({ api, onToast: flash, at });
const statsStore = createStatsStore({ api, onToast: flash, at }); const statsStore = createStatsStore({ api, onToast: flash, at });
const supportStore = createAdminSupportStore({ api, onToast: flash, at }); const supportStore = createAdminSupportStore({ api, onToast: flash, at, routePrefix });
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at }); const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
const themesStore = createThemesStore({ api, onThemesSaved, flash, at }); const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
const translationsStore = createTranslationsStore({ api, onToast: flash, at }); const translationsStore = createTranslationsStore({ api, onToast: flash, at });
const usersStore = createUsersStore({ api, onToast: flash, at }); const usersStore = createUsersStore({ api, onToast: flash, at, routePrefix });
setContext("promosStore", promosStore); setContext("promosStore", promosStore);
setContext("adsStore", adsStore); setContext("adsStore", adsStore);
@@ -296,33 +298,38 @@
onLanguageChange(value, { section: "admin", adminSection: active }); onLanguageChange(value, { section: "admin", adminSection: active });
} }
function currentRoutePathname() {
if (typeof window === "undefined") return "/";
return stripRoutePrefix(window.location.pathname, routePrefix);
}
function readSectionFromPath() { function readSectionFromPath() {
if (typeof window === "undefined") return "stats"; if (typeof window === "undefined") return "stats";
const match = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/.*)?$/i); const match = currentRoutePathname().match(/^\/admin\/([a-z0-9_-]+)(?:\/.*)?$/i);
return normalizeSection(match ? match[1].toLowerCase() : "stats"); return normalizeSection(match ? match[1].toLowerCase() : "stats");
} }
function readUserIdFromPath() { function readUserIdFromPath() {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/^\/admin\/users\/(-?\d+)$/); const match = currentRoutePathname().match(/^\/admin\/users\/(-?\d+)$/);
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
} }
function readSupportTicketIdFromPath() { function readSupportTicketIdFromPath() {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/^\/admin\/support\/(\d+)$/); const match = currentRoutePathname().match(/^\/admin\/support\/(\d+)$/);
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
} }
function readPaymentIdFromPath() { function readPaymentIdFromPath() {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/^\/admin\/payments\/(\d+)$/); const match = currentRoutePathname().match(/^\/admin\/payments\/(\d+)$/);
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
} }
function readPaymentUserIdFromPath() { function readPaymentUserIdFromPath() {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/^\/admin\/payments\/users\/(-?\d+)$/); const match = currentRoutePathname().match(/^\/admin\/payments\/users\/(-?\d+)$/);
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
} }
@@ -377,10 +384,26 @@
paymentsStore.closePayment({ skipPush: true }); paymentsStore.closePayment({ skipPush: true });
onSectionChange(next); onSectionChange(next);
} }
usersStore.setActive(next);
paymentsStore.closePayment({ skipPush: true }); paymentsStore.closePayment({ skipPush: true });
usersStore.openUser(uid, { pathContext: "payments" }); usersStore.openUser(uid, { pathContext: "payments" });
} }
function openUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return;
const next = normalizeSection("users");
sidebarOpen = false;
if (active !== next) {
active = next;
paymentsStore.closePayment({ skipPush: true });
supportStore.closeTicketView({ skipPush: true });
onSectionChange(next, uid);
}
usersStore.setActive(next);
usersStore.openUser(uid);
}
function resolvedAvatarUrl(user) { function resolvedAvatarUrl(user) {
return userAvatarUrl(user) || (user?.email ? gravatarCache.gravatarUrl(user.email) : ""); return userAvatarUrl(user) || (user?.email ? gravatarCache.gravatarUrl(user.email) : "");
} }
@@ -786,6 +809,7 @@
{at} {at}
{brand} {brand}
{resolvedAvatarUrl} {resolvedAvatarUrl}
onOpenUserCard={openUserCard}
initialTicketId={readSupportTicketIdFromPath()} initialTicketId={readSupportTicketIdFromPath()}
/> />
{/if} {/if}
@@ -17,6 +17,7 @@
export let initialTicketId = null; export let initialTicketId = null;
export let brand = {}; export let brand = {};
export let resolvedAvatarUrl = () => ""; export let resolvedAvatarUrl = () => "";
export let onOpenUserCard = () => {};
const supportStore = getContext("adminSupportStore"); const supportStore = getContext("adminSupportStore");
let reply = ""; let reply = "";
@@ -306,7 +307,12 @@
onPatch={(updates) => supportStore.patchTicket(updates)} onPatch={(updates) => supportStore.patchTicket(updates)}
onClose={() => supportStore.closeTicket()} onClose={() => supportStore.closeTicket()}
/> />
<SupportUserContextPanel ticket={openedTicket} snapshot={userSnapshot} {at} /> <SupportUserContextPanel
ticket={openedTicket}
snapshot={userSnapshot}
{at}
onOpenUser={onOpenUserCard}
/>
<ScrollArea <ScrollArea
bind:element={messagesScrollEl} bind:element={messagesScrollEl}
maxHeight="none" maxHeight="none"
@@ -1,9 +1,11 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { withRoutePrefix } from "../../webapp/routes.js";
export function createPaymentsStore({ export function createPaymentsStore({
api, api,
onToast = () => {}, onToast = () => {},
at = (key, _params, fallback) => fallback || key, at = (key, _params, fallback) => fallback || key,
routePrefix = "",
}) { }) {
const state = writable({ const state = writable({
payments: [], payments: [],
@@ -25,7 +27,10 @@ export function createPaymentsStore({
function pushPaymentPath(paymentId) { function pushPaymentPath(paymentId) {
if (typeof window === "undefined" || window.location.protocol === "file:") return; if (typeof window === "undefined" || window.location.protocol === "file:") return;
if (active !== "payments") return; if (active !== "payments") return;
const target = paymentId ? `/admin/payments/${paymentId}` : "/admin/payments"; const target = withRoutePrefix(
paymentId ? `/admin/payments/${paymentId}` : "/admin/payments",
routePrefix
);
if (window.location.pathname === target) return; if (window.location.pathname === target) return;
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`); window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
} }
@@ -1,6 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { withRoutePrefix } from "../../webapp/routes.js";
export function createAdminSupportStore({ api, onToast, at }) { export function createAdminSupportStore({ api, onToast, at, routePrefix = "" }) {
const OPEN_TICKET_POLL_MS = 3_000; const OPEN_TICKET_POLL_MS = 3_000;
const STATS_POLL_MS = 30_000; const STATS_POLL_MS = 30_000;
const HIDDEN_POLL_MS = 300_000; const HIDDEN_POLL_MS = 300_000;
@@ -58,7 +59,10 @@ export function createAdminSupportStore({ api, onToast, at }) {
function pushTicketPath(ticketId) { function pushTicketPath(ticketId) {
if (typeof window === "undefined" || window.location.protocol === "file:") return; if (typeof window === "undefined" || window.location.protocol === "file:") return;
if (active !== "support") return; if (active !== "support") return;
const target = ticketId ? `/admin/support/${ticketId}` : "/admin/support"; const target = withRoutePrefix(
ticketId ? `/admin/support/${ticketId}` : "/admin/support",
routePrefix
);
if (window.location.pathname !== target) { if (window.location.pathname !== target) {
window.history.pushState( window.history.pushState(
null, null,
+3 -1
View File
@@ -1,6 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { withRoutePrefix } from "../../webapp/routes.js";
export function createUsersStore({ api, onToast, at }) { export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
const USERS_PAGE_SIZE = 25; const USERS_PAGE_SIZE = 25;
const USER_LOGS_PAGE_SIZE = 20; const USER_LOGS_PAGE_SIZE = 20;
@@ -70,6 +71,7 @@ export function createUsersStore({ api, onToast, at }) {
target = userId ? `/admin/payments/users/${userId}` : `/admin/payments`; target = userId ? `/admin/payments/users/${userId}` : `/admin/payments`;
} }
if (!target) return; if (!target) return;
target = withRoutePrefix(target, routePrefix);
if (window.location.pathname === target) return; if (window.location.pathname === target) return;
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`); window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
} }
@@ -5,6 +5,7 @@
export let ticket; export let ticket;
export let snapshot = {}; export let snapshot = {};
export let at = (key) => key; export let at = (key) => key;
export let onOpenUser = () => {};
$: user = ticket?.user || {}; $: user = ticket?.user || {};
$: displayName = snapshot?.name || user.username || user.email || user.user_id || "-"; $: displayName = snapshot?.name || user.username || user.email || user.user_id || "-";
@@ -65,7 +66,7 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
disabled={!canOpenUser} disabled={!canOpenUser}
onclick={() => (window.location.href = `/admin/users/${user.user_id}`)} onclick={() => onOpenUser(user.user_id)}
aria-label={at("support_open_user", {}, "Карточка")} aria-label={at("support_open_user", {}, "Карточка")}
title={at("support_open_user", {}, "Карточка")} title={at("support_open_user", {}, "Карточка")}
> >
+64 -36
View File
@@ -25,15 +25,41 @@ export function normalizeAdminSection(value) {
return ADMIN_SECTIONS.has(section) ? section : "stats"; return ADMIN_SECTIONS.has(section) ? section : "stats";
} }
export function sectionFromPath(pathname) { function normalizePathname(pathname) {
const normalized = String(pathname || "")
.trim()
.replace(/\/+$/, "");
return normalized || "/";
}
export function stripRoutePrefix(pathname, routePrefix = "") {
const path = normalizePathname(pathname);
const prefix = normalizePathname(routePrefix);
if (prefix === "/") return path;
if (path.toLowerCase() === prefix.toLowerCase()) return "/";
if (path.toLowerCase().startsWith(`${prefix.toLowerCase()}/`)) {
return path.slice(prefix.length) || "/";
}
return path;
}
export function withRoutePrefix(pathname, routePrefix = "") {
const path = normalizePathname(pathname);
const prefix = normalizePathname(routePrefix);
if (prefix === "/") return path;
if (path === "/") return prefix;
return `${prefix}${path}`;
}
export function sectionFromPath(pathname, routePrefix = "") {
const normalizedPath = String(pathname || "") const normalizedPath = String(pathname || "")
.trim() .trim()
.toLowerCase()
.replace(/\/+$/, ""); .replace(/\/+$/, "");
if (!normalizedPath || normalizedPath === "/") return "home"; const routePath = stripRoutePrefix(normalizedPath, routePrefix).toLowerCase().replace(/\/+$/, "");
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin"; if (!routePath || routePath === "/") return "home";
if (normalizedPath === "/support" || normalizedPath.startsWith("/support/")) return "support"; if (routePath === "/admin" || routePath.startsWith("/admin/")) return "admin";
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath; if (routePath === "/support" || routePath.startsWith("/support/")) return "support";
const section = routePath.startsWith("/") ? routePath.slice(1) : routePath;
return normalizeSection(section); return normalizeSection(section);
} }
@@ -45,67 +71,68 @@ export function publicInstallTokenFromPath(pathname) {
return match ? match[1].toLowerCase() : ""; return match ? match[1].toLowerCase() : "";
} }
export function adminSectionFromPath(pathname) { export function adminSectionFromPath(pathname, routePrefix = "") {
const normalized = String(pathname || "") const normalized = stripRoutePrefix(pathname, routePrefix).toLowerCase().replace(/\/+$/, "");
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/.*)?$/); const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/.*)?$/);
return normalizeAdminSection(m ? m[1] : ""); return normalizeAdminSection(m ? m[1] : "");
} }
export function adminUserIdFromPath(pathname) { export function adminUserIdFromPath(pathname, routePrefix = "") {
const normalized = String(pathname || "") const normalized = stripRoutePrefix(pathname, routePrefix).toLowerCase().replace(/\/+$/, "");
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/users\/(-?\d+)$/); const m = normalized.match(/^\/admin\/users\/(-?\d+)$/);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} }
export function adminPaymentIdFromPath(pathname) { export function adminPaymentIdFromPath(pathname, routePrefix = "") {
const normalized = String(pathname || "") const normalized = stripRoutePrefix(pathname, routePrefix).toLowerCase().replace(/\/+$/, "");
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/payments\/(\d+)$/); const m = normalized.match(/^\/admin\/payments\/(\d+)$/);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} }
export function adminPaymentsUserIdFromPath(pathname) { export function adminPaymentsUserIdFromPath(pathname, routePrefix = "") {
const normalized = String(pathname || "") const normalized = stripRoutePrefix(pathname, routePrefix).toLowerCase().replace(/\/+$/, "");
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/payments\/users\/(-?\d+)$/); const m = normalized.match(/^\/admin\/payments\/users\/(-?\d+)$/);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} }
export function supportTicketIdFromPath(pathname) { export function supportTicketIdFromPath(pathname, routePrefix = "") {
const normalized = String(pathname || "") const normalized = stripRoutePrefix(pathname, routePrefix).toLowerCase().replace(/\/+$/, "");
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/support\/(\d+)$/); const m = normalized.match(/^\/support\/(\d+)$/);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} }
export function adminSupportTicketIdFromPath(pathname) { export function adminSupportTicketIdFromPath(pathname, routePrefix = "") {
const normalized = String(pathname || "") const normalized = stripRoutePrefix(pathname, routePrefix).toLowerCase().replace(/\/+$/, "");
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/support\/(\d+)$/); const m = normalized.match(/^\/admin\/support\/(\d+)$/);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} }
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) { export function syncSectionPath(
section,
replace = false,
adminSection = null,
adminUserId = null,
routePrefix = ""
) {
if (window.location.protocol === "file:") return; if (window.location.protocol === "file:") return;
const normalized = normalizeSection(section); const normalized = normalizeSection(section);
let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
if (normalized === "admin") { if (normalized === "admin") {
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats"; const adm =
adminSection || adminSectionFromPath(window.location.pathname, routePrefix) || "stats";
const uid = const uid =
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null); adminUserId ??
(adm === "users" ? adminUserIdFromPath(window.location.pathname, routePrefix) : null);
const supportTicketId = const supportTicketId =
adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname) : null; adm === "support"
const paymentId = adm === "payments" ? adminPaymentIdFromPath(window.location.pathname) : null; ? adminSupportTicketIdFromPath(window.location.pathname, routePrefix)
: null;
const paymentId =
adm === "payments" ? adminPaymentIdFromPath(window.location.pathname, routePrefix) : null;
const paymentUserId = const paymentUserId =
adm === "payments" ? adminPaymentsUserIdFromPath(window.location.pathname) : null; adm === "payments"
? adminPaymentsUserIdFromPath(window.location.pathname, routePrefix)
: null;
if (adm === "users" && uid) targetPath = `/admin/users/${uid}`; if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`; else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`;
else if (adm === "payments" && paymentUserId) else if (adm === "payments" && paymentUserId)
@@ -113,6 +140,7 @@ export function syncSectionPath(section, replace = false, adminSection = null, a
else if (adm === "payments" && paymentId) targetPath = `/admin/payments/${paymentId}`; else if (adm === "payments" && paymentId) targetPath = `/admin/payments/${paymentId}`;
else targetPath = `/admin/${adm}`; else targetPath = `/admin/${adm}`;
} }
targetPath = withRoutePrefix(targetPath, routePrefix);
if (window.location.pathname === targetPath) return; if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
@@ -1,6 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { withRoutePrefix } from "../routes.js";
export function createSupportStore({ api, t, showToast }) { export function createSupportStore({ api, t, showToast, routePrefix = "" }) {
const OPEN_TICKET_POLL_MS = 3_000; const OPEN_TICKET_POLL_MS = 3_000;
const ACTIVE_POLL_MS = 8_000; const ACTIVE_POLL_MS = 8_000;
const BACKGROUND_POLL_MS = 45_000; const BACKGROUND_POLL_MS = 45_000;
@@ -198,7 +199,7 @@ export function createSupportStore({ api, t, showToast }) {
detailLoading: true, detailLoading: true,
})); }));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") { if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
const target = `/support/${id}`; const target = withRoutePrefix(`/support/${id}`, routePrefix);
if (window.location.pathname !== target) { if (window.location.pathname !== target) {
window.history.pushState( window.history.pushState(
null, null,
@@ -232,11 +233,12 @@ export function createSupportStore({ api, t, showToast }) {
function closeTicketView(opts = {}) { function closeTicketView(opts = {}) {
state.update((s) => ({ ...s, openedTicketId: null, openedTicket: null, messages: [] })); state.update((s) => ({ ...s, openedTicketId: null, openedTicket: null, messages: [] }));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") { if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
if (window.location.pathname.startsWith("/support/")) { const supportPath = withRoutePrefix("/support", routePrefix);
if (window.location.pathname.startsWith(`${supportPath}/`)) {
window.history.pushState( window.history.pushState(
null, null,
"", "",
`/support${window.location.search}${window.location.hash}` `${supportPath}${window.location.search}${window.location.hash}`
); );
} }
} }