feat(dashboard-parity): DashboardLayout runtime-config nav, auth hardening, onboarding UX

- DashboardLayout.tsx: fully runtime-config driven sidebar nav using
  MODULE_NAV_MAP, role switcher, guided tour spotlight overlay, responsive
  mobile drawer
- auth.ts: hardened JWT access token handling, httpOnly cookie refresh flow
- guided-tour-content.ts: expanded per-role tour steps for all modules
- gateway.ts: improved proxy with Set-Cookie forwarding for refresh token
- onboarding/complete.ts + schema.ts: refined onboarding completion flow
- login.ts + register.ts: cleaner error handling and response forwarding
- dashboard/index.tsx: role-based KPI cards from runtime-config
- jobs/index.tsx: status filters, post job action gated by requiresJobApproval
- marketplace/index.tsx + [id].tsx: leads browsing with tracecoin hold display
- requirements/index.tsx + [id].tsx: post requirement with profession-specific
  conditional fields, budget/timeline/mode, resubmit support
- portfolio/index.tsx: CRUD for photographer portfolio projects
- services/index.tsx: service management for marketplace professionals
- applications/index.tsx: jobseeker applied jobs list
- notifications.tsx: all/unread tabs, mark read, deep link routing
- settings.tsx: change password form
- wallet/index.tsx + ledger.tsx: tracecoin balance and transaction history
- onboarding.tsx: multi-step onboarding form with profession branching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar 2026-03-23 00:45:51 +01:00
parent 7baa38aa97
commit d67e436828
22 changed files with 674 additions and 245 deletions

View file

