Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
205 lines
5.0 KiB
TypeScript
205 lines
5.0 KiB
TypeScript
import { eq } from "drizzle-orm";
|
|
import { CheckCircle2, Clock, MailX } from "lucide-react";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { isValidVerificationToken } from "@/actions/email-verify";
|
|
import Link from "@/components/link";
|
|
import { SurfaceCard } from "@/components/surface-card";
|
|
import { db, User } from "@/lib/db";
|
|
|
|
type Status = "verified" | "already" | "invalid" | "unavailable";
|
|
|
|
function StatusCard({
|
|
icon,
|
|
title,
|
|
subtitle,
|
|
children,
|
|
color,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
subtitle: string;
|
|
children: React.ReactNode;
|
|
color: string;
|
|
}) {
|
|
const tintMap: Record<string, string> = {
|
|
green: "#16a34a",
|
|
yellow: "#d97706",
|
|
red: "#dc2626",
|
|
blue: "#2563eb",
|
|
};
|
|
const tint = tintMap[color] ?? tintMap.blue;
|
|
|
|
return (
|
|
<main className="flex min-h-[60vh] items-center justify-center px-4">
|
|
<SurfaceCard className="w-full max-w-md">
|
|
<div
|
|
className="flex items-center gap-2.5 border-b px-5 py-4"
|
|
style={{
|
|
background: `color-mix(in srgb, ${tint} 12%, var(--color-surface))`,
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
|
}}
|
|
>
|
|
<span
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg"
|
|
style={{
|
|
background: `color-mix(in srgb, ${tint} 18%, transparent)`,
|
|
color: tint,
|
|
}}
|
|
>
|
|
{icon}
|
|
</span>
|
|
<h1
|
|
className="text-sm font-bold tracking-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{title}
|
|
</h1>
|
|
</div>
|
|
<div className="flex flex-col items-center gap-4 px-6 py-8 text-center">
|
|
<div
|
|
className="flex h-16 w-16 items-center justify-center rounded-full"
|
|
style={{
|
|
background: `color-mix(in srgb, ${tint} 12%, transparent)`,
|
|
color: tint,
|
|
}}
|
|
>
|
|
{icon}
|
|
</div>
|
|
<p
|
|
className="text-sm font-semibold"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{subtitle}
|
|
</p>
|
|
{children}
|
|
</div>
|
|
</SurfaceCard>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default async function VerifyPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{
|
|
token?: string;
|
|
email?: string;
|
|
}>;
|
|
}) {
|
|
const t = await getTranslations("pages.verify");
|
|
const { token = "", email = "" } = await searchParams;
|
|
const normalisedEmail = email.trim().toLowerCase();
|
|
|
|
let status: Status = "invalid";
|
|
|
|
if (normalisedEmail && token) {
|
|
const ok = await isValidVerificationToken(normalisedEmail, token);
|
|
if (ok) {
|
|
try {
|
|
const [user] = await db
|
|
.select({ id: User.id, mailVerified: User.mailVerified })
|
|
.from(User)
|
|
.where(eq(User.mail, normalisedEmail))
|
|
.limit(1);
|
|
if (!user) {
|
|
status = "invalid";
|
|
} else if (user.mailVerified === "1") {
|
|
status = "already";
|
|
} else {
|
|
await db
|
|
.update(User)
|
|
.set({ mailVerified: "1" })
|
|
.where(eq(User.id, user.id));
|
|
status = "verified";
|
|
}
|
|
} catch {
|
|
status = "unavailable";
|
|
}
|
|
}
|
|
}
|
|
|
|
const linkClass =
|
|
"btn-shine inline-flex items-center gap-2 font-extrabold text-sm px-6 py-3 rounded-xl transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg";
|
|
const linkStyle = {
|
|
background: "var(--color-primary)",
|
|
color: "var(--color-primary-foreground)",
|
|
boxShadow:
|
|
"0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{status === "verified" && (
|
|
<StatusCard
|
|
icon={<CheckCircle2 size={22} />}
|
|
title={t("verifiedTitle")}
|
|
subtitle={t("verifiedSubtitle")}
|
|
color="green"
|
|
>
|
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
|
{t("verifiedBody")}
|
|
</p>
|
|
<Link href="/login" className={linkClass} style={linkStyle}>
|
|
{t("goToLogin")}
|
|
</Link>
|
|
</StatusCard>
|
|
)}
|
|
|
|
{status === "already" && (
|
|
<StatusCard
|
|
icon={<CheckCircle2 size={22} />}
|
|
title={t("alreadyTitle")}
|
|
subtitle={t("alreadySubtitle")}
|
|
color="yellow"
|
|
>
|
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
|
{t("alreadyBody")}
|
|
</p>
|
|
<Link href="/login" className={linkClass} style={linkStyle}>
|
|
{t("goToLogin")}
|
|
</Link>
|
|
</StatusCard>
|
|
)}
|
|
|
|
{status === "unavailable" && (
|
|
<StatusCard
|
|
icon={<Clock size={22} />}
|
|
title={t("unavailableTitle")}
|
|
subtitle={t("unavailableSubtitle")}
|
|
color="blue"
|
|
>
|
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
|
{t("unavailableBody")}
|
|
</p>
|
|
</StatusCard>
|
|
)}
|
|
|
|
{status === "invalid" && (
|
|
<StatusCard
|
|
icon={<MailX size={22} />}
|
|
title={t("invalidTitle")}
|
|
subtitle={t("invalidSubtitle")}
|
|
color="red"
|
|
>
|
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
|
{t("invalidBody")}
|
|
</p>
|
|
<Link
|
|
href="/login"
|
|
className={`${linkClass} !shadow-none`}
|
|
style={{
|
|
background: "transparent",
|
|
color: "var(--color-text-readable)",
|
|
border:
|
|
"2px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
|
}}
|
|
>
|
|
{t("backToLogin")}
|
|
</Link>
|
|
</StatusCard>
|
|
)}
|
|
</>
|
|
);
|
|
}
|