Refine onboarding UX, role cards, and runtime schema integration

This commit is contained in:
Ashwin Kumar 2026-03-22 15:54:12 +01:00
parent 4cf4a8c310
commit 7baa38aa97
44 changed files with 1118 additions and 212 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View file

@ -24,10 +24,7 @@ body {
font-family: 'Exo 2', sans-serif;
color: var(--ink);
scrollbar-gutter: stable;
background:
radial-gradient(120% 90% at 0% 0%, rgba(253, 98, 22, 0.22), transparent 52%),
radial-gradient(100% 80% at 100% 0%, rgba(26, 54, 93, 0.16), transparent 56%),
linear-gradient(180deg, #fff9f4 0%, #f8f9ff 48%, #eef2ff 100%);
background: #07051a;
}
.container {
@ -1798,13 +1795,19 @@ body {
min-height: 100vh;
overflow-x: clip;
isolation: isolate;
background: #07051a;
}
.lp-main > *:not(.lp-bg) {
position: relative;
z-index: 1;
}
.lp-bg {
pointer-events: none;
position: fixed;
inset: 0;
z-index: -1;
z-index: 0;
}
.lp-dark-base {
@ -2863,6 +2866,166 @@ body {
width: min(680px, calc(100% - 32px));
}
.onboarding-auth-layout {
align-items: start;
padding-top: 36px;
padding-bottom: 36px;
}
.onboarding-auth-form {
padding: 18px 20px 22px;
}
.onboarding-auth-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 12px;
}
.onboarding-primary-btn {
margin-top: 0;
width: 100%;
}
.onboarding-auth-form .error {
color: #6e7591;
}
.onboarding-progress {
width: 100%;
height: 8px;
border-radius: 999px;
background: #eceff5;
overflow: hidden;
margin-top: 8px;
margin-bottom: 10px;
}
.onboarding-progress-fill {
height: 100%;
border-radius: 999px;
background: #fd6216;
transition: width 220ms ease;
}
.multi-select-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.multi-select-option {
width: 100%;
min-height: 44px;
border-radius: 12px;
border: 1px solid #cfd4e3;
background: #f8fafc;
color: #3f4967;
padding: 10px 12px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
text-align: left;
cursor: pointer;
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.multi-select-option:hover {
border-color: #fdc9ad;
background: #fff4ee;
}
.multi-select-option.is-selected {
border-color: #fd6216;
background: #fff2ea;
color: #2c3551;
box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.12);
}
.multi-select-option:focus-visible {
outline: none;
border-color: #fd6216;
box-shadow: 0 0 0 2px #ffd8c3;
}
.multi-select-option:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.multi-select-option-text {
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.multi-select-tick {
width: 18px;
height: 18px;
border-radius: 999px;
border: 1px solid #d2d7e6;
background: #f3f5fa;
color: #9aa3bc;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.multi-select-tick svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.multi-select-tick.is-visible {
border-color: #fd6216;
background: #fd6216;
color: #ffffff;
}
.multi-select-tick.is-visible svg {
opacity: 1;
}
.multi-select-grid.is-disabled .multi-select-option {
pointer-events: none;
}
.locked-input-wrap {
position: relative;
}
.locked-input {
padding-right: 40px;
}
.locked-input-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
pointer-events: none;
}
.locked-input-icon svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.auth-visual {
min-height: 620px;
border-radius: 28px;
@ -4572,9 +4735,9 @@ body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
align-items: stretch;
justify-content: flex-start;
padding: 24px 16px 60px;
padding: 0;
color: #fff;
isolation: isolate;
}
@ -4599,7 +4762,7 @@ body {
.choose-role-header h1 {
margin: 0 0 12px;
font-size: clamp(28px, 5vw, 40px);
font-size: clamp(36px, 6vw, 56px);
font-weight: 800;
color: #fff;
line-height: 1.1;
@ -4640,7 +4803,41 @@ body {
}
.professional-roles-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.role-path-card {
appearance: none;
-webkit-appearance: none;
width: 100%;
padding: 0;
text-align: left;
cursor: pointer;
}
.role-path-card.selected {
border-color: #fd6216;
box-shadow: 0 0 0 2px rgba(253, 98, 22, 0.28), 0 22px 42px -30px rgba(253, 98, 22, 0.85);
}
.role-path-card .path-chip {
margin-left: auto;
}
.role-path-card .role-path-cta {
pointer-events: none;
}
@media (max-width: 1024px) {
.professional-roles-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.professional-roles-grid {
grid-template-columns: 1fr;
}
}
.role-card {
@ -4652,12 +4849,11 @@ body {
gap: 12px;
padding: 28px 18px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
border: 1px solid rgba(15, 23, 42, 0.12);
background: #ffffff;
cursor: pointer;
transition: all 240ms ease;
box-shadow: 0 18px 36px -24px rgba(2, 6, 23, 0.5);
box-shadow: 0 18px 36px -24px rgba(2, 6, 23, 0.25);
}
.role-card::before {
@ -4665,7 +4861,7 @@ body {
position: absolute;
inset: -1px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(253, 98, 22, 0), rgba(253, 98, 22, 0.15));
background: linear-gradient(135deg, rgba(253, 98, 22, 0), rgba(253, 98, 22, 0.08));
opacity: 0;
transition: opacity 240ms ease;
pointer-events: none;
@ -4674,9 +4870,9 @@ body {
.role-card:hover {
border-color: rgba(253, 98, 22, 0.6);
background: rgba(255, 255, 255, 0.15);
background: #ffffff;
transform: translateY(-6px);
box-shadow: 0 24px 52px -20px rgba(253, 98, 22, 0.45);
box-shadow: 0 24px 52px -20px rgba(253, 98, 22, 0.3);
}
.role-card:hover::before {
@ -4685,8 +4881,8 @@ body {
.role-card.selected {
border-color: #fd6216;
background: rgba(253, 98, 22, 0.2);
box-shadow: 0 0 0 2px rgba(253, 98, 22, 0.3) inset, 0 24px 52px -20px rgba(253, 98, 22, 0.5);
background: #ffffff;
box-shadow: 0 0 0 2px rgba(253, 98, 22, 0.3) inset, 0 24px 52px -20px rgba(253, 98, 22, 0.3);
}
.role-card:disabled {
@ -4694,23 +4890,40 @@ body {
cursor: not-allowed;
}
.role-icon {
font-size: clamp(36px, 6vw, 48px);
line-height: 1;
.role-image {
display: block;
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #f8fafc;
box-shadow: 0 10px 24px -16px rgba(15, 23, 42, 0.35);
transition: transform 500ms ease;
}
.role-media {
width: 96px;
height: 96px;
overflow: hidden;
border-radius: 14px;
}
.role-card:hover .role-image {
transform: scale(1.08);
}
.role-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #fff;
color: #0f172a;
}
.role-description {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
color: #475569;
line-height: 1.5;
flex-grow: 1;
}
@ -4732,7 +4945,7 @@ body {
text-align: center;
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.choose-role-footer .footer-text {

View file

@ -4,6 +4,7 @@ import { Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js';
type PublicHeaderProps = {
loginHref?: string;
signupHref?: string;
showAuthActions?: boolean;
};
const isRouteActive = (pathname: string, href: string) => {
@ -19,6 +20,7 @@ export default function PublicHeader(props: PublicHeaderProps) {
const loginHref = () => props.loginHref || '/auth/login';
const signupHref = () => props.signupHref || '/auth/register';
const showAuthActions = () => props.showAuthActions !== false;
onMount(() => {
const onScroll = () => setScrolled(window.scrollY > 10);
@ -46,10 +48,12 @@ export default function PublicHeader(props: PublicHeaderProps) {
<A class="nav-underline" classList={{ active: isRouteActive(location.pathname, '/contact') }} href="/contact">Contact Us</A>
</div>
<div class="desktop-only nav-actions">
<A class="nav-auth-btn nav-auth-secondary" href={loginHref()}>Login</A>
<A class="nav-auth-btn nav-auth-primary" href={signupHref()}>Sign Up</A>
</div>
<Show when={showAuthActions()}>
<div class="desktop-only nav-actions">
<A class="nav-auth-btn nav-auth-secondary" href={loginHref()}>Login</A>
<A class="nav-auth-btn nav-auth-primary" href={signupHref()}>Sign Up</A>
</div>
</Show>
<button
type="button"
@ -76,10 +80,12 @@ export default function PublicHeader(props: PublicHeaderProps) {
<A href="/help-center" onClick={() => setMobileOpen(false)}>Help Center</A>
<A href="/contact" onClick={() => setMobileOpen(false)}>Contact Us</A>
</div>
<div class="mobile-nav-actions container">
<A class="mobile-login" href={loginHref()} onClick={() => setMobileOpen(false)}>Login</A>
<A class="mobile-signup" href={signupHref()} onClick={() => setMobileOpen(false)}>Sign Up</A>
</div>
<Show when={showAuthActions()}>
<div class="mobile-nav-actions container">
<A class="mobile-login" href={loginHref()} onClick={() => setMobileOpen(false)}>Login</A>
<A class="mobile-signup" href={signupHref()} onClick={() => setMobileOpen(false)}>Sign Up</A>
</div>
</Show>
</div>
</Show>
</header>

View file

@ -9,6 +9,7 @@ export default createHandler(() => (
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<style>{`html,body,#app{min-height:100%;margin:0}body{background:#07051a} .dashboard-shell{background:#f6f8ff;min-height:100vh} .lp-main,.auth-page,.choose-role-page{background:#07051a;min-height:100vh}`}</style>
{assets}
</head>
<body>

View file

@ -17,6 +17,18 @@ describe('schemaIdFromInput', () => {
expect(schemaIdFromInput('jobseeker', '')).toBe('jobseeker_onboarding_v1');
});
it('maps professional sub-role keys directly', () => {
expect(schemaIdFromInput('PHOTOGRAPHER', '')).toBe('photographer_onboarding_v1');
expect(schemaIdFromInput('MAKEUP_ARTIST', '')).toBe('makeup_artist_onboarding_v1');
expect(schemaIdFromInput('TUTOR', '')).toBe('tutor_onboarding_v1');
expect(schemaIdFromInput('DEVELOPER', '')).toBe('developer_onboarding_v1');
expect(schemaIdFromInput('VIDEO_EDITOR', '')).toBe('video_editor_onboarding_v1');
expect(schemaIdFromInput('GRAPHIC_DESIGNER', '')).toBe('graphic_designer_onboarding_v1');
expect(schemaIdFromInput('SOCIAL_MEDIA_MANAGER', '')).toBe('social_media_manager_onboarding_v1');
expect(schemaIdFromInput('FITNESS_TRAINER', '')).toBe('fitness_trainer_onboarding_v1');
expect(schemaIdFromInput('CATERING_SERVICES', '')).toBe('catering_services_onboarding_v1');
});
it('maps professional schema with profession key', () => {
expect(schemaIdFromInput('PROFESSIONAL', 'Photographer')).toBe('photographer_onboarding_v1');
expect(schemaIdFromInput('professional', 'Social Media Manager')).toBe('social_media_manager_onboarding_v1');

View file

@ -31,6 +31,15 @@ export function schemaIdFromInput(roleKey: string, profession: string) {
if (normalizedRole === 'CUSTOMER') return 'customer_onboarding_v1';
if (normalizedRole === 'COMPANY') return 'company_onboarding_v1';
if (normalizedRole === 'JOB_SEEKER' || normalizedRole === 'JOBSEEKER') return 'jobseeker_onboarding_v1';
if (normalizedRole === 'PHOTOGRAPHER') return 'photographer_onboarding_v1';
if (normalizedRole === 'MAKEUP_ARTIST') return 'makeup_artist_onboarding_v1';
if (normalizedRole === 'TUTOR') return 'tutor_onboarding_v1';
if (normalizedRole === 'DEVELOPER') return 'developer_onboarding_v1';
if (normalizedRole === 'VIDEO_EDITOR') return 'video_editor_onboarding_v1';
if (normalizedRole === 'GRAPHIC_DESIGNER') return 'graphic_designer_onboarding_v1';
if (normalizedRole === 'SOCIAL_MEDIA_MANAGER') return 'social_media_manager_onboarding_v1';
if (normalizedRole === 'FITNESS_TRAINER') return 'fitness_trainer_onboarding_v1';
if (normalizedRole === 'CATERING_SERVICES') return 'catering_services_onboarding_v1';
if (normalizedRole === 'PROFESSIONAL' && normalizedProfession) return `${normalizedProfession}_onboarding_v1`;
if (normalizedRole === 'PROFESSIONAL') return 'professional_onboarding_v1';
return '';

View file

@ -1,4 +1,6 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
type VerificationFlow = 'register' | 'login';
@ -11,6 +13,37 @@ type VerificationRecord = {
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
const MAX_ATTEMPTS = 5;
const records = new Map<string, VerificationRecord>();
const STORE_FILE = path.join(process.cwd(), '.vinxi', 'verification-codes.json');
function loadRecords(): void {
try {
if (!fs.existsSync(STORE_FILE)) return;
const raw = fs.readFileSync(STORE_FILE, 'utf8');
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, VerificationRecord>;
for (const [key, value] of Object.entries(parsed || {})) {
if (!value || typeof value !== 'object') continue;
const expiresAt = Number(value.expiresAt || 0);
const attempts = Number(value.attempts || 0);
const codeHash = String(value.codeHash || '');
if (!codeHash || !Number.isFinite(expiresAt) || !Number.isFinite(attempts)) continue;
records.set(key, { codeHash, expiresAt, attempts });
}
} catch {
// Ignore corrupt persistence and continue with empty in-memory store.
}
}
function persistRecords(): void {
try {
const dir = path.dirname(STORE_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const payload = Object.fromEntries(records.entries());
fs.writeFileSync(STORE_FILE, JSON.stringify(payload), 'utf8');
} catch {
// Persistence best effort in local/dev context.
}
}
function hashCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex');
@ -22,11 +55,18 @@ function keyFor(email: string, flow: VerificationFlow): string {
function cleanupExpired(): void {
const now = Date.now();
let changed = false;
for (const [key, record] of records.entries()) {
if (record.expiresAt <= now) records.delete(key);
if (record.expiresAt <= now) {
records.delete(key);
changed = true;
}
}
if (changed) persistRecords();
}
loadRecords();
export function createVerificationCode(input: { email: string; flow: VerificationFlow }) {
cleanupExpired();
const email = input.email.toLowerCase().trim();
@ -39,6 +79,7 @@ export function createVerificationCode(input: { email: string; flow: Verificatio
expiresAt,
attempts: 0,
});
persistRecords();
return {
code,
@ -57,20 +98,24 @@ export function consumeVerificationCode(input: { email: string; flow: Verificati
if (record.expiresAt <= Date.now()) {
records.delete(key);
persistRecords();
return { ok: false, reason: 'EXPIRED' };
}
if (record.attempts >= MAX_ATTEMPTS) {
records.delete(key);
persistRecords();
return { ok: false, reason: 'TOO_MANY_ATTEMPTS' };
}
if (record.codeHash !== hashCode(input.code.trim())) {
record.attempts += 1;
records.set(key, record);
persistRecords();
return { ok: false, reason: 'INVALID_CODE' };
}
records.delete(key);
persistRecords();
return { ok: true };
}

View file

@ -2,6 +2,12 @@ const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_U
export function gatewayUrl(path: string) {
const normalized = path.startsWith('/') ? path : `/${path}`;
if (gatewayBase.endsWith('/api')) {
if (normalized === '/api') return gatewayBase;
if (normalized.startsWith('/api/')) {
return `${gatewayBase}${normalized.slice(4)}`;
}
}
return `${gatewayBase}${normalized}`;
}

View file

@ -1,9 +1,10 @@
const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080';
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
const url = new URL(request.url);
const roleKey = String(url.searchParams.get('roleKey') || '').trim().toUpperCase();
const schemaId = String(url.searchParams.get('schemaId') || '').trim();
if (!roleKey) {
return new Response(JSON.stringify({ success: false, error: 'roleKey is required' }), {
@ -12,17 +13,80 @@ export async function GET({ request }: { request: Request }) {
});
}
const configRes = await fetch(`${RUST_API_URL}/api/admin/onboarding-config/by-key/${roleKey}`);
if (!configRes.ok) {
return new Response(JSON.stringify({ success: false, error: 'Onboarding config not found for this role' }), {
status: configRes.status,
const roleKeyCandidates = Array.from(
new Set([
roleKey,
roleKey.replace(/_/g, ''),
roleKey.replace(/_/g, '-'),
].filter(Boolean)),
);
const authHeaders = withAuthHeaders(request, { Accept: 'application/json', 'x-portal-target': 'public' });
let lastStatus = 404;
let lastError = `Onboarding config not found for role ${roleKey}.`;
let schemaJson: any = null;
const rustBase = (import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080').replace(/\/+$/, '');
for (const candidate of roleKeyCandidates) {
const endpoints = Array.from(
new Set([
gatewayUrl(`/admin/onboarding-config/by-key/${encodeURIComponent(candidate)}`),
`${rustBase}/api/admin/onboarding-config/by-key/${encodeURIComponent(candidate)}`,
`${rustBase}/admin/onboarding-config/by-key/${encodeURIComponent(candidate)}`,
]),
);
for (const endpoint of endpoints) {
let configRes: Response;
try {
configRes = await fetch(endpoint, {
method: 'GET',
headers: authHeaders,
cache: 'no-store',
});
} catch (error: any) {
lastStatus = 503;
lastError = `Onboarding backend unavailable. Could not reach ${endpoint}. Start gateway/backend services and retry.`;
continue;
}
const config = await configRes.json().catch(() => ({}));
if (!configRes.ok) {
lastStatus = configRes.status || lastStatus;
lastError = String(config?.error || config?.message || lastError);
continue;
}
const parsedSchema = config?.schema_json || config?.schemaJson || null;
if (!parsedSchema) {
lastStatus = 500;
lastError = `Runtime onboarding schema is missing for role ${candidate}.`;
continue;
}
schemaJson = parsedSchema;
break;
}
if (schemaJson) break;
}
if (!schemaJson) {
const errorMessage = lastStatus === 503
? lastError
: `${lastError} (role=${roleKey}${schemaId ? `, schemaId=${schemaId}` : ''})`;
return new Response(JSON.stringify({
success: false,
error: errorMessage,
...(schemaId ? { schemaId } : {}),
}), {
status: lastStatus,
headers: { 'Content-Type': 'application/json' },
});
}
const config = await configRes.json();
return new Response(JSON.stringify({ success: true, data: config.schema_json }), {
return new Response(JSON.stringify({ success: true, data: schemaJson }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});

View file

@ -1,4 +1,9 @@
const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080';
const API_BASE = (
process.env.NEXT_PUBLIC_API_URL
|| process.env.PUBLIC_API_URL
|| import.meta.env.VITE_RUST_API_URL
|| 'http://localhost:3005/api'
).replace(/\/+$/, '');
export async function POST({ request }: { request: Request }) {
try {
@ -12,7 +17,7 @@ export async function POST({ request }: { request: Request }) {
});
}
const res = await fetch(`${RUST_API_URL}/api/auth/check-email`, {
const res = await fetch(`${API_BASE}/auth/check-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
@ -31,8 +36,15 @@ export async function POST({ request }: { request: Request }) {
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
const message = String(error?.message || '');
const unavailable = message.toLowerCase().includes('fetch failed') || message.toLowerCase().includes('econnrefused');
return new Response(JSON.stringify({
success: false,
error: unavailable
? `Auth backend unavailable at ${API_BASE}. Start Rust gateway/service and retry.`
: (error?.message || 'Internal Server Error'),
}), {
status: unavailable ? 503 : 500,
headers: { 'Content-Type': 'application/json' },
});
}

View file

@ -1,4 +1,9 @@
const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080';
const API_BASE = (
process.env.NEXT_PUBLIC_API_URL
|| process.env.PUBLIC_API_URL
|| import.meta.env.VITE_RUST_API_URL
|| 'http://localhost:3005/api'
).replace(/\/+$/, '');
export async function POST({ request }: { request: Request }) {
try {
@ -10,7 +15,7 @@ export async function POST({ request }: { request: Request }) {
password: payload.password,
};
const res = await fetch(`${RUST_API_URL}/api/auth/login`, {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rustPayload)
@ -33,8 +38,15 @@ export async function POST({ request }: { request: Request }) {
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
const message = String(error?.message || '');
const unavailable = message.toLowerCase().includes('fetch failed') || message.toLowerCase().includes('econnrefused');
return new Response(JSON.stringify({
success: false,
error: unavailable
? `Auth backend unavailable at ${API_BASE}. Start Rust gateway/service and retry.`
: (error?.message || 'Internal Server Error'),
}), {
status: unavailable ? 503 : 500,
headers: { 'Content-Type': 'application/json' },
});
}

View file

@ -1,4 +1,9 @@
const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080';
const API_BASE = (
process.env.NEXT_PUBLIC_API_URL
|| process.env.PUBLIC_API_URL
|| import.meta.env.VITE_RUST_API_URL
|| 'http://localhost:3005/api'
).replace(/\/+$/, '');
export async function POST({ request }: { request: Request }) {
try {
@ -6,16 +11,27 @@ export async function POST({ request }: { request: Request }) {
// Convert to Rust expected format
let roleKey = payload.intent === 'company' ? 'COMPANY' : 'CUSTOMER';
if (payload.intent === 'jobseeker') roleKey = 'JOBSEEKER';
if (payload.intent === 'job_seeker' || payload.intent === 'job-seeker' || payload.intent === 'jobseeker') roleKey = 'JOB_SEEKER';
if (payload.intent === 'professional') roleKey = 'PHOTOGRAPHER'; // default fallback for now if none specified
const firstName = String(payload.firstName || payload.first_name || '').trim();
const lastName = String(payload.lastName || payload.last_name || '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
|| String(payload.name || payload.full_name || '').trim();
if (!fullName) {
return new Response(JSON.stringify({ success: false, error: 'Full name is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const rustPayload = {
full_name: fullName,
email: payload.email,
password: payload.password,
role_key: roleKey
};
const res = await fetch(`${RUST_API_URL}/api/auth/register`, {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rustPayload)
@ -38,8 +54,15 @@ export async function POST({ request }: { request: Request }) {
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
const message = String(error?.message || '');
const unavailable = message.toLowerCase().includes('fetch failed') || message.toLowerCase().includes('econnrefused');
return new Response(JSON.stringify({
success: false,
error: unavailable
? `Auth backend unavailable at ${API_BASE}. Start Rust gateway/service and retry.`
: (error?.message || 'Internal Server Error'),
}), {
status: unavailable ? 503 : 500,
headers: { 'Content-Type': 'application/json' },
});
}

View file

@ -0,0 +1,43 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const otp = String(body?.otp || '').trim();
if (!otp) {
return new Response(
JSON.stringify({ success: false, error: 'OTP is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
const res = await fetch(gatewayUrl('/api/auth/verify-email'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ otp }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return new Response(
JSON.stringify({
success: false,
error: data?.error || data?.message || 'Failed to verify email',
code: data?.code || null,
}),
{ status: res.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(
JSON.stringify({ success: true, message: data?.message || 'Email verified successfully' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (error: any) {
return new Response(
JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}

View file

@ -61,6 +61,8 @@ export async function POST({ request }: { request: Request }) {
? 'Invalid verification code'
: result.reason === 'TOO_MANY_ATTEMPTS'
? 'Too many attempts. Please request a new code.'
: result.reason === 'NOT_FOUND'
? 'Verification session not found. Please request a new code.'
: 'Verification code expired. Please request a new code.';
return new Response(JSON.stringify({ success: false, message }), {

View file

@ -208,7 +208,43 @@ export default function RegisterPage() {
const exists = await checkEmailExists(email());
if (exists) {
setError('This email is already registered. Please sign in or use another email.');
const normalizedEmail = email().trim().toLowerCase();
const fullName = `${firstName().trim()} ${lastName().trim()}`.trim();
// Keep pending credentials so verification page can auto-login after OTP.
window.localStorage.setItem(
PENDING_REGISTER_KEY,
JSON.stringify({
firstName: firstName().trim(),
lastName: lastName().trim(),
name: fullName,
email: normalizedEmail,
password: password(),
userType: getRegistrationExtras(resolvedIntent).userType,
intent: resolvedIntent,
redirect: resolvedRedirect(),
}),
);
// Existing account may be unverified; resend backend OTP and continue.
const resendResponse = await fetch('/api/users/auth/verification/resend-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: normalizedEmail }),
});
const resendPayload = await resendResponse.json().catch(() => ({}));
if (!resendResponse.ok || !resendPayload?.success) {
setError('This email is already registered. Please sign in or use another email.');
return;
}
const next = new URLSearchParams({
email: normalizedEmail,
flow: 'register',
intent: resolvedIntent || 'customer',
redirect: resolvedRedirect(),
});
navigate(`/auth/verification?${next.toString()}`);
return;
}
@ -251,31 +287,32 @@ export default function RegisterPage() {
const normalizedEmail = email().trim().toLowerCase();
saveCanonicalIntent(resolvedIntent);
const verificationResponse = await fetch('/api/users/auth/verification/request-code', {
const fullName = `${firstName().trim()} ${lastName().trim()}`.trim();
const registerResponse = await fetch('/api/users/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: normalizedEmail, flow: 'register' }),
body: JSON.stringify({
firstName: firstName().trim(),
lastName: lastName().trim(),
name: fullName,
email: normalizedEmail,
password: password(),
userType: getRegistrationExtras(resolvedIntent).userType,
intent: resolvedIntent,
}),
});
const verificationPayload = await verificationResponse.json().catch(() => ({}));
if (!verificationResponse.ok || !verificationPayload?.success) {
setError(verificationPayload?.error || verificationPayload?.message || 'Failed to send verification code.');
setLoading(false);
const registerPayload = await registerResponse.json().catch(() => ({}));
if (!registerResponse.ok || !registerPayload?.success) {
setError(registerPayload?.error || registerPayload?.message || 'Registration failed.');
return;
}
const debugCode = String(verificationPayload?.data?.debugCode || '').trim();
if (debugCode) {
window.localStorage.setItem(
DEV_VERIFICATION_CODE_KEY,
JSON.stringify({ email: normalizedEmail, flow: 'register', code: debugCode, createdAt: Date.now() }),
);
}
const fullName = `${firstName().trim()} ${lastName().trim()}`.trim();
window.localStorage.setItem(
PENDING_REGISTER_KEY,
JSON.stringify({
firstName: firstName().trim(),
lastName: lastName().trim(),
name: fullName,
email: normalizedEmail,
password: password(),

View file

@ -9,6 +9,8 @@ const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1';
const DEV_VERIFICATION_CODE_KEY = 'nxtgauge_dev_verification_code_v1';
type PendingRegisterPayload = {
firstName?: string;
lastName?: string;
name: string;
email: string;
password: string;
@ -47,6 +49,10 @@ function readDevVerificationCode(email: string, flow: string): string {
}
}
function getApiErrorMessage(payload: any): string {
return String(payload?.error || payload?.message || payload?.code || '').trim();
}
export default function VerificationPage() {
const navigate = useNavigate();
const [search] = useSearchParams();
@ -61,6 +67,24 @@ export default function VerificationPage() {
const resolvedIntent = createMemo(() => intent() || readCanonicalIntent());
const registerTarget = createMemo(() => (resolvedIntent() ? intentToOnboardingPath(resolvedIntent()) : '/dashboard'));
const chooseRoleTarget = createMemo(() => {
const params = new URLSearchParams();
if (resolvedIntent()) params.set('intent', String(resolvedIntent()));
const profession = String(search.profession || '').trim();
if (profession) params.set('profession', profession);
const qs = params.toString();
return `/users/choose-role${qs ? `?${qs}` : ''}`;
});
const backHref = createMemo(() => {
const params = new URLSearchParams();
if (resolvedIntent()) params.set('intent', String(resolvedIntent()));
if (redirect()) params.set('redirect', String(redirect()));
const profession = String(search.profession || '').trim();
if (profession) params.set('profession', profession);
const qs = params.toString();
if (flow() === 'register') return `/auth/register${qs ? `?${qs}` : ''}`;
return `/auth/login${qs ? `?${qs}` : ''}`;
});
const [otp, setOtp] = createSignal(Array.from({ length: OTP_LENGTH }, () => ''));
const [timer, setTimer] = createSignal(30);
@ -99,25 +123,7 @@ export default function VerificationPage() {
}
try {
const registerResponse = await fetch('/api/users/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: pending.name,
email: pending.email,
password: pending.password,
...(typeof pending.userType === 'number' ? { userType: pending.userType } : {}),
}),
});
const registerPayload = await registerResponse.json().catch(() => ({}));
if (!registerResponse.ok || !registerPayload?.success) {
setError(String(registerPayload?.error || registerPayload?.message || 'Registration failed.'));
setLoading(false);
return;
}
// Auto login right after
// Auto login after successful backend OTP verification
const loginResponse = await fetch('/api/users/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -127,7 +133,8 @@ export default function VerificationPage() {
const loginPayload = await loginResponse.json().catch(() => ({}));
if (!loginResponse.ok || !loginPayload?.success) {
setError('Email verified and account created. Please sign in to continue.');
const loginError = getApiErrorMessage(loginPayload);
setError(loginError || 'Email verified and account created. Please sign in to continue.');
setLoading(false);
return;
}
@ -139,11 +146,11 @@ export default function VerificationPage() {
window.localStorage.removeItem(PENDING_REGISTER_KEY);
window.localStorage.removeItem(DEV_VERIFICATION_CODE_KEY);
navigate(pending.redirect || redirect() || registerTarget(), { replace: true });
navigate(chooseRoleTarget(), { replace: true });
return;
} catch (err: any) {
setError('Registration failed: ' + err.message);
setError('Login failed: ' + err.message);
setLoading(false);
return;
}
@ -172,14 +179,20 @@ export default function VerificationPage() {
setInfo('');
try {
const response = await fetch('/api/users/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email(), code, flow: flow() }),
});
const response = flow() === 'register'
? await fetch('/api/users/auth/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ otp: code }),
})
: await fetch('/api/users/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email(), code, flow: flow() }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
setError(payload?.message || payload?.error || 'Invalid verification code.');
setError(getApiErrorMessage(payload) || 'Invalid verification code.');
setLoading(false);
return;
}
@ -198,15 +211,21 @@ export default function VerificationPage() {
setInfo('');
try {
const response = await fetch('/api/users/auth/verification/request-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email(), flow: flow() }),
});
const response = flow() === 'register'
? await fetch('/api/users/auth/verification/resend-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email() }),
})
: await fetch('/api/users/auth/verification/request-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email(), flow: flow() }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
setError(payload?.message || payload?.error || 'Failed to resend verification code.');
setError(getApiErrorMessage(payload) || 'Failed to resend verification code.');
setIsResending(false);
return;
}
@ -267,9 +286,12 @@ export default function VerificationPage() {
<button class="btn" onClick={resend} disabled={timer() > 0 || isResending()}>
{timer() > 0 ? `Resend in ${timer()}s` : isResending() ? 'Resending...' : 'Resend code'}
</button>
<A class="btn" href={backHref()}>Back</A>
</div>
<p class="note">Back to <A href="/auth/login">Sign In</A></p>
<p class="note">
Back to {flow() === 'register' ? <A href={backHref()}>Sign Up</A> : <A href={backHref()}>Sign In</A>}
</p>
</section>
</div>
</main>

View file

@ -1,8 +1,10 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import { authState } from '~/lib/auth';
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, UploadedFileMeta } from '~/lib/runtime/types';
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeOption, UploadedFileMeta } from '~/lib/runtime/types';
import { evaluateVisibility, normalizeRoleKey, schemaIdFromInput } from '~/lib/onboarding-flow';
import { isValidEmail } from '~/lib/form-validation';
import PublicBackground from '~/components/PublicBackground';
function isEmptyValue(value: unknown) {
if (value == null) return true;
@ -11,34 +13,126 @@ function isEmptyValue(value: unknown) {
return false;
}
function parseBoolean(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes';
}
return false;
}
const ROLE_SKILL_OPTIONS: Record<string, string[]> = {
DEVELOPER: ['JavaScript', 'TypeScript', 'React', 'SolidJS', 'Node.js', 'Rust', 'PostgreSQL', 'Docker'],
PHOTOGRAPHER: ['Portrait Photography', 'Wedding Photography', 'Event Photography', 'Photo Editing', 'Studio Lighting', 'Product Photography'],
MAKEUP_ARTIST: ['Bridal Makeup', 'Party Makeup', 'Editorial Makeup', 'Airbrush Makeup', 'Skincare Prep', 'Hairstyling'],
TUTOR: ['Mathematics', 'Science', 'English', 'Coding', 'Test Preparation', 'Language Training'],
VIDEO_EDITOR: ['Adobe Premiere Pro', 'Final Cut Pro', 'DaVinci Resolve', 'Motion Graphics', 'Color Grading', 'Short-form Content'],
GRAPHIC_DESIGNER: ['Brand Identity', 'Logo Design', 'Social Media Design', 'UI Design', 'Typography', 'Illustration'],
SOCIAL_MEDIA_MANAGER: ['Content Strategy', 'Content Calendar', 'Community Management', 'Performance Analytics', 'Paid Campaigns', 'Copywriting'],
FITNESS_TRAINER: ['Weight Training', 'Fat Loss', 'Functional Training', 'Mobility', 'Nutrition Basics', 'Online Coaching'],
CATERING_SERVICES: ['Wedding Catering', 'Corporate Catering', 'Live Counters', 'Menu Planning', 'Bulk Orders', 'Event Logistics'],
COMPANY: ['Hiring', 'Team Management', 'Project Planning', 'Operations', 'Client Communication', 'Growth Strategy'],
JOB_SEEKER: ['Communication', 'Problem Solving', 'Teamwork', 'Time Management', 'Project Ownership', 'Adaptability'],
CUSTOMER: ['Home Services', 'Event Services', 'Education Services', 'Tech Services', 'Beauty Services', 'Fitness Services'],
};
function skillOptionsForRole(roleKey: string): RuntimeOption[] {
const normalized = String(roleKey || '').trim().toUpperCase();
const options = ROLE_SKILL_OPTIONS[normalized] || [];
return options.map((entry) => ({ label: entry, value: entry }));
}
function isSkillFieldCandidate(fieldId: unknown, fieldLabel: unknown): boolean {
const id = String(fieldId || '').toLowerCase();
const label = String(fieldLabel || '').toLowerCase();
return id.includes('skill') || label.includes('skill');
}
function isWorkModeFieldCandidate(fieldId: unknown, fieldLabel: unknown): boolean {
const id = String(fieldId || '').toLowerCase();
const label = String(fieldLabel || '').toLowerCase();
return id.includes('work_mode') || id.includes('workmode') || label.includes('work mode');
}
function isAlwaysEditableField(fieldId: unknown): boolean {
const id = String(fieldId || '').trim().toLowerCase();
return id === 'preferred_role';
}
function isForcedTextField(fieldId: unknown): boolean {
const id = String(fieldId || '').trim().toLowerCase();
return id === 'preferred_role';
}
function validateField(field: RuntimeOnboardingField, value: unknown): string | null {
if (field.required && isEmptyValue(value)) return `${field.label} is required.`;
if (field.required && isEmptyValue(value)) {
if (field.type === 'checkbox') return 'Please enable this option.';
if (field.type === 'file') return 'Please upload the file.';
if (field.type === 'select') return 'Please select an option.';
if (field.type === 'date') return 'Please select the date.';
return 'Please fill this field.';
}
if (isEmptyValue(value)) return null;
if (field.type === 'number') {
const numeric = Number(value);
if (Number.isNaN(numeric)) return `${field.label} must be a number.`;
if (typeof field.validation?.min === 'number' && numeric < field.validation.min) return `${field.label} must be at least ${field.validation.min}.`;
if (typeof field.validation?.max === 'number' && numeric > field.validation.max) return `${field.label} must be at most ${field.validation.max}.`;
if (Number.isNaN(numeric)) return 'Please enter a valid number.';
if (typeof field.validation?.min === 'number' && numeric < field.validation.min) return `Please enter a value of at least ${field.validation.min}.`;
if (typeof field.validation?.max === 'number' && numeric > field.validation.max) return `Please enter a value of at most ${field.validation.max}.`;
}
if (typeof value === 'string') {
if (field.type === 'email' && !isValidEmail(value)) {
return 'Please enter a valid email address (e.g., user@example.com).';
}
if (field.type === 'tel') {
const telRegex = /^\+?[0-9\s\-()]{7,20}$/;
if (!telRegex.test(value.trim())) {
return 'Please enter a valid phone number.';
}
}
if (field.type === 'url') {
try {
const parsed = new URL(value);
if (!/^https?:$/.test(parsed.protocol)) return 'Please enter a URL starting with http:// or https://.';
} catch {
return 'Please enter a valid URL.';
}
}
if (field.type === 'date' && Number.isNaN(Date.parse(value))) {
return 'Please select a valid date.';
}
if (typeof field.validation?.minLength === 'number' && value.length < field.validation.minLength) {
return `${field.label} must be at least ${field.validation.minLength} characters.`;
if (field.type === 'textarea') return `Please fill the description, min ${field.validation.minLength} characters.`;
return `Please enter at least ${field.validation.minLength} characters.`;
}
if (typeof field.validation?.maxLength === 'number' && value.length > field.validation.maxLength) {
return `${field.label} must be at most ${field.validation.maxLength} characters.`;
return `Please enter no more than ${field.validation.maxLength} characters.`;
}
if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) {
return `${field.label} format is invalid.`;
return 'Please enter a valid value.';
}
}
if (field.type === 'select') {
const options = (field.options || []).map((opt) => opt.value);
if (field.multiple) {
const values = Array.isArray(value) ? value : [];
const invalid = values.find((entry) => !options.includes(String(entry)));
if (invalid) return 'Please select a valid option.';
} else {
const selected = String(value || '');
if (selected && !options.includes(selected)) return 'Please select a valid option.';
}
}
if (field.type === 'file') {
const files = value as UploadedFileMeta[] | undefined;
const length = files?.length || 0;
if (field.required && length === 0) return `${field.label} is required.`;
if (typeof field.maxFiles === 'number' && length > field.maxFiles) return `${field.label} allows only ${field.maxFiles} file(s).`;
if (field.required && length === 0) return 'Please upload the file.';
if (typeof field.maxFiles === 'number' && length > field.maxFiles) return `Please upload at most ${field.maxFiles} file(s).`;
}
return null;
@ -79,11 +173,19 @@ function normalizeSchemaPayload(payload: any, schemaId: string, roleKey: string)
...(field?.validation && typeof field.validation === 'object' ? field.validation : {}),
};
const roleSkills = skillOptionsForRole(roleKey);
const shouldForceSkillSelect = isSkillFieldCandidate(field?.id, field?.label);
const useSkillOptions = shouldForceSkillSelect && roleSkills.length > 0;
const useWorkModeSelect = isWorkModeFieldCandidate(field?.id, field?.label) && Array.isArray(options) && options.length > 0;
const forceText = isForcedTextField(field?.id);
return {
...field,
type,
options,
readOnly: Boolean(field?.readOnly ?? field?.readonly),
type: forceText ? 'text' : ((useSkillOptions || useWorkModeSelect) ? 'select' : type),
options: forceText ? undefined : (useSkillOptions ? roleSkills : options),
readOnly: isAlwaysEditableField(field?.id) ? false : parseBoolean(field?.readOnly ?? field?.readonly),
multiple: forceText ? false : (useSkillOptions ? true : (useWorkModeSelect ? false : parseBoolean(field?.multiple))),
...(useSkillOptions ? { placeholder: 'Select one or more options' } : {}),
validation: Object.keys(validation).length > 0 ? validation : undefined,
};
}) : [];
@ -109,6 +211,7 @@ export default function OnboardingPage() {
const [schema, setSchema] = createSignal<RuntimeOnboardingConfig | null>(null);
const [values, setValues] = createSignal<Record<string, unknown>>({});
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [touched, setTouched] = createSignal<Record<string, boolean>>({});
const [stepIndex, setStepIndex] = createSignal(0);
const [statusMessage, setStatusMessage] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
@ -126,6 +229,16 @@ export default function OnboardingPage() {
if (fromQuery) return fromQuery;
return schemaIdFromInput(effectiveRoleKey(), requestedProfession());
});
const userIdentity = createMemo(() => {
const fullName = String(authState().runtime_config?.user?.full_name || '').trim();
if (!fullName) return { fullName: '', firstName: '', lastName: '' };
const parts = fullName.split(/\s+/).filter(Boolean);
const firstName = parts[0] || '';
const lastName = parts.slice(1).join(' ');
return { fullName, firstName, lastName };
});
const isManagedNameField = (fieldId: string) => ['full_name', 'first_name', 'last_name'].includes(fieldId);
const isLockedProfileField = (fieldId: string) => ['full_name', 'first_name', 'last_name', 'city'].includes(fieldId);
onMount(async () => {
try {
@ -155,7 +268,14 @@ export default function OnboardingPage() {
const initialValues: Record<string, unknown> = {};
normalized.steps.forEach((step) => {
step.fields.forEach((field) => {
if (field.defaultValue !== undefined) initialValues[field.id] = field.defaultValue;
if (field.id === 'full_name') initialValues[field.id] = userIdentity().fullName;
else if (field.id === 'first_name') initialValues[field.id] = userIdentity().firstName;
else if (field.id === 'last_name') initialValues[field.id] = userIdentity().lastName;
else if (field.multiple && Array.isArray(field.defaultValue)) initialValues[field.id] = field.defaultValue;
else if (field.multiple && typeof field.defaultValue === 'string') {
initialValues[field.id] = field.defaultValue.split(',').map((entry) => entry.trim()).filter(Boolean);
}
else if (field.defaultValue !== undefined) initialValues[field.id] = field.defaultValue;
else if (field.multiple) initialValues[field.id] = [];
else initialValues[field.id] = field.type === 'checkbox' ? false : '';
});
@ -176,6 +296,8 @@ export default function OnboardingPage() {
if (profileResponse.ok && profilePayload?.success) {
setProfileStatus(String(profilePayload?.data?.profileStatus || ''));
}
} catch (error: any) {
setStatusMessage(String(error?.message || 'Unable to load onboarding flow right now.'));
} finally {
setLoading(false);
}
@ -199,6 +321,38 @@ export default function OnboardingPage() {
if (total === 0) return '0 / 0';
return `${Math.min(stepIndex() + 1, total)} / ${total}`;
});
const progressPercent = createMemo(() => {
const total = visibleSteps().length;
if (total <= 0) return 0;
const current = Math.min(stepIndex() + 1, total);
return Math.round((current / total) * 100);
});
const roleWelcome = createMemo(() => {
const role = String(schema()?.roleKey || '').toUpperCase();
if (role === 'COMPANY') {
return {
title: "Let's build your company profile",
subtitle: 'A few quick details and you will be ready to post opportunities.',
};
}
if (role === 'JOB_SEEKER' || role === 'JOBSEEKER') {
return {
title: "Let's shape your job profile",
subtitle: 'Share your details so we can match you with better opportunities.',
};
}
if (role === 'CUSTOMER') {
return {
title: "Let's get your request journey started",
subtitle: 'Tell us what you need, and we will help you connect with trusted professionals.',
};
}
return {
title: "Let's build your professional profile",
subtitle: 'Add your details once and start receiving stronger, relevant opportunities.',
};
});
const setFieldValue = (fieldId: string, next: unknown) => {
setValues((prev) => ({ ...prev, [fieldId]: next }));
@ -209,10 +363,84 @@ export default function OnboardingPage() {
});
};
const validateSingleField = (field: RuntimeOnboardingField, nextValue: unknown) => {
if (isLockedProfileField(field.id)) return true;
const message = validateField(field, nextValue);
setErrors((prev) => {
const copy = { ...prev };
if (message) copy[field.id] = message;
else delete copy[field.id];
return copy;
});
return !message;
};
const markFieldTouched = (fieldId: string) => {
setTouched((prev) => ({ ...prev, [fieldId]: true }));
};
const markCurrentStepTouched = () => {
const marks: Record<string, boolean> = {};
visibleFields().forEach((field) => {
marks[field.id] = true;
});
setTouched((prev) => ({ ...prev, ...marks }));
};
const handleFieldInput = (field: RuntimeOnboardingField, nextValue: unknown) => {
setFieldValue(field.id, nextValue);
if (touched()[field.id]) validateSingleField(field, nextValue);
};
const handleFieldBlur = (field: RuntimeOnboardingField, nextValue?: unknown) => {
markFieldTouched(field.id);
validateSingleField(field, nextValue ?? values()[field.id]);
};
const validationNote = (field: RuntimeOnboardingField) => {
if (isLockedProfileField(field.id)) return null;
const value = values()[field.id];
const hasValue = !isEmptyValue(value);
const message = validateField(field, value);
if (message) return { text: message, tone: 'error' as const };
if (!hasValue) {
if (field.type === 'select') return { text: 'Please select an option.', tone: 'hint' as const };
if (field.type === 'date') return { text: 'Please select the date.', tone: 'hint' as const };
if (field.type === 'file') return { text: 'Please upload the file.', tone: 'hint' as const };
if (field.type === 'checkbox') return { text: 'Please enable this option.', tone: 'hint' as const };
if (field.type === 'textarea') return { text: 'Please fill the description.', tone: 'hint' as const };
return { text: 'Please fill this field.', tone: 'hint' as const };
}
if (field.type === 'email') return { text: 'Valid email format', tone: 'ok' as const };
if (field.type === 'url') return { text: 'Valid URL format', tone: 'ok' as const };
if (field.type === 'tel') return { text: 'Valid phone format', tone: 'ok' as const };
if (field.type === 'date') return { text: 'Great, date looks good', tone: 'ok' as const };
if (field.type === 'number') return { text: 'Great, that looks good', tone: 'ok' as const };
if (field.type === 'checkbox') return { text: 'Option enabled', tone: 'ok' as const };
if (field.type === 'file') {
const files = Array.isArray(value) ? value : [];
return { text: `${files.length} file(s) selected`, tone: 'ok' as const };
}
if (field.type === 'select') {
if (field.multiple) {
const selected = Array.isArray(value) ? value : [];
return { text: `${selected.length} option(s) selected`, tone: 'ok' as const };
}
return { text: 'Option selected', tone: 'ok' as const };
}
if (typeof value === 'string' && field.validation?.minLength) {
return { text: `${value.length} characters entered`, tone: 'ok' as const };
}
return { text: 'Looks good', tone: 'ok' as const };
};
const validateCurrentStep = () => {
const fieldList = visibleFields();
const nextErrors: Record<string, string> = {};
fieldList.forEach((field) => {
if (isManagedNameField(field.id)) return;
const message = validateField(field, values()[field.id]);
if (message) nextErrors[field.id] = message;
});
@ -236,6 +464,7 @@ export default function OnboardingPage() {
};
const goNext = async () => {
markCurrentStepTouched();
if (!validateCurrentStep()) {
setStatusMessage('Please fix the highlighted fields.');
return;
@ -254,6 +483,7 @@ export default function OnboardingPage() {
};
const submit = async () => {
markCurrentStepTouched();
if (!validateCurrentStep()) {
setStatusMessage('Please fix the highlighted fields.');
return;
@ -261,13 +491,20 @@ export default function OnboardingPage() {
const currentSchema = schema();
if (!currentSchema) return;
const payloadData = {
...values(),
...(userIdentity().fullName ? { full_name: userIdentity().fullName } : {}),
...(userIdentity().firstName ? { first_name: userIdentity().firstName } : {}),
...(userIdentity().lastName ? { last_name: userIdentity().lastName } : {}),
};
const response = await fetch('/api/runtime/onboarding/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
roleKey: currentSchema.roleKey,
requiresApproval: true,
dataJson: values(),
dataJson: payloadData,
}),
});
const payload = await response.json().catch(() => ({}));
@ -281,35 +518,99 @@ export default function OnboardingPage() {
const renderField = (field: RuntimeOnboardingField) => {
const value = values()[field.id];
const lockedIdentityField = isLockedProfileField(field.id);
const fieldReadOnly = lockedIdentityField && !isAlwaysEditableField(field.id);
if (field.type === 'textarea') {
return (
<textarea
class="textarea"
value={String(value || '')}
readOnly={field.readOnly}
readOnly={Boolean(fieldReadOnly || lockedIdentityField)}
placeholder={field.placeholder || field.label}
onInput={(e) => setFieldValue(field.id, e.currentTarget.value)}
onInput={(e) => handleFieldInput(field, e.currentTarget.value)}
onBlur={(e) => {
const nextValue = e.currentTarget.value;
handleFieldBlur(field, nextValue);
}}
/>
);
}
if (field.type === 'select') {
const useOptionCardUi = isSkillFieldCandidate(field.id, field.label) || isWorkModeFieldCandidate(field.id, field.label);
if (field.multiple) {
const selected = Array.isArray(value) ? (value as string[]) : [];
const disabled = fieldReadOnly || lockedIdentityField;
const toggleOption = (optionValue: string) => {
if (disabled) return;
const exists = selected.includes(optionValue);
const next = exists ? selected.filter((entry) => entry !== optionValue) : [...selected, optionValue];
markFieldTouched(field.id);
handleFieldInput(field, next);
validateSingleField(field, next);
};
return (
<select
class="select"
multiple
value={selected}
disabled={field.readOnly}
onInput={(e) => {
const selectedValues = Array.from(e.currentTarget.selectedOptions).map((opt) => opt.value);
setFieldValue(field.id, selectedValues);
}}
>
<For each={field.options || []}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
<div class={`multi-select-grid${disabled ? ' is-disabled' : ''}`} role="group" aria-label={field.label}>
<For each={field.options || []}>
{(option) => {
const active = selected.includes(option.value);
return (
<button
type="button"
class={`multi-select-option${active ? ' is-selected' : ''}`}
disabled={disabled}
aria-pressed={active}
onClick={() => toggleOption(option.value)}
>
<span class="multi-select-option-text">{option.label}</span>
<span class={`multi-select-tick${active ? ' is-visible' : ''}`} aria-hidden="true">
<svg viewBox="0 0 20 20" role="img" focusable="false">
<path d="M4 10.5l4 4 8-9" />
</svg>
</span>
</button>
);
}}
</For>
</div>
);
}
if (useOptionCardUi) {
const selected = String(value || '');
const disabled = fieldReadOnly || lockedIdentityField;
const selectOption = (optionValue: string) => {
if (disabled) return;
markFieldTouched(field.id);
handleFieldInput(field, optionValue);
validateSingleField(field, optionValue);
};
return (
<div class={`multi-select-grid${disabled ? ' is-disabled' : ''}`} role="radiogroup" aria-label={field.label}>
<For each={field.options || []}>
{(option) => {
const active = selected === option.value;
return (
<button
type="button"
class={`multi-select-option${active ? ' is-selected' : ''}`}
disabled={disabled}
role="radio"
aria-checked={active}
onClick={() => selectOption(option.value)}
>
<span class="multi-select-option-text">{option.label}</span>
<span class={`multi-select-tick${active ? ' is-visible' : ''}`} aria-hidden="true">
<svg viewBox="0 0 20 20" role="img" focusable="false">
<path d="M4 10.5l4 4 8-9" />
</svg>
</span>
</button>
);
}}
</For>
</div>
);
}
@ -317,8 +618,9 @@ export default function OnboardingPage() {
<select
class="select"
value={String(value || '')}
disabled={field.readOnly}
onInput={(e) => setFieldValue(field.id, e.currentTarget.value)}
disabled={fieldReadOnly || lockedIdentityField}
onInput={(e) => handleFieldInput(field, e.currentTarget.value)}
onBlur={(e) => handleFieldBlur(field, e.currentTarget.value)}
>
<option value="">Select {field.label}</option>
<For each={field.options || []}>{(option) => <option value={option.value}>{option.label}</option>}</For>
@ -332,8 +634,9 @@ export default function OnboardingPage() {
<input
type="checkbox"
checked={Boolean(value)}
disabled={field.readOnly}
onInput={(e) => setFieldValue(field.id, e.currentTarget.checked)}
disabled={fieldReadOnly || lockedIdentityField}
onInput={(e) => handleFieldInput(field, e.currentTarget.checked)}
onBlur={(e) => handleFieldBlur(field, e.currentTarget.checked)}
/>
<span>{field.helperText || field.label}</span>
</label>
@ -352,7 +655,7 @@ export default function OnboardingPage() {
onInput={(e) => {
const selected = Array.from(e.currentTarget.files || []);
if (selected.length === 0) {
setFieldValue(field.id, []);
handleFieldInput(field, []);
return;
}
@ -376,8 +679,9 @@ export default function OnboardingPage() {
uploadedAt: new Date().toISOString(),
}));
setFieldValue(field.id, mapped);
handleFieldInput(field, mapped);
}}
onBlur={() => handleFieldBlur(field, values()[field.id])}
/>
<Show when={files.length > 0}>
<p class="note ok">Uploaded {files.length} file(s) </p>
@ -399,78 +703,152 @@ export default function OnboardingPage() {
? 'url'
: 'text';
if (lockedIdentityField) {
return (
<div class="locked-input-wrap">
<input
class="input locked-input"
type={inputType}
value={typeof value === 'string' || typeof value === 'number' ? String(value) : ''}
readOnly
disabled
placeholder={field.placeholder || field.label}
onInput={(e) => handleFieldInput(field, e.currentTarget.value)}
onBlur={(e) => handleFieldBlur(field, e.currentTarget.value)}
/>
<span class="locked-input-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
</svg>
</span>
</div>
);
}
return (
<input
class="input"
type={inputType}
value={typeof value === 'string' || typeof value === 'number' ? String(value) : ''}
readOnly={field.readOnly}
readOnly={Boolean(fieldReadOnly)}
placeholder={field.placeholder || field.label}
onInput={(e) => setFieldValue(field.id, e.currentTarget.value)}
onInput={(e) => handleFieldInput(field, e.currentTarget.value)}
onBlur={(e) => {
const nextValue = e.currentTarget.value;
handleFieldBlur(field, nextValue);
}}
/>
);
};
return (
<main class="page">
<h1 class="title">Runtime Onboarding</h1>
<p class="subtitle">Schema-driven and backend-connected flow.</p>
<Show when={profileStatus()}><p class="note">Profile status: {profileStatus()}</p></Show>
<Show when={!loading()} fallback={<section class="card"><p>Loading runtime onboarding schema...</p></section>}>
<main class="auth-page onboarding-auth-page">
<PublicBackground />
<div class="auth-layout auth-layout-single onboarding-auth-layout">
<Show when={!loading()} fallback={<section class="auth-form card glass-light onboarding-auth-form"><p>Loading runtime onboarding schema...</p></section>}>
<Show
when={schema()}
fallback={<section class="card"><p>{statusMessage() || 'No onboarding schema available for this role yet.'}</p></section>}
fallback={<section class="auth-form card glass-light onboarding-auth-form"><p>{statusMessage() || 'No onboarding schema available for this role yet.'}</p></section>}
>
<Show
when={!submitted()}
fallback={
<section class="card">
<h2>Verification in Progress</h2>
<section class="auth-form card glass-light onboarding-auth-form">
<h2 class="title">Verification in Progress</h2>
<p class="subtitle">Your documents have been submitted. Please wait for 24-48 hours for profile approval.</p>
</section>
}
>
<div class="grid">
<section class="card">
<section class="auth-form card glass-light onboarding-auth-form">
<h1 class="title">{roleWelcome().title}</h1>
<p class="subtitle">{roleWelcome().subtitle}</p>
<Show when={profileStatus()}><p class="note">Profile status: {profileStatus()}</p></Show>
<span class="step-pill">Step {progressText()}</span>
<div class="onboarding-progress" aria-hidden="true">
<div class="onboarding-progress-fill" style={{ width: `${progressPercent()}%` }} />
</div>
<h2>{activeStep()?.title}</h2>
<p class="subtitle">{activeStep()?.description || 'Fill required details and continue.'}</p>
<Show when={activeStep()?.description}>
<p class="subtitle">{activeStep()?.description}</p>
</Show>
<For each={visibleFields()}>
{(field) => (
<div class="field">
<label class="label">
{field.label}
{field.required ? ' *' : ''}
</label>
{renderField(field)}
<Show when={field.helperText}><p class="note">{field.helperText}</p></Show>
<Show when={errors()[field.id]}><p class="error">{errors()[field.id]}</p></Show>
</div>
<Show
when={field.id === 'full_name'}
fallback={
<div class="field">
<label class="label">
{field.label}
{field.required ? ' *' : ''}
</label>
{renderField(field)}
<Show when={validationNote(field) && !errors()[field.id]}>
{(note) => (
<p
class="validation-note"
style={{
color:
note().tone === 'ok'
? '#fd6216'
: '#6e7591',
}}
>
{note().tone === 'ok' ? `${note().text}` : note().text}
</p>
)}
</Show>
<Show when={field.helperText}><p class="note">{field.helperText}</p></Show>
<Show when={errors()[field.id]}><p class="error">{errors()[field.id]}</p></Show>
</div>
}
>
<>
<div class="field">
<label class="label">FIRST NAME</label>
<div class="locked-input-wrap">
<input class="input locked-input" value={userIdentity().firstName} readOnly disabled />
<span class="locked-input-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
</svg>
</span>
</div>
</div>
<div class="field">
<label class="label">LAST NAME</label>
<div class="locked-input-wrap">
<input class="input locked-input" value={userIdentity().lastName} readOnly disabled />
<span class="locked-input-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
</svg>
</span>
</div>
</div>
</>
</Show>
)}
</For>
<div class="actions">
<div class="actions onboarding-auth-actions">
<button class="btn" disabled={stepIndex() === 0} onClick={goBack}>Back</button>
<Show
when={stepIndex() < visibleSteps().length - 1}
fallback={<button class="btn primary" onClick={submit}>Submit</button>}
fallback={<button class="auth-submit-btn onboarding-primary-btn" onClick={submit}>Submit</button>}
>
<button class="btn primary" onClick={goNext}>Next</button>
<button class="auth-submit-btn onboarding-primary-btn" onClick={goNext}>Next</button>
</Show>
</div>
<Show when={statusMessage()}><p class="error">{statusMessage()}</p></Show>
</section>
<section class="card">
<h3>Runtime Schema</h3>
<pre class="preview">{JSON.stringify(schema(), null, 2)}</pre>
</section>
</div>
</section>
</Show>
</Show>
</Show>
</div>
</main>
);
}

View file

@ -1,13 +1,14 @@
import { useNavigate } from '@solidjs/router';
import { createSignal, For } from 'solid-js';
import { For } from 'solid-js';
import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader';
import PublicFooter from '~/components/PublicFooter';
type RoleOption = {
id: string;
title: string;
description: string;
icon: string;
image: string;
intent: string;
isSubtype?: boolean;
};
@ -17,51 +18,60 @@ const MAIN_ROLES: RoleOption[] = [
id: 'company',
title: 'Company',
description: 'Post jobs and hire verified professionals',
icon: '🏢',
image: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?q=80&w=800&auto=format&fit=crop',
intent: 'company',
},
{
id: 'job_seeker',
title: 'Job Seeker',
description: 'Browse and apply for job opportunities',
icon: '💼',
image: 'https://images.unsplash.com/photo-1586281380349-632531db7ed4?q=80&w=800&auto=format&fit=crop',
intent: 'job_seeker',
},
{
id: 'customer',
title: 'Customer',
title: 'Service Seeker',
description: 'Find verified professionals for your needs',
icon: '🛍️',
image: 'https://images.unsplash.com/photo-1450101499163-c8848c66ca85?q=80&w=800&auto=format&fit=crop',
intent: 'customer',
},
];
const PROFESSIONAL_SUBTYPES: RoleOption[] = [
{ id: 'photographer', title: 'Photographer', description: 'Photography & Visual Content', icon: '📸', intent: 'professional', isSubtype: true },
{ id: 'makeup_artist', title: 'Makeup Artist', description: 'Makeup & Beauty Services', icon: '💄', intent: 'professional', isSubtype: true },
{ id: 'tutor', title: 'Tutor', description: 'Online & Offline Tutoring', icon: '📚', intent: 'professional', isSubtype: true },
{ id: 'developer', title: 'Developer', description: 'Software Development & Coding', icon: '💻', intent: 'professional', isSubtype: true },
{ id: 'video_editor', title: 'Video Editor', description: 'Video Editing & Production', icon: '🎬', intent: 'professional', isSubtype: true },
{ id: 'graphic_designer', title: 'Graphic Designer', description: 'Design & Branding Services', icon: '🎨', intent: 'professional', isSubtype: true },
{ id: 'social_media_manager', title: 'Social Media Manager', description: 'Social Media & Content Management', icon: '📱', intent: 'professional', isSubtype: true },
{ id: 'fitness_trainer', title: 'Fitness Trainer', description: 'Fitness & Personal Training', icon: '💪', intent: 'professional', isSubtype: true },
{ id: 'catering_services', title: 'Catering Services', description: 'Food & Catering Services', icon: '🍽️', intent: 'professional', isSubtype: true },
{ id: 'photographer', title: 'Photographer', description: 'Photography & Visual Content', image: 'https://images.unsplash.com/photo-1516035069371-29a1b244cc32?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'makeup_artist', title: 'Makeup Artist', description: 'Makeup & Beauty Services', image: 'https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'tutor', title: 'Tutor', description: 'Online & Offline Tutoring', image: 'https://images.unsplash.com/photo-1497633762265-9d179a990aa6?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'developer', title: 'Developer', description: 'Software Development & Coding', image: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'video_editor', title: 'Video Editor', description: 'Video Editing & Production', image: 'https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'graphic_designer', title: 'Graphic Designer', description: 'Design & Branding Services', image: 'https://images.unsplash.com/photo-1558655146-d09347e92766?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'social_media_manager', title: 'Social Media Manager', description: 'Social Media & Content Management', image: 'https://images.unsplash.com/photo-1611162618071-b39a2ec055fb?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'fitness_trainer', title: 'Fitness Trainer', description: 'Fitness & Personal Training', image: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
{ id: 'catering_services', title: 'Catering Services', description: 'Food & Catering Services', image: 'https://images.unsplash.com/photo-1555244162-803834f70033?q=80&w=800&auto=format&fit=crop', intent: 'professional', isSubtype: true },
];
export default function ChooseRolePage() {
const navigate = useNavigate();
const [selectedRole, setSelectedRole] = createSignal<RoleOption | null>(null);
const mainRoleRouteMap: Record<string, string> = {
company: '/users/onboarding/company',
job_seeker: '/users/onboarding/job-seeker',
customer: '/users/onboarding/customer',
};
const handleSelectRole = (role: RoleOption) => {
setSelectedRole(role);
// Navigate to appropriate onboarding page
if (role.isSubtype) {
// Professional roles with subtype
navigate(`/users/onboarding/professional?profession=${role.id}&intent=${role.intent}`);
const params = new URLSearchParams({
profession: role.id,
intent: role.intent,
});
navigate(`/users/onboarding/professional?${params.toString()}`);
} else {
// Main roles
navigate(`/users/onboarding/${role.id}?intent=${role.intent}`);
const route = mainRoleRouteMap[role.id] || '/users/onboarding/customer';
const params = new URLSearchParams({ intent: role.intent });
navigate(`${route}?${params.toString()}`);
}
};
@ -74,24 +84,29 @@ export default function ChooseRolePage() {
<div class="container choose-role-container">
<div class="choose-role-header">
<h1 class="choose-role-title">What would you like to do today?</h1>
<p class="choose-role-subtitle">Select your role to get started with Nxtgauge</p>
</div>
{/* Main Roles Section */}
<div class="choose-role-section">
<h2 class="section-title">Choose Your Primary Role</h2>
<div class="roles-grid main-roles-grid">
<div class="path-grid path-grid-3">
<For each={MAIN_ROLES}>
{(role) => (
<button
class="role-card"
classList={{ selected: selectedRole()?.id === role.id }}
class="path-card path-card-hero role-path-card"
onClick={() => handleSelectRole(role)}
>
<div class="role-icon">{role.icon}</div>
<h3 class="role-title">{role.title}</h3>
<p class="role-description">{role.description}</p>
<div class="role-cta">Select Role </div>
<div class="path-media">
<img src={role.image} alt={role.title} loading="lazy" />
<div class="path-media-overlay" />
</div>
<div class="path-body">
<div class="path-head-row">
<span class="path-chip">Primary role</span>
</div>
<h3>{role.title}</h3>
<p>{role.description}</p>
<div class="path-secondary-btn role-path-cta">Get Started</div>
</div>
</button>
)}
</For>
@ -102,28 +117,34 @@ export default function ChooseRolePage() {
<div class="choose-role-section">
<h2 class="section-title">Or Explore Professional Opportunities</h2>
<p class="section-subtitle">Register as a verified professional in your field</p>
<div class="roles-grid professional-roles-grid">
<div class="path-grid path-grid-3">
<For each={PROFESSIONAL_SUBTYPES}>
{(role) => (
<button
class="role-card professional-card"
classList={{ selected: selectedRole()?.id === role.id }}
class="path-card path-card-hero role-path-card professional-card"
onClick={() => handleSelectRole(role)}
>
<div class="role-icon">{role.icon}</div>
<h3 class="role-title">{role.title}</h3>
<p class="role-description">{role.description}</p>
<div class="role-cta">Select Role </div>
<div class="path-media">
<img src={role.image} alt={role.title} loading="lazy" />
<div class="path-media-overlay" />
</div>
<div class="path-body">
<div class="path-head-row">
<span class="path-chip">Professional</span>
</div>
<h3>{role.title}</h3>
<p>{role.description}</p>
<div class="path-secondary-btn role-path-cta">Get Started</div>
</div>
</button>
)}
</For>
</div>
</div>
<div class="choose-role-footer">
<p class="footer-text">You can always add more roles to your account later from your dashboard.</p>
</div>
</div>
<PublicFooter />
</main>
);
}