mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 09:16:46 +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 {
|
||||
id: string;
|
||||
user_id: string;
|
||||
uuid_tenant: string;
|
||||
status: string;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -20,11 +26,13 @@ export function useAgentInstance() {
|
|||
queryFn: async (): Promise<AgentInstance | null> => {
|
||||
const { data, error } = await supabase
|
||||
.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)
|
||||
.maybeSingle();
|
||||
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";
|
||||
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Bot, Cpu, MessageSquare, Sparkles, BarChart3, Loader2 } from "lucide-react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Bot, Cpu, BarChart3 } from "lucide-react";
|
||||
import { useProfile } from "@/hooks/use-profile";
|
||||
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||
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 { useAuth } from "@/hooks/use-auth";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TelegramStatusCard } from "@/components/mika/telegram/TelegramStatusCard";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -121,26 +116,8 @@ function AgentePage() {
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Canais conectados */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||
<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 3: Telegram */}
|
||||
<TelegramStatusCard />
|
||||
|
||||
{/* Card 4: Estatísticas */}
|
||||
<div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||
|
|
|
|||
|
|
@ -1,22 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||
import { useSubscription } from "@/hooks/use-profile";
|
||||
import { useProfile } from "@/hooks/use-profile";
|
||||
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubscriptionBanner } from "@/components/mika/SubscriptionBanner";
|
||||
import { SkillsDashboardWidget } from "@/components/mika/SkillsDashboardWidget";
|
||||
import { TelegramOnboardingWizard } from "@/components/mika/telegram/TelegramOnboardingWizard";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/painel/")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
status: typeof search.status === "string" ? (search.status as string) : undefined,
|
||||
}),
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
function DashboardPage() {
|
||||
const { data: subscription, isLoading } = useSubscription();
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -49,6 +77,8 @@ function DashboardPage() {
|
|||
)}
|
||||
|
||||
{subscription && subscription.status === "active" && <SkillsDashboardWidget />}
|
||||
|
||||
<TelegramOnboardingWizard open={wizardOpen} onOpenChange={setWizardOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const PADDLE_ENV = (import.meta.env.VITE_PADDLE_ENVIRONMENT as string) === "prod
|
|||
import { Logo } from "@/components/mika/Logo";
|
||||
import { PaymentIssueBanner } from "@/components/mika/PaymentIssueBanner";
|
||||
import { CancellationScheduledBanner } from "@/components/mika/CancellationScheduledBanner";
|
||||
import { TelegramConnectionBanner } from "@/components/mika/telegram/TelegramConnectionBanner";
|
||||
import { ThemeToggle } from "@/components/mika/ThemeToggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -159,7 +160,8 @@ function PainelLayout() {
|
|||
<CancellationScheduledBanner />
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue