diff --git a/app/(admin)/admin-layout-client.tsx b/app/(admin)/admin-layout-client.tsx new file mode 100644 index 00000000..267da70c --- /dev/null +++ b/app/(admin)/admin-layout-client.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { Bell, User, Menu } from "lucide-react"; +import { AdminSidebar } from "@/components/admin/AdminSidebar"; +import { AdminGuard } from "@/components/admin/AdminGuard"; +import { usePathname } from "next/navigation"; + +export default function AdminLayoutClient({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + const title = pathname.split("/").filter(Boolean).pop() || "Admin"; + const capitalisedTitle = + title.charAt(0).toUpperCase() + title.slice(1).replace(/-/g, " "); + + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + return ( + +
+ {/* Sidebar */} + setIsSidebarOpen(false)} + /> + + {/* Main Content Area */} +
+ {/* Topbar */} +
+
+ +

+ {capitalisedTitle} +

+
+ +
+ + +
+
+

+ Admin User +

+

+ Super Admin +

+
+
+ +
+
+
+
+ + {/* Page Content */} +
+ {children} +
+
+
+
+ ); +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 9b42c637..0e28eded 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,76 +1,14 @@ -"use client"; +import type { Metadata } from "next"; +import AdminLayoutClient from "./admin-layout-client"; -import { useState } from "react"; -import { Bell, User, Menu } from "lucide-react"; -import { AdminSidebar } from "@/components/admin/AdminSidebar"; -import { AdminGuard } from "@/components/admin/AdminGuard"; -import { usePathname } from "next/navigation"; +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; export default function AdminLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const pathname = usePathname(); - const title = pathname.split("/").filter(Boolean).pop() || "Admin"; - const capitalisedTitle = - title.charAt(0).toUpperCase() + title.slice(1).replace(/-/g, " "); - - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - return ( - -
- {/* Sidebar */} - setIsSidebarOpen(false)} - /> - - {/* Main Content Area */} -
- {/* Topbar */} -
-
- -

- {capitalisedTitle} -

-
- -
- - -
-
-

- Admin User -

-

- Super Admin -

-
-
- -
-
-
-
- - {/* Page Content */} -
- {children} -
-
-
-
- ); + return {children}; } diff --git a/app/(auth)/forgot-password/forgot-password-client.tsx b/app/(auth)/forgot-password/forgot-password-client.tsx new file mode 100644 index 00000000..0cd8ffba --- /dev/null +++ b/app/(auth)/forgot-password/forgot-password-client.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { CheckCircle2 } from "lucide-react"; +import { forgotPassword } from "@/lib/api/auth"; + +export default function ForgotPasswordClient() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [status, setStatus] = useState<"form" | "confirmation">("form"); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailRegex.test(email)) { + setError("Please enter a valid email address"); + return; + } + + setIsLoading(true); + + try { + await forgotPassword({ email }); + + setStatus("confirmation"); + + // Redirect after short delay + setTimeout(() => { + router.push( + `/reset-password?email=${encodeURIComponent(email)}`, + ); + }, 2000); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Something went wrong'); + } finally { + setIsLoading(false); + } + + { + /* + setTimeout(() => { + setIsLoading(false); + setStatus("confirmation"); + }, 1000); + + setTimeout(() => { + setStatus("form"); + router.push("/reset-password"); + }, 3000); + */ + } + }; + + const ConfirmationModal = () => ( +
+
+
+ +
+ +

+ Reset Code Sent +

+ +
+

+ Check your email for a reset code +

+
+
+ ); + + return ( +
+ {/* Desktop Header */} +
+
+ NexaFX +
+ + Sign in + +
+
+
+ + {/* Desktop View */} + +
+ {status === "form" ? ( +
+
+

+ Forgot password +

+

+ Please enter your email address and you we would + send you an OTP +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + placeholder="" + className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" + disabled={isLoading} + /> +
+ + +
+
+ ) : ( + + )} +
+ + {/* Mobile View */} +
+ {status === "form" ? ( +
+
+
+ NexaFX +
+ +

+ Forgot password +

+

+ Please enter your email address and you we would + send you an OTP +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + placeholder="" + className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" + disabled={isLoading} + /> +
+ + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index 28a0cc68..3e703e4b 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -1,221 +1,10 @@ -"use client"; +import type { Metadata } from "next"; +import ForgotPasswordClient from "./forgot-password-client"; -import { useState } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { CheckCircle2 } from "lucide-react"; -import { forgotPassword } from "@/lib/api/auth"; +export const metadata: Metadata = { + title: "Reset Password", +}; export default function ForgotPasswordPage() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(""); - const [status, setStatus] = useState<"form" | "confirmation">("form"); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - - if (!emailRegex.test(email)) { - setError("Please enter a valid email address"); - return; - } - - setIsLoading(true); - - try { - await forgotPassword({ email }); - - setStatus("confirmation"); - - // Redirect after short delay - setTimeout(() => { - router.push( - `/reset-password?email=${encodeURIComponent(email)}`, - ); - }, 2000); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Something went wrong'); - } finally { - setIsLoading(false); - } - - { - /* - setTimeout(() => { - setIsLoading(false); - setStatus("confirmation"); - }, 1000); - - setTimeout(() => { - setStatus("form"); - router.push("/reset-password"); - }, 3000); - */ - } - }; - - const ConfirmationModal = () => ( -
-
-
- -
- -

- Reset Code Sent -

- -
-

- Check your email for a reset code -

-
-
- ); - - return ( -
- {/* Desktop Header */} -
-
- NexaFX -
- - Sign in - -
-
-
- - {/* Desktop View */} - -
- {status === "form" ? ( -
-
-

- Forgot password -

-

- Please enter your email address and you we would - send you an OTP -

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - placeholder="" - className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" - disabled={isLoading} - /> -
- - -
-
- ) : ( - - )} -
- - {/* Mobile View */} -
- {status === "form" ? ( -
-
-
- NexaFX -
- -

- Forgot password -

-

- Please enter your email address and you we would - send you an OTP -

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - placeholder="" - className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" - disabled={isLoading} - /> -
- - -
-
- ) : ( - - )} -
-
- ); + return ; } diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 62550e4c..2c8fd9ef 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -1,422 +1,10 @@ -"use client"; +import type { Metadata } from "next"; +import ResetPasswordClient from "./reset-password-client"; -import { Suspense, useEffect, useRef, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { resetPassword } from "@/lib/api/auth"; - -import Image from "next/image"; +export const metadata: Metadata = { + title: "Reset Password", +}; export default function ResetPasswordPage() { - return ( - - - - ); -} - -function ResetPasswordContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const email = searchParams.get("email") || ""; - - const [otp, setOtp] = useState(["", "", "", "", "", ""]); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - - const [errors, setErrors] = useState<{ [key: string]: string }>({}); - const [showPassword, setShowPassword] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [apiError, setApiError] = useState(""); - const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - - useEffect(() => { - inputRefs.current[0]?.focus(); - }, []); - - const handleChange = (index: number, value: string) => { - if (!/^\d*$/.test(value)) return; - - const updatedOtp = [...otp]; - updatedOtp[index] = value.slice(-1); - setOtp(updatedOtp); - - if (value && index < 5) { - inputRefs.current[index + 1]?.focus(); - } - }; - - const handleKeyDown = ( - index: number, - e: React.KeyboardEvent, - ) => { - if (e.key === "Backspace" && !otp[index] && index > 0) { - inputRefs.current[index - 1]?.focus(); - } - }; - - const handlePaste = (e: React.ClipboardEvent) => { - e.preventDefault(); - const pastedData = e.clipboardData.getData("text").slice(0, 6); - if (!/^\d+$/.test(pastedData)) return; - - const updatedOtp = [...otp]; - pastedData.split("").forEach((char, index) => { - if (index < 6) updatedOtp[index] = char; - }); - setOtp(updatedOtp); - const lastFilledIndex = Math.min(pastedData.length, 5); - inputRefs.current[lastFilledIndex]?.focus(); - }; - - const validate = () => { - const newErrors: { [key: string]: string } = {}; - const otpCode = otp.join(""); - - if (otpCode.length !== 6) { - newErrors.otp = "Please enter all 6 digits"; - } - - if (!newPassword) newErrors.newPassword = "Password is required"; - else if (newPassword.length < 8) - newErrors.newPassword = "Password must be at least 8 characters"; - - if (newPassword !== confirmPassword) { - newErrors.confirmPassword = "Passwords do not match"; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!validate()) return; - - if (!email) { - setApiError("Session expired. Please start again."); - return; - } - - const otpCode = otp.join(""); - - setIsLoading(true); - setApiError(""); - - try { - await resetPassword({ - email, - otp: otpCode, - password: newPassword, - }); - - router.push("/sign-in?reset=success"); - } catch (err) { - setApiError( - err instanceof Error ? err.message : "Invalid or expired OTP", - ); - } finally { - setIsLoading(false); - } - }; - - return ( -
- {/* Desktop Header */} -
-
- NexaFX -
-
- - {/* Desktop View */} -
-
-
-

- Reset Password -

-
- - {apiError && ( -
- {apiError} -
- )} - -
-
-
- {otp.map((digit, index) => ( - { - inputRefs.current[index] = el; - }} - type="text" - inputMode="numeric" - maxLength={1} - value={digit} - onChange={(e) => handleChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} - onPaste={handlePaste} - className="w-12 h-12 text-center text-xl font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" - disabled={isLoading} - /> - ))} -
- {errors.otp && ( -

{errors.otp}

- )} -
-
- - -
- setNewPassword(e.target.value)} - placeholder="" - className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" - disabled={isLoading} - /> - {errors.newPassword && ( -

- {errors.newPassword} -

- )} -
-
- -
- -
- setConfirmPassword(e.target.value)} - placeholder="" - className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" - disabled={isLoading} - /> - {errors.confirmPassword && ( -

- {errors.confirmPassword} -

- )} - -
-
- - -
-
-
- - {/* Mobile View */} -
-
-
-
- NexaFX -
- -

- Reset Password -

-
- - {apiError && ( -
- {apiError} -
- )} - -
-
-
- {otp.map((digit, index) => ( - { - inputRefs.current[index] = el; - }} - type="text" - inputMode="numeric" - maxLength={1} - value={digit} - onChange={(e) => handleChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} - onPaste={handlePaste} - className="w-10 h-11 text-center text-lg font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" - disabled={isLoading} - /> - ))} -
- {errors.otp && ( -

{errors.otp}

- )} -
-
- - setNewPassword(e.target.value)} - placeholder="" - className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" - disabled={isLoading} - /> - {errors.newPassword && ( -

- {errors.newPassword} -

- )} -
- -
- -
- setConfirmPassword(e.target.value)} - placeholder="" - className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" - disabled={isLoading} - /> - {errors.confirmPassword && ( -

- {errors.confirmPassword} -

- )} - -
-
- - -
-
-
-
- ); + return ; } diff --git a/app/(auth)/reset-password/reset-password-client.tsx b/app/(auth)/reset-password/reset-password-client.tsx new file mode 100644 index 00000000..de38c3c6 --- /dev/null +++ b/app/(auth)/reset-password/reset-password-client.tsx @@ -0,0 +1,422 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { resetPassword } from "@/lib/api/auth"; + +import Image from "next/image"; + +export default function ResetPasswordClient() { + return ( + + + + ); +} + +function ResetPasswordContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const email = searchParams.get("email") || ""; + + const [otp, setOtp] = useState(["", "", "", "", "", ""]); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [apiError, setApiError] = useState(""); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + const handleChange = (index: number, value: string) => { + if (!/^\d*$/.test(value)) return; + + const updatedOtp = [...otp]; + updatedOtp[index] = value.slice(-1); + setOtp(updatedOtp); + + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = ( + index: number, + e: React.KeyboardEvent, + ) => { + if (e.key === "Backspace" && !otp[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedData = e.clipboardData.getData("text").slice(0, 6); + if (!/^\d+$/.test(pastedData)) return; + + const updatedOtp = [...otp]; + pastedData.split("").forEach((char, index) => { + if (index < 6) updatedOtp[index] = char; + }); + setOtp(updatedOtp); + const lastFilledIndex = Math.min(pastedData.length, 5); + inputRefs.current[lastFilledIndex]?.focus(); + }; + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + const otpCode = otp.join(""); + + if (otpCode.length !== 6) { + newErrors.otp = "Please enter all 6 digits"; + } + + if (!newPassword) newErrors.newPassword = "Password is required"; + else if (newPassword.length < 8) + newErrors.newPassword = "Password must be at least 8 characters"; + + if (newPassword !== confirmPassword) { + newErrors.confirmPassword = "Passwords do not match"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + if (!email) { + setApiError("Session expired. Please start again."); + return; + } + + const otpCode = otp.join(""); + + setIsLoading(true); + setApiError(""); + + try { + await resetPassword({ + email, + otp: otpCode, + password: newPassword, + }); + + router.push("/sign-in?reset=success"); + } catch (err) { + setApiError( + err instanceof Error ? err.message : "Invalid or expired OTP", + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Desktop Header */} +
+
+ NexaFX +
+
+ + {/* Desktop View */} +
+
+
+

+ Reset Password +

+
+ + {apiError && ( +
+ {apiError} +
+ )} + +
+
+
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + className="w-12 h-12 text-center text-xl font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" + disabled={isLoading} + /> + ))} +
+ {errors.otp && ( +

{errors.otp}

+ )} +
+
+ + +
+ setNewPassword(e.target.value)} + placeholder="" + className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" + disabled={isLoading} + /> + {errors.newPassword && ( +

+ {errors.newPassword} +

+ )} +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="" + className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" + disabled={isLoading} + /> + {errors.confirmPassword && ( +

+ {errors.confirmPassword} +

+ )} + +
+
+ + +
+
+
+ + {/* Mobile View */} +
+
+
+
+ NexaFX +
+ +

+ Reset Password +

+
+ + {apiError && ( +
+ {apiError} +
+ )} + +
+
+
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + className="w-10 h-11 text-center text-lg font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" + disabled={isLoading} + /> + ))} +
+ {errors.otp && ( +

{errors.otp}

+ )} +
+
+ + setNewPassword(e.target.value)} + placeholder="" + className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" + disabled={isLoading} + /> + {errors.newPassword && ( +

+ {errors.newPassword} +

+ )} +
+ +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="" + className="w-full px-4 py-2.5 bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm" + disabled={isLoading} + /> + {errors.confirmPassword && ( +

+ {errors.confirmPassword} +

+ )} + +
+
+ + +
+
+
+
+ ); +} diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index 891bc26e..d08a5e9e 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,316 +1,10 @@ -'use client'; +import type { Metadata } from "next"; +import SignInClient from "./sign-in-client"; -import Image from 'next/image'; -import Link from 'next/link'; -import { login } from '@/lib/api/auth'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +export const metadata: Metadata = { + title: "Sign In", +}; export default function SignInPage() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - - if (!email || !password) { - setError('Please fill in all fields'); - return; - } - - setIsLoading(true); - - try { - await login({ email, password }); - setIsLoading(false); - sessionStorage.setItem('login-email', email); - router.push('/verify-otp'); - } catch (err: unknown) { - setIsLoading(false); - setError(err instanceof Error ? err.message : 'Login failed'); - } - }; - - return ( -
- {/* Desktop Header */} -
-
- NexaFX -
- Don't have an account?{' '} - - Sign up - -
-
-
- - {/* Desktop View */} -
-
-
-

Sign in

-

- Hey, Welcome back -

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - placeholder="Enter email address or phone" - title="Email address or mobile number" - className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" - disabled={isLoading} - /> -
- -
- -
- setPassword(e.target.value)} - placeholder="Enter password" - title="Password" - className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" - disabled={isLoading} - /> - -
-
- - Forgotten password? - -
-
- - -
- -
- Don't have an account?{' '} - - Sign up - -
-
-
- - {/* Mobile View */} -
-
-
-
- NexaFX -
- -

Sign in

-

