mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 12:56:51 +00:00
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:
commit
488e3c0e8c
18 changed files with 1167 additions and 31 deletions
76
src/components/mika/telegram/DisconnectTelegramDialog.tsx
Normal file
76
src/components/mika/telegram/DisconnectTelegramDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/mika/telegram/StepConfiguring.tsx
Normal file
67
src/components/mika/telegram/StepConfiguring.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/mika/telegram/StepCreateBot.tsx
Normal file
47
src/components/mika/telegram/StepCreateBot.tsx
Normal 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}>
|
||||||
|
Já criei meu bot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/mika/telegram/StepNaming.tsx
Normal file
71
src/components/mika/telegram/StepNaming.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/mika/telegram/StepToken.tsx
Normal file
111
src/components/mika/telegram/StepToken.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/components/mika/telegram/StepWaiting.tsx
Normal file
119
src/components/mika/telegram/StepWaiting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/mika/telegram/StepWelcome.tsx
Normal file
48
src/components/mika/telegram/StepWelcome.tsx
Normal 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ê já 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/mika/telegram/TelegramConnectionBanner.tsx
Normal file
73
src/components/mika/telegram/TelegramConnectionBanner.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/mika/telegram/TelegramIcon.tsx
Normal file
12
src/components/mika/telegram/TelegramIcon.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
212
src/components/mika/telegram/TelegramOnboardingWizard.tsx
Normal file
212
src/components/mika/telegram/TelegramOnboardingWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/mika/telegram/TelegramStatusCard.tsx
Normal file
105
src/components/mika/telegram/TelegramStatusCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
src/hooks/use-telegram-first-message.ts
Normal file
103
src/hooks/use-telegram-first-message.ts
Normal 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 };
|
||||||
|
}
|
||||||
50
src/lib/invoke-function.ts
Normal file
50
src/lib/invoke-function.ts
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/lib/telegram-username.ts
Normal file
25
src/lib/telegram-username.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue