feat(01-02): build branded login page with password toggle and error handling

- Split-screen layout: agent photo left (lg+), login card right
- loginAction server action: signIn credentials, redirects on AuthError
- PasswordField client component: show/hide toggle with eye/eye-off SVG
- Signed-out confirmation banner on ?signed_out=1
- Error banner on ?error=invalid with generic message
- Brand colors: gold #C9A84C accent, navy #1B2B4B text, off-white #FAF9F7 bg
- Copied /Users/ccopeland/Downloads/red.jpg to public/red.jpg
This commit is contained in:
Chandler Copeland
2026-03-19 13:38:42 -06:00
parent 7fdce32d0d
commit f221597677
3 changed files with 231 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
export function PasswordField() {
const [showPassword, setShowPassword] = useState(false);
return (
<div>
<label
htmlFor="password"
className="block text-sm font-medium mb-1.5"
style={{ color: "#1B2B4B" }}
>
Password
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
autoComplete="current-password"
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 pr-11 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-500 hover:text-gray-700 focus:outline-none"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
// Eye-off icon (password visible — click to hide)
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
// Eye icon (password hidden — click to show)
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import Image from "next/image";
import { redirect } from "next/navigation";
import { AuthError } from "next-auth";
import { signIn } from "@/lib/auth";
import { PasswordField } from "./PasswordField";
async function loginAction(formData: FormData) {
"use server";
try {
await signIn("credentials", {
email: formData.get("email") as string,
password: formData.get("password") as string,
redirectTo: "/agent/dashboard",
});
} catch (error) {
// CRITICAL: Only catch AuthError. Everything else (including NEXT_REDIRECT) must re-throw.
if (error instanceof AuthError) {
redirect("/agent/login?error=invalid");
}
throw error; // Re-throws NEXT_REDIRECT — allows the redirect to fire
}
}
interface LoginPageProps {
searchParams: Promise<{ error?: string; signed_out?: string }>;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const hasError = params.error === "invalid";
const signedOut = params.signed_out === "1";
return (
<div className="min-h-screen flex">
{/* Left side: branded photo panel — hidden on mobile */}
<div className="hidden lg:flex lg:w-1/2 relative">
<Image
src="/red.jpg"
alt="Teressa Copeland"
fill
className="object-cover"
priority
/>
{/* Dark gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-[#1B2B4B]/80 via-[#1B2B4B]/30 to-transparent" />
{/* Brand name text */}
<div className="absolute bottom-10 left-8 right-8">
<p className="text-[#C9A84C] text-sm font-semibold tracking-widest uppercase mb-2">
Agent Portal
</p>
<h2 className="text-white text-3xl font-bold leading-tight">
Teressa Copeland Homes
</h2>
<p className="text-white/70 text-sm mt-2">
Utah real estate expertise
</p>
</div>
</div>
{/* Right side: login form */}
<div
className="flex-1 flex items-center justify-center px-6 py-12"
style={{ backgroundColor: "#FAF9F7" }}
>
<div className="w-full max-w-md">
{/* Mobile brand header */}
<div className="lg:hidden text-center mb-8">
<p
className="text-sm font-semibold tracking-widest uppercase mb-1"
style={{ color: "#C9A84C" }}
>
Teressa Copeland Homes
</p>
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-lg px-8 py-10">
<h1
className="text-2xl font-bold mb-6"
style={{ color: "#1B2B4B" }}
>
Agent Portal
</h1>
{/* Signed-out confirmation banner */}
{signedOut && (
<div
className="mb-5 rounded-lg px-4 py-3 text-sm font-medium"
style={{
backgroundColor: "#F0FDF4",
color: "#166534",
border: "1px solid #BBF7D0",
}}
role="alert"
>
You&apos;ve been signed out.
</div>
)}
{/* Error banner */}
{hasError && (
<div
className="mb-5 rounded-lg px-4 py-3 text-sm font-medium"
style={{
backgroundColor: "#FEF2F2",
color: "#991B1B",
border: "1px solid #FECACA",
}}
role="alert"
>
Invalid email or password.
</div>
)}
<form action={loginAction} className="space-y-5">
{/* Email field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium mb-1.5"
style={{ color: "#1B2B4B" }}
>
Email address
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
style={
{
"--tw-ring-color": "#C9A84C",
} as React.CSSProperties
}
placeholder="you@example.com"
/>
</div>
{/* Password field — client component for show/hide toggle */}
<PasswordField />
{/* Submit button */}
<button
type="submit"
className="w-full rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{
backgroundColor: "#C9A84C",
// @ts-expect-error CSS custom property for focus ring
"--tw-ring-color": "#C9A84C",
}}
>
Sign in
</button>
</form>
</div>
</div>
</div>
</div>
);
}