diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 7b78766..67e43f8 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,93 +1,10 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { type FormEvent, useState } from "react"; - -import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { authClient } from "@/lib/auth-client"; +import { AuthLayout } from "@/components/auth/auth-ui"; +import { LoginForm } from "@/components/login-form"; export default function LoginPage() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [submitting, setSubmitting] = useState(false); - - const onSubmit = async (event: FormEvent) => { - event.preventDefault(); - if (submitting) return; - setSubmitting(true); - setError(null); - - const { error: err } = await authClient.signIn.email({ - email: email.trim(), - password, - callbackURL: `${window.location.origin}/`, - }); - - if (err) { - setError( - err.message ?? - "Could not sign in. Check your email and password and try again." - ); - setSubmitting(false); - return; - } - router.push("/"); - }; - return ( - - New to temetro?{" "} - - Create an account - - - } - subtitle="Sign in to your clinician account" - title="Welcome back" - > -
- {error && {error}} - - setEmail(e.target.value)} - placeholder="you@clinic.org" - required - type="email" - value={email} - /> - - - setPassword(e.target.value)} - placeholder="••••••••••••" - required - type="password" - value={password} - /> - -
- - Forgot password? - -
- -
-
+ + + ); } diff --git a/frontend/app/(auth)/signup/page.tsx b/frontend/app/(auth)/signup/page.tsx index b8e3d8e..dc43a9a 100644 --- a/frontend/app/(auth)/signup/page.tsx +++ b/frontend/app/(auth)/signup/page.tsx @@ -1,124 +1,10 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { type FormEvent, useState } from "react"; - -import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { authClient } from "@/lib/auth-client"; - -const MIN_PASSWORD = 12; +import { AuthLayout } from "@/components/auth/auth-ui"; +import { SignupForm } from "@/components/signup-form"; export default function SignupPage() { - const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [error, setError] = useState(null); - const [submitting, setSubmitting] = useState(false); - - const onSubmit = async (event: FormEvent) => { - event.preventDefault(); - if (submitting) return; - setError(null); - - if (password.length < MIN_PASSWORD) { - setError(`Password must be at least ${MIN_PASSWORD} characters.`); - return; - } - if (password !== confirm) { - setError("Passwords do not match."); - return; - } - - setSubmitting(true); - const { error: err } = await authClient.signUp.email({ - name: name.trim(), - email: email.trim(), - password, - callbackURL: `${window.location.origin}/verify-email`, - }); - - if (err) { - setError(err.message ?? "Could not create your account."); - setSubmitting(false); - return; - } - // Email verification isn't enforced yet (see backend auth config), so the - // user is signed in on sign-up — go straight to clinic onboarding. A - // verification email is still sent for when verification is re-enabled. - router.push("/"); - }; - return ( - - Already have an account?{" "} - - Sign in - - - } - subtitle="Start using temetro in your clinic" - title="Create your account" - > -
- {error && {error}} - - setName(e.target.value)} - placeholder="Dr. Jane Okafor" - required - value={name} - /> - - - setEmail(e.target.value)} - placeholder="you@clinic.org" - required - type="email" - value={email} - /> - - - setPassword(e.target.value)} - placeholder="••••••••••••" - required - type="password" - value={password} - /> - - - setConfirm(e.target.value)} - placeholder="••••••••••••" - required - type="password" - value={confirm} - /> - - -
-
+ + + ); } diff --git a/frontend/components/auth/auth-ui.tsx b/frontend/components/auth/auth-ui.tsx index 8a986f7..722b9e7 100644 --- a/frontend/components/auth/auth-ui.tsx +++ b/frontend/components/auth/auth-ui.tsx @@ -1,51 +1,46 @@ -"use client"; - -import { motion, type Variants } from "framer-motion"; import Image from "next/image"; import type { ReactNode } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { cn } from "@/lib/utils"; -const container: Variants = { - hidden: {}, - show: { transition: { staggerChildren: 0.09, delayChildren: 0.18 } }, -}; - -const item: Variants = { - hidden: { opacity: 0, y: 16 }, - show: { - opacity: 1, - y: 0, - transition: { type: "spring", stiffness: 150, damping: 18 }, - }, -}; - -// Slowly drifting colored blobs behind everything. -function Aurora() { +// temetro logo + wordmark, centered. +export function AuthBrand() { return ( -
- - - + temetro + temetro
); } +// Centered page wrapper for every auth screen: brand on top, content below. +export function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+
+ + {children} +
+
+ ); +} + +// Card-based shell for the non-block auth pages (onboarding, verify, reset, +// forgot-password, accept-invite). export function AuthShell({ title, subtitle, @@ -58,100 +53,18 @@ export function AuthShell({ footer?: ReactNode; }) { return ( -
- -
- - - {/* rotating gradient halo */} - - - {/* the card */} - - {/* top edge highlight */} -
- {/* one-time shimmer sweep on mount */} - - - - - temetro - -

