Criou wizard de 6 passos

X-Lovable-Edit-ID: edt-52bc28d9-4d6e-4f98-8e6e-c2882f77051c
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 20:04:55 +00:00
commit 488e3c0e8c
18 changed files with 1167 additions and 31 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,67 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { invokeFunction } from "@/lib/invoke-function";
interface Props {
onConfigured: () => void;
onSkip: () => void;
}
export function StepConfiguring({ onConfigured, onSkip }: Props) {
const [state, setState] = useState<"loading" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState("loading");
setError(null);
(async () => {
const { error: err } = await invokeFunction("configure-telegram-webhook");
if (cancelled) return;
if (err) {
setError(err.message);
setState("error");
return;
}
window.setTimeout(() => {
if (!cancelled) onConfigured();
}, 800);
})();
return () => {
cancelled = true;
};
}, [attempt, onConfigured]);
return (
<div className="px-6 py-12 flex flex-col items-center justify-center min-h-[320px]">
{state === "loading" && (
<>
<Loader2 className="h-10 w-10 text-primary animate-spin" />
<p className="mt-4 text-sm text-muted-foreground">
Configurando recebimento de mensagens...
</p>
</>
)}
{state === "error" && (
<div className="w-full max-w-md rounded-lg border border-destructive bg-destructive/5 p-6 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-destructive" />
<h3 className="mt-3 font-semibold">Falha ao configurar webhook</h3>
{error && <p className="mt-2 text-sm text-muted-foreground">{error}</p>}
<div className="mt-5 flex flex-col sm:flex-row gap-2 justify-center">
<Button onClick={() => setAttempt((a) => a + 1)}>Tentar novamente</Button>
<Button variant="ghost" onClick={onSkip}>
Pular (configurar depois)
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
const STEPS = [
'Clique em "Abrir BotFather" acima',
"Envie /newbot",
"Siga as instruções do BotFather",
];
export function StepCreateBot({ onNext }: { onNext: () => void }) {
return (
<div className="px-6 py-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold tracking-tight">Crie seu bot no BotFather</h2>
<p className="mt-2 text-muted-foreground">
O BotFather é o bot oficial do Telegram para criar outros bots. É gratuito e leva 1 minuto.
</p>
<Button asChild size="lg" className="mt-6 w-full sm:w-auto">
<a href="https://t.me/BotFather?start" target="_blank" rel="noopener noreferrer">
Abrir BotFather <ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
<ol className="mt-8 space-y-3">
{STEPS.map((step, i) => (
<li
key={step}
className="flex items-start gap-3 rounded-lg border border-border bg-card p-4"
>
<span className="h-7 w-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-bold text-sm shrink-0">
{i + 1}
</span>
<span className="text-sm pt-1">{step}</span>
</li>
))}
</ol>
<div className="mt-8 flex justify-end">
<Button variant="secondary" onClick={onNext}>
criei meu bot
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
interface Props {
suggestedName: string;
suggestedUsername: string;
onNext: () => void;
}
export function StepNaming({ suggestedName, suggestedUsername, onNext }: Props) {
return (
<div className="px-6 py-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold tracking-tight">Escolha os nomes</h2>
<p className="mt-2 text-muted-foreground">
O BotFather vai pedir dois nomes. Use nossas sugestões se quiser.
</p>
<div className="mt-6 space-y-4">
<CopyField
label="Nome do bot"
value={suggestedName}
help="Esse é o nome que aparece no topo da conversa."
/>
<CopyField
label="Username (termina em bot)"
value={suggestedUsername}
help="Se o username já estiver em uso, tente adicionar um número no final."
/>
</div>
<div className="mt-8 flex justify-end">
<Button onClick={onNext}>Próximo: colar o token</Button>
</div>
</div>
);
}
function CopyField({ label, value, help }: { label: string; value: string; help?: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
toast.success("Copiado para a área de transferência");
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Não foi possível copiar");
}
}
return (
<div className="rounded-lg border border-border bg-card p-4">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</label>
<div className="mt-2 flex gap-2">
<Input value={value} readOnly className="font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={copy} aria-label="Copiar">
{copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
{help && <p className="mt-2 text-xs text-muted-foreground">{help}</p>}
</div>
);
}

View file

@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Loader2, Check, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { invokeFunction } from "@/lib/invoke-function";
import { cn } from "@/lib/utils";
export interface ValidatedBot {
bot_username: string;
bot_name: string;
bot_id: number;
}
interface Props {
onValidated: (bot: ValidatedBot) => void;
onNext: () => void;
validated: ValidatedBot | null;
}
type State = "idle" | "validating" | "success" | "error";
export function StepToken({ onValidated, onNext, validated }: Props) {
const [token, setToken] = useState("");
const [state, setState] = useState<State>(validated ? "success" : "idle");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
async function handleValidate() {
setState("validating");
setErrorMsg(null);
const { data, error } = await invokeFunction<ValidatedBot>("validate-telegram-bot", {
token: token.trim(),
});
if (error || !data?.bot_username) {
setState("error");
setErrorMsg(error?.message ?? "Não foi possível validar o token.");
return;
}
onValidated(data);
setState("success");
}
return (
<div className="px-6 py-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold tracking-tight">Cole o token do seu bot</h2>
<p className="mt-2 text-muted-foreground">
No final da conversa, o BotFather te enviou um token parecido com{" "}
<span className="font-mono text-xs">8234567890:ABC-DEF...</span>. Cole ele abaixo.
</p>
<div
className={cn(
"mt-6 rounded-lg border bg-card p-4 transition-colors",
state === "success" && "border-success bg-success/5",
state === "error" && "border-destructive bg-destructive/5",
)}
>
<Input
type="text"
placeholder="123456789:ABC-DEF..."
value={token}
onChange={(e) => {
setToken(e.target.value);
if (state === "error") setState("idle");
}}
disabled={state === "validating" || state === "success"}
className="font-mono text-sm"
/>
<Button
className="mt-3 w-full"
onClick={handleValidate}
disabled={!token.trim() || state === "validating" || state === "success"}
>
{state === "validating" && (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Validando...
</>
)}
{state === "success" && (
<>
<Check className="mr-2 h-4 w-4" /> Bot validado!
</>
)}
{(state === "idle" || state === "error") && "Validar conexão"}
</Button>
{state === "error" && errorMsg && (
<div className="mt-3 flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p>{errorMsg}</p>
</div>
)}
{state === "success" && validated && (
<div className="mt-4 rounded-md bg-success/10 border border-success/30 p-3">
<p className="text-sm font-semibold text-success-foreground">{validated.bot_name}</p>
<p className="text-xs text-muted-foreground">@{validated.bot_username}</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<Button onClick={onNext} disabled={state !== "success"}>
Próximo
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,119 @@
"use client";
import { useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageCircle, ExternalLink, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTelegramFirstMessage } from "@/hooks/use-telegram-first-message";
interface Props {
agentInstanceId: string;
botUsername: string;
connectedAt: string | null;
onFinish: () => void;
}
const EMOJIS = ["🎉", "✨", "⭐", "🚀", "🎉", "✨", "⭐", "🚀", "🎉", "✨", "⭐", "🚀"];
export function StepWaiting({ agentInstanceId, botUsername, connectedAt, onFinish }: Props) {
const { received } = useTelegramFirstMessage({
agentInstanceId,
since: connectedAt,
enabled: true,
});
const positions = useMemo(
() =>
EMOJIS.map(() => ({
x: (Math.random() - 0.5) * 320,
y: (Math.random() - 0.5) * 240,
rotate: (Math.random() - 0.5) * 80,
})),
[],
);
return (
<div className="px-6 py-8 max-w-xl mx-auto">
<AnimatePresence mode="wait">
{!received ? (
<motion.div
key="waiting"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight">
Mande qualquer mensagem para seu Mika agora
</h2>
<p className="mt-2 text-muted-foreground">
Estou aguardando sua primeira mensagem. Pode ser "oi". 😊
</p>
<div className="mt-8 rounded-2xl border-2 border-primary bg-primary/5 p-8 animate-pulse">
<MessageCircle className="mx-auto h-12 w-12 text-primary" />
<p className="mt-4 text-sm font-medium">Aguardando sua primeira mensagem...</p>
</div>
<Button asChild size="lg" className="mt-6">
<a
href={`https://t.me/${botUsername}`}
target="_blank"
rel="noopener noreferrer"
>
Abrir meu bot no Telegram <ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</motion.div>
) : (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="relative text-center"
>
{/* emojis flutuantes */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{EMOJIS.map((emoji, i) => (
<motion.span
key={i}
initial={{ opacity: 0, scale: 0.4, x: 0, y: 0, rotate: 0 }}
animate={{
opacity: [0, 1, 0],
scale: [0.4, 1.2, 0.8],
x: positions[i].x,
y: positions[i].y,
rotate: positions[i].rotate,
}}
transition={{
duration: 1.5,
delay: i * 0.05,
ease: "easeOut",
}}
className="absolute text-3xl"
>
{emoji}
</motion.span>
))}
</div>
<div className="relative">
<div className="mx-auto h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircle2 className="h-9 w-9 text-success" />
</div>
<h2 className="mt-4 text-2xl font-bold tracking-tight">🎉 Mika conectado!</h2>
<p className="mt-2 text-muted-foreground">
Você recebeu a primeira resposta do seu agente. Ele ainda está em modo de teste,
mas em breve vai responder de verdade.
</p>
<Button size="lg" className="mt-8 min-w-56" onClick={onFinish}>
Finalizar onboarding
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import { Sparkles, Shield, Smartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TelegramIcon } from "./TelegramIcon";
export function StepWelcome({ onNext }: { onNext: () => void }) {
return (
<div className="flex flex-col items-center text-center px-6 py-8">
<div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
<TelegramIcon className="h-10 w-10 text-primary" />
</div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Vamos conectar seu Telegram em 30 segundos
</h2>
<p className="mt-3 text-muted-foreground max-w-md">
Seu agente Mika ficará disponível diretamente no Telegram onde você passa seu dia.
</p>
<ul className="mt-8 space-y-3 text-left max-w-sm w-full">
<Bullet icon={<Sparkles className="h-4 w-4" />}>
Criação gratuita via BotFather
</Bullet>
<Bullet icon={<Shield className="h-4 w-4" />}>
Nenhum dado sensível compartilhado conosco
</Bullet>
<Bullet icon={<Smartphone className="h-4 w-4" />}>
Funciona no celular, desktop e web
</Bullet>
</ul>
<Button size="lg" className="mt-8 min-w-48" onClick={onNext}>
Vamos começar
</Button>
</div>
);
}
function Bullet({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<li className="flex items-center gap-3">
<span className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
{icon}
</span>
<span className="text-sm text-foreground">{children}</span>
</li>
);
}

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,12 @@
"use client";
import { Send } from "lucide-react";
interface Props {
className?: string;
}
/** Ícone do Telegram (paper plane) — usa apenas tokens, herda cor via currentColor. */
export function TelegramIcon({ className }: Props) {
return <Send className={className} aria-hidden="true" />;
}

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>
);
}

View file

@ -7,8 +7,14 @@ import { useAuth } from "@/hooks/use-auth";
export interface AgentInstance { export interface AgentInstance {
id: string; id: string;
user_id: string; user_id: string;
uuid_tenant: string;
status: string; status: string;
telegram_bot_username: string | null; telegram_bot_username: string | null;
telegram_webhook_configured: boolean;
telegram_token_invalid: boolean;
telegram_first_message_received_at: string | null;
telegram_connected_at: string | null;
telegram_onboarding_completed: boolean;
created_at: string; created_at: string;
} }
@ -20,11 +26,13 @@ export function useAgentInstance() {
queryFn: async (): Promise<AgentInstance | null> => { queryFn: async (): Promise<AgentInstance | null> => {
const { data, error } = await supabase const { data, error } = await supabase
.from("agent_instances") .from("agent_instances")
.select("id, user_id, status, telegram_bot_username, created_at") .select(
"id, user_id, uuid_tenant, status, telegram_bot_username, telegram_webhook_configured, telegram_token_invalid, telegram_first_message_received_at, telegram_connected_at, telegram_onboarding_completed, created_at",
)
.eq("user_id", user!.id) .eq("user_id", user!.id)
.maybeSingle(); .maybeSingle();
if (error) throw error; if (error) throw error;
return data; return data as AgentInstance | null;
}, },
}); });
} }

View file

@ -0,0 +1,103 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
interface FirstMessage {
id: string;
created_at: string;
message_text: string | null;
message_type: string;
}
/**
* Aguarda a primeira mensagem incoming do Telegram para o agent_instance dado.
* - Subscribe via Realtime com filter explícito.
* - Fallback de polling a cada 5s caso o Realtime não conecte em 10s.
* - Considera apenas mensagens com created_at >= since (telegram_connected_at).
*/
export function useTelegramFirstMessage(opts: {
agentInstanceId: string | null | undefined;
since: string | null | undefined;
enabled: boolean;
}): { received: FirstMessage | null; reset: () => void } {
const { agentInstanceId, since, enabled } = opts;
const [received, setReceived] = useState<FirstMessage | null>(null);
const realtimeConnected = useRef(false);
const pollingRef = useRef<number | null>(null);
function reset() {
setReceived(null);
}
useEffect(() => {
if (!enabled || !agentInstanceId) return;
let cancelled = false;
realtimeConnected.current = false;
const channel = supabase
.channel(`telegram-messages-${agentInstanceId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "telegram_messages_log",
filter: `agent_instance_id=eq.${agentInstanceId}`,
},
(payload) => {
// deno-lint-ignore no-explicit-any
const row = payload.new as any;
if (row?.direction !== "incoming") return;
if (since && new Date(row.created_at) < new Date(since)) return;
if (cancelled) return;
setReceived({
id: row.id,
created_at: row.created_at,
message_text: row.message_text ?? null,
message_type: row.message_type ?? "text",
});
},
)
.subscribe((status) => {
if (status === "SUBSCRIBED") {
realtimeConnected.current = true;
}
});
// Fallback de polling: começa em 10s caso Realtime ainda não tenha conectado
const fallbackTimer = window.setTimeout(() => {
if (realtimeConnected.current || cancelled) return;
pollingRef.current = window.setInterval(async () => {
if (cancelled) return;
let q = supabase
.from("telegram_messages_log")
.select("id, created_at, message_text, message_type, direction")
.eq("agent_instance_id", agentInstanceId)
.eq("direction", "incoming")
.order("created_at", { ascending: false })
.limit(1);
if (since) q = q.gte("created_at", since);
const { data } = await q.maybeSingle();
if (data && !cancelled) {
setReceived({
id: data.id as string,
created_at: data.created_at as string,
message_text: (data.message_text as string | null) ?? null,
message_type: (data.message_type as string) ?? "text",
});
}
}, 5000);
}, 10_000);
return () => {
cancelled = true;
window.clearTimeout(fallbackTimer);
if (pollingRef.current) window.clearInterval(pollingRef.current);
supabase.removeChannel(channel);
};
}, [agentInstanceId, since, enabled]);
return { received, reset };
}

View file

@ -0,0 +1,50 @@
"use client";
import { supabase } from "@/integrations/supabase/client";
interface InvokeResult<T> {
data: T | null;
error: { message: string; status?: number } | null;
}
/**
* Wrapper para supabase.functions.invoke que normaliza erros de função
* (FunctionsHttpError vem com Response no .context que precisa ser lido).
*/
export async function invokeFunction<T = unknown>(
name: string,
body?: Record<string, unknown>,
): Promise<InvokeResult<T>> {
try {
const { data, error } = await supabase.functions.invoke<T>(name, {
body: body ?? {},
});
if (error) {
// Tenta extrair mensagem do response real
let msg = error.message ?? "Erro inesperado";
let status: number | undefined;
// deno-lint-ignore no-explicit-any
const ctx = (error as any).context as Response | undefined;
if (ctx && typeof ctx.json === "function") {
try {
status = ctx.status;
const parsed = await ctx.json();
if (parsed?.error) msg = parsed.error;
} catch {
// ignora
}
}
return { data: null, error: { message: msg, status } };
}
return { data: (data ?? null) as T | null, error: null };
} catch (err) {
return {
data: null,
error: {
message: err instanceof Error ? err.message : "Erro inesperado",
},
};
}
}

View file

@ -0,0 +1,25 @@
"use client";
/**
* Sanitiza um primeiro nome para uso em sugestões de username do Telegram.
* - remove acentos via NFD
* - remove caracteres não-alfanuméricos
* - lowercase
*/
export function sanitizeForUsername(name: string): string {
return (name || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]/g, "")
.toLowerCase();
}
export function suggestBotName(fullName: string | null | undefined): string {
const first = (fullName || "").trim().split(/\s+/)[0] || "Você";
return `Mika de ${first}`;
}
export function suggestBotUsername(fullName: string | null | undefined): string {
const first = sanitizeForUsername((fullName || "").trim().split(/\s+/)[0] || "voce");
return `mika_${first || "voce"}_bot`;
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Bot, Cpu, MessageSquare, Sparkles, BarChart3, Loader2 } from "lucide-react"; import { Bot, Cpu, BarChart3 } from "lucide-react";
import { useProfile } from "@/hooks/use-profile"; import { useProfile } from "@/hooks/use-profile";
import { useAgentInstance } from "@/hooks/use-agent-instance"; import { useAgentInstance } from "@/hooks/use-agent-instance";
import { useUserSkillLimits } from "@/hooks/use-user-skill-limits"; import { useUserSkillLimits } from "@/hooks/use-user-skill-limits";
@ -9,13 +9,8 @@ import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import { TelegramStatusCard } from "@/components/mika/telegram/TelegramStatusCard";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -121,26 +116,8 @@ function AgentePage() {
</Badge> </Badge>
</div> </div>
{/* Card 3: Canais conectados */} {/* Card 3: Telegram */}
<div className="rounded-xl border border-border bg-card p-6 shadow-soft"> <TelegramStatusCard />
<div className="flex items-center gap-3 mb-3">
<MessageSquare className="h-5 w-5 text-primary" />
<h3 className="font-semibold">Canais conectados</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Telegram</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" disabled>
Conectar
</Button>
</TooltipTrigger>
<TooltipContent>Disponível em breve</TooltipContent>
</Tooltip>
</div>
</div>
{/* Card 4: Estatísticas */} {/* Card 4: Estatísticas */}
<div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft"> <div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">

View file

@ -1,22 +1,50 @@
"use client"; "use client";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react"; import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { useSubscription } from "@/hooks/use-profile"; import { useSubscription } from "@/hooks/use-profile";
import { useProfile } from "@/hooks/use-profile"; import { useProfile } from "@/hooks/use-profile";
import { useAgentInstance } from "@/hooks/use-agent-instance";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SubscriptionBanner } from "@/components/mika/SubscriptionBanner"; import { SubscriptionBanner } from "@/components/mika/SubscriptionBanner";
import { SkillsDashboardWidget } from "@/components/mika/SkillsDashboardWidget"; import { SkillsDashboardWidget } from "@/components/mika/SkillsDashboardWidget";
import { TelegramOnboardingWizard } from "@/components/mika/telegram/TelegramOnboardingWizard";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const Route = createFileRoute("/painel/")({ export const Route = createFileRoute("/painel/")({
validateSearch: (search: Record<string, unknown>) => ({
status: typeof search.status === "string" ? (search.status as string) : undefined,
}),
component: DashboardPage, component: DashboardPage,
}); });
function DashboardPage() { function DashboardPage() {
const { data: subscription, isLoading } = useSubscription(); const { data: subscription, isLoading } = useSubscription();
const { data: profile } = useProfile(); const { data: profile } = useProfile();
const { data: agent } = useAgentInstance();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const [wizardOpen, setWizardOpen] = useState(false);
// Auto-open do wizard ao voltar com ?status=success
useEffect(() => {
if (search.status !== "success" || !agent) return;
if (agent.status === "suspended" || agent.status === "error") {
navigate({ search: {}, replace: true });
return;
}
if (!agent.telegram_bot_username) setWizardOpen(true);
navigate({ search: {}, replace: true });
}, [search.status, agent, navigate]);
useEffect(() => {
if (search.status === "success" && !agent && !isLoading) {
toast.info("Seu agente ainda não está pronto. Aguarde o provisionamento.");
}
}, [search.status, agent, isLoading]);
if (isLoading) { if (isLoading) {
return ( return (
@ -49,6 +77,8 @@ function DashboardPage() {
)} )}
{subscription && subscription.status === "active" && <SkillsDashboardWidget />} {subscription && subscription.status === "active" && <SkillsDashboardWidget />}
<TelegramOnboardingWizard open={wizardOpen} onOpenChange={setWizardOpen} />
</div> </div>
); );
} }

View file

@ -23,6 +23,7 @@ const PADDLE_ENV = (import.meta.env.VITE_PADDLE_ENVIRONMENT as string) === "prod
import { Logo } from "@/components/mika/Logo"; import { Logo } from "@/components/mika/Logo";
import { PaymentIssueBanner } from "@/components/mika/PaymentIssueBanner"; import { PaymentIssueBanner } from "@/components/mika/PaymentIssueBanner";
import { CancellationScheduledBanner } from "@/components/mika/CancellationScheduledBanner"; import { CancellationScheduledBanner } from "@/components/mika/CancellationScheduledBanner";
import { TelegramConnectionBanner } from "@/components/mika/telegram/TelegramConnectionBanner";
import { ThemeToggle } from "@/components/mika/ThemeToggle"; import { ThemeToggle } from "@/components/mika/ThemeToggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -159,7 +160,8 @@ function PainelLayout() {
<CancellationScheduledBanner /> <CancellationScheduledBanner />
<main className="flex-1 px-4 sm:px-6 py-6 sm:py-8"> <main className="flex-1 px-4 sm:px-6 py-6 sm:py-8">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl space-y-4">
<TelegramConnectionBanner />
<Outlet /> <Outlet />
</div> </div>
</main> </main>