feat: add Ina Trading portal flows and API integration
This commit is contained in:
128
src/app/(auth)/account-not-found/page.tsx
Normal file
128
src/app/(auth)/account-not-found/page.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
function AccountNotFoundContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email") || "";
|
||||
const { t } = useLanguage();
|
||||
const a = t.auth.accountNotFound;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col md:flex-row min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="hidden md:flex md:w-1/2 lg:w-3/5 bg-secondary relative items-end p-12 lg:p-20 overflow-hidden">
|
||||
<div className="absolute inset-0 z-0 bg-gradient-to-t from-secondary via-secondary/80 to-secondary/60" />
|
||||
<div className="relative z-10 max-w-xl">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<span className="h-1 w-12 bg-primary" />
|
||||
<span className="text-on-secondary font-headline font-bold uppercase tracking-widest text-xs">
|
||||
{a.editorialIntelligence}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-on-secondary font-headline text-5xl lg:text-7xl font-black leading-none tracking-tighter mb-6 whitespace-pre-line">
|
||||
{a.tradePrecision}
|
||||
</h1>
|
||||
<p className="text-on-secondary/80 text-xl font-medium max-w-md leading-relaxed">
|
||||
{a.tradeSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="flex-1 bg-surface-container-lowest flex flex-col p-8 md:p-12 lg:p-20 relative md:overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-16">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageToggle />
|
||||
<Link
|
||||
href="/login"
|
||||
className="hidden sm:flex text-secondary font-semibold hover:text-primary transition-colors items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">help_outline</span>
|
||||
{a.helpLink}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center max-w-md w-full mx-auto">
|
||||
<div className="mb-8 h-16 w-16 bg-error-container rounded-xl flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-error text-3xl">person_off</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<h2 className="font-headline text-4xl font-extrabold tracking-tight text-on-surface mb-3">
|
||||
{a.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 p-3 bg-surface-container-low rounded-lg border-l-4 border-tertiary">
|
||||
<p className="text-on-surface-variant font-medium truncate">{email}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-tertiary font-bold hover:underline ml-auto flex items-center gap-1 shrink-0"
|
||||
>
|
||||
{a.change}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/register?email=${encodeURIComponent(email)}`}
|
||||
className="block w-full text-center signature-cta-gradient text-on-primary font-headline font-bold py-5 rounded-xl shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all text-xl uppercase tracking-wider"
|
||||
>
|
||||
{a.createAccount}
|
||||
</Link>
|
||||
|
||||
<div className="relative py-4">
|
||||
<div aria-hidden="true" className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-outline-variant/30" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-surface-container-lowest px-4 text-sm font-medium text-outline">
|
||||
{t.common.or}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-3 w-full border-2 border-outline-variant/30 text-on-surface font-bold py-4 rounded-xl hover:bg-surface-container transition-colors group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-secondary group-hover:text-primary transition-colors">
|
||||
login
|
||||
</span>
|
||||
{a.loginOther}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="mt-auto pt-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-4">
|
||||
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
{t.common.privacy}
|
||||
</a>
|
||||
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
{t.common.terms}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-outline/60 max-w-sm">{a.disclaimer}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountNotFoundPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AccountNotFoundContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
152
src/app/(auth)/forgot-password/page.tsx
Normal file
152
src/app/(auth)/forgot-password/page.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useLanguage();
|
||||
const f = t.auth.forgotPassword;
|
||||
|
||||
const [contact, setContact] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!contact.trim()) return;
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="min-h-screen flex flex-col md:flex-row bg-surface overflow-x-hidden">
|
||||
<section className="hidden md:flex md:w-1/2 lg:w-3/5 relative overflow-hidden flex-col justify-end p-16 bg-primary">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.14),transparent_25%),linear-gradient(160deg,#b7131a_0%,#8d1116_55%,#57070a_100%)]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-1 w-12 bg-white rounded-full" />
|
||||
<span className="font-headline font-extrabold text-white tracking-widest text-sm">
|
||||
{f.securityFirst}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-6xl lg:text-7xl font-headline font-black text-white tracking-tighter leading-none whitespace-pre-line">
|
||||
{f.heroTitle}
|
||||
</h1>
|
||||
<p className="text-xl text-white/80 max-w-md font-light leading-relaxed">
|
||||
{f.heroSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-12 pt-8 border-t border-white/20 flex gap-12">
|
||||
<div>
|
||||
<span className="block text-white font-headline font-bold text-3xl">99.9%</span>
|
||||
<span className="text-white/60 text-xs font-label uppercase tracking-widest">{f.uptime}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-white font-headline font-bold text-3xl">24/7</span>
|
||||
<span className="text-white/60 text-xs font-label uppercase tracking-widest">{f.fraudMonitoring}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex-1 bg-surface flex flex-col justify-center items-center p-8 md:p-16 lg:p-24 relative">
|
||||
<div className="absolute top-12 left-8 md:static md:mb-20 md:self-start flex items-center gap-4">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
</div>
|
||||
|
||||
<div className="absolute top-12 right-8">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md space-y-10">
|
||||
<header className="space-y-4">
|
||||
<h2 className="text-4xl font-headline font-extrabold text-on-surface tracking-tight">
|
||||
{f.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant leading-relaxed text-lg">{f.subtitle}</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
{submitted && (
|
||||
<div className="rounded-xl border border-tertiary/10 bg-tertiary/5 p-4 text-sm text-on-tertiary-fixed-variant">
|
||||
{f.apiNotReady} <strong>{contact}</strong>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative group">
|
||||
<label
|
||||
className="block text-xs font-headline font-bold uppercase tracking-wider text-outline mb-2 group-focus-within:text-primary transition-colors"
|
||||
htmlFor="recovery-contact"
|
||||
>
|
||||
{f.emailOrPhone}
|
||||
</label>
|
||||
<div className="flex items-center rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-4 transition-all group-focus-within:border-primary group-focus-within:bg-surface-container-lowest">
|
||||
<span className="material-symbols-outlined text-outline group-focus-within:text-primary mr-3">
|
||||
alternate_email
|
||||
</span>
|
||||
<input
|
||||
id="recovery-contact"
|
||||
name="recovery-contact"
|
||||
type="text"
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
placeholder="name@company.com"
|
||||
required
|
||||
className="w-full bg-transparent border-none p-0 text-lg font-medium text-on-surface placeholder:text-outline/50 focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-on-surface-variant/70 italic">
|
||||
{/* privacy note kept short */}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-5 px-8 rounded-xl editorial-gradient text-on-primary font-headline font-bold text-xl tracking-tight magazine-shadow active:scale-[0.98] transition-all hover:brightness-110 flex items-center justify-center gap-3"
|
||||
>
|
||||
{f.submit}
|
||||
<span className="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="pt-8 border-t border-outline-variant/30 flex flex-col gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center gap-2 text-secondary font-semibold hover:text-primary transition-colors group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl group-hover:-translate-x-1 transition-transform">
|
||||
chevron_left
|
||||
</span>
|
||||
{f.backToLogin}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 p-4 rounded-xl bg-tertiary/5 border border-tertiary/10">
|
||||
<span className="material-symbols-outlined text-tertiary">contact_support</span>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{f.havingTrouble}{" "}
|
||||
<a href="#" className="text-tertiary font-bold hover:underline">{f.supportLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 right-0 w-64 h-64 bg-primary-container/5 rounded-full blur-3xl -z-10 pointer-events-none" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
<button className="bg-surface-container-lowest text-secondary p-4 rounded-full magazine-shadow border border-outline-variant/20 hover:bg-secondary hover:text-on-secondary transition-all active:scale-95 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined">help_center</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
231
src/app/(auth)/login/page.tsx
Normal file
231
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const authFieldWrapperClass =
|
||||
"relative rounded-xl border border-outline-variant/60 bg-surface-container-high px-0 transition-all duration-300 focus-within:border-primary focus-within:bg-surface-container-lowest";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const l = t.auth.login;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 404 && data.error === "ACCOUNT_NOT_FOUND") {
|
||||
router.push(`/account-not-found?email=${encodeURIComponent(email)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || l.errorGeneric);
|
||||
return;
|
||||
}
|
||||
|
||||
if (remember) {
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("role", data.role);
|
||||
} else {
|
||||
sessionStorage.setItem("token", data.token);
|
||||
sessionStorage.setItem("role", data.role);
|
||||
}
|
||||
|
||||
if (data.role === "admin") {
|
||||
router.push("/admin/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.role === "seller") {
|
||||
// Check seller profile completeness
|
||||
try {
|
||||
const profileRes = await fetch("/api/seller/profile", {
|
||||
headers: { "x-auth-token": data.token },
|
||||
});
|
||||
const profileData = await profileRes.json();
|
||||
const profile = profileData?.data || profileData;
|
||||
|
||||
const isIncomplete =
|
||||
!profile?.storeName ||
|
||||
!profile?.biography ||
|
||||
!profile?.sellerImageUrl;
|
||||
|
||||
if (isIncomplete || data.onboardingRequired) {
|
||||
router.push("/onboarding/business");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If profile check fails, still proceed to dashboard
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError(l.errorConnection);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="hidden lg:flex lg:w-1/2 bg-primary-editorial relative overflow-hidden flex-col justify-end p-16">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute -top-24 -left-24 w-96 h-96 bg-white/10 rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-48 -right-48 w-[40rem] h-[40rem] bg-black/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<h1 className="text-7xl font-extrabold text-white font-headline leading-[0.95] tracking-tighter mb-8 max-w-lg whitespace-pre-line">
|
||||
{l.heroTitle}
|
||||
</h1>
|
||||
<p className="text-white/80 text-xl font-medium max-w-md leading-relaxed">
|
||||
{l.heroSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-24 bg-surface-container-lowest md:overflow-y-auto">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile Logo + Language Toggle */}
|
||||
<div className="lg:hidden mb-12 flex justify-between items-center">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
{/* Desktop language toggle */}
|
||||
<div className="hidden lg:flex items-start justify-between mb-10">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={180} height={54} priority />
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
<header className="mb-12">
|
||||
<h2 className="text-4xl font-extrabold text-on-surface font-headline tracking-tight mb-2">
|
||||
{l.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant font-medium">{l.subtitle}</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-outline mb-2 block">
|
||||
{l.emailOrPhone}
|
||||
</label>
|
||||
<div className={authFieldWrapperClass}>
|
||||
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline-variant">
|
||||
person
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@business.com"
|
||||
required
|
||||
className="w-full rounded-xl border-none bg-transparent py-4 pl-12 pr-4 font-medium placeholder:text-outline-variant focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-outline block">
|
||||
Password
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-xs font-bold text-tertiary hover:underline">
|
||||
{l.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={authFieldWrapperClass}>
|
||||
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline-variant">
|
||||
lock
|
||||
</span>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full rounded-xl border-none bg-transparent py-4 pl-12 pr-12 font-medium placeholder:text-outline-variant focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-outline-variant hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-outline-variant text-primary focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="remember" className="text-sm font-medium text-on-surface-variant">
|
||||
{l.rememberDevice}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary text-white font-bold py-5 rounded-xl shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all duration-200 text-lg disabled:opacity-70 disabled:scale-100"
|
||||
>
|
||||
{loading ? l.submitting : l.submit}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="mt-12 text-center">
|
||||
<p className="text-on-surface-variant font-medium">
|
||||
{l.noAccount}{" "}
|
||||
<Link href="/register" className="text-primary font-bold hover:underline ml-1">
|
||||
{l.registerFree}
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
103
src/app/(auth)/register/complete/page.tsx
Normal file
103
src/app/(auth)/register/complete/page.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
export default function CompleteRegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const c = t.auth.complete;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem("registerData");
|
||||
const otpVerified = sessionStorage.getItem("otpVerified");
|
||||
if (!raw || otpVerified !== "true") {
|
||||
router.replace("/register");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
async function handleComplete() {
|
||||
const raw = sessionStorage.getItem("registerData");
|
||||
if (!raw) {
|
||||
setError(c.noData);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const registerData = JSON.parse(raw);
|
||||
const res = await fetch("/api/auth/finalize-register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: registerData.role, registerData }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data?.error || c.registerFail);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("registerData");
|
||||
sessionStorage.removeItem("otpVerified");
|
||||
sessionStorage.removeItem("otpVerifiedEmail");
|
||||
router.push("/login");
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface p-6">
|
||||
<div className="w-full max-w-xl rounded-2xl bg-surface-container-lowest p-10 shadow-lg">
|
||||
<div className="flex justify-end mb-4">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 inline-flex items-center rounded-full bg-primary/10 px-4 py-1.5 text-xs font-black uppercase tracking-widest text-primary">
|
||||
{c.finalStep}
|
||||
</div>
|
||||
<h1 className="font-headline text-4xl font-black tracking-tight text-on-surface">
|
||||
{c.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-on-surface-variant">{c.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-6 rounded-xl bg-error-container p-4 text-sm font-medium text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleComplete}
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-primary px-6 py-4 text-lg font-bold text-white shadow-lg shadow-primary/20 transition-all hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{loading ? c.submitting : c.submit}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/register"
|
||||
className="block text-center text-sm font-semibold text-on-surface-variant hover:text-primary"
|
||||
>
|
||||
{c.backToRegister}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
337
src/app/(auth)/register/page.tsx
Normal file
337
src/app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
type Role = "seller" | "buyer";
|
||||
|
||||
const authInputClass =
|
||||
"w-full rounded-xl border border-outline-variant/60 bg-surface-container-highest px-4 py-3 text-on-surface placeholder:text-surface-dim focus:border-primary focus:bg-surface-container-lowest focus:outline-none transition-all duration-300";
|
||||
|
||||
const authInputWithIconClass =
|
||||
"w-full rounded-xl border border-outline-variant/60 bg-surface-container-highest px-4 py-3 pr-10 text-on-surface placeholder:text-surface-dim focus:border-primary focus:bg-surface-container-lowest focus:outline-none transition-all duration-300";
|
||||
|
||||
function RegisterContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const emailFromQuery = searchParams.get("email") || "";
|
||||
const { t } = useLanguage();
|
||||
const r = t.auth.register;
|
||||
|
||||
const [role, setRole] = useState<Role>("seller");
|
||||
const [email, setEmail] = useState(emailFromQuery);
|
||||
const [name, setName] = useState("");
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(r.passwordMismatch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError(r.passwordTooShort);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/send-otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || r.otpError);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(
|
||||
"registerData",
|
||||
JSON.stringify({ name, mobile, password, role, email })
|
||||
);
|
||||
|
||||
router.push(`/register/verify?email=${encodeURIComponent(email)}`);
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col md:flex-row min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="relative w-full md:w-5/12 lg:w-1/2 min-h-[280px] md:h-full flex items-end p-8 lg:p-16 overflow-hidden bg-on-surface">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/80 via-primary/60 to-primary-container/40" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-on-surface via-transparent to-transparent opacity-80" />
|
||||
</div>
|
||||
<div className="relative z-10 max-w-lg">
|
||||
<h1 className="text-white font-headline text-5xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] mb-6 whitespace-pre-line">
|
||||
{r.joinNetwork}
|
||||
</h1>
|
||||
<p className="text-surface-container-low text-lg lg:text-xl font-medium leading-relaxed opacity-90 max-w-md">
|
||||
{r.joinSubtitle}
|
||||
</p>
|
||||
<div className="mt-12 pt-12 border-t border-white/20 flex gap-12">
|
||||
<div>
|
||||
<div className="text-white font-headline text-4xl font-bold">98%</div>
|
||||
<div className="text-white/60 text-sm uppercase tracking-widest font-bold mt-1">{r.successRate}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-headline text-4xl font-bold">24/7</div>
|
||||
<div className="text-white/60 text-sm uppercase tracking-widest font-bold mt-1">{r.marketPulse}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="w-full md:w-7/12 lg:w-1/2 bg-surface-container-lowest flex flex-col overflow-y-auto">
|
||||
<div className="w-full h-1.5 bg-primary" />
|
||||
<div className="w-full max-w-2xl mx-auto p-8 lg:p-10 xl:p-12 flex-1 flex flex-col">
|
||||
<div className="mb-8">
|
||||
<div className="mb-8 flex items-start justify-between gap-4">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={180} height={54} priority />
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<h2 className="font-headline text-3xl font-extrabold tracking-tight text-on-surface mb-2 text-center">
|
||||
{r.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant font-medium text-center">{r.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Selector */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline">
|
||||
{r.registerAs}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("seller")}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
|
||||
role === "seller"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-outline-variant/30 text-on-surface-variant hover:border-outline/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-2xl"
|
||||
style={{ fontVariationSettings: role === "seller" ? "'FILL' 1" : "'FILL' 0" }}
|
||||
>
|
||||
storefront
|
||||
</span>
|
||||
<span className="text-sm font-bold">Seller</span>
|
||||
<span className="text-[10px] text-center opacity-70 leading-tight">
|
||||
{r.sellerDesc}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("buyer")}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
|
||||
role === "buyer"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-outline-variant/30 text-on-surface-variant hover:border-outline/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-2xl"
|
||||
style={{ fontVariationSettings: role === "buyer" ? "'FILL' 1" : "'FILL' 0" }}
|
||||
>
|
||||
shopping_bag
|
||||
</span>
|
||||
<span className="text-sm font-bold">Buyer</span>
|
||||
<span className="text-[10px] text-center opacity-70 leading-tight">
|
||||
{r.buyerDesc}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="email">
|
||||
{r.email}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@business.com"
|
||||
required
|
||||
readOnly={!!emailFromQuery}
|
||||
className={`${authInputClass} ${
|
||||
emailFromQuery ? "bg-surface-container text-on-surface-variant cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
{emailFromQuery && (
|
||||
<p className="text-[10px] text-on-surface-variant mt-1">{r.emailFromPrevious}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="name">
|
||||
{r.fullName}
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
className={authInputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="mobile">
|
||||
{r.phone}
|
||||
</label>
|
||||
<input
|
||||
id="mobile"
|
||||
type="tel"
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(e.target.value)}
|
||||
placeholder="+62 812 3456 789"
|
||||
required
|
||||
className={authInputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="password">
|
||||
{r.password}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={r.passwordPlaceholder}
|
||||
required
|
||||
className={authInputWithIconClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-0 top-3 text-outline-variant hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="confirm-password">
|
||||
{r.confirmPassword}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={r.confirmPasswordPlaceholder}
|
||||
required
|
||||
className={authInputWithIconClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-0 top-3 text-outline-variant hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">
|
||||
{showConfirm ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="signature-cta-gradient w-full py-4 text-on-primary font-headline font-bold text-lg rounded-xl magazine-shadow hover:scale-[1.02] active:scale-95 transition-all duration-200 disabled:opacity-70 disabled:scale-100"
|
||||
>
|
||||
{loading ? r.submitting : r.submit}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-on-surface-variant font-medium">
|
||||
{r.haveAccount}{" "}
|
||||
<Link href="/login" className="text-tertiary font-bold hover:text-primary transition-colors ml-1">
|
||||
{r.signIn}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Step Progress */}
|
||||
<div className="mt-10 flex justify-between items-center bg-surface-container-low p-4 rounded-xl">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-1.5 rounded-full bg-primary" />
|
||||
<div className="w-8 h-1.5 rounded-full bg-outline-variant" />
|
||||
<div className="w-8 h-1.5 rounded-full bg-outline-variant" />
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{r.stepOf}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pb-10 text-center">
|
||||
<p className="text-[10px] text-outline leading-tight uppercase tracking-tighter">
|
||||
{r.termsAgreement}{" "}
|
||||
<a href="#" className="underline">{t.common.terms}</a> {r.and}{" "}
|
||||
<a href="#" className="underline">{t.common.privacy}</a> {r.inaTrading}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RegisterContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
321
src/app/(auth)/register/verify/page.tsx
Normal file
321
src/app/(auth)/register/verify/page.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, useRef, Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
function VerifyContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email") || "";
|
||||
const { t } = useLanguage();
|
||||
const v = t.auth.verify;
|
||||
|
||||
const [otp, setOtp] = useState(["", "", "", "", "", ""]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [errorStep, setErrorStep] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [countdown]);
|
||||
|
||||
function handleOtpChange(index: number, value: string) {
|
||||
if (!/^\d*$/.test(value)) return;
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = value.slice(-1);
|
||||
setOtp(newOtp);
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
|
||||
if (!pasted) return;
|
||||
const newOtp = [...otp];
|
||||
pasted.split("").forEach((char, i) => { if (i < 6) newOtp[i] = char; });
|
||||
setOtp(newOtp);
|
||||
const nextIndex = Math.min(pasted.length, 5);
|
||||
inputRefs.current[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setErrorStep("");
|
||||
const otpCode = otp.join("");
|
||||
|
||||
if (otpCode.length < 6) {
|
||||
setError(v.otpTooShort);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData = sessionStorage.getItem("registerData");
|
||||
if (!rawData) {
|
||||
setError(v.noData);
|
||||
return;
|
||||
}
|
||||
|
||||
const { role } = JSON.parse(rawData);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/verify-otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, otp: otpCode }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const backendMessage = data?.error || data?.responseDesc || data?.message || v.verifyFail;
|
||||
setError(backendMessage);
|
||||
setErrorStep(data?.step || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedData = JSON.parse(rawData);
|
||||
|
||||
if (role === "seller") {
|
||||
const registerData = { ...parsedData, email, otpVerified: true };
|
||||
|
||||
const registerRes = await fetch("/api/auth/finalize-register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: "seller", registerData }),
|
||||
});
|
||||
|
||||
const registerResult = await registerRes.json();
|
||||
|
||||
if (!registerRes.ok) {
|
||||
setError(registerResult?.error || v.registerFail);
|
||||
setErrorStep(registerResult?.step || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerResult?.token) {
|
||||
sessionStorage.setItem("token", registerResult.token);
|
||||
sessionStorage.setItem("role", "seller");
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("registerData");
|
||||
sessionStorage.removeItem("otpVerified");
|
||||
sessionStorage.removeItem("otpVerifiedEmail");
|
||||
setSuccess(v.successSeller);
|
||||
setTimeout(() => { router.push("/onboarding/business"); }, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem("registerData", JSON.stringify({ ...parsedData, email, otpVerified: true }));
|
||||
sessionStorage.setItem("otpVerified", "true");
|
||||
sessionStorage.setItem("otpVerifiedEmail", email);
|
||||
setSuccess(v.successBuyer);
|
||||
setTimeout(() => { router.push("/register/complete"); }, 1000);
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (countdown > 0) return;
|
||||
setError("");
|
||||
setErrorStep("");
|
||||
setResending(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/send-otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setOtp(["", "", "", "", "", ""]);
|
||||
inputRefs.current[0]?.focus();
|
||||
setCountdown(60);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || t.common.connectionError);
|
||||
}
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col md:flex-row min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="hidden md:flex md:w-5/12 lg:w-1/2 relative bg-primary overflow-hidden items-center justify-center p-12">
|
||||
<div className="absolute inset-0 z-0 opacity-20 bg-gradient-to-br from-primary-container to-primary" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary via-primary to-primary-container opacity-90 z-10" />
|
||||
<div className="relative z-20 max-w-lg">
|
||||
<h1 className="text-5xl lg:text-7xl font-headline font-extrabold text-on-primary leading-none tracking-tight mb-8 whitespace-pre-line">
|
||||
{v.secureYourFuture}
|
||||
</h1>
|
||||
<div className="h-1 w-24 bg-on-primary mb-8" />
|
||||
<p className="text-on-primary/80 text-xl font-medium leading-relaxed max-w-sm">
|
||||
{v.verifyIdentity}
|
||||
</p>
|
||||
<div className="mt-16 grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<p className="text-on-primary font-bold text-3xl font-headline tracking-tighter">99.9%</p>
|
||||
<p className="text-on-primary/60 text-sm">{v.transactionSecurity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-on-primary font-bold text-3xl font-headline tracking-tighter">256-bit</p>
|
||||
<p className="text-on-primary/60 text-sm">{v.bankLevelEncryption}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="flex-1 bg-surface flex items-center justify-center p-6 md:p-12 lg:p-24 relative md:overflow-y-auto">
|
||||
{/* Logo */}
|
||||
<div className="absolute top-8 left-8">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<header className="mb-12">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-tertiary/10 rounded-xl mb-6">
|
||||
<span className="material-symbols-outlined text-tertiary" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
mark_email_read
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-4xl font-headline font-extrabold text-on-surface tracking-tight mb-4">
|
||||
{v.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant leading-relaxed">
|
||||
{v.subtitle}{" "}
|
||||
<span className="font-semibold text-on-surface">{email}</span>
|
||||
{v.subtitleSuffix}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm font-medium">
|
||||
<div>{error}</div>
|
||||
{errorStep ? (
|
||||
<div className="mt-1 text-[11px] font-semibold uppercase tracking-wider opacity-70">
|
||||
Step: {errorStep}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-3 bg-tertiary/10 text-tertiary rounded-lg text-sm font-medium">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OTP Grid */}
|
||||
<div className="flex gap-3 justify-between" onPaste={handlePaste}>
|
||||
{otp.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleOtpChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
placeholder="•"
|
||||
className="otp-input w-14 h-16 text-center text-2xl font-bold rounded-xl border-none bg-surface-container-highest text-on-surface transition-all focus:bg-surface-container-lowest focus:ring-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-on-surface-variant">{v.noCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={countdown > 0 || resending}
|
||||
className="text-tertiary font-bold hover:underline transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{resending
|
||||
? v.resending
|
||||
: countdown > 0
|
||||
? `${v.resendCountdown} (${countdown}s)`
|
||||
: v.resend}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otp.join("").length < 6}
|
||||
className="w-full bg-gradient-to-br from-primary to-primary-container text-on-primary font-headline font-bold py-5 px-8 rounded-xl shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.98] transition-all flex items-center justify-center gap-3 disabled:opacity-60 disabled:scale-100"
|
||||
>
|
||||
<span>{loading ? v.submitting : v.submit}</span>
|
||||
{!loading && <span className="material-symbols-outlined text-lg">arrow_forward</span>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="mt-16 pt-8 border-t border-outline-variant/20">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-outline text-sm">shield</span>
|
||||
<span className="text-xs text-on-surface-variant uppercase tracking-widest font-semibold">
|
||||
{v.securityTitle}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant/70 leading-relaxed">{v.securityDesc}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/register"
|
||||
className="absolute top-8 right-8 flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
<span className="hidden md:inline font-label">{t.common.cancel}</span>
|
||||
</Link>
|
||||
|
||||
<div className="absolute top-8 right-24">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
261
src/app/(auth)/reset-password/page.tsx
Normal file
261
src/app/(auth)/reset-password/page.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
|
||||
const passwordFieldWrapperClass =
|
||||
"relative rounded-xl border border-outline-variant/60 bg-surface-container-highest transition-all duration-300 focus-within:border-primary focus-within:bg-surface-container-lowest";
|
||||
|
||||
function getPasswordChecks(password: string) {
|
||||
return {
|
||||
minLength: password.length >= 12,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
number: /\d/.test(password),
|
||||
special: /[^A-Za-z0-9]/.test(password),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const checks = getPasswordChecks(newPassword);
|
||||
const isStrongPassword = Object.values(checks).every(Boolean);
|
||||
const passwordsMatch = newPassword === confirmPassword;
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSubmitted(false);
|
||||
|
||||
if (!isStrongPassword) {
|
||||
setError("Password baru belum memenuhi requirement minimum.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordsMatch) {
|
||||
setError("Konfirmasi password tidak sama.");
|
||||
return;
|
||||
}
|
||||
|
||||
// API reset password belum tersedia.
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-screen w-full bg-background">
|
||||
<div className="hidden lg:flex lg:w-7/12 relative overflow-hidden editorial-gradient flex-col justify-between p-16">
|
||||
<div className="z-10">
|
||||
<Image
|
||||
src="/ina_logo.png"
|
||||
alt="Ina Trading"
|
||||
width={180}
|
||||
height={54}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="z-10 max-w-xl">
|
||||
<h1 className="font-headline text-white text-7xl font-extrabold leading-[1.05] tracking-tight mb-8">
|
||||
Secure. <br />
|
||||
Refined. <br />
|
||||
Absolute.
|
||||
</h1>
|
||||
<p className="text-white/80 text-xl font-medium leading-relaxed">
|
||||
Protecting your financial ecosystem with world-class encryption
|
||||
and institutional-grade security protocols.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="z-10 grid grid-cols-2 gap-8 border-t border-white/20 pt-12">
|
||||
<div>
|
||||
<div className="text-white/60 text-xs font-bold uppercase tracking-widest mb-2">
|
||||
Security Status
|
||||
</div>
|
||||
<div className="text-white text-3xl font-bold font-headline">
|
||||
99.9% Up
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/60 text-xs font-bold uppercase tracking-widest mb-2">
|
||||
Data Protection
|
||||
</div>
|
||||
<div className="text-white text-3xl font-bold font-headline">
|
||||
AES-256
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.15),transparent_28%),linear-gradient(140deg,rgba(0,0,0,0.12),rgba(0,0,0,0.32))]" />
|
||||
<div className="absolute inset-0 opacity-40 mix-blend-overlay bg-[linear-gradient(120deg,transparent_0%,rgba(255,255,255,0.06)_35%,transparent_60%),radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.12),transparent_24%)]" />
|
||||
</div>
|
||||
<div className="absolute -bottom-24 -left-24 w-96 h-96 bg-tertiary/30 blur-[120px] rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-5/12 bg-surface-container-lowest flex flex-col justify-center px-8 sm:px-16 lg:px-24">
|
||||
<div className="max-w-md w-full mx-auto">
|
||||
<div className="lg:hidden mb-12 flex justify-between items-center">
|
||||
<Image
|
||||
src="/ina_logo.png"
|
||||
alt="Ina Trading"
|
||||
width={150}
|
||||
height={45}
|
||||
priority
|
||||
/>
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<div className="hidden lg:flex justify-end mb-4">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
<header className="mb-12">
|
||||
<div className="h-1 w-12 bg-primary mb-6" />
|
||||
<h2 className="font-headline text-4xl font-extrabold text-on-surface tracking-tight leading-tight">
|
||||
Reset password
|
||||
</h2>
|
||||
<p className="mt-4 text-on-surface-variant font-medium leading-relaxed">
|
||||
Enter your new credentials to regain access to your trading
|
||||
dashboard.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error/10 bg-error-container p-4 text-sm text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<div className="rounded-xl border border-tertiary/10 bg-tertiary/5 p-4 text-sm text-on-tertiary-fixed-variant">
|
||||
Reset password API belum tersedia. Screen ini sudah siap dan
|
||||
submit reset password berhasil disimulasikan.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 group">
|
||||
<label
|
||||
htmlFor="new-password"
|
||||
className="text-xs font-bold uppercase tracking-widest text-on-surface-variant group-focus-within:text-primary transition-colors"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<div className={passwordFieldWrapperClass}>
|
||||
<input
|
||||
id="new-password"
|
||||
name="new-password"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full rounded-xl bg-transparent border-none py-4 px-4 pr-12 text-on-surface font-medium placeholder:text-outline-variant focus:ring-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword((prev) => !prev)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{showNewPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 group">
|
||||
<label
|
||||
htmlFor="confirm-password"
|
||||
className="text-xs font-bold uppercase tracking-widest text-on-surface-variant group-focus-within:text-primary transition-colors"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div className={passwordFieldWrapperClass}>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full rounded-xl bg-transparent border-none py-4 px-4 pr-12 text-on-surface font-medium placeholder:text-outline-variant focus:ring-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{showConfirmPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-surface-container rounded-lg border-l-4 border-tertiary">
|
||||
<p className="text-xs font-medium text-on-surface-variant leading-relaxed">
|
||||
<span className="text-tertiary font-bold">Requirement:</span>{" "}
|
||||
Minimum 12 characters, including one uppercase letter, one
|
||||
special character, and one numeric value.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] font-medium">
|
||||
<span className={checks.minLength ? "text-tertiary" : "text-outline"}>
|
||||
12+ characters
|
||||
</span>
|
||||
<span className={checks.uppercase ? "text-tertiary" : "text-outline"}>
|
||||
Uppercase letter
|
||||
</span>
|
||||
<span className={checks.number ? "text-tertiary" : "text-outline"}>
|
||||
Numeric value
|
||||
</span>
|
||||
<span className={checks.special ? "text-tertiary" : "text-outline"}>
|
||||
Special character
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="editorial-gradient w-full py-5 rounded-xl shadow-2xl shadow-primary/20 text-on-primary font-headline font-bold text-lg hover:scale-[1.02] active:scale-95 transition-all duration-200 disabled:opacity-60 disabled:scale-100"
|
||||
disabled={!newPassword || !confirmPassword}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="mt-12 pt-8 border-t border-surface-container text-center">
|
||||
<p className="text-sm text-on-surface-variant font-medium">
|
||||
Remember your password?
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-secondary font-bold hover:text-primary transition-colors ml-1"
|
||||
>
|
||||
Go back to Login
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-8 right-8">
|
||||
<button className="bg-surface-container-high text-on-surface w-14 h-14 rounded-full shadow-lg flex items-center justify-center hover:bg-surface-dim transition-colors group">
|
||||
<span className="material-symbols-outlined text-2xl group-hover:scale-110 transition-transform">
|
||||
help_outline
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user