Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 17:05:05 +00:00
parent fa8bee0e36
commit 104dd8af7e
12 changed files with 1428 additions and 50 deletions

View file

@ -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 (
<Banner
tone="destructive"
icon={AlertCircle}
title="Pagamento pendente."
description="Atualize seu método de pagamento para não perder acesso."
action={
<Button
disabled
variant="outline"
className="rounded-lg border-destructive/40 text-destructive hover:bg-destructive/10"
>
Atualizar pagamento
</Button>
}
/>
);
}
// Cancelamento agendado
if (subscription.cancel_at_period_end && periodEnd && !periodEndPassed) {
return (
<Banner
tone="warning"
icon={AlertTriangle}
title={`Sua assinatura será encerrada em ${format(periodEnd, "d 'de' MMMM 'de' yyyy", { locale: ptBR })}.`}
description="Você ainda tem acesso completo até essa data."
action={
<Button disabled variant="outline" className="rounded-lg">
Reativar assinatura
</Button>
}
/>
);
}
// Cancelada e período já passou
if (subscription.status === "canceled" && periodEndPassed) {
return (
<Banner
tone="muted"
icon={Info}
title="Sua assinatura foi encerrada."
description="Para voltar a usar o Mika, escolha um novo plano."
action={
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground">
<Link to="/" hash="planos">Assinar novamente</Link>
</Button>
}
/>
);
}
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 (
<div
className={cn(
"rounded-xl border p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center gap-4",
tones[tone],
)}
>
<div className="flex items-start gap-3 flex-1">
<Icon className="h-5 w-5 mt-0.5 shrink-0" />
<div>
<p className="font-semibold">{title}</p>
<p className={cn("text-sm mt-0.5", tone === "muted" ? "text-muted-foreground" : "opacity-90")}>
{description}
</p>
</div>
</div>
<div className="shrink-0 sm:ml-4">{action}</div>
</div>
);
}

View file

@ -10,14 +10,35 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as SignupRouteImport } from './routes/signup' 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 LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index' 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({ const SignupRoute = SignupRouteImport.update({
id: '/signup', id: '/signup',
path: '/signup', path: '/signup',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
path: '/login', path: '/login',
@ -28,34 +49,96 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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 { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/painel': typeof PainelRouteWithChildren
'/recuperar-senha': typeof RecuperarSenhaRoute
'/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/': typeof PainelIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/recuperar-senha': typeof RecuperarSenhaRoute
'/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute
'/painel': typeof PainelIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/painel': typeof PainelRouteWithChildren
'/recuperar-senha': typeof RecuperarSenhaRoute
'/redefinir-senha': typeof RedefinirSenhaRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/': typeof PainelIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login' | '/signup' fullPaths:
| '/'
| '/login'
| '/painel'
| '/recuperar-senha'
| '/redefinir-senha'
| '/signup'
| '/painel/configuracoes'
| '/painel/faturamento'
| '/painel/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/login' | '/signup' to:
id: '__root__' | '/' | '/login' | '/signup' | '/'
| '/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 fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PainelRoute: typeof PainelRouteWithChildren
RecuperarSenhaRoute: typeof RecuperarSenhaRoute
RedefinirSenhaRoute: typeof RedefinirSenhaRoute
SignupRoute: typeof SignupRoute SignupRoute: typeof SignupRoute
} }
@ -68,6 +151,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SignupRouteImport preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport 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': { '/login': {
id: '/login' id: '/login'
path: '/login' path: '/login'
@ -82,14 +186,62 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport 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 = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PainelRoute: PainelRouteWithChildren,
RecuperarSenhaRoute: RecuperarSenhaRoute,
RedefinirSenhaRoute: RedefinirSenhaRoute,
SignupRoute: SignupRoute, SignupRoute: SignupRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

View file

