Merge branch 'dev' into feature/multicurrency

This commit is contained in:
3252a8
2026-06-01 08:18:25 +03:00
31 changed files with 1151 additions and 63 deletions
@@ -25,6 +25,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsPageSize: USERS_PAGE_SIZE,
userReferralsInviter: null,
userDetailTab: "profile",
premiumUnlimitedDraft: false,
premiumBonusGbDraft: "",
@@ -126,6 +133,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userExtendDays: 30,
userDetailLoading: true,
userDetailTab: "subscription",
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsInviter: null,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
@@ -175,6 +188,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsInviter: null,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
@@ -231,6 +250,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
loadUserLogs(page);
}
async function openUserReferrals(page = 0) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const userId = s.openedUser.user_id;
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : 0;
state.update((st) => ({
...st,
userReferralsOpen: true,
userReferralsLoading: true,
userReferralsPage: targetPage,
}));
try {
const params = new URLSearchParams({
page: String(targetPage),
page_size: String(s.userReferralsPageSize || USERS_PAGE_SIZE),
});
const data = await api(`/admin/users/${userId}/referrals?${params.toString()}`);
if (data?.ok) {
state.update((st) => {
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
return {
...st,
userReferrals: data.invitees || [],
userReferralsTotal: Number(data.total || 0),
userReferralsPage: Number(data.page || 0),
userReferralsPageSize: Number(data.page_size || st.userReferralsPageSize),
userReferralsInviter: data.inviter || null,
};
});
} else if (data?.error) {
onToast(data.error);
}
} finally {
state.update((st) => ({ ...st, userReferralsLoading: false }));
}
}
function closeUserReferrals() {
state.update((s) => ({
...s,
userReferralsOpen: false,
}));
}
function setUserReferralsPage(page) {
openUserReferrals(page);
}
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
if (!text) return;
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
@@ -564,5 +635,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
grantTraffic,
loadUserLogs,
setUserLogsPage,
openUserReferrals,
closeUserReferrals,
setUserReferralsPage,
};
}
+1 -1
View File
@@ -66,7 +66,7 @@
<X size={18} />
</Button>
</div>
<ScrollArea class="dialog-body-scroll" maxHeight="none">
<ScrollArea class="dialog-body-scroll scroll-area--dialog" maxHeight="none">
<slot />
</ScrollArea>
</section>
@@ -36,6 +36,10 @@
overflow: hidden;
}
:global(.scroll-area--dialog) {
overflow: visible;
}
:global(.scroll-area__viewport) {
width: 100%;
height: 100%;
@@ -56,6 +60,10 @@
width: 10px;
}
:global(.scroll-area--dialog .scroll-area__scrollbar[data-orientation="vertical"]) {
transform: translateX(12px);
}
:global(.scroll-area__scrollbar[data-orientation="horizontal"]) {
flex-direction: column;
height: 10px;
+20 -21
View File
@@ -104957,10 +104957,9 @@ export const DEMO_DATASET = {
updated_by: null,
},
en: {
base: "Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
base: "Unlimited access and a persistent bonus on the main traffic limit.",
fallback: "Режим безлимита и постоянный бонус к лимиту основного трафика.",
effective:
"Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
effective: "Unlimited access and a persistent bonus on the main traffic limit.",
override: "",
overridden: false,
updated_at: null,
@@ -105241,22 +105240,22 @@ export const DEMO_DATASET = {
audience: "internal",
values: {
ru: {
base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
fallback:
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
effective:
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
base: "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
fallback:
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
effective:
"Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
"Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
override: "",
overridden: false,
updated_at: null,
@@ -134112,18 +134111,18 @@ export const DEMO_DATASET = {
audience: "user",
values: {
ru: {
base: "Докупить премиум-трафик (мини-приложение)",
fallback: "Докупить премиум-трафик (мини-приложение)",
effective: "Докупить премиум-трафик (мини-приложение)",
base: "Докупить премиум-трафик",
fallback: "Докупить премиум-трафик",
effective: "Докупить премиум-трафик",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "Top up premium traffic (mini app)",
fallback: "Докупить премиум-трафик (мини-приложение)",
effective: "Top up premium traffic (mini app)",
base: "Top up premium traffic",
fallback: "Докупить премиум-трафик",
effective: "Top up premium traffic",
override: "",
overridden: false,
updated_at: null,
@@ -134136,18 +134135,18 @@ export const DEMO_DATASET = {
audience: "user",
values: {
ru: {
base: "Докупить трафик (мини-приложение)",
fallback: "Докупить трафик (мини-приложение)",
effective: "Докупить трафик (мини-приложение)",
base: "Докупить трафик",
fallback: "Докупить трафик",
effective: "Докупить трафик",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "Top up traffic (mini app)",
fallback: "Докупить трафик (мини-приложение)",
effective: "Top up traffic (mini app)",
base: "Top up traffic",
fallback: "Докупить трафик",
effective: "Top up traffic",
override: "",
overridden: false,
updated_at: null,
+40 -1
View File
@@ -405,6 +405,32 @@ function withDemoAvatars(users, size = 96) {
return (users || []).map((user) => withDemoAvatar(user, size));
}
function demoAdminUserById(userId) {
return (DEMO_DATASET.adminUsers || []).find((user) => Number(user.user_id) === Number(userId));
}
function demoInviteesForUser(userId) {
return (DEMO_DATASET.adminUsers || [])
.filter((user) => Number(user.referred_by_id) === Number(userId))
.sort((a, b) => stringDate(b.registration_date) - stringDate(a.registration_date));
}
function withDemoReferralSummary(detail) {
if (!detail || typeof detail !== "object") return detail;
const decorated = withDemoAvatarDetail(detail);
const user = decorated.user || {};
const inviter = user.referred_by_id ? demoAdminUserById(user.referred_by_id) : null;
const invitees = demoInviteesForUser(user.user_id);
return {
...decorated,
referral: {
...(decorated.referral || {}),
inviter: inviter ? withDemoAvatar(inviter) : null,
invitees_total: invitees.length,
},
};
}
function withDemoAvatarTickets(tickets, size = 96) {
return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size));
}
@@ -610,8 +636,21 @@ function demoApiResponse(path, cleanPath, options, context) {
const id = Number(parts[3]);
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
if (!detail) return { ok: false, error: "not_found" };
const decoratedDetail = withDemoAvatarDetail(detail);
const decoratedDetail = withDemoReferralSummary(detail);
if (parts[4]) {
if (parts[4] === "referrals") {
const invitees = demoInviteesForUser(id);
const page = paged(invitees, params, 25);
return {
ok: true,
user: clone(decoratedDetail.user),
inviter: clone(decoratedDetail.referral?.inviter || null),
invitees: clone(withDemoAvatars(page.items)),
total: page.total,
page: page.page,
page_size: page.pageSize,
};
}
if (parts[4] === "telegram-profile-link") {
return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` };
}
+12 -2
View File
@@ -188,10 +188,17 @@ export function createAccountStore({
async function requestLinkEmailCode() {
const s = get(state);
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
const normalized = String(s.linkEmailValue || "")
.trim()
.toLowerCase();
if (
s.linkEmailPending &&
s.linkEmailResendCooldown > 0 &&
(!normalized || normalized === s.linkEmailPending)
) {
state.update((s) => ({ ...s, linkEmailOpen: true }));
return;
}
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
return;
@@ -251,7 +258,10 @@ export function createAccountStore({
async function requestSetPasswordCode() {
const s = get(state);
if (s.setPasswordPending && s.setPasswordResendCooldown > 0) return;
if (s.setPasswordPending && s.setPasswordResendCooldown > 0) {
state.update((s) => ({ ...s, setPasswordOpen: true }));
return;
}
if (!validatePasswordDraft()) return;
state.update((s) => ({ ...s, setPasswordBusy: true }));
setPasswordStatus(t("wa_auth_sending_code"));
+93 -1
View File
@@ -6,6 +6,10 @@ import {
emailError,
} from "../authHelpers.js";
const EMAIL_CODE_PENDING_STORAGE_KEY = "rw_email_code_login_pending_v1";
const EMAIL_CODE_PENDING_TTL_MS = 10 * 60 * 1000;
const EMAIL_CODE_RESEND_MS = 60 * 1000;
export function createAuthStore({
publicApi,
setToken,
@@ -35,6 +39,80 @@ export function createAuthStore({
let authResendTimer = null;
let telegramLoginWatchdogTimer = null;
function readPendingEmailCodeSession() {
if (typeof window === "undefined" || !window.sessionStorage) return null;
try {
const raw = window.sessionStorage.getItem(EMAIL_CODE_PENDING_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
const email = String(parsed?.email || "")
.trim()
.toLowerCase();
const expiresAt = Number(parsed?.expiresAt || 0);
const cooldownUntil = Number(parsed?.cooldownUntil || 0);
if (!email || !email.includes("@") || !expiresAt || expiresAt <= Date.now()) {
window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
return null;
}
return { email, expiresAt, cooldownUntil };
} catch (_error) {
window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
return null;
}
}
function writePendingEmailCodeSession(email) {
if (typeof window === "undefined" || !window.sessionStorage) return;
try {
window.sessionStorage.setItem(
EMAIL_CODE_PENDING_STORAGE_KEY,
JSON.stringify({
email,
expiresAt: Date.now() + EMAIL_CODE_PENDING_TTL_MS,
cooldownUntil: Date.now() + EMAIL_CODE_RESEND_MS,
})
);
} catch (_error) {
void _error;
}
}
function clearPendingEmailCode() {
if (typeof window === "undefined" || !window.sessionStorage) return;
try {
window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
} catch (_error) {
void _error;
}
}
function restorePendingEmailCode(changeScreen) {
const pending = readPendingEmailCodeSession();
if (!pending) return false;
state.update((s) => ({
...s,
email: pending.email,
pendingEmail: pending.email,
emailCode: "",
authStatus: "",
authIsError: false,
authBusy: false,
passwordLoginMode: false,
passwordLoginFallback: false,
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
}));
const cooldownSeconds = Math.ceil((pending.cooldownUntil - Date.now()) / 1000);
if (cooldownSeconds > 0) {
startCooldownTimer(cooldownSeconds);
} else {
clearCooldownTimer();
state.update((s) => ({ ...s, authResendCooldown: 0 }));
}
if (typeof changeScreen === "function") changeScreen("code");
return true;
}
function setAuthStatus(message, isError = false) {
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
}
@@ -101,6 +179,7 @@ export function createAuthStore({
const response = await publicApi("/auth/email/magic", payload);
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
clearPendingEmailCode();
clearAuthQuery();
await loadData();
return true;
@@ -131,6 +210,7 @@ export function createAuthStore({
const response = await publicApi("/auth/token", payload, { signal: options.signal });
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
clearPendingEmailCode();
clearAuthQuery();
setAuthStatus("");
await loadData();
@@ -157,8 +237,15 @@ export function createAuthStore({
async function requestEmailCode(changeScreen) {
const s = get(state);
if (s.authResendCooldown > 0 && s.pendingEmail) return;
const normalized = s.email.trim().toLowerCase();
if (
s.authResendCooldown > 0 &&
s.pendingEmail &&
(!normalized || normalized === s.pendingEmail)
) {
if (typeof changeScreen === "function") changeScreen("code");
return;
}
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({
...s,
@@ -185,6 +272,7 @@ export function createAuthStore({
.replace(/\D/g, "")
.slice(0, 6);
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: presetCode }));
writePendingEmailCodeSession(normalized);
changeScreen("code");
setAuthStatus("");
startCooldownTimer(60);
@@ -226,6 +314,7 @@ export function createAuthStore({
});
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
clearPendingEmailCode();
await loadData();
setAuthStatus("");
} catch (error) {
@@ -258,6 +347,7 @@ export function createAuthStore({
const response = await publicApi("/auth/email/verify", payload);
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
clearPendingEmailCode();
await loadData();
setAuthStatus("");
} catch (error) {
@@ -335,6 +425,8 @@ export function createAuthStore({
loginWithEmailPassword,
verifyEmailCode,
openTelegramLogin,
restorePendingEmailCode,
clearPendingEmailCode,
clearCooldownTimer,
stopTelegramLoginWatchdog,
setAuthStatus,