Concluiu Etapa 2 do setup

X-Lovable-Edit-ID: edt-258d3d85-2f10-45e4-a781-72397eb18445
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 17:07:15 +00:00
commit 293c772af5
29 changed files with 2824 additions and 274 deletions

BIN
bun.lockb

Binary file not shown.

440
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.25.5", "@cloudflare/vite-plugin": "^1.25.5",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@lovable.dev/cloud-auth-js": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",

View file

@ -0,0 +1,38 @@
import { Link } from "@tanstack/react-router";
import { Logo } from "./Logo";
import { ThemeToggle } from "./ThemeToggle";
export function AuthCard({
title,
subtitle,
children,
footer,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col bg-background">
<header className="flex items-center justify-between px-4 sm:px-6 py-4">
<Link to="/" aria-label="Início">
<Logo />
</Link>
<ThemeToggle />
</header>
<main className="flex-1 flex items-center justify-center px-4 py-8">
<div className="w-full max-w-md">
<div className="rounded-xl border border-border bg-card p-6 sm:p-8 shadow-soft">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{subtitle && <p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>}
</div>
{children}
</div>
{footer && <div className="mt-6 text-center text-sm text-muted-foreground">{footer}</div>}
</div>
</main>
</div>
);
}

View file

@ -6,6 +6,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { IMaskInput } from "react-imask"; import { IMaskInput } from "react-imask";
import { toast } from "sonner"; import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { translateAuthError } from "@/lib/auth-errors";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@ -37,17 +39,22 @@ export function EnterpriseLeadForm() {
const onSubmit = async (data: FormValues) => { const onSubmit = async (data: FormValues) => {
setSubmitting(true); setSubmitting(true);
try { const { error } = await supabase.from("enterprise_leads").insert({
// Etapa 2: gravar em enterprise_leads via supabase.from(...).insert(...) company_name: data.company_name,
// Por ora: feedback otimista para o usuário. contact_name: data.contact_name,
await new Promise((r) => setTimeout(r, 700)); email: data.email,
phone: data.phone,
team_size: data.team_size,
message: data.message || null,
status: "new",
});
setSubmitting(false);
if (error) {
toast.error(translateAuthError(error.message));
return;
}
toast.success("Recebemos seu contato! Falamos com você em até 1 dia útil."); toast.success("Recebemos seu contato! Falamos com você em até 1 dia útil.");
reset(); reset();
} catch {
toast.error("Algo deu errado do nosso lado. Já estamos investigando.");
} finally {
setSubmitting(false);
}
}; };
const phone = watch("phone") || ""; const phone = watch("phone") || "";

View file

@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { lovable } from "@/integrations/lovable/index";
import { translateAuthError } from "@/lib/auth-errors";
export function GoogleButton({ redirectTo }: { redirectTo?: string }) {
const [loading, setLoading] = useState(false);
const onClick = async () => {
setLoading(true);
try {
const url = redirectTo
? `${window.location.origin}${redirectTo}`
: window.location.origin;
const result = await lovable.auth.signInWithOAuth("google", { redirect_uri: url });
if (result.error) {
toast.error(translateAuthError(result.error.message));
setLoading(false);
return;
}
if (result.redirected) return;
// tokens recebidos: redireciona
window.location.href = url;
} catch (e) {
toast.error(translateAuthError(e instanceof Error ? e.message : String(e)));
setLoading(false);
}
};
return (
<Button
type="button"
variant="outline"
onClick={onClick}
disabled={loading}
className="w-full rounded-lg gap-2"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden>
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"/>
</svg>
)}
Entrar com Google
</Button>
);
}

View file

@ -5,6 +5,7 @@ import { Link } from "@tanstack/react-router";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetHeader } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetHeader } from "@/components/ui/sheet";
import { useAuth } from "@/hooks/use-auth";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -19,6 +20,7 @@ const NAV = [
export function LandingHeader() { export function LandingHeader() {
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { user } = useAuth();
useEffect(() => { useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8); const onScroll = () => setScrolled(window.scrollY > 8);
@ -53,12 +55,20 @@ export function LandingHeader() {
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
{user ? (
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]">
<Link to="/painel">Ir para o painel</Link>
</Button>
) : (
<>
<Button asChild variant="ghost" className="rounded-lg"> <Button asChild variant="ghost" className="rounded-lg">
<Link to="/login">Entrar</Link> <Link to="/login">Entrar</Link>
</Button> </Button>
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"> <Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]">
<Link to="/signup">Começar agora</Link> <Link to="/signup">Começar agora</Link>
</Button> </Button>
</>
)}
</div> </div>
<div className="flex md:hidden items-center gap-1"> <div className="flex md:hidden items-center gap-1">
@ -86,12 +96,20 @@ export function LandingHeader() {
))} ))}
</nav> </nav>
<div className="mt-6 flex flex-col gap-3"> <div className="mt-6 flex flex-col gap-3">
{user ? (
<Button asChild className="rounded-lg w-full bg-primary hover:bg-primary-dark text-primary-foreground">
<Link to="/painel" onClick={() => setOpen(false)}>Ir para o painel</Link>
</Button>
) : (
<>
<Button asChild variant="outline" className="rounded-lg w-full"> <Button asChild variant="outline" className="rounded-lg w-full">
<Link to="/login" onClick={() => setOpen(false)}>Entrar</Link> <Link to="/login" onClick={() => setOpen(false)}>Entrar</Link>
</Button> </Button>
<Button asChild className="rounded-lg w-full bg-primary hover:bg-primary-dark text-primary-foreground"> <Button asChild className="rounded-lg w-full bg-primary hover:bg-primary-dark text-primary-foreground">
<Link to="/signup" onClick={() => setOpen(false)}>Começar agora</Link> <Link to="/signup" onClick={() => setOpen(false)}>Começar agora</Link>
</Button> </Button>
</>
)}
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View file

@ -0,0 +1,36 @@
"use client";
import { passwordStrength, passwordStrengthLabel } from "@/lib/password";
import { cn } from "@/lib/utils";
export function PasswordStrengthMeter({ password }: { password: string }) {
if (!password) return null;
const score = passwordStrength(password);
const colors = [
"bg-destructive",
"bg-destructive",
"bg-warning",
"bg-success",
];
return (
<div className="space-y-1.5" aria-live="polite">
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<div
key={i}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
i < score ? colors[score] : "bg-muted",
)}
/>
))}
</div>
<p className={cn(
"text-xs",
score < 2 ? "text-destructive" : score === 2 ? "text-warning" : "text-success",
)}>
Força: {passwordStrengthLabel(score)}
</p>
</div>
);
}

View file

