diff --git a/src/components/mika/SubscriptionBanner.tsx b/src/components/mika/SubscriptionBanner.tsx new file mode 100644 index 0000000..f45b318 --- /dev/null +++ b/src/components/mika/SubscriptionBanner.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { format } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { AlertCircle, AlertTriangle, Info } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { SubscriptionRow } from "@/hooks/use-profile"; + +export function SubscriptionBanner({ subscription }: { subscription: SubscriptionRow | null }) { + if (!subscription) return null; + + const periodEnd = subscription.current_period_end ? new Date(subscription.current_period_end) : null; + const periodEndPassed = periodEnd && periodEnd.getTime() < Date.now(); + + // Pagamento pendente + if (subscription.status === "past_due") { + return ( + + Atualizar pagamento + + } + /> + ); + } + + // Cancelamento agendado + if (subscription.cancel_at_period_end && periodEnd && !periodEndPassed) { + return ( + + Reativar assinatura + + } + /> + ); + } + + // Cancelada e período já passou + if (subscription.status === "canceled" && periodEndPassed) { + return ( + + Assinar novamente + + } + /> + ); + } + + return null; +} + +function Banner({ + tone, + icon: Icon, + title, + description, + action, +}: { + tone: "destructive" | "warning" | "muted"; + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; + action: React.ReactNode; +}) { + const tones = { + destructive: "bg-destructive/10 border-destructive/30 text-destructive", + warning: "bg-warning/10 border-warning/30 text-warning", + muted: "bg-muted border-border text-foreground", + }; + return ( +
+
+ +
+

{title}

+

+ {description} +

+
+
+
{action}
+
+ ); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 05e7907..dbc2b0c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,14 +10,35 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SignupRouteImport } from './routes/signup' +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 IndexRouteImport } from './routes/index' +import { Route as PainelIndexRouteImport } from './routes/painel.index' +import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento' +import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes' const SignupRoute = SignupRouteImport.update({ id: '/signup', path: '/signup', getParentRoute: () => rootRouteImport, } as any) +const RedefinirSenhaRoute = RedefinirSenhaRouteImport.update({ + id: '/redefinir-senha', + path: '/redefinir-senha', + getParentRoute: () => rootRouteImport, +} as any) +const RecuperarSenhaRoute = RecuperarSenhaRouteImport.update({ + id: '/recuperar-senha', + path: '/recuperar-senha', + getParentRoute: () => rootRouteImport, +} as any) +const PainelRoute = PainelRouteImport.update({ + id: '/painel', + path: '/painel', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -28,34 +49,96 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const PainelIndexRoute = PainelIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PainelRoute, +} as any) +const PainelFaturamentoRoute = PainelFaturamentoRouteImport.update({ + id: '/faturamento', + path: '/faturamento', + getParentRoute: () => PainelRoute, +} as any) +const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({ + id: '/configuracoes', + path: '/configuracoes', + getParentRoute: () => PainelRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/painel': typeof PainelRouteWithChildren + '/recuperar-senha': typeof RecuperarSenhaRoute + '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/painel/configuracoes': typeof PainelConfiguracoesRoute + '/painel/faturamento': typeof PainelFaturamentoRoute + '/painel/': typeof PainelIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute + '/recuperar-senha': typeof RecuperarSenhaRoute + '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/painel/configuracoes': typeof PainelConfiguracoesRoute + '/painel/faturamento': typeof PainelFaturamentoRoute + '/painel': typeof PainelIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/login': typeof LoginRoute + '/painel': typeof PainelRouteWithChildren + '/recuperar-senha': typeof RecuperarSenhaRoute + '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/painel/configuracoes': typeof PainelConfiguracoesRoute + '/painel/faturamento': typeof PainelFaturamentoRoute + '/painel/': typeof PainelIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' | '/signup' + fullPaths: + | '/' + | '/login' + | '/painel' + | '/recuperar-senha' + | '/redefinir-senha' + | '/signup' + | '/painel/configuracoes' + | '/painel/faturamento' + | '/painel/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' | '/signup' - id: '__root__' | '/' | '/login' | '/signup' + to: + | '/' + | '/login' + | '/recuperar-senha' + | '/redefinir-senha' + | '/signup' + | '/painel/configuracoes' + | '/painel/faturamento' + | '/painel' + id: + | '__root__' + | '/' + | '/login' + | '/painel' + | '/recuperar-senha' + | '/redefinir-senha' + | '/signup' + | '/painel/configuracoes' + | '/painel/faturamento' + | '/painel/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute LoginRoute: typeof LoginRoute + PainelRoute: typeof PainelRouteWithChildren + RecuperarSenhaRoute: typeof RecuperarSenhaRoute + RedefinirSenhaRoute: typeof RedefinirSenhaRoute SignupRoute: typeof SignupRoute } @@ -68,6 +151,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SignupRouteImport parentRoute: typeof rootRouteImport } + '/redefinir-senha': { + id: '/redefinir-senha' + path: '/redefinir-senha' + fullPath: '/redefinir-senha' + preLoaderRoute: typeof RedefinirSenhaRouteImport + parentRoute: typeof rootRouteImport + } + '/recuperar-senha': { + id: '/recuperar-senha' + path: '/recuperar-senha' + fullPath: '/recuperar-senha' + preLoaderRoute: typeof RecuperarSenhaRouteImport + parentRoute: typeof rootRouteImport + } + '/painel': { + id: '/painel' + path: '/painel' + fullPath: '/painel' + preLoaderRoute: typeof PainelRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -82,14 +186,62 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/painel/': { + id: '/painel/' + path: '/' + fullPath: '/painel/' + preLoaderRoute: typeof PainelIndexRouteImport + parentRoute: typeof PainelRoute + } + '/painel/faturamento': { + id: '/painel/faturamento' + path: '/faturamento' + fullPath: '/painel/faturamento' + preLoaderRoute: typeof PainelFaturamentoRouteImport + parentRoute: typeof PainelRoute + } + '/painel/configuracoes': { + id: '/painel/configuracoes' + path: '/configuracoes' + fullPath: '/painel/configuracoes' + preLoaderRoute: typeof PainelConfiguracoesRouteImport + parentRoute: typeof PainelRoute + } } } +interface PainelRouteChildren { + PainelConfiguracoesRoute: typeof PainelConfiguracoesRoute + PainelFaturamentoRoute: typeof PainelFaturamentoRoute + PainelIndexRoute: typeof PainelIndexRoute +} + +const PainelRouteChildren: PainelRouteChildren = { + PainelConfiguracoesRoute: PainelConfiguracoesRoute, + PainelFaturamentoRoute: PainelFaturamentoRoute, + PainelIndexRoute: PainelIndexRoute, +} + +const PainelRouteWithChildren = + PainelRoute._addFileChildren(PainelRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, + PainelRoute: PainelRouteWithChildren, + RecuperarSenhaRoute: RecuperarSenhaRoute, + RedefinirSenhaRoute: RedefinirSenhaRoute, SignupRoute: SignupRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/router.tsx b/src/router.tsx index adcf1a9..bde43d8 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,4 +1,5 @@ import { createRouter, useRouter } from "@tanstack/react-router"; +import { QueryClient } from "@tanstack/react-query"; import { routeTree } from "./routeTree.gen"; function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => void }) { @@ -23,9 +24,9 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo /> -

Something went wrong

+

Algo deu errado

- An unexpected error occurred. Please try again. + Ocorreu um erro inesperado. Tente novamente.

{import.meta.env.DEV && error.message && (
@@ -38,15 +39,15 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
               router.invalidate();
               reset();
             }}
-            className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
+            className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-dark"
           >
-            Try again
+            Tentar novamente
           
           
-            Go home
+            Ir para o início
           
         
       
@@ -55,9 +56,20 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
 }
 
 export const getRouter = () => {
+  // Fresh QueryClient per request — never module-level (vaza dados entre SSR requests)
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: {
+        staleTime: 30_000,
+        retry: 1,
+        refetchOnWindowFocus: false,
+      },
+    },
+  });
+
   const router = createRouter({
     routeTree,
-    context: {},
+    context: { queryClient },
     scrollRestoration: true,
     defaultPreloadStaleTime: 0,
     defaultErrorComponent: DefaultErrorComponent,
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index ad8467f..3605010 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,9 +1,15 @@
-import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
+import { Outlet, createRootRouteWithContext, HeadContent, Scripts, Link } from "@tanstack/react-router";
+import type { QueryClient } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
 import { Toaster } from "@/components/ui/sonner";
 import { ThemeProvider } from "@/components/theme-provider";
 
 import appCss from "../styles.css?url";
 
+interface RouterContext {
+  queryClient: QueryClient;
+}
+
 function NotFoundComponent() {
   return (
     
@@ -14,19 +20,19 @@ function NotFoundComponent() { A página que você procura não existe ou foi movida.

); } -export const Route = createRootRoute({ +export const Route = createRootRouteWithContext()({ head: () => ({ meta: [ { charSet: "utf-8" }, @@ -88,10 +94,13 @@ function RootShell({ children }: { children: React.ReactNode }) { } function RootComponent() { + const { queryClient } = Route.useRouteContext(); return ( - - - - + + + + + + ); } diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 8f48862..269ffd6 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,21 +1,106 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { Logo } from "@/components/mika/Logo"; +"use client"; + +import { useState } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { GoogleButton } from "@/components/mika/GoogleButton"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type LoginSearch = { redirect?: string }; + +const schema = z.object({ + email: z.string().email("Formato de e-mail inválido."), + password: z.string().min(1, "Informe sua senha."), +}); +type FormValues = z.infer; export const Route = createFileRoute("/login")({ - component: LoginPlaceholder, + validateSearch: (search: Record): LoginSearch => ({ + redirect: typeof search.redirect === "string" ? search.redirect : undefined, + }), + component: LoginPage, }); -function LoginPlaceholder() { +function LoginPage() { + const { redirect } = Route.useSearch(); + const navigate = useNavigate(); + const [submitting, setSubmitting] = useState(false); + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormValues) => { + setSubmitting(true); + const { error } = await supabase.auth.signInWithPassword({ + email: data.email, + password: data.password, + }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + toast.success("Bem-vindo de volta!"); + navigate({ to: (redirect as "/painel") || "/painel" }); + }; + return ( -
-
- -

Login

-

- A página de login completa será implementada na Etapa 2 (Auth + Supabase). -

- ← Voltar para o início + + Não tem conta?{" "} + + Criar conta + + + } + > +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+
+ + + Esqueci minha senha + +
+ + {errors.password &&

{errors.password.message}

} +
+ + +
+ +
+
+ ou +
-
+ + + ); } diff --git a/src/routes/painel.configuracoes.tsx b/src/routes/painel.configuracoes.tsx new file mode 100644 index 0000000..9be147e --- /dev/null +++ b/src/routes/painel.configuracoes.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import { useState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { IMaskInput } from "react-imask"; +import { toast } from "sonner"; +import { Loader2, LogOut } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { useProfile } from "@/hooks/use-profile"; +import { translateAuthError } from "@/lib/auth-errors"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const Route = createFileRoute("/painel/configuracoes")({ + component: SettingsPage, +}); + +const profileSchema = z.object({ + full_name: z.string().min(2, "Informe seu nome completo.").max(120), + company_name: z.string().max(120).optional().or(z.literal("")), + cpf_cnpj: z.string().optional().or(z.literal("")), + phone: z.string().optional().or(z.literal("")), +}); +type ProfileForm = z.infer; + +function SettingsPage() { + const { user } = useAuth(); + const { data: profile, isLoading } = useProfile(); + const queryClient = useQueryClient(); + + const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({ + resolver: zodResolver(profileSchema), + }); + + useEffect(() => { + if (profile) { + reset({ + full_name: profile.full_name, + company_name: profile.company_name ?? "", + cpf_cnpj: profile.cpf_cnpj ?? "", + phone: profile.phone ?? "", + }); + } + }, [profile, reset]); + + const cpfCnpj = watch("cpf_cnpj") || ""; + const phone = watch("phone") || ""; + const cpfCnpjDigits = cpfCnpj.replace(/\D/g, ""); + const cpfCnpjMask = cpfCnpjDigits.length > 11 ? "00.000.000/0000-00" : "000.000.000-00"; + + const updateProfile = useMutation({ + mutationFn: async (data: ProfileForm) => { + if (!user) throw new Error("Sem sessão"); + const { error } = await supabase + .from("profiles") + .update({ + full_name: data.full_name, + company_name: data.company_name || null, + cpf_cnpj: data.cpf_cnpj || null, + phone: data.phone || null, + }) + .eq("id", user.id); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Perfil atualizado com sucesso."); + queryClient.invalidateQueries({ queryKey: ["profile"] }); + }, + onError: (e: Error) => toast.error(translateAuthError(e.message)), + }); + + const signOutAll = async () => { + const { error } = await supabase.auth.signOut({ scope: "global" }); + if (error) toast.error(translateAuthError(error.message)); + else toast.success("Saiu de todos os dispositivos."); + }; + + if (isLoading) return ; + + return ( +
+
+

Configurações

+

Gerencie seu perfil e segurança.

+
+ +
+

Perfil

+

Informações para faturamento e suporte.

+ +
updateProfile.mutate(d))} className="space-y-4"> +
+ + + {errors.full_name &&

{errors.full_name.message}

} +
+ +
+ + +
+ +
+
+ + setValue("cpf_cnpj", v as string, { shouldValidate: true })} + placeholder="000.000.000-00" + className="flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> +
+ +
+ + setValue("phone", v as string, { shouldValidate: true })} + placeholder="(11) 99999-9999" + className="flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> +
+
+ + +
+
+ +
+

Segurança

+

+ Para alterar sua senha, use o link de recuperação no e-mail. +

+ +
+
+ ); +} diff --git a/src/routes/painel.faturamento.tsx b/src/routes/painel.faturamento.tsx new file mode 100644 index 0000000..331d81d --- /dev/null +++ b/src/routes/painel.faturamento.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { CreditCard, ExternalLink } from "lucide-react"; +import { useSubscription, useProfile } from "@/hooks/use-profile"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const Route = createFileRoute("/painel/faturamento")({ + component: BillingPage, +}); + +function BillingPage() { + const { data: subscription, isLoading } = useSubscription(); + const { data: profile } = useProfile(); + + const { data: plan } = useQuery({ + queryKey: ["plan", subscription?.plan_id], + enabled: !!subscription?.plan_id, + queryFn: async () => { + const { data } = await supabase + .from("plans") + .select("*") + .eq("id", subscription!.plan_id!) + .maybeSingle(); + return data; + }, + }); + + return ( +
+
+

Faturamento

+

+ Gerencie sua assinatura, método de pagamento e histórico. +

+
+ + {isLoading ? ( + + ) : ( +
+

Plano atual

+ {subscription && plan ? ( +
+ + + + +
+ ) : ( +

+ Você ainda não possui uma assinatura ativa. +

+ )} + +
+ )} + +
+

Histórico de pagamentos

+

+ Seu histórico aparecerá aqui após a primeira cobrança. +

+
+ +
+

Método de pagamento

+
+ + Nenhum método cadastrado +
+ +
+
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function statusLabel(s: string): string { + return { + active: "Ativa", + trialing: "Em período de teste", + past_due: "Pagamento pendente", + canceled: "Cancelada", + incomplete: "Em provisionamento", + incomplete_expired: "Expirada", + unpaid: "Não paga", + }[s] || s; +} diff --git a/src/routes/painel.index.tsx b/src/routes/painel.index.tsx new file mode 100644 index 0000000..e339ea9 --- /dev/null +++ b/src/routes/painel.index.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react"; +import { useSubscription } from "@/hooks/use-profile"; +import { useProfile } from "@/hooks/use-profile"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SubscriptionBanner } from "@/components/mika/SubscriptionBanner"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/painel/")({ + component: DashboardPage, +}); + +function DashboardPage() { + const { data: subscription, isLoading } = useSubscription(); + const { data: profile } = useProfile(); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + const firstName = (profile?.full_name || "").split(" ")[0] || "por aqui"; + + return ( +
+ + +
+

Olá, {firstName} 👋

+

+ {subscription + ? "Acompanhe abaixo o status do seu agente Mika." + : "Vamos colocar seu agente Mika no ar."} +

+
+ + {!subscription && } + + {subscription && (subscription.status === "incomplete" || subscription.status === "active") && ( + + )} +
+ ); +} + +function NoSubscriptionCard() { + return ( +
+
+ +
+

Escolha um plano para começar

+

+ Em poucos minutos seu agente Mika estará disponível no Telegram, com memória persistente e + skills personalizadas. +

+ +
+ ); +} + +function ProvisioningCard() { + const steps = [ + { label: "Provisionar container na VPS", state: "active" as const }, + { label: "Configurar modelo de IA", state: "pending" as const }, + { label: "Conectar seu Telegram", state: "pending" as const }, + { label: "Personalizar seu agente", state: "pending" as const }, + ]; + return ( +
+
+
+ +
+
+

Seu agente Mika está sendo provisionado

+

+ Você receberá um e-mail quando estiver pronto — geralmente em até 10 minutos. +

+
+
+ +
    + {steps.map((step, i) => ( +
  1. +
    +
    + {step.state === "active" ? ( + + ) : step.state === "pending" ? ( + i + 1 + ) : ( + + )} +
    +

    + {step.label} +

    +
    +
  2. + ))} +
+
+ ); +} diff --git a/src/routes/painel.tsx b/src/routes/painel.tsx new file mode 100644 index 0000000..f2df24b --- /dev/null +++ b/src/routes/painel.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { createFileRoute, Outlet, redirect, Link, useNavigate, useLocation } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { + Bot, + CreditCard, + Home, + LogOut, + Menu, + Plug, + Settings, + Sparkles, + User, +} from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { useProfile } from "@/hooks/use-profile"; +import { Logo } from "@/components/mika/Logo"; +import { ThemeToggle } from "@/components/mika/ThemeToggle"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/painel")({ + beforeLoad: () => { + // Auth real é validada client-side abaixo (Supabase usa localStorage). + // Mantemos beforeLoad como hook para futuro SSR auth. + }, + component: PainelLayout, +}); + +interface NavItem { + to: "/painel" | "/painel/faturamento" | "/painel/configuracoes"; + label: string; + icon: React.ComponentType<{ className?: string }>; + disabled?: boolean; +} + +interface DisabledNavItem { + to: null; + label: string; + icon: React.ComponentType<{ className?: string }>; + disabled: true; +} + +const NAV: (NavItem | DisabledNavItem)[] = [ + { to: "/painel", label: "Dashboard", icon: Home }, + { to: null, label: "Meu Agente", icon: Bot, disabled: true }, + { to: null, label: "Skills", icon: Sparkles, disabled: true }, + { to: null, label: "Integrações", icon: Plug, disabled: true }, + { to: "/painel/faturamento", label: "Faturamento", icon: CreditCard }, + { to: "/painel/configuracoes", label: "Configurações", icon: Settings }, +]; + +function PainelLayout() { + const { user, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { + if (!loading && !user) { + navigate({ to: "/login", search: { redirect: location.pathname } }); + } + }, [loading, user, navigate, location.pathname]); + + if (loading || !user) { + return ( +
+
+
+ ); + } + + return ( + +
+ + +
+
+
+ + + + + + + + + setMobileOpen(false)} /> + + +
+
+ + +
+ +
+
+ +
+
+
+
+
+ ); +} + +function DesktopSidebar() { + return ( + + ); +} + +function SidebarNav({ onNavigate }: { onNavigate?: () => void } = {}) { + const location = useLocation(); + return ( + + ); +} + +function UserMenu() { + const { user } = useAuth(); + const { data: profile } = useProfile(); + const navigate = useNavigate(); + + const initials = (profile?.full_name || user?.email || "U") + .split(" ") + .map((s) => s[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + + const signOut = async () => { + await supabase.auth.signOut(); + navigate({ to: "/" }); + }; + + return ( +
+ + + + + + + +
{profile?.full_name || "Usuário"}
+
{user?.email}
+
+ + + + Perfil + + + + Sair + +
+
+
+ ); +} diff --git a/src/routes/recuperar-senha.tsx b/src/routes/recuperar-senha.tsx new file mode 100644 index 0000000..6acfe58 --- /dev/null +++ b/src/routes/recuperar-senha.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const schema = z.object({ + email: z.string().email("Formato de e-mail inválido."), +}); +type FormValues = z.infer; + +export const Route = createFileRoute("/recuperar-senha")({ + component: ForgotPasswordPage, +}); + +function ForgotPasswordPage() { + const [submitting, setSubmitting] = useState(false); + const [sent, setSent] = useState(false); + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormValues) => { + setSubmitting(true); + const { error } = await supabase.auth.resetPasswordForEmail(data.email, { + redirectTo: `${window.location.origin}/redefinir-senha`, + }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + setSent(true); + toast.success("Se houver uma conta com esse e-mail, enviamos um link de recuperação."); + }; + + return ( + + ← Voltar para o login + + } + > + {sent ? ( +
+

+ Se o e-mail informado existir em nossa base, você receberá o link em alguns minutos. + Verifique também a pasta de spam. +

+ +
+ ) : ( +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ )} +
+ ); +} diff --git a/src/routes/redefinir-senha.tsx b/src/routes/redefinir-senha.tsx new file mode 100644 index 0000000..67c1c94 --- /dev/null +++ b/src/routes/redefinir-senha.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { passwordStrength } from "@/lib/password"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { PasswordStrengthMeter } from "@/components/mika/PasswordStrengthMeter"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const schema = z + .object({ + password: z + .string() + .min(8, "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número.") + .refine((v) => /[A-Z]/.test(v), "A senha precisa de pelo menos 1 maiúscula.") + .refine((v) => /\d/.test(v), "A senha precisa de pelo menos 1 número."), + confirm: z.string(), + }) + .refine((d) => d.password === d.confirm, { + path: ["confirm"], + message: "As senhas não coincidem.", + }); +type FormValues = z.infer; + +export const Route = createFileRoute("/redefinir-senha")({ + component: ResetPasswordPage, +}); + +function ResetPasswordPage() { + const navigate = useNavigate(); + const [submitting, setSubmitting] = useState(false); + const { register, handleSubmit, watch, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + const password = watch("password") || ""; + + const onSubmit = async (data: FormValues) => { + if (passwordStrength(data.password) < 2) { + toast.error("Escolha uma senha mais forte."); + return; + } + setSubmitting(true); + const { error } = await supabase.auth.updateUser({ password: data.password }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + toast.success("Senha redefinida com sucesso. Faça login."); + await supabase.auth.signOut(); + navigate({ to: "/login" }); + }; + + return ( + +
+
+ + + {errors.password &&

{errors.password.message}

} + +
+ +
+ + + {errors.confirm &&

{errors.confirm.message}

} +
+ + +
+
+ ); +} diff --git a/src/routes/signup.tsx b/src/routes/signup.tsx index 848ccdd..6536f40 100644 --- a/src/routes/signup.tsx +++ b/src/routes/signup.tsx @@ -1,38 +1,215 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { Logo } from "@/components/mika/Logo"; +"use client"; + +import { useState } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2, MailCheck } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { passwordStrength } from "@/lib/password"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { GoogleButton } from "@/components/mika/GoogleButton"; +import { PasswordStrengthMeter } from "@/components/mika/PasswordStrengthMeter"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; type SignupSearch = { plan?: string; cycle?: "monthly" | "yearly"; }; +const schema = z.object({ + full_name: z.string().min(2, "Informe seu nome completo.").max(120), + email: z.string().email("Formato de e-mail inválido."), + password: z + .string() + .min(8, "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número.") + .refine((v) => /[A-Z]/.test(v), "A senha precisa de pelo menos 1 maiúscula.") + .refine((v) => /\d/.test(v), "A senha precisa de pelo menos 1 número."), + accept_terms: z.literal(true, { message: "Você precisa aceitar os termos." }), +}); +type FormValues = z.infer; + export const Route = createFileRoute("/signup")({ validateSearch: (search: Record): SignupSearch => ({ plan: typeof search.plan === "string" ? search.plan : undefined, cycle: search.cycle === "yearly" || search.cycle === "monthly" ? search.cycle : undefined, }), - component: SignupPlaceholder, + component: SignupPage, }); -function SignupPlaceholder() { +function SignupPage() { const { plan, cycle } = Route.useSearch(); - return ( -
-
- -

Criar conta

-

- Cadastro completo será implementado na Etapa 2. -

- {plan && ( -

- Plano selecionado: {plan} · {cycle === "yearly" ? "anual" : "mensal"} + const navigate = useNavigate(); + const [submitting, setSubmitting] = useState(false); + const [needsConfirm, setNeedsConfirm] = useState(null); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { accept_terms: false as unknown as true }, + }); + + const password = watch("password") || ""; + + const onSubmit = async (data: FormValues) => { + if (passwordStrength(data.password) < 2) { + toast.error("Escolha uma senha mais forte."); + return; + } + setSubmitting(true); + const { data: signupData, error } = await supabase.auth.signUp({ + email: data.email, + password: data.password, + options: { + data: { full_name: data.full_name }, + emailRedirectTo: `${window.location.origin}/painel`, + }, + }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + // Sessão presente = e-mail já confirmado (auto-confirm desligado, então normalmente null) + if (signupData.session) { + toast.success("Conta criada!"); + if (plan && plan !== "enterprise") { + // TODO Etapa 3: chamar create-checkout-session com plan/cycle + navigate({ to: "/painel" }); + } else { + navigate({ to: "/painel" }); + } + return; + } + setNeedsConfirm(data.email); + }; + + const resend = async () => { + if (!needsConfirm) return; + const { error } = await supabase.auth.resend({ type: "signup", email: needsConfirm }); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + toast.success("E-mail reenviado. Confira sua caixa de entrada."); + }; + + if (needsConfirm) { + return ( + +

+
+ +
+

+ Clique no link do e-mail para ativar sua conta. Não esqueça de checar a pasta de spam.

- )} -
- ← Voltar para o início + + + Voltar para o login +
+ + ); + } + + return ( + + Já tem conta?{" "} + + Entrar + + + } + > +
+
+ + + {errors.full_name &&

{errors.full_name.message}

} +
+ +
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.password &&

{errors.password.message}

} + +
+ +
+ + setValue("accept_terms", v === true ? true : (false as unknown as true), { + shouldValidate: true, + }) + } + /> + +
+ {errors.accept_terms && ( +

{errors.accept_terms.message}

+ )} + + +
+ +
+
+ ou +
-
+ + + ); }