From 3ddc34206dde74031c30b0b772ae278cabad5eed Mon Sep 17 00:00:00 2001 From: amoomustakim-hue Date: Thu, 21 May 2026 10:48:09 +0100 Subject: [PATCH] feat(web): add login, register, forgot-password, and verify-email pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the W-03 auth pages using react-hook-form + Zod v4. All forms validate client-side and call confirmed backend endpoints. JWT tokens are never touched on the client — backend sets HttpOnly cookies. - lib/api.ts: thin fetch wrapper with credentials:include and envelope unwrap - (auth)/layout.tsx: minimal auth chrome (logo only, no nav/sidebar) - login/page.tsx: email+password form → POST /auth/login → redirectTo - forgot-password/page.tsx: email form → POST /auth/forgot-password (safe msg) - verify-email/page.tsx: 6-digit OTP → POST /auth/email/verify + resend - register/page.tsx: 8-step wizard Step 1 — method selection (Google/Apple disabled, email active) Step 2 — email + password (full strength validation) Step 3 — Nigerian phone (+234[789]XXXXXXXXX) Step 4 — DOB (13+ enforced client and backend) Step 5 — display name + username → POST /auth/register/email Step 6 — interests (client-side only, skip available) Step 7 — bio (optional, max 180 chars, skip available) Step 8 — store prompt (18+ only) → /verify-email?next=... No role selection anywhere. No JWT in localStorage. No backend files changed. Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/(auth)/forgot-password/page.tsx | 106 ++++ apps/web/src/app/(auth)/layout.tsx | 29 + apps/web/src/app/(auth)/login/page.tsx | 135 ++++ apps/web/src/app/(auth)/register/page.tsx | 587 ++++++++++++++++++ apps/web/src/app/(auth)/verify-email/page.tsx | 146 +++++ apps/web/src/lib/api.ts | 35 ++ 6 files changed, 1038 insertions(+) create mode 100644 apps/web/src/app/(auth)/forgot-password/page.tsx create mode 100644 apps/web/src/app/(auth)/layout.tsx create mode 100644 apps/web/src/app/(auth)/login/page.tsx create mode 100644 apps/web/src/app/(auth)/register/page.tsx create mode 100644 apps/web/src/app/(auth)/verify-email/page.tsx create mode 100644 apps/web/src/lib/api.ts diff --git a/apps/web/src/app/(auth)/forgot-password/page.tsx b/apps/web/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..c34161a --- /dev/null +++ b/apps/web/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import Link from "next/link"; +import { CheckCircle } from "lucide-react"; +import { Input } from "@/components/ui/Input"; +import { Button } from "@/components/ui/Button"; +import { api } from "@/lib/api"; + +const schema = z.object({ + email: z.string().email("Enter a valid email address"), +}); + +type Fields = z.infer; + +export default function ForgotPasswordPage() { + const [sent, setSent] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(schema) }); + + const onSubmit = async (data: Fields) => { + try { + await api.post("/auth/forgot-password", data); + } catch { + // Always show the safe generic message regardless of outcome — + // do not reveal whether the email is registered. + } finally { + setSent(true); + } + }; + + if (sent) { + return ( +
+
+
+
+
+

+ Check your email +

+

+ If an account exists for that email, we'll send reset + instructions shortly. +

+ + Back to sign in + +
+ ); + } + + return ( +
+

+ Forgot your password? +

+

+ Enter your email and we'll send instructions to reset it. +

+ +
+ + + +
+ +

+ + Back to sign in + +

+
+ ); +} diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..72e0ea6 --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,29 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ + twizrr + +
+ +
+
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..c39d054 --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Eye, EyeOff } from "lucide-react"; +import { Input } from "@/components/ui/Input"; +import { Button } from "@/components/ui/Button"; +import { api, type ApiError } from "@/lib/api"; + +const loginSchema = z.object({ + email: z.string().email("Enter a valid email address"), + password: z.string().min(1, "Password is required"), +}); + +type LoginFields = z.infer; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [showPassword, setShowPassword] = useState(false); + const [serverError, setServerError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(loginSchema) }); + + const onSubmit = async (data: LoginFields) => { + setServerError(null); + try { + const res = await api.post<{ redirectTo: string }>("/auth/login", data); + const destination = searchParams.get("redirect") ?? res.redirectTo; + router.push(destination); + } catch (err) { + setServerError( + (err as ApiError).message ?? "Something went wrong. Please try again.", + ); + } + }; + + return ( +
+

+ Welcome back +

+

+ Sign in to your twizrr account. +

+ +
+ + + setShowPassword((p) => !p)} + aria-label={showPassword ? "Hide password" : "Show password"} + className="pointer-events-auto cursor-pointer p-1 -m-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" + > + {showPassword ? ( +
+ ); +} + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..df692a8 --- /dev/null +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,587 @@ +"use client"; + +import { useState } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Eye, EyeOff } from "lucide-react"; +import { Input } from "@/components/ui/Input"; +import { Button } from "@/components/ui/Button"; +import { api, type ApiError } from "@/lib/api"; + +// ─── Interest categories (client-side only — for feed personalisation) ─────── + +const INTERESTS = [ + "Fashion & Clothing", + "Footwear", + "Beauty & Skincare", + "Electronics", + "Food & Groceries", + "Home & Living", + "Kids & Baby", + "Sports & Fitness", + "Arts & Crafts", + "Jewellery & Accessories", + "Health & Wellness", + "Books", +]; + +// ─── Shared utility ─────────────────────────────────────────────────────────── + +function calcAge(dobString: string): number { + const dob = new Date(dobString); + const now = new Date(); + const age = + now.getFullYear() - + dob.getFullYear() - + (now < new Date(now.getFullYear(), dob.getMonth(), dob.getDate()) ? 1 : 0); + return age; +} + +// ─── Zod schemas per step ───────────────────────────────────────────────────── + +const step2Schema = z.object({ + email: z.string().email("Enter a valid email address"), + password: z + .string() + .min(8, "At least 8 characters") + .max(128) + .regex(/[A-Z]/, "Include an uppercase letter") + .regex(/[a-z]/, "Include a lowercase letter") + .regex(/\d/, "Include a number") + .regex(/[^A-Za-z0-9]/, "Include a special character (e.g. !@#$%)"), +}); + +const step3Schema = z.object({ + phone: z + .string() + .regex( + /^\+234[789]\d{8}$/, + "Enter a valid Nigerian number (e.g. +2348012345678)", + ), +}); + +const step4Schema = z.object({ + dateOfBirth: z + .string() + .min(1, "Date of birth is required") + .refine((v) => !isNaN(new Date(v).getTime()), "Enter a valid date") + .refine((v) => new Date(v) < new Date(), "Date must be in the past") + .refine((v) => calcAge(v) >= 13, "You must be 13 or older to join twizrr"), +}); + +const step5Schema = z.object({ + displayName: z + .string() + .min(1, "Your name is required") + .max(80, "At most 80 characters"), + username: z + .string() + .min(3, "At least 3 characters") + .max(30, "At most 30 characters") + .regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers, and underscores"), +}); + +type Step2Fields = z.infer; +type Step3Fields = z.infer; +type Step4Fields = z.infer; +type Step5Fields = z.infer; + +// ─── Wizard state ───────────────────────────────────────────────────────────── + +type WizardData = { + email: string; + password: string; + phone: string; + dateOfBirth: string; + username: string; + displayName: string; + interests: string[]; + bio: string; +}; + +// ─── Step progress bar ──────────────────────────────────────────────────────── + +function StepProgress({ step, total }: { step: number; total: number }) { + return ( +
+

+ Step {step} of {total} +

+
+
+
+
+ ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function RegisterPage() { + const router = useRouter(); + + const [step, setStep] = useState(1); + const [isMinor, setIsMinor] = useState(false); + const [serverError, setServerError] = useState(null); + const [isRegistering, setIsRegistering] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const [data, setData] = useState({ + email: "", + password: "", + phone: "", + dateOfBirth: "", + username: "", + displayName: "", + interests: [], + bio: "", + }); + + // Steps 6-8 are post-registration; step 8 (store prompt) only for 18+ + const TOTAL_STEPS = isMinor ? 7 : 8; + + function goBack() { + setStep((s) => Math.max(1, s - 1)); + setServerError(null); + } + + // Redirect to verify-email then on to `destination` + function goToVerify(destination: string) { + const params = new URLSearchParams({ + email: data.email, + next: destination, + }); + router.push(`/verify-email?${params.toString()}`); + } + + // ── Per-step forms ────────────────────────────────────────────────────────── + + const form2 = useForm({ + resolver: zodResolver(step2Schema), + defaultValues: { email: data.email, password: data.password }, + }); + + const form3 = useForm({ + resolver: zodResolver(step3Schema), + defaultValues: { phone: data.phone }, + }); + + const form4 = useForm({ + resolver: zodResolver(step4Schema), + defaultValues: { dateOfBirth: data.dateOfBirth }, + }); + + const form5 = useForm({ + resolver: zodResolver(step5Schema), + defaultValues: { displayName: data.displayName, username: data.username }, + }); + + const onStep2: SubmitHandler = (fields) => { + setData((d) => ({ ...d, ...fields })); + setStep(3); + }; + + const onStep3: SubmitHandler = (fields) => { + setData((d) => ({ ...d, ...fields })); + setStep(4); + }; + + const onStep4: SubmitHandler = (fields) => { + setIsMinor(calcAge(fields.dateOfBirth) < 18); + setData((d) => ({ ...d, dateOfBirth: fields.dateOfBirth })); + setStep(5); + }; + + // Step 5 submits to the backend + const onStep5: SubmitHandler = async (fields) => { + const merged: WizardData = { ...data, ...fields }; + setData(merged); + setServerError(null); + setIsRegistering(true); + try { + await api.post("/auth/register/email", { + email: merged.email, + phone: merged.phone, + username: merged.username, + displayName: merged.displayName, + dateOfBirth: new Date(merged.dateOfBirth), + password: merged.password, + }); + setStep(6); + } catch (err) { + setServerError( + (err as ApiError).message ?? "Something went wrong. Please try again.", + ); + } finally { + setIsRegistering(false); + } + }; + + function toggleInterest(interest: string) { + setData((d) => ({ + ...d, + interests: d.interests.includes(interest) + ? d.interests.filter((i) => i !== interest) + : [...d.interests, interest], + })); + } + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Back button — visible on data-collection steps only */} + {step > 1 && step <= 5 && ( + + )} + + {/* Progress indicator — visible from step 2 onward */} + {step > 1 && } + + {/* ===== STEP 1: Choose method ===== */} + {step === 1 && ( +
+

+ Create your account +

+

+ Join twizrr — shop from verified Nigerian stores, every purchase + protected by escrow. +

+ +
+ + + +
+
+ + or + +
+
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+ )} + + {/* ===== STEP 2: Email + Password ===== */} + {step === 2 && ( +
+

+ Your email +

+

+ Use an email you can access — we'll send a verification code. +

+ +
+ + + setShowPassword((p) => !p)} + aria-label={showPassword ? "Hide password" : "Show password"} + className="pointer-events-auto cursor-pointer p-1 -m-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" + > + {showPassword ? ( +
+ )} + + {/* ===== STEP 3: Phone ===== */} + {step === 3 && ( +
+

+ Phone number +

+

+ Your Nigerian mobile number — used for order updates. +

+ +
+ + + +
+
+ )} + + {/* ===== STEP 4: Date of birth ===== */} + {step === 4 && ( +
+

+ Date of birth +

+

+ Required — you must be 13 or older to use twizrr. +

+ +
+ + + +
+
+ )} + + {/* ===== STEP 5: Identity (username + display name) → backend call ===== */} + {step === 5 && ( +
+

+ Your identity +

+

+ Choose a name and a unique username for your profile. +

+ +
+ + + + + {serverError && ( +

+ {serverError} +

+ )} + + +
+
+ )} + + {/* ===== STEP 6: Interests (UI only — no backend) ===== */} + {step === 6 && ( +
+

+ What are you into? +

+

+ Personalise your feed. Select as many as you like. +

+ +
+ {INTERESTS.map((interest) => { + const selected = data.interests.includes(interest); + return ( + + ); + })} +
+ + +
+ )} + + {/* ===== STEP 7: Bio (optional) ===== */} + {step === 7 && ( +
+

+ Tell us about yourself +

+

+ Optional — you can always update this later from your profile. +

+ +
+
+ +