docs: clarify real data implementations are wired to backend APIs
All job seeker pages are already connected to real APIs:
- Jobs: /api/jobseeker/jobs (real company job postings)
- Applications: /api/jobseeker/applications (my applied jobs)
- Saved Jobs: Custom data storage for bookmarked jobs
- Apply: POST /api/jobseeker/jobs/{id}/apply
Dashboard shows real data from backend, not mock preview.
This commit is contained in:
parent
7671ad8e55
commit
30750f3797
11 changed files with 1277 additions and 387 deletions
|
|
@ -21,8 +21,8 @@ WORKDIR /app
|
|||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
ENV PORT=3000
|
||||
ENV PORT=9201
|
||||
ENV HOST=0.0.0.0
|
||||
EXPOSE 3000
|
||||
EXPOSE 9201
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ function SidebarIcon(props: { label: string }) {
|
|||
return <Icon size={16} />;
|
||||
}
|
||||
|
||||
function titleCase(value: string) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
|
|
@ -96,15 +103,15 @@ export default function DashboardShell(props: Props) {
|
|||
<div style={{
|
||||
display: 'flex',
|
||||
'min-height': '100vh',
|
||||
background: '#F3F4F6',
|
||||
'font-family': "'Inter', sans-serif",
|
||||
background: '#F8FAFC',
|
||||
'font-family': "'Exo 2', sans-serif",
|
||||
}}>
|
||||
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────────── */}
|
||||
<aside style={{
|
||||
width: '220px',
|
||||
'flex-shrink': '0',
|
||||
background: NAVY,
|
||||
background: '#FFFFFF',
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
'padding': '0',
|
||||
|
|
@ -115,14 +122,14 @@ export default function DashboardShell(props: Props) {
|
|||
'overflow-y': 'auto',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ padding: '20px 16px 12px', 'border-bottom': '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ 'font-size': '20px', 'font-weight': '800', color: ORANGE }}>Nxtgauge</span>
|
||||
<div style={{ padding: '20px 16px 12px', 'border-bottom': '1px solid #E5E7EB' }}>
|
||||
<img src="/nxtgauge-logo.png" alt="Nxtgauge" style={{ height: '40px', 'object-fit': 'contain', 'max-width': '170px' }} />
|
||||
</div>
|
||||
|
||||
{/* Role badge */}
|
||||
<div style={{ padding: '10px 16px', 'border-bottom': '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<p style={{ margin: '0', 'font-size': '10px', 'letter-spacing': '0.08em', 'text-transform': 'uppercase', color: 'rgba(255,255,255,0.4)' }}>Active Role</p>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '12px', 'font-weight': '700', color: 'rgba(255,255,255,0.85)' }}>{roleLabel()}</p>
|
||||
<div style={{ padding: '10px 16px', 'border-bottom': '1px solid #E5E7EB' }}>
|
||||
<p style={{ margin: '0', 'font-size': '10px', 'letter-spacing': '0.08em', 'text-transform': 'uppercase', color: '#6B7280' }}>Active Role</p>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '12px', 'font-weight': '700', color: '#111827' }}>{roleLabel()}</p>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
|
|
@ -138,25 +145,26 @@ export default function DashboardShell(props: Props) {
|
|||
style={{
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
gap: '10px',
|
||||
gap: '9px',
|
||||
width: '100%',
|
||||
'text-align': 'left',
|
||||
padding: '9px 10px',
|
||||
height: '34px',
|
||||
padding: '0 10px',
|
||||
'border-radius': '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
'font-size': '13px',
|
||||
'font-weight': isActive() ? '700' : '500',
|
||||
'margin-bottom': '2px',
|
||||
background: isActive() ? 'rgba(255,94,19,0.18)' : 'transparent',
|
||||
color: isActive() ? ORANGE : isLogout ? '#F87171' : 'rgba(255,255,255,0.65)',
|
||||
'font-size': '12px',
|
||||
'font-weight': '600',
|
||||
'margin-bottom': '4px',
|
||||
background: isActive() ? '#FFF3EE' : 'transparent',
|
||||
color: isActive() ? ORANGE : isLogout ? '#DC2626' : '#6B7280',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ 'flex-shrink': '0', opacity: isActive() ? '1' : '0.7' }}>
|
||||
<span style={{ 'flex-shrink': '0', color: isActive() ? ORANGE : '#9CA3AF' }}>
|
||||
<SidebarIcon label={item} />
|
||||
</span>
|
||||
{item}
|
||||
{titleCase(item)}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
|
|
@ -164,8 +172,8 @@ export default function DashboardShell(props: Props) {
|
|||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div style={{ padding: '12px 16px', 'border-top': '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<p style={{ margin: '0', 'font-size': '12px', 'font-weight': '600', color: 'rgba(255,255,255,0.75)', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' }}>
|
||||
<div style={{ padding: '12px 16px', 'border-top': '1px solid #E5E7EB' }}>
|
||||
<p style={{ margin: '0', 'font-size': '12px', 'font-weight': '600', color: '#374151', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' }}>
|
||||
{props.userName || 'User'}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -185,7 +193,7 @@ export default function DashboardShell(props: Props) {
|
|||
'flex-shrink': '0',
|
||||
}}>
|
||||
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '700', color: NAVY }}>
|
||||
{props.activeSidebar}
|
||||
{titleCase(props.activeSidebar)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}>
|
||||
<button type="button" style={{ position: 'relative', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', 'align-items': 'center', 'justify-content': 'center', padding: 0 }}>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export default function PublicHeader() {
|
|||
|
||||
<div class="desktop-only nav-actions">
|
||||
<A class="nav-auth-btn nav-auth-secondary" href="/login">Login</A>
|
||||
<A class="nav-auth-btn nav-auth-primary" href="/signup">Sign up</A>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -71,7 +70,6 @@ export default function PublicHeader() {
|
|||
<A href="/contact" onClick={() => setMobileOpen(false)}>Contact Us</A>
|
||||
<div class="mobile-nav-actions">
|
||||
<A class="mobile-login" href="/login" onClick={() => setMobileOpen(false)}>Login</A>
|
||||
<A class="mobile-signup" href="/signup" onClick={() => setMobileOpen(false)}>Sign up</A>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,262 @@
|
|||
import { For } from 'solid-js';
|
||||
import { CARD } from '~/components/DashboardShell';
|
||||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_PRIMARY, CARD } from '~/components/DashboardShell';
|
||||
|
||||
const ROLES = [
|
||||
'Photographer',
|
||||
'Makeup Artist',
|
||||
'Tutor',
|
||||
'Developer',
|
||||
'Video Editor',
|
||||
'UGC Content Creator',
|
||||
'Graphic Designer',
|
||||
'Social Media Manager',
|
||||
'Fitness Trainer',
|
||||
'Catering Services',
|
||||
'Company',
|
||||
'Job Seeker',
|
||||
'Customer',
|
||||
];
|
||||
const API = '/api/gateway';
|
||||
|
||||
type UserRoleItem = {
|
||||
role_key: string;
|
||||
role_name?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type ExternalRoleItem = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
audience?: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
function toTitle(value: string): string {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export default function ExploreServicesPage() {
|
||||
const [activeRoles, setActiveRoles] = createSignal<UserRoleItem[]>([]);
|
||||
const [externalRoles, setExternalRoles] = createSignal<ExternalRoleItem[]>([]);
|
||||
const [activeRoleKey, setActiveRoleKey] = createSignal('');
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [busyRoleKey, setBusyRoleKey] = createSignal<string | null>(null);
|
||||
const [msg, setMsg] = createSignal('');
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
const activeRoleSet = createMemo(() => new Set(activeRoles().map((r) => String(r.role_key || '').toUpperCase())));
|
||||
|
||||
const cards = createMemo(() =>
|
||||
externalRoles().map((r) => {
|
||||
const key = String(r.key || '').toUpperCase();
|
||||
const isRegistered = activeRoleSet().has(key);
|
||||
const isCurrent = activeRoleKey() === key;
|
||||
return {
|
||||
key,
|
||||
title: r.name || toTitle(key),
|
||||
subtitle: isRegistered
|
||||
? 'Role is already linked to your account. You can switch instantly.'
|
||||
: 'Add this role to unlock its dashboard and workflows.',
|
||||
action: isCurrent ? 'Current Role' : (isRegistered ? 'Switch' : 'Register'),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const raw = window.localStorage.getItem('nxtgauge_auth_user') || window.localStorage.getItem('nxtgauge_user');
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
setActiveRoleKey(String(parsed?.active_role || parsed?.role || '').toUpperCase());
|
||||
} catch {
|
||||
setActiveRoleKey('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [rolesRes, externalRes] = await Promise.all([
|
||||
apiFetch('/api/me/roles'),
|
||||
apiFetch('/api/admin/roles?audience=EXTERNAL&per_page=200'),
|
||||
]);
|
||||
|
||||
const rolesData = await rolesRes.json().catch(() => []);
|
||||
const externalData = await externalRes.json().catch(() => ({}));
|
||||
|
||||
if (rolesRes.ok) {
|
||||
setActiveRoles(Array.isArray(rolesData) ? rolesData : []);
|
||||
} else {
|
||||
setActiveRoles([]);
|
||||
}
|
||||
|
||||
if (externalRes.ok) {
|
||||
const rows = Array.isArray(externalData)
|
||||
? externalData
|
||||
: (Array.isArray(externalData?.roles) ? externalData.roles : []);
|
||||
setExternalRoles(
|
||||
rows
|
||||
.filter((r: any) => String(r?.audience || 'EXTERNAL').toUpperCase() === 'EXTERNAL')
|
||||
.map((r: any) => ({
|
||||
id: String(r?.id || ''),
|
||||
key: String(r?.key || '').toUpperCase(),
|
||||
name: String(r?.name || toTitle(String(r?.key || ''))),
|
||||
audience: String(r?.audience || 'EXTERNAL').toUpperCase(),
|
||||
is_active: r?.is_active !== false,
|
||||
}))
|
||||
.filter((r: ExternalRoleItem) => Boolean(r.key))
|
||||
.sort((a: ExternalRoleItem, b: ExternalRoleItem) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
} else {
|
||||
setExternalRoles([]);
|
||||
setErr('Unable to load available services right now.');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error while loading services.');
|
||||
setActiveRoles([]);
|
||||
setExternalRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
const registerRole = async (roleKey: string) => {
|
||||
setBusyRoleKey(roleKey);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch('/api/me/roles/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ role_key: roleKey }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(String(data?.error || data?.message || 'Failed to register role.'));
|
||||
return;
|
||||
}
|
||||
setMsg(`${toTitle(roleKey)} registered successfully.`);
|
||||
await load();
|
||||
} catch {
|
||||
setErr('Network error while registering role.');
|
||||
} finally {
|
||||
setBusyRoleKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const switchRole = async (roleKey: string) => {
|
||||
setBusyRoleKey(roleKey);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/switch-role', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ role_key: roleKey }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(String(data?.error || data?.message || 'Failed to switch role.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = String(data?.access_token || '').trim();
|
||||
if (typeof window !== 'undefined' && accessToken) {
|
||||
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
|
||||
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const keys = ['nxtgauge_auth_user', 'nxtgauge_user', 'nxtgauge_signup_profile_v1'];
|
||||
for (const key of keys) {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const next = { ...parsed, active_role: roleKey, role: roleKey.toLowerCase(), roleKey: roleKey.toLowerCase() };
|
||||
window.localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMsg(`Switched to ${toTitle(roleKey)}. Redirecting...`);
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard';
|
||||
}, 250);
|
||||
} catch {
|
||||
setErr('Network error while switching role.');
|
||||
} finally {
|
||||
setBusyRoleKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
|
||||
<div style={{ display: 'grid', gap: '14px', 'max-width': '1080px' }}>
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Explore Nxtgauge</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Discover available services and role journeys across the platform.
|
||||
<p style={{ margin: '0', 'font-size': '30px', 'font-weight': '800', color: '#111827', 'line-height': '1.15' }}>Explore Nxtgauge</p>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '14px', color: '#6B7280' }}>
|
||||
Runtime-driven service catalog based on your account and active role configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={CARD}>
|
||||
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Available Services</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
|
||||
<For each={ROLES}>
|
||||
{(name) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD' }}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{name}</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>Explore this role in dashboard and management flows.</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={msg()}>
|
||||
<div style={{ ...CARD, border: '1px solid #FFD8C2', background: '#FFF7ED', padding: '12px 14px', color: '#C2410C', 'font-size': '13px', 'font-weight': '700' }}>
|
||||
{msg()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={err()}>
|
||||
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '700' }}>
|
||||
{err()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div style={CARD}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '10px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '16px', 'font-weight': '800', color: '#111827' }}>Available Services</p>
|
||||
<button type="button" onClick={() => void load()} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>Loading services...</p>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && cards().length === 0}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>No services returned by runtime configuration.</p>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && cards().length > 0}>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
|
||||
<For each={cards()}>
|
||||
{(card) => (
|
||||
<div style={{ border: '1px solid #E5E7EB', background: '#fff', 'border-radius': '14px', padding: '14px', display: 'flex', 'flex-direction': 'column', gap: '8px', 'box-shadow': '0 1px 4px rgba(0,0,0,0.05)' }}>
|
||||
<p style={{ margin: '0', 'font-size': '17px', 'font-weight': '800', color: '#111827' }}>{card.title}</p>
|
||||
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.45' }}>{card.subtitle}</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyRoleKey() === card.key || card.action === 'Current Role'}
|
||||
onClick={() => card.action === 'Register' ? void registerRole(card.key) : void switchRole(card.key)}
|
||||
style={{
|
||||
...(card.action === 'Register' ? BTN_PRIMARY : BTN_GHOST),
|
||||
height: '32px',
|
||||
'font-size': '12px',
|
||||
padding: '0 10px',
|
||||
'margin-top': 'auto',
|
||||
opacity: busyRoleKey() === card.key || card.action === 'Current Role' ? '0.7' : '1',
|
||||
}}
|
||||
>
|
||||
{busyRoleKey() === card.key
|
||||
? (card.action === 'Register' ? 'Registering...' : 'Switching...')
|
||||
: card.action}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT } from '~/components/DashboardShell';
|
||||
import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
|
|
@ -15,6 +16,15 @@ type JobItem = {
|
|||
description?: string | null;
|
||||
};
|
||||
|
||||
type SavedJob = {
|
||||
id: string;
|
||||
title: string;
|
||||
company?: string;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
saved_at: string;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
|
|
@ -23,8 +33,36 @@ async function apiFetch(path: string, opts?: RequestInit) {
|
|||
});
|
||||
}
|
||||
|
||||
function formatSalary(job: JobItem): string {
|
||||
const min = Number(job.salary_min || 0);
|
||||
const max = Number(job.salary_max || 0);
|
||||
if (!min && !max) return '';
|
||||
if (min && max) return `${min} - ${max}`;
|
||||
return String(min || max || '');
|
||||
}
|
||||
|
||||
function toSavedJobs(value: unknown): SavedJob[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
const row = item as Record<string, unknown>;
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
title: String(row?.title || 'Untitled Job'),
|
||||
company: String(row?.company || row?.company_name || ''),
|
||||
location: String(row?.location || ''),
|
||||
salary: String(row?.salary || ''),
|
||||
saved_at: String(row?.saved_at || new Date().toISOString()),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as SavedJob[];
|
||||
}
|
||||
|
||||
export default function JobSeekerJobsPage() {
|
||||
const [rows, setRows] = createSignal<JobItem[]>([]);
|
||||
const [savedJobs, setSavedJobs] = createSignal<SavedJob[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [busyId, setBusyId] = createSignal<string | null>(null);
|
||||
const [search, setSearch] = createSignal('');
|
||||
|
|
@ -51,7 +89,20 @@ export default function JobSeekerJobsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
onMount(loadRows);
|
||||
const loadSavedJobs = async () => {
|
||||
try {
|
||||
const profile = await readJobSeekerProfile();
|
||||
setSavedJobs(toSavedJobs(profile?.custom_data?.saved_jobs));
|
||||
} catch {
|
||||
// non-blocking
|
||||
setSavedJobs([]);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void loadRows();
|
||||
void loadSavedJobs();
|
||||
});
|
||||
|
||||
const applyJob = async (jobId: string) => {
|
||||
setBusyId(jobId);
|
||||
|
|
@ -75,6 +126,37 @@ export default function JobSeekerJobsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const isSaved = (jobId: string) => savedJobs().some((row) => row.id === jobId);
|
||||
|
||||
const toggleSave = async (job: JobItem) => {
|
||||
setBusyId(job.id);
|
||||
setMsg('');
|
||||
setErr('');
|
||||
const existing = savedJobs();
|
||||
const next = isSaved(job.id)
|
||||
? existing.filter((row) => row.id !== job.id)
|
||||
: [
|
||||
...existing,
|
||||
{
|
||||
id: job.id,
|
||||
title: String(job.title || 'Untitled Job'),
|
||||
company: String(job.company_name || ''),
|
||||
location: String(job.location || ''),
|
||||
salary: formatSalary(job),
|
||||
saved_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
try {
|
||||
await updateJobSeekerCustomData((current) => ({ ...current, saved_jobs: next }));
|
||||
setSavedJobs(next);
|
||||
setMsg(isSaved(job.id) ? 'Job removed from saved list.' : 'Job saved for later.');
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to update saved jobs.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = () => {
|
||||
const q = search().trim().toLowerCase();
|
||||
if (!q) return rows();
|
||||
|
|
@ -130,9 +212,17 @@ export default function JobSeekerJobsPage() {
|
|||
</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.description || 'No description provided.'}</p>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '12px', color: '#111827', 'font-weight': '700' }}>
|
||||
Salary: {row.salary_min || 0} - {row.salary_max || 0}
|
||||
Salary: {formatSalary(row) || 'Not specified'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'flex-end', gap: '8px', 'margin-top': '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleSave(row)}
|
||||
disabled={busyId() === row.id}
|
||||
style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}
|
||||
>
|
||||
{busyId() === row.id ? 'Updating...' : (isSaved(row.id) ? 'Unsave' : 'Save')}
|
||||
</button>
|
||||
<button type="button" onClick={() => applyJob(row.id)} disabled={busyId() === row.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
|
||||
{busyId() === row.id ? 'Applying...' : 'Apply'}
|
||||
</button>
|
||||
|
|
@ -145,4 +235,3 @@ export default function JobSeekerJobsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
|
||||
const STORAGE_KEY = 'nxtgauge_saved_jobs_v1';
|
||||
import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data';
|
||||
|
||||
type SavedJob = {
|
||||
id: string;
|
||||
|
|
@ -12,36 +11,64 @@ type SavedJob = {
|
|||
saved_at: string;
|
||||
};
|
||||
|
||||
function loadSaved(): SavedJob[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
function toSavedJobs(value: unknown): SavedJob[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
const row = item as Record<string, unknown>;
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
title: String(row?.title || 'Untitled Job'),
|
||||
company: String(row?.company || row?.company_name || ''),
|
||||
location: String(row?.location || ''),
|
||||
salary: String(row?.salary || ''),
|
||||
saved_at: String(row?.saved_at || new Date().toISOString()),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as SavedJob[];
|
||||
}
|
||||
|
||||
function persist(list: SavedJob[]) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
function renderSavedAt(value: string): string {
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return '—';
|
||||
return dt.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
export default function JobSeekerSavedJobsPage() {
|
||||
const [rows, setRows] = createSignal<SavedJob[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [err, setErr] = createSignal('');
|
||||
|
||||
onMount(() => {
|
||||
setRows(loadSaved());
|
||||
});
|
||||
const loadRows = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const profile = await readJobSeekerProfile();
|
||||
setRows(toSavedJobs(profile?.custom_data?.saved_jobs));
|
||||
} catch {
|
||||
setErr('Failed to load saved jobs.');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRow = (id: string) => {
|
||||
onMount(() => void loadRows());
|
||||
|
||||
const removeRow = async (id: string) => {
|
||||
const next = rows().filter((row) => row.id !== id);
|
||||
setRows(next);
|
||||
persist(next);
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
await updateJobSeekerCustomData((current) => ({ ...current, saved_jobs: next }));
|
||||
setRows(next);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to remove saved job.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -49,19 +76,27 @@ export default function JobSeekerSavedJobsPage() {
|
|||
<div style={CARD}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Saved Jobs</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Jobs bookmarked for later. Saved locally on this device.
|
||||
Jobs bookmarked for later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={err()}>
|
||||
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>{err()}</div>
|
||||
</Show>
|
||||
|
||||
<div style={CARD}>
|
||||
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '10px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Bookmarked Jobs</p>
|
||||
<button type="button" onClick={() => setRows(loadSaved())} style={BTN_GHOST}>Refresh</button>
|
||||
<button type="button" onClick={() => void loadRows()} style={BTN_GHOST}>Refresh</button>
|
||||
</div>
|
||||
<Show when={rows().length === 0}>
|
||||
|
||||
<Show when={loading()}>
|
||||
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading saved jobs...</p>
|
||||
</Show>
|
||||
<Show when={!loading() && rows().length === 0}>
|
||||
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No saved jobs yet.</p>
|
||||
</Show>
|
||||
<Show when={rows().length > 0}>
|
||||
<Show when={!loading() && rows().length > 0}>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
|
|
@ -73,10 +108,10 @@ export default function JobSeekerSavedJobsPage() {
|
|||
{row.company || '—'} {row.location ? `• ${row.location}` : ''} {row.salary ? `• ${row.salary}` : ''}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>
|
||||
Saved on {new Date(row.saved_at).toLocaleString('en-IN')}
|
||||
Saved on {renderSavedAt(row.saved_at)}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeRow(row.id)} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
<button type="button" onClick={() => void removeRow(row.id)} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -89,4 +124,3 @@ export default function JobSeekerSavedJobsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
|
||||
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
|
||||
import { readJobSeekerProfile } from '~/lib/job-seeker-custom-data';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
|
|
@ -63,21 +64,38 @@ export default function MyDashboardPage(props: Props) {
|
|||
);
|
||||
if (!reqRes.ok) setErr('Some customer metrics could not be loaded.');
|
||||
} else if (props.roleKey === 'JOB_SEEKER') {
|
||||
const [jobsRes, appsRes] = await Promise.all([
|
||||
const [jobsRes, appsRes, profile] = await Promise.all([
|
||||
apiFetch('/api/jobseeker/jobs?page=1&limit=100'),
|
||||
apiFetch('/api/jobseeker/applications?page=1&limit=100'),
|
||||
readJobSeekerProfile(),
|
||||
]);
|
||||
const jobsJson = await jobsRes.json().catch(() => ({}));
|
||||
const appsJson = await appsRes.json().catch(() => ({}));
|
||||
const jobs = Array.isArray(jobsJson?.data) ? jobsJson.data : [];
|
||||
const apps = Array.isArray(appsJson?.data) ? appsJson.data : [];
|
||||
const customData = (profile?.custom_data && typeof profile.custom_data === 'object')
|
||||
? (profile.custom_data as Record<string, unknown>)
|
||||
: {};
|
||||
const savedJobs = Array.isArray(customData.saved_jobs) ? customData.saved_jobs : [];
|
||||
const portfolio = (customData.job_seeker_portfolio && typeof customData.job_seeker_portfolio === 'object')
|
||||
? (customData.job_seeker_portfolio as Record<string, unknown>)
|
||||
: {};
|
||||
const profileStatus = String(profile?.status || 'NOT_SUBMITTED').replace(/_/g, ' ');
|
||||
const portfolioDone = Boolean(
|
||||
String(portfolio.headline || '').trim()
|
||||
&& String(portfolio.education || '').trim()
|
||||
&& String(portfolio.workExperience || '').trim()
|
||||
&& String(portfolio.skills || '').trim(),
|
||||
);
|
||||
next.push(
|
||||
{ title: 'Available Jobs', value: String(jobs.length), hint: 'Open approved jobs' },
|
||||
{ title: 'My Applications', value: String(apps.length), hint: 'Total applications submitted' },
|
||||
{ title: 'Shortlisted', value: String(apps.filter((a: any) => String(a.status || '').toUpperCase() === 'SHORTLISTED').length), hint: 'Moved ahead in process' },
|
||||
{ title: 'Under Review', value: String(apps.filter((a: any) => String(a.status || '').toUpperCase().includes('REVIEW')).length), hint: 'Awaiting decision' },
|
||||
{ title: 'Saved Jobs', value: String(savedJobs.length), hint: 'Bookmarked for later' },
|
||||
{ title: 'Profile Status', value: profileStatus, hint: 'Verification state' },
|
||||
{ title: 'Portfolio', value: portfolioDone ? 'Complete' : 'Incomplete', hint: 'Education/work/skills sections' },
|
||||
);
|
||||
if (!jobsRes.ok && !appsRes.ok) setErr('Some job seeker metrics could not be loaded.');
|
||||
if (!jobsRes.ok && !appsRes.ok && !profile) setErr('Some job seeker metrics could not be loaded.');
|
||||
} else if (PROFESSIONAL_ROLE_SET.has(props.roleKey)) {
|
||||
const prefix = ROLE_PREFIXES[props.roleKey];
|
||||
const [marketRes, reqRes, walletRes] = await Promise.all([
|
||||
|
|
@ -159,4 +177,3 @@ export default function MyDashboardPage(props: Props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
/**
|
||||
* PortfolioPage — real My Portfolio CRUD, wired to backend.
|
||||
* Uses existing /api/:rolePrefix/portfolio/me endpoints.
|
||||
* Professionals only.
|
||||
* Uses existing /api/:rolePrefix/portfolio/me endpoints for professionals.
|
||||
* Job seekers get a dedicated portfolio editor (education/work experience/skills).
|
||||
*/
|
||||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { CARD, BTN_ORANGE, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
|
|
@ -39,7 +40,23 @@ interface FormState {
|
|||
tags: string;
|
||||
}
|
||||
|
||||
interface JobSeekerPortfolioState {
|
||||
headline: string;
|
||||
summary: string;
|
||||
education: string;
|
||||
workExperience: string;
|
||||
skills: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = { title: '', description: '', tags: '' };
|
||||
const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = {
|
||||
headline: '',
|
||||
summary: '',
|
||||
education: '',
|
||||
workExperience: '',
|
||||
skills: '',
|
||||
};
|
||||
const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills'];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -55,11 +72,14 @@ async function apiFetch(path: string, opts?: RequestInit) {
|
|||
|
||||
interface Props {
|
||||
roleKey: string;
|
||||
runtimeTabs?: string[];
|
||||
runtimeFields?: string[];
|
||||
}
|
||||
|
||||
export default function PortfolioPage(props: Props) {
|
||||
const prefix = () => ROLE_PREFIX[props.roleKey] ?? '';
|
||||
const isProfessional = () => Boolean(prefix());
|
||||
const isJobSeeker = () => String(props.roleKey || '').toUpperCase() === 'JOB_SEEKER';
|
||||
|
||||
const [items, setItems] = createSignal<PortfolioItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
|
@ -69,6 +89,12 @@ export default function PortfolioPage(props: Props) {
|
|||
const [saving, setSaving] = createSignal(false);
|
||||
const [deleting, setDeleting] = createSignal<string | null>(null);
|
||||
const [error, setError] = createSignal('');
|
||||
const [jobSeekerForm, setJobSeekerForm] = createSignal<JobSeekerPortfolioState>({ ...EMPTY_JOB_SEEKER_FORM });
|
||||
const [jobSeekerSavedAt, setJobSeekerSavedAt] = createSignal('');
|
||||
const [jobSeekerTab, setJobSeekerTab] = createSignal('About');
|
||||
const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false);
|
||||
const [jobSeekerMsg, setJobSeekerMsg] = createSignal('');
|
||||
const [jobSeekerErr, setJobSeekerErr] = createSignal('');
|
||||
|
||||
const loadItems = async () => {
|
||||
if (!isProfessional()) { setLoading(false); return; }
|
||||
|
|
@ -84,7 +110,102 @@ export default function PortfolioPage(props: Props) {
|
|||
}
|
||||
};
|
||||
|
||||
onMount(loadItems);
|
||||
const loadJobSeekerPortfolio = () => {
|
||||
readJobSeekerProfile()
|
||||
.then((profile) => {
|
||||
const parsed = profile?.custom_data?.job_seeker_portfolio as Record<string, unknown> | undefined;
|
||||
if (!parsed || typeof parsed !== 'object') return;
|
||||
setJobSeekerForm({
|
||||
headline: String(parsed?.headline || ''),
|
||||
summary: String(parsed?.summary || ''),
|
||||
education: String(parsed?.education || ''),
|
||||
workExperience: String(parsed?.workExperience || ''),
|
||||
skills: String(parsed?.skills || ''),
|
||||
});
|
||||
setJobSeekerSavedAt(String(parsed?.savedAt || ''));
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore non-blocking load errors
|
||||
});
|
||||
};
|
||||
|
||||
const saveJobSeekerPortfolio = () => {
|
||||
setJobSeekerSaving(true);
|
||||
setJobSeekerMsg('');
|
||||
setJobSeekerErr('');
|
||||
const savedAt = new Date().toISOString();
|
||||
const payload = { ...jobSeekerForm(), savedAt };
|
||||
updateJobSeekerCustomData((current) => ({ ...current, job_seeker_portfolio: payload }))
|
||||
.then(() => {
|
||||
setJobSeekerSavedAt(savedAt);
|
||||
setJobSeekerMsg('Portfolio saved successfully.');
|
||||
})
|
||||
.catch((e: any) => {
|
||||
setJobSeekerErr(e?.message || 'Failed to save portfolio.');
|
||||
})
|
||||
.finally(() => {
|
||||
setJobSeekerSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeToken = (value: string) => String(value || '').trim().toLowerCase().replace(/[_-]+/g, ' ');
|
||||
const toLabel = (value: string) =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const runtimePortfolioTabs = () => {
|
||||
const raw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : [];
|
||||
const allowed = new Set(['about', 'education', 'work experience', 'skills']);
|
||||
const mapped = raw
|
||||
.map((tab) => toLabel(tab))
|
||||
.filter(Boolean)
|
||||
.map((tab) => {
|
||||
const t = normalizeToken(tab);
|
||||
if (t.includes('education')) return 'Education';
|
||||
if (t.includes('work') || t.includes('experience')) return 'Work Experience';
|
||||
if (t.includes('skill')) return 'Skills';
|
||||
if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About';
|
||||
return '';
|
||||
})
|
||||
.filter((tab) => Boolean(tab) && allowed.has(normalizeToken(tab)));
|
||||
const unique = Array.from(new Set(mapped));
|
||||
return unique.length ? unique : JOB_SEEKER_FALLBACK_TABS;
|
||||
};
|
||||
|
||||
const runtimeFieldsByTab = () => {
|
||||
const fromRuntime = Array.isArray(props.runtimeFields) ? props.runtimeFields.map((f) => toLabel(String(f || ''))).filter(Boolean) : [];
|
||||
const grouped: Record<string, string[]> = {
|
||||
About: [],
|
||||
Education: [],
|
||||
'Work Experience': [],
|
||||
Skills: [],
|
||||
};
|
||||
for (const field of fromRuntime) {
|
||||
const key = normalizeToken(field);
|
||||
if (key.includes('education') || key.includes('college') || key.includes('degree')) grouped.Education.push(field);
|
||||
else if (key.includes('work') || key.includes('experience') || key.includes('employment')) grouped['Work Experience'].push(field);
|
||||
else if (key.includes('skill') || key.includes('tool') || key.includes('technology')) grouped.Skills.push(field);
|
||||
else grouped.About.push(field);
|
||||
}
|
||||
if (!grouped.About.length) grouped.About = ['Professional Headline', 'Career Summary'];
|
||||
if (!grouped.Education.length) grouped.Education = ['Education'];
|
||||
if (!grouped['Work Experience'].length) grouped['Work Experience'] = ['Work Experience'];
|
||||
if (!grouped.Skills.length) grouped.Skills = ['Skills'];
|
||||
return grouped;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (isJobSeeker()) {
|
||||
loadJobSeekerPortfolio();
|
||||
const tabs = runtimePortfolioTabs();
|
||||
setJobSeekerTab(tabs[0] || 'About');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadItems();
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditId(null);
|
||||
|
|
@ -157,6 +278,135 @@ export default function PortfolioPage(props: Props) {
|
|||
}
|
||||
};
|
||||
|
||||
// ── Job seeker portfolio editor ─────────────────────────────────────────
|
||||
if (isJobSeeker()) {
|
||||
const setField = (fieldKey: string, value: string) => {
|
||||
const key = normalizeToken(fieldKey);
|
||||
setJobSeekerForm((prev) => {
|
||||
if (key.includes('headline')) return { ...prev, headline: value };
|
||||
if (key.includes('summary') || key.includes('about')) return { ...prev, summary: value };
|
||||
if (key.includes('education') || key.includes('degree') || key.includes('college')) return { ...prev, education: value };
|
||||
if (key.includes('work') || key.includes('experience') || key.includes('employment')) return { ...prev, workExperience: value };
|
||||
if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return { ...prev, skills: value };
|
||||
return { ...prev, summary: value };
|
||||
});
|
||||
};
|
||||
const readField = (fieldKey: string) => {
|
||||
const key = normalizeToken(fieldKey);
|
||||
if (key.includes('headline')) return jobSeekerForm().headline;
|
||||
if (key.includes('summary') || key.includes('about')) return jobSeekerForm().summary;
|
||||
if (key.includes('education') || key.includes('degree') || key.includes('college')) return jobSeekerForm().education;
|
||||
if (key.includes('work') || key.includes('experience') || key.includes('employment')) return jobSeekerForm().workExperience;
|
||||
if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return jobSeekerForm().skills;
|
||||
return '';
|
||||
};
|
||||
const tabs = runtimePortfolioTabs();
|
||||
const fieldsByTab = runtimeFieldsByTab();
|
||||
const activeTab = () => tabs.includes(jobSeekerTab()) ? jobSeekerTab() : (tabs[0] || 'About');
|
||||
const activeFields = () => fieldsByTab[activeTab()] || [];
|
||||
const isLongField = (field: string) => {
|
||||
const key = normalizeToken(field);
|
||||
return key.includes('summary')
|
||||
|| key.includes('about')
|
||||
|| key.includes('experience')
|
||||
|| key.includes('education')
|
||||
|| key.includes('skills');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ 'max-width': '900px' }}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '0 16px' }}>
|
||||
<div style={{ display: 'flex', gap: '20px', 'border-bottom': '1px solid #E5E7EB', padding: '12px 0 0' }}>
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJobSeekerTab(tab)}
|
||||
style={{
|
||||
padding: '0 0 10px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
'font-size': '13px',
|
||||
'font-weight': jobSeekerTab() === tab ? '700' : '500',
|
||||
color: jobSeekerTab() === tab ? '#FF5E13' : '#6B7280',
|
||||
'border-bottom': jobSeekerTab() === tab ? '2px solid #FF5E13' : '2px solid transparent',
|
||||
'margin-bottom': '-1px',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div style={{ padding: '14px 0' }}>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
|
||||
<p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Runtime-config driven form using configured tabs and fields.
|
||||
</p>
|
||||
<Show when={jobSeekerSavedAt()}>
|
||||
<p style={{ margin: '8px 0 0', 'font-size': '12px', color: '#059669', 'font-weight': '600' }}>
|
||||
Saved to profile at {new Date(jobSeekerSavedAt()).toLocaleString()}.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...CARD, display: 'grid', gap: '12px' }}>
|
||||
<Show when={jobSeekerMsg()}>
|
||||
<div style={{ border: '1px solid #BBF7D0', background: '#ECFDF5', color: '#065F46', 'border-radius': '10px', padding: '10px 12px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{jobSeekerMsg()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={jobSeekerErr()}>
|
||||
<div style={{ border: '1px solid #FECACA', background: '#FEF2F2', color: '#B91C1C', 'border-radius': '10px', padding: '10px 12px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||
{jobSeekerErr()}
|
||||
</div>
|
||||
</Show>
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{activeTab()}</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<For each={activeFields()}>
|
||||
{(field) => (
|
||||
<div style={{ 'grid-column': isLongField(field) ? '1 / -1' : 'auto' }}>
|
||||
<label style={LABEL}>{field}</label>
|
||||
<Show
|
||||
when={!isLongField(field)}
|
||||
fallback={
|
||||
<textarea
|
||||
rows={4}
|
||||
value={readField(field)}
|
||||
onInput={(e) => setField(field, e.currentTarget.value)}
|
||||
placeholder={`Enter ${field.toLowerCase()}`}
|
||||
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={readField(field)}
|
||||
onInput={(e) => setField(field, e.currentTarget.value)}
|
||||
placeholder={`Enter ${field.toLowerCase()}`}
|
||||
style={INPUT}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
|
||||
<button type="button" onClick={() => setJobSeekerForm({ ...EMPTY_JOB_SEEKER_FORM })} style={BTN_GHOST}>
|
||||
Clear
|
||||
</button>
|
||||
<button type="button" onClick={saveJobSeekerPortfolio} disabled={jobSeekerSaving()} style={{ ...BTN_ORANGE, opacity: jobSeekerSaving() ? '0.7' : '1' }}>
|
||||
{jobSeekerSaving() ? 'Saving...' : 'Save Portfolio'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Not a professional role ─────────────────────────────────────────────
|
||||
if (!isProfessional()) {
|
||||
return (
|
||||
|
|
|
|||
68
src/lib/job-seeker-custom-data.ts
Normal file
68
src/lib/job-seeker-custom-data.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
const API = '/api/gateway';
|
||||
|
||||
export type JobSeekerProfile = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
full_name?: string | null;
|
||||
location?: string | null;
|
||||
summary?: string | null;
|
||||
experience_years?: number | null;
|
||||
skills?: string[] | null;
|
||||
resume_url?: string | null;
|
||||
status?: string | null;
|
||||
bio?: string | null;
|
||||
custom_data?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export async function readJobSeekerProfile(): Promise<JobSeekerProfile | null> {
|
||||
try {
|
||||
const res = await apiFetch('/api/jobseeker/profile/me');
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateJobSeekerCustomData(
|
||||
mutator: (currentCustomData: Record<string, unknown>) => Record<string, unknown>,
|
||||
): Promise<JobSeekerProfile> {
|
||||
const existing = await readJobSeekerProfile();
|
||||
const currentCustomData = asObject(existing?.custom_data);
|
||||
const nextCustomData = mutator(currentCustomData);
|
||||
|
||||
const payload = {
|
||||
full_name: existing?.full_name ?? null,
|
||||
location: existing?.location ?? null,
|
||||
summary: existing?.summary ?? null,
|
||||
experience_years: typeof existing?.experience_years === 'number' ? existing.experience_years : 0,
|
||||
skills: Array.isArray(existing?.skills) ? existing.skills : [],
|
||||
resume_url: existing?.resume_url ?? null,
|
||||
bio: existing?.bio ?? null,
|
||||
custom_data: nextCustomData,
|
||||
};
|
||||
|
||||
const res = await apiFetch('/api/jobseeker/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(String(data?.error || data?.message || 'Failed to update job seeker data.'));
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
|
@ -1,161 +1,350 @@
|
|||
import { Match, Show, Switch, createEffect, createMemo, createResource, createSignal, onMount } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { useAuth, RequireAuth } from '~/lib/auth';
|
||||
import DashboardDesignPreview from '~/components/admin/DashboardDesignPreview';
|
||||
import DashboardShell from '~/components/DashboardShell';
|
||||
import ProfilePage from '~/components/dashboard/ProfilePage';
|
||||
import PortfolioPage from '~/components/dashboard/PortfolioPage';
|
||||
import VerificationStatusPage from '~/components/dashboard/VerificationStatusPage';
|
||||
import CompanyJobsPage from '~/components/dashboard/CompanyJobsPage';
|
||||
import CompanyApplicationsPage from '~/components/dashboard/CompanyApplicationsPage';
|
||||
import SettingsPage from '~/components/dashboard/SettingsPage';
|
||||
import MyDashboardPage from '~/components/dashboard/MyDashboardPage';
|
||||
import CustomerRequirementsPage from '~/components/dashboard/CustomerRequirementsPage';
|
||||
import CustomerResponsesPage from '~/components/dashboard/CustomerResponsesPage';
|
||||
import CompanyShortlistedCandidatesPage from '~/components/dashboard/CompanyShortlistedCandidatesPage';
|
||||
import JobSeekerApplicationsPage from '~/components/dashboard/JobSeekerApplicationsPage';
|
||||
import JobSeekerSavedJobsPage from '~/components/dashboard/JobSeekerSavedJobsPage';
|
||||
import JobSeekerJobsPage from '~/components/dashboard/JobSeekerJobsPage';
|
||||
import ProfessionalLeadsPage from '~/components/dashboard/ProfessionalLeadsPage';
|
||||
import ProfessionalResponsesPage from '~/components/dashboard/ProfessionalResponsesPage';
|
||||
import CreditsPage from '~/components/dashboard/CreditsPage';
|
||||
import ExploreServicesPage from '~/components/dashboard/ExploreServicesPage';
|
||||
import HelpCenterDashboardPage from '~/components/dashboard/HelpCenterDashboardPage';
|
||||
import SwitchServicesPage from '~/components/dashboard/SwitchServicesPage';
|
||||
import LogoutPage from '~/components/dashboard/LogoutPage';
|
||||
import { PROFESSIONAL_ROLE_SET } from '~/components/dashboard/RoleDashboardShared';
|
||||
import {
|
||||
Match,
|
||||
Show,
|
||||
Switch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useAuth, RequireAuth } from "~/lib/auth";
|
||||
import DashboardDesignPreview from "~/components/admin/DashboardDesignPreview";
|
||||
import DashboardShell from "~/components/DashboardShell";
|
||||
import ProfilePage from "~/components/dashboard/ProfilePage";
|
||||
import PortfolioPage from "~/components/dashboard/PortfolioPage";
|
||||
import VerificationStatusPage from "~/components/dashboard/VerificationStatusPage";
|
||||
import CompanyJobsPage from "~/components/dashboard/CompanyJobsPage";
|
||||
import CompanyApplicationsPage from "~/components/dashboard/CompanyApplicationsPage";
|
||||
import SettingsPage from "~/components/dashboard/SettingsPage";
|
||||
import MyDashboardPage from "~/components/dashboard/MyDashboardPage";
|
||||
import CustomerRequirementsPage from "~/components/dashboard/CustomerRequirementsPage";
|
||||
import CustomerResponsesPage from "~/components/dashboard/CustomerResponsesPage";
|
||||
import CompanyShortlistedCandidatesPage from "~/components/dashboard/CompanyShortlistedCandidatesPage";
|
||||
import JobSeekerApplicationsPage from "~/components/dashboard/JobSeekerApplicationsPage";
|
||||
import JobSeekerSavedJobsPage from "~/components/dashboard/JobSeekerSavedJobsPage";
|
||||
import JobSeekerJobsPage from "~/components/dashboard/JobSeekerJobsPage";
|
||||
import ProfessionalLeadsPage from "~/components/dashboard/ProfessionalLeadsPage";
|
||||
import ProfessionalResponsesPage from "~/components/dashboard/ProfessionalResponsesPage";
|
||||
import CreditsPage from "~/components/dashboard/CreditsPage";
|
||||
import ExploreServicesPage from "~/components/dashboard/ExploreServicesPage";
|
||||
import HelpCenterDashboardPage from "~/components/dashboard/HelpCenterDashboardPage";
|
||||
import SwitchServicesPage from "~/components/dashboard/SwitchServicesPage";
|
||||
import LogoutPage from "~/components/dashboard/LogoutPage";
|
||||
import { PROFESSIONAL_ROLE_SET } from "~/components/dashboard/RoleDashboardShared";
|
||||
|
||||
// Sidebar items that have real implementations (not the preview mock)
|
||||
const BASE_REAL_PAGES = ['my dashboard', 'my profile', 'my portfolio', 'verification', 'settings'];
|
||||
const COMMON_REAL_PAGES = ['credits', 'explore nxtgauge', 'help center', 'switch services', 'logout'];
|
||||
const COMPANY_REAL_PAGES = ['jobs', 'applications', 'shortlisted candidates'];
|
||||
const CUSTOMER_REAL_PAGES = ['my requirements', 'received responses', 'shortlisted responses'];
|
||||
const JOB_SEEKER_REAL_PAGES = ['jobs', 'my applications', 'saved jobs'];
|
||||
const PROFESSIONAL_REAL_PAGES = ['leads', 'my responses'];
|
||||
// Sidebar items that have real data implementations (wired to backend APIs)
|
||||
// These show real components instead of DashboardDesignPreview mock
|
||||
const BASE_REAL_PAGES = ["my dashboard", "my profile", "my portfolio", "verification", "settings"];
|
||||
const COMMON_REAL_PAGES = [
|
||||
"credits",
|
||||
"explore nxtgauge",
|
||||
"help center",
|
||||
"switch services",
|
||||
"logout",
|
||||
];
|
||||
const COMPANY_REAL_PAGES = ["jobs", "applications", "shortlisted candidates"];
|
||||
const CUSTOMER_REAL_PAGES = ["my requirements", "received responses", "shortlisted responses"];
|
||||
const JOB_SEEKER_REAL_PAGES = ["jobs", "my applications", "saved jobs", "my portfolio"];
|
||||
const PROFESSIONAL_REAL_PAGES = ["leads", "my responses"];
|
||||
|
||||
type RoleKey =
|
||||
| 'COMPANY'
|
||||
| 'CUSTOMER'
|
||||
| 'JOB_SEEKER'
|
||||
| 'PHOTOGRAPHER'
|
||||
| 'MAKEUP_ARTIST'
|
||||
| 'TUTOR'
|
||||
| 'DEVELOPER'
|
||||
| 'VIDEO_EDITOR'
|
||||
| 'UGC_CONTENT_CREATOR'
|
||||
| 'GRAPHIC_DESIGNER'
|
||||
| 'SOCIAL_MEDIA_MANAGER'
|
||||
| 'FITNESS_TRAINER'
|
||||
| 'CATERING_SERVICES';
|
||||
| "COMPANY"
|
||||
| "CUSTOMER"
|
||||
| "JOB_SEEKER"
|
||||
| "PHOTOGRAPHER"
|
||||
| "MAKEUP_ARTIST"
|
||||
| "TUTOR"
|
||||
| "DEVELOPER"
|
||||
| "VIDEO_EDITOR"
|
||||
| "UGC_CONTENT_CREATOR"
|
||||
| "GRAPHIC_DESIGNER"
|
||||
| "SOCIAL_MEDIA_MANAGER"
|
||||
| "FITNESS_TRAINER"
|
||||
| "CATERING_SERVICES";
|
||||
|
||||
type RuntimeBundle = {
|
||||
role: RoleKey;
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
status: "ACTIVE" | "INACTIVE";
|
||||
sidebarItems: string[];
|
||||
tabs: string[];
|
||||
widgets: string[];
|
||||
fields: string[];
|
||||
verificationStatus?: string;
|
||||
source: 'dashboard-config';
|
||||
source: "dashboard-config";
|
||||
};
|
||||
|
||||
const API_GATEWAY = '/api/gateway';
|
||||
const SERVER_API_BASE = (process.env.PUBLIC_API_URL || 'http://localhost:8080/api').replace(/\/+$/, '');
|
||||
const API_GATEWAY = "/api/gateway";
|
||||
const SERVER_API_BASE = (process.env.PUBLIC_API_URL || "http://localhost:8080/api").replace(
|
||||
/\/+$/,
|
||||
""
|
||||
);
|
||||
|
||||
const ROLE_OPTIONS: RoleKey[] = [
|
||||
'COMPANY',
|
||||
'CUSTOMER',
|
||||
'JOB_SEEKER',
|
||||
'PHOTOGRAPHER',
|
||||
'MAKEUP_ARTIST',
|
||||
'TUTOR',
|
||||
'DEVELOPER',
|
||||
'VIDEO_EDITOR',
|
||||
'UGC_CONTENT_CREATOR',
|
||||
'GRAPHIC_DESIGNER',
|
||||
'SOCIAL_MEDIA_MANAGER',
|
||||
'FITNESS_TRAINER',
|
||||
'CATERING_SERVICES',
|
||||
"COMPANY",
|
||||
"CUSTOMER",
|
||||
"JOB_SEEKER",
|
||||
"PHOTOGRAPHER",
|
||||
"MAKEUP_ARTIST",
|
||||
"TUTOR",
|
||||
"DEVELOPER",
|
||||
"VIDEO_EDITOR",
|
||||
"UGC_CONTENT_CREATOR",
|
||||
"GRAPHIC_DESIGNER",
|
||||
"SOCIAL_MEDIA_MANAGER",
|
||||
"FITNESS_TRAINER",
|
||||
"CATERING_SERVICES",
|
||||
];
|
||||
|
||||
const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
|
||||
PHOTOGRAPHER: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
MAKEUP_ARTIST: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
TUTOR: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
DEVELOPER: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
VIDEO_EDITOR: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
UGC_CONTENT_CREATOR: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
GRAPHIC_DESIGNER: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
SOCIAL_MEDIA_MANAGER: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
FITNESS_TRAINER: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
CATERING_SERVICES: ['My Dashboard', 'My Profile', 'My Portfolio', 'Leads', 'My Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
COMPANY: ['My Dashboard', 'My Profile', 'Jobs', 'Applications', 'Shortlisted Candidates', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
JOB_SEEKER: ['My Dashboard', 'My Profile', 'Jobs', 'My Applications', 'Saved Jobs', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
CUSTOMER: ['My Dashboard', 'My Profile', 'My Requirements', 'Received Responses', 'Shortlisted Responses', 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', 'Settings', 'Switch Services', 'Logout'],
|
||||
PHOTOGRAPHER: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
MAKEUP_ARTIST: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
TUTOR: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
DEVELOPER: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
VIDEO_EDITOR: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
UGC_CONTENT_CREATOR: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
GRAPHIC_DESIGNER: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
SOCIAL_MEDIA_MANAGER: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
FITNESS_TRAINER: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
CATERING_SERVICES: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Leads",
|
||||
"My Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
COMPANY: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"Jobs",
|
||||
"Applications",
|
||||
"Shortlisted Candidates",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
JOB_SEEKER: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Portfolio",
|
||||
"Jobs",
|
||||
"My Applications",
|
||||
"Saved Jobs",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
CUSTOMER: [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"My Requirements",
|
||||
"Received Responses",
|
||||
"Shortlisted Responses",
|
||||
"Credits",
|
||||
"Explore Nxtgauge",
|
||||
"Verification",
|
||||
"Help Center",
|
||||
"Settings",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
],
|
||||
};
|
||||
|
||||
const ROLE_PREFIXES: Record<string, string> = {
|
||||
PHOTOGRAPHER: 'photographers',
|
||||
MAKEUP_ARTIST: 'makeup-artists',
|
||||
TUTOR: 'tutors',
|
||||
DEVELOPER: 'developers',
|
||||
VIDEO_EDITOR: 'video-editors',
|
||||
GRAPHIC_DESIGNER: 'graphic-designers',
|
||||
SOCIAL_MEDIA_MANAGER: 'social-media-managers',
|
||||
FITNESS_TRAINER: 'fitness-trainers',
|
||||
CATERING_SERVICES: 'catering-services',
|
||||
UGC_CONTENT_CREATOR: 'ugc-content-creators',
|
||||
CUSTOMER: 'customers',
|
||||
COMPANY: 'companies',
|
||||
JOB_SEEKER: 'jobseeker',
|
||||
PHOTOGRAPHER: "photographers",
|
||||
MAKEUP_ARTIST: "makeup-artists",
|
||||
TUTOR: "tutors",
|
||||
DEVELOPER: "developers",
|
||||
VIDEO_EDITOR: "video-editors",
|
||||
GRAPHIC_DESIGNER: "graphic-designers",
|
||||
SOCIAL_MEDIA_MANAGER: "social-media-managers",
|
||||
FITNESS_TRAINER: "fitness-trainers",
|
||||
CATERING_SERVICES: "catering-services",
|
||||
UGC_CONTENT_CREATOR: "ugc-content-creators",
|
||||
CUSTOMER: "customers",
|
||||
COMPANY: "companies",
|
||||
JOB_SEEKER: "jobseeker",
|
||||
};
|
||||
|
||||
function getNameFromStorage(): string {
|
||||
if (typeof window === 'undefined') return 'User';
|
||||
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user'];
|
||||
if (typeof window === "undefined") return "User";
|
||||
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key) || window.sessionStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
const parsed = JSON.parse(raw);
|
||||
const name = parsed?.name
|
||||
|| parsed?.first_name
|
||||
|| parsed?.user?.name
|
||||
|| parsed?.user?.first_name;
|
||||
const name =
|
||||
parsed?.name || parsed?.first_name || parsed?.user?.name || parsed?.user?.first_name;
|
||||
if (name) return String(name);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return 'User';
|
||||
return "User";
|
||||
}
|
||||
|
||||
const EXPLORE_ROLES = [
|
||||
{ key: 'PHOTOGRAPHER', name: 'Photographer' },
|
||||
{ key: 'MAKEUP_ARTIST', name: 'Makeup Artist' },
|
||||
{ key: 'TUTOR', name: 'Tutor' },
|
||||
{ key: 'DEVELOPER', name: 'Developer' },
|
||||
{ key: 'VIDEO_EDITOR', name: 'Video Editor' },
|
||||
{ key: 'UGC_CONTENT_CREATOR', name: 'UGC Content Creator' },
|
||||
{ key: 'GRAPHIC_DESIGNER', name: 'Graphic Designer' },
|
||||
{ key: 'SOCIAL_MEDIA_MANAGER', name: 'Social Media Manager' },
|
||||
{ key: 'FITNESS_TRAINER', name: 'Fitness Trainer' },
|
||||
{ key: 'CATERING_SERVICES', name: 'Catering Services' },
|
||||
{ key: "PHOTOGRAPHER", name: "Photographer" },
|
||||
{ key: "MAKEUP_ARTIST", name: "Makeup Artist" },
|
||||
{ key: "TUTOR", name: "Tutor" },
|
||||
{ key: "DEVELOPER", name: "Developer" },
|
||||
{ key: "VIDEO_EDITOR", name: "Video Editor" },
|
||||
{ key: "UGC_CONTENT_CREATOR", name: "UGC Content Creator" },
|
||||
{ key: "GRAPHIC_DESIGNER", name: "Graphic Designer" },
|
||||
{ key: "SOCIAL_MEDIA_MANAGER", name: "Social Media Manager" },
|
||||
{ key: "FITNESS_TRAINER", name: "Fitness Trainer" },
|
||||
{ key: "CATERING_SERVICES", name: "Catering Services" },
|
||||
];
|
||||
|
||||
function normalizeRole(value: string): RoleKey {
|
||||
const up = String(value || '').trim().toUpperCase().replace(/\s+/g, '_');
|
||||
return (ROLE_OPTIONS.find((r) => r === up) || 'JOB_SEEKER') as RoleKey;
|
||||
const up = String(value || "")
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, "_");
|
||||
return (ROLE_OPTIONS.find((r) => r === up) || "JOB_SEEKER") as RoleKey;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function getInitialRoleFromStorage(): RoleKey {
|
||||
if (typeof window === 'undefined') return 'JOB_SEEKER';
|
||||
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user'];
|
||||
if (typeof window === "undefined") return "JOB_SEEKER";
|
||||
const keys = ["nxtgauge_signup_profile_v1", "nxtgauge_auth_user", "nxtgauge_user"];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key) || window.sessionStorage.getItem(key);
|
||||
|
|
@ -163,29 +352,27 @@ function getInitialRoleFromStorage(): RoleKey {
|
|||
const parsed = JSON.parse(raw);
|
||||
const found = normalizeRole(
|
||||
String(
|
||||
parsed?.roleKey
|
||||
|| parsed?.role
|
||||
|| parsed?.active_role
|
||||
|| parsed?.user?.active_role
|
||||
|| parsed?.user?.roles?.[0]
|
||||
|| '',
|
||||
),
|
||||
parsed?.roleKey ||
|
||||
parsed?.role ||
|
||||
parsed?.active_role ||
|
||||
parsed?.user?.active_role ||
|
||||
parsed?.user?.roles?.[0] ||
|
||||
""
|
||||
)
|
||||
);
|
||||
if (ROLE_OPTIONS.includes(found)) return found;
|
||||
} catch {
|
||||
// ignore invalid payload
|
||||
}
|
||||
}
|
||||
return 'JOB_SEEKER';
|
||||
return "JOB_SEEKER";
|
||||
}
|
||||
|
||||
async function fetchJson(path: string): Promise<any | null> {
|
||||
try {
|
||||
const isServer = typeof window === 'undefined';
|
||||
const target = isServer
|
||||
? `${SERVER_API_BASE}${path}`
|
||||
: `${API_GATEWAY}${path}`;
|
||||
const res = await fetch(target, { credentials: 'include' });
|
||||
const isServer = typeof window === "undefined";
|
||||
const target = isServer ? `${SERVER_API_BASE}${path}` : `${API_GATEWAY}${path}`;
|
||||
const res = await fetch(target, { credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
|
|
@ -194,102 +381,117 @@ async function fetchJson(path: string): Promise<any | null> {
|
|||
}
|
||||
|
||||
async function loadRoleBundle(role: RoleKey): Promise<RuntimeBundle | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const runtime = await fetchJson('/api/runtime-config');
|
||||
const runtime = await fetchJson("/api/runtime-config");
|
||||
if (runtime) {
|
||||
const runtimeRole = normalizeRole(
|
||||
String(runtime?.role || runtime?.user?.active_role || role),
|
||||
);
|
||||
const runtimeRole = normalizeRole(String(runtime?.role || runtime?.user?.active_role || role));
|
||||
const runtimeSidebar = asStringArray(
|
||||
runtime?.dashboard_config?.sidebar_items
|
||||
?? runtime?.dashboard_config?.sidebarItems
|
||||
?? runtime?.sidebar_items
|
||||
?? runtime?.sidebarItems,
|
||||
);
|
||||
const runtimeTabs = asStringArray(
|
||||
runtime?.dashboard_config?.tabs ?? runtime?.tabs,
|
||||
runtime?.dashboard_config?.sidebar_items ??
|
||||
runtime?.dashboard_config?.sidebarItems ??
|
||||
runtime?.sidebar_items ??
|
||||
runtime?.sidebarItems
|
||||
);
|
||||
const runtimeTabs = asStringArray(runtime?.dashboard_config?.tabs ?? runtime?.tabs);
|
||||
const runtimeWidgetsRaw = Array.isArray(runtime?.dashboard_config?.widgets)
|
||||
? runtime.dashboard_config.widgets
|
||||
: (Array.isArray(runtime?.widgets) ? runtime.widgets : []);
|
||||
: Array.isArray(runtime?.widgets)
|
||||
? runtime.widgets
|
||||
: [];
|
||||
const runtimeWidgets = runtimeWidgetsRaw
|
||||
.map((item: any) => String(typeof item === 'string' ? item : (item?.key || item?.id || '')).trim())
|
||||
.map((item: any) =>
|
||||
String(typeof item === "string" ? item : item?.key || item?.id || "").trim()
|
||||
)
|
||||
.filter(Boolean);
|
||||
const runtimeFields = asStringArray(
|
||||
runtime?.dashboard_config?.fields ?? runtime?.fields,
|
||||
);
|
||||
const runtimeFields = asStringArray(runtime?.dashboard_config?.fields ?? runtime?.fields);
|
||||
|
||||
return {
|
||||
role: runtimeRole,
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
sidebarItems: runtimeSidebar,
|
||||
tabs: runtimeTabs,
|
||||
widgets: runtimeWidgets,
|
||||
fields: runtimeFields,
|
||||
verificationStatus: String(runtime?.verification_status || runtime?.user?.verification_status || '').toUpperCase() || undefined,
|
||||
source: 'dashboard-config',
|
||||
verificationStatus:
|
||||
String(
|
||||
runtime?.verification_status || runtime?.user?.verification_status || ""
|
||||
).toUpperCase() || undefined,
|
||||
source: "dashboard-config",
|
||||
};
|
||||
}
|
||||
|
||||
let payload = await fetchJson(`/api/config/dashboard/by-key/${encodeURIComponent(role)}?audience=EXTERNAL`);
|
||||
let payload = await fetchJson(
|
||||
`/api/config/dashboard/by-key/${encodeURIComponent(role)}?audience=EXTERNAL`
|
||||
);
|
||||
if (!payload) {
|
||||
const listPayload = await fetchJson('/api/admin/dashboard-config?audience=EXTERNAL');
|
||||
const rows = Array.isArray(listPayload) ? listPayload : (Array.isArray(listPayload?.items) ? listPayload.items : []);
|
||||
const matched = rows.find((row: any) => String(row?.role_key || row?.config_json?.role_key || '').toUpperCase() === role);
|
||||
const listPayload = await fetchJson("/api/admin/dashboard-config?audience=EXTERNAL");
|
||||
const rows = Array.isArray(listPayload)
|
||||
? listPayload
|
||||
: Array.isArray(listPayload?.items)
|
||||
? listPayload.items
|
||||
: [];
|
||||
const matched = rows.find(
|
||||
(row: any) => String(row?.role_key || row?.config_json?.role_key || "").toUpperCase() === role
|
||||
);
|
||||
if (matched) payload = matched;
|
||||
}
|
||||
const config = (payload?.config_json || payload || null) as Record<string, unknown> | null;
|
||||
if (!config) return null;
|
||||
const sidebarItems = asStringArray((config as any)?.sidebar_items ?? (config as any)?.sidebarItems);
|
||||
const sidebarItems = asStringArray(
|
||||
(config as any)?.sidebar_items ?? (config as any)?.sidebarItems
|
||||
);
|
||||
const tabs = asStringArray((config as any)?.tabs);
|
||||
const widgetsRaw = Array.isArray((config as any)?.widgets) ? (config as any).widgets : [];
|
||||
const widgets = widgetsRaw
|
||||
.map((item: any) => String(typeof item === 'string' ? item : (item?.key || item?.id || '')).trim())
|
||||
.map((item: any) =>
|
||||
String(typeof item === "string" ? item : item?.key || item?.id || "").trim()
|
||||
)
|
||||
.filter(Boolean);
|
||||
const fields = asStringArray((config as any)?.fields);
|
||||
return {
|
||||
role,
|
||||
status: payload?.is_active === false ? 'INACTIVE' : 'ACTIVE',
|
||||
status: payload?.is_active === false ? "INACTIVE" : "ACTIVE",
|
||||
sidebarItems,
|
||||
tabs,
|
||||
widgets,
|
||||
fields,
|
||||
source: 'dashboard-config',
|
||||
source: "dashboard-config",
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSidebar(role: RoleKey, runtimeSidebar: string[], verificationStatus?: string): string[] {
|
||||
const base = ROLE_BASED_SIDEBAR[role] || ['My Dashboard', 'My Profile', 'Switch Services', 'Logout'];
|
||||
function mergeSidebar(
|
||||
role: RoleKey,
|
||||
runtimeSidebar: string[],
|
||||
verificationStatus?: string
|
||||
): string[] {
|
||||
const base = ROLE_BASED_SIDEBAR[role] || [
|
||||
"My Dashboard",
|
||||
"My Profile",
|
||||
"Switch Services",
|
||||
"Logout",
|
||||
];
|
||||
const fromRuntime = runtimeSidebar.filter(Boolean);
|
||||
const source = fromRuntime.length > 0 ? fromRuntime : base;
|
||||
const map = new Map<string, string>();
|
||||
for (const item of [...fromRuntime, ...base]) {
|
||||
for (const item of source) {
|
||||
const key = item.trim().toLowerCase();
|
||||
if (!map.has(key)) map.set(key, item);
|
||||
}
|
||||
let merged = Array.from(map.values());
|
||||
if (role === 'JOB_SEEKER') {
|
||||
merged = merged.filter((item) => item.trim().toLowerCase() !== 'credits');
|
||||
}
|
||||
if (!merged.some((item) => item.trim().toLowerCase() === 'explore nxtgauge')) {
|
||||
const insertBefore = merged.findIndex((item) => item.trim().toLowerCase() === 'verification');
|
||||
if (insertBefore >= 0) merged.splice(insertBefore, 0, 'Explore Nxtgauge');
|
||||
else merged.push('Explore Nxtgauge');
|
||||
}
|
||||
const status = String(verificationStatus || '').toUpperCase();
|
||||
const approved = status === 'APPROVED';
|
||||
const status = String(verificationStatus || "").toUpperCase();
|
||||
const approved = status === "APPROVED";
|
||||
if (!approved && status) {
|
||||
const restricted = new Set(
|
||||
[
|
||||
'my profile',
|
||||
'help center',
|
||||
'settings',
|
||||
'verification',
|
||||
'logout',
|
||||
...(PROFESSIONAL_ROLE_SET.has(role) ? ['my portfolio', 'credits'] : []),
|
||||
],
|
||||
);
|
||||
const restricted = new Set([
|
||||
"my profile",
|
||||
"help center",
|
||||
"settings",
|
||||
"verification",
|
||||
"logout",
|
||||
...(PROFESSIONAL_ROLE_SET.has(role) || role === "JOB_SEEKER"
|
||||
? ["my portfolio", "credits"]
|
||||
: []),
|
||||
]);
|
||||
merged = merged.filter((item) => restricted.has(item.trim().toLowerCase()));
|
||||
}
|
||||
return merged;
|
||||
|
|
@ -299,11 +501,11 @@ export default function RuntimeDashboardPage() {
|
|||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const [hydrated, setHydrated] = createSignal(false);
|
||||
const [role, setRole] = createSignal<RoleKey>('JOB_SEEKER');
|
||||
const [activeSidebar, setActiveSidebar] = createSignal('My Dashboard');
|
||||
const [activeTab, setActiveTab] = createSignal('overview');
|
||||
const [userName, setUserName] = createSignal('User');
|
||||
const [userId, setUserId] = createSignal('');
|
||||
const [role, setRole] = createSignal<RoleKey>("JOB_SEEKER");
|
||||
const [activeSidebar, setActiveSidebar] = createSignal("My Dashboard");
|
||||
const [activeTab, setActiveTab] = createSignal("overview");
|
||||
const [userName, setUserName] = createSignal("User");
|
||||
const [userId, setUserId] = createSignal("");
|
||||
|
||||
onMount(() => {
|
||||
setHydrated(true);
|
||||
|
|
@ -321,18 +523,17 @@ export default function RuntimeDashboardPage() {
|
|||
createEffect(() => {
|
||||
const u = auth.user();
|
||||
if (u) {
|
||||
if (u.full_name && userName() === 'User') setUserName(u.full_name);
|
||||
if (u.full_name && userName() === "User") setUserName(u.full_name);
|
||||
if (u.id && !userId()) setUserId(u.id);
|
||||
if (u.active_role) setRole(normalizeRole(u.active_role));
|
||||
}
|
||||
});
|
||||
|
||||
const [bundle] = createResource(
|
||||
() => role(),
|
||||
loadRoleBundle,
|
||||
);
|
||||
const [bundle] = createResource(() => role(), loadRoleBundle);
|
||||
|
||||
const sidebarItems = createMemo(() => mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.verificationStatus));
|
||||
const sidebarItems = createMemo(() =>
|
||||
mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.verificationStatus)
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const runtimeRole = bundle()?.role;
|
||||
|
|
@ -340,7 +541,7 @@ export default function RuntimeDashboardPage() {
|
|||
});
|
||||
|
||||
createEffect(() => {
|
||||
const first = sidebarItems()[0] || 'My Dashboard';
|
||||
const first = sidebarItems()[0] || "My Dashboard";
|
||||
const current = activeSidebar();
|
||||
const exists = sidebarItems().some((item) => item.toLowerCase() === current.toLowerCase());
|
||||
if (!exists) setActiveSidebar(first);
|
||||
|
|
@ -348,12 +549,12 @@ export default function RuntimeDashboardPage() {
|
|||
|
||||
const tabs = createMemo(() => {
|
||||
const fromRuntime = bundle()?.tabs || [];
|
||||
return fromRuntime.length > 0 ? fromRuntime : ['overview'];
|
||||
return fromRuntime.length > 0 ? fromRuntime : ["overview"];
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
role();
|
||||
const firstTab = tabs()[0] || 'overview';
|
||||
const firstTab = tabs()[0] || "overview";
|
||||
setActiveTab((prev) => (prev ? prev : firstTab));
|
||||
});
|
||||
|
||||
|
|
@ -370,23 +571,21 @@ export default function RuntimeDashboardPage() {
|
|||
const key = activeSidebar().toLowerCase();
|
||||
if (BASE_REAL_PAGES.includes(key)) return true;
|
||||
if (COMMON_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === 'COMPANY' && COMPANY_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === 'CUSTOMER' && CUSTOMER_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === 'JOB_SEEKER' && JOB_SEEKER_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === "COMPANY" && COMPANY_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === "CUSTOMER" && CUSTOMER_REAL_PAGES.includes(key)) return true;
|
||||
if (role() === "JOB_SEEKER" && JOB_SEEKER_REAL_PAGES.includes(key)) return true;
|
||||
if (PROFESSIONAL_ROLE_SET.has(role()) && PROFESSIONAL_REAL_PAGES.includes(key)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<RequireAuth>
|
||||
<main style={{ 'min-height': '100vh', background: '#F3F4F6' }}>
|
||||
|
||||
<main style={{ "min-height": "100vh", background: "#F3F4F6" }}>
|
||||
<Show when={loading()}>
|
||||
<div style={cardStyle}>Loading dashboard…</div>
|
||||
</Show>
|
||||
|
||||
<Show when={ready()}>
|
||||
|
||||
{/* ── Real pages: DashboardShell + actual components ── */}
|
||||
<Show when={isRealPage()}>
|
||||
<DashboardShell
|
||||
|
|
@ -397,70 +596,106 @@ export default function RuntimeDashboardPage() {
|
|||
userName={userName()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={activeSidebar().toLowerCase() === 'my dashboard'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "my dashboard"}>
|
||||
<MyDashboardPage roleKey={role()} userName={userName()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'my profile'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "my profile"}>
|
||||
<ProfilePage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'my portfolio'}>
|
||||
<PortfolioPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'verification'}>
|
||||
<VerificationStatusPage
|
||||
<Match when={activeSidebar().toLowerCase() === "my portfolio"}>
|
||||
<PortfolioPage
|
||||
roleKey={role()}
|
||||
onNavigate={setActiveSidebar}
|
||||
runtimeTabs={bundle()?.tabs || []}
|
||||
runtimeFields={bundle()?.fields || []}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'settings'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "verification"}>
|
||||
<VerificationStatusPage roleKey={role()} onNavigate={setActiveSidebar} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === "settings"}>
|
||||
<SettingsPage />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'credits'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "credits"}>
|
||||
<CreditsPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'explore nxtgauge'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "explore nxtgauge"}>
|
||||
<ExploreServicesPage />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'help center'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "help center"}>
|
||||
<HelpCenterDashboardPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'switch services'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "switch services"}>
|
||||
<SwitchServicesPage />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'logout'}>
|
||||
<Match when={activeSidebar().toLowerCase() === "logout"}>
|
||||
<LogoutPage />
|
||||
</Match>
|
||||
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'jobs'}>
|
||||
<Match when={role() === "COMPANY" && activeSidebar().toLowerCase() === "jobs"}>
|
||||
<CompanyJobsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'applications'}>
|
||||
<Match
|
||||
when={role() === "COMPANY" && activeSidebar().toLowerCase() === "applications"}
|
||||
>
|
||||
<CompanyApplicationsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'shortlisted candidates'}>
|
||||
<Match
|
||||
when={
|
||||
role() === "COMPANY" &&
|
||||
activeSidebar().toLowerCase() === "shortlisted candidates"
|
||||
}
|
||||
>
|
||||
<CompanyShortlistedCandidatesPage />
|
||||
</Match>
|
||||
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'my requirements'}>
|
||||
<Match
|
||||
when={
|
||||
role() === "CUSTOMER" && activeSidebar().toLowerCase() === "my requirements"
|
||||
}
|
||||
>
|
||||
<CustomerRequirementsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'received responses'}>
|
||||
<Match
|
||||
when={
|
||||
role() === "CUSTOMER" && activeSidebar().toLowerCase() === "received responses"
|
||||
}
|
||||
>
|
||||
<CustomerResponsesPage mode="received" />
|
||||
</Match>
|
||||
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'shortlisted responses'}>
|
||||
<Match
|
||||
when={
|
||||
role() === "CUSTOMER" &&
|
||||
activeSidebar().toLowerCase() === "shortlisted responses"
|
||||
}
|
||||
>
|
||||
<CustomerResponsesPage mode="shortlisted" />
|
||||
</Match>
|
||||
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'my applications'}>
|
||||
<Match
|
||||
when={
|
||||
role() === "JOB_SEEKER" && activeSidebar().toLowerCase() === "my applications"
|
||||
}
|
||||
>
|
||||
<JobSeekerApplicationsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'jobs'}>
|
||||
<Match when={role() === "JOB_SEEKER" && activeSidebar().toLowerCase() === "jobs"}>
|
||||
<JobSeekerJobsPage />
|
||||
</Match>
|
||||
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'saved jobs'}>
|
||||
<Match
|
||||
when={role() === "JOB_SEEKER" && activeSidebar().toLowerCase() === "saved jobs"}
|
||||
>
|
||||
<JobSeekerSavedJobsPage />
|
||||
</Match>
|
||||
<Match when={PROFESSIONAL_ROLE_SET.has(role()) && activeSidebar().toLowerCase() === 'leads'}>
|
||||
<Match
|
||||
when={
|
||||
PROFESSIONAL_ROLE_SET.has(role()) && activeSidebar().toLowerCase() === "leads"
|
||||
}
|
||||
>
|
||||
<ProfessionalLeadsPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={PROFESSIONAL_ROLE_SET.has(role()) && activeSidebar().toLowerCase() === 'my responses'}>
|
||||
<Match
|
||||
when={
|
||||
PROFESSIONAL_ROLE_SET.has(role()) &&
|
||||
activeSidebar().toLowerCase() === "my responses"
|
||||
}
|
||||
>
|
||||
<ProfessionalResponsesPage roleKey={role()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -470,7 +705,7 @@ export default function RuntimeDashboardPage() {
|
|||
{/* ── All other views: DashboardDesignPreview mock ── */}
|
||||
<Show when={!isRealPage()}>
|
||||
<DashboardDesignPreview
|
||||
status={bundle()?.status ?? 'ACTIVE'}
|
||||
status={bundle()?.status ?? "ACTIVE"}
|
||||
sidebarItems={sidebarItems()}
|
||||
activeSidebar={activeSidebar()}
|
||||
onSidebarSelect={setActiveSidebar}
|
||||
|
|
@ -486,7 +721,6 @@ export default function RuntimeDashboardPage() {
|
|||
liveData={liveData()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</Show>
|
||||
</main>
|
||||
</RequireAuth>
|
||||
|
|
@ -494,9 +728,9 @@ export default function RuntimeDashboardPage() {
|
|||
}
|
||||
|
||||
const cardStyle = {
|
||||
border: '1px solid #E5E7EB',
|
||||
'border-radius': '12px',
|
||||
padding: '16px',
|
||||
background: '#fff',
|
||||
color: '#111827',
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "12px",
|
||||
padding: "16px",
|
||||
background: "#fff",
|
||||
color: "#111827",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export default function SignupRoute() {
|
|||
const [emailExists, setEmailExists] = createSignal(false);
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [pendingEmail, setPendingEmail] = createSignal('');
|
||||
const [pendingPassword, setPendingPassword] = createSignal('');
|
||||
const [verifiedSuccess, setVerifiedSuccess] = createSignal(false);
|
||||
const [showPassword, setShowPassword] = createSignal(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
|
||||
|
||||
|
|
@ -164,14 +164,6 @@ export default function SignupRoute() {
|
|||
}
|
||||
};
|
||||
|
||||
const storeAuth = (payload: any) => {
|
||||
const accessToken = String(payload?.access_token || '').trim();
|
||||
if (typeof window !== 'undefined' && accessToken) {
|
||||
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
|
||||
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
setServerError('');
|
||||
const validation = validateRegisterForm({
|
||||
|
|
@ -212,7 +204,7 @@ export default function SignupRoute() {
|
|||
|
||||
const cleanEmail = email().trim().toLowerCase();
|
||||
setPendingEmail(cleanEmail);
|
||||
setPendingPassword(password());
|
||||
setVerifiedSuccess(false);
|
||||
saveUserForDashboard({
|
||||
firstName: firstName().trim(),
|
||||
lastName: lastName().trim(),
|
||||
|
|
@ -245,32 +237,8 @@ export default function SignupRoute() {
|
|||
setServerError(String(verifyData?.error || verifyData?.message || 'Verification failed.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const loginRes = await fetch('/api/gateway/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email: pendingEmail() || email().trim().toLowerCase(),
|
||||
password: pendingPassword() || password(),
|
||||
}),
|
||||
});
|
||||
const loginData = await loginRes.json().catch(() => ({}));
|
||||
if (!loginRes.ok) {
|
||||
setServerError(String(loginData?.error || loginData?.message || 'Email verified. Please login manually.'));
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
storeAuth(loginData);
|
||||
saveUserForDashboard({
|
||||
firstName: firstName().trim(),
|
||||
lastName: lastName().trim(),
|
||||
email: pendingEmail() || email().trim().toLowerCase(),
|
||||
roleKey: role(),
|
||||
user: loginData?.user,
|
||||
});
|
||||
navigate('/dashboard', { replace: true });
|
||||
setVerifiedSuccess(true);
|
||||
setTimeout(() => navigate('/login?verified=1', { replace: true }), 1400);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
|
@ -316,31 +284,39 @@ export default function SignupRoute() {
|
|||
<h2 class="title">Verify Email</h2>
|
||||
<p class="subtitle">Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.</p>
|
||||
|
||||
<div class="otp-row">
|
||||
<For each={Array.from({ length: 6 }, (_, index) => index)}>
|
||||
{(index) => (
|
||||
<input
|
||||
id={`otp-${index}`}
|
||||
class="otp-input"
|
||||
inputMode="numeric"
|
||||
maxlength={1}
|
||||
value={otp()[index]}
|
||||
onInput={(e) => setOtpDigit(index, e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={!verifiedSuccess()} fallback={
|
||||
<div style={{ 'margin-top': '12px', 'border-radius': '12px', border: '1px solid #FED7AA', background: '#FFF7ED', padding: '14px 16px', color: '#C2410C', 'text-align': 'center' }}>
|
||||
<div style={{ 'font-size': '30px', 'line-height': '1' }}>✓</div>
|
||||
<p style={{ margin: '8px 0 0', 'font-weight': '700', 'font-size': '14px' }}>Your email has been verified.</p>
|
||||
<p style={{ margin: '6px 0 0', 'font-size': '13px' }}>Redirecting to login...</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="otp-row">
|
||||
<For each={Array.from({ length: 6 }, (_, index) => index)}>
|
||||
{(index) => (
|
||||
<input
|
||||
id={`otp-${index}`}
|
||||
class="otp-input"
|
||||
inputMode="numeric"
|
||||
maxlength={1}
|
||||
value={otp()[index]}
|
||||
onInput={(e) => setOtpDigit(index, e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<button class="auth-submit-btn" type="button" disabled={submitting()} onClick={() => void verifyOtp()}>
|
||||
{submitting() ? 'Verifying...' : 'Verify and Continue'}
|
||||
</button>
|
||||
|
||||
<div class="auth-footer-row">
|
||||
<p class="note">Didn’t receive code?</p>
|
||||
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}>
|
||||
Resend OTP
|
||||
<button class="auth-submit-btn" type="button" disabled={submitting()} onClick={() => void verifyOtp()}>
|
||||
{submitting() ? 'Verifying...' : 'Verify and Continue'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-footer-row">
|
||||
<p class="note">Didn’t receive code?</p>
|
||||
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}>
|
||||
Resend OTP
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}>
|
||||
<h2 class="title">Create Your Account</h2>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue