Files
IdP/apps/frontend/app/mini-apps/bot-manage/content.tsx
2026-06-26 10:49:34 +03:00

384 lines
15 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Copy, ImageIcon, LayoutGrid, Loader2, RefreshCw, TextQuote, UserCircle2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
fetchBot,
getApiErrorMessage,
revokeManagedBotToken,
updateManagedBot,
updateManagedBotProfile
} from '@/lib/api';
import { useEmbeddedAuthFallback } from '@/lib/embedded-auth-bridge';
import { parseComposerMenuButtonJson } from '@/lib/bot-menu-button';
function FieldTextarea({
label,
hint,
value,
onChange,
maxLength,
rows = 3
}: {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
maxLength?: number;
rows?: number;
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
{hint ? <p className="text-xs text-[#667085]">{hint}</p> : null}
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
maxLength={maxLength}
rows={rows}
className="w-full resize-y rounded-xl border border-[#dce3ec] bg-white px-3 py-2 text-sm outline-none transition focus:border-[#3390ec] focus:ring-2 focus:ring-[#3390ec]/15"
/>
{maxLength ? <p className="text-right text-xs text-[#a8adbc]">{value.length}/{maxLength}</p> : null}
</div>
);
}
function SectionCard({
icon: Icon,
title,
description,
children
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-4 rounded-[20px] border border-[#eceef4] bg-[#fafbfc] p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#3390ec]/10 text-[#3390ec]">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{title}</h2>
<p className="text-sm text-[#667085]">{description}</p>
</div>
</div>
{children}
</section>
);
}
export function BotManageMiniAppContent() {
const searchParams = useSearchParams();
const botId = searchParams.get('botId') ?? '';
const { token, isPinLocked, isLoading } = useAuth();
const embeddedToken = useEmbeddedAuthFallback();
const effectiveToken = token ?? embeddedToken;
const { showToast } = useToast();
const [loading, setLoading] = useState(true);
const [savingBasic, setSavingBasic] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [revoking, setRevoking] = useState(false);
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [description, setDescription] = useState('');
const [aboutText, setAboutText] = useState('');
const [botPicUrl, setBotPicUrl] = useState('');
const [menuButtonUrl, setMenuButtonUrl] = useState('');
const [menuButtonText, setMenuButtonText] = useState('App');
const [tokenPrefix, setTokenPrefix] = useState('');
const [newToken, setNewToken] = useState<string | null>(null);
const canUse = Boolean(effectiveToken && !isPinLocked && botId);
const loadBot = useCallback(async () => {
if (!canUse || !effectiveToken) return;
setLoading(true);
try {
const bot = await fetchBot(botId, effectiveToken);
setName(bot.name);
setUsername(bot.username);
setDescription(bot.description ?? '');
setAboutText(bot.aboutText ?? '');
setBotPicUrl(bot.botPicUrl ?? '');
setTokenPrefix(bot.tokenPrefix ?? '');
const menuButton = parseComposerMenuButtonJson(bot.menuButtonJson);
if (menuButton) {
setMenuButtonUrl(menuButton.web_app.url);
setMenuButtonText(menuButton.text);
} else {
setMenuButtonUrl('');
setMenuButtonText('App');
}
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить бота') ?? 'Ошибка');
} finally {
setLoading(false);
}
}, [botId, canUse, effectiveToken, showToast]);
useEffect(() => {
void loadBot();
}, [loadBot]);
const usernameWithoutSuffix = useMemo(() => username.replace(/_bot$/i, ''), [username]);
async function handleSaveBasic() {
if (!canUse || !effectiveToken) return;
setSavingBasic(true);
try {
const bot = await updateManagedBot(
botId,
{
name: name.trim(),
username: usernameWithoutSuffix.trim()
},
effectiveToken
);
setName(bot.name);
setUsername(bot.username);
showToast('Основные настройки сохранены');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось сохранить настройки') ?? 'Ошибка');
} finally {
setSavingBasic(false);
}
}
async function handleSaveProfile() {
if (!canUse || !effectiveToken) return;
setSavingProfile(true);
try {
const bot = await updateManagedBotProfile(
botId,
{
description: description.trim(),
aboutText: aboutText.trim(),
botPicUrl: botPicUrl.trim(),
menuButtonUrl: menuButtonUrl.trim(),
menuButtonText: menuButtonText.trim() || 'App'
},
effectiveToken
);
setDescription(bot.description ?? '');
setAboutText(bot.aboutText ?? '');
setBotPicUrl(bot.botPicUrl ?? '');
const menuButton = parseComposerMenuButtonJson(bot.menuButtonJson);
if (menuButton) {
setMenuButtonUrl(menuButton.web_app.url);
setMenuButtonText(menuButton.text);
}
showToast('Профиль бота обновлён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось обновить профиль') ?? 'Ошибка');
} finally {
setSavingProfile(false);
}
}
async function handleRevokeToken() {
if (!canUse || !effectiveToken) return;
setRevoking(true);
try {
const response = await revokeManagedBotToken(botId, effectiveToken);
setNewToken(response.token ?? null);
setTokenPrefix(response.bot?.tokenPrefix ?? tokenPrefix);
showToast('Токен перевыпущен');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось перевыпустить токен') ?? 'Ошибка');
} finally {
setRevoking(false);
}
}
async function copyToken() {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
showToast('Токен скопирован');
}
if (!botId) {
return <div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Не указан botId</div>;
}
if (isLoading && !effectiveToken) {
return (
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка...
</div>
);
}
if (!canUse) {
return (
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
Войдите в Lendry ID, чтобы управлять ботом
</div>
);
}
return (
<div className="min-h-screen bg-[#f4f5f8] p-4 pb-8">
<div className="mx-auto max-w-lg space-y-4">
<div className="rounded-[24px] bg-white p-5 shadow-sm">
<h1 className="text-lg font-semibold">Настройки бота</h1>
<p className="text-sm text-[#667085]">Интерактивное управление профилем аналог команд BotFather</p>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 rounded-[24px] bg-white p-8 text-sm text-[#667085] shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : (
<>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<SectionCard icon={UserCircle2} title="Основное" description="Название и username бота (/newbot)">
<div className="space-y-2">
<label className="text-sm font-medium">Название</label>
<Input value={name} onChange={(event) => setName(event.target.value)} className="rounded-xl" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<div className="flex items-center gap-2">
<span className="text-sm text-[#667085]">@</span>
<Input
value={usernameWithoutSuffix}
onChange={(event) => setUsername(`${event.target.value.replace(/_bot$/i, '')}_bot`)}
className="rounded-xl"
/>
</div>
</div>
<Button className="w-full rounded-xl" disabled={savingBasic} onClick={() => void handleSaveBasic()}>
{savingBasic ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Сохранить основное
</Button>
</SectionCard>
</div>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<SectionCard
icon={TextQuote}
title="Профиль"
description="Описание и краткий текст — /setdescription и /setabouttext"
>
<FieldTextarea
label="Описание (/setdescription)"
hint="Показывается пользователю перед началом диалога"
value={description}
onChange={setDescription}
maxLength={512}
rows={4}
/>
<FieldTextarea
label="О боте (/setabouttext)"
hint="Краткое описание на странице профиля бота"
value={aboutText}
onChange={setAboutText}
maxLength={120}
rows={2}
/>
</SectionCard>
<SectionCard icon={ImageIcon} title="Аватар" description="URL изображения — /setuserpic">
<div className="space-y-3">
<Input
value={botPicUrl}
onChange={(event) => setBotPicUrl(event.target.value)}
placeholder="https://cdn.example.com/avatar.png"
className="rounded-xl"
/>
{botPicUrl.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={botPicUrl.trim()}
alt="Аватар бота"
className="h-16 w-16 rounded-full border border-[#dce3ec] object-cover"
onError={(event) => {
event.currentTarget.style.display = 'none';
}}
/>
) : null}
</div>
</SectionCard>
<SectionCard
icon={LayoutGrid}
title="Кнопка меню"
description="Глобальная Web App-кнопка слева от поля ввода — /setmenubutton"
>
<div className="space-y-2">
<label className="text-sm font-medium">URL Mini App</label>
<Input
value={menuButtonUrl}
onChange={(event) => setMenuButtonUrl(event.target.value)}
placeholder="https://app.example.com/webapp"
className="rounded-xl"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Текст кнопки</label>
<Input
value={menuButtonText}
onChange={(event) => setMenuButtonText(event.target.value)}
placeholder="App"
className="rounded-xl"
/>
</div>
<p className="text-xs text-[#667085]">Оставьте URL пустым, чтобы сбросить кнопку меню.</p>
</SectionCard>
<Button className="w-full rounded-xl" disabled={savingProfile} onClick={() => void handleSaveProfile()}>
{savingProfile ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Сохранить профиль
</Button>
</div>
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
<h2 className="font-semibold">Токен Bot API</h2>
<div className="rounded-2xl bg-[#f4f5f8] px-3 py-3 text-sm text-[#667085]">
Текущий префикс: <span className="font-mono text-[#1f2430]">{tokenPrefix || '—'}</span>
</div>
{newToken ? (
<div className="space-y-2 rounded-2xl border border-[#dce3ec] p-3">
<p className="text-sm font-medium text-red-600">Сохраните новый токен он больше не будет показан</p>
<div className="flex items-center gap-2">
<Input value={newToken} readOnly className="rounded-xl font-mono text-xs" />
<Button type="button" variant="secondary" className="rounded-xl" onClick={() => void copyToken()}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
<Button variant="secondary" className="w-full rounded-xl" disabled={revoking} onClick={() => void handleRevokeToken()}>
{revoking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
Перевыпустить токен
</Button>
</div>
<div className="rounded-[20px] border border-dashed border-[#dce3ec] bg-white/70 p-4 text-xs leading-relaxed text-[#667085]">
<p className="mb-2 font-medium text-[#1f2430]">Эквивалент команд BotFather</p>
<ul className="space-y-1">
<li>/setdescription поле «Описание»</li>
<li>/setabouttext поле «О боте»</li>
<li>/setuserpic URL аватара</li>
<li>/setmenubutton URL и текст кнопки меню</li>
</ul>
</div>
</>
)}
</div>
</div>
);
}