- Hey, welcome back -

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - placeholder="Enter email address" - title="Email address" - className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" - disabled={isLoading} - /> -
- -
- -
- setPassword(e.target.value)} - placeholder="Enter password" - title="Password" - className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" - disabled={isLoading} - /> - -
-
- - Forgotten password? - -
-
- - -
- -
- Don't have an account?{' '} - - Sign up - -
-
-
-
- ); + return ; } diff --git a/app/(auth)/sign-in/sign-in-client.tsx b/app/(auth)/sign-in/sign-in-client.tsx new file mode 100644 index 00000000..b708bd31 --- /dev/null +++ b/app/(auth)/sign-in/sign-in-client.tsx @@ -0,0 +1,316 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { login } from '@/lib/api/auth'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +export default function SignInClient() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!email || !password) { + setError('Please fill in all fields'); + return; + } + + setIsLoading(true); + + try { + await login({ email, password }); + setIsLoading(false); + sessionStorage.setItem('login-email', email); + router.push('/verify-otp'); + } catch (err: unknown) { + setIsLoading(false); + setError(err instanceof Error ? err.message : 'Login failed'); + } + }; + + return ( +
+ {/* Desktop Header */} +
+
+ NexaFX +
+ Don't have an account?{' '} + + Sign up + +
+
+
+ + {/* Desktop View */} +
+
+
+

Sign in

+

+ Hey, Welcome back +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + placeholder="Enter email address or phone" + title="Email address or mobile number" + className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" + disabled={isLoading} + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="Enter password" + title="Password" + className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" + disabled={isLoading} + /> + +
+
+ + Forgotten password? + +
+
+ + +
+ +
+ Don't have an account?{' '} + + Sign up + +
+
+
+ + {/* Mobile View */} +
+
+
+
+ NexaFX +
+ +

Sign in

+

+ Hey, welcome back +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + placeholder="Enter email address" + title="Email address" + className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" + disabled={isLoading} + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="Enter password" + title="Password" + className="w-full px-4 py-2.5 bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all text-sm text-foreground" + disabled={isLoading} + /> + +
+
+ + Forgotten password? + +
+
+ + +
+ +
+ Don't have an account?{' '} + + Sign up + +
+
+
+
+ ); +} diff --git a/app/(auth)/verify-otp/page.tsx b/app/(auth)/verify-otp/page.tsx index d9c8e786..1b45d401 100644 --- a/app/(auth)/verify-otp/page.tsx +++ b/app/(auth)/verify-otp/page.tsx @@ -1,279 +1,10 @@ -'use client'; +import type { Metadata } from "next"; +import VerifyOtpClient from "./verify-otp-client"; -import { verifyLoginOtp, resendLoginOtp } from '@/lib/api/auth'; -import { useEffect, useRef, useState } from 'react'; - -import Image from 'next/image'; -import Link from 'next/link'; -import { useAuthStore } from '@/hooks/use-auth-store'; -import { useRouter } from 'next/navigation'; +export const metadata: Metadata = { + title: "Verify OTP", +}; export default function VerifyOtpPage() { - const router = useRouter(); - const setAuth = useAuthStore((s) => s.setAuth); - const [otp, setOtp] = useState(['', '', '', '', '', '']); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - - useEffect(() => { - inputRefs.current[0]?.focus(); - }, []); - - const handleChange = (index: number, value: string) => { - if (!/^\d*$/.test(value)) return; - - const newOtp = [...otp]; - newOtp[index] = value.slice(-1); - setOtp(newOtp); - setError(''); - - if (value && index < 5) { - inputRefs.current[index + 1]?.focus(); - } - }; - - const handleKeyDown = ( - index: number, - e: React.KeyboardEvent, - ) => { - if (e.key === 'Backspace' && !otp[index] && index > 0) { - inputRefs.current[index - 1]?.focus(); - } - }; - - const handlePaste = (e: React.ClipboardEvent) => { - e.preventDefault(); - const pastedData = e.clipboardData.getData('text').slice(0, 6); - if (!/^\d+$/.test(pastedData)) return; - - const newOtp = [...otp]; - pastedData.split('').forEach((char, index) => { - if (index < 6) newOtp[index] = char; - }); - setOtp(newOtp); - - const lastFilledIndex = Math.min(pastedData.length, 5); - inputRefs.current[lastFilledIndex]?.focus(); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const otpCode = otp.join(''); - if (otpCode.length !== 6) { - setError('Please enter all 6 digits'); - return; - } - const storedEmail = sessionStorage.getItem('login-email'); - if (!storedEmail) { - setError('No email found. Please sign in again.'); - return; - } - setIsLoading(true); - setError(''); - try { - const res = await verifyLoginOtp({ email: storedEmail, otp: otpCode }) as { user: { id: string; firstName: string; lastName: string; email: string; role: 'USER' | 'ADMIN' }; accessToken: string; refreshToken: string }; - const fullName = [res.user.firstName, res.user.lastName].filter(Boolean).join(' '); - setAuth({ ...res.user, name: fullName }, res.accessToken, res.refreshToken); - setIsLoading(false); - router.push('/dashboard'); - } catch (err: unknown) { - setIsLoading(false); - setError(err instanceof Error ? err.message : 'Invalid or expired OTP'); - } - }; - - const handleResend = async () => { - setOtp(['', '', '', '', '', '']); - setError(''); - inputRefs.current[0]?.focus(); - const storedEmail = sessionStorage.getItem('login-email'); - if (!storedEmail) { - setError('Missing email. Please sign in again.'); - return; - } - try { - await resendLoginOtp({ email: storedEmail }); - } catch { - setError('Failed to resend code'); - } - }; - - const isComplete = otp.every((digit) => digit !== ''); - - return ( -
- {/* Desktop Header */} -
-
- NexaFX -
-
- - {/* Desktop View */} -
-
-
-

- VERIFY CODE -

-

- Confirmation code sent. Check inbox or spam folder for the code -

-
- - {error && ( -
- {error} -
- )} - -
-
- {otp.map((digit, index) => ( - { - inputRefs.current[index] = el; - }} - type="text" - inputMode="numeric" - maxLength={1} - value={digit} - onChange={(e) => handleChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} - onPaste={handlePaste} - className="w-12 h-12 text-center text-xl font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" - disabled={isLoading} - /> - ))} -
- -
- -
- - -
-
-
- - {/* Mobile View */} -
- {/* Mobile Header */} -
- - - Request access - -
- -
-
-
-

- VERIFY CODE -

-

- Confirmation code sent. Check inbox or spam folder for the code -

-
- - {error && ( -
- {error} -
- )} - -
-
- {otp.map((digit, index) => ( - { - inputRefs.current[index] = el; - }} - type="text" - inputMode="numeric" - maxLength={1} - value={digit} - onChange={(e) => handleChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} - onPaste={handlePaste} - className="w-11 h-11 text-center text-lg font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" - disabled={isLoading} - /> - ))} -
- -
- -
- - -
-
-
-
-
- ); + return ; } diff --git a/app/(auth)/verify-otp/verify-otp-client.tsx b/app/(auth)/verify-otp/verify-otp-client.tsx new file mode 100644 index 00000000..fa007563 --- /dev/null +++ b/app/(auth)/verify-otp/verify-otp-client.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { verifyLoginOtp, resendLoginOtp } from '@/lib/api/auth'; +import { useEffect, useRef, useState } from 'react'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useAuthStore } from '@/hooks/use-auth-store'; +import { useRouter } from 'next/navigation'; + +export default function VerifyOtpClient() { + const router = useRouter(); + const setAuth = useAuthStore((s) => s.setAuth); + const [otp, setOtp] = useState(['', '', '', '', '', '']); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + const handleChange = (index: number, value: string) => { + if (!/^\d*$/.test(value)) return; + + const newOtp = [...otp]; + newOtp[index] = value.slice(-1); + setOtp(newOtp); + setError(''); + + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = ( + index: number, + e: React.KeyboardEvent, + ) => { + if (e.key === 'Backspace' && !otp[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedData = e.clipboardData.getData('text').slice(0, 6); + if (!/^\d+$/.test(pastedData)) return; + + const newOtp = [...otp]; + pastedData.split('').forEach((char, index) => { + if (index < 6) newOtp[index] = char; + }); + setOtp(newOtp); + + const lastFilledIndex = Math.min(pastedData.length, 5); + inputRefs.current[lastFilledIndex]?.focus(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const otpCode = otp.join(''); + if (otpCode.length !== 6) { + setError('Please enter all 6 digits'); + return; + } + const storedEmail = sessionStorage.getItem('login-email'); + if (!storedEmail) { + setError('No email found. Please sign in again.'); + return; + } + setIsLoading(true); + setError(''); + try { + const res = await verifyLoginOtp({ email: storedEmail, otp: otpCode }) as { user: { id: string; firstName: string; lastName: string; email: string; role: 'USER' | 'ADMIN' }; accessToken: string; refreshToken: string }; + const fullName = [res.user.firstName, res.user.lastName].filter(Boolean).join(' '); + setAuth({ ...res.user, name: fullName }, res.accessToken, res.refreshToken); + setIsLoading(false); + router.push('/dashboard'); + } catch (err: unknown) { + setIsLoading(false); + setError(err instanceof Error ? err.message : 'Invalid or expired OTP'); + } + }; + + const handleResend = async () => { + setOtp(['', '', '', '', '', '']); + setError(''); + inputRefs.current[0]?.focus(); + const storedEmail = sessionStorage.getItem('login-email'); + if (!storedEmail) { + setError('Missing email. Please sign in again.'); + return; + } + try { + await resendLoginOtp({ email: storedEmail }); + } catch { + setError('Failed to resend code'); + } + }; + + const isComplete = otp.every((digit) => digit !== ''); + + return ( +
+ {/* Desktop Header */} +
+
+ NexaFX +
+
+ + {/* Desktop View */} +
+
+
+

+ VERIFY CODE +

+

+ Confirmation code sent. Check inbox or spam folder for the code +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + className="w-12 h-12 text-center text-xl font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" + disabled={isLoading} + /> + ))} +
+ +
+ +
+ + +
+
+
+ + {/* Mobile View */} +
+ {/* Mobile Header */} +
+ + + Request access + +
+ +
+
+
+

+ VERIFY CODE +

+

+ Confirmation code sent. Check inbox or spam folder for the code +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + className="w-11 h-11 text-center text-lg font-semibold bg-[#F5F5F5] border-0 rounded-md focus:outline-none focus:ring-2 focus:ring-[#F39A00] transition-all" + disabled={isLoading} + /> + ))} +
+ +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/app/(dashboard)/dashboard-layout-client.tsx b/app/(dashboard)/dashboard-layout-client.tsx new file mode 100644 index 00000000..d199c503 --- /dev/null +++ b/app/(dashboard)/dashboard-layout-client.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Sidebar } from '../../components/dashboard/sidebar'; +import { Topbar } from '../../components/dashboard/topbar'; +import { cn } from '../../lib/utils'; +import { useAuthStore } from '../../hooks/use-auth-store'; +import { useRouter } from 'next/navigation'; +import { useSidebarStore } from '../../hooks/use-sidebar-store'; + +export default function DashboardLayoutClient({ + children, +}: { + children: React.ReactNode; +}) { + const { isOpen, close } = useSidebarStore(); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const { isAuthenticated, accessToken } = useAuthStore(); + const router = useRouter(); + + useEffect(() => { + if (!isAuthenticated || !accessToken) { + router.replace('/sign-in'); + } + }, [isAuthenticated, accessToken, router]); + + if (!isAuthenticated || !accessToken) { + return null; + } + + return ( +
+ {/* Sidebar - Desktop */} + + + {/* Sidebar - Mobile Drawer Overlay */} + {isOpen && ( +
+ )} + + {/* Sidebar - Mobile Drawer */} + + +
+
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index eae4b06d..2c8b85d4 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,75 +1,14 @@ -'use client'; +import type { Metadata } from "next"; +import DashboardLayoutClient from "./dashboard-layout-client"; -import { useEffect, useState } from 'react'; - -import { Sidebar } from '../../components/dashboard/sidebar'; -import { Topbar } from '../../components/dashboard/topbar'; -import { cn } from '../../lib/utils'; -import { useAuthStore } from '../../hooks/use-auth-store'; -import { useRouter } from 'next/navigation'; -import { useSidebarStore } from '../../hooks/use-sidebar-store'; +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { - const { isOpen, close } = useSidebarStore(); - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const { isAuthenticated, accessToken } = useAuthStore(); - const router = useRouter(); - - useEffect(() => { - if (!isAuthenticated || !accessToken) { - router.replace('/sign-in'); - } - }, [isAuthenticated, accessToken, router]); - - if (!isAuthenticated || !accessToken) { - return null; - } - - return ( -
- {/* Sidebar - Desktop */} - - - {/* Sidebar - Mobile Drawer Overlay */} - {isOpen && ( -
- )} - - {/* Sidebar - Mobile Drawer */} - - -
-
- -
-
- {children} -
-
-
- ); + return {children}; } diff --git a/app/landing-page-client.tsx b/app/landing-page-client.tsx new file mode 100644 index 00000000..98335a23 --- /dev/null +++ b/app/landing-page-client.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuthStore } from "@/hooks/use-auth-store"; + +import Navbar from "@/components/landing/Navbar"; +import Hero from "@/components/landing/Hero"; +import Features from "@/components/landing/Features"; +import CTA from "@/components/landing/CTA"; +import Footer from "@/components/landing/Footer"; + +export default function LandingPageClient() { + const { user } = useAuthStore(); + const router = useRouter(); + + useEffect(() => { + if (user) router.replace("/dashboard"); + }, [user, router]); + + if (user) return null; + + return ( +
+ +
+ + + +
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 9190fbd2..7d991c22 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,15 +12,49 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); -export const manrope = Manrope({ +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); +const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + metadataBase: new URL("https://nexafx.io"), + title: { + default: "NexaFx \u2014 Multi-Currency Finance on Stellar", + template: "%s | NexaFx", + }, + description: + "Convert, deposit, and transfer currencies instantly on the Stellar blockchain.", + keywords: [ + "currency exchange", + "Stellar blockchain", + "cross-border payments", + "NGN to USD", + "crypto finance", + ], + authors: [{ name: "Nexacore" }], + creator: "Nexacore", + openGraph: { + type: "website", + locale: "en_US", + url: "https://nexafx.io", + siteName: "NexaFx", + title: "NexaFx \u2014 Multi-Currency Finance on Stellar", + description: + "Convert, deposit, and transfer currencies instantly on the Stellar blockchain.", + images: [ + { url: "/og-image.png", width: 1200, height: 630, alt: "NexaFx" }, + ], + }, + twitter: { + card: "summary_large_image", + title: "NexaFx \u2014 Multi-Currency Finance on Stellar", + description: + "Convert, deposit, and transfer currencies instantly on the Stellar blockchain.", + images: ["/og-image.png"], + }, + robots: { index: true, follow: true }, }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 3120bc62..0bfb4f8d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,34 +1,18 @@ -"use client"; +import type { Metadata } from "next"; +import LandingPageClient from "./landing-page-client"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { useAuthStore } from "@/hooks/use-auth-store"; - -import Navbar from "@/components/landing/Navbar"; -import Hero from "@/components/landing/Hero"; -import Features from "@/components/landing/Features"; -import CTA from "@/components/landing/CTA"; -import Footer from "@/components/landing/Footer"; +export const metadata: Metadata = { + title: "NexaFx \u2014 Multi-Currency Finance on Stellar", + description: + "Convert, deposit, and transfer currencies instantly on the Stellar blockchain.", + openGraph: { + title: "NexaFx \u2014 Multi-Currency Finance on Stellar", + description: + "Convert, deposit, and transfer currencies instantly on the Stellar blockchain.", + url: "https://nexafx.io", + }, +}; export default function HomePage() { - const { user } = useAuthStore(); - const router = useRouter(); - - useEffect(() => { - if (user) router.replace("/dashboard"); - }, [user, router]); - - if (user) return null; - - return ( -
- -
- - - -
-
-
- ); + return ; } diff --git a/app/signup/page.tsx b/app/signup/page.tsx index 7cc43a66..31588d59 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -1,209 +1,10 @@ -"use client"; +import type { Metadata } from "next"; +import SignupClient from "./signup-client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Eye, EyeOff } from "lucide-react"; -import { signUp } from "@/lib/api/auth"; +export const metadata: Metadata = { + title: "Create Account", +}; -export default function CreateAccountPage() { - const router = useRouter(); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [apiError, setApiError] = useState(""); - - const [formData, setFormData] = useState({ - email: "", - phone: "", - password: "", - confirmPassword: "", - acceptTerms: true, - }); - - const [errors, setErrors] = useState<{ [key: string]: string }>({}); - - const validate = () => { - const newErrors: { [key: string]: string } = {}; - if (!formData.email) newErrors.email = "Email is required"; - else if (!/\S+@\S+\.\S+/.test(formData.email)) - newErrors.email = "Invalid email address"; - - if (!formData.phone) newErrors.phone = "Phone number is required"; - - if (!formData.password) newErrors.password = "Password is required"; - else if (formData.password.length < 8) - newErrors.password = "Password must be at least 8 characters"; - - if (formData.password !== formData.confirmPassword) { - newErrors.confirmPassword = "Passwords do not match"; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!validate()) return; - - setIsLoading(true); - setApiError(""); - try { - await signUp({ - email: formData.email, - phone: formData.phone, - password: formData.password, - }); - sessionStorage.setItem("signup_email", formData.email); - router.push("/signup/verify"); - } catch (err) { - setApiError(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-

- Create an account -

-

Let's get started...

-
- -
-
- - setFormData({ ...formData, email: e.target.value }) - } - /> - {errors.email && ( -

{errors.email}

- )} -
- -
- - setFormData({ ...formData, phone: e.target.value }) - } - /> - {errors.phone && ( -

{errors.phone}

- )} -
- -
- - setFormData({ ...formData, password: e.target.value }) - } - /> - - {errors.password && ( -

- {errors.password} -

- )} -
- -
- - setFormData({ ...formData, confirmPassword: e.target.value }) - } - /> - - {errors.confirmPassword && ( -

- {errors.confirmPassword} -

- )} -
- -
-
- - setFormData({ ...formData, acceptTerms: e.target.checked }) - } - className="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-zinc-300 dark:border-border transition-all checked:bg-orange-500 checked:border-orange-500" - /> - - - -
- -
- - {apiError && ( -

{apiError}

- )} - -
-
- ); +export default function SignupPage() { + return ; } diff --git a/app/signup/signup-client.tsx b/app/signup/signup-client.tsx new file mode 100644 index 00000000..279d900b --- /dev/null +++ b/app/signup/signup-client.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Eye, EyeOff } from "lucide-react"; +import { signUp } from "@/lib/api/auth"; + +export default function SignupClient() { + const router = useRouter(); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [apiError, setApiError] = useState(""); + + const [formData, setFormData] = useState({ + email: "", + phone: "", + password: "", + confirmPassword: "", + acceptTerms: true, + }); + + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + if (!formData.email) newErrors.email = "Email is required"; + else if (!/\S+@\S+\.\S+/.test(formData.email)) + newErrors.email = "Invalid email address"; + + if (!formData.phone) newErrors.phone = "Phone number is required"; + + if (!formData.password) newErrors.password = "Password is required"; + else if (formData.password.length < 8) + newErrors.password = "Password must be at least 8 characters"; + + if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = "Passwords do not match"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + setIsLoading(true); + setApiError(""); + try { + await signUp({ + email: formData.email, + phone: formData.phone, + password: formData.password, + }); + sessionStorage.setItem("signup_email", formData.email); + router.push("/signup/verify"); + } catch (err) { + setApiError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ Create an account +

+

Let's get started...

+
+ +
+
+ + setFormData({ ...formData, email: e.target.value }) + } + /> + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + setFormData({ ...formData, phone: e.target.value }) + } + /> + {errors.phone && ( +

{errors.phone}

+ )} +
+ +
+ + setFormData({ ...formData, password: e.target.value }) + } + /> + + {errors.password && ( +

+ {errors.password} +

+ )} +
+ +
+ + setFormData({ ...formData, confirmPassword: e.target.value }) + } + /> + + {errors.confirmPassword && ( +

+ {errors.confirmPassword} +

+ )} +
+ +
+
+ + setFormData({ ...formData, acceptTerms: e.target.checked }) + } + className="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-zinc-300 dark:border-border transition-all checked:bg-orange-500 checked:border-orange-500" + /> + + + +
+ +
+ + {apiError && ( +

{apiError}

+ )} + +
+
+ ); +} diff --git a/app/signup/success/page.tsx b/app/signup/success/page.tsx index 4f973e21..07656dab 100644 --- a/app/signup/success/page.tsx +++ b/app/signup/success/page.tsx @@ -1,62 +1,10 @@ -"use client"; +import type { Metadata } from "next"; +import SuccessClient from "./success-client"; -import { useEffect, useState } from "react"; -import { CheckCircle2 } from "lucide-react"; -import { useRouter } from "next/navigation"; +export const metadata: Metadata = { + title: "Email Confirmed", +}; export default function SuccessPage() { - const router = useRouter(); - const [dots, setDots] = useState(""); - - useEffect(() => { - const interval = setInterval(() => { - setDots((prev) => (prev.length >= 3 ? "" : prev + ".")); - }, 500); - - // Redirect after 5 seconds - const timeout = setTimeout(() => { - router.push("/"); - }, 5000); - - return () => { - clearInterval(interval); - clearTimeout(timeout); - }; - }, [router]); - - return ( -
-
-
- -
- -

- EMAIL CONFIRMED -

- -
-

- Your account has been successfully verified. You now have full access - to NexaFX. -

- -
-

- Redirecting you to dashboard{dots} -

- -
-
-
- ); + return ; } diff --git a/app/signup/success/success-client.tsx b/app/signup/success/success-client.tsx new file mode 100644 index 00000000..7c69a180 --- /dev/null +++ b/app/signup/success/success-client.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CheckCircle2 } from "lucide-react"; +import { useRouter } from "next/navigation"; + +export default function SuccessClient() { + const router = useRouter(); + const [dots, setDots] = useState(""); + + useEffect(() => { + const interval = setInterval(() => { + setDots((prev) => (prev.length >= 3 ? "" : prev + ".")); + }, 500); + + // Redirect after 5 seconds + const timeout = setTimeout(() => { + router.push("/"); + }, 5000); + + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; + }, [router]); + + return ( +
+
+
+ +
+ +

+ EMAIL CONFIRMED +

+ +
+

+ Your account has been successfully verified. You now have full access + to NexaFX. +

+ +
+

+ Redirecting you to dashboard{dots} +

+ +
+
+
+ ); +} diff --git a/app/signup/verify/page.tsx b/app/signup/verify/page.tsx index 26c1cd0e..d18989fd 100644 --- a/app/signup/verify/page.tsx +++ b/app/signup/verify/page.tsx @@ -1,144 +1,10 @@ -"use client"; +import type { Metadata } from "next"; +import VerifyClient from "./verify-client"; -import { useState, useRef, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { verifySignupOtp, resendSignupOtp } from "@/lib/api/auth"; +export const metadata: Metadata = { + title: "Verify Email", +}; -export default function VerifyOtpPage() { - const router = useRouter(); - const [otp, setOtp] = useState(new Array(6).fill("")); - const [isLoading, setIsLoading] = useState(false); - const [apiError, setApiError] = useState(""); - const [resendMessage, setResendMessage] = useState(""); - const [isResending, setIsResending] = useState(false); - const [email, setEmail] = useState(""); - const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - - useEffect(() => { - inputRefs.current[0]?.focus(); - const stored = sessionStorage.getItem("signup_email"); - if (stored) setEmail(stored); - }, []); - - const handleChange = (element: HTMLInputElement, index: number) => { - if (isNaN(Number(element.value))) return; - - const newOtp = [...otp]; - newOtp[index] = element.value.substring(element.value.length - 1); - setOtp(newOtp); - - // Auto-advance - if (element.value && index < 5) { - inputRefs.current[index + 1]?.focus(); - } - }; - - const handleKeyDown = ( - e: React.KeyboardEvent, - index: number, - ) => { - if (e.key === "Backspace" && !otp[index] && index > 0) { - inputRefs.current[index - 1]?.focus(); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (otp.some((digit) => digit === "")) return; - - setIsLoading(true); - setApiError(""); - try { - await verifySignupOtp({ email, otp: otp.join("") }); - router.push("/signup/success"); - } catch (err) { - setApiError( - err instanceof Error ? err.message : "Invalid or expired OTP", - ); - } finally { - setIsLoading(false); - } - }; - - const handleResend = async () => { - setIsResending(true); - setApiError(""); - setResendMessage(""); - try { - await resendSignupOtp({ email }); - setResendMessage("Code resent successfully"); - } catch (err) { - setApiError(err instanceof Error ? err.message : "Failed to resend code"); - } finally { - setIsResending(false); - } - }; - - const isOtpComplete = otp.every((digit) => digit !== ""); - - return ( -
-
-

- VERIFY CODE -

-

- Confirmation code sent. Check inbox or spam folder for the code -

-
- -
-
- {otp.map((digit, index) => ( - { - inputRefs.current[index] = el; - }} - type="text" - inputMode="numeric" - maxLength={1} - value={digit} - onChange={(e) => handleChange(e.target, index)} - onKeyDown={(e) => handleKeyDown(e, index)} - className="w-full aspect-square text-center text-2xl font-bold border-2 border-zinc-100 rounded-xl focus:border-orange-500 focus:ring-4 focus:ring-orange-50 outline-none transition-all text-zinc-900 bg-zinc-50/50" - /> - ))} -
- - {apiError && ( -

{apiError}

- )} - {resendMessage && ( -

{resendMessage}

- )} - - - -
- -
-
-
- ); +export default function VerifyPage() { + return ; } diff --git a/app/signup/verify/verify-client.tsx b/app/signup/verify/verify-client.tsx new file mode 100644 index 00000000..a1e3cdd4 --- /dev/null +++ b/app/signup/verify/verify-client.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { verifySignupOtp, resendSignupOtp } from "@/lib/api/auth"; + +export default function VerifyClient() { + const router = useRouter(); + const [otp, setOtp] = useState(new Array(6).fill("")); + const [isLoading, setIsLoading] = useState(false); + const [apiError, setApiError] = useState(""); + const [resendMessage, setResendMessage] = useState(""); + const [isResending, setIsResending] = useState(false); + const [email, setEmail] = useState(""); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + inputRefs.current[0]?.focus(); + const stored = sessionStorage.getItem("signup_email"); + if (stored) setEmail(stored); + }, []); + + const handleChange = (element: HTMLInputElement, index: number) => { + if (isNaN(Number(element.value))) return; + + const newOtp = [...otp]; + newOtp[index] = element.value.substring(element.value.length - 1); + setOtp(newOtp); + + // Auto-advance + if (element.value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + index: number, + ) => { + if (e.key === "Backspace" && !otp[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (otp.some((digit) => digit === "")) return; + + setIsLoading(true); + setApiError(""); + try { + await verifySignupOtp({ email, otp: otp.join("") }); + router.push("/signup/success"); + } catch (err) { + setApiError( + err instanceof Error ? err.message : "Invalid or expired OTP", + ); + } finally { + setIsLoading(false); + } + }; + + const handleResend = async () => { + setIsResending(true); + setApiError(""); + setResendMessage(""); + try { + await resendSignupOtp({ email }); + setResendMessage("Code resent successfully"); + } catch (err) { + setApiError(err instanceof Error ? err.message : "Failed to resend code"); + } finally { + setIsResending(false); + } + }; + + const isOtpComplete = otp.every((digit) => digit !== ""); + + return ( +
+
+

+ VERIFY CODE +

+

+ Confirmation code sent. Check inbox or spam folder for the code +

+
+ +
+
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(e.target, index)} + onKeyDown={(e) => handleKeyDown(e, index)} + className="w-full aspect-square text-center text-2xl font-bold border-2 border-zinc-100 rounded-xl focus:border-orange-500 focus:ring-4 focus:ring-orange-50 outline-none transition-all text-zinc-900 bg-zinc-50/50" + /> + ))} +
+ + {apiError && ( +

{apiError}

+ )} + {resendMessage && ( +

{resendMessage}

+ )} + + + +
+ +
+
+
+ ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 00000000..e9d0838a --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from "next"; + +export default function sitemap(): MetadataRoute.Sitemap { + const baseUrl = "https://nexafx.io"; + + return [ + { + url: baseUrl, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 1, + }, + { + url: `${baseUrl}/sign-in`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.8, + }, + { + url: `${baseUrl}/signup`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.8, + }, + { + url: `${baseUrl}/forgot-password`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.3, + }, + ]; +} diff --git a/package-lock.json b/package-lock.json index 49392d9e..c94c5866 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,8 @@ "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.7.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.10" }, diff --git a/package.json b/package.json index dc7461f0..319ae9cc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.7.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.10" }, diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 00000000..7e64eee0 Binary files /dev/null and b/public/og-image.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..fdf3d912 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Allow: / +Disallow: /dashboard +Disallow: /admin + +Sitemap: https://nexafx.io/sitemap.xml