@ -1,6 +1,6 @@
import { Component, Show, createEffect, For, createSignal } from 'solid-js';
import { useNavigate, A } from '@solidjs/router';
import { authState, logout, switchRole } from '~/lib/auth';
import { Component, Show, createEffect, For, createSignal, onMount } from 'solid-js';
import { useNavigate, A, useSearchParams } from '@solidjs/router';
import { authState, logout, switchRole, bootstrapAuth, setMockRuntimeConfig } from '~/lib/auth';
import { shouldShowRoleSwitcher } from '~/lib/auth-flow';
import {
getRoleTourStorageKey,
@ -52,74 +52,283 @@ const IconCompass = () => (
<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>
</svg>
);
const IconPortfolio = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
);
const IconServices = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
);
const IconWallet = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M20 12V8H6a2 2 0 0 1-2-2c0-1.1.9-2 2-2h12v4"/><path d="M4 6v12c0 1.1.9 2 2 2h14v-4"/><path d="M18 12a2 2 0 0 0-2 2c0 1.1.9 2 2 2h4v-4h-4z"/>
</svg>
);
// ── Module → nav item mapping ─────────────────────────────────────────────────
const MODULE_NAV_MAP: Record<string, { label: string; href: string; icon: Component }> = {
// Uppercase module keys
COMPANY_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
JOBSEEKER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
CUSTOMER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
PROFESSIONAL_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
JOBS: { label: 'Jobs', href: '/dashboard/jobs', icon: IconJobs },
JOBS_BROWSE: { label: 'Browse Jobs', href: '/dashboard/jobs', icon: IconJobs },
APPLICATIONS: { label: 'Applications', href: '/dashboard/applications', icon: IconJobs },
MY_APPLICATIONS: { label: 'My Applications', href: '/dashboard/applications', icon: IconJobs },
REQUIREMENTS: { label: 'Requirements', href: '/dashboard/requirements', icon: IconJobs },
MARKETPLACE: { label: 'Marketplace', href: '/dashboard/marketplace', icon: IconCompass },
MY_REQUESTS: { label: 'My Requests', href: '/dashboard/requests', icon: IconJobs },
ACCEPTED_LEADS: { label: 'Accepted Leads',href: '/dashboard/leads/accepted', icon: IconJobs },
PORTFOLIO: { label: 'Portfolio', href: '/dashboard/portfolio', icon: IconJobs },
SERVICES: { label: 'Services', href: '/dashboard/services', icon: IconJobs },
WALLET: { label: 'Wallet', href: '/dashboard/wallet', icon: IconCompass },
COMPANY_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings },
JOBSEEKER_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings },
CUSTOMER_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings },
PROFESSIONAL_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings },
PURCHASE_PACKAGES: { label: 'Buy Packages', href: '/dashboard/packages', icon: IconCompass },
NOTIFICATIONS: { label: 'Notifications', href: '/dashboard/notifications', icon: IconBell },
SETTINGS: { label: 'Settings', href: '/dashboard/settings', icon: IconSettings },
EXPLORE_NXTGAUGE: { label: 'Explore Nxtgauge', href: '/dashboard/explore', icon: IconCompass },
const MODULE_NAV_MAP: Record<string, { label: string; href: string; icon: Component; tourId: string }> = {
// lowercase keys (from seed/runtime config)
dashboard: { label: 'My Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
jobs: { label: 'Jobs', href: '/dashboard/jobs', icon: IconJobs, tourId: 'jobs' },
applications: { label: 'Applications', href: '/dashboard/applications', icon: IconJobs, tourId: 'applications' },
my_applications: { label: 'My Applications', href: '/dashboard/applications', icon: IconJobs, tourId: 'applications' },
browse_jobs: { label: 'Browse Jobs', href: '/dashboard/jobs', icon: IconJobs, tourId: 'jobs' },
profile: { label: 'My Profile', href: '/dashboard/profile', icon: IconSettings, tourId: 'profile' },
requirements: { label: 'Requirements', href: '/dashboard/requirements', icon: IconJobs, tourId: 'requirements' },
marketplace: { label: 'Marketplace', href: '/dashboard/marketplace', icon: IconCompass, tourId: 'marketplace' },
leads: { label: 'My Leads', href: '/dashboard/requests', icon: IconJobs, tourId: 'requests' },
portfolio: { label: 'Portfolio', href: '/dashboard/portfolio', icon: IconPortfolio, tourId: 'portfolio' },
services: { label: 'Services', href: '/dashboard/services', icon: IconServices, tourId: 'services' },
wallet: { label: 'Wallet', href: '/dashboard/wallet', icon: IconWallet, tourId: 'wallet' },
notifications: { label: 'Notifications', href: '/dashboard/notifications', icon: IconBell, tourId: 'notifications' },
settings: { label: 'Settings', href: '/dashboard/settings', icon: IconSettings, tourId: 'settings' },
// Lowercase module keys (from seed/runtime config)
jobs: { label: 'Jobs', href: '/dashboard/jobs', icon: IconJobs },
applications: { label: 'Applications', href: '/dashboard/applications', icon: IconJobs },
profile: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings },
browse_jobs: { label: 'Browse Jobs', href: '/dashboard/jobs', icon: IconJobs },
my_applications: { label: 'My Applications',href: '/dashboard/applications', icon: IconJobs },
requirements: { label: 'Requirements', href: '/dashboard/requirements', icon: IconJobs },
marketplace: { label: 'Marketplace', href: '/dashboard/marketplace', icon: IconCompass },
leads: { label: 'My Leads', href: '/dashboard/requests', icon: IconJobs },
portfolio: { label: 'Portfolio', href: '/dashboard/portfolio', icon: IconJobs },
services: { label: 'Services', href: '/dashboard/services', icon: IconJobs },
wallet: { label: 'Wallet', href: '/dashboard/wallet', icon: IconCompass },
notifications: { label: 'Notifications', href: '/dashboard/notifications', icon: IconBell },
settings: { label: 'Settings', href: '/dashboard/settings', icon: IconSettings },
// Uppercase fallbacks
COMPANY_DASHBOARD: { label: 'My Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
JOBSEEKER_DASHBOARD: { label: 'My Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
CUSTOMER_DASHBOARD: { label: 'My Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
PROFESSIONAL_DASHBOARD: { label: 'My Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
JOBS: { label: 'Jobs', href: '/dashboard/jobs', icon: IconJobs, tourId: 'jobs' },
APPLICATIONS: { label: 'Applications', href: '/dashboard/applications', icon: IconJobs, tourId: 'applications' },
REQUIREMENTS: { label: 'Requirements', href: '/dashboard/requirements', icon: IconJobs, tourId: 'requirements' },
MARKETPLACE: { label: 'Marketplace', href: '/dashboard/marketplace', icon: IconCompass, tourId: 'marketplace' },
MY_REQUESTS: { label: 'My Leads', href: '/dashboard/requests', icon: IconJobs, tourId: 'requests' },
ACCEPTED_LEADS: { label: 'Accepted Leads', href: '/dashboard/leads/accepted',icon: IconJobs, tourId: 'leads' },
PORTFOLIO: { label: 'Portfolio', href: '/dashboard/portfolio', icon: IconPortfolio, tourId: 'portfolio' },
SERVICES: { label: 'Services', href: '/dashboard/services', icon: IconServices, tourId: 'services' },
WALLET: { label: 'Wallet', href: '/dashboard/wallet', icon: IconWallet, tourId: 'wallet' },
NOTIFICATIONS: { label: 'Notifications', href: '/dashboard/notifications', icon: IconBell, tourId: 'notifications' },
SETTINGS: { label: 'Settings', href: '/dashboard/settings', icon: IconSettings, tourId: 'settings' },
EXPLORE_NXTGAUGE: { label: 'Explore', href: '/dashboard/explore', icon: IconCompass, tourId: 'explore' },
};
// ── Spotlight Tour Overlay ────────────────────────────────────────────────────
function SpotlightOverlay(props: {
step: GuidedTourStep;
stepIndex: number;
total: number;
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}) {
const PAD = 10;
const TW = 300; // tooltip width
const TH = 230; // tooltip approx height
type Rect = { x: number; y: number; w: number; h: number };
const [rect, setRect] = createSignal<Rect | null>(null);
// Re-measure spotlight target whenever the step changes
createEffect(() => {
const target = props.step.target;
void props.stepIndex; // track index changes too
requestAnimationFrame(() => {
if (!target) { setRect(null); return; }
const el = document.querySelector(target);
if (!el) { setRect(null); return; }
const r = el.getBoundingClientRect();
setRect({
x: Math.max(0, r.left - PAD),
y: Math.max(0, r.top - PAD),
w: r.width + PAD * 2,
h: r.height + PAD * 2,
});
});
});
const tooltipStyle = () => {
const r = rect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (!r) return { top: `${(vh - TH) / 2}px`, left: `${(vw - TW) / 2}px` };
const clampY = (y: number) => Math.max(12, Math.min(y, vh - TH - 12));
const clampX = (x: number) => Math.max(12, Math.min(x, vw - TW - 12));
// Right
if (r.x + r.w + TW + 20 <= vw)
return { top: `${clampY(r.y + r.h / 2 - TH / 2)}px`, left: `${r.x + r.w + 20}px` };
// Below
if (r.y + r.h + TH + 20 <= vh)
return { top: `${r.y + r.h + 20}px`, left: `${clampX(r.x + r.w / 2 - TW / 2)}px` };
// Above
if (r.y - TH - 20 >= 0)
return { top: `${r.y - TH - 20}px`, left: `${clampX(r.x + r.w / 2 - TW / 2)}px` };
// Left
return { top: `${clampY(r.y + r.h / 2 - TH / 2)}px`, left: `${clampX(r.x - TW - 20)}px` };
};
const progress = () => Math.round(((props.stepIndex + 1) / props.total) * 100);
return (
<>
{/* ── Dark overlay with spotlight cutout ── */}
<svg
style={{
position: 'fixed', inset: '0',
width: '100vw', height: '100vh',
'z-index': '9000', 'pointer-events': 'none',
transition: 'opacity 0.2s',
}}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask id="tour-spotlight-mask">
<rect width="100%" height="100%" fill="white" />
<Show when={rect()}>
<rect
fill="black"
x={rect()!.x} y={rect()!.y}
width={rect()!.w} height={rect()!.h}
rx="10"
/>
</Show>
</mask>
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.72)" mask="url(#tour-spotlight-mask)" />
{/* Orange border around spotlight */}
<Show when={rect()}>
<rect
x={rect()!.x} y={rect()!.y}
width={rect()!.w} height={rect()!.h}
rx="10" fill="none"
stroke="#fd6216" stroke-width="2.5" opacity="0.9"
/>
</Show>
</svg>
{/* ── Tooltip card ── */}
<div
role="dialog"
aria-modal="true"
aria-label="Guided Tour"
style={{
position: 'fixed',
'z-index': '9001',
width: `${TW}px`,
background: '#ffffff',
'border-radius': '16px',
padding: '22px 24px 20px',
'box-shadow': '0 24px 64px rgba(0,0,0,0.28), 0 0 0 1px rgba(0,0,0,0.06)',
...tooltipStyle(),
}}
>
{/* Header */}
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '10px' }}>
<span style={{
'font-size': '10px', 'font-weight': '800', 'text-transform': 'uppercase',
color: '#fd6216', 'letter-spacing': '0.1em',
}}>
Guided Tour
</span>
<span style={{ 'font-size': '11px', color: '#94a3b8', 'font-weight': '600' }}>
{props.stepIndex + 1} / {props.total}
</span>
</div>
{/* Title */}
<h3 style={{
margin: '0 0 8px', 'font-size': '15px', 'font-weight': '800',
color: '#0f172a', 'line-height': '1.35',
}}>
{props.step.title}
</h3>
{/* Body */}
<p style={{
margin: '0 0 16px', 'font-size': '13px',
color: '#475569', 'line-height': '1.65',
}}>
{props.step.body}
</p>
{/* Progress bar */}
<div style={{
height: '3px', background: '#f1f5f9',
'border-radius': '2px', 'margin-bottom': '16px', overflow: 'hidden',
}}>
<div style={{
height: '100%', background: '#fd6216', 'border-radius': '2px',
width: `${progress()}%`, transition: 'width 0.35s ease',
}} />
</div>
{/* Buttons */}
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
<button
style={{
padding: '8px 12px', border: '1.5px solid #e2e8f0', 'border-radius': '8px',
background: 'white', cursor: 'pointer', 'font-size': '12px', color: '#64748b',
'font-weight': '500',
}}
onClick={props.onSkip}
>
Skip
</button>
<Show when={props.stepIndex > 0}>
<button
style={{
padding: '8px 14px', border: '1.5px solid #e2e8f0', 'border-radius': '8px',
background: 'white', cursor: 'pointer', 'font-size': '12px', color: '#0f172a',
'font-weight': '600',
}}
onClick={props.onBack}
>
Back
</button>
</Show>
<button
style={{
flex: '1', padding: '9px 14px', background: '#fd6216', color: '#ffffff',
border: 'none', 'border-radius': '8px', cursor: 'pointer',
'font-weight': '700', 'font-size': '13px', transition: 'opacity 0.15s',
}}
onClick={props.onNext}
>
{props.stepIndex >= props.total - 1 ? '✓ Done' : 'Next →'}
</button>
</div>
</div>
</>
);
}
// ── Dashboard Layout ──────────────────────────────────────────────────────────
export default function DashboardLayout(props: { children: any }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [switchingRole, setSwitchingRole] = createSignal(false);
const [tourKind, setTourKind] = createSignal<GuidedTourKind | null>(null);
const [tourStepIndex, setTourStepIndex] = createSignal(0);
const [authReady, setAuthReady] = createSignal(false);
// Restore session or inject mock preview config
onMount(async () => {
const preview = String(searchParams._preview || '').toUpperCase();
if (preview) {
setMockRuntimeConfig(preview);
} else {
await bootstrapAuth();
}
setAuthReady(true);
});
createEffect(() => {
if (!authReady()) return;
const s = authState();
if (!s.access_token) {
navigate('/auth/login', { replace: true });
return;
}
if (s.runtime_config?.onboarding_required) {
navigate('/onboarding', { replace: true });
return;
}
});
const rc = () => authState().runtime_config;
const roleOptions = () => rc()?.user?.roles ?? [];
const activeRole = () => rc()?.user?.active_role ?? rc()?.role ?? '';
const activeTourSteps = (): GuidedTourStep[] => {
const kind = tourKind();
if (!kind) return [];
@ -131,12 +340,11 @@ export default function DashboardLayout(props: { children: any }) {
const navItems = () => {
if (rc()?.role === 'USER') {
return [
{ label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
{ label: 'Explore Nxtgauge', href: '/dashboard/explore', icon: IconCompass },
{ label: 'Settings', href: '/dashboard/settings', icon: IconSettings },
{ label: 'My Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
{ label: 'Explore', href: '/dashboard/explore', icon: IconCompass, tourId: 'explore' },
{ label: 'Settings', href: '/dashboard/settings',icon: IconSettings, tourId: 'settings' },
];
}
const modules = rc()?.enabled_modules ?? [];
const seen = new Set<string>();
return modules
@ -173,7 +381,6 @@ export default function DashboardLayout(props: { children: any }) {
setTourStepIndex(0);
return;
}
if (tourKind() === 'welcome') {
window.localStorage.setItem(getWelcomeTourStorageKey(userId), WELCOME_TOUR_VALUE);
} else {
@ -182,37 +389,31 @@ export default function DashboardLayout(props: { children: any }) {
seen.add(activeRole());
window.localStorage.setItem(key, writeSeenRoleTours(seen));
}
setTourKind(null);
setTourStepIndex(0);
}
function nextTourStep() {
const total = activeTourSteps().length;
if (tourStepIndex() >= total - 1) {
finishTour();
return;
}
setTourStepIndex((current) => current + 1);
if (tourStepIndex() >= activeTourSteps().length - 1) { finishTour(); return; }
setTourStepIndex(i => i + 1);
}
// Auto-start guided tour when runtime config loads
createEffect(() => {
if (typeof window === 'undefined') return;
const runtime = rc();
const userId = runtime?.user?.id;
if (!runtime || !userId) return;
const welcomeTourSeen = window.localStorage.getItem(getWelcomeTourStorageKey(userId)) === WELCOME_TOUR_VALUE;
const seenRoleTours = readSeenRoleTours(window.localStorage.getItem(getRoleTourStorageKey(userId)));
const nextTour = pickGuidedTour({
const welcomeSeen = window.localStorage.getItem(getWelcomeTourStorageKey(userId)) === WELCOME_TOUR_VALUE;
const seenRoles = readSeenRoleTours(window.localStorage.getItem(getRoleTourStorageKey(userId)));
const next = pickGuidedTour({
userId,
activeRole: runtime?.user?.active_role ?? runtime?.role,
welcomeTourSeen,
seenRoleTours,
welcomeTourSeen: welcomeSeen,
seenRoleTours: seenRoles,
});
if (nextTour && nextTour !== tourKind()) {
setTourKind(nextTour);
if (next && next !== tourKind()) {
setTourKind(next);
setTourStepIndex(0);
}
});
@ -222,9 +423,7 @@ export default function DashboardLayout(props: { children: any }) {
{/* ── Sidebar ── */}
<aside class="sidebar">
<div class="sidebar-logo">
<A href="/dashboard">
<span class="logo-text">NXTGAUGE</span>
</A>
<A href="/dashboard"><span class="logo-text">NXTGAUGE</span></A>
</div>
<div class="sidebar-role-badge">
@ -234,7 +433,12 @@ export default function DashboardLayout(props: { children: any }) {
<nav class="sidebar-nav">
<For each={navItems()}>
{(item) => (
<A href={item.href} class="nav-item" activeClass="nav-item-active">
<A
href={item.href}
class="nav-item"
activeClass="nav-item-active"
data-tour-id={item.tourId}
>
<item.icon />
<span>{item.label}</span>
</A>
@ -252,7 +456,6 @@ export default function DashboardLayout(props: { children: any }) {
{/* ── Main Content ── */}
<div class="dashboard-main">
{/* Top bar */}
<header class="dashboard-topbar">
<div class="topbar-title">&nbsp;</div>
<div class="topbar-right">
@ -282,7 +485,6 @@ export default function DashboardLayout(props: { children: any }) {
</div>
</header>
{/* Page content */}
<main class="dashboard-content">
<Show when={rc()} fallback={<div class="loading-spinner">Loading...</div>}>
{props.children}
@ -290,26 +492,16 @@ export default function DashboardLayout(props: { children: any }) {
</main>
</div>
{/* ── Spotlight Guided Tour ── */}
<Show when={tourKind() && tourStep()}>
<div class="tour-overlay">
<section class="tour-modal" role="dialog" aria-modal="true" aria-label="Guided tour">
<p class="tour-eyebrow">Guided Tour</p>
<h3>{tourStep()?.title}</h3>
<p>{tourStep()?.body}</p>
<div class="tour-progress">
<span style={{ width: `${((tourStepIndex() + 1) / (activeTourSteps().length || 1)) * 100}%` }} />
</div>
<div class="tour-actions">
<button class="btn" onClick={finishTour}>Skip for now</button>
<Show when={tourStepIndex() > 0}>
<button class="btn" onClick={() => setTourStepIndex((current) => current - 1)}>Back</button>
</Show>
<button class="btn primary" onClick={nextTourStep}>
{tourStepIndex() >= activeTourSteps().length - 1 ? 'Finish' : 'Next'}
</button>
</div>
</section>
</div>
<SpotlightOverlay
step={tourStep()!}
stepIndex={tourStepIndex()}
total={activeTourSteps().length}
onNext={nextTourStep}
onBack={() => setTourStepIndex(i => Math.max(0, i - 1))}
onSkip={finishTour}
/>
</Show>
</div>
);

View file

@ -124,6 +124,27 @@ export async function switchRole(roleKey: string): Promise<void> {
}
}
// ── Bootstrap ──────────────────────────────────────────────────────────────────
// Called on page load / direct navigation to restore the access token from the
// httpOnly refresh-token cookie via the SolidStart server proxy route.
export async function bootstrapAuth(): Promise<boolean> {
if (authState().access_token) return true; // already have token in memory
try {
const res = await fetch('/api/users/auth/refresh', {
method: 'POST',
credentials: 'include', // sends httpOnly nxtgauge_refresh_token cookie
});
if (!res.ok) return false;
const data = await res.json().catch(() => ({}));
if (!data?.access_token) return false;
setAuthState(s => ({ ...s, access_token: data.access_token }));
await fetchRuntimeConfig(data.access_token);
return true;
} catch {
return false;
}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
export function isAuthenticated(): boolean {
@ -143,6 +164,43 @@ export function getAuthHeader(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
// ── UI Test helper — injects a mock runtime config without real auth ───────────
// Used during onboarding UI-testing so the dashboard renders correctly.
export function setMockRuntimeConfig(roleKey: string): void {
const modulesByRole: Record<string, string[]> = {
JOB_SEEKER: ['dashboard', 'profile', 'jobs', 'applications', 'notifications', 'settings'],
COMPANY: ['dashboard', 'profile', 'jobs', 'applications', 'notifications', 'settings'],
CUSTOMER: ['dashboard', 'profile', 'requirements', 'marketplace', 'wallet', 'notifications', 'settings'],
PHOTOGRAPHER: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
MAKEUP_ARTIST: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
TUTOR: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
DEVELOPER: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
VIDEO_EDITOR: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
GRAPHIC_DESIGNER: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
SOCIAL_MEDIA_MANAGER: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
FITNESS_TRAINER: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
CATERING_SERVICE: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet', 'notifications', 'settings'],
};
const mockConfig: RuntimeConfig = {
role: roleKey,
onboarding_required: false,
onboarding_status: 'PENDING_REVIEW',
enabled_modules: modulesByRole[roleKey] ?? ['profile', 'settings'],
feature_flags: {},
permissions: {},
user: {
id: 'preview-user',
full_name: 'Preview User',
email: 'preview@nxtgauge.com',
roles: [roleKey],
active_role: roleKey,
},
};
setAuthState(s => ({ ...s, runtime_config: mockConfig }));
}
// ── Exported helpers ───────────────────────────────────────────────────────────
export { authState };

View file

@ -3,6 +3,7 @@ import { normalizeRoleForTour } from './guided-tour';
export interface GuidedTourStep {
title: string;
body: string;
target?: string; // CSS selector — spotlight highlights this element
}
export interface RuntimeGuidedTours {
@ -14,30 +15,56 @@ export interface RuntimeGuidedTours {
const DEFAULT_WELCOME_STEPS: GuidedTourStep[] = [
{
title: 'Welcome to your Nxtgauge dashboard',
body: 'This is your home base. You can explore options and start your first onboarding flow from here.',
body: 'This is your home base. Everything you need is organised in one place.',
target: '.sidebar',
},
{
title: 'Choose what you want to do',
body: 'Use "Choose What You Need" or "Explore Nxtgauge" to select a path that matches your goal.',
title: 'Navigate using the sidebar',
body: 'Click any item to switch between modules like Jobs, Profile, Wallet, Portfolio and more.',
target: '.sidebar-nav',
},
{
title: 'Track your progress',
body: 'You can always return to check status updates, notifications, and your profile in one place.',
title: 'Your active role',
body: 'This badge shows which role you are operating as. You can switch roles from the top bar.',
target: '.sidebar-role-badge',
},
{
title: 'Top bar — quick access',
body: 'Access notifications, switch roles, and view your profile from the top bar.',
target: '.dashboard-topbar',
},
{
title: 'Your workspace',
body: 'All pages and actions appear here, tailored to your active role.',
target: '.dashboard-content',
},
];
const DEFAULT_COMPANY_APPROVED_STEPS: GuidedTourStep[] = [
{
title: 'Your company profile is approved',
body: 'You can now create job posts and manage hiring activity from your company dashboard.',
body: 'You can now create job posts and manage hiring activity from your dashboard.',
target: '.sidebar-role-badge',
},
{
title: 'Start with job postings',
body: 'Go to Jobs to create or update postings and send them for admin approval when required.',
title: 'Post and manage jobs',
body: 'Go to Jobs to create new postings. Jobs go live only after admin approval.',
target: '[data-tour-id="jobs"]',
},
{
title: 'Review incoming applications',
body: 'Use Applications to shortlist, reject, and progress candidates through your hiring flow.',
target: '[data-tour-id="applications"]',
},
{
title: 'Keep your profile updated',
body: 'A complete company profile builds trust with job seekers and improves application quality.',
target: '[data-tour-id="profile"]',
},
{
title: 'Dashboard at a glance',
body: 'Your KPI cards show real-time counts for active jobs, total applicants, and pending reviews.',
target: '.kpi-grid',
},
];
@ -45,46 +72,48 @@ const DEFAULT_CUSTOMER_APPROVED_STEPS: GuidedTourStep[] = [
{
title: 'Your customer profile is approved',
body: 'You now have access to requirement workflows and can connect with verified professionals.',
target: '.sidebar-role-badge',
},
{
title: 'Share your requirements',
body: 'Create requirements with your details, timeline, and budget to receive relevant responses.',
title: 'Post your requirements',
body: 'Create a requirement with your timeline, budget, and location to receive relevant responses.',
target: '[data-tour-id="requirements"]',
},
{
title: 'Track and manage responses',
body: 'Use marketplace and request views to compare professionals and move forward confidently.',
title: 'Browse the marketplace',
body: 'Explore available professionals and send direct requests to those who match your needs.',
target: '[data-tour-id="marketplace"]',
},
{
title: 'Manage your Tracecoin wallet',
body: 'Tracecoins are reserved when you send a request and deducted only when a professional accepts.',
target: '[data-tour-id="wallet"]',
},
];
const DEFAULT_JOB_SEEKER_APPROVED_STEPS: GuidedTourStep[] = [
{
title: 'Your job seeker profile is approved',
body: 'You can now browse jobs and apply directly from your dashboard.',
body: 'You can now browse open jobs and apply directly from your dashboard.',
target: '.sidebar-role-badge',
},
{
title: 'Find opportunities faster',
body: 'Use job filters and details pages to focus on roles that match your goals.',
title: 'Find opportunities',
body: 'Browse all open job postings and filter by role, location, and experience level.',
target: '[data-tour-id="jobs"]',
},
{
title: 'Track every application',
body: 'Check your applications panel for shortlist, interview, and offer updates.',
body: 'Check Applications for shortlist, interview, and offer updates in real time.',
target: '[data-tour-id="applications"]',
},
{
title: 'Your stats at a glance',
body: 'Your dashboard shows Applied, Shortlisted, and Interview counts at the top.',
target: '.kpi-grid',
},
];
function isValidStepArray(value: unknown): value is GuidedTourStep[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(step) =>
step &&
typeof step === 'object' &&
typeof (step as GuidedTourStep).title === 'string' &&
typeof (step as GuidedTourStep).body === 'string',
)
);
}
function prettyRoleName(role: string): string {
const normalized = normalizeRoleForTour(role);
if (!normalized) return 'professional';
@ -97,18 +126,45 @@ function defaultProfessionalApprovedSteps(role: string): GuidedTourStep[] {
{
title: `Your ${roleName} profile is approved`,
body: 'Your role-specific dashboard is now unlocked with modules tailored to your work.',
target: '.sidebar-role-badge',
},
{
title: 'Complete your public profile',
body: 'Update portfolio, services, and profile sections so customers and companies can trust your expertise.',
title: 'Complete your portfolio',
body: 'Add portfolio items so customers can see your past work and trust your expertise.',
target: '[data-tour-id="portfolio"]',
},
{
title: 'Respond and grow',
body: 'Track incoming leads or opportunities, and use your dashboard tools to convert them into outcomes.',
title: 'List your services',
body: 'Add the services you offer with pricing and duration so customers know what to expect.',
target: '[data-tour-id="services"]',
},
{
title: 'Track incoming leads',
body: 'View and respond to lead requests from customers browsing the marketplace.',
target: '[data-tour-id="requests"]',
},
{
title: 'Your Tracecoin wallet',
body: 'Earn Tracecoins when customers accept your response. Track your balance and history here.',
target: '[data-tour-id="wallet"]',
},
];
}
function isValidStepArray(value: unknown): value is GuidedTourStep[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(step) =>
step &&
typeof step === 'object' &&
typeof (step as GuidedTourStep).title === 'string' &&
typeof (step as GuidedTourStep).body === 'string',
)
);
}
export function resolveWelcomeTourSteps(runtimeTours?: RuntimeGuidedTours | null): GuidedTourStep[] {
if (isValidStepArray(runtimeTours?.welcome)) return runtimeTours!.welcome;
return DEFAULT_WELCOME_STEPS;

View file

@ -12,21 +12,27 @@ export function gatewayUrl(path: string) {
}
export function readAccessTokenFromRequest(request: Request): string | null {
const cookie = request.headers.get('cookie') || '';
if (!cookie) return null;
const parts = cookie.split(';').map((part) => part.trim());
const pair = parts.find((part) => part.startsWith('nxtgauge_access_token='));
if (!pair) return null;
const token = pair.split('=').slice(1).join('=').trim();
if (!token) return null;
try {
return decodeURIComponent(token);
} catch {
return token;
// 1. Prefer Authorization header forwarded by the client-side fetch
const authHeader = request.headers.get('authorization') || request.headers.get('Authorization') || '';
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();
if (token) return token;
}
// 2. Fall back to legacy cookie (nxtgauge_access_token) if set
const cookie = request.headers.get('cookie') || '';
if (cookie) {
const parts = cookie.split(';').map((part) => part.trim());
const pair = parts.find((part) => part.startsWith('nxtgauge_access_token='));
if (pair) {
const token = pair.split('=').slice(1).join('=').trim();
if (token) {
try { return decodeURIComponent(token); } catch { return token; }
}
}
}
return null;
}
export function withAuthHeaders(request: Request, extra?: Record<string, string>) {

View file

@ -60,22 +60,25 @@ export async function POST({ request }: { request: Request }) {
// ── Step 1: Save profile data to the role-specific service ─────────────
// Every profession is a separate microservice — PHOTOGRAPHER → photographers,
// MAKEUP_ARTIST → makeup-artists, etc. Nothing goes to a generic /professionals.
// Non-fatal: if the profession service is down the onboarding submit still proceeds.
// The full form data is preserved in progress_json on the users service.
const profilePath = ROLE_PROFILE_PATHS[roleKey];
if (profilePath && dataJson) {
const profilePayload = buildProfilePayload(roleKey, dataJson);
const profileRes = await fetch(gatewayUrl(profilePath), {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify(profilePayload),
cache: 'no-store',
});
if (!profileRes.ok) {
const err = await profileRes.json().catch(() => ({}));
return new Response(
JSON.stringify({ success: false, error: err?.message || err?.error || `Failed to save ${roleKey} profile` }),
{ status: profileRes.status, headers: { 'Content-Type': 'application/json' } },
);
try {
const profilePayload = buildProfilePayload(roleKey, dataJson);
const profileRes = await fetch(gatewayUrl(profilePath), {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify(profilePayload),
cache: 'no-store',
});
if (!profileRes.ok) {
const err = await profileRes.json().catch(() => ({}));
console.warn(`[onboarding/complete] ${roleKey} profile PATCH failed (non-fatal):`, err?.message || err?.error);
}
} catch (profileErr: any) {
// Service may not be running — log and continue to mark onboarding complete
console.warn(`[onboarding/complete] ${roleKey} profile service unreachable (non-fatal):`, profileErr?.message);
}
}

View file

@ -31,7 +31,6 @@ export async function GET({ request }: { request: Request }) {
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)}`,
]),
);

View file

@ -30,11 +30,14 @@ export async function POST({ request }: { request: Request }) {
});
}
// Wrap in standard response wrapper so frontend doesn't break
// Pass everything up so client can save `access_token` and `refresh_token`
// Forward the httpOnly refresh token cookie set by Rust so the browser stores it
const responseHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
const setCookie = res.headers.get('set-cookie');
if (setCookie) responseHeaders['set-cookie'] = setCookie;
return new Response(JSON.stringify({ success: true, ...data }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
headers: responseHeaders,
});
} catch (error: any) {

View file

@ -46,11 +46,14 @@ export async function POST({ request }: { request: Request }) {
});
}
// Wrap in standard response wrapper
// Make sure we pass the raw data up so the client gets the `access_token` and `refresh_token`
// Forward the httpOnly refresh token cookie set by Rust so the browser stores it
const responseHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
const setCookie = res.headers.get('set-cookie');
if (setCookie) responseHeaders['set-cookie'] = setCookie;
return new Response(JSON.stringify({ success: true, ...data }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
headers: responseHeaders,
});
} catch (error: any) {

View file

@ -21,11 +21,12 @@ export default function MyApplications() {
const [apps, { refetch }] = createResource(
() => ({ page: page(), status: statusFilter() }),
async ({ page, status }) => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const params = new URLSearchParams({ page: String(page), limit: '20' });
if (status) params.set('status', status);
const res = await fetch(`${API}/api/jobseeker/applications?${params}`, {
headers: getAuthHeader(),
});
const res = await fetch(`${API}/api/jobseeker/applications?${params}`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
}
);

View file

@ -39,7 +39,7 @@ export default function DashboardIndex() {
</Show>
{/* KPI cards — rendered based on role via runtimeConfig */}
<div class="kpi-grid">
<div class="kpi-grid" data-tour-id="kpi-grid">
<Show when={role() === 'USER'}>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">🧭</div>
@ -152,27 +152,35 @@ function ProfessionalKPIs() {
return (
<>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--orange">🪙</div>
<div class="kpi-icon kpi-icon--blue">📩</div>
<div class="kpi-content">
<div class="kpi-value"></div>
<div class="kpi-label">Responses Sent</div>
</div>
<A href="/dashboard/requests" class="kpi-link">View </A>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--green"></div>
<div class="kpi-content">
<div class="kpi-value">/5</div>
<div class="kpi-label">Rating</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--orange">👁</div>
<div class="kpi-content">
<div class="kpi-value"></div>
<div class="kpi-label">Profile Views</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--purple">🪙</div>
<div class="kpi-content">
<div class="kpi-value"></div>
<div class="kpi-label">Tracecoins</div>
</div>
<A href="/dashboard/wallet" class="kpi-link">View Wallet </A>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">📩</div>
<div class="kpi-content">
<div class="kpi-value"></div>
<div class="kpi-label">Active Requests</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--green">🤝</div>
<div class="kpi-content">
<div class="kpi-value"></div>
<div class="kpi-label">Accepted Leads</div>
</div>
</div>
</>
);
}

View file

@ -20,11 +20,12 @@ export default function CompanyJobs() {
const [jobs, { refetch }] = createResource(
() => ({ page: page(), status: statusFilter() }),
async ({ page, status }) => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const params = new URLSearchParams({ page: String(page), limit: '20' });
if (status) params.set('status', status);
const res = await fetch(`${API}/api/companies/jobs?${params}`, {
headers: getAuthHeader(),
});
const res = await fetch(`${API}/api/companies/jobs?${params}`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
}
);

View file

@ -13,9 +13,10 @@ export default function MarketplaceDetail() {
const [error, setError] = createSignal('');
const [req] = createResource(reqId, async (id) => {
const res = await fetch(`${API}/api/photographers/marketplace/${id}`, {
headers: getAuthHeader(),
});
const auth = getAuthHeader();
if (!auth.Authorization) return null;
const res = await fetch(`${API}/api/photographers/marketplace/${id}`, { headers: auth });
if (!res.ok) return null;
return res.json();
});

View file

@ -11,10 +11,13 @@ export default function Marketplace() {
const [requirements, { refetch }] = createResource(
() => ({ page: page(), search: search() }),
async ({ page }) => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(
`${API}/api/professionals/marketplace?page=${page}&limit=20`,
{ headers: getAuthHeader() }
{ headers: auth }
);
if (!res.ok) return { data: [] };
return res.json();
}
);

View file

@ -9,9 +9,10 @@ export default function Notifications() {
const [notifications, { refetch }] = createResource(
() => page(),
async (p) => {
const res = await fetch(`${API}/api/me/notifications?page=${p}&limit=30`, {
headers: getAuthHeader(),
});
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/me/notifications?page=${p}&limit=30`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
}
);

View file

@ -12,7 +12,10 @@ export default function Portfolio() {
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); }
const [items, { refetch }] = createResource(async () => {
const res = await fetch(`${API}/api/photographers/portfolio/me`, { headers: getAuthHeader() });
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/photographers/portfolio/me`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});

View file

@ -17,16 +17,18 @@ export default function RequirementDetail() {
const [actionLoading, setActionLoading] = createSignal('');
const [req, { refetch: refetchReq }] = createResource(reqId, async (id) => {
const res = await fetch(`${API}/api/customers/requirements/${id}`, {
headers: getAuthHeader(),
});
const auth = getAuthHeader();
if (!auth.Authorization) return null;
const res = await fetch(`${API}/api/customers/requirements/${id}`, { headers: auth });
if (!res.ok) return null;
return res.json();
});
const [requests, { refetch: refetchRequests }] = createResource(reqId, async (id) => {
const res = await fetch(`${API}/api/customers/requirements/${id}/requests?limit=50`, {
headers: getAuthHeader(),
});
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/customers/requirements/${id}/requests?limit=50`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});

View file

@ -28,9 +28,10 @@ export default function Requirements() {
const [requirements, { refetch }] = createResource(
() => page(),
async (p) => {
const res = await fetch(`${API}/api/customers/requirements?page=${p}&limit=20`, {
headers: getAuthHeader(),
});
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/customers/requirements?page=${p}&limit=20`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
}
);

View file

@ -14,7 +14,10 @@ export default function Services() {
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); }
const [services, { refetch }] = createResource(async () => {
const res = await fetch(`${API}/api/photographers/services/me`, { headers: getAuthHeader() });
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/photographers/services/me`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});

View file

@ -1,5 +1,5 @@
import { createResource, createSignal, Show } from 'solid-js';
import { getAuthHeader, authStore } from '~/lib/auth';
import { getAuthHeader } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
@ -14,7 +14,10 @@ export default function ProfileSettings() {
const [success, setSuccess] = createSignal(false);
const [me, { refetch }] = createResource(async () => {
const res = await fetch(`${API}/api/me`, { headers: getAuthHeader() });
const auth = getAuthHeader();
if (!auth.Authorization) return { full_name: '', phone: '', location: '', bio: '' };
const res = await fetch(`${API}/api/me`, { headers: auth });
if (!res.ok) return { full_name: '', phone: '', location: '', bio: '' };
return res.json();
});

View file

@ -6,9 +6,10 @@ const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
export default function Wallet() {
const [balance] = createResource(async () => {
const res = await fetch(`${API}/api/professionals/wallet/balance`, {
headers: getAuthHeader(),
});
const auth = getAuthHeader();
if (!auth.Authorization) return { balance: 0, reserved: 0, available: 0 };
const res = await fetch(`${API}/api/professionals/wallet/balance`, { headers: auth });
if (!res.ok) return { balance: 0, reserved: 0, available: 0 };
return res.json();
});

View file

@ -8,14 +8,18 @@ export default function WalletLedger() {
const [page, setPage] = createSignal(1);
const [balance] = createResource(async () => {
const res = await fetch(`${API}/api/photographers/wallet/balance`, { headers: getAuthHeader() });
const auth = getAuthHeader();
if (!auth.Authorization) return { balance: 0, reserved: 0, available: 0 };
const res = await fetch(`${API}/api/photographers/wallet/balance`, { headers: auth });
if (!res.ok) return { balance: 0, reserved: 0, available: 0 };
return res.json();
});
const [ledger] = createResource(() => page(), async (p) => {
const res = await fetch(`${API}/api/photographers/wallet/ledger?page=${p}&limit=30`, {
headers: getAuthHeader(),
});
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/photographers/wallet/ledger?page=${p}&limit=30`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});

View file

@ -1,6 +1,15 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import { authState } from '~/lib/auth';
import { useNavigate, useSearchParams } from '@solidjs/router';
import { authState, bootstrapAuth, setMockRuntimeConfig } from '~/lib/auth';
function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
const token = authState().access_token;
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
return fetch(url, { ...options, headers });
}
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeOption, UploadedFileMeta } from '~/lib/runtime/types';
import { evaluateVisibility, normalizeRoleKey, schemaIdFromInput } from '~/lib/onboarding-flow';
import { isValidEmail } from '~/lib/form-validation';
@ -207,6 +216,7 @@ function normalizeSchemaPayload(payload: any, schemaId: string, roleKey: string)
}
export default function OnboardingPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [schema, setSchema] = createSignal<RuntimeOnboardingConfig | null>(null);
const [values, setValues] = createSignal<Record<string, unknown>>({});
@ -217,6 +227,8 @@ export default function OnboardingPage() {
const [submitted, setSubmitted] = createSignal(false);
const [loading, setLoading] = createSignal(true);
const [profileStatus, setProfileStatus] = createSignal<string>('');
const [termsAccepted, setTermsAccepted] = createSignal(false);
const [termsError, setTermsError] = createSignal(false);
const requestedRoleKey = createMemo(() => normalizeRoleKey(Array.isArray(searchParams.roleKey) ? searchParams.roleKey[0] : searchParams.roleKey));
const activeRoleKey = createMemo(() =>
@ -243,6 +255,10 @@ export default function OnboardingPage() {
onMount(async () => {
try {
setLoading(true);
// Restore access token from httpOnly refresh cookie on direct navigation / page reload
await bootstrapAuth();
const schemaId = requestedSchemaId();
const roleKey = effectiveRoleKey();
if (!schemaId || !roleKey) {
@ -250,7 +266,7 @@ export default function OnboardingPage() {
return;
}
const schemaResponse = await fetch(`/api/runtime/onboarding/schema?${new URLSearchParams({ schemaId, roleKey }).toString()}`);
const schemaResponse = await authFetch(`/api/runtime/onboarding/schema?${new URLSearchParams({ schemaId, roleKey }).toString()}`);
const schemaPayload = await schemaResponse.json().catch(() => ({}));
if (!schemaResponse.ok || !schemaPayload?.success) {
setStatusMessage(schemaPayload?.error || 'Unable to load onboarding schema from backend.');
@ -282,7 +298,7 @@ export default function OnboardingPage() {
});
setValues(initialValues);
const stateResponse = await fetch(`/api/runtime/onboarding/state?${new URLSearchParams({ roleKey: normalized.roleKey }).toString()}`);
const stateResponse = await authFetch(`/api/runtime/onboarding/state?${new URLSearchParams({ roleKey: normalized.roleKey }).toString()}`);
const statePayload = await stateResponse.json().catch(() => ({}));
if (stateResponse.ok && statePayload?.success && statePayload?.data) {
const currentStep = Math.max(0, Number(statePayload.data.currentStep || 0));
@ -291,7 +307,7 @@ export default function OnboardingPage() {
if (['SUBMITTED', 'COMPLETED', 'APPROVED'].includes(status)) setSubmitted(true);
}
const profileResponse = await fetch('/api/runtime/profile-status');
const profileResponse = await authFetch('/api/runtime/profile-status');
const profilePayload = await profileResponse.json().catch(() => ({}));
if (profileResponse.ok && profilePayload?.success) {
setProfileStatus(String(profilePayload?.data?.profileStatus || ''));
@ -451,7 +467,7 @@ export default function OnboardingPage() {
const syncProgress = async (nextStep: number) => {
const currentSchema = schema();
if (!currentSchema) return;
await fetch('/api/runtime/onboarding/progress', {
await authFetch('/api/runtime/onboarding/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -488,9 +504,25 @@ export default function OnboardingPage() {
setStatusMessage('Please fix the highlighted fields.');
return;
}
if (!termsAccepted()) {
setTermsError(true);
setStatusMessage('Please accept the Terms & Conditions to continue.');
return;
}
setTermsError(false);
const currentSchema = schema();
if (!currentSchema) return;
// If not authenticated (UI-only testing), skip API and go straight to success
if (!authState().access_token) {
const roleKey = String(searchParams.roleKey || searchParams.intent || '').toUpperCase();
if (roleKey) setMockRuntimeConfig(roleKey);
setSubmitted(true);
setStatusMessage('');
setTimeout(() => navigate('/dashboard', { replace: true }), 3000);
return;
}
const payloadData = {
...values(),
...(userIdentity().fullName ? { full_name: userIdentity().fullName } : {}),
@ -498,7 +530,7 @@ export default function OnboardingPage() {
...(userIdentity().lastName ? { last_name: userIdentity().lastName } : {}),
};
const response = await fetch('/api/runtime/onboarding/complete', {
const response = await authFetch('/api/runtime/onboarding/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -514,10 +546,13 @@ export default function OnboardingPage() {
}
setSubmitted(true);
setStatusMessage('');
// Navigate to dashboard after showing the success card for 3 seconds
setTimeout(() => navigate('/dashboard', { replace: true }), 3000);
};
const renderField = (field: RuntimeOnboardingField) => {
const value = values()[field.id];
const RenderField = (props: { field: RuntimeOnboardingField }) => {
const field = props.field;
const value = createMemo(() => values()[field.id]);
const lockedIdentityField = isLockedProfileField(field.id);
const fieldReadOnly = lockedIdentityField && !isAlwaysEditableField(field.id);
@ -525,7 +560,7 @@ export default function OnboardingPage() {
return (
<textarea
class="textarea"
value={String(value || '')}
value={String(value() || '')}
readOnly={Boolean(fieldReadOnly || lockedIdentityField)}
placeholder={field.placeholder || field.label}
onInput={(e) => handleFieldInput(field, e.currentTarget.value)}
@ -540,12 +575,13 @@ export default function OnboardingPage() {
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 getSelected = createMemo(() => 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];
const cur = getSelected();
const exists = cur.includes(optionValue);
const next = exists ? cur.filter((entry) => entry !== optionValue) : [...cur, optionValue];
markFieldTouched(field.id);
handleFieldInput(field, next);
validateSingleField(field, next);
@ -554,17 +590,17 @@ export default function OnboardingPage() {
<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);
const active = () => getSelected().includes(option.value);
return (
<button
type="button"
class={`multi-select-option${active ? ' is-selected' : ''}`}
class={`multi-select-option${active() ? ' is-selected' : ''}`}
disabled={disabled}
aria-pressed={active}
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">
<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>
@ -578,7 +614,7 @@ export default function OnboardingPage() {
}
if (useOptionCardUi) {
const selected = String(value || '');
const getSelected = createMemo(() => String(value() || ''));
const disabled = fieldReadOnly || lockedIdentityField;
const selectOption = (optionValue: string) => {
if (disabled) return;
@ -590,18 +626,18 @@ export default function OnboardingPage() {
<div class={`multi-select-grid${disabled ? ' is-disabled' : ''}`} role="radiogroup" aria-label={field.label}>
<For each={field.options || []}>
{(option) => {
const active = selected === option.value;
const active = () => getSelected() === option.value;
return (
<button
type="button"
class={`multi-select-option${active ? ' is-selected' : ''}`}
class={`multi-select-option${active() ? ' is-selected' : ''}`}
disabled={disabled}
role="radio"
aria-checked={active}
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">
<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>
@ -617,7 +653,7 @@ export default function OnboardingPage() {
return (
<select
class="select"
value={String(value || '')}
value={String(value() || '')}
disabled={fieldReadOnly || lockedIdentityField}
onInput={(e) => handleFieldInput(field, e.currentTarget.value)}
onBlur={(e) => handleFieldBlur(field, e.currentTarget.value)}
@ -633,7 +669,7 @@ export default function OnboardingPage() {
<label class="inline">
<input
type="checkbox"
checked={Boolean(value)}
checked={Boolean(value())}
disabled={fieldReadOnly || lockedIdentityField}
onInput={(e) => handleFieldInput(field, e.currentTarget.checked)}
onBlur={(e) => handleFieldBlur(field, e.currentTarget.checked)}
@ -644,7 +680,7 @@ export default function OnboardingPage() {
}
if (field.type === 'file') {
const files = Array.isArray(value) ? (value as UploadedFileMeta[]) : [];
const getFiles = createMemo(() => Array.isArray(value()) ? (value() as UploadedFileMeta[]) : []);
return (
<div>
<input
@ -683,8 +719,8 @@ export default function OnboardingPage() {
}}
onBlur={() => handleFieldBlur(field, values()[field.id])}
/>
<Show when={files.length > 0}>
<p class="note ok">Uploaded {files.length} file(s) </p>
<Show when={getFiles().length > 0}>
<p class="note ok">Uploaded {getFiles().length} file(s) </p>
</Show>
</div>
);
@ -709,7 +745,7 @@ export default function OnboardingPage() {
<input
class="input locked-input"
type={inputType}
value={typeof value === 'string' || typeof value === 'number' ? String(value) : ''}
value={typeof value() === 'string' || typeof value() === 'number' ? String(value()) : ''}
readOnly
disabled
placeholder={field.placeholder || field.label}
@ -730,7 +766,7 @@ export default function OnboardingPage() {
<input
class="input"
type={inputType}
value={typeof value === 'string' || typeof value === 'number' ? String(value) : ''}
value={typeof value() === 'string' || typeof value() === 'number' ? String(value()) : ''}
readOnly={Boolean(fieldReadOnly)}
placeholder={field.placeholder || field.label}
onInput={(e) => handleFieldInput(field, e.currentTarget.value)}
@ -754,9 +790,21 @@ export default function OnboardingPage() {
<Show
when={!submitted()}
fallback={
<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 class="auth-form card glass-light onboarding-auth-form" style={{ "text-align": "center", padding: "2.5rem 2rem" }}>
<div style={{ display: "flex", "justify-content": "center", "margin-bottom": "1.25rem" }}>
<div style={{
width: "72px", height: "72px", "border-radius": "50%",
background: "#fd6216", display: "flex", "align-items": "center",
"justify-content": "center",
}}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style={{ width: "38px", height: "38px" }}>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
</div>
<h2 class="title" style={{ "margin-bottom": "0.5rem" }}>Documents Submitted!</h2>
<p class="subtitle">Your documents have been submitted successfully.<br />Approvals will take <strong>2448 hours</strong>.</p>
<p style={{ "margin-top": "1.5rem", color: "#6e7591", "font-size": "0.85rem" }}>Redirecting you to your dashboard</p>
</section>
}
>
@ -783,8 +831,8 @@ export default function OnboardingPage() {
{field.label}
{field.required ? ' *' : ''}
</label>
{renderField(field)}
<Show when={validationNote(field) && !errors()[field.id]}>
<RenderField field={field} />
<Show when={errors()[field.id] ? null : validationNote(field)}>
{(note) => (
<p
class="validation-note"
@ -834,6 +882,35 @@ export default function OnboardingPage() {
)}
</For>
<Show when={stepIndex() === visibleSteps().length - 1}>
<div class="field" style={{ "margin-top": "1.25rem" }}>
<label class="inline" style={{ "align-items": "flex-start", gap: "0.6rem", cursor: "pointer" }}>
<input
type="checkbox"
checked={termsAccepted()}
onInput={(e) => {
setTermsAccepted(e.currentTarget.checked);
if (e.currentTarget.checked) setTermsError(false);
}}
style={{ "margin-top": "3px", "flex-shrink": "0" }}
/>
<span style={{ "font-size": "0.875rem", color: "#6e7591", "line-height": "1.5" }}>
I agree to the{' '}
<a href="/terms" target="_blank" style={{ color: "#fd6216", "text-decoration": "underline" }}>
Terms &amp; Conditions
</a>{' '}
and{' '}
<a href="/privacy" target="_blank" style={{ color: "#fd6216", "text-decoration": "underline" }}>
Privacy Policy
</a>
</span>
</label>
<Show when={termsError()}>
<p class="error" style={{ "margin-top": "0.35rem" }}>You must accept the Terms &amp; Conditions to submit.</p>
</Show>
</div>
</Show>
<div class="actions onboarding-auth-actions">
<button class="btn" disabled={stepIndex() === 0} onClick={goBack}>Back</button>
<Show