Refine onboarding UX, role cards, and runtime schema integration
BIN
public/images/roles/better_graphic.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/images/roles/better_job.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/images/roles/better_service.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/images/roles/catering_services.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/images/roles/company.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/images/roles/developer.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
public/images/roles/fitness_trainer.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/images/roles/graphic_designer.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
public/images/roles/job_seeker.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/images/roles/makeup_artist.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/images/roles/photographer.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/images/roles/service_seeker.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/images/roles/social_media_manager.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/images/roles/test_camera.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/images/roles/test_fitness.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
public/images/roles/test_job.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/images/roles/test_makeup.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/images/roles/test_makeup2.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/roles/test_makeup3.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/images/roles/test_makeup4.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/images/roles/test_service.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/images/roles/test_social.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/images/roles/test_social2.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/images/roles/test_video.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/images/roles/test_video2.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
public/images/roles/tutor.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/images/roles/video_editor.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
261
src/app.css
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
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) {
|
||||
return new Response(JSON.stringify({ success: false, error: 'Onboarding config not found for this role' }), {
|
||||
status: configRes.status,
|
||||
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' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
43
src/routes/api/users/auth/verify-email.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }), {
|
||||
|
|
|
|||
|
|
@ -208,10 +208,46 @@ export default function RegisterPage() {
|
|||
|
||||
const exists = await checkEmailExists(email());
|
||||
if (exists) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!password()) {
|
||||
setError('Password is required.');
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
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,7 +211,13 @@ export default function VerificationPage() {
|
|||
setInfo('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/auth/verification/request-code', {
|
||||
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() }),
|
||||
|
|
@ -206,7 +225,7 @@ export default function VerificationPage() {
|
|||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
<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)}
|
||||
>
|
||||
<For each={field.options || []}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
<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) => (
|
||||
<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>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||