{title}

- {subtitle && ( -

{subtitle}

- )} -
- - {children} - - {footer && ( - - {footer} - - )} -
- -
+ + + + {title} + {subtitle && {subtitle}} + + {children} + + {footer && ( +
{footer}
+ )} +
); } diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx new file mode 100644 index 0000000..6fcc4fb --- /dev/null +++ b/frontend/components/login-form.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; +import { cn } from "@/lib/utils"; + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting) return; + setSubmitting(true); + setError(null); + + const { error: err } = await authClient.signIn.email({ + email: email.trim(), + password, + callbackURL: `${window.location.origin}/`, + }); + + if (err) { + setError( + err.message ?? + "Could not sign in. Check your email and password and try again." + ); + setSubmitting(false); + return; + } + router.push("/"); + }; + + return ( +
+ + + Welcome back + Sign in to your clinician account + + +
+ + {error && ( +

+ {error} +

+ )} + + Email + setEmail(e.target.value)} + placeholder="you@clinic.org" + required + type="email" + value={email} + /> + + +
+ Password + + Forgot your password? + +
+ setPassword(e.target.value)} + required + type="password" + value={password} + /> +
+ + + + Don't have an account?{" "} + Sign up + + +
+
+
+
+
+ ); +} diff --git a/frontend/components/signup-form.tsx b/frontend/components/signup-form.tsx new file mode 100644 index 0000000..9ae6a67 --- /dev/null +++ b/frontend/components/signup-form.tsx @@ -0,0 +1,157 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; +import { cn } from "@/lib/utils"; + +const MIN_PASSWORD = 12; + +export function SignupForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting) return; + setError(null); + + if (password.length < MIN_PASSWORD) { + setError(`Password must be at least ${MIN_PASSWORD} characters.`); + return; + } + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + + setSubmitting(true); + const { error: err } = await authClient.signUp.email({ + name: name.trim(), + email: email.trim(), + password, + callbackURL: `${window.location.origin}/verify-email`, + }); + + if (err) { + setError(err.message ?? "Could not create your account."); + setSubmitting(false); + return; + } + // Verification isn't enforced yet — the user is signed in, so go to + // clinic onboarding. + router.push("/"); + }; + + return ( +
+ + + Create your account + + Start using temetro in your clinic + + + +
+ + {error && ( +

+ {error} +

+ )} + + Full name + setName(e.target.value)} + placeholder="Dr. Jane Okafor" + required + type="text" + value={name} + /> + + + Email + setEmail(e.target.value)} + placeholder="you@clinic.org" + required + type="email" + value={email} + /> + + + + + Password + setPassword(e.target.value)} + required + type="password" + value={password} + /> + + + + Confirm password + + setConfirm(e.target.value)} + required + type="password" + value={confirm} + /> + + + + Must be at least {MIN_PASSWORD} characters long. + + + + + + Already have an account? Sign in + + +
+
+
+
+
+ ); +} diff --git a/frontend/components/ui/field.tsx b/frontend/components/ui/field.tsx new file mode 100644 index 0000000..5b3ff19 --- /dev/null +++ b/frontend/components/ui/field.tsx @@ -0,0 +1,238 @@ +"use client" + +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +