Files
IdP/apps/frontend/components/family/family-invite-dialog.tsx
2026-06-25 23:48:57 +03:00

273 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Bot, Loader2 } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
FamilyGroup,
FamilyInviteCandidate,
ManagedBot,
addBotFatherToFamily,
addBotToFamily,
fetchMyBots,
getApiErrorMessage,
searchFamilyInviteUsers,
sendFamilyInvite
} from '@/lib/api';
export function FamilyInviteDialog({
groupId,
group,
open,
onOpenChange,
onInvited
}: {
groupId: string;
group?: FamilyGroup | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onInvited?: () => void;
}) {
const { token } = useAuth();
const { showToast } = useToast();
const [inviteQuery, setInviteQuery] = useState('');
const [inviteResults, setInviteResults] = useState<FamilyInviteCandidate[]>([]);
const [inviteSearching, setInviteSearching] = useState(false);
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
const [submitting, setSubmitting] = useState(false);
const [myBots, setMyBots] = useState<ManagedBot[]>([]);
const [myBotsLoading, setMyBotsLoading] = useState(false);
const [addingBotId, setAddingBotId] = useState<string | null>(null);
const familyBotUsernames = useMemo(
() => new Set((group?.members ?? []).map((member) => member.botUsername).filter(Boolean) as string[]),
[group?.members]
);
const availableMyBots = useMemo(
() => myBots.filter((bot) => !familyBotUsernames.has(bot.username)),
[familyBotUsernames, myBots]
);
useEffect(() => {
if (!open) {
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
setMyBots([]);
return;
}
if (!token) return;
setMyBotsLoading(true);
fetchMyBots(token)
.then((response) => setMyBots(response.bots ?? []))
.catch(() => setMyBots([]))
.finally(() => setMyBotsLoading(false));
}, [open, token]);
useEffect(() => {
if (!open) return;
if (!token || inviteQuery.trim().length < 2) {
setInviteResults([]);
return;
}
const timer = window.setTimeout(() => {
setInviteSearching(true);
searchFamilyInviteUsers(groupId, inviteQuery.trim(), token)
.then((response) => setInviteResults(response.users ?? []))
.catch(() => setInviteResults([]))
.finally(() => setInviteSearching(false));
}, 300);
return () => window.clearTimeout(timer);
}, [groupId, inviteQuery, open, token]);
async function addBotById(botId: string, displayName: string) {
if (!token) return;
setAddingBotId(botId);
try {
await addBotToFamily(groupId, botId, token);
showToast(`${displayName} добавлен в семью`);
onOpenChange(false);
onInvited?.();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось добавить бота') ?? 'Ошибка');
} finally {
setAddingBotId(null);
}
}
async function submitInvite() {
if (!token || !selectedInviteUser) return;
setSubmitting(true);
try {
if (selectedInviteUser.isBot) {
if (selectedInviteUser.botId) {
await addBotToFamily(groupId, selectedInviteUser.botId, token);
showToast(`${selectedInviteUser.displayName} добавлен в семью`);
} else {
await addBotFatherToFamily(groupId, token);
showToast('BotFather добавлен в семью');
}
} else {
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
showToast('Приглашение отправлено');
}
onOpenChange(false);
onInvited?.();
} catch (error) {
showToast(
getApiErrorMessage(
error,
selectedInviteUser.isBot ? 'Не удалось добавить бота' : 'Не удалось отправить приглашение'
) ?? 'Ошибка'
);
} finally {
setSubmitting(false);
}
}
function botCandidateLabel(candidate: FamilyInviteCandidate) {
if (!candidate.isBot) {
return candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов';
}
const handle = `@${candidate.botUsername ?? 'bot'}`;
if (candidate.ownerDisplayName) {
return `${handle} · владелец: ${candidate.ownerDisplayName}`;
}
if (candidate.botUsername === 'BotFather_bot') {
return `${handle} — создавайте ботов через чат`;
}
return handle;
}
function submitButtonLabel() {
if (!selectedInviteUser?.isBot) return 'Отправить приглашение';
if (selectedInviteUser.botId) return 'Добавить бота';
return 'Добавить BotFather';
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Пригласить в семью</DialogTitle>
</DialogHeader>
{selectedInviteUser ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
<p className="font-medium">
{selectedInviteUser.displayName}
{selectedInviteUser.isBot ? ' 🤖' : ''}
</p>
<p className="text-sm text-[#667085]">{botCandidateLabel(selectedInviteUser)}</p>
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
Выбрать другого
</button>
</div>
) : (
<>
<Input
placeholder="ФИО, почта, телефон, логин или @бот"
value={inviteQuery}
onChange={(event) => setInviteQuery(event.target.value)}
/>
{inviteQuery.trim().length < 2 ? (
<div className="mt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-[#667085]">Мои боты</p>
<div className="max-h-40 overflow-y-auto rounded-2xl border border-[#eceef4]">
{myBotsLoading ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка ботов...
</div>
) : availableMyBots.length ? (
availableMyBots.map((bot) => (
<button
key={bot.id}
type="button"
disabled={addingBotId === bot.id}
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd] disabled:opacity-60"
onClick={() => void addBotById(bot.id, bot.name)}
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
{addingBotId === bot.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bot className="h-4 w-4" />}
</div>
<div className="min-w-0">
<p className="truncate font-medium">{bot.name}</p>
<p className="truncate text-sm text-[#667085]">@{bot.username}</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">
{myBots.length ? 'Все ваши боты уже в семье' : 'У вас пока нет ботов. Создайте через BotFather.'}
</p>
)}
</div>
<p className="mt-2 text-xs text-[#667085]">Или найдите пользователя / бота другого владельца через поиск выше</p>
</div>
) : null}
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
{inviteSearching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Ищем...
</div>
) : inviteQuery.trim().length < 2 ? (
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска пользователей и ботов</p>
) : inviteResults.length ? (
inviteResults.map((candidate) => (
<button
key={`${candidate.id}-${candidate.botId ?? 'user'}`}
type="button"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
onClick={() => {
setSelectedInviteUser(candidate);
setInviteResults([]);
}}
>
{candidate.isBot ? (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
<Bot className="h-4 w-4" />
</div>
) : (
<AvatarWithPresence
userId={candidate.id}
displayName={candidate.displayName}
hasAvatar={candidate.hasAvatar}
token={token}
isVerified={candidate.isVerified}
verificationIcon={candidate.verificationIcon}
className="h-9 w-9"
/>
)}
<div className="min-w-0">
<p className="truncate font-medium">
{candidate.displayName}
{candidate.isBot ? ' 🤖' : ''}
</p>
<p className="truncate text-sm text-[#667085]">{botCandidateLabel(candidate)}</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">Ничего не найдено</p>
)}
</div>
</>
)}
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser || submitting} onClick={() => void submitInvite()}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : submitButtonLabel()}
</Button>
</DialogContent>
</Dialog>
);
}