Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 20:03:05 +00:00
parent ce0108cec3
commit 93ee695c0e
4 changed files with 466 additions and 0 deletions

View file

@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/hooks/use-auth";
import { invokeFunction } from "@/lib/invoke-function";
import { toast } from "sonner";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DisconnectTelegramDialog({ open, onOpenChange }: Props) {
const { user } = useAuth();
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
async function handleDisconnect() {
setLoading(true);
const { error } = await invokeFunction("disconnect-telegram");
setLoading(false);
if (error) {
toast.error(error.message ?? "Falha ao desconectar.");
return;
}
if (user) {
await queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] });
}
toast.success("Telegram desconectado.");
onOpenChange(false);
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Desconectar Telegram?</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza? Você precisará criar um novo bot no BotFather para reconectar.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDisconnect();
}}
disabled={loading}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Desconectando...
</>
) : (
"Desconectar"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { Link } from "@tanstack/react-router";
import { AlertCircle, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAgentInstance } from "@/hooks/use-agent-instance";
import { TelegramOnboardingWizard } from "./TelegramOnboardingWizard";
/**
* Banner sticky no topo do painel.
* - Suspended/error vermelho com link p/ faturamento
* - token revogado vermelho com link p/ Meu Agente
* - bot ainda não conectado amber com CTA para abrir o wizard
*/
export function TelegramConnectionBanner() {
const { data: agent } = useAgentInstance();
const [open, setOpen] = useState(false);
if (!agent) return null;
if (agent.status === "suspended" || agent.status === "error") {
return (
<div className="sticky top-16 z-30 -mx-4 sm:mx-0 sm:rounded-lg border-y sm:border border-destructive bg-destructive/10 px-4 py-3 text-sm">
<div className="flex items-start sm:items-center gap-3 flex-wrap">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="flex-1 text-destructive-foreground">
Seu agente está suspenso. Regularize sua assinatura em Faturamento.
</span>
<Button asChild size="sm" variant="destructive">
<Link to="/painel/faturamento">Ir para Faturamento</Link>
</Button>
</div>
</div>
);
}
if (agent.telegram_token_invalid) {
return (
<div className="sticky top-16 z-30 -mx-4 sm:mx-0 sm:rounded-lg border-y sm:border border-destructive bg-destructive/10 px-4 py-3 text-sm">
<div className="flex items-start sm:items-center gap-3 flex-wrap">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="flex-1 text-destructive-foreground">
Seu token Telegram foi revogado. Desconecte e reconecte o bot.
</span>
<Button asChild size="sm" variant="destructive">
<Link to="/painel/agente">Ir para Meu Agente</Link>
</Button>
</div>
</div>
);
}
if (agent.telegram_bot_username) return null;
return (
<>
<div className="sticky top-16 z-30 -mx-4 sm:mx-0 sm:rounded-lg border-y sm:border border-warning bg-warning/10 px-4 py-3 text-sm">
<div className="flex items-start sm:items-center gap-3 flex-wrap">
<AlertTriangle className="h-4 w-4 text-warning shrink-0" />
<span className="flex-1">
Conecte seu Telegram para começar a conversar com o Mika.
</span>
<Button size="sm" onClick={() => setOpen(true)}>
Conectar agora
</Button>
</div>
</div>
<TelegramOnboardingWizard open={open} onOpenChange={setOpen} />
</>
);
}

View file

@ -0,0 +1,212 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useProfile } from "@/hooks/use-profile";
import { useAgentInstance } from "@/hooks/use-agent-instance";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { suggestBotName, suggestBotUsername } from "@/lib/telegram-username";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { StepWelcome } from "./StepWelcome";
import { StepCreateBot } from "./StepCreateBot";
import { StepNaming } from "./StepNaming";
import { StepToken, type ValidatedBot } from "./StepToken";
import { StepConfiguring } from "./StepConfiguring";
import { StepWaiting } from "./StepWaiting";
const STORAGE_KEY = "mika-onboarding-last-step";
const TOTAL_STEPS = 6;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Step inicial (1-6). Se omitido, lê do localStorage ou começa em 1. */
initialStep?: number;
}
export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Props) {
const { data: profile } = useProfile();
const { data: agent } = useAgentInstance();
const { user } = useAuth();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [validated, setValidated] = useState<ValidatedBot | null>(null);
const suggestedName = useMemo(() => suggestBotName(profile?.full_name), [profile?.full_name]);
const suggestedUsername = useMemo(
() => suggestBotUsername(profile?.full_name),
[profile?.full_name],
);
// Inicializa step ao abrir
useEffect(() => {
if (!open) return;
if (initialStep) {
setStep(Math.min(Math.max(initialStep, 1), TOTAL_STEPS));
return;
}
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem(STORAGE_KEY);
const parsed = stored ? Number(stored) : NaN;
if (Number.isFinite(parsed) && parsed >= 1 && parsed <= TOTAL_STEPS) {
setStep(parsed);
return;
}
}
setStep(1);
}, [open, initialStep]);
// Persiste step ao mudar
useEffect(() => {
if (!open) return;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, String(step));
}
}, [step, open]);
function handleClose() {
onOpenChange(false);
}
async function handleFinish() {
if (!agent || !user) return handleClose();
const { error } = await supabase
.from("agent_instances")
.update({ telegram_onboarding_completed: true })
.eq("id", agent.id);
if (error) {
toast.error("Não foi possível salvar o status do onboarding.");
return;
}
if (typeof window !== "undefined") {
window.localStorage.removeItem(STORAGE_KEY);
}
await queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] });
toast.success("Onboarding concluído!");
onOpenChange(false);
}
// Após validar token, recarrega agent_instance (já tem bot_username persistido)
function handleValidated(bot: ValidatedBot) {
setValidated(bot);
if (user) {
queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] });
}
}
function handleConfigured() {
if (user) {
queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] });
}
setStep(6);
}
const botUsername = validated?.bot_username ?? agent?.telegram_bot_username ?? "";
const connectedAt = agent?.telegram_connected_at ?? null;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className={cn(
"fixed z-50 bg-background shadow-2xl",
// Mobile: full-screen
"inset-0 sm:inset-auto",
// Desktop: dialog grande centralizado
"sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]",
"sm:w-full sm:max-w-2xl sm:rounded-2xl sm:max-h-[90vh] sm:overflow-y-auto",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
)}
>
<DialogPrimitive.Title className="sr-only">
Conectar Telegram ao Mika
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Wizard guiado de 6 passos para conectar seu agente Mika ao Telegram.
</DialogPrimitive.Description>
{/* Header com progress + close */}
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border">
<div className="flex items-center justify-between px-4 sm:px-6 py-3">
<span className="text-xs font-medium text-muted-foreground">
Passo {step} de {TOTAL_STEPS}
</span>
<button
onClick={handleClose}
aria-label="Fechar"
className="h-8 w-8 rounded-md hover:bg-muted flex items-center justify-center transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="h-1 w-full bg-muted overflow-hidden">
<motion.div
className="h-full bg-primary"
initial={false}
animate={{ width: `${(step / TOTAL_STEPS) * 100}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</div>
</div>
{/* Conteúdo dos steps */}
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -40 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{step === 1 && <StepWelcome onNext={() => setStep(2)} />}
{step === 2 && <StepCreateBot onNext={() => setStep(3)} />}
{step === 3 && (
<StepNaming
suggestedName={suggestedName}
suggestedUsername={suggestedUsername}
onNext={() => setStep(4)}
/>
)}
{step === 4 && (
<StepToken
validated={validated}
onValidated={handleValidated}
onNext={() => setStep(5)}
/>
)}
{step === 5 && (
<StepConfiguring
onConfigured={handleConfigured}
onSkip={() => setStep(6)}
/>
)}
{step === 6 && agent && botUsername && (
<StepWaiting
agentInstanceId={agent.id}
botUsername={botUsername}
connectedAt={connectedAt}
onFinish={handleFinish}
/>
)}
{step === 6 && (!agent || !botUsername) && (
<div className="px-6 py-12 text-center text-sm text-muted-foreground">
Conecte o bot primeiro para receber a primeira mensagem.
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { ExternalLink, AlertCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAgentInstance } from "@/hooks/use-agent-instance";
import { TelegramIcon } from "./TelegramIcon";
import { TelegramOnboardingWizard } from "./TelegramOnboardingWizard";
import { DisconnectTelegramDialog } from "./DisconnectTelegramDialog";
function formatPtBR(date: string | null): string | null {
if (!date) return null;
try {
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
} catch {
return null;
}
}
export function TelegramStatusCard() {
const { data: agent } = useAgentInstance();
const [wizardOpen, setWizardOpen] = useState(false);
const [disconnectOpen, setDisconnectOpen] = useState(false);
const connected = !!agent?.telegram_bot_username;
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
<div className="flex items-center gap-3 mb-4">
<TelegramIcon className="h-5 w-5 text-primary" />
<h3 className="font-semibold">Telegram</h3>
{connected && (
<Badge variant="success" className="ml-auto">
Conectado
</Badge>
)}
</div>
{!connected ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<TelegramIcon className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Telegram não conectado</p>
<p className="text-sm text-muted-foreground">
Conecte seu bot para começar a conversar com o Mika.
</p>
</div>
</div>
<Button
onClick={() => setWizardOpen(true)}
disabled={agent?.status === "suspended" || agent?.status === "error"}
>
Conectar agora
</Button>
</div>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="font-mono text-sm">@{agent!.telegram_bot_username}</p>
{agent!.telegram_connected_at && (
<p className="mt-1 text-xs text-muted-foreground">
Conectado em {formatPtBR(agent!.telegram_connected_at)}
</p>
)}
</div>
{agent!.telegram_token_invalid && (
<div className="flex items-start gap-2 rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-destructive-foreground">
Token revogado desconecte e reconecte.
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<a
href={`https://t.me/${agent!.telegram_bot_username}`}
target="_blank"
rel="noopener noreferrer"
>
Abrir bot <ExternalLink className="ml-2 h-3 w-3" />
</a>
</Button>
<Button variant="ghost" size="sm" onClick={() => setDisconnectOpen(true)}>
Desconectar
</Button>
</div>
</div>
)}
<TelegramOnboardingWizard open={wizardOpen} onOpenChange={setWizardOpen} />
<DisconnectTelegramDialog open={disconnectOpen} onOpenChange={setDisconnectOpen} />
</div>
);
}