All job seeker pages are already connected to real APIs:
- Jobs: /api/jobseeker/jobs (real company job postings)
- Applications: /api/jobseeker/applications (my applied jobs)
- Saved Jobs: Custom data storage for bookmarked jobs
- Apply: POST /api/jobseeker/jobs/{id}/apply
Dashboard shows real data from backend, not mock preview.
439 lines
19 KiB
TypeScript
439 lines
19 KiB
TypeScript
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 [verifiedSuccess, setVerifiedSuccess] = createSignal(false);
|
||
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 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,
|
||
intent: role(),
|
||
profession: selectedProfessionalRole() || undefined,
|
||
}),
|
||
});
|
||
|
||
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);
|
||
setVerifiedSuccess(false);
|
||
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;
|
||
}
|
||
setVerifiedSuccess(true);
|
||
setTimeout(() => navigate('/login?verified=1', { replace: true }), 1400);
|
||
} 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>
|
||
|
||
<Show when={!verifiedSuccess()} fallback={
|
||
<div style={{ 'margin-top': '12px', 'border-radius': '12px', border: '1px solid #FED7AA', background: '#FFF7ED', padding: '14px 16px', color: '#C2410C', 'text-align': 'center' }}>
|
||
<div style={{ 'font-size': '30px', 'line-height': '1' }}>✓</div>
|
||
<p style={{ margin: '8px 0 0', 'font-weight': '700', 'font-size': '14px' }}>Your email has been verified.</p>
|
||
<p style={{ margin: '6px 0 0', 'font-size': '13px' }}>Redirecting to login...</p>
|
||
</div>
|
||
}>
|
||
<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">Didn’t receive code?</p>
|
||
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}>
|
||
Resend OTP
|
||
</button>
|
||
</div>
|
||
</Show>
|
||
</>
|
||
}>
|
||
<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>
|
||
);
|
||
}
|