250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
'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,
|
||
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 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 submitInvite() {
|
||
if (!token || !selectedInviteUser) return;
|
||
setSubmitting(true);
|
||
try {
|
||
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
|
||
if (selectedInviteUser.isBot) {
|
||
showToast(`${selectedInviteUser.displayName} добавлен в семью`);
|
||
} else {
|
||
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} — создавайте ботов через чат`;
|
||
}
|
||
if (candidate.botUsername === 'GetMyIdBot_bot') {
|
||
return `${handle} — получите user_id и chat_id`;
|
||
}
|
||
return handle;
|
||
}
|
||
|
||
function submitButtonLabel() {
|
||
if (!selectedInviteUser?.isBot) return 'Отправить приглашение';
|
||
return 'Пригласить бота';
|
||
}
|
||
|
||
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"
|
||
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={() => setInviteQuery(`@${bot.username}`)}
|
||
>
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||
<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>
|
||
);
|
||
}
|