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:
parent
7baa38aa97
commit
d67e436828
22 changed files with 674 additions and 245 deletions
|
|
@ -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"> </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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>24–48 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 & 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 & Conditions to submit.</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="actions onboarding-auth-actions">
|
||||
<button class="btn" disabled={stepIndex() === 0} onClick={goBack}>Back</button>
|
||||
<Show
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue