Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions web/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"use client";

import { useState } from "react";
import { AuthCard, EmailField, LinkSentPanel } from "@/components/auth";
import { Button } from "@/components/ui";
import { useLogin } from "@/hooks/auth";
import { isLikelyEmail } from "@/lib/auth/email";
import { ApiError } from "@/lib/api";

export default function LoginPage() {
const [email, setEmail] = useState("");
// fieldErr: client-side validation (shown inline on the input).
// apiErr: server / network failure (shown as a banner above the form).
const [fieldErr, setFieldErr] = useState<string | null>(null);
const [apiErr, setApiErr] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const login = useLogin();

function onSubmit(e: React.FormEvent) {
e.preventDefault();
setFieldErr(null);
setApiErr(null);

// client-side gate — no API call when the address is malformed (parity with
// the legacy login.js: same regex, on the trimmed input).
if (!isLikelyEmail(email)) {
setFieldErr("E-mail inválido. Confira e tente de novo.");
return;
}

login.mutate(
{ email },
{
onSuccess: () => setSent(true),
onError: (e) => {
if (e instanceof ApiError) {
const body = e.body as { error?: string } | undefined;
setApiErr(body?.error || "Erro ao enviar o link. Tente de novo.");
} else {
// fetch rejected (TypeError / abort) — no HTTP response.
setApiErr("Sem conexão. Verifique a internet e tente de novo.");
}
},
}
);
}

return (
<AuthCard
title="Entrar na conta"
subtitle="Login sem senha — enviamos um link mágico para o seu e-mail."
>
{sent ? (
<LinkSentPanel id="login-sent" />
) : (
<>
{apiErr ? (
<div
id="login-err"
role="alert"
className="mb-4 rounded-md8 border border-bad-line bg-bad-soft px-3.5 py-2.5 text-[13.5px] text-bad"
>
{apiErr}
</div>
) : null}

<form onSubmit={onSubmit} noValidate className="flex flex-col gap-4">
<EmailField
id="login-email"
name="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (fieldErr) setFieldErr(null);
}}
error={fieldErr ?? undefined}
hint="Você receberá um link de acesso válido por 15 minutos."
required
/>
<Button
type="submit"
className="w-full"
loading={login.isPending}
data-testid="login-submit"
>
{login.isPending ? "Enviando…" : "Enviar link de acesso"}
</Button>
</form>

<hr className="my-6 border-line" />
<p className="text-center text-[13.5px] text-muted">
Não tem conta?{" "}
<a
href="/app/register/"
className="font-medium text-accent-strong hover:underline"
>
Solicitar convite →
</a>
</p>
</>
)}
</AuthCard>
);
}
291 changes: 291 additions & 0 deletions web/app/register/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
"use client";

import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { AuthCard, EmailField, LinkSentPanel } from "@/components/auth";
import { Button, Field, TextField } from "@/components/ui";
import { useRegister, useRequestInvite } from "@/hooks/auth";
import { isLikelyEmail } from "@/lib/auth/email";

type Tab = "invite" | "waitlist";

function RegisterInner() {
const params = useSearchParams();
const prefillInvite = params.get("invite") ?? "";
const prefillEmail = params.get("email") ?? "";
const linkExpired = params.get("error") === "link";
// Both tabs default to "invite"; a prefilled invite/email simply lands here
// already (the invite tab is the default), with the fields populated below.
const [tab, setTab] = useState<Tab>("invite");

return (
<AuthCard
title="Criar sua conta"
subtitle="Seu segundo cérebro pesquisável, conectado às suas fontes."
>
{linkExpired ? (
<div
id="register-link-expired"
role="alert"
className="mb-4 rounded-md8 border border-warn-line bg-warn-soft px-3.5 py-2.5 text-[13.5px] text-warn"
>
Esse link expirou ou já foi usado. Peça um novo abaixo.
</div>
) : null}

<div
role="tablist"
aria-label="Acesso"
className="mb-5 grid grid-cols-2 gap-1 rounded-md8 bg-paper-2 p-1"
>
<TabButton active={tab === "invite"} onClick={() => setTab("invite")}>
Tenho convite
</TabButton>
<TabButton
active={tab === "waitlist"}
onClick={() => setTab("waitlist")}
>
Pedir convite
</TabButton>
</div>

{tab === "invite" ? (
<InvitePane
defaultInvite={prefillInvite}
defaultEmail={prefillEmail}
/>
) : (
<WaitlistPane defaultEmail={prefillEmail} />
)}

<hr className="my-6 border-line" />
<p className="text-center text-[13.5px] text-muted">
Já tem conta?{" "}
<a
href="/app/login/"
className="font-medium text-accent-strong hover:underline"
>
Entrar →
</a>
</p>
</AuthCard>
);
}

function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={[
"h-9 rounded-sm7 text-[13.5px] font-medium transition-colors",
active
? "bg-surface text-ink shadow-sm"
: "text-muted hover:text-ink-soft",
].join(" ")}
>
{children}
</button>
);
}

function InvitePane({
defaultInvite,
defaultEmail,
}: {
defaultInvite: string;
defaultEmail: string;
}) {
const [invite, setInvite] = useState(defaultInvite);
const [email, setEmail] = useState(defaultEmail);
const [fieldErr, setFieldErr] = useState<string | null>(null);
const [apiErr, setApiErr] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const register = useRegister();

if (sent) {
return (
<LinkSentPanel
id="register-sent"
title="Conta criada"
message="Enviamos um link de acesso para o seu e-mail. Use-o para entrar."
/>
);
}

function onSubmit(e: React.FormEvent) {
e.preventDefault();
setFieldErr(null);
setApiErr(null);
if (!invite.trim()) {
setFieldErr("Informe o código do convite.");
return;
}
if (!isLikelyEmail(email)) {
setFieldErr("E-mail inválido. Confira e tente de novo.");
return;
}
register.mutate(
{ invite_code: invite, email },
{
onSuccess: () => setSent(true),
onError: (err) =>
setApiErr(
err instanceof Error && err.name === "ApiError"
? "Convite inválido ou já usado. Confira o código."
: "Sem conexão. Verifique a internet e tente de novo."
),
}
);
}

return (
<>
{apiErr ? (
<div
id="register-err"
role="alert"
className="mb-4 rounded-md8 border border-bad-line bg-bad-soft px-3.5 py-2.5 text-[13.5px] text-bad"
>
{apiErr}
</div>
) : null}
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-4">
<Field
id="invite"
label="Código do convite"
placeholder="zin_..."
value={invite}
onChange={(e) => {
setInvite(e.target.value);
if (fieldErr) setFieldErr(null);
}}
error={fieldErr && !invite.trim() ? fieldErr : undefined}
autoComplete="off"
required
/>
<EmailField
id="reg-email"
name="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (fieldErr) setFieldErr(null);
}}
error={fieldErr && invite.trim() ? fieldErr : undefined}
required
/>
<Button
type="submit"
className="w-full"
loading={register.isPending}
data-testid="register-submit"
>
{register.isPending ? "Criando…" : "Criar conta"}
</Button>
</form>
</>
);
}

function WaitlistPane({ defaultEmail }: { defaultEmail: string }) {
const [email, setEmail] = useState(defaultEmail);
const [note, setNote] = useState("");
const [fieldErr, setFieldErr] = useState<string | null>(null);
const [apiErr, setApiErr] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const requestInvite = useRequestInvite();

if (sent) {
return (
<LinkSentPanel
id="waitlist-sent"
title="Pedido recebido"
message="Obrigado! Entraremos em contato assim que houver uma vaga."
/>
);
}

function onSubmit(e: React.FormEvent) {
e.preventDefault();
setFieldErr(null);
setApiErr(null);
if (!isLikelyEmail(email)) {
setFieldErr("E-mail inválido. Confira e tente de novo.");
return;
}
requestInvite.mutate(
{ email, note },
{
onSuccess: () => setSent(true),
onError: (err) =>
setApiErr(
err instanceof Error && err.name === "ApiError"
? "Não foi possível registrar o pedido. Tente de novo."
: "Sem conexão. Verifique a internet e tente de novo."
),
}
);
}

return (
<>
{apiErr ? (
<div
id="waitlist-err"
role="alert"
className="mb-4 rounded-md8 border border-bad-line bg-bad-soft px-3.5 py-2.5 text-[13.5px] text-bad"
>
{apiErr}
</div>
) : null}
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-4">
<EmailField
id="req-email"
name="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (fieldErr) setFieldErr(null);
}}
error={fieldErr ?? undefined}
required
/>
<TextField
id="req-note"
label="Como pretende usar? (opcional)"
placeholder="Conte um pouco sobre você."
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
/>
<Button
type="submit"
className="w-full"
loading={requestInvite.isPending}
data-testid="waitlist-submit"
>
{requestInvite.isPending ? "Enviando…" : "Pedir convite"}
</Button>
</form>
</>
);
}

export default function RegisterPage() {
return (
<Suspense fallback={<AuthCard title="Criar sua conta" />}>
<RegisterInner />
</Suspense>
);
}
Loading
Loading