mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 05:16:48 +00:00
Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
parent
fa8bee0e36
commit
104dd8af7e
12 changed files with 1428 additions and 50 deletions
112
src/components/mika/SubscriptionBanner.tsx
Normal file
112
src/components/mika/SubscriptionBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,14 +10,35 @@
|
|||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SignupRouteImport } from './routes/signup'
|
||||
import { Route as RedefinirSenhaRouteImport } from './routes/redefinir-senha'
|
||||
import { Route as RecuperarSenhaRouteImport } from './routes/recuperar-senha'
|
||||
import { Route as PainelRouteImport } from './routes/painel'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as PainelIndexRouteImport } from './routes/painel.index'
|
||||
import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento'
|
||||
import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RedefinirSenhaRoute = RedefinirSenhaRouteImport.update({
|
||||
id: '/redefinir-senha',
|
||||
path: '/redefinir-senha',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RecuperarSenhaRoute = RecuperarSenhaRouteImport.update({
|
||||
id: '/recuperar-senha',
|
||||
path: '/recuperar-senha',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PainelRoute = PainelRouteImport.update({
|
||||
id: '/painel',
|
||||
path: '/painel',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
|
|
@ -28,34 +49,96 @@ const IndexRoute = IndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PainelIndexRoute = PainelIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => PainelRoute,
|
||||
} as any)
|
||||
const PainelFaturamentoRoute = PainelFaturamentoRouteImport.update({
|
||||
id: '/faturamento',
|
||||
path: '/faturamento',
|
||||
getParentRoute: () => PainelRoute,
|
||||
} as any)
|
||||
const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({
|
||||
id: '/configuracoes',
|
||||
path: '/configuracoes',
|
||||
getParentRoute: () => PainelRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/painel': typeof PainelRouteWithChildren
|
||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||
'/painel/': typeof PainelIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||
'/painel': typeof PainelIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/painel': typeof PainelRouteWithChildren
|
||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||
'/painel/': typeof PainelIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/login' | '/signup'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/painel'
|
||||
| '/recuperar-senha'
|
||||
| '/redefinir-senha'
|
||||
| '/signup'
|
||||
| '/painel/configuracoes'
|
||||
| '/painel/faturamento'
|
||||
| '/painel/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/login' | '/signup'
|
||||
id: '__root__' | '/' | '/login' | '/signup'
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/recuperar-senha'
|
||||
| '/redefinir-senha'
|
||||
| '/signup'
|
||||
| '/painel/configuracoes'
|
||||
| '/painel/faturamento'
|
||||
| '/painel'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/painel'
|
||||
| '/recuperar-senha'
|
||||
| '/redefinir-senha'
|
||||
| '/signup'
|
||||
| '/painel/configuracoes'
|
||||
| '/painel/faturamento'
|
||||
| '/painel/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
PainelRoute: typeof PainelRouteWithChildren
|
||||
RecuperarSenhaRoute: typeof RecuperarSenhaRoute
|
||||
RedefinirSenhaRoute: typeof RedefinirSenhaRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +151,27 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof SignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/redefinir-senha': {
|
||||
id: '/redefinir-senha'
|
||||
path: '/redefinir-senha'
|
||||
fullPath: '/redefinir-senha'
|
||||
preLoaderRoute: typeof RedefinirSenhaRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/recuperar-senha': {
|
||||
id: '/recuperar-senha'
|
||||
path: '/recuperar-senha'
|
||||
fullPath: '/recuperar-senha'
|
||||
preLoaderRoute: typeof RecuperarSenhaRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/painel': {
|
||||
id: '/painel'
|
||||
path: '/painel'
|
||||
fullPath: '/painel'
|
||||
preLoaderRoute: typeof PainelRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
|
|
@ -82,14 +186,62 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/painel/': {
|
||||
id: '/painel/'
|
||||
path: '/'
|
||||
fullPath: '/painel/'
|
||||
preLoaderRoute: typeof PainelIndexRouteImport
|
||||
parentRoute: typeof PainelRoute
|
||||
}
|
||||
'/painel/faturamento': {
|
||||
id: '/painel/faturamento'
|
||||
path: '/faturamento'
|
||||
fullPath: '/painel/faturamento'
|
||||
preLoaderRoute: typeof PainelFaturamentoRouteImport
|
||||
parentRoute: typeof PainelRoute
|
||||
}
|
||||
'/painel/configuracoes': {
|
||||
id: '/painel/configuracoes'
|
||||
path: '/configuracoes'
|
||||
fullPath: '/painel/configuracoes'
|
||||
preLoaderRoute: typeof PainelConfiguracoesRouteImport
|
||||
parentRoute: typeof PainelRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PainelRouteChildren {
|
||||
PainelConfiguracoesRoute: typeof PainelConfiguracoesRoute
|
||||
PainelFaturamentoRoute: typeof PainelFaturamentoRoute
|
||||
PainelIndexRoute: typeof PainelIndexRoute
|
||||
}
|
||||
|
||||
const PainelRouteChildren: PainelRouteChildren = {
|
||||
PainelConfiguracoesRoute: PainelConfiguracoesRoute,
|
||||
PainelFaturamentoRoute: PainelFaturamentoRoute,
|
||||
PainelIndexRoute: PainelIndexRoute,
|
||||
}
|
||||
|
||||
const PainelRouteWithChildren =
|
||||
PainelRoute._addFileChildren(PainelRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
PainelRoute: PainelRouteWithChildren,
|
||||
RecuperarSenhaRoute: RecuperarSenhaRoute,
|
||||
RedefinirSenhaRoute: RedefinirSenhaRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<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>>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createRouter, useRouter } from "@tanstack/react-router";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||
|
|
@ -23,9 +24,9 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
|
|||
/>
|
||||
</svg>
|
||||
</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">
|
||||
An unexpected error occurred. Please try again.
|
||||
Ocorreu um erro inesperado. Tente novamente.
|
||||
</p>
|
||||
{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">
|
||||
|
|
@ -38,15 +39,15 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
|
|||
router.invalidate();
|
||||
reset();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
Try again
|
||||
Tentar novamente
|
||||
</button>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
Go home
|
||||
Ir para o início
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -55,9 +56,20 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
|
|||
}
|
||||
|
||||
export const getRouter = () => {
|
||||
// Fresh QueryClient per request — never module-level (vaza dados entre SSR requests)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {},
|
||||
context: { queryClient },
|
||||
scrollRestoration: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultErrorComponent: DefaultErrorComponent,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import { Outlet, createRootRouteWithContext, HeadContent, Scripts, Link } from "@tanstack/react-router";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
import appCss from "../styles.css?url";
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
function NotFoundComponent() {
|
||||
return (
|
||||
<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.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<a
|
||||
href="/"
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
Ir para o início
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
|
|
@ -88,10 +94,13 @@ function RootShell({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
function RootComponent() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Outlet />
|
||||
<Toaster richColors position="top-right" />
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<Outlet />
|
||||
<Toaster richColors position="top-right" />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,106 @@
|
|||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Logo } from "@/components/mika/Logo";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { translateAuthError } from "@/lib/auth-errors";
|
||||
import { AuthCard } from "@/components/mika/AuthCard";
|
||||
import { GoogleButton } from "@/components/mika/GoogleButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type LoginSearch = { redirect?: string };
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email("Formato de e-mail inválido."),
|
||||
password: z.string().min(1, "Informe sua senha."),
|
||||
});
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen grid place-items-center bg-background px-4">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<Logo size="lg" />
|
||||
<h1 className="text-2xl font-bold">Login</h1>
|
||||
<p className="text-muted-foreground">
|
||||
A página de login completa será implementada na Etapa 2 (Auth + Supabase).
|
||||
</p>
|
||||
<Link to="/" className="inline-block text-primary hover:underline">← Voltar para o início</Link>
|
||||
<AuthCard
|
||||
title="Entrar"
|
||||
subtitle="Acesse seu agente Mika"
|
||||
footer={
|
||||
<>
|
||||
Não tem conta?{" "}
|
||||
<Link to="/signup" className="text-primary font-medium hover:underline">
|
||||
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 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<GoogleButton redirectTo={redirect || "/painel"} />
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
159
src/routes/painel.configuracoes.tsx
Normal file
159
src/routes/painel.configuracoes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/routes/painel.faturamento.tsx
Normal file
127
src/routes/painel.faturamento.tsx
Normal 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
132
src/routes/painel.index.tsx
Normal 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
237
src/routes/painel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/routes/recuperar-senha.tsx
Normal file
86
src/routes/recuperar-senha.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/routes/redefinir-senha.tsx
Normal file
90
src/routes/redefinir-senha.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +1,215 @@
|
|||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Logo } from "@/components/mika/Logo";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, MailCheck } from "lucide-react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { translateAuthError } from "@/lib/auth-errors";
|
||||
import { passwordStrength } from "@/lib/password";
|
||||
import { AuthCard } from "@/components/mika/AuthCard";
|
||||
import { GoogleButton } from "@/components/mika/GoogleButton";
|
||||
import { PasswordStrengthMeter } from "@/components/mika/PasswordStrengthMeter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
type SignupSearch = {
|
||||
plan?: string;
|
||||
cycle?: "monthly" | "yearly";
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
full_name: z.string().min(2, "Informe seu nome completo.").max(120),
|
||||
email: z.string().email("Formato de e-mail inválido."),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número.")
|
||||
.refine((v) => /[A-Z]/.test(v), "A senha precisa de pelo menos 1 maiúscula.")
|
||||
.refine((v) => /\d/.test(v), "A senha precisa de pelo menos 1 número."),
|
||||
accept_terms: z.literal(true, { message: "Você precisa aceitar os termos." }),
|
||||
});
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
validateSearch: (search: Record<string, unknown>): SignupSearch => ({
|
||||
plan: typeof search.plan === "string" ? search.plan : undefined,
|
||||
cycle: search.cycle === "yearly" || search.cycle === "monthly" ? search.cycle : undefined,
|
||||
}),
|
||||
component: SignupPlaceholder,
|
||||
component: SignupPage,
|
||||
});
|
||||
|
||||
function SignupPlaceholder() {
|
||||
function SignupPage() {
|
||||
const { plan, cycle } = Route.useSearch();
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-background px-4">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<Logo size="lg" />
|
||||
<h1 className="text-2xl font-bold">Criar conta</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cadastro completo será implementado na Etapa 2.
|
||||
</p>
|
||||
{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"}
|
||||
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 (
|
||||
<AuthCard title="Confirme seu e-mail" subtitle={`Enviamos um link para ${needsConfirm}`}>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<MailCheck className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
<div>
|
||||
<Link to="/" className="inline-block text-primary hover:underline">← Voltar para o início</Link>
|
||||
<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={
|
||||
<>
|
||||
Já 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>
|
||||
</div>
|
||||
|
||||
<GoogleButton redirectTo="/painel" />
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue