From e57287e2da712c7f3745cf3395d39de6a6e7c0b3 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Tue, 2 Jun 2026 03:56:24 +0100 Subject: [PATCH 1/3] fix(web): improve auth and store setup flow --- apps/web/src/app/(auth)/login/page.tsx | 20 ++++++- apps/web/src/app/(auth)/register/page.tsx | 44 ++++++++++---- .../(store)/store/setup/StoreSetupClient.tsx | 59 +++++++++++++------ 3 files changed, 94 insertions(+), 29 deletions(-) diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index a491d6d..f38d6d3 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -19,6 +19,20 @@ const loginSchema = z.object({ type LoginFields = z.infer; +const DEFAULT_LOGIN_DESTINATION = "/home"; + +function getSafeRedirectDestination(value: string | null): string | null { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return null; + } + + if (value === "/login" || value.startsWith("/login?")) { + return null; + } + + return value; +} + function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); @@ -34,8 +48,10 @@ function LoginForm() { 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; + await api.post<{ redirectTo: string }>("/auth/login", data); + const destination = + getSafeRedirectDestination(searchParams.get("redirect")) ?? + DEFAULT_LOGIN_DESTINATION; router.push(destination); } catch (err) { setServerError( diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx index ae8b29f..85ccdd6 100644 --- a/apps/web/src/app/(auth)/register/page.tsx +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -40,6 +40,26 @@ function calcAge(dobString: string): number { return age; } +const NIGERIAN_PHONE_ERROR = "Enter a valid Nigerian mobile number."; + +function normalizeNigerianPhone(input: string): string | null { + const value = input.trim(); + + if (/^0[789]\d{9}$/.test(value)) { + return `+234${value.slice(1)}`; + } + + if (/^[789]\d{9}$/.test(value)) { + return `+234${value}`; + } + + if (/^\+234[789]\d{9}$/.test(value)) { + return value; + } + + return null; +} + // ─── Zod schemas per step ───────────────────────────────────────────────────── const step2Schema = z.object({ @@ -57,10 +77,11 @@ const step2Schema = z.object({ const step3Schema = z.object({ phone: z .string() - .regex( - /^\+234[789]\d{8}$/, - "Enter a valid Nigerian number (e.g. +2348012345678)", - ), + .trim() + .refine((value) => normalizeNigerianPhone(value) !== null, { + message: NIGERIAN_PHONE_ERROR, + }) + .transform((value) => normalizeNigerianPhone(value) ?? value), }); const step4Schema = z.object({ @@ -69,7 +90,10 @@ const step4Schema = z.object({ .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"), + .refine( + (v) => calcAge(v) >= 13, + "You must meet the age requirement to join twizrr", + ), }); const step5Schema = z.object({ @@ -375,8 +399,8 @@ export default function RegisterPage() { type="tel" inputMode="tel" autoComplete="tel" - placeholder="+2348012345678" - helperText="Include country code: +234 followed by 10 digits" + placeholder="Enter your phone number" + helperText="Use a valid Nigerian mobile number." error={form3.formState.errors.phone?.message} {...form3.register("phone")} /> @@ -401,7 +425,7 @@ export default function RegisterPage() { Date of birth

- Required — you must be 13 or older to use twizrr. + Required to confirm account eligibility.

@@ -459,7 +483,7 @@ export default function RegisterPage() { label="Username" type="text" autoComplete="username" - placeholder="aminaokafor" + placeholder="your_username" helperText="3–30 characters, lowercase letters, numbers, underscores only. You can change your username once in the first 7 days." error={form5.formState.errors.username?.message} {...form5.register("username")} diff --git a/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx b/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx index f776208..3ae9881 100644 --- a/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx +++ b/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { AlertTriangle, ArrowRight, CheckCircle2, Store } from "lucide-react"; import { Button } from "@/components/ui/Button"; +import { PageLoadingState } from "@/components/ui/brand-loader"; import { Input } from "@/components/ui/Input"; -import { Skeleton } from "@/components/ui/Skeleton"; type StoreType = "DIGITAL" | "PHYSICAL"; @@ -32,6 +32,7 @@ interface ApiErrorBody { } const API_BASE = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); +const STORE_CHECK_TIMEOUT_MS = 8_000; const categoryOptions = [ "Fashion", @@ -58,6 +59,27 @@ function normalizeHandle(value: string): string { .slice(0, 30); } +function getSafePostSetupRedirect(value: string | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/store/dashboard"; + } + + if (value === "/store/setup" || value.startsWith("/store/setup?")) { + return "/store/dashboard"; + } + + return value; +} + +function getPostSetupRedirect(): string { + if (typeof window === "undefined") { + return "/store/dashboard"; + } + + const params = new URLSearchParams(window.location.search); + return getSafePostSetupRedirect(params.get("redirect")); +} + function getErrorMessage(body: ApiErrorBody | null): string { if (!body) { return "Something went wrong. Please try again."; @@ -119,10 +141,17 @@ export function StoreSetupClient() { return; } + const controller = new AbortController(); + const timeoutId = window.setTimeout( + () => controller.abort(), + STORE_CHECK_TIMEOUT_MS, + ); + try { const response = await fetch(`${API_BASE}/stores/me`, { credentials: "include", headers: { Accept: "application/json" }, + signal: controller.signal, }); if (cancelled) { @@ -130,7 +159,7 @@ export function StoreSetupClient() { } if (response.ok) { - router.replace("/store/dashboard"); + router.replace(getPostSetupRedirect()); return; } @@ -146,6 +175,7 @@ export function StoreSetupClient() { ); } } finally { + window.clearTimeout(timeoutId); if (!cancelled) { setCheckingStore(false); } @@ -161,7 +191,7 @@ export function StoreSetupClient() { const handlePreview = useMemo(() => { const normalized = normalizeHandle(storeHandle); - return normalized ? `@${normalized}` : "@your_store"; + return normalized || "your_store_name"; }, [storeHandle]); const canSubmit = @@ -213,13 +243,13 @@ export function StoreSetupClient() { }); if (response.ok) { - router.replace("/store/dashboard"); + router.replace(getPostSetupRedirect()); return; } const body = await readErrorBody(response); if (response.status === 409 && body?.code === "STORE_ALREADY_EXISTS") { - router.replace("/store/dashboard"); + router.replace(getPostSetupRedirect()); return; } @@ -232,12 +262,7 @@ export function StoreSetupClient() { } if (checkingStore) { - return ( -
- - -
- ); + return ; } return ( @@ -281,7 +306,7 @@ export function StoreSetupClient() { label="Store name" value={storeName} onChange={(event) => setStoreName(event.target.value)} - placeholder="Raseed Wears" + placeholder="Your store name" maxLength={80} required /> @@ -291,7 +316,7 @@ export function StoreSetupClient() { value={storeHandle} onChange={(event) => setStoreHandle(event.target.value)} onBlur={() => setStoreHandle(normalizeHandle(storeHandle))} - placeholder="raseed_wears" + placeholder="your_store_name" helperText="Lowercase letters, numbers, and underscores only." maxLength={30} required @@ -362,8 +387,8 @@ export function StoreSetupClient() { onChange={(event) => setFormattedAddress(event.target.value)} placeholder={ storeType === "PHYSICAL" - ? "12 Allen Avenue, Ikeja, Lagos" - : "Yaba, Lagos" + ? "Street address, city, state" + : "Your city and state" } required /> @@ -373,14 +398,14 @@ export function StoreSetupClient() { label="City" value={city} onChange={(event) => setCity(event.target.value)} - placeholder="Ikeja" + placeholder="City" required /> setStateName(event.target.value)} - placeholder="Lagos" + placeholder="State" required /> From e3f3db17c66958b4e310fe027508f5f48c1de731 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Tue, 2 Jun 2026 04:22:53 +0100 Subject: [PATCH 2/3] fix(web): remove unused login response type --- apps/web/src/app/(auth)/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index f38d6d3..df5cf25 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -48,7 +48,7 @@ function LoginForm() { const onSubmit = async (data: LoginFields) => { setServerError(null); try { - await api.post<{ redirectTo: string }>("/auth/login", data); + await api.post("/auth/login", data); const destination = getSafeRedirectDestination(searchParams.get("redirect")) ?? DEFAULT_LOGIN_DESTINATION; From 5a4f52c22304d3a7c46a86e11bffb351c322cf19 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Tue, 2 Jun 2026 04:29:45 +0100 Subject: [PATCH 3/3] fix(web): harden store setup redirect guard --- apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx b/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx index 3ae9881..281b648 100644 --- a/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx +++ b/apps/web/src/app/(store)/store/setup/StoreSetupClient.tsx @@ -64,7 +64,11 @@ function getSafePostSetupRedirect(value: string | null): string { return "/store/dashboard"; } - if (value === "/store/setup" || value.startsWith("/store/setup?")) { + const redirectUrl = new URL(value, window.location.origin); + if ( + redirectUrl.pathname === "/store/setup" || + redirectUrl.pathname === "/store/setup/" + ) { return "/store/dashboard"; }