diff --git a/src/hooks/use-agent-instance.ts b/src/hooks/use-agent-instance.ts index d3e8081..a9dbb52 100644 --- a/src/hooks/use-agent-instance.ts +++ b/src/hooks/use-agent-instance.ts @@ -16,6 +16,8 @@ export interface AgentInstance { telegram_first_message_received_at: string | null; telegram_connected_at: string | null; telegram_onboarding_completed: boolean; + onboarding_completed: boolean; + agent_name: string | null; created_at: string; } @@ -31,7 +33,7 @@ export function useAgentInstance() { const { data, error } = await supabase .from("agent_instances") .select( - "id, user_id, uuid_tenant, status, telegram_bot_username, telegram_bot_token_vault_id, telegram_webhook_configured, telegram_token_invalid, telegram_first_message_received_at, telegram_connected_at, telegram_onboarding_completed, created_at", + "id, user_id, uuid_tenant, status, telegram_bot_username, telegram_bot_token_vault_id, telegram_webhook_configured, telegram_token_invalid, telegram_first_message_received_at, telegram_connected_at, telegram_onboarding_completed, onboarding_completed, agent_name, created_at", ) .eq("user_id", user!.id) .maybeSingle(); diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 919f517..e08f5ef 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -16,11 +16,13 @@ export type Database = { Tables: { agent_instances: { Row: { + agent_name: string | null container_name: string | null created_at: string id: string last_health_check_at: string | null model_config: Json + onboarding_completed: boolean provisioned_at: string | null railway_service_id: string | null status: string @@ -38,13 +40,16 @@ export type Database = { uuid_tenant: string vps_host: string | null vps_pool_id: string | null + welcome_message_sent_at: string | null } Insert: { + agent_name?: string | null container_name?: string | null created_at?: string id?: string last_health_check_at?: string | null model_config?: Json + onboarding_completed?: boolean provisioned_at?: string | null railway_service_id?: string | null status?: string @@ -62,13 +67,16 @@ export type Database = { uuid_tenant?: string vps_host?: string | null vps_pool_id?: string | null + welcome_message_sent_at?: string | null } Update: { + agent_name?: string | null container_name?: string | null created_at?: string id?: string last_health_check_at?: string | null model_config?: Json + onboarding_completed?: boolean provisioned_at?: string | null railway_service_id?: string | null status?: string @@ -86,6 +94,7 @@ export type Database = { uuid_tenant?: string vps_host?: string | null vps_pool_id?: string | null + welcome_message_sent_at?: string | null } Relationships: [ { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index dd06ff5..ec2af70 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as RedefinirSenhaRouteImport } from './routes/redefinir-senha' import { Route as RecuperarSenhaRouteImport } from './routes/recuperar-senha' import { Route as PainelRouteImport } from './routes/painel' import { Route as LoginRouteImport } from './routes/login' +import { Route as BemVindoRouteImport } from './routes/bem-vindo' import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' import { Route as PainelIndexRouteImport } from './routes/painel.index' @@ -59,6 +60,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const BemVindoRoute = BemVindoRouteImport.update({ + id: '/bem-vindo', + path: '/bem-vindo', + getParentRoute: () => rootRouteImport, +} as any) const AdminRoute = AdminRouteImport.update({ id: '/admin', path: '/admin', @@ -158,6 +164,7 @@ const AdminAgenteIdRoute = AdminAgenteIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/admin': typeof AdminRouteWithChildren + '/bem-vindo': typeof BemVindoRoute '/login': typeof LoginRoute '/painel': typeof PainelRouteWithChildren '/recuperar-senha': typeof RecuperarSenhaRoute @@ -183,6 +190,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/bem-vindo': typeof BemVindoRoute '/login': typeof LoginRoute '/recuperar-senha': typeof RecuperarSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute @@ -208,6 +216,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/admin': typeof AdminRouteWithChildren + '/bem-vindo': typeof BemVindoRoute '/login': typeof LoginRoute '/painel': typeof PainelRouteWithChildren '/recuperar-senha': typeof RecuperarSenhaRoute @@ -236,6 +245,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/admin' + | '/bem-vindo' | '/login' | '/painel' | '/recuperar-senha' @@ -261,6 +271,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/bem-vindo' | '/login' | '/recuperar-senha' | '/redefinir-senha' @@ -285,6 +296,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/admin' + | '/bem-vindo' | '/login' | '/painel' | '/recuperar-senha' @@ -312,6 +324,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AdminRoute: typeof AdminRouteWithChildren + BemVindoRoute: typeof BemVindoRoute LoginRoute: typeof LoginRoute PainelRoute: typeof PainelRouteWithChildren RecuperarSenhaRoute: typeof RecuperarSenhaRoute @@ -357,6 +370,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/bem-vindo': { + id: '/bem-vindo' + path: '/bem-vindo' + fullPath: '/bem-vindo' + preLoaderRoute: typeof BemVindoRouteImport + parentRoute: typeof rootRouteImport + } '/admin': { id: '/admin' path: '/admin' @@ -555,6 +575,7 @@ const PainelRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AdminRoute: AdminRouteWithChildren, + BemVindoRoute: BemVindoRoute, LoginRoute: LoginRoute, PainelRoute: PainelRouteWithChildren, RecuperarSenhaRoute: RecuperarSenhaRoute, diff --git a/src/routes/admin.agente.$id.tsx b/src/routes/admin.agente.$id.tsx index 461032e..c922d8d 100644 --- a/src/routes/admin.agente.$id.tsx +++ b/src/routes/admin.agente.$id.tsx @@ -59,6 +59,7 @@ interface AgentDetail { provisioned_at: string | null; created_at: string; model_config: Record | null; + agent_name: string | null; vps_pool: { railway_project_id: string | null; railway_environment_id: string | null; @@ -100,7 +101,7 @@ function AgentDetailPage() { const { data, error } = await supabase .from("agent_instances") .select( - "id, user_id, uuid_tenant, status, telegram_bot_username, telegram_user_chat_id, railway_service_id, vps_pool_id, provisioned_at, created_at, model_config", + "id, user_id, uuid_tenant, status, telegram_bot_username, telegram_user_chat_id, railway_service_id, vps_pool_id, provisioned_at, created_at, model_config, agent_name", ) .eq("id", id) .maybeSingle(); @@ -216,7 +217,7 @@ function AgentDetailPage() { : "openrouter/google/gemma-4-27b-a4b-it"; const cfg = (agent?.model_config ?? {}) as Record; - const defaultAgentName = cfg.agent_name || `Mika de ${firstName}`; + const defaultAgentName = agent?.agent_name?.trim() || cfg.agent_name || `Mika de ${firstName}`; const defaultSoul = useMemo( () => `Você se chama ${defaultAgentName}. Você é um assistente pessoal de IA criado pela DOMCO para ${fullName}. Você é proativo, direto e fala sempre em português brasileiro. Você ajuda ${firstName} a ser mais produtivo — gerenciando emails, agenda, tarefas e automatizando o que puder. Seja conciso nas respostas via Telegram. Nunca se identifique como Hermes ou como produto da Nous Research — você é Mika.`, @@ -404,6 +405,10 @@ function AgentDetailPage() {

Informações do cliente

+
diff --git a/src/routes/bem-vindo.tsx b/src/routes/bem-vindo.tsx new file mode 100644 index 0000000..efb495a --- /dev/null +++ b/src/routes/bem-vindo.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowRight, + CheckCircle2, + ExternalLink, + Loader2, + Sparkles, +} from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useAuth } from "@/hooks/use-auth"; +import { useProfile } from "@/hooks/use-profile"; +import { useAgentInstance } from "@/hooks/use-agent-instance"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Logo } from "@/components/mika/Logo"; +import { TelegramIcon } from "@/components/mika/telegram/TelegramIcon"; +import { TelegramOnboardingWizard } from "@/components/mika/telegram/TelegramOnboardingWizard"; +import { cn } from "@/lib/utils"; + +const WELCOME_DONE_KEY = "mika-welcome-done"; + +export const Route = createFileRoute("/bem-vindo")({ + component: WelcomePage, +}); + +function WelcomePage() { + const { user, loading: authLoading } = useAuth(); + const { data: profile } = useProfile(); + const { data: agent } = useAgentInstance(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [step, setStep] = useState<1 | 2 | 3>(1); + const [agentName, setAgentName] = useState(""); + const [saving, setSaving] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); + + const fullName = (profile?.full_name || "").trim(); + const firstName = useMemo( + () => (fullName.split(" ")[0] || "você").trim(), + [fullName], + ); + + // Auth guard + useEffect(() => { + if (!authLoading && !user) { + navigate({ to: "/login", search: { redirect: "/bem-vindo" } }); + } + }, [authLoading, user, navigate]); + + // Pré-preenche o input com o default + useEffect(() => { + if (agentName) return; + if (agent?.agent_name) { + setAgentName(agent.agent_name); + } else if (firstName && firstName !== "você") { + setAgentName(`Mika de ${firstName}`); + } + }, [agent?.agent_name, firstName, agentName]); + + // Etapa 1 → 2 automático em 3s + useEffect(() => { + if (step !== 1) return; + const t = setTimeout(() => setStep(2), 3000); + return () => clearTimeout(t); + }, [step]); + + // Se já completou o onboarding, vai direto para /painel + useEffect(() => { + if (!agent) return; + if (agent.onboarding_completed) { + navigate({ to: "/painel", search: {} }); + } + }, [agent, navigate]); + + async function handleSaveName() { + const trimmed = agentName.trim(); + if (trimmed.length < 2) { + toast.error("O nome precisa ter pelo menos 2 caracteres."); + return; + } + if (!agent) { + toast.error("Aguarde, ainda estamos preparando seu agente…"); + return; + } + setSaving(true); + const { error } = await supabase + .from("agent_instances") + .update({ agent_name: trimmed }) + .eq("id", agent.id); + setSaving(false); + if (error) { + toast.error("Não foi possível salvar o nome. Tente novamente."); + return; + } + if (user) { + await queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] }); + } + setStep(3); + } + + async function markWelcomeDone() { + if (typeof window !== "undefined") { + window.localStorage.setItem(WELCOME_DONE_KEY, "1"); + } + if (agent) { + await supabase + .from("agent_instances") + .update({ onboarding_completed: true }) + .eq("id", agent.id); + if (user) { + await queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] }); + } + } + } + + async function handleConnectTelegram() { + await markWelcomeDone(); + setWizardOpen(true); + } + + async function handleSkip() { + await markWelcomeDone(); + navigate({ to: "/painel", search: {} }); + } + + if (authLoading || !user) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+ +
+
+ + {step === 1 && ( + + + + + +

+ Bem-vindo à Mika! 🎉 +

+

+ Seu assistente pessoal de IA está sendo preparado. +

+ +
    + {[ + { icon: "✅", text: "Pagamento confirmado" }, + { icon: "✅", text: "Sua conta está ativa" }, + { icon: "⏳", text: "Configurando seu agente..." }, + ].map((item, i) => ( + + {item.icon} + {item.text} + + ))} +
+
+ )} + + {step === 2 && ( + +

+ Como você quer chamar seu assistente? +

+

+ Este será o nome que aparecerá nas conversas. +

+ +
+ setAgentName(e.target.value)} + placeholder="Ex: Mika de João, Maya, Assistente..." + className="h-12 text-center text-lg bg-white/5 border-white/20 text-white placeholder:text-white/40 focus-visible:ring-primary" + /> +
+ + {agentName.trim().length < 2 + ? "Mínimo 2 caracteres" + : "\u00A0"} + + {agentName.length}/40 +
+
+ +
+ {[ + `Mika de ${firstName}`, + "Maya", + "Alex", + "Assistente", + ].map((sugg) => ( + + ))} +
+ + +
+ )} + + {step === 3 && ( + +

+ Quase lá! Conecte seu Telegram. +

+

+ Para conversar com{" "} + + {agentName || "seu agente"} + + , você precisa conectar seu Telegram. Leva menos de 2 minutos. +

+ + + + + +
    + + + Abrir BotFather + + + } + /> + + +
+ +
+ + +
+
+ )} +
+
+
+ + { + setWizardOpen(open); + // Quando fechar o wizard, leva ao painel + if (!open) { + navigate({ to: "/painel", search: {} }); + } + }} + /> +
+ ); +} + +function StepRow({ + number, + title, + hint, + extra, +}: { + number: number; + title: string; + hint?: string; + extra?: React.ReactNode; +}) { + return ( +
  • + + {number} + +
    +

    {title}

    + {hint &&

    {hint}

    } + {extra} +
    +
  • + ); +} + +// Marker no componente para cumprir contrato de "marcar como visitada" +// (também usado pelo redirect do /painel) +export function isWelcomeDone(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(WELCOME_DONE_KEY) === "1"; +} diff --git a/src/routes/checkout.sucesso.tsx b/src/routes/checkout.sucesso.tsx index 833c614..9dd763a 100644 --- a/src/routes/checkout.sucesso.tsx +++ b/src/routes/checkout.sucesso.tsx @@ -15,11 +15,10 @@ function CheckoutSuccessPage() { const queryClient = useQueryClient(); useEffect(() => { - // Invalida queries de assinatura para refletir o novo estado quando o webhook chegar queryClient.invalidateQueries({ queryKey: ["subscription"] }); const t = setTimeout(() => { - navigate({ to: "/painel", search: {} }); - }, 6000); + navigate({ to: "/bem-vindo" }); + }, 4000); return () => clearTimeout(t); }, [navigate, queryClient]); @@ -32,15 +31,12 @@ function CheckoutSuccessPage() {

    Assinatura confirmada!

    - Estamos provisionando seu acesso. Em alguns segundos seu plano estará ativo. + Em instantes vamos preparar seu agente Mika.

    -

    Você será redirecionado em instantes…

    diff --git a/src/routes/painel.index.tsx b/src/routes/painel.index.tsx index 08f6171..2059052 100644 --- a/src/routes/painel.index.tsx +++ b/src/routes/painel.index.tsx @@ -35,13 +35,17 @@ function DashboardPage() { const [wizardOpen, setWizardOpen] = useState(false); const previousStatusRef = useRef(null); - // Auto-open do wizard ao voltar com ?status=success + // Redireciona para /bem-vindo se cliente acabou de pagar e ainda não completou onboarding useEffect(() => { if (search.status !== "success" || !agent) return; if (agent.status === "suspended" || agent.status === "error") { navigate({ search: { status: undefined }, replace: true }); return; } + if (!agent.onboarding_completed) { + navigate({ to: "/bem-vindo", replace: true }); + return; + } if (!agent.telegram_bot_username) setWizardOpen(true); navigate({ search: { status: undefined }, replace: true }); }, [search.status, agent, navigate]); @@ -74,6 +78,17 @@ function DashboardPage() { } const firstName = (profile?.full_name || "").split(" ")[0] || "por aqui"; + const agentName = agent?.agent_name?.trim() || "Mika"; + const statusLabel = + agent?.status === "active" + ? "ativo" + : agent?.status === "provisioning" + ? "sendo preparado" + : agent?.status === "suspended" + ? "pausado" + : agent?.status === "error" + ? "com erro" + : "aguardando configuração"; return (
    @@ -83,7 +98,9 @@ function DashboardPage() {

    Olá, {firstName} 👋

    {subscription - ? "Acompanhe abaixo o status do seu agente Mika." + ? agent + ? <>Seu agente {agentName} está {statusLabel}. + : "Acompanhe abaixo o status do seu agente." : "Vamos colocar seu agente Mika no ar."}

    diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts index d858792..1ef6e02 100644 --- a/supabase/functions/provision-agent/index.ts +++ b/supabase/functions/provision-agent/index.ts @@ -78,7 +78,7 @@ Deno.serve(async (req) => { const { data: agent, error: agentErr } = await supabase .from("agent_instances") .select( - "id, user_id, uuid_tenant, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id", + "id, user_id, uuid_tenant, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id, agent_name", ) .eq("id", body.agent_instance_id) .maybeSingle(); @@ -108,7 +108,11 @@ Deno.serve(async (req) => { const fullName = (profile?.full_name?.trim() || "Usuário").toString(); const firstName = fullName.split(" ")[0] || "Usuário"; - const agentName = body.agent_name?.trim() || `Mika de ${firstName}`; + // Prioridade: body > coluna agent_name no DB > default "Mika de {firstName}" + const agentName = + body.agent_name?.trim() || + (agent.agent_name?.trim() ?? "") || + `Mika de ${firstName}`; console.log(`[provision-agent] profile carregado: ${fullName} → agent_name=${agentName}`); // 1c) Carregar subscription ativa (para definir modelo Pro vs Basic) @@ -280,6 +284,7 @@ Deno.serve(async (req) => { .update({ railway_service_id: railwayServiceId, vps_pool_id: pool.id, + agent_name: agentNameFinal, model_config: { provider: modelFinal, stt: sttProvider, @@ -389,7 +394,10 @@ async function handleUpdateExistingService( const fullName = (profile?.full_name?.trim() || "Usuário").toString(); const firstName = fullName.split(" ")[0] || "Usuário"; - const agentName = body.agent_name?.trim() || `Mika de ${firstName}`; + const agentName = + body.agent_name?.trim() || + (agent.agent_name?.trim() ?? "") || + `Mika de ${firstName}`; const { data: subscription } = await supabase .from("subscriptions") diff --git a/supabase/functions/railway-webhook/index.ts b/supabase/functions/railway-webhook/index.ts index 67dfc01..416a91e 100644 --- a/supabase/functions/railway-webhook/index.ts +++ b/supabase/functions/railway-webhook/index.ts @@ -81,7 +81,9 @@ Deno.serve(async (req) => { const { data: agent } = await supabase .from("agent_instances") - .select("id, status, user_id, telegram_bot_username, railway_service_id") + .select( + "id, status, user_id, telegram_bot_username, railway_service_id, telegram_user_chat_id, telegram_bot_token_vault_id, agent_name, welcome_message_sent_at", + ) .eq("railway_service_id", serviceId) .maybeSingle(); @@ -123,6 +125,19 @@ Deno.serve(async (req) => { console.log(`railway-webhook: agent ${agent.id} marcado como active (status=${upper})`); + // Envia mensagem de boas-vindas via Telegram (apenas na primeira ativação) + if ( + !agent.welcome_message_sent_at && + agent.telegram_user_chat_id && + agent.telegram_bot_token_vault_id + ) { + try { + await sendWelcomeMessage(supabase, agent); + } catch (e) { + console.error("railway-webhook: falha ao enviar welcome message:", e); + } + } + // Notifica admin somente se era um auto-provisionamento (status anterior=provisioning) if (wasProvisioning) { const fullName = await loadFullName(); @@ -181,3 +196,60 @@ function jsonResponse(status: number, body: unknown) { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } + +// deno-lint-ignore no-explicit-any +async function sendWelcomeMessage(supabase: any, agent: any): Promise { + // Decifra o token do bot + const { data: secret } = await supabase.rpc("vault_decrypt_secret", { + secret_id: agent.telegram_bot_token_vault_id, + }); + const token: string = secret?.[0]?.decrypted_secret ?? ""; + if (!token) { + console.warn("sendWelcomeMessage: token vazio, abortando"); + return; + } + + // Carrega first name + const { data: profile } = await supabase + .from("profiles") + .select("full_name") + .eq("id", agent.user_id) + .maybeSingle(); + + const fullName = (profile?.full_name as string | undefined)?.trim() || ""; + const firstName = fullName.split(" ")[0] || "você"; + const agentName = (agent.agent_name as string | undefined)?.trim() || "Mika"; + + const text = + `Olá, ${firstName}! 👋\n\n` + + `Sou ${agentName}, sua assistente pessoal de IA criada pela DomCo.\n\n` + + `Estou pronta para começar! Aqui estão algumas coisas que posso fazer por você:\n\n` + + `📧 Resumir seus e-mails importantes\n` + + `📅 Gerenciar sua agenda\n` + + `✅ Organizar suas tarefas\n` + + `🔍 Pesquisar qualquer coisa\n` + + `⚡ Criar automações personalizadas\n\n` + + `Pode me mandar uma mensagem quando quiser. Estou aqui! 🚀`; + + const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: agent.telegram_user_chat_id, + text, + }), + }); + + if (!res.ok) { + const err = await res.text().catch(() => ""); + console.error(`sendWelcomeMessage: Telegram API ${res.status}: ${err}`); + return; + } + + await supabase + .from("agent_instances") + .update({ welcome_message_sent_at: new Date().toISOString() }) + .eq("id", agent.id); + + console.log(`sendWelcomeMessage: enviada para agent ${agent.id}`); +} diff --git a/supabase/migrations/20260425120053_73879f42-52a8-423a-8ed5-43b8fdcd9c6d.sql b/supabase/migrations/20260425120053_73879f42-52a8-423a-8ed5-43b8fdcd9c6d.sql new file mode 100644 index 0000000..318e2b9 --- /dev/null +++ b/supabase/migrations/20260425120053_73879f42-52a8-423a-8ed5-43b8fdcd9c6d.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.agent_instances + ADD COLUMN IF NOT EXISTS agent_name text, + ADD COLUMN IF NOT EXISTS welcome_message_sent_at timestamp with time zone, + ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false; \ No newline at end of file