feat(invites): страница приглашения со splash и переопределением

- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
  сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
  создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
  карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
  возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
  присоединение, недействительная ссылка)
This commit is contained in:
2026-09-21 19:25:06 +03:00
parent ed98c78c9e
commit 566e1cf981
14 changed files with 701 additions and 24 deletions
+205
View File
@@ -0,0 +1,205 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router';
import { acceptInvite, fetchInvite, inviteQueryKey } from '@/api/invites';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { Avatar } from '@/components/ui/Avatar';
import { AnimatedImage } from '@/components/ui/AnimatedImage';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { fileContentUrl } from '@/api/files';
import { roleColorHex } from '@/lib/format';
import { isUnauthorized } from '@/lib/http';
import { useCurrentUser } from '@/lib/hooks';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useSessionStore } from '@/stores/session';
/**
* Страница приглашения `/invite/{code}` (AGENT.md 7.9, критерий Фазы 4):
* показывает splash сервера или переопределение приглашения, карточку сервера,
* кто позвал, и присоединяет одним нажатием. Доступна без входа — гость видит
* предпросмотр, а кнопка ведёт на вход с возвратом на эту же страницу.
*/
export default function InvitePage() {
const { t } = useTranslation();
const params = useParams<{ code?: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const describeError = useApiErrorMessage();
const currentUser = useCurrentUser();
const guilds = useSessionStore((state) => state.guilds);
const [joinError, setJoinError] = useState<unknown>(null);
const code = params.code ?? '';
// Гость (401) видит карточку приглашения: сервер отдаёт её без сессии.
const guest = currentUser.isError && isUnauthorized(currentUser.error);
const preview = useQuery({
queryKey: inviteQueryKey(code),
queryFn: ({ signal }) => fetchInvite(code, signal),
enabled: code !== '' && (currentUser.isSuccess || guest),
retry: 0,
});
const join = useMutation({
mutationFn: () => acceptInvite(code),
onSuccess: (guildId) => {
setJoinError(null);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guildId}`);
},
onError: (cause: unknown) => setJoinError(cause),
});
const guild = preview.data?.guild;
const memberGuild = useMemo(
() => (guild === undefined ? undefined : guilds.find((item) => item.id === guild.id)),
[guild, guilds],
);
// Фон страницы: переопределение приглашения → splash → баннер сервера.
const backgroundFileId =
preview.data?.background_file_id ?? guild?.splash_file_id ?? guild?.banner_file_id;
const accent = guild?.accent_color;
const accentHex = accent === undefined || accent === 0 ? null : roleColorHex(accent);
const inviterName =
guild?.inviter === undefined
? ''
: guild.inviter.display_name === ''
? guild.inviter.username
: guild.inviter.display_name;
return (
<main className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-bg px-4 py-10">
{backgroundFileId === undefined ? (
<span
aria-hidden="true"
className="absolute inset-0"
style={
accentHex === null
? undefined
: { background: `radial-gradient(circle at 50% 0%, ${accentHex}55, transparent 60%)` }
}
/>
) : (
<div aria-hidden="true" className="absolute inset-0">
<AnimatedImage
src={fileContentUrl(backgroundFileId)}
className="h-full w-full object-cover opacity-50"
/>
<span className="absolute inset-0 bg-bg/70" data-testid="invite-background" />
</div>
)}
<section
className="relative w-full max-w-lg rounded-[var(--radius-lg)] border border-border/60 bg-surface-1/95 p-6 shadow-[var(--shadow-3)] gl-pop-in"
data-testid="invite-page"
>
{code === '' ? (
<p className="text-sm text-fg-muted">{t('invites.notFound')}</p>
) : preview.isError || (currentUser.isError && !guest) ? (
<>
<h1 className="text-lg font-semibold">{t('invites.notFoundTitle')}</h1>
<p className="mt-2 text-sm text-fg-muted">{t('invites.notFound')}</p>
<p className="mt-3 text-sm text-danger">{describeError(preview.error)}</p>
<Button className="mt-4" onClick={() => void navigate('/login')}>
{t('invites.toLogin')}
</Button>
</>
) : preview.isPending || currentUser.isPending ? (
<SkeletonLines rows={4} />
) : guild === undefined ? (
<p className="text-sm text-fg-muted">{t('invites.notFound')}</p>
) : (
<>
<p className="text-xs uppercase tracking-wide text-fg-muted">
{t('invites.invitedTo')}
</p>
<div className="mt-3 flex items-center gap-3">
<Avatar
name={guild.name}
seed={guild.id}
fileId={guild.icon_file_id}
size="lg"
shape="square"
/>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold" data-testid="invite-guild-name">
{guild.name}
</h1>
<p className="text-sm text-fg-muted">
{t('invites.members', { count: guild.member_count })}
</p>
</div>
</div>
{guild.description === undefined || guild.description === '' ? null : (
<p className="mt-4 text-sm text-fg-muted">{guild.description}</p>
)}
{guild.inviter === undefined ? null : (
<div className="mt-4 flex items-center gap-2" data-testid="invite-inviter">
<Avatar
name={inviterName}
seed={guild.inviter.id}
fileId={guild.inviter.avatar_file_id}
size="sm"
/>
<span className="text-sm text-fg-muted">
{t('invites.inviter', { name: inviterName })}
</span>
</div>
)}
{currentUser.data === undefined || guest ? (
// Гость видит предпросмотр: вход вернёт его на эту же страницу.
<div className="mt-6 flex flex-col gap-2">
<p className="text-sm text-fg-muted">{t('invites.loginHint')}</p>
<div className="flex gap-2">
<Button
data-testid="invite-login"
onClick={() => void navigate(`/login?next=/invite/${encodeURIComponent(code)}`)}
>
{t('invites.login')}
</Button>
<Button
variant="ghost"
onClick={() =>
void navigate(`/register?next=/invite/${encodeURIComponent(code)}`)
}
>
{t('invites.register')}
</Button>
</div>
</div>
) : memberGuild !== undefined || guild.is_member ? (
<Button
className="mt-6"
data-testid="invite-open"
onClick={() => void navigate(`/app/${guild.id}`)}
>
{t('invites.open')}
</Button>
) : (
<Button
className="mt-6"
data-testid="invite-accept"
disabled={join.isPending}
onClick={() => join.mutate()}
>
{t(join.isPending ? 'invites.joining' : 'invites.accept')}
</Button>
)}
{joinError === null ? null : <ErrorNotice className="mt-3" error={joinError} />}
</>
)}
</section>
</main>
);
}
+7 -2
View File
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigate } from 'react-router';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { login } from '@/api/auth';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
@@ -30,8 +30,13 @@ export default function LoginPage() {
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [needsTotp, setNeedsTotp] = useState(false);
const [searchParams] = useSearchParams();
const from = (location.state as LocationState | null)?.from ?? '/app';
// Возврат после входа: `?next=` (страница приглашения) важнее состояния
// перехода из AuthGuard, но только для внутренних путей.
const next = searchParams.get('next');
const stateFrom = (location.state as LocationState | null)?.from ?? null;
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app');
const submit = useMutation({
mutationFn: () => {
+1
View File
@@ -9,6 +9,7 @@ export const LoginPage = lazy(() => import('@/pages/LoginPage'));
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
export const InvitePage = lazy(() => import('@/pages/InvitePage'));
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
export const FriendsPage = lazy(() => import('@/pages/app/FriendsPage'));