@ -3,91 +3,63 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger,
} from "@/components/ui/dialog";
import { EnterpriseLeadForm } from "./EnterpriseLeadForm"; import { EnterpriseLeadForm } from "./EnterpriseLeadForm";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// Fallback estático — na Etapa 2 será substituído por query Supabase à tabela `plans`. interface PlanRow {
const STATIC_PLANS = [ id: string;
{ slug: string;
slug: "basic", name: string;
name: "Basic", description: string | null;
description: "Para começar a usar IA no dia a dia.", price_monthly_brl: number | null;
monthly: 69.9, price_yearly_brl: number | null;
yearly: 671.04, features: string[];
highlighted: false, highlighted: boolean;
is_enterprise: false, is_enterprise: boolean;
features: [ display_order: number;
"1 agente pessoal no Telegram", }
"Memória persistente básica",
"Integração Google Workspace",
"5 skills personalizadas",
"Suporte por e-mail",
],
},
{
slug: "starter",
name: "Starter",
description: "Para quem usa IA todos os dias.",
monthly: 199.9,
yearly: 1919.04,
highlighted: false,
is_enterprise: false,
features: [
"Tudo do Basic",
"Memória avançada e contextual",
"Skills ilimitadas",
"Agendamentos automáticos",
"Suporte prioritário",
],
},
{
slug: "professional",
name: "Professional",
description: "Para profissionais e times pequenos.",
monthly: 399.9,
yearly: 3839.04,
highlighted: true,
is_enterprise: false,
features: [
"Tudo do Starter",
"VPS dedicada de alta performance",
"Modelos de IA premium",
"Integrações personalizadas",
"Onboarding 1:1",
"Suporte em horário estendido",
],
},
{
slug: "enterprise",
name: "Enterprise",
description: "Para empresas com necessidades específicas.",
monthly: null,
yearly: null,
highlighted: false,
is_enterprise: true,
features: [
"Tudo do Professional",
"Múltiplos agentes",
"SSO e gestão de equipe",
"SLA contratual",
"Treinamento da equipe",
"Account manager dedicado",
],
},
] as const;
const fmtBRL = (v: number) => const fmtBRL = (v: number) =>
v.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); v.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function usePlans() {
return useQuery({
queryKey: ["plans"],
staleTime: 5 * 60_000,
queryFn: async (): Promise<PlanRow[]> => {
const { data, error } = await supabase
.from("plans")
.select("*")
.order("display_order", { ascending: true });
if (error) throw error;
return (data || []).map((p) => ({
...p,
features: Array.isArray(p.features) ? (p.features as string[]) : [],
})) as PlanRow[];
},
});
}
export function PlansSection() { export function PlansSection() {
const [yearly, setYearly] = useState(false); const [yearly, setYearly] = useState(false);
const { data: plans, isLoading } = usePlans();
return ( return (
<section id="planos" className="py-20 sm:py-28 bg-background"> <section id="planos" className="py-20 sm:py-28 bg-background scroll-mt-20">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto text-center"> <div className="max-w-2xl mx-auto text-center">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight">Planos para cada estágio</h2> <h2 className="text-3xl sm:text-4xl font-bold tracking-tight">Planos para cada estágio</h2>
@ -96,21 +68,42 @@ export function PlansSection() {
</p> </p>
<div className="mt-8 inline-flex items-center gap-3 bg-muted rounded-full px-4 py-2"> <div className="mt-8 inline-flex items-center gap-3 bg-muted rounded-full px-4 py-2">
<span className={cn("text-sm font-medium", !yearly && "text-foreground", yearly && "text-muted-foreground")}> <span
className={cn(
"text-sm font-medium",
!yearly ? "text-foreground" : "text-muted-foreground",
)}
>
Mensal Mensal
</span> </span>
<Switch checked={yearly} onCheckedChange={setYearly} aria-label="Alternar entre mensal e anual" /> <Switch
<span className={cn("text-sm font-medium flex items-center gap-2", yearly && "text-foreground", !yearly && "text-muted-foreground")}> checked={yearly}
onCheckedChange={setYearly}
aria-label="Alternar entre mensal e anual"
/>
<span
className={cn(
"text-sm font-medium flex items-center gap-2",
yearly ? "text-foreground" : "text-muted-foreground",
)}
>
Anual Anual
<Badge className="bg-success/15 text-success hover:bg-success/15 border-0 font-semibold">20% off</Badge> <Badge className="bg-success/15 text-success hover:bg-success/15 border-0 font-semibold">
20% off
</Badge>
</span> </span>
</div> </div>
</div> </div>
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch"> <div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{STATIC_PLANS.map((plan) => { {isLoading
const price = yearly ? plan.yearly : plan.monthly; ? Array.from({ length: 4 }).map((_, i) => (
const monthlyEquivalent = yearly && plan.yearly ? plan.yearly / 12 : null; <Skeleton key={i} className="h-[480px] rounded-xl" />
))
: (plans || []).map((plan) => {
const monthly = plan.price_monthly_brl ?? 0;
const yearlyPrice = plan.price_yearly_brl ?? 0;
const monthlyEquivalent = yearly && yearlyPrice ? yearlyPrice / 12 : monthly;
return ( return (
<article <article
key={plan.slug} key={plan.slug}
@ -140,13 +133,13 @@ export function PlansSection() {
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-sm text-muted-foreground">R$</span> <span className="text-sm text-muted-foreground">R$</span>
<span className="text-4xl font-bold tracking-tight"> <span className="text-4xl font-bold tracking-tight">
{fmtBRL(yearly && monthlyEquivalent ? monthlyEquivalent : (price as number))} {fmtBRL(monthlyEquivalent)}
</span> </span>
<span className="text-sm text-muted-foreground">/mês</span> <span className="text-sm text-muted-foreground">/mês</span>
</div> </div>
{yearly && ( {yearly && yearlyPrice > 0 && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
R$ {fmtBRL(plan.yearly!)} cobrados anualmente R$ {fmtBRL(yearlyPrice)} cobrados anualmente
</p> </p>
)} )}
</> </>
@ -156,7 +149,10 @@ export function PlansSection() {
<ul className="mt-6 space-y-3 flex-1"> <ul className="mt-6 space-y-3 flex-1">
{plan.features.map((f) => ( {plan.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm"> <li key={f} className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 text-success mt-0.5 flex-shrink-0" aria-hidden /> <Check
className="h-4 w-4 text-success mt-0.5 flex-shrink-0"
aria-hidden
/>
<span>{f}</span> <span>{f}</span>
</li> </li>
))} ))}
@ -166,13 +162,16 @@ export function PlansSection() {
{plan.is_enterprise ? ( {plan.is_enterprise ? (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="w-full rounded-lg">Falar com vendas</Button> <Button variant="outline" className="w-full rounded-lg">
Falar com vendas
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Plano Enterprise</DialogTitle> <DialogTitle>Plano Enterprise</DialogTitle>
<DialogDescription> <DialogDescription>
Conte um pouco sobre sua empresa. Nosso time entra em contato em até 1 dia útil. Conte um pouco sobre sua empresa. Nosso time entra em contato em até
1 dia útil.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<EnterpriseLeadForm /> <EnterpriseLeadForm />
@ -188,7 +187,10 @@ export function PlansSection() {
: "bg-foreground hover:bg-foreground/90 text-background", : "bg-foreground hover:bg-foreground/90 text-background",
)} )}
> >
<Link to="/signup" search={{ plan: plan.slug, cycle: yearly ? "yearly" : "monthly" } as never}> <Link
to="/signup"
search={{ plan: plan.slug, cycle: yearly ? "yearly" : "monthly" }}
>
Assinar agora Assinar agora
</Link> </Link>
</Button> </Button>

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>
);
}

34
src/hooks/use-auth.ts Normal file
View file

@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import type { Session, User } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
interface AuthState {
user: User | null;
session: Session | null;
loading: boolean;
}
export function useAuth(): AuthState {
const [state, setState] = useState<AuthState>({
user: null,
session: null,
loading: true,
});
useEffect(() => {
// CRITICAL: subscribe BEFORE getSession (avoid missed events)
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setState({ user: session?.user ?? null, session, loading: false });
});
supabase.auth.getSession().then(({ data: { session } }) => {
setState({ user: session?.user ?? null, session, loading: false });
});
return () => subscription.unsubscribe();
}, []);
return state;
}

68
src/hooks/use-profile.ts Normal file
View file

@ -0,0 +1,68 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "./use-auth";
export interface Profile {
id: string;
full_name: string;
company_name: string | null;
cpf_cnpj: string | null;
phone: string | null;
avatar_url: string | null;
stripe_customer_id: string | null;
onboarding_completed: boolean;
}
export function useProfile() {
const { user } = useAuth();
return useQuery({
queryKey: ["profile", user?.id],
enabled: !!user,
queryFn: async (): Promise<Profile | null> => {
if (!user) return null;
const { data, error } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.maybeSingle();
if (error) throw error;
return data as Profile | null;
},
});
}
export interface SubscriptionRow {
id: string;
user_id: string;
plan_id: string | null;
stripe_subscription_id: string | null;
status: "active" | "trialing" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "unpaid";
billing_cycle: "monthly" | "yearly";
current_period_start: string | null;
current_period_end: string | null;
cancel_at_period_end: boolean;
}
export function useSubscription() {
const { user } = useAuth();
return useQuery({
queryKey: ["subscription", user?.id],
enabled: !!user,
queryFn: async (): Promise<SubscriptionRow | null> => {
if (!user) return null;
const { data, error } = await supabase
.from("subscriptions")
.select("*")
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return data as SubscriptionRow | null;
},
});
}

View file

@ -0,0 +1,38 @@
// This file is auto-generated by Lovable. Do not modify it.
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
import { supabase } from "../supabase/client";
const lovableAuth = createLovableAuth();
type SignInOptions = {
redirect_uri?: string;
extraParams?: Record<string, string>;
};
export const lovable = {
auth: {
signInWithOAuth: async (provider: "google" | "apple" | "microsoft", opts?: SignInOptions) => {
const result = await lovableAuth.signInWithOAuth(provider, {
redirect_uri: opts?.redirect_uri,
extraParams: {
...opts?.extraParams,
},
});
if (result.redirected) {
return result;
}
if (result.error) {
return result;
}
try {
await supabase.auth.setSession(result.tokens);
} catch (e) {
return { error: e instanceof Error ? e : new Error(String(e)) };
}
return result;
},
},
};

View file

@ -14,7 +14,257 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
[_ in never]: never agent_instances: {
Row: {
container_name: string | null
created_at: string
id: string
status: string
telegram_bot_token_vault_id: string | null
telegram_bot_username: string | null
updated_at: string
user_id: string
uuid_tenant: string
vps_host: string | null
}
Insert: {
container_name?: string | null
created_at?: string
id?: string
status?: string
telegram_bot_token_vault_id?: string | null
telegram_bot_username?: string | null
updated_at?: string
user_id: string
uuid_tenant?: string
vps_host?: string | null
}
Update: {
container_name?: string | null
created_at?: string
id?: string
status?: string
telegram_bot_token_vault_id?: string | null
telegram_bot_username?: string | null
updated_at?: string
user_id?: string
uuid_tenant?: string
vps_host?: string | null
}
Relationships: [
{
foreignKeyName: "agent_instances_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
enterprise_leads: {
Row: {
company_name: string
contact_name: string
created_at: string
email: string
id: string
message: string | null
phone: string | null
status: string
team_size: string
}
Insert: {
company_name: string
contact_name: string
created_at?: string
email: string
id?: string
message?: string | null
phone?: string | null
status?: string
team_size: string
}
Update: {
company_name?: string
contact_name?: string
created_at?: string
email?: string
id?: string
message?: string | null
phone?: string | null
status?: string
team_size?: string
}
Relationships: []
}
plans: {
Row: {
created_at: string
description: string | null
display_order: number
features: Json
highlighted: boolean
id: string
is_enterprise: boolean
name: string
price_monthly_brl: number | null
price_yearly_brl: number | null
slug: string
stripe_price_id_monthly: string | null
stripe_price_id_yearly: string | null
}
Insert: {
created_at?: string
description?: string | null
display_order?: number
features?: Json
highlighted?: boolean
id?: string
is_enterprise?: boolean
name: string
price_monthly_brl?: number | null
price_yearly_brl?: number | null
slug: string
stripe_price_id_monthly?: string | null
stripe_price_id_yearly?: string | null
}
Update: {
created_at?: string
description?: string | null
display_order?: number
features?: Json
highlighted?: boolean
id?: string
is_enterprise?: boolean
name?: string
price_monthly_brl?: number | null
price_yearly_brl?: number | null
slug?: string
stripe_price_id_monthly?: string | null
stripe_price_id_yearly?: string | null
}
Relationships: []
}
profiles: {
Row: {
avatar_url: string | null
company_name: string | null
cpf_cnpj: string | null
created_at: string
full_name: string
id: string
onboarding_completed: boolean
phone: string | null
stripe_customer_id: string | null
updated_at: string
}
Insert: {
avatar_url?: string | null
company_name?: string | null
cpf_cnpj?: string | null
created_at?: string
full_name?: string
id: string
onboarding_completed?: boolean
phone?: string | null
stripe_customer_id?: string | null
updated_at?: string
}
Update: {
avatar_url?: string | null
company_name?: string | null
cpf_cnpj?: string | null
created_at?: string
full_name?: string
id?: string
onboarding_completed?: boolean
phone?: string | null
stripe_customer_id?: string | null
updated_at?: string
}
Relationships: []
}
stripe_webhook_events: {
Row: {
event_type: string
id: string
payload: Json | null
processed_at: string
stripe_event_id: string
}
Insert: {
event_type: string
id?: string
payload?: Json | null
processed_at?: string
stripe_event_id: string
}
Update: {
event_type?: string
id?: string
payload?: Json | null
processed_at?: string
stripe_event_id?: string
}
Relationships: []
}
subscriptions: {
Row: {
billing_cycle: string
cancel_at_period_end: boolean
created_at: string
current_period_end: string | null
current_period_start: string | null
id: string
plan_id: string | null
status: string
stripe_subscription_id: string | null
updated_at: string
user_id: string
}
Insert: {
billing_cycle: string
cancel_at_period_end?: boolean
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
id?: string
plan_id?: string | null
status: string
stripe_subscription_id?: string | null
updated_at?: string
user_id: string
}
Update: {
billing_cycle?: string
cancel_at_period_end?: boolean
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
id?: string
plan_id?: string | null
status?: string
stripe_subscription_id?: string | null
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "subscriptions_plan_id_fkey"
columns: ["plan_id"]
isOneToOne: false
referencedRelation: "plans"
referencedColumns: ["id"]
},
{
foreignKeyName: "subscriptions_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never

26
src/lib/auth-errors.ts Normal file
View file

@ -0,0 +1,26 @@
/**
* Traduz mensagens de erro do Supabase Auth para mensagens em português específicas.
*/
export function translateAuthError(message: string | undefined): string {
if (!message) return "Algo deu errado. Tente novamente.";
const m = message.toLowerCase();
if (m.includes("invalid login credentials") || m.includes("invalid_credentials"))
return "E-mail ou senha incorretos. Tente novamente.";
if (m.includes("user already registered") || m.includes("already been registered") || m.includes("already exists"))
return "Esse e-mail já possui cadastro. Deseja fazer login?";
if (m.includes("password") && (m.includes("short") || m.includes("weak") || m.includes("characters")))
return "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número.";
if (m.includes("email") && m.includes("invalid"))
return "Formato de e-mail inválido.";
if (m.includes("rate limit") || m.includes("too many requests"))
return "Muitas tentativas. Aguarde 1 minuto e tente novamente.";
if (m.includes("expired") || m.includes("jwt") || m.includes("session"))
return "Sua sessão expirou. Faça login novamente.";
if (m.includes("email not confirmed"))
return "Confirme seu e-mail antes de entrar. Verifique sua caixa de entrada.";
if (m.includes("pwned") || m.includes("compromised") || m.includes("breach"))
return "Esta senha apareceu em vazamentos públicos. Escolha outra mais forte.";
return message;
}

14
src/lib/password.ts Normal file
View file

@ -0,0 +1,14 @@
export type PasswordStrength = 0 | 1 | 2 | 3;
export function passwordStrength(password: string): PasswordStrength {
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
if (/\d/.test(password) && /[^A-Za-z0-9]/.test(password)) score++;
if (password.length >= 12) score = Math.min(3, score + 1) as PasswordStrength;
return Math.min(3, score) as PasswordStrength;
}
export function passwordStrengthLabel(s: PasswordStrength): string {
return ["Muito fraca", "Fraca", "Média", "Forte"][s];
}

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">
Clique no link do e-mail para ativar sua conta. Não esqueça de checar a pasta de spam.
</p> </p>
{plan && ( <Button onClick={resend} variant="outline" className="rounded-lg w-full">
<p className="text-sm bg-primary/10 text-primary rounded-lg px-3 py-2 inline-block"> Reenviar e-mail de confirmação
Plano selecionado: <strong>{plan}</strong> · {cycle === "yearly" ? "anual" : "mensal"} </Button>
</p> <Link to="/login" className="block text-sm text-primary hover:underline">
)} Voltar para o login
<div> </Link>
<Link to="/" className="inline-block text-primary hover:underline"> Voltar para o início</Link>
</div>
</div>
</div> </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>
)}
<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" />}
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>
<GoogleButton redirectTo="/painel" />
</AuthCard>
); );
} }

View file

@ -0,0 +1,210 @@
-- ============================================
-- HELPER: trigger para updated_at
-- ============================================
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
-- ============================================
-- PROFILES
-- ============================================
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
full_name TEXT NOT NULL DEFAULT '',
company_name TEXT,
cpf_cnpj TEXT,
phone TEXT,
avatar_url TEXT,
stripe_customer_id TEXT UNIQUE,
onboarding_completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_profiles_stripe_customer_id ON public.profiles(stripe_customer_id);
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Usuários veem o próprio perfil"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Usuários atualizam o próprio perfil"
ON public.profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Usuários inserem o próprio perfil"
ON public.profiles FOR INSERT
WITH CHECK (auth.uid() = id);
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Trigger: cria profile automaticamente no signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.profiles (id, full_name)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'full_name', '')
);
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- ============================================
-- PLANS
-- ============================================
CREATE TABLE public.plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
price_monthly_brl NUMERIC(10,2),
price_yearly_brl NUMERIC(10,2),
stripe_price_id_monthly TEXT,
stripe_price_id_yearly TEXT,
features JSONB NOT NULL DEFAULT '[]'::jsonb,
highlighted BOOLEAN NOT NULL DEFAULT false,
is_enterprise BOOLEAN NOT NULL DEFAULT false,
display_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Planos são públicos"
ON public.plans FOR SELECT
USING (true);
INSERT INTO public.plans (slug, name, description, price_monthly_brl, price_yearly_brl, features, highlighted, is_enterprise, display_order) VALUES
('basic', 'Basic', 'Para quem está começando a explorar IA pessoal', 69.90, 671.04,
'["Memória persistente básica","Até 500 mensagens/mês","Integração com Telegram","Suporte por e-mail"]'::jsonb,
false, false, 1),
('starter', 'Starter', 'Para profissionais que querem produtividade real', 199.90, 1919.04,
'["Memória persistente avançada","Até 3.000 mensagens/mês","Integração Google Workspace","Skills personalizadas (até 10)","Suporte prioritário"]'::jsonb,
false, false, 2),
('professional', 'Professional', 'Para quem usa IA o dia inteiro', 399.90, 3839.04,
'["Memória persistente ilimitada","Mensagens ilimitadas","Skills ilimitadas","Agendamentos automáticos","Integrações premium","Suporte dedicado em horário comercial"]'::jsonb,
true, false, 3),
('enterprise', 'Enterprise', 'Para times e empresas', NULL, NULL,
'["Tudo do Professional","Múltiplos usuários","SSO e auditoria","SLA garantido","Onboarding personalizado","Gerente de conta dedicado"]'::jsonb,
false, true, 4);
-- ============================================
-- SUBSCRIPTIONS
-- ============================================
CREATE TABLE public.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
plan_id UUID REFERENCES public.plans(id),
stripe_subscription_id TEXT UNIQUE,
status TEXT NOT NULL CHECK (status IN ('active','trialing','past_due','canceled','incomplete','incomplete_expired','unpaid')),
billing_cycle TEXT NOT NULL CHECK (billing_cycle IN ('monthly','yearly')),
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe_subscription_id ON public.subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_status ON public.subscriptions(status);
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Usuários veem a própria assinatura"
ON public.subscriptions FOR SELECT
USING (auth.uid() = user_id);
CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- ============================================
-- AGENT INSTANCES
-- ============================================
CREATE TABLE public.agent_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'provisioning' CHECK (status IN ('provisioning','active','suspended','error')),
vps_host TEXT,
container_name TEXT,
telegram_bot_token_vault_id UUID,
telegram_bot_username TEXT,
uuid_tenant UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_agent_instances_user_id ON public.agent_instances(user_id);
CREATE INDEX idx_agent_instances_uuid_tenant ON public.agent_instances(uuid_tenant);
CREATE INDEX idx_agent_instances_status ON public.agent_instances(status);
ALTER TABLE public.agent_instances ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Usuários veem o próprio agente"
ON public.agent_instances FOR SELECT
USING (auth.uid() = user_id);
CREATE TRIGGER update_agent_instances_updated_at
BEFORE UPDATE ON public.agent_instances
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- ============================================
-- STRIPE WEBHOOK EVENTS (idempotência)
-- ============================================
CREATE TABLE public.stripe_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stripe_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload JSONB
);
CREATE INDEX idx_stripe_webhook_events_event_id ON public.stripe_webhook_events(stripe_event_id);
CREATE INDEX idx_stripe_webhook_events_event_type ON public.stripe_webhook_events(event_type);
ALTER TABLE public.stripe_webhook_events ENABLE ROW LEVEL SECURITY;
-- Sem policies: apenas service_role acessa
-- ============================================
-- ENTERPRISE LEADS
-- ============================================
CREATE TABLE public.enterprise_leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_name TEXT NOT NULL,
contact_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
team_size TEXT NOT NULL CHECK (team_size IN ('1-10','11-50','51-200','200+')),
message TEXT,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','contacted','qualified','converted','lost')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.enterprise_leads ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Qualquer pessoa pode enviar lead"
ON public.enterprise_leads FOR INSERT
WITH CHECK (true);
-- Leitura: apenas service_role

View file

@ -0,0 +1,15 @@
DROP POLICY IF EXISTS "Qualquer pessoa pode enviar lead" ON public.enterprise_leads;
CREATE POLICY "Visitantes podem enviar lead"
ON public.enterprise_leads FOR INSERT
TO anon, authenticated
WITH CHECK (
char_length(company_name) BETWEEN 1 AND 200
AND char_length(contact_name) BETWEEN 1 AND 200
AND char_length(email) BETWEEN 3 AND 320
AND email ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$'
AND team_size IN ('1-10','11-50','51-200','200+')
AND (message IS NULL OR char_length(message) <= 2000)
AND status = 'new'
);