nxtgauge-frontend-solid/src/components/dashboard/DashboardLayout.tsx

534 lines
23 KiB
TypeScript
Raw Normal View History

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,
getWelcomeTourStorageKey,
pickGuidedTour,
readSeenRoleTours,
WELCOME_TOUR_VALUE,
writeSeenRoleTours,
} from '~/lib/guided-tour';
import type { GuidedTourKind } from '~/lib/guided-tour';
import {
type GuidedTourStep,
resolveRoleApprovedTourSteps,
resolveWelcomeTourSteps,
} from '~/lib/guided-tour-content';
// ── Icons (inline SVGs for zero deps) ─────────────────────────────────────────
const IconDashboard = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
);
const IconJobs = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
<line x1="12" y1="12" x2="12" y2="17"/><line x1="9" y1="14.5" x2="15" y2="14.5"/>
</svg>
);
const IconBell = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
);
const IconSettings = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
);
const IconLogout = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
);
const IconCompass = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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; tourId: string; order?: number }> = {
// Next.js role-dashboard module keys
dashboard: { label: 'Dashboard', href: '/users/dashboard', icon: IconDashboard, tourId: 'dashboard', order: 1 },
onboarding: { label: 'Get Started', href: '/users/onboarding', icon: IconCompass, tourId: 'onboarding', order: 2 },
role_selection: { label: 'Choose Your Role', href: '/users/onboarding/role-selection', icon: IconCompass, tourId: 'role-selection', order: 3 },
profile: { label: 'Profile', href: '/users/profile', icon: IconSettings, tourId: 'profile', order: 4 },
leads: { label: 'Leads', href: '/users/leads', icon: IconJobs, tourId: 'requests', order: 5 },
job_postings: { label: 'Job Postings', href: '/companies/job-postings', icon: IconJobs, tourId: 'jobs', order: 6 },
applications: { label: 'Applications', href: '/companies/applications', icon: IconJobs, tourId: 'applications', order: 7 },
portfolio: { label: 'Portfolio', href: '/users/professional/portfolio', icon: IconPortfolio, tourId: 'portfolio', order: 8 },
verification: { label: 'Verification Status', href: '/users/verification-status', icon: IconSettings, tourId: 'verification', order: 9 },
tracecoins: { label: 'Tracecoins', href: '/companies/tracecoins', icon: IconWallet, tourId: 'wallet', order: 10 },
feedback: { label: 'Feedback', href: '/companies/feedback', icon: IconBell, tourId: 'support', order: 11 },
notifications: { label: 'Notifications', href: '/users/notifications', icon: IconBell, tourId: 'notifications', order: 12 },
support: { label: 'Support', href: '/users/support', icon: IconBell, tourId: 'support', order: 13 },
settings: { label: 'Settings', href: '/users/settings', icon: IconSettings, tourId: 'settings', order: 14 },
// Existing Solid module keys (backward compatibility)
jobs: { label: 'Jobs', href: '/dashboard/jobs', icon: IconJobs, tourId: 'jobs' },
my_applications: { label: 'My Applications', href: '/dashboard/applications', icon: IconJobs, tourId: 'applications' },
browse_jobs: { label: 'Browse Jobs', href: '/dashboard/jobs', icon: IconJobs, tourId: 'jobs' },
requirements: { label: 'Requirements', href: '/dashboard/requirements', icon: IconJobs, tourId: 'requirements' },
marketplace: { label: 'Marketplace', href: '/dashboard/marketplace', icon: IconCompass, tourId: 'marketplace' },
services: { label: 'Services', href: '/dashboard/services', icon: IconServices, tourId: 'services' },
wallet: { label: 'Wallet', href: '/dashboard/wallet', icon: IconWallet, tourId: 'wallet' },
// Uppercase fallbacks
COMPANY_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
JOBSEEKER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
CUSTOMER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard, tourId: 'dashboard' },
PROFESSIONAL_DASHBOARD: { label: '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: '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="#fd6116" 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: '#fd6116', '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: '#fd6116', '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: '#fd6116', 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.runtime_config?.onboarding_required) {
navigate('/onboarding', { replace: true });
}
});
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 [];
if (kind === 'welcome') return resolveWelcomeTourSteps(rc()?.guided_tours ?? null);
return resolveRoleApprovedTourSteps(activeRole(), rc()?.guided_tours ?? null);
};
const tourStep = () => activeTourSteps()[tourStepIndex()];
const navItems = () => {
const role = String(activeRole() || rc()?.role || '').toUpperCase();
const moduleSet = new Set(
(Array.isArray(rc()?.enabled_modules) ? rc()!.enabled_modules : [])
.map((moduleKey) => String(moduleKey || '').toLowerCase()),
);
if (role !== 'USER') {
moduleSet.add('dashboard');
}
const modules = Array.from(moduleSet);
return modules
.map((m) => {
const base = MODULE_NAV_MAP[m];
if (!base) return null;
// Match Next.js role override: customer "leads" is "Post Requirement"
if (m === 'leads' && role === 'CUSTOMER') {
return { ...base, label: 'Post Requirement', href: '/users/requirements/new', tourId: 'requirements' };
}
// Match Next.js company route overrides.
if (role === 'COMPANY') {
if (m === 'profile') return { ...base, href: '/companies/profile' };
if (m === 'support') return { ...base, href: '/companies/support' };
if (m === 'settings') return { ...base, href: '/companies/settings' };
}
return base;
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => (left.order ?? 999) - (right.order ?? 999));
};
async function handleLogout() {
await logout();
navigate('/auth/login', { replace: true });
}
async function handleRoleSwitch(nextRole: string) {
const normalized = String(nextRole || '').trim().toUpperCase();
if (!normalized || normalized === activeRole()) return;
setSwitchingRole(true);
try {
await switchRole(normalized);
navigate('/dashboard', { replace: true });
} finally {
setSwitchingRole(false);
}
}
function finishTour() {
if (typeof window === 'undefined') return;
const userId = rc()?.user?.id;
if (!userId || !tourKind()) {
setTourKind(null);
setTourStepIndex(0);
return;
}
if (tourKind() === 'welcome') {
window.localStorage.setItem(getWelcomeTourStorageKey(userId), WELCOME_TOUR_VALUE);
} else {
const key = getRoleTourStorageKey(userId);
const seen = readSeenRoleTours(window.localStorage.getItem(key));
seen.add(activeRole());
window.localStorage.setItem(key, writeSeenRoleTours(seen));
}
setTourKind(null);
setTourStepIndex(0);
}
function nextTourStep() {
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 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: welcomeSeen,
seenRoleTours: seenRoles,
});
if (next && next !== tourKind()) {
setTourKind(next);
setTourStepIndex(0);
}
});
return (
<div class="dashboard-shell">
{/* ── Sidebar ── */}
<aside class="sidebar">
<div class="sidebar-logo">
<A href="/dashboard"><span class="logo-text">NXTGAUGE</span></A>
</div>
<div class="sidebar-role-badge">
<span class="role-badge">{rc()?.user?.active_role ?? 'Loading...'}</span>
</div>
<nav class="sidebar-nav">
<For each={navItems()}>
{(item) => (
<A
href={item.href}
class="nav-item"
activeClass="nav-item-active"
data-tour-id={item.tourId}
>
<item.icon />
<span>{item.label}</span>
</A>
)}
</For>
</nav>
<div class="sidebar-footer">
<button class="nav-item nav-item-logout" onClick={handleLogout}>
<IconLogout />
<span>Logout</span>
</button>
</div>
</aside>
{/* ── Main Content ── */}
<div class="dashboard-main">
<header class="dashboard-topbar">
<div class="topbar-title">&nbsp;</div>
<div class="topbar-right">
<Show when={shouldShowRoleSwitcher(roleOptions())}>
<select
class="input"
style={{ width: '210px', 'font-size': '13px', padding: '8px 10px' }}
value={activeRole()}
disabled={switchingRole()}
onChange={(e) => handleRoleSwitch(e.currentTarget.value)}
title="Switch your dashboard view"
>
<For each={roleOptions()}>
{(role) => <option value={role}>{role.replaceAll('_', ' ')}</option>}
</For>
</select>
</Show>
<A href="/choose-role" class="btn btn-sm" style={{ 'text-decoration': 'none' }}>
Choose What You Need
</A>
<A href="/dashboard/notifications" class="topbar-icon-btn" title="Notifications">
<IconBell />
</A>
<div class="topbar-user">
<span class="topbar-name">{rc()?.user?.full_name ?? 'User'}</span>
</div>
</div>
</header>
<main class="dashboard-content">
<Show when={rc()} fallback={<div class="loading-spinner">Loading...</div>}>
{props.children}
</Show>
</main>
</div>
{/* ── Spotlight Guided Tour ── */}
<Show when={tourKind() && tourStep()}>
<SpotlightOverlay
step={tourStep()!}
stepIndex={tourStepIndex()}
total={activeTourSteps().length}
onNext={nextTourStep}
onBack={() => setTourStepIndex(i => Math.max(0, i - 1))}
onSkip={finishTour}
/>
</Show>
</div>
);
}