nxtgauge-frontend-solid/src/routes/signup.tsx
2026-04-17 11:56:08 +02:00

611 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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({
first_name: firstName().trim(),
last_name: lastName().trim(),
email: email().trim().toLowerCase(),
password: password(),
phone: "",
intent: role(),
role_key: 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">Didnt 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>
);
}