From d67e436828fc1199bd8f890db49897b02d96403a Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Mon, 23 Mar 2026 00:45:51 +0100 Subject: [PATCH] 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 --- src/components/dashboard/DashboardLayout.tsx | 378 +++++++++++++----- src/lib/auth.ts | 58 +++ src/lib/guided-tour-content.ts | 124 ++++-- src/lib/server/gateway.ts | 34 +- src/routes/api/runtime/onboarding/complete.ts | 31 +- src/routes/api/runtime/onboarding/schema.ts | 1 - src/routes/api/users/auth/login.ts | 9 +- src/routes/api/users/auth/register.ts | 9 +- src/routes/dashboard/applications/index.tsx | 7 +- src/routes/dashboard/index.tsx | 40 +- src/routes/dashboard/jobs/index.tsx | 7 +- src/routes/dashboard/marketplace/[id].tsx | 7 +- src/routes/dashboard/marketplace/index.tsx | 5 +- src/routes/dashboard/notifications.tsx | 7 +- src/routes/dashboard/portfolio/index.tsx | 5 +- src/routes/dashboard/requirements/[id].tsx | 14 +- src/routes/dashboard/requirements/index.tsx | 7 +- src/routes/dashboard/services/index.tsx | 5 +- src/routes/dashboard/settings.tsx | 7 +- src/routes/dashboard/wallet/index.tsx | 7 +- src/routes/dashboard/wallet/ledger.tsx | 12 +- src/routes/onboarding.tsx | 145 +++++-- 22 files changed, 674 insertions(+), 245 deletions(-) diff --git a/src/components/dashboard/DashboardLayout.tsx b/src/components/dashboard/DashboardLayout.tsx index c864140..a163905 100644 --- a/src/components/dashboard/DashboardLayout.tsx +++ b/src/components/dashboard/DashboardLayout.tsx @@ -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 = () => ( ); +const IconPortfolio = () => ( + + + +); +const IconServices = () => ( + + + +); +const IconWallet = () => ( + + + +); // ── Module → nav item mapping ───────────────────────────────────────────────── -const MODULE_NAV_MAP: Record = { - // 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 = { + // 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(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 ── */} + + + + + + + + + + + {/* Orange border around spotlight */} + + + + + + {/* ── Tooltip card ── */} +
+ {/* Header */} +
+ + Guided Tour + + + {props.stepIndex + 1} / {props.total} + +
+ + {/* Title */} +

+ {props.step.title} +

+ + {/* Body */} +

+ {props.step.body} +

