feat: show referral relationships in admin user cards

This commit is contained in:
3252a8
2026-06-01 00:36:05 +03:00
parent 7cf1d577f3
commit c2344824dc
11 changed files with 662 additions and 12 deletions
@@ -23,6 +23,7 @@
Trash2,
UserMinus,
UserPlus,
UsersRound,
} from "$components/ui/icons.js";
import { getContext } from "svelte";
@@ -63,6 +64,12 @@
userDeleteOpen,
userBanConfirmOpen,
userMessageConfirmOpen,
userReferralsOpen,
userReferralsLoading,
userReferrals,
userReferralsTotal,
userReferralsPage,
userReferralsPageSize,
premiumUnlimitedDraft,
userDetailTab,
userLogs,
@@ -75,8 +82,13 @@
$: userLogsHasMore =
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
$: userReferralsHasMore =
Number(userReferralsTotal || 0) >
(Number(userReferralsPage || 0) + 1) * Number(userReferralsPageSize || 25);
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
$: referralInviter = openedUserDetail?.referral?.inviter || null;
$: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
$: openedUserTelegramProfileHint =
@@ -119,6 +131,12 @@
}
openTelegramProfileLink(openedUserTelegramProfileLink);
}
function openRelatedUser(user) {
if (!user?.user_id) return;
usersStore.closeUserReferrals();
usersStore.openUser(user);
}
</script>
<Dialog
@@ -225,6 +243,41 @@
"—"}</strong
>
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_by", {}, "Пригласил")}</span>
<strong class="admin-user-ref-value">
{#if referralInviter}
<span>{userDisplayName(referralInviter)}</span>
<small>ID {referralInviter.user_id}</small>
{:else}
<span>{at("user_invited_by_none", {}, "—")}</span>
{/if}
</strong>
{#if referralInviter}
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(referralInviter)}
>
<ExternalLink size={14} />
</AdminButton>
{/if}
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_users", {}, "Приглашённые")}</span>
<strong>{referralInviteesTotal}</strong>
<AdminButton
size="sm"
variant="ghost"
disabled={referralInviteesTotal <= 0}
onclick={() => usersStore.openUserReferrals(0)}
>
<UsersRound size={14} />
{at("user_invitees_open", {}, "Показать")}
</AdminButton>
</li>
</ul>
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
@@ -934,6 +987,101 @@
{/if}
</Dialog>
<Dialog
open={userReferralsOpen}
title={at("user_invitees_title", {}, "Приглашённые пользователи")}
description={openedUser
? at(
"user_invitees_description",
{ name: userDisplayName(openedUser), count: userReferralsTotal },
`${userDisplayName(openedUser)} · ${userReferralsTotal}`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={usersStore.closeUserReferrals}
class="admin-dialog admin-user-referrals-dialog"
>
<div class="admin-user-referrals-body">
{#if userReferralsLoading}
<AdminTableSkeleton
headers={[
at("user_col_user", {}, "Пользователь"),
"ID",
at("user_label_registration", {}, "Регистрация"),
"",
]}
rows={5}
widths={["42%", "18%", "26%", "14%"]}
/>
{:else if !userReferrals.length}
<AdminEmptyState tone="card">
<span class="admin-muted"
>{at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")}</span
>
</AdminEmptyState>
{:else}
<ScrollArea class="admin-user-referrals-table-wrap" maxHeight="min(55vh, 460px)">
<AdminTable class="admin-user-referrals-table">
<thead>
<tr>
<th>{at("user_col_user", {}, "Пользователь")}</th>
<th>ID</th>
<th>{at("user_label_registration", {}, "Регистрация")}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each userReferrals as invitee (invitee.user_id)}
<tr>
<td data-label={at("user_col_user", {}, "Пользователь")}>
<span class="admin-referral-user-cell">
<strong>{userDisplayName(invitee)}</strong>
<small>{userSecondaryName(invitee)}</small>
</span>
</td>
<td class="admin-cell-mono" data-label="ID">{invitee.user_id}</td>
<td data-label={at("user_label_registration", {}, "Регистрация")}>
{fmtDateShort(invitee.registration_date)}
</td>
<td class="admin-referral-user-actions">
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(invitee)}
>
<ExternalLink size={14} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
</ScrollArea>
{/if}
{#if userReferralsTotal > userReferralsPageSize}
<AdminPagination
meta={at(
"pagination_meta",
{
current: userReferralsPage + 1,
total: Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize)),
},
`${userReferralsPage + 1}/${Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize))}`
)}
prevLabel={at("prev_page", {}, "Назад")}
nextLabel={at("next_page", {}, "Вперёд")}
prevDisabled={userReferralsLoading || userReferralsPage <= 0}
nextDisabled={userReferralsLoading || !userReferralsHasMore}
onPrev={() => usersStore.setUserReferralsPage(userReferralsPage - 1)}
onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)}
/>
{/if}
</div>
</Dialog>
<Dialog
open={avatarPreviewOpen}
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
@@ -1159,6 +1307,62 @@
gap: 8px;
margin-top: 6px;
}
.admin-user-ref-row {
grid-template-columns: 130px minmax(0, 1fr) auto;
align-items: center;
}
.admin-user-ref-row :global(.admin-btn) {
flex: 0 0 auto;
}
.admin-user-ref-value {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-user-ref-value small {
color: var(--admin-muted);
font-size: 11px;
font-weight: 500;
}
:global(.admin-user-referrals-dialog) {
width: min(760px, calc(100vw - 28px));
max-height: min(760px, calc(100dvh - 28px));
}
.admin-user-referrals-body {
display: grid;
gap: 12px;
min-width: 0;
}
:global(.admin-user-referrals-table-wrap) {
min-height: 120px;
}
.admin-referral-user-cell {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-referral-user-cell strong {
color: var(--admin-text);
font-weight: 650;
word-break: break-word;
}
.admin-referral-user-cell small {
color: var(--admin-muted);
font-size: 12px;
word-break: break-word;
}
.admin-referral-user-actions {
text-align: right;
}
@media (max-width: 560px) {
.admin-user-ref-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 8px;
}
.admin-user-ref-row > span {
grid-column: 1 / -1;
}
}
:global(.admin-avatar-dialog) {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -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,
};
}
+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"}` };
}