Initial SeConectai briefing system

This commit is contained in:
2026-10-04 10:42:23 -03:00
commit 45cd8d2f03
163 changed files with 12659 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
"use server";
import argon2 from "argon2";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { encryptCpf, hashCpf, onlyDigits } from "@/lib/crypto";
import { cadastroSchema, normalizeWhatsapp } from "@/lib/validators";
import { sendMail } from "@/lib/mail";
import { checkRateLimit } from "@/lib/rate-limit";
export type CadastroState = {
ok: boolean;
message?: string;
};
export async function cadastrarCliente(_state: CadastroState, formData: FormData): Promise<CadastroState> {
const emailForLimit = formData.get("email")?.toString().trim().toLowerCase() ?? "sem-email";
const limit = checkRateLimit(`cadastro:${emailForLimit}`, 5, 60 * 60 * 1000);
if (!limit.allowed) {
return { ok: false, message: "Muitas tentativas. Tente novamente mais tarde." };
}
const parsed = cadastroSchema.safeParse({
nomeCompleto: formData.get("nomeCompleto"),
cpf: formData.get("cpf"),
whatsapp: formData.get("whatsapp"),
email: formData.get("email"),
senha: formData.get("senha"),
confirmarSenha: formData.get("confirmarSenha"),
tipo: formData.get("tipo"),
razaoSocial: formData.get("razaoSocial") || undefined,
cnpj: formData.get("cnpj") || undefined,
cep: formData.get("cep") || undefined,
logradouro: formData.get("logradouro") || undefined,
numero: formData.get("numero") || undefined,
complemento: formData.get("complemento") || undefined,
bairro: formData.get("bairro") || undefined,
cidade: formData.get("cidade") || undefined,
uf: formData.get("uf") || undefined,
lgpdAceite: formData.get("lgpdAceite")
});
if (!parsed.success) {
return {
ok: false,
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
};
}
const data = parsed.data;
const cpfHash = hashCpf(data.cpf);
const whatsapp = normalizeWhatsapp(data.whatsapp);
if (!whatsapp) {
return { ok: false, message: "Informe um WhatsApp com DDD." };
}
try {
await db.$transaction(async (tx) => {
const exists = await tx.user.findFirst({
where: {
OR: [{ email: data.email }, { cliente: { cpfHash } }]
},
select: { id: true }
});
if (exists) {
throw new Error("DUPLICADO");
}
await tx.user.create({
data: {
email: data.email,
senhaHash: await argon2.hash(data.senha),
papel: "CLIENTE",
cliente: {
create: {
nomeCompleto: data.nomeCompleto,
cpfCifrado: encryptCpf(data.cpf),
cpfHash,
whatsapp,
tipo: data.tipo,
razaoSocial: data.tipo === "PJ" ? data.razaoSocial : null,
cnpj: data.tipo === "PJ" && data.cnpj ? onlyDigits(data.cnpj) : null,
cep: data.cep ? onlyDigits(data.cep) : null,
logradouro: data.logradouro || null,
numero: data.numero || null,
complemento: data.complemento || null,
bairro: data.bairro || null,
cidade: data.cidade || null,
uf: data.uf || null,
lgpdAceiteEm: new Date()
}
}
}
});
});
} catch (error) {
if (error instanceof Error && error.message === "DUPLICADO") {
return {
ok: false,
message: "Nao foi possivel concluir o cadastro com os dados informados."
};
}
return {
ok: false,
message: "Nao foi possivel concluir o cadastro agora. Tente novamente."
};
}
try {
await sendMail({
to: data.email,
subject: "Cadastro criado - Briefing SeConectai",
text: [
"Seu cadastro foi criado com sucesso.",
"",
"Acesse o sistema para iniciar ou acompanhar seu briefing.",
"",
"Este e-mail nao inclui CPF completo nem senhas."
].join("\n")
});
} catch {
// Falha de e-mail nao desfaz cadastro.
}
redirect("/login");
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cadastrarCliente, type CadastroState } from "./actions";
const initialState: CadastroState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Criando cadastro..." : "Criar cadastro"}
</Button>
);
}
export function CadastroForm() {
const [state, action] = useFormState(cadastrarCliente, initialState);
const [tipo, setTipo] = useState<"PF" | "PJ">("PF");
return (
<form action={action} className="space-y-5">
{state.message ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<input type="hidden" name="tipo" value={tipo} />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="nomeCompleto">Nome completo</Label>
<Input id="nomeCompleto" name="nomeCompleto" autoComplete="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="cpf">CPF</Label>
<Input id="cpf" name="cpf" inputMode="numeric" autoComplete="off" required />
</div>
<div className="space-y-2">
<Label htmlFor="whatsapp">WhatsApp</Label>
<Input id="whatsapp" name="whatsapp" inputMode="tel" autoComplete="tel" required />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="email">E-mail</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="senha">Senha</Label>
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
</div>
<div className="space-y-2">
<Label htmlFor="confirmarSenha">Confirmar senha</Label>
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
</div>
</div>
<fieldset className="space-y-3 rounded-lg border p-4">
<legend className="px-1 text-sm font-medium">Este site e para uma empresa?</legend>
<div className="grid gap-3 sm:grid-cols-2">
<Button type="button" variant={tipo === "PF" ? "default" : "outline"} onClick={() => setTipo("PF")}>
Nao
</Button>
<Button type="button" variant={tipo === "PJ" ? "default" : "outline"} onClick={() => setTipo("PJ")}>
Sim
</Button>
</div>
</fieldset>
{tipo === "PJ" ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="razaoSocial">Razao social</Label>
<Input id="razaoSocial" name="razaoSocial" required />
</div>
<div className="space-y-2">
<Label htmlFor="cnpj">CNPJ</Label>
<Input id="cnpj" name="cnpj" inputMode="numeric" required />
</div>
<div className="space-y-2">
<Label htmlFor="cep">CEP</Label>
<Input id="cep" name="cep" inputMode="numeric" autoComplete="postal-code" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="logradouro">Logradouro</Label>
<Input id="logradouro" name="logradouro" autoComplete="address-line1" />
</div>
<div className="space-y-2">
<Label htmlFor="numero">Numero</Label>
<Input id="numero" name="numero" autoComplete="address-line2" />
</div>
<div className="space-y-2">
<Label htmlFor="complemento">Complemento</Label>
<Input id="complemento" name="complemento" />
</div>
<div className="space-y-2">
<Label htmlFor="bairro">Bairro</Label>
<Input id="bairro" name="bairro" />
</div>
<div className="space-y-2">
<Label htmlFor="cidade">Cidade</Label>
<Input id="cidade" name="cidade" autoComplete="address-level2" />
</div>
<div className="space-y-2">
<Label htmlFor="uf">UF</Label>
<Input id="uf" name="uf" maxLength={2} autoComplete="address-level1" />
</div>
</div>
) : null}
<label className="flex items-start gap-3 rounded-lg border p-4 text-sm leading-6">
<Checkbox name="lgpdAceite" required />
<span>
Li e aceito a Politica de Privacidade e autorizo o tratamento dos dados para criacao e acompanhamento do briefing.
</span>
</label>
<SubmitButton />
</form>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CadastroForm } from "./cadastro-form";
export default function CadastroPage() {
return (
<main className="container flex min-h-screen items-center justify-center py-8">
<Card className="w-full max-w-2xl shadow-sm">
<CardHeader>
<CardTitle>Crie seu cadastro</CardTitle>
<p className="text-sm leading-6 text-muted-foreground">
Depois do cadastro, voce podera iniciar e continuar seu briefing de site.
</p>
</CardHeader>
<CardContent>
<CadastroForm />
<p className="mt-4 text-center text-xs leading-5 text-muted-foreground">
Ao criar cadastro, consulte tambem a{" "}
<Link href="/privacidade" className="text-primary underline-offset-4 hover:underline">
Politica de Privacidade
</Link>{" "}
e os{" "}
<Link href="/termos" className="text-primary underline-offset-4 hover:underline">
Termos de Uso
</Link>.
</p>
<p className="mt-6 text-center text-sm text-muted-foreground">
Ja tem cadastro?{" "}
<Link href="/login" className="font-medium text-primary underline-offset-4 hover:underline">
Entrar
</Link>
</p>
</CardContent>
</Card>
</main>
);
}