@ -1,4 +1,5 @@
import { createRouter, useRouter } from "@tanstack/react-router"; import { createRouter, useRouter } from "@tanstack/react-router";
import { QueryClient } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => void }) { function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
@ -23,9 +24,9 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
/> />
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">Something went wrong</h1> <h1 className="text-2xl font-bold tracking-tight text-foreground">Algo deu errado</h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
An unexpected error occurred. Please try again. Ocorreu um erro inesperado. Tente novamente.
</p> </p>
{import.meta.env.DEV && error.message && ( {import.meta.env.DEV && error.message && (
<pre className="mt-4 max-h-40 overflow-auto rounded-md bg-muted p-3 text-left font-mono text-xs text-destructive"> <pre className="mt-4 max-h-40 overflow-auto rounded-md bg-muted p-3 text-left font-mono text-xs text-destructive">
@ -38,15 +39,15 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
router.invalidate(); router.invalidate();
reset(); 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
</button> </button>
<a <a
href="/" href="/"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent" className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
> >
Go home Ir para o início
</a> </a>
</div> </div>
</div> </div>
@ -55,9 +56,20 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
} }
export const getRouter = () => { 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({ const router = createRouter({
routeTree, routeTree,
context: {}, context: { queryClient },
scrollRestoration: true, scrollRestoration: true,
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
defaultErrorComponent: DefaultErrorComponent, defaultErrorComponent: DefaultErrorComponent,

View file

@ -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 { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import appCss from "../styles.css?url"; import appCss from "../styles.css?url";
interface RouterContext {
queryClient: QueryClient;
}
function NotFoundComponent() { function NotFoundComponent() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background px-4"> <div className="flex min-h-screen items-center justify-center bg-background px-4">
@ -14,19 +20,19 @@ function NotFoundComponent() {
A página que você procura não existe ou foi movida. A página que você procura não existe ou foi movida.
</p> </p>
<div className="mt-6"> <div className="mt-6">
<a <Link
href="/" to="/"
className="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-dark" className="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-dark"
> >
Ir para o início Ir para o início
</a> </Link>
</div> </div>
</div> </div>
</div> </div>
); );
} }
export const Route = createRootRoute({ export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({ head: () => ({
meta: [ meta: [
{ charSet: "utf-8" }, { charSet: "utf-8" },
@ -88,10 +94,13 @@ function RootShell({ children }: { children: React.ReactNode }) {
} }
function RootComponent() { function RootComponent() {
const { queryClient } = Route.useRouteContext();
return ( return (
<QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<Outlet /> <Outlet />
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
</ThemeProvider> </ThemeProvider>
</QueryClientProvider>
); );
} }

View file

@ -1,21 +1,106 @@
import { createFileRoute, Link } from "@tanstack/react-router"; "use client";
import { Logo } from "@/components/mika/Logo";
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<typeof schema>;
export const Route = createFileRoute("/login")({ export const Route = createFileRoute("/login")({
component: LoginPlaceholder, validateSearch: (search: Record<string, unknown>): 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<FormValues>({
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 ( return (
<div className="min-h-screen grid place-items-center bg-background px-4"> <AuthCard
<div className="max-w-md text-center space-y-4"> title="Entrar"
<Logo size="lg" /> subtitle="Acesse seu agente Mika"
<h1 className="text-2xl font-bold">Login</h1> footer={
<p className="text-muted-foreground"> <>
A página de login completa será implementada na Etapa 2 (Auth + Supabase). Não tem conta?{" "}
</p> <Link to="/signup" className="text-primary font-medium hover:underline">
<Link to="/" className="inline-block text-primary hover:underline"> Voltar para o início</Link> Criar conta
</Link>
</>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
</div> </div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Senha</Label>
<Link to="/recuperar-senha" className="text-xs text-primary hover:underline">
Esqueci minha senha
</Link>
</div> </div>
<Input id="password" type="password" autoComplete="current-password" {...register("password")} />
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
</div>
<Button
type="submit"
disabled={submitting}
className="w-full rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Entrar
</Button>
</form>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-muted-foreground">ou</span>
<div className="h-px flex-1 bg-border" />
</div>
<GoogleButton redirectTo={redirect || "/painel"} />
</AuthCard>
); );
} }

View file

@ -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<typeof profileSchema>;
function SettingsPage() {
const { user } = useAuth();
const { data: profile, isLoading } = useProfile();
const queryClient = useQueryClient();
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm<ProfileForm>({
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 <Skeleton className="h-96 w-full rounded-xl" />;
return (
<div className="space-y-8 max-w-2xl">
<header>
<h1 className="text-3xl font-bold tracking-tight">Configurações</h1>
<p className="mt-1 text-muted-foreground">Gerencie seu perfil e segurança.</p>
</header>
<section className="rounded-xl border border-border bg-card p-6 shadow-soft">
<h2 className="text-lg font-semibold mb-1">Perfil</h2>
<p className="text-sm text-muted-foreground mb-6">Informações para faturamento e suporte.</p>
<form onSubmit={handleSubmit((d) => updateProfile.mutate(d))} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="full_name">Nome completo</Label>
<Input id="full_name" {...register("full_name")} />
{errors.full_name && <p className="text-xs text-destructive">{errors.full_name.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="company_name">Empresa (opcional)</Label>
<Input id="company_name" {...register("company_name")} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="cpf_cnpj">CPF ou CNPJ</Label>
<IMaskInput
id="cpf_cnpj"
mask={cpfCnpjMask}
value={cpfCnpj}
onAccept={(v) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Telefone</Label>
<IMaskInput
id="phone"
mask="(00) 00000-0000"
value={phone}
onAccept={(v) => 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"
/>
</div>
</div>
<Button
type="submit"
disabled={updateProfile.isPending}
className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"
>
{updateProfile.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Salvar alterações
</Button>
</form>
</section>
<section className="rounded-xl border border-border bg-card p-6 shadow-soft">
<h2 className="text-lg font-semibold mb-1">Segurança</h2>
<p className="text-sm text-muted-foreground mb-6">
Para alterar sua senha, use o link de recuperação no e-mail.
</p>
<Button onClick={signOutAll} variant="outline" className="rounded-lg gap-2">
<LogOut className="h-4 w-4" />
Sair de todos os dispositivos
</Button>
</section>
</div>
);
}

View file

@ -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 (
<div className="space-y-8">
<header>
<h1 className="text-3xl font-bold tracking-tight">Faturamento</h1>
<p className="mt-1 text-muted-foreground">
Gerencie sua assinatura, método de pagamento e histórico.
</p>
</header>
{isLoading ? (
<Skeleton className="h-48 w-full rounded-xl" />
) : (
<section className="rounded-xl border border-border bg-card p-6 shadow-soft">
<h2 className="text-lg font-semibold">Plano atual</h2>
{subscription && plan ? (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Plano" value={plan.name} />
<Field
label="Valor"
value={`R$ ${(subscription.billing_cycle === "yearly"
? plan.price_yearly_brl
: plan.price_monthly_brl
)?.toLocaleString("pt-BR", { minimumFractionDigits: 2 })} / ${
subscription.billing_cycle === "yearly" ? "ano" : "mês"
}`}
/>
<Field
label="Próxima cobrança"
value={
subscription.current_period_end
? format(new Date(subscription.current_period_end), "d 'de' MMMM 'de' yyyy", {
locale: ptBR,
})
: "—"
}
/>
<Field label="Status" value={statusLabel(subscription.status)} />
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground">
Você ainda não possui uma assinatura ativa.
</p>
)}
<Button
disabled={!profile?.stripe_customer_id}
className="mt-6 rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
>
<ExternalLink className="h-4 w-4 mr-2" />
Gerenciar assinatura
</Button>
</section>
)}
<section className="rounded-xl border border-border bg-card p-6 shadow-soft">
<h2 className="text-lg font-semibold">Histórico de pagamentos</h2>
<p className="mt-3 text-sm text-muted-foreground">
Seu histórico aparecerá aqui após a primeira cobrança.
</p>
</section>
<section className="rounded-xl border border-border bg-card p-6 shadow-soft">
<h2 className="text-lg font-semibold">Método de pagamento</h2>
<div className="mt-4 flex items-center gap-3 text-sm text-muted-foreground">
<CreditCard className="h-5 w-5" />
<span>Nenhum método cadastrado</span>
</div>
<Button disabled variant="outline" className="mt-4 rounded-lg">
Trocar método
</Button>
</section>
</div>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 font-medium">{value}</p>
</div>
);
}
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;
}

132
src/routes/painel.index.tsx Normal file
View file

@ -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 (
<div className="space-y-4">
<Skeleton className="h-12 w-1/3" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
}
const firstName = (profile?.full_name || "").split(" ")[0] || "por aqui";
return (
<div className="space-y-6">
<SubscriptionBanner subscription={subscription ?? null} />
<header>
<h1 className="text-3xl font-bold tracking-tight">Olá, {firstName} 👋</h1>
<p className="mt-1 text-muted-foreground">
{subscription
? "Acompanhe abaixo o status do seu agente Mika."
: "Vamos colocar seu agente Mika no ar."}
</p>
</header>
{!subscription && <NoSubscriptionCard />}
{subscription && (subscription.status === "incomplete" || subscription.status === "active") && (
<ProvisioningCard />
)}
</div>
);
}
function NoSubscriptionCard() {
return (
<div className="rounded-xl border border-border bg-card p-8 sm:p-12 text-center shadow-soft">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<h2 className="mt-6 text-2xl font-bold">Escolha um plano para começar</h2>
<p className="mt-2 text-muted-foreground max-w-md mx-auto">
Em poucos minutos seu agente Mika estará disponível no Telegram, com memória persistente e
skills personalizadas.
</p>
<Button
asChild
size="lg"
className="mt-6 rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"
>
<Link to="/" hash="planos">
Ver planos <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
);
}
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 (
<div className="rounded-xl border border-border bg-card p-6 sm:p-8 shadow-soft">
<div className="flex items-start gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center animate-pulse">
<Loader2 className="h-5 w-5 text-primary animate-spin" />
</div>
<div className="flex-1">
<h2 className="text-xl font-bold">Seu agente Mika está sendo provisionado</h2>
<p className="mt-1 text-sm text-muted-foreground">
Você receberá um e-mail quando estiver pronto geralmente em até 10 minutos.
</p>
</div>
</div>
<ol className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{steps.map((step, i) => (
<li key={step.label} className="relative">
<div className="flex flex-col items-start gap-3">
<div
className={cn(
"h-10 w-10 rounded-full flex items-center justify-center font-bold text-sm shrink-0",
step.state === "active"
? "bg-primary text-primary-foreground shadow-glow"
: "bg-muted text-muted-foreground",
)}
>
{step.state === "active" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : step.state === "pending" ? (
i + 1
) : (
<CheckCircle2 className="h-5 w-5" />
)}
</div>
<p
className={cn(
"text-sm font-medium",
step.state === "active" ? "text-foreground" : "text-muted-foreground",
)}
>
{step.label}
</p>
</div>
</li>
))}
</ol>
</div>
);
}

237
src/routes/painel.tsx Normal file
View file

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
);
}
return (
<TooltipProvider>
<div className="min-h-screen flex bg-background">
<DesktopSidebar />
<div className="flex-1 flex flex-col min-w-0">
<header className="h-16 border-b border-border bg-card/50 backdrop-blur-sm flex items-center justify-between px-4 sm:px-6 sticky top-0 z-30">
<div className="flex items-center gap-2">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden" aria-label="Abrir menu">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-72 p-0">
<SheetHeader className="p-4 border-b">
<SheetTitle><Logo /></SheetTitle>
</SheetHeader>
<SidebarNav onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
<div className="lg:hidden"><Logo /></div>
</div>
<UserMenu />
</header>
<main className="flex-1 px-4 sm:px-6 py-6 sm:py-8">
<div className="mx-auto max-w-6xl">
<Outlet />
</div>
</main>
</div>
</div>
</TooltipProvider>
);
}
function DesktopSidebar() {
return (
<aside className="hidden lg:flex w-64 flex-col border-r border-border bg-sidebar shrink-0">
<div className="h-16 px-6 flex items-center border-b border-border">
<Link to="/" aria-label="Início"><Logo /></Link>
</div>
<SidebarNav />
</aside>
);
}
function SidebarNav({ onNavigate }: { onNavigate?: () => void } = {}) {
const location = useLocation();
return (
<nav className="flex-1 p-3 space-y-1" aria-label="Navegação do painel">
{NAV.map((item) => {
const Icon = item.icon;
if (item.disabled) {
return (
<Tooltip key={item.label} delayDuration={150}>
<TooltipTrigger asChild>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-muted-foreground/60 cursor-not-allowed">
<Icon className="h-4 w-4" />
{item.label}
</div>
</TooltipTrigger>
<TooltipContent side="right">Em breve</TooltipContent>
</Tooltip>
);
}
const active = location.pathname === item.to ||
(item.to !== "/painel" && location.pathname.startsWith(item.to));
return (
<Link
key={item.to}
to={item.to}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
);
}
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 (
<div className="flex items-center gap-2">
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="rounded-full p-1 h-auto" aria-label="Menu do usuário">
<Avatar className="h-8 w-8">
{profile?.avatar_url && <AvatarImage src={profile.avatar_url} alt="" />}
<AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
{initials}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="text-sm font-semibold">{profile?.full_name || "Usuário"}</div>
<div className="text-xs text-muted-foreground truncate">{user?.email}</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/painel/configuracoes" className="cursor-pointer">
<User className="h-4 w-4 mr-2" /> Perfil
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={signOut} className="cursor-pointer text-destructive focus:text-destructive">
<LogOut className="h-4 w-4 mr-2" /> Sair
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -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<typeof schema>;
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<FormValues>({
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 (
<AuthCard
title="Recuperar senha"
subtitle="Vamos te enviar um link para redefinir"
footer={
<Link to="/login" className="text-primary hover:underline">
Voltar para o login
</Link>
}
>
{sent ? (
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">
Se o e-mail informado existir em nossa base, você receberá o link em alguns minutos.
Verifique também a pasta de spam.
</p>
<Button asChild variant="outline" className="rounded-lg w-full">
<Link to="/login">Voltar para o login</Link>
</Button>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
</div>
<Button
type="submit"
disabled={submitting}
className="w-full rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Enviar link de recuperação
</Button>
</form>
)}
</AuthCard>
);
}

View file

@ -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<typeof schema>;
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<FormValues>({
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 (
<AuthCard title="Definir nova senha" subtitle="Escolha uma senha forte">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="password">Nova senha</Label>
<Input id="password" type="password" autoComplete="new-password" {...register("password")} />
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
<PasswordStrengthMeter password={password} />
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirmar nova senha</Label>
<Input id="confirm" type="password" autoComplete="new-password" {...register("confirm")} />
{errors.confirm && <p className="text-xs text-destructive">{errors.confirm.message}</p>}
</div>
<Button
type="submit"
disabled={submitting}
className="w-full rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Redefinir senha
</Button>
</form>
</AuthCard>
);
}

View file

@ -1,38 +1,215 @@
import { createFileRoute, Link } from "@tanstack/react-router"; "use client";
import { Logo } from "@/components/mika/Logo";
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 = { type SignupSearch = {
plan?: string; plan?: string;
cycle?: "monthly" | "yearly"; 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<typeof schema>;
export const Route = createFileRoute("/signup")({ export const Route = createFileRoute("/signup")({
validateSearch: (search: Record<string, unknown>): SignupSearch => ({ validateSearch: (search: Record<string, unknown>): SignupSearch => ({
plan: typeof search.plan === "string" ? search.plan : undefined, plan: typeof search.plan === "string" ? search.plan : undefined,
cycle: search.cycle === "yearly" || search.cycle === "monthly" ? search.cycle : undefined, cycle: search.cycle === "yearly" || search.cycle === "monthly" ? search.cycle : undefined,
}), }),
component: SignupPlaceholder, component: SignupPage,
}); });
function SignupPlaceholder() { function SignupPage() {
const { plan, cycle } = Route.useSearch(); const { plan, cycle } = Route.useSearch();
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [needsConfirm, setNeedsConfirm] = useState<string | null>(null);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormValues>({
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 ( return (
<div className="min-h-screen grid place-items-center bg-background px-4"> <AuthCard title="Confirme seu e-mail" subtitle={`Enviamos um link para ${needsConfirm}`}>
<div className="max-w-md text-center space-y-4"> <div className="text-center space-y-4">
<Logo size="lg" /> <div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<h1 className="text-2xl font-bold">Criar conta</h1> <MailCheck className="h-8 w-8 text-primary" />
<p className="text-muted-foreground"> </div>
Cadastro completo será implementado na Etapa 2. <p className="text-sm text-muted-foreground">
</p> Clique no link do e-mail para ativar sua conta. Não esqueça de checar a pasta de spam.
{plan && (
<p className="text-sm bg-primary/10 text-primary rounded-lg px-3 py-2 inline-block">
Plano selecionado: <strong>{plan}</strong> · {cycle === "yearly" ? "anual" : "mensal"}
</p> </p>
<Button onClick={resend} variant="outline" className="rounded-lg w-full">
Reenviar e-mail de confirmação
</Button>
<Link to="/login" className="block text-sm text-primary hover:underline">
Voltar para o login
</Link>
</div>
</AuthCard>
);
}
return (
<AuthCard
title="Criar conta"
subtitle={
plan
? `Plano selecionado: ${plan} · ${cycle === "yearly" ? "anual" : "mensal"}`
: "Comece em menos de 1 minuto"
}
footer={
<>
tem conta?{" "}
<Link to="/login" className="text-primary font-medium hover:underline">
Entrar
</Link>
</>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="full_name">Nome completo</Label>
<Input id="full_name" autoComplete="name" {...register("full_name")} />
{errors.full_name && <p className="text-xs text-destructive">{errors.full_name.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Senha</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
{...register("password")}
/>
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
<PasswordStrengthMeter password={password} />
</div>
<div className="flex items-start gap-2 pt-1">
<Checkbox
id="accept_terms"
onCheckedChange={(v) =>
setValue("accept_terms", v === true ? true : (false as unknown as true), {
shouldValidate: true,
})
}
/>
<Label htmlFor="accept_terms" className="text-xs text-muted-foreground leading-relaxed cursor-pointer">
Li e aceito os{" "}
<a href="/termos" className="text-primary hover:underline">
Termos de Uso
</a>{" "}
e a{" "}
<a href="/privacidade" className="text-primary hover:underline">
Política de Privacidade
</a>
.
</Label>
</div>
{errors.accept_terms && (
<p className="text-xs text-destructive">{errors.accept_terms.message}</p>
)} )}
<div>
<Link to="/" className="inline-block text-primary hover:underline"> Voltar para o início</Link> <Button
</div> type="submit"
</div> disabled={submitting}
className="w-full rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Criar conta
</Button>
</form>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-muted-foreground">ou</span>
<div className="h-px flex-1 bg-border" />
</div> </div>
<GoogleButton redirectTo="/painel" />
</AuthCard>
); );
} }