nxtgauge-frontend-solid/src/routes/signup.tsx

463 lines
19 KiB
TypeScript
Raw Normal View History

import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader';
import CaptchaCanvas from '~/components/CaptchaCanvas';
import {
checkPasswordStrength,
isPasswordStrong,
isValidCaptcha,
isValidEmail,
isValidName,
validateRegisterForm,
} from '~/lib/form-validation';
type RoleKey = 'company' | 'job_seeker' | 'professional' | 'customer';
type RegisterErrors = Record<string, string>;
function normalizeIntent(intent: string | null | undefined): RoleKey {
const v = String(intent || '').toLowerCase();
if (v.includes('company')) return 'company';
if (v.includes('professional')) return 'professional';
if (v.includes('developer') || v.includes('photographer') || v.includes('makeup') || v.includes('tutor') || v.includes('video') || v.includes('graphic') || v.includes('social') || v.includes('fitness') || v.includes('catering') || v.includes('ugc')) return 'professional';
if (v.includes('customer')) return 'customer';
return 'job_seeker';
}
function randomCaptcha(length = 6): string {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let out = '';
for (let i = 0; i < length; i += 1) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
function PasswordVisibilityIcon(props: { visible: boolean }) {
if (props.visible) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 3l18 18" />
<path d="M10.58 10.58a2 2 0 0 0 2.83 2.83" />
<path d="M9.88 5.09A11 11 0 0 1 12 4.9c5.5 0 10 4.1 10 7.1 0 1.2-.72 2.53-1.95 3.72" />
<path d="M6.1 6.1C3.54 7.58 2 9.79 2 12c0 3 4.48 7.1 10 7.1 1.72 0 3.36-.4 4.84-1.12" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export default function SignupRoute() {
const navigate = useNavigate();
const [search] = useSearchParams();
onMount(() => {
// Legacy redirect to choose-role removed for dashboard-first flow.
// If no intent/role provided, normalizeIntent will default to job_seeker.
});
const [step, setStep] = createSignal<'register' | 'verify'>('register');
const [firstName, setFirstName] = createSignal('');
const [lastName, setLastName] = createSignal('');
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const role = createMemo<RoleKey>(() => normalizeIntent(search.intent || search.role));
const selectedProfessionalRole = createMemo(() => String(search.role || '').trim().toUpperCase());
const [termsAccepted, setTermsAccepted] = createSignal(false);
const [captcha, setCaptcha] = createSignal('');
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
const [otp, setOtp] = createSignal(['', '', '', '', '', '']);
const [errors, setErrors] = createSignal<RegisterErrors>({});
const [serverError, setServerError] = createSignal('');
const [emailExists, setEmailExists] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false);
const [pendingEmail, setPendingEmail] = createSignal('');
const [pendingPassword, setPendingPassword] = createSignal('');
const [showPassword, setShowPassword] = createSignal(false);
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
const passwordChecks = createMemo(() => checkPasswordStrength(password(), confirmPassword()));
const otpCode = createMemo(() => otp().join(''));
const firstNameValid = createMemo(() => !firstName().trim() || isValidName(firstName()));
const lastNameValid = createMemo(() => !lastName().trim() || isValidName(lastName()));
const emailValid = createMemo(() => !email().trim() || isValidEmail(email()));
const canSubmit = createMemo(() =>
firstName().trim().length > 0 &&
firstNameValid() &&
lastName().trim().length > 0 &&
lastNameValid() &&
emailValid() &&
isValidEmail(email()) &&
isPasswordStrong(passwordChecks()) &&
passwordChecks().match &&
isValidCaptcha(captcha(), captchaCode()) &&
termsAccepted() &&
!emailExists()
);
const refreshCaptcha = () => {
setCaptcha('');
setCaptchaCode(randomCaptcha());
};
const checkEmailExists = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) {
setEmailExists(false);
return false;
}
try {
const response = await fetch('/api/gateway/api/auth/check-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: normalized }),
});
const payload = await response.json().catch(() => ({}));
const exists = Boolean(response.ok && payload?.exists);
setEmailExists(exists);
return exists;
} catch {
setEmailExists(false);
return false;
}
};
const setOtpDigit = (index: number, value: string) => {
const clean = value.replace(/\D/g, '').slice(0, 1);
setOtp((prev) => {
const next = prev.slice();
next[index] = clean;
return next;
});
if (clean) {
const nextEl = document.querySelector<HTMLInputElement>(`#otp-${index + 1}`);
if (nextEl) nextEl.focus();
}
};
const saveUserForDashboard = (input: { firstName: string; lastName: string; email: string; roleKey: RoleKey; user?: any }) => {
const fullName = `${input.firstName} ${input.lastName}`.trim();
const payload = {
firstName: input.firstName,
lastName: input.lastName,
fullName,
name: fullName,
displayName: fullName,
email: input.email.toLowerCase(),
roleKey: input.roleKey,
role: input.roleKey,
selectedProfessionalRole: selectedProfessionalRole() || null,
user: input.user || null,
};
if (typeof window !== 'undefined') {
window.localStorage.setItem('nxtgauge_signup_profile_v1', JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_auth_user', JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_user', JSON.stringify(payload));
}
};
const storeAuth = (payload: any) => {
const accessToken = String(payload?.access_token || '').trim();
if (typeof window !== 'undefined' && accessToken) {
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
window.localStorage.setItem('nxtgauge_access_token', accessToken);
}
};
const register = async () => {
setServerError('');
const validation = validateRegisterForm({
firstName: firstName(),
lastName: lastName(),
email: email(),
password: password(),
confirmPassword: confirmPassword(),
captcha: captcha(),
expectedCaptcha: captchaCode(),
termsAccepted: termsAccepted(),
});
setErrors(validation.errors);
if (!validation.isValid) return;
setSubmitting(true);
try {
const res = await fetch('/api/gateway/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({
full_name: `${firstName().trim()} ${lastName().trim()}`.trim(),
email: email().trim().toLowerCase(),
password: password(),
phone: null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setServerError(String(data?.error || data?.message || 'Unable to create account.'));
refreshCaptcha();
return;
}
const cleanEmail = email().trim().toLowerCase();
setPendingEmail(cleanEmail);
setPendingPassword(password());
saveUserForDashboard({
firstName: firstName().trim(),
lastName: lastName().trim(),
email: cleanEmail,
roleKey: role(),
});
setStep('verify');
setOtp(['', '', '', '', '', '']);
} finally {
setSubmitting(false);
}
};
const verifyOtp = async () => {
setServerError('');
if (otpCode().length !== 6) {
setServerError('Enter the 6-digit code sent to your email.');
return;
}
setSubmitting(true);
try {
const verifyRes = await fetch('/api/gateway/api/auth/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ otp: otpCode() }),
});
const verifyData = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setServerError(String(verifyData?.error || verifyData?.message || 'Verification failed.'));
return;
}
const loginRes = await fetch('/api/gateway/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: pendingEmail() || email().trim().toLowerCase(),
password: pendingPassword() || password(),
}),
});
const loginData = await loginRes.json().catch(() => ({}));
if (!loginRes.ok) {
setServerError(String(loginData?.error || loginData?.message || 'Email verified. Please login manually.'));
navigate('/login', { replace: true });
return;
}
storeAuth(loginData);
saveUserForDashboard({
firstName: firstName().trim(),
lastName: lastName().trim(),
email: pendingEmail() || email().trim().toLowerCase(),
roleKey: role(),
user: loginData?.user,
});
navigate('/dashboard', { replace: true });
} finally {
setSubmitting(false);
}
};
const resendOtp = async () => {
setServerError('');
setSubmitting(true);
try {
const res = await fetch('/api/gateway/api/auth/resend-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: pendingEmail() || email().trim().toLowerCase() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setServerError(String(data?.error || data?.message || 'Unable to resend OTP right now.'));
}
} finally {
setSubmitting(false);
}
};
return (
<main class="auth-page">
<PublicBackground />
<PublicHeader />
<div class="auth-layout">
<section class="auth-visual card glass-dark">
<img class="auth-visual-img" src="/images/auth-company-2.jpg" alt="Get Started" />
<div class="auth-visual-overlay" />
<div class="auth-visual-content">
<p class="eyebrow">Get Started</p>
<h1 class="title light">Create Your Nxtgauge Account</h1>
<p class="subtitle light">Join verified opportunities and continue directly to your dashboard after signup.</p>
</div>
</section>
<section class="auth-form card glass-light">
<Show when={step() === 'register'} fallback={
<>
<h2 class="title">Verify Email</h2>
<p class="subtitle">Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.</p>
<div class="otp-row">
<For each={Array.from({ length: 6 }, (_, index) => index)}>
{(index) => (
<input
id={`otp-${index}`}
class="otp-input"
inputMode="numeric"
maxlength={1}
value={otp()[index]}
onInput={(e) => setOtpDigit(index, e.currentTarget.value)}
/>
)}
</For>
</div>
<button class="auth-submit-btn" type="button" disabled={submitting()} onClick={() => void verifyOtp()}>
{submitting() ? 'Verifying...' : 'Verify and Continue'}
</button>
<div class="auth-footer-row">
<p class="note">Didnt receive code?</p>
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}>
Resend OTP
</button>
</div>
</>
}>
<h2 class="title">Create Your Account</h2>
<p class="subtitle">Sign up first, then go directly to dashboard after email verification.</p>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<div class="field">
<label class="label" for="first-name">FULL NAME</label>
<input id="first-name" class="input" value={firstName()} onInput={(e) => setFirstName(e.currentTarget.value)} />
<p class="validation-note" style={{ color: firstName().trim() && firstNameValid() ? '#fd6116' : '#6e7591' }}>
{firstName().trim() && firstNameValid() ? '✓ First name looks good' : '• First name is required'}
</p>
</div>
<div class="field">
<label class="label" for="last-name">LAST NAME</label>
<input id="last-name" class="input" value={lastName()} onInput={(e) => setLastName(e.currentTarget.value)} />
<p class="validation-note" style={{ color: lastName().trim() && lastNameValid() ? '#fd6116' : '#6e7591' }}>
{lastName().trim() && lastNameValid() ? '✓ Last name looks good' : '• Last name is required'}
</p>
</div>
</div>
<div class="field">
<label class="label" for="email">EMAIL ADDRESS</label>
<input
id="email"
type="email"
class="input"
value={email()}
onInput={(e) => {
setEmail(e.currentTarget.value);
setEmailExists(false);
}}
onBlur={() => { void checkEmailExists(email()); }}
/>
<p class="validation-note" style={{ color: emailExists() ? '#dc2626' : (email().trim() && emailValid() ? '#fd6116' : '#6e7591') }}>
{emailExists()
? '• This email is already registered'
: (email().trim() && emailValid() ? '✓ Valid email format' : '• Enter a valid email format')}
</p>
</div>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<div class="field">
<label class="label" for="password">PASSWORD</label>
<div class="auth-password-wrap">
<input id="password" type={showPassword() ? 'text' : 'password'} class="input" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
<button
class="auth-toggle-visibility"
type="button"
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword() ? 'Hide password' : 'Show password'}
>
<PasswordVisibilityIcon visible={showPassword()} />
</button>
</div>
<div class="password-strength-grid">
<p style={{ color: passwordChecks().minLength ? '#fd6116' : '#6e7591' }}>{passwordChecks().minLength ? '✓' : '•'} 8+ chars</p>
<p style={{ color: passwordChecks().uppercase ? '#fd6116' : '#6e7591' }}>{passwordChecks().uppercase ? '✓' : '•'} Uppercase</p>
<p style={{ color: passwordChecks().special ? '#fd6116' : '#6e7591' }}>{passwordChecks().special ? '✓' : '•'} Special</p>
<p style={{ color: passwordChecks().lowercase ? '#fd6116' : '#6e7591' }}>{passwordChecks().lowercase ? '✓' : '•'} Lowercase</p>
<p style={{ color: passwordChecks().number ? '#fd6116' : '#6e7591' }}>{passwordChecks().number ? '✓' : '•'} Number</p>
</div>
</div>
<div class="field">
<label class="label" for="confirm-password">CONFIRM PASSWORD</label>
<div class="auth-password-wrap">
<input id="confirm-password" type={showConfirmPassword() ? 'text' : 'password'} class="input" value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} />
<button
class="auth-toggle-visibility"
type="button"
onClick={() => setShowConfirmPassword((prev) => !prev)}
aria-label={showConfirmPassword() ? 'Hide password' : 'Show password'}
>
<PasswordVisibilityIcon visible={showConfirmPassword()} />
</button>
</div>
<p class="validation-note" style={{ color: confirmPassword() && passwordChecks().match ? '#fd6116' : '#6e7591' }}>
{confirmPassword() && passwordChecks().match ? '✓ Passwords match' : '• Passwords do not match'}
</p>
</div>
</div>
<div class="field">
<label class="label" for="captcha">CAPTCHA</label>
<div class="auth-captcha-row">
<button type="button" class="auth-captcha-refresh" onClick={refreshCaptcha} aria-label="Refresh captcha"></button>
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
<input id="captcha" class="input" value={captcha()} onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())} placeholder="Enter captcha" />
</div>
<p class="validation-note" style={{ color: captcha() && isValidCaptcha(captcha(), captchaCode()) ? '#fd6116' : '#6e7591' }}>
{captcha() ? (isValidCaptcha(captcha(), captchaCode()) ? '✓ Captcha matched' : '• Captcha does not match') : '• Enter captcha to continue'}
</p>
</div>
<div class="field" style={{ 'margin-top': '16px' }}>
<label class="auth-checkbox-wrapper">
<input class="auth-checkbox" type="checkbox" checked={termsAccepted()} onChange={(e) => setTermsAccepted(e.currentTarget.checked)} />
<span class="auth-checkbox-label">I agree to the <A href="/terms">Terms and Conditions</A> and <A href="/privacy">Privacy Policy</A></span>
</label>
</div>
<button class="auth-submit-btn" type="button" disabled={submitting() || !canSubmit()} onClick={() => void register()}>
{submitting() ? 'Creating Account...' : 'Sign Up'}
</button>
<div class="auth-footer-row">
<p class="footer-text">We will send a verification code to your email.</p>
<p class="note">Already have an account? <A href="/login">Sign In</A></p>
</div>
</Show>
<Show when={serverError()}>
<p class="error">{serverError()}</p>
</Show>
</section>
</div>
</main>
);
}