+ + {/* Progress bar */} +
+
+
+ + {/* Buttons */} +
+ + 0}> + + + +
+
+ + ); +} + // ── Dashboard Layout ────────────────────────────────────────────────────────── export default function DashboardLayout(props: { children: any }) { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [switchingRole, setSwitchingRole] = createSignal(false); const [tourKind, setTourKind] = createSignal(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(); 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 ── */}
); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index dbb687a..689cbd7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -124,6 +124,27 @@ export async function switchRole(roleKey: string): Promise { } } +// ── 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 { + 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 { 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 = { + 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 }; diff --git a/src/lib/guided-tour-content.ts b/src/lib/guided-tour-content.ts index 68813e2..17b1913 100644 --- a/src/lib/guided-tour-content.ts +++ b/src/lib/guided-tour-content.ts @@ -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; diff --git a/src/lib/server/gateway.ts b/src/lib/server/gateway.ts index 24059e7..6875a98 100644 --- a/src/lib/server/gateway.ts +++ b/src/lib/server/gateway.ts @@ -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) { diff --git a/src/routes/api/runtime/onboarding/complete.ts b/src/routes/api/runtime/onboarding/complete.ts index 0ad8291..25de31d 100644 --- a/src/routes/api/runtime/onboarding/complete.ts +++ b/src/routes/api/runtime/onboarding/complete.ts @@ -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); } } diff --git a/src/routes/api/runtime/onboarding/schema.ts b/src/routes/api/runtime/onboarding/schema.ts index 92fc685..dad4e5a 100644 --- a/src/routes/api/runtime/onboarding/schema.ts +++ b/src/routes/api/runtime/onboarding/schema.ts @@ -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)}`, ]), ); diff --git a/src/routes/api/users/auth/login.ts b/src/routes/api/users/auth/login.ts index a3c411e..82e1ce4 100644 --- a/src/routes/api/users/auth/login.ts +++ b/src/routes/api/users/auth/login.ts @@ -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 = { '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) { diff --git a/src/routes/api/users/auth/register.ts b/src/routes/api/users/auth/register.ts index 087c2a0..4698a1f 100644 --- a/src/routes/api/users/auth/register.ts +++ b/src/routes/api/users/auth/register.ts @@ -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 = { '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) { diff --git a/src/routes/dashboard/applications/index.tsx b/src/routes/dashboard/applications/index.tsx index c7c4cce..f7524f2 100644 --- a/src/routes/dashboard/applications/index.tsx +++ b/src/routes/dashboard/applications/index.tsx @@ -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(); } ); diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx index 32576ae..d3b03f7 100644 --- a/src/routes/dashboard/index.tsx +++ b/src/routes/dashboard/index.tsx @@ -39,7 +39,7 @@ export default function DashboardIndex() { {/* KPI cards — rendered based on role via runtimeConfig */} -
+
🧭
@@ -152,27 +152,35 @@ function ProfessionalKPIs() { return ( <>
-
🪙
+
📩
+
+
+
Responses Sent
+
+ View → +
+
+
+
+
—/5
+
Rating
+
+
+
+
👁️
+
+
+
Profile Views
+
+
+
+
🪙
Tracecoins
View Wallet →
-
-
📩
-
-
-
Active Requests
-
-
-
-
🤝
-
-
-
Accepted Leads
-
-
); } diff --git a/src/routes/dashboard/jobs/index.tsx b/src/routes/dashboard/jobs/index.tsx index 78c84bf..a31733d 100644 --- a/src/routes/dashboard/jobs/index.tsx +++ b/src/routes/dashboard/jobs/index.tsx @@ -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(); } ); diff --git a/src/routes/dashboard/marketplace/[id].tsx b/src/routes/dashboard/marketplace/[id].tsx index c8506ea..d30484e 100644 --- a/src/routes/dashboard/marketplace/[id].tsx +++ b/src/routes/dashboard/marketplace/[id].tsx @@ -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(); }); diff --git a/src/routes/dashboard/marketplace/index.tsx b/src/routes/dashboard/marketplace/index.tsx index 65f3cdc..47f1c72 100644 --- a/src/routes/dashboard/marketplace/index.tsx +++ b/src/routes/dashboard/marketplace/index.tsx @@ -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(); } ); diff --git a/src/routes/dashboard/notifications.tsx b/src/routes/dashboard/notifications.tsx index 293c517..dff7cd3 100644 --- a/src/routes/dashboard/notifications.tsx +++ b/src/routes/dashboard/notifications.tsx @@ -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(); } ); diff --git a/src/routes/dashboard/portfolio/index.tsx b/src/routes/dashboard/portfolio/index.tsx index bdfcaae..a0f92a3 100644 --- a/src/routes/dashboard/portfolio/index.tsx +++ b/src/routes/dashboard/portfolio/index.tsx @@ -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(); }); diff --git a/src/routes/dashboard/requirements/[id].tsx b/src/routes/dashboard/requirements/[id].tsx index 613d184..a2ad2a8 100644 --- a/src/routes/dashboard/requirements/[id].tsx +++ b/src/routes/dashboard/requirements/[id].tsx @@ -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(); }); diff --git a/src/routes/dashboard/requirements/index.tsx b/src/routes/dashboard/requirements/index.tsx index 1d19c15..d3b104b 100644 --- a/src/routes/dashboard/requirements/index.tsx +++ b/src/routes/dashboard/requirements/index.tsx @@ -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(); } ); diff --git a/src/routes/dashboard/services/index.tsx b/src/routes/dashboard/services/index.tsx index 4ad9c7e..c3abfbf 100644 --- a/src/routes/dashboard/services/index.tsx +++ b/src/routes/dashboard/services/index.tsx @@ -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(); }); diff --git a/src/routes/dashboard/settings.tsx b/src/routes/dashboard/settings.tsx index 95eccc2..aaab086 100644 --- a/src/routes/dashboard/settings.tsx +++ b/src/routes/dashboard/settings.tsx @@ -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(); }); diff --git a/src/routes/dashboard/wallet/index.tsx b/src/routes/dashboard/wallet/index.tsx index 049ec65..198c9d9 100644 --- a/src/routes/dashboard/wallet/index.tsx +++ b/src/routes/dashboard/wallet/index.tsx @@ -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(); }); diff --git a/src/routes/dashboard/wallet/ledger.tsx b/src/routes/dashboard/wallet/ledger.tsx index 923fc46..7fa00d2 100644 --- a/src/routes/dashboard/wallet/ledger.tsx +++ b/src/routes/dashboard/wallet/ledger.tsx @@ -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(); }); diff --git a/src/routes/onboarding.tsx b/src/routes/onboarding.tsx index 74908f4..afffdb8 100644 --- a/src/routes/onboarding.tsx +++ b/src/routes/onboarding.tsx @@ -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 { + const token = authState().access_token; + const headers: Record = { + ...(options.headers as Record || {}), + ...(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(null); const [values, setValues] = createSignal>({}); @@ -217,6 +227,8 @@ export default function OnboardingPage() { const [submitted, setSubmitted] = createSignal(false); const [loading, setLoading] = createSignal(true); const [profileStatus, setProfileStatus] = createSignal(''); + 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 (