refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,17 @@
import { RiTerminalLine } from "@remixicon/react";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
interface AuthErrorAlertProps {
error: string;
}
export function AuthErrorAlert({ error }: AuthErrorAlertProps) {
if (!error) return null;
return (
<Alert className="mt-2 border border-destructive" variant="destructive">
<RiTerminalLine className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/shared/utils/ui";
interface AuthHeaderProps {
title: string;
}
export function AuthHeader({ title }: AuthHeaderProps) {
return (
<div className={cn("flex flex-col gap-1.5")}>
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
{title}
</h1>
</div>
);
}

View File

@@ -0,0 +1,19 @@
function AuthSidebar() {
return (
<div className="relative hidden flex-col overflow-hidden bg-primary md:flex">
<div className="relative flex flex-1 flex-col justify-between p-8">
<div className="space-y-4">
<h2 className="text-3xl font-semibold leading-tight">
Controle suas finanças com clareza e foco diário.
</h2>
<p className="text-sm opacity-90">
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia.
</p>
</div>
</div>
</div>
);
}
export default AuthSidebar;

View File

@@ -0,0 +1,54 @@
import { RiLoader4Line } from "@remixicon/react";
import { Button } from "@/shared/components/ui/button";
interface GoogleAuthButtonProps {
onClick: () => void;
loading?: boolean;
disabled?: boolean;
text?: string;
}
export function GoogleAuthButton({
onClick,
loading = false,
disabled = false,
text = "Continuar com Google",
}: GoogleAuthButtonProps) {
return (
<Button
variant="outline"
type="button"
onClick={onClick}
disabled={disabled || loading}
className="w-full gap-2"
>
{loading ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="h-5 w-5"
>
<path
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"
fill="#4285F4"
/>
<path
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"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
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.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
<span>{text}</span>
</Button>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import { RiFingerprintLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/shared/components/logo";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/shared/components/ui/field";
import { Input } from "@/shared/components/ui/input";
import { authClient, googleSignInAvailable } from "@/shared/lib/auth/client";
import { cn } from "@/shared/utils/ui";
import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
import AuthSidebar from "./auth-sidebar";
import { GoogleAuthButton } from "./google-auth-button";
type DivProps = React.ComponentProps<"div">;
export function LoginForm({ className, ...props }: DivProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const [loadingPasskey, setLoadingPasskey] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof PublicKeyCredential === "undefined") return;
setPasskeySupported(true);
}, []);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
await authClient.signIn.email(
{
email,
password,
callbackURL: "/dashboard",
rememberMe: false,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Login realizado com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
if (
ctx.error.status === 500 &&
ctx.error.statusText === "Internal Server Error"
) {
toast.error(
"Ocorreu uma falha na requisição. Tente novamente mais tarde.",
);
}
setError(ctx.error.message);
setLoadingEmail(false);
},
},
);
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
},
);
}
async function handlePasskey() {
setError("");
setLoadingPasskey(true);
const { error: passkeyError } = await authClient.signIn.passkey({
fetchOptions: {
onSuccess: () => {
setLoadingPasskey(false);
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingPasskey(false);
},
},
});
if (passkeyError) {
setError(passkeyError.message || "Erro ao entrar com passkey.");
setLoadingPasskey(false);
}
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Entrar no OpenMonetis" />
<AuthErrorAlert error={error} />
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="username webauthn"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Senha</FieldLabel>
</div>
<Input
id="password"
type="password"
required
placeholder="Digite sua senha"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<Button
type="submit"
disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Entrar"
)}
</Button>
</Field>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={
loadingEmail ||
loadingGoogle ||
loadingPasskey ||
!isGoogleAvailable
}
text="Entrar com Google"
/>
</Field>
{passkeySupported && (
<Field>
<Button
variant="outline"
type="button"
onClick={handlePasskey}
disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="w-full gap-2"
>
{loadingPasskey ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiFingerprintLine className="h-5 w-5" />
)}
<span>Entrar com passkey</span>
</Button>
</Field>
)}
<FieldDescription className="text-center">
Não tem uma conta?{" "}
<a href="/signup" className="underline underline-offset-4">
Inscreva-se
</a>
</FieldDescription>
</FieldGroup>
</form>
<AuthSidebar />
</CardContent>
</Card>
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/shared/components/logo";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/shared/components/ui/field";
import { Input } from "@/shared/components/ui/input";
import { authClient, googleSignInAvailable } from "@/shared/lib/auth/client";
import { cn } from "@/shared/utils/ui";
import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
import AuthSidebar from "./auth-sidebar";
import { GoogleAuthButton } from "./google-auth-button";
interface PasswordValidation {
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
}
function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
}
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met ? "text-success" : "text-muted-foreground",
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
}
type DivProps = React.ComponentProps<"div">;
export function SignupForm({ className, ...props }: DivProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const [fullname, setFullname] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const passwordValidation = validatePassword(password);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!passwordValidation.isValid) {
setError("A senha não atende aos requisitos de segurança.");
return;
}
await authClient.signUp.email(
{
email,
password,
name: fullname,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Conta criada com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingEmail(false);
},
},
);
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
},
);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Criar sua conta" />
<AuthErrorAlert error={error} />
<Field>
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
<Input
id="name"
type="text"
placeholder="Digite seu nome"
autoComplete="name"
required
value={fullname}
onChange={(e) => setFullname(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Input
id="password"
type="password"
required
autoComplete="new-password"
placeholder="Crie uma senha forte"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={
!!error ||
(password.length > 0 && !passwordValidation.isValid)
}
maxLength={23}
/>
{password.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</Field>
<Field>
<Button
type="submit"
disabled={
loadingEmail ||
loadingGoogle ||
(password.length > 0 && !passwordValidation.isValid)
}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Criar conta"
)}
</Button>
</Field>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
text="Continuar com Google"
/>
</Field>
<FieldDescription className="text-center">
tem uma conta?{" "}
<a href="/login" className="underline underline-offset-4">
Entrar
</a>
</FieldDescription>
</FieldGroup>
</form>
<AuthSidebar />
</CardContent>
</Card>
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
}