feat(auth): unify and polish login, register and homepage auth UI
CI / check (push) Successful in 35s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-integration (push) Successful in 2m1s
CI / tests-unit (push) Successful in 2m11s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 5m7s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m11s
CI / check (push) Successful in 35s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-integration (push) Successful in 2m1s
CI / tests-unit (push) Successful in 2m11s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 5m7s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m11s
Login and register each carried their own copy of the promo panel and the error banner, so the two pages drifted apart and every caller had to wire role="alert" by hand. Extract a shared AuthPromoCard and AuthAlert, use them in both forms and for the login notices, and link errors to their fields via aria-invalid / aria-describedby. Focus now lands on the 2FA code field the moment it appears, the credential fields lock while a request is in flight, and invalid inputs get a danger tint. The homepage top bar was a byte-for-byte duplicate of AuthTopBar; it now renders the shared component, and the compact homepage login uses the tighter spacing its comment already promised.
This commit is contained in:
1 parent
2249303dbd
commit
c6e5e0b1a9
8 files changed
+215
-296
No files matched your search
@@ -1,15 +1,15 @@
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||||
import {
|
import {
|
||||||
AuthPageFrame,
|
AuthPageFrame,
|
||||||
AuthUsersCards,
|
AuthUsersCards,
|
||||||
} from "@/components/auth/auth-page-frame";
|
} from "@/components/auth/auth-page-frame";
|
||||||
|
import { AuthPromoCard } from "@/components/auth/auth-promo-card";
|
||||||
import { LoginForm } from "@/components/auth/login-form";
|
import { LoginForm } from "@/components/auth/login-form";
|
||||||
import { SurfaceCard } from "@/components/surface-card";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
|
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
@@ -90,22 +90,16 @@ export default async function LoginPage({
|
|||||||
// exists.
|
// exists.
|
||||||
const notice =
|
const notice =
|
||||||
sp.registered === "1" ? (
|
sp.registered === "1" ? (
|
||||||
<p
|
<AuthAlert variant="success" className="mb-4">
|
||||||
role="status"
|
|
||||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
|
||||||
>
|
|
||||||
{t("registeredSuccess")}
|
{t("registeredSuccess")}
|
||||||
</p>
|
</AuthAlert>
|
||||||
) : sp.reset === "1" ? (
|
) : sp.reset === "1" ? (
|
||||||
// `?reset=1` comes from a successful password reset; saying so matters
|
// `?reset=1` comes from a successful password reset; saying so matters
|
||||||
// because the visitor just changed their password and a silent form
|
// because the visitor just changed their password and a silent form
|
||||||
// reads like the reset failed.
|
// reads like the reset failed.
|
||||||
<p
|
<AuthAlert variant="success" className="mb-4">
|
||||||
role="status"
|
|
||||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
|
||||||
>
|
|
||||||
{t("passwordChanged")}
|
{t("passwordChanged")}
|
||||||
</p>
|
</AuthAlert>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,89 +121,11 @@ export default async function LoginPage({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SurfaceCard
|
<AuthPromoCard
|
||||||
className="card-glow relative overflow-hidden p-6 text-center"
|
onlineLabel={t("usersOnline", { count: online })}
|
||||||
style={{
|
title={t("welcomeBack")}
|
||||||
borderColor:
|
subtitle={t("welcomeBackSub", { hotelName })}
|
||||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
|
||||||
<div
|
|
||||||
className="aurora-blob -left-16 top-10 h-56 w-56"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="aurora-blob -right-16 bottom-0 h-56 w-56"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
|
|
||||||
animationDelay: "-8s",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
|
|
||||||
<Image
|
|
||||||
src="/assets/images/FrankwithBag.gif"
|
|
||||||
alt="Frank"
|
|
||||||
width={130}
|
|
||||||
height={170}
|
|
||||||
className="relative object-contain mx-auto drop-shadow-xl animate-float"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
|
|
||||||
style={{
|
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
|
||||||
boxShadow:
|
|
||||||
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span
|
|
||||||
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
|
||||||
style={{ background: "var(--color-primary)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="relative inline-flex rounded-full h-2 w-2"
|
|
||||||
style={{ background: "var(--color-primary)" }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{t("usersOnline", { count: online })}
|
|
||||||
</div>
|
|
||||||
<h1
|
|
||||||
className="text-xl font-black"
|
|
||||||
style={{
|
|
||||||
color: "var(--color-text-readable)",
|
|
||||||
fontFamily: "var(--font-nunito)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("welcomeBack")}
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{t("welcomeBackSub", { hotelName })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SurfaceCard>
|
|
||||||
|
|
||||||
<AuthUsersCards
|
<AuthUsersCards
|
||||||
recentUsers={recentUsers}
|
recentUsers={recentUsers}
|
||||||
|
|||||||
+4
-85
@@ -7,16 +7,14 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { AnimatedCounter } from "@/components/animated-counter";
|
import { AnimatedCounter } from "@/components/animated-counter";
|
||||||
|
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||||
import { LoginForm } from "@/components/auth/login-form";
|
import { LoginForm } from "@/components/auth/login-form";
|
||||||
import { Clock } from "@/components/clock";
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { LiveOnlineCounter } from "@/components/live-online-counter";
|
import { LiveOnlineCounter } from "@/components/live-online-counter";
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
import { PublicLoadError } from "@/components/public/load-error";
|
import { PublicLoadError } from "@/components/public/load-error";
|
||||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { SpotlightCard } from "@/components/spotlight-card";
|
import { SpotlightCard } from "@/components/spotlight-card";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
||||||
import { TypewriterText } from "@/components/typewriter-text";
|
import { TypewriterText } from "@/components/typewriter-text";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
@@ -41,7 +39,6 @@ import {
|
|||||||
ICON_CAMERA,
|
ICON_CAMERA,
|
||||||
ICON_CATALOG,
|
ICON_CATALOG,
|
||||||
ICON_FRIENDS,
|
ICON_FRIENDS,
|
||||||
ICON_LIGHTHOUSE,
|
|
||||||
ICON_NAV_GOODY,
|
ICON_NAV_GOODY,
|
||||||
ICON_NAV_ME,
|
ICON_NAV_ME,
|
||||||
} from "@/lib/site-icons";
|
} from "@/lib/site-icons";
|
||||||
@@ -406,87 +403,9 @@ async function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
|
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
|
||||||
{/* ── Top bar ── */}
|
{/* ── Top bar ── */}
|
||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
<div className="mx-auto w-full max-w-7xl">
|
||||||
<div
|
<AuthTopBar hotelName={hotelName} logo={logo} />
|
||||||
className="flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
|
</div>
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
|
|
||||||
backdropFilter: "blur(14px)",
|
|
||||||
WebkitBackdropFilter: "blur(14px)",
|
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
|
||||||
boxShadow:
|
|
||||||
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
{logo ? (
|
|
||||||
<Image
|
|
||||||
src={logo}
|
|
||||||
alt={hotelName}
|
|
||||||
width={100}
|
|
||||||
height={28}
|
|
||||||
className="shrink-0 object-contain"
|
|
||||||
unoptimized
|
|
||||||
style={{ maxHeight: 26, width: "auto", height: "auto" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 60%, var(--color-primary)))",
|
|
||||||
boxShadow:
|
|
||||||
"0 4px 16px -4px color-mix(in srgb, var(--color-primary) 60%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={ICON_LIGHTHOUSE}
|
|
||||||
alt=""
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
className="shrink-0"
|
|
||||||
unoptimized
|
|
||||||
style={{ filter: "brightness(0) invert(1)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
|
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
|
||||||
>
|
|
||||||
{hotelName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<div
|
|
||||||
className="hidden w-px h-5 sm:block"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Clock />
|
|
||||||
<div
|
|
||||||
className="w-px h-5"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ThemeSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* ── Cinematic hero ── */}
|
{/* ── Cinematic hero ── */}
|
||||||
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
|
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
AuthPageFrame,
|
AuthPageFrame,
|
||||||
AuthUsersCards,
|
AuthUsersCards,
|
||||||
} from "@/components/auth/auth-page-frame";
|
} from "@/components/auth/auth-page-frame";
|
||||||
|
import { AuthPromoCard } from "@/components/auth/auth-promo-card";
|
||||||
import { RegisterForm } from "@/components/auth/register-form";
|
import { RegisterForm } from "@/components/auth/register-form";
|
||||||
import { SurfaceCard } from "@/components/surface-card";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
|
|
||||||
@@ -86,96 +85,15 @@ export default async function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SurfaceCard
|
<AuthPromoCard
|
||||||
className="card-glow relative overflow-hidden p-6"
|
onlineLabel={tpr("usersOnline", { count: online })}
|
||||||
style={{
|
title={
|
||||||
borderColor:
|
<>
|
||||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
{tpr("title")} — <span className="gradient-text">{hotelName}</span>
|
||||||
}}
|
</>
|
||||||
>
|
}
|
||||||
<div
|
subtitle={tpr("subtitle")}
|
||||||
className="absolute inset-0"
|
/>
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
|
||||||
<div
|
|
||||||
className="aurora-blob -left-16 top-6 h-56 w-56"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="aurora-blob -right-16 bottom-0 h-56 w-56"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
|
|
||||||
animationDelay: "-8s",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="shrink-0 animate-float">
|
|
||||||
<div className="brand-halo left-1/2 top-1/2 h-52 w-52 -translate-x-1/2 -translate-y-1/2" />
|
|
||||||
<Image
|
|
||||||
src="/assets/images/FrankwithBag.gif"
|
|
||||||
alt="Frank"
|
|
||||||
width={110}
|
|
||||||
height={145}
|
|
||||||
className="relative object-contain drop-shadow-xl"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-2 border"
|
|
||||||
style={{
|
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
|
||||||
boxShadow:
|
|
||||||
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span
|
|
||||||
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
|
||||||
style={{ background: "var(--color-primary)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="relative inline-flex rounded-full h-2 w-2"
|
|
||||||
style={{ background: "var(--color-primary)" }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{tpr("usersOnline", { count: online })}
|
|
||||||
</div>
|
|
||||||
<h1
|
|
||||||
className="text-lg font-black leading-tight"
|
|
||||||
style={{
|
|
||||||
color: "var(--color-text-readable)",
|
|
||||||
fontFamily: "var(--font-nunito)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tpr("title")} —{" "}
|
|
||||||
<span className="gradient-text">{hotelName}</span>
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="text-xs mt-1.5 leading-relaxed"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{tpr("subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SurfaceCard>
|
|
||||||
|
|
||||||
<AuthUsersCards
|
<AuthUsersCards
|
||||||
recentUsers={recentUsers}
|
recentUsers={recentUsers}
|
||||||
|
|||||||
@@ -2893,6 +2893,20 @@ html {
|
|||||||
0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent),
|
0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent),
|
||||||
0 0 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);
|
0 0 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
.auth-input[aria-invalid="true"]:not(:focus) {
|
||||||
|
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-danger) 6%,
|
||||||
|
color-mix(in srgb, var(--color-background) 88%, var(--color-surface))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.auth-input[aria-invalid="true"]:focus {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px color-mix(in srgb, var(--color-danger) 18%, transparent),
|
||||||
|
0 0 24px -8px color-mix(in srgb, var(--color-danger) 55%, transparent);
|
||||||
|
}
|
||||||
.auth-input:disabled {
|
.auth-input:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AuthAlertProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Tint of the box: red for errors, green for confirmations. */
|
||||||
|
variant?: "error" | "success";
|
||||||
|
/** Extra classes, e.g. spacing (`mb-4`) or entrance animation. */
|
||||||
|
className?: string;
|
||||||
|
/** Optional id so inputs can point at the message via `aria-describedby`. */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single status/error banner for the auth forms. Uses `role="alert"` for
|
||||||
|
* failures (screen readers announce them immediately) and `role="status"` for
|
||||||
|
* confirmations, so the two are wired correctly instead of every caller
|
||||||
|
* repeating the markup and risking an unannounced message.
|
||||||
|
*/
|
||||||
|
export function AuthAlert({
|
||||||
|
children,
|
||||||
|
variant = "error",
|
||||||
|
className = "",
|
||||||
|
id,
|
||||||
|
}: AuthAlertProps) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
id={id}
|
||||||
|
role={variant === "error" ? "alert" : "status"}
|
||||||
|
className={`auth-alert animate-fade-in-up m-0 ${
|
||||||
|
variant === "success" ? "auth-alert--success" : ""
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { SurfaceCard } from "@/components/surface-card";
|
||||||
|
|
||||||
|
interface AuthPromoCardProps {
|
||||||
|
/** Fully formatted "N users online" label. */
|
||||||
|
onlineLabel: string;
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single promo panel both `/login` and `/register` show above the form:
|
||||||
|
* Frank, the online badge and a short welcome. Keeping it in one place means
|
||||||
|
* the two auth pages can never drift apart visually again.
|
||||||
|
*/
|
||||||
|
export function AuthPromoCard({
|
||||||
|
onlineLabel,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: AuthPromoCardProps) {
|
||||||
|
return (
|
||||||
|
<SurfaceCard
|
||||||
|
className="card-glow relative overflow-hidden p-6 text-center"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
<div
|
||||||
|
className="aurora-blob -left-16 top-10 h-56 w-56"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="aurora-blob -right-16 bottom-0 h-56 w-56"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
|
||||||
|
animationDelay: "-8s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
|
||||||
|
<Image
|
||||||
|
src="/assets/images/FrankwithBag.gif"
|
||||||
|
alt="Frank"
|
||||||
|
width={130}
|
||||||
|
height={170}
|
||||||
|
className="relative object-contain mx-auto drop-shadow-xl animate-float"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
||||||
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
|
boxShadow:
|
||||||
|
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span
|
||||||
|
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
||||||
|
style={{ background: "var(--color-primary)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="relative inline-flex rounded-full h-2 w-2"
|
||||||
|
style={{ background: "var(--color-primary)" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{onlineLabel}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-xl font-black"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
fontFamily: "var(--font-nunito)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-1.5 mx-auto max-w-[240px] leading-relaxed"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
import { KeyRound, Lock, UserRound } from "lucide-react";
|
import { KeyRound, Lock, UserRound } from "lucide-react";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { type FormEvent, useId, useState } from "react";
|
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
|
||||||
import { precheckLogin } from "@/actions/auth-precheck";
|
import { precheckLogin } from "@/actions/auth-precheck";
|
||||||
|
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||||
import {
|
import {
|
||||||
type CaptchaPublicConfig,
|
type CaptchaPublicConfig,
|
||||||
CaptchaWidget,
|
CaptchaWidget,
|
||||||
@@ -51,8 +52,17 @@ export function LoginForm({
|
|||||||
const [unverified, setUnverified] = useState(false);
|
const [unverified, setUnverified] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(false);
|
||||||
|
const codeRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// The 2FA field only exists after the pre-check asks for a code; move focus
|
||||||
|
// there automatically so the visitor can keep typing without reaching for
|
||||||
|
// the mouse (the lint rule rules out a bare `autoFocus`).
|
||||||
|
useEffect(() => {
|
||||||
|
if (needs2fa) codeRef.current?.focus();
|
||||||
|
}, [needs2fa]);
|
||||||
|
|
||||||
const showFooter = variant === "page";
|
const showFooter = variant === "page";
|
||||||
|
const compact = variant === "compact";
|
||||||
const lockCredentials =
|
const lockCredentials =
|
||||||
needs2fa || locked ? "opacity-50 pointer-events-none" : "";
|
needs2fa || locked ? "opacity-50 pointer-events-none" : "";
|
||||||
|
|
||||||
@@ -115,7 +125,9 @@ export function LoginForm({
|
|||||||
<>
|
<>
|
||||||
{needs2fa && (
|
{needs2fa && (
|
||||||
<p
|
<p
|
||||||
className="animate-fade-in-up mb-4 rounded-xl border px-3 py-2.5 text-sm font-semibold"
|
className={`animate-fade-in-up rounded-xl border px-3 py-2.5 text-sm font-semibold ${
|
||||||
|
compact ? "mb-3" : "mb-4"
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
color: "var(--color-text-readable)",
|
color: "var(--color-text-readable)",
|
||||||
background:
|
background:
|
||||||
@@ -131,7 +143,7 @@ export function LoginForm({
|
|||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
aria-label={t("signIn")}
|
aria-label={t("signIn")}
|
||||||
className="relative flex flex-col gap-4"
|
className={`relative flex flex-col ${compact ? "gap-3" : "gap-4"}`}
|
||||||
>
|
>
|
||||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
<div className={`space-y-1.5 ${lockCredentials}`}>
|
||||||
<label htmlFor={`${fieldId}-username`} className="auth-label">
|
<label htmlFor={`${fieldId}-username`} className="auth-label">
|
||||||
@@ -151,8 +163,10 @@ export function LoginForm({
|
|||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder={t("usernamePlaceholder")}
|
placeholder={t("usernamePlaceholder")}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
disabled={needs2fa}
|
disabled={needs2fa || pending}
|
||||||
required
|
required
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||||
className="auth-input has-icon"
|
className="auth-input has-icon"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,8 +190,10 @@ export function LoginForm({
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={t("passwordPlaceholder")}
|
placeholder={t("passwordPlaceholder")}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={needs2fa}
|
disabled={needs2fa || pending}
|
||||||
required
|
required
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||||
className="auth-input has-icon has-action"
|
className="auth-input has-icon has-action"
|
||||||
/>
|
/>
|
||||||
<PasswordToggle
|
<PasswordToggle
|
||||||
@@ -201,6 +217,7 @@ export function LoginForm({
|
|||||||
style={{ color: "var(--color-primary)" }}
|
style={{ color: "var(--color-primary)" }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
ref={codeRef}
|
||||||
id={`${fieldId}-2fa`}
|
id={`${fieldId}-2fa`}
|
||||||
name="code"
|
name="code"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -209,6 +226,9 @@ export function LoginForm({
|
|||||||
placeholder={t("codePlaceholder")}
|
placeholder={t("codePlaceholder")}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
disabled={pending}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||||
className="auth-input has-icon"
|
className="auth-input has-icon"
|
||||||
style={{
|
style={{
|
||||||
borderColor:
|
borderColor:
|
||||||
@@ -221,15 +241,7 @@ export function LoginForm({
|
|||||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && <AuthAlert id={`${fieldId}-error`}>{error}</AuthAlert>}
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
className="auth-alert animate-fade-in-up m-0"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{unverified && (
|
{unverified && (
|
||||||
<p
|
<p
|
||||||
@@ -249,7 +261,9 @@ export function LoginForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending || locked}
|
disabled={pending || locked}
|
||||||
className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
|
className={`btn-brand btn-shine w-full cursor-pointer text-sm font-extrabold uppercase tracking-wider disabled:opacity-60 ${
|
||||||
|
compact ? "py-3" : "py-3.5"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<span className="inline-flex items-center justify-center gap-2">
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type RegisterState,
|
type RegisterState,
|
||||||
register,
|
register,
|
||||||
} from "@/actions/register";
|
} from "@/actions/register";
|
||||||
|
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||||
import { PasswordToggle } from "@/components/auth/password-toggle";
|
import { PasswordToggle } from "@/components/auth/password-toggle";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||||
@@ -183,15 +184,7 @@ export function RegisterForm({
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
{errorMessage && (
|
{errorMessage && <AuthAlert>{errorMessage}</AuthAlert>}
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
className="auth-alert animate-fade-in-up m-0"
|
|
||||||
>
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{referralCode && (
|
{referralCode && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in new issue
Block a user