Merge branch 'dev' into feature/multicurrency
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}` };
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user