feat: align external dashboards, profile settings split, and role flows

This commit is contained in:
Ashwin Kumar 2026-04-08 22:40:43 +02:00
parent f990b9a9e0
commit b8faf752e9
28 changed files with 3146 additions and 96 deletions

View file

@ -41,7 +41,7 @@ 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="/users/choose-role">Sign up</A>
<A class="nav-auth-btn nav-auth-primary" href="/signup">Sign up</A>
</div>
<button
@ -71,7 +71,7 @@ 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="/users/choose-role" onClick={() => setMobileOpen(false)}>Sign up</A>
<A class="mobile-signup" href="/signup" onClick={() => setMobileOpen(false)}>Sign up</A>
</div>
</div>
</div>

View file

@ -93,7 +93,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
CUSTOMER: {
title: 'Service Seeker Profile',
subtitle: 'Manage your personal details, service preferences, documents, and account settings.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Area', 'Place', 'PIN Code', 'Service Category'],
documents: ['Identity Proof', 'Address Proof'],
@ -103,7 +103,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
COMPANY: {
title: 'Company Profile',
subtitle: 'Configure organization details, hiring preferences, compliance documents, and settings.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['Company Name', 'Company Email', 'Company Phone', 'City', 'Area', 'Place', 'PIN Code', 'Website URL'],
documents: ['GST Certificate', 'PAN Card', 'Incorporation Certificate'],
@ -113,7 +113,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
JOB_SEEKER: {
title: 'Job Seeker Profile',
subtitle: 'Maintain your career profile, resume, preferences, and verification docs.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Current Role', 'Total Experience', 'City', 'Area', 'Place'],
documents: ['Identity Proof', 'Address Proof', 'Education Proof'],
@ -123,7 +123,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
PHOTOGRAPHER: {
title: 'Photographer Profile',
subtitle: 'Manage your photography details, pricing, portfolio, and documents.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Portfolio Ownership Proof'],
@ -133,7 +133,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
MAKEUP_ARTIST: {
title: 'Makeup Artist Profile',
subtitle: 'Manage makeup specialization, services, portfolio, and compliance documents.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Professional Certifications'],
@ -143,7 +143,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
DEVELOPER: {
title: 'Developer Profile',
subtitle: 'Showcase technical profile, pricing models, portfolio projects, and documents.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
@ -153,7 +153,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
VIDEO_EDITOR: {
title: 'Video Editor Profile',
subtitle: 'Manage editing profile, services, portfolio, and verification documents.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
@ -163,7 +163,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
UGC_CONTENT_CREATOR: {
title: 'UGC Content Creator Profile',
subtitle: 'Manage your creator profile, content style, pricing, and portfolio deliverables.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
@ -173,7 +173,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
GRAPHIC_DESIGNER: {
title: 'Graphic Designer Profile',
subtitle: 'Manage design profile, service pricing, portfolio assets, and documents.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
@ -183,7 +183,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
SOCIAL_MEDIA_MANAGER: {
title: 'Social Media Manager Profile',
subtitle: 'Manage social profile details, service plans, case studies, and documents.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],
@ -193,7 +193,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
FITNESS_TRAINER: {
title: 'Fitness Trainer Profile',
subtitle: 'Manage training details, plans, certifications, and profile settings.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Certification Proof'],
@ -203,7 +203,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
TUTOR: {
title: 'Tutor Profile',
subtitle: 'Manage teaching details, subjects, pricing, documents, and settings.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['First Name', 'Last Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Educational Proof'],
@ -213,7 +213,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
CATERING_SERVICES: {
title: 'Catering Services Profile',
subtitle: 'Manage business details, menu packages, gallery, and compliance docs.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['Business Name', 'Contact Person Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Food License'],
@ -223,7 +223,7 @@ const PROFILE_SPECS: Record<string, ProfileSpec> = {
PROFESSIONAL: {
title: 'Professional Profile',
subtitle: 'Manage professional details, pricing, portfolio, and account settings.',
tabs: ['basic information', 'documents', 'settings'],
tabs: ['basic information', 'documents'],
tabFields: {
'basic information': ['Full Name', 'Email Address', 'Mobile Number', 'Gender', 'Address Line 1', 'Address Line 2 (Optional)', 'City', 'Area', 'State', 'PIN Code'],
documents: ['Identity Proof', 'Address Proof', 'Tax Document'],

View file

@ -0,0 +1,320 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
const API = '/api/gateway';
interface JobItem {
id: string;
title: string;
status: string;
}
interface ApplicationItem {
id: string;
job_id: string;
job_seeker_id: string;
status: string;
cover_letter?: string | null;
resume_url?: string | null;
applied_at?: string;
contact_viewed?: boolean;
}
interface ContactInfo {
full_name?: string | null;
email?: string | null;
phone?: string | null;
quota?: {
total_remaining: number;
};
}
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function CompanyApplicationsPage() {
const [jobs, setJobs] = createSignal<JobItem[]>([]);
const [selectedJobId, setSelectedJobId] = createSignal('');
const [applications, setApplications] = createSignal<ApplicationItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadingApps, setLoadingApps] = createSignal(false);
const [statusFilter, setStatusFilter] = createSignal('');
const [actionMsg, setActionMsg] = createSignal('');
const [busyAppId, setBusyAppId] = createSignal<string | null>(null);
const [contactByApp, setContactByApp] = createSignal<Record<string, ContactInfo>>({});
const loadJobs = async () => {
setLoading(true);
try {
const res = await apiFetch('/api/companies/jobs?page=1&limit=100');
if (!res.ok) {
setJobs([]);
return;
}
const payload = await res.json().catch(() => ({}));
const list = Array.isArray(payload?.data) ? payload.data : [];
setJobs(list);
if (list.length > 0) {
const nextJobId = selectedJobId() || list[0].id;
setSelectedJobId(nextJobId);
await loadApplications(nextJobId, statusFilter());
} else {
setSelectedJobId('');
setApplications([]);
}
} finally {
setLoading(false);
}
};
const loadApplications = async (jobId: string, status?: string) => {
if (!jobId) {
setApplications([]);
return;
}
setLoadingApps(true);
try {
const query = new URLSearchParams();
query.set('page', '1');
query.set('limit', '50');
if (status) query.set('status', status);
const res = await apiFetch(`/api/companies/jobs/${jobId}/applications?${query.toString()}`);
if (!res.ok) {
setApplications([]);
return;
}
const payload = await res.json().catch(() => ({}));
setApplications(Array.isArray(payload?.data) ? payload.data : []);
} finally {
setLoadingApps(false);
}
};
onMount(async () => {
await loadJobs();
});
const refreshCurrent = async () => {
await loadApplications(selectedJobId(), statusFilter());
};
const updateApplicationStatus = async (id: string, status: string) => {
setBusyAppId(id);
setActionMsg('');
try {
const res = await apiFetch(`/api/companies/applications/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
if (res.ok) {
setActionMsg(`Application marked as ${status.replace(/_/g, ' ')}.`);
await refreshCurrent();
} else {
const data = await res.json().catch(() => ({}));
setActionMsg(data.error ?? data.message ?? 'Failed to update application status.');
}
} finally {
setBusyAppId(null);
}
};
const loadContact = async (id: string) => {
setBusyAppId(id);
setActionMsg('');
try {
const res = await apiFetch(`/api/companies/applications/${id}/contact`);
const data = await res.json().catch(() => ({}));
if (res.ok) {
setContactByApp((prev) => ({ ...prev, [id]: data }));
} else {
setActionMsg(data.error ?? data.message ?? 'Failed to load contact details.');
}
} finally {
setBusyAppId(null);
}
};
const prettyDate = (value?: string) => {
if (!value) return '—';
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleString('en-IN');
};
return (
<div style={{ 'max-width': '920px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '16px', gap: '12px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Applications</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Review applicants and update their hiring stage.
</p>
</div>
<button type="button" onClick={refreshCurrent} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={actionMsg()}>
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '12px 14px', 'font-size': '13px', color: '#374151' }}>
{actionMsg()}
</div>
</Show>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading jobs</div>
</Show>
<Show when={!loading()}>
<div style={{ ...CARD, 'margin-bottom': '12px', display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div>
<label style={LABEL}>Select Job</label>
<select
value={selectedJobId()}
onChange={(e) => {
const next = e.currentTarget.value;
setSelectedJobId(next);
loadApplications(next, statusFilter());
}}
style={INPUT}
>
<Show when={jobs().length === 0}>
<option value="">No jobs available</option>
</Show>
<For each={jobs()}>
{(job) => (
<option value={job.id}>
{job.title} ({job.status.replace(/_/g, ' ')})
</option>
)}
</For>
</select>
</div>
<div>
<label style={LABEL}>Filter by Status</label>
<select
value={statusFilter()}
onChange={(e) => {
const next = e.currentTarget.value;
setStatusFilter(next);
loadApplications(selectedJobId(), next);
}}
style={INPUT}
>
<option value="">All Statuses</option>
<option value="APPLIED">Applied</option>
<option value="SHORTLISTED">Shortlisted</option>
<option value="INTERVIEW">Interview</option>
<option value="OFFERED">Offered</option>
<option value="HIRED">Hired</option>
<option value="REJECTED">Rejected</option>
<option value="WITHDRAWN">Withdrawn</option>
</select>
</div>
</div>
<Show when={!selectedJobId()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>
Create a job first to view applications.
</div>
</Show>
</Show>
<Show when={loadingApps()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading applications</div>
</Show>
<Show when={!loadingApps() && selectedJobId() && applications().length === 0}>
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>No applications found for this job.</div>
</Show>
<Show when={!loadingApps() && applications().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={applications()}>
{(app) => (
<div style={{ ...CARD, padding: '16px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-start', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>
Application #{app.id.slice(0, 8)}
</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Applied at {prettyDate(app.applied_at)}
</p>
</div>
<span style={{
display: 'inline-flex',
'align-items': 'center',
height: '24px',
padding: '0 10px',
'border-radius': '999px',
background: '#EEF2FF',
color: '#3730A3',
'font-size': '11px',
'font-weight': '700',
}}>
{app.status.replace(/_/g, ' ')}
</span>
</div>
<Show when={app.cover_letter}>
<p style={{ margin: '10px 0 0', 'font-size': '13px', color: '#374151', 'line-height': '1.5' }}>
{app.cover_letter}
</p>
</Show>
<Show when={app.resume_url}>
<p style={{ margin: '8px 0 0', 'font-size': '12px' }}>
<a href={app.resume_url!} target="_blank" rel="noreferrer" style={{ color: '#1D4ED8', 'font-weight': '600' }}>
View Resume
</a>
</p>
</Show>
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap', 'margin-top': '12px' }}>
<button type="button" onClick={() => updateApplicationStatus(app.id, 'SHORTLISTED')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
Shortlist
</button>
<button type="button" onClick={() => updateApplicationStatus(app.id, 'INTERVIEW')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
Interview
</button>
<button type="button" onClick={() => updateApplicationStatus(app.id, 'OFFERED')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
Offer
</button>
<button type="button" onClick={() => updateApplicationStatus(app.id, 'HIRED')} disabled={busyAppId() === app.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
Hire
</button>
<button type="button" onClick={() => updateApplicationStatus(app.id, 'REJECTED')} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
Reject
</button>
<button type="button" onClick={() => loadContact(app.id)} disabled={busyAppId() === app.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
View Contact
</button>
</div>
<Show when={contactByApp()[app.id]}>
<div style={{ margin: '12px 0 0', padding: '10px', border: '1px solid #E5E7EB', 'border-radius': '8px', background: '#F9FAFB' }}>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>
{contactByApp()[app.id]?.full_name || 'Applicant'}
</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#4B5563' }}>
{contactByApp()[app.id]?.email || 'No email'}
</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#4B5563' }}>
{contactByApp()[app.id]?.phone || 'No phone'}
</p>
<p style={{ margin: '6px 0 0', 'font-size': '11px', color: '#6B7280' }}>
Remaining contact views: {contactByApp()[app.id]?.quota?.total_remaining ?? 0}
</p>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
);
}

View file

@ -0,0 +1,351 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, INPUT, LABEL } from '~/components/DashboardShell';
const API = '/api/gateway';
interface JobItem {
id: string;
title: string;
description: string;
location: string;
job_type: string;
status: string;
category?: string | null;
salary_min?: number | null;
salary_max?: number | null;
experience_years?: number | null;
skills?: string[] | null;
created_at?: string;
}
interface JobFormState {
title: string;
category: string;
description: string;
location: string;
job_type: string;
salary_min: string;
salary_max: string;
experience_years: string;
skills: string;
}
const EMPTY_FORM: JobFormState = {
title: '',
category: '',
description: '',
location: '',
job_type: 'FULL_TIME',
salary_min: '',
salary_max: '',
experience_years: '',
skills: '',
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function CompanyJobsPage() {
const [jobs, setJobs] = createSignal<JobItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [showForm, setShowForm] = createSignal(false);
const [form, setForm] = createSignal<JobFormState>({ ...EMPTY_FORM });
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [actionMsg, setActionMsg] = createSignal('');
const [busyJobId, setBusyJobId] = createSignal<string | null>(null);
const loadJobs = async () => {
setLoading(true);
try {
const res = await apiFetch('/api/companies/jobs?page=1&limit=50');
if (!res.ok) {
setJobs([]);
return;
}
const payload = await res.json().catch(() => ({}));
setJobs(Array.isArray(payload?.data) ? payload.data : []);
} finally {
setLoading(false);
}
};
onMount(loadJobs);
const setField = (key: keyof JobFormState, val: string) =>
setForm((prev) => ({ ...prev, [key]: val }));
const openCreate = () => {
setForm({ ...EMPTY_FORM });
setError('');
setActionMsg('');
setShowForm(true);
};
const closeCreate = () => {
setShowForm(false);
setForm({ ...EMPTY_FORM });
setError('');
};
const handleCreate = async () => {
if (!form().title.trim() || !form().description.trim() || !form().location.trim()) {
setError('Title, description, and location are required.');
return;
}
setSaving(true);
setError('');
setActionMsg('');
const payload = {
title: form().title.trim(),
category: form().category.trim() || undefined,
description: form().description.trim(),
location: form().location.trim(),
job_type: form().job_type,
salary_min: form().salary_min ? Number(form().salary_min) : undefined,
salary_max: form().salary_max ? Number(form().salary_max) : undefined,
experience_years: form().experience_years ? Number(form().experience_years) : undefined,
skills: form().skills
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
try {
const res = await apiFetch('/api/companies/jobs', {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error ?? data.message ?? 'Failed to create job.');
return;
}
setActionMsg('Job created as draft.');
closeCreate();
await loadJobs();
} catch {
setError('Network error. Please try again.');
} finally {
setSaving(false);
}
};
const submitJob = async (jobId: string) => {
setBusyJobId(jobId);
setActionMsg('');
try {
const res = await apiFetch(`/api/companies/jobs/${jobId}/submit`, { method: 'POST' });
if (res.ok) {
setActionMsg('Job submitted for verification.');
await loadJobs();
} else {
const data = await res.json().catch(() => ({}));
setActionMsg(data.error ?? data.message ?? 'Unable to submit this job.');
}
} finally {
setBusyJobId(null);
}
};
const closeJob = async (jobId: string) => {
setBusyJobId(jobId);
setActionMsg('');
try {
const res = await apiFetch(`/api/companies/jobs/${jobId}/close`, { method: 'POST' });
if (res.ok) {
setActionMsg('Job closed.');
await loadJobs();
} else {
const data = await res.json().catch(() => ({}));
setActionMsg(data.error ?? data.message ?? 'Unable to close this job.');
}
} finally {
setBusyJobId(null);
}
};
const statusColor = (status: string) => {
switch (status) {
case 'DRAFT': return '#6B7280';
case 'PENDING_APPROVAL': return '#F59E0B';
case 'LIVE': return '#10B981';
case 'REJECTED': return '#EF4444';
case 'CLOSED': return '#374151';
default: return '#6B7280';
}
};
return (
<div style={{ 'max-width': '920px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '16px', gap: '12px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Jobs</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Create and manage your job postings.
</p>
</div>
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
+ Create Job
</button>
</div>
<Show when={actionMsg()}>
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '12px 14px', 'font-size': '13px', color: '#374151' }}>
{actionMsg()}
</div>
</Show>
<Show when={showForm()}>
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
<p style={{ margin: '0 0 14px', 'font-size': '16px', 'font-weight': '800', color: '#111827' }}>
New Job
</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Job Title</label>
<input value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} placeholder="Frontend Developer" />
</div>
<div>
<label style={LABEL}>Category</label>
<input value={form().category} onInput={(e) => setField('category', e.currentTarget.value)} style={INPUT} placeholder="Engineering" />
</div>
<div>
<label style={LABEL}>Job Type</label>
<select value={form().job_type} onChange={(e) => setField('job_type', e.currentTarget.value)} style={INPUT}>
<option value="FULL_TIME">Full Time</option>
<option value="PART_TIME">Part Time</option>
<option value="CONTRACT">Contract</option>
</select>
</div>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Location</label>
<input value={form().location} onInput={(e) => setField('location', e.currentTarget.value)} style={INPUT} placeholder="Bengaluru (Hybrid)" />
</div>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Description</label>
<textarea
rows={4}
value={form().description}
onInput={(e) => setField('description', e.currentTarget.value)}
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
placeholder="Role overview, responsibilities, and requirements"
/>
</div>
<div>
<label style={LABEL}>Min Salary</label>
<input type="number" value={form().salary_min} onInput={(e) => setField('salary_min', e.currentTarget.value)} style={INPUT} />
</div>
<div>
<label style={LABEL}>Max Salary</label>
<input type="number" value={form().salary_max} onInput={(e) => setField('salary_max', e.currentTarget.value)} style={INPUT} />
</div>
<div>
<label style={LABEL}>Experience (years)</label>
<input type="number" value={form().experience_years} onInput={(e) => setField('experience_years', e.currentTarget.value)} style={INPUT} />
</div>
<div>
<label style={LABEL}>Skills (comma separated)</label>
<input value={form().skills} onInput={(e) => setField('skills', e.currentTarget.value)} style={INPUT} placeholder="Rust, SQL, Docker" />
</div>
</div>
<Show when={error()}>
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>{error()}</p>
</Show>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '14px' }}>
<button type="button" onClick={handleCreate} disabled={saving()} style={{ ...BTN_PRIMARY, opacity: saving() ? '0.7' : '1' }}>
{saving() ? 'Creating…' : 'Create Draft'}
</button>
<button type="button" onClick={closeCreate} style={BTN_GHOST}>Cancel</button>
</div>
</div>
</Show>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading jobs</div>
</Show>
<Show when={!loading() && jobs().length === 0}>
<div style={{ ...CARD, 'text-align': 'center', padding: '34px 24px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>No jobs yet</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>Create your first draft job to start receiving applications.</p>
</div>
</Show>
<Show when={!loading() && jobs().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={jobs()}>
{(job) => (
<div style={{ ...CARD, padding: '16px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'align-items': 'flex-start', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '800', color: '#111827' }}>{job.title}</p>
<p style={{ margin: '3px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{[job.location, job.job_type, job.category || 'General'].join(' • ')}
</p>
</div>
<span style={{
display: 'inline-flex',
'align-items': 'center',
padding: '0 10px',
height: '24px',
'border-radius': '999px',
background: `${statusColor(job.status)}20`,
color: statusColor(job.status),
'font-size': '11px',
'font-weight': '700',
}}>
{job.status.replace(/_/g, ' ')}
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151', 'line-height': '1.5' }}>
{job.description}
</p>
<Show when={(job.skills || []).length > 0}>
<div style={{ display: 'flex', gap: '6px', 'flex-wrap': 'wrap', 'margin-top': '8px' }}>
<For each={job.skills || []}>
{(skill) => (
<span style={{ 'font-size': '11px', color: '#4B5563', background: '#F3F4F6', border: '1px solid #E5E7EB', 'border-radius': '6px', padding: '2px 8px' }}>
{skill}
</span>
)}
</For>
</div>
</Show>
<div style={{ display: 'flex', gap: '8px', 'margin-top': '12px', 'flex-wrap': 'wrap' }}>
<Show when={job.status === 'DRAFT'}>
<button
type="button"
onClick={() => submitJob(job.id)}
disabled={busyJobId() === job.id}
style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 14px', opacity: busyJobId() === job.id ? '0.65' : '1' }}
>
Submit for Approval
</button>
</Show>
<Show when={job.status !== 'CLOSED'}>
<button
type="button"
onClick={() => closeJob(job.id)}
disabled={busyJobId() === job.id}
style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 14px', opacity: busyJobId() === job.id ? '0.65' : '1' }}
>
Close Job
</button>
</Show>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
);
}

View file

@ -0,0 +1,109 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
const API = '/api/gateway';
type JobItem = { id: string; title: string };
type ApplicationItem = {
id: string;
job_id: string;
job_seeker_id: string;
status: string;
applied_at?: string;
cover_letter?: string | null;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function CompanyShortlistedCandidatesPage() {
const [rows, setRows] = createSignal<Array<ApplicationItem & { job_title?: string }>>([]);
const [loading, setLoading] = createSignal(true);
const [err, setErr] = createSignal('');
const loadAllShortlisted = async () => {
setLoading(true);
setErr('');
try {
const jobsRes = await apiFetch('/api/companies/jobs?page=1&limit=100');
const jobsPayload = await jobsRes.json().catch(() => ({}));
const jobs = (Array.isArray(jobsPayload?.data) ? jobsPayload.data : []) as JobItem[];
const titleById = new Map(jobs.map((j) => [j.id, j.title]));
const allRows: Array<ApplicationItem & { job_title?: string }> = [];
for (const job of jobs) {
const res = await apiFetch(`/api/companies/jobs/${job.id}/applications?page=1&limit=100&status=SHORTLISTED`);
if (!res.ok) continue;
const payload = await res.json().catch(() => ({}));
const list = Array.isArray(payload?.data) ? payload.data : [];
for (const app of list) {
allRows.push({ ...app, job_title: titleById.get(app.job_id) || job.title });
}
}
setRows(allRows);
} catch {
setErr('Failed to load shortlisted candidates.');
setRows([]);
} finally {
setLoading(false);
}
};
onMount(loadAllShortlisted);
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Shortlisted Candidates</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>Candidates shortlisted across all approved job posts.</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' }}>Candidate List</p>
<button type="button" onClick={loadAllShortlisted} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading shortlisted candidates...</p>
</Show>
<Show when={!loading() && rows().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No shortlisted candidates yet.</p>
</Show>
<Show when={!loading() && rows().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={rows()}>
{(row) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>
Candidate #{row.job_seeker_id?.slice(0, 8) || row.id.slice(0, 8)}
</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{row.job_title || '—'} {row.applied_at ? `${new Date(row.applied_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#FFF1EB', color: '#C2410C', 'font-size': '11px', 'font-weight': '700' }}>
{String(row.status || 'SHORTLISTED').replace(/_/g, ' ')}
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_letter || 'No cover letter available.'}</p>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,118 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
const API = '/api/gateway';
type Props = { roleKey: RoleKey };
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function CreditsPage(props: Props) {
const [wallet, setWallet] = createSignal<any>(null);
const [ledger, setLedger] = createSignal<any[]>([]);
const [loading, setLoading] = createSignal(true);
const [err, setErr] = createSignal('');
const isProfessional = () => PROFESSIONAL_ROLE_SET.has(props.roleKey);
const prefix = () => ROLE_PREFIXES[props.roleKey];
const loadData = async () => {
setLoading(true);
setErr('');
try {
if (!isProfessional()) {
setWallet(null);
setLedger([]);
return;
}
const [walletRes, ledgerRes] = await Promise.all([
apiFetch(`/api/${prefix()}/wallet/me`),
apiFetch(`/api/${prefix()}/wallet/me/ledger?page=1&limit=20`),
]);
const walletJson = await walletRes.json().catch(() => ({}));
const ledgerJson = await ledgerRes.json().catch(() => ({}));
if (walletRes.ok) setWallet(walletJson);
else setErr(walletJson.error || walletJson.message || 'Failed to load wallet.');
if (ledgerRes.ok) setLedger(Array.isArray(ledgerJson?.data) ? ledgerJson.data : []);
else if (!err()) setErr(ledgerJson.error || ledgerJson.message || 'Failed to load ledger.');
} catch {
setErr('Network error while loading credits.');
} finally {
setLoading(false);
}
};
onMount(loadData);
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Credits</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
{isProfessional() ? 'Track Tracecoin balance and usage history.' : 'Credits and billing summary for your account.'}
</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>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading credits...</div>
</Show>
<Show when={!loading() && !isProfessional()}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Billing Overview</p>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Your role does not use professional wallet endpoints. Billing and payments are managed through role-specific transactions.
</p>
</div>
</Show>
<Show when={!loading() && isProfessional()}>
<div style={{ ...CARD, display: 'grid', 'grid-template-columns': '1fr auto', gap: '10px', 'align-items': 'center' }}>
<div>
<p style={{ margin: '0', 'font-size': '12px', 'letter-spacing': '0.06em', 'text-transform': 'uppercase', color: '#6B7280' }}>Current Balance</p>
<p style={{ margin: '6px 0 0', 'font-size': '28px', 'font-weight': '800', color: '#111827' }}>
{wallet()?.balance ?? 0} <span style={{ 'font-size': '14px', color: '#6B7280', 'font-weight': '600' }}>Tracecoins</span>
</p>
</div>
<button type="button" onClick={loadData} style={BTN_GHOST}>Refresh</button>
</div>
<div style={CARD}>
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Recent Ledger</p>
<Show when={ledger().length === 0}>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>No ledger entries yet.</p>
</Show>
<Show when={ledger().length > 0}>
<div style={{ display: 'grid', gap: '8px' }}>
<For each={ledger()}>
{(item: any) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD', display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{item.reason || item.type || 'Transaction'}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{item.created_at ? new Date(item.created_at).toLocaleString('en-IN') : '—'}</p>
</div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '800', color: Number(item.amount || 0) >= 0 ? '#15803D' : '#B91C1C' }}>
{Number(item.amount || 0) >= 0 ? '+' : ''}{item.amount || 0}
</p>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
);
}

View file

@ -0,0 +1,217 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
const API = '/api/gateway';
type RequirementItem = {
id: string;
title: string;
description?: string | null;
status?: string;
budget_min?: number | null;
budget_max?: number | null;
area?: string | null;
city?: string | null;
created_at?: string;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function CustomerRequirementsPage() {
const [requirements, setRequirements] = createSignal<RequirementItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [busyId, setBusyId] = createSignal<string | null>(null);
const [saving, setSaving] = createSignal(false);
const [msg, setMsg] = createSignal('');
const [err, setErr] = createSignal('');
const [form, setForm] = createSignal({
title: '',
description: '',
budget_min: '',
budget_max: '',
area: '',
city: '',
});
const loadRequirements = async () => {
setLoading(true);
setErr('');
try {
const res = await apiFetch('/api/customers/requirements?page=1&limit=100');
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(payload.error || payload.message || 'Failed to load requirements.');
setRequirements([]);
return;
}
setRequirements(Array.isArray(payload?.data) ? payload.data : []);
} catch {
setErr('Network error while loading requirements.');
} finally {
setLoading(false);
}
};
onMount(loadRequirements);
const setField = (key: keyof ReturnType<typeof form>, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const createRequirement = async () => {
setSaving(true);
setMsg('');
setErr('');
try {
const payload = {
title: form().title.trim(),
description: form().description.trim() || undefined,
budget_min: form().budget_min ? Number(form().budget_min) : undefined,
budget_max: form().budget_max ? Number(form().budget_max) : undefined,
area: form().area.trim() || undefined,
city: form().city.trim() || undefined,
};
const res = await apiFetch('/api/customers/requirements', {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to create requirement.');
return;
}
setMsg('Requirement created.');
setForm({ title: '', description: '', budget_min: '', budget_max: '', area: '', city: '' });
await loadRequirements();
} catch {
setErr('Network error while creating requirement.');
} finally {
setSaving(false);
}
};
const submitRequirement = async (id: string) => {
setBusyId(id);
setMsg('');
setErr('');
try {
const res = await apiFetch(`/api/customers/requirements/${id}/submit`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to submit requirement.');
return;
}
setMsg('Requirement submitted to verification.');
await loadRequirements();
} catch {
setErr('Network error while submitting requirement.');
} finally {
setBusyId(null);
}
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Requirements</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Create requirements. They move to verification first, then final approval.
</p>
</div>
<div style={CARD}>
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Post New Requirement</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div style={{ 'grid-column': '1 / -1' }}>
<label style={LABEL}>Title</label>
<input value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} placeholder="Wedding photographer in Chennai" />
</div>
<div style={{ 'grid-column': '1 / -1' }}>
<label style={LABEL}>Description</label>
<textarea
rows={3}
value={form().description}
onInput={(e) => setField('description', e.currentTarget.value)}
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
placeholder="Describe what you need"
/>
</div>
<div>
<label style={LABEL}>Budget Min</label>
<input value={form().budget_min} onInput={(e) => setField('budget_min', e.currentTarget.value)} style={INPUT} placeholder="10000" />
</div>
<div>
<label style={LABEL}>Budget Max</label>
<input value={form().budget_max} onInput={(e) => setField('budget_max', e.currentTarget.value)} style={INPUT} placeholder="50000" />
</div>
<div>
<label style={LABEL}>Area</label>
<input value={form().area} onInput={(e) => setField('area', e.currentTarget.value)} style={INPUT} placeholder="T Nagar" />
</div>
<div>
<label style={LABEL}>City</label>
<input value={form().city} onInput={(e) => setField('city', e.currentTarget.value)} style={INPUT} placeholder="Chennai" />
</div>
</div>
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '12px' }}>
<button type="button" onClick={createRequirement} disabled={saving() || !form().title.trim()} style={{ ...BTN_ORANGE, opacity: saving() ? '0.7' : '1' }}>
{saving() ? 'Posting...' : 'Post Requirement'}
</button>
</div>
</div>
<Show when={msg()}>
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{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': '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' }}>My Requirement List</p>
<button type="button" onClick={loadRequirements} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading requirements...</p>
</Show>
<Show when={!loading() && requirements().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No requirements found.</p>
</Show>
<Show when={!loading() && requirements().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={requirements()}>
{(row) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.title}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{row.city || '—'} {row.area ? `${row.area}` : ''} {row.created_at ? `${new Date(row.created_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
{String(row.status || 'DRAFT').replace(/_/g, ' ')}
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.description || 'No description added.'}</p>
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
<button type="button" onClick={() => submitRequirement(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 ? 'Submitting...' : 'Submit for Verification'}
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,223 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from '~/components/DashboardShell';
const API = '/api/gateway';
type RequirementItem = {
id: string;
title: string;
status?: string;
};
type LeadRequestItem = {
id: string;
professional_id?: string;
status?: string;
message?: string | null;
created_at?: string;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
type Props = {
mode: 'received' | 'shortlisted';
};
export default function CustomerResponsesPage(props: Props) {
const [requirements, setRequirements] = createSignal<RequirementItem[]>([]);
const [selectedRequirementId, setSelectedRequirementId] = createSignal('');
const [rows, setRows] = createSignal<LeadRequestItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadingRows, setLoadingRows] = createSignal(false);
const [busyId, setBusyId] = createSignal<string | null>(null);
const [msg, setMsg] = createSignal('');
const [err, setErr] = createSignal('');
const loadRequirements = async () => {
setLoading(true);
try {
const res = await apiFetch('/api/customers/requirements?page=1&limit=100');
const payload = await res.json().catch(() => ({}));
const list = Array.isArray(payload?.data) ? payload.data : [];
setRequirements(list);
if (list.length > 0) {
const first = selectedRequirementId() || list[0].id;
setSelectedRequirementId(first);
await loadResponses(first);
} else {
setSelectedRequirementId('');
setRows([]);
}
} catch {
setErr('Failed to load requirements.');
} finally {
setLoading(false);
}
};
const loadResponses = async (requirementId: string) => {
if (!requirementId) {
setRows([]);
return;
}
setLoadingRows(true);
setErr('');
try {
const res = await apiFetch(`/api/customers/requirements/${requirementId}/requests`);
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setRows([]);
setErr(payload.error || payload.message || 'Failed to load responses.');
return;
}
const list = Array.isArray(payload?.data) ? payload.data : [];
setRows(list);
} catch {
setErr('Network error while loading responses.');
} finally {
setLoadingRows(false);
}
};
onMount(loadRequirements);
const runDecision = async (leadId: string, approve: boolean) => {
if (!selectedRequirementId()) return;
setBusyId(leadId);
setMsg('');
setErr('');
try {
const path = approve
? `/api/customers/requirements/${selectedRequirementId()}/requests/${leadId}/approve`
: `/api/customers/requirements/${selectedRequirementId()}/requests/${leadId}/reject`;
const res = await apiFetch(path, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to update response.');
return;
}
setMsg(approve ? 'Response moved to shortlisted.' : 'Response rejected.');
await loadResponses(selectedRequirementId());
} catch {
setErr('Network error while updating response.');
} finally {
setBusyId(null);
}
};
const filteredRows = () => {
if (props.mode === 'shortlisted') {
return rows().filter((row) => {
const status = String(row.status || '').toUpperCase();
return status === 'APPROVED' || status === 'SHORTLISTED';
});
}
return rows();
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>
{props.mode === 'shortlisted' ? 'Shortlisted Responses' : 'Received Responses'}
</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
{props.mode === 'shortlisted'
? 'Review approved responses from your requirements.'
: 'Review incoming responses and shortlist the right professionals.'}
</p>
</div>
<Show when={msg()}>
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{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': '600' }}>{err()}</div>
</Show>
<div style={CARD}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-end', gap: '10px', 'flex-wrap': 'wrap' }}>
<div style={{ 'min-width': '300px', flex: '1' }}>
<label style={LABEL}>Select Requirement</label>
<select
value={selectedRequirementId()}
onChange={(e) => {
const next = e.currentTarget.value;
setSelectedRequirementId(next);
loadResponses(next);
}}
style={INPUT}
>
<Show when={requirements().length === 0}>
<option value="">No requirements</option>
</Show>
<For each={requirements()}>
{(item) => (
<option value={item.id}>
{item.title} ({String(item.status || 'DRAFT').replace(/_/g, ' ')})
</option>
)}
</For>
</select>
</div>
<button type="button" onClick={() => loadResponses(selectedRequirementId())} style={BTN_GHOST}>Refresh</button>
</div>
</div>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading requirements...</div>
</Show>
<Show when={!loading() && !selectedRequirementId()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>Post a requirement first to view responses.</div>
</Show>
<Show when={loadingRows()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading responses...</div>
</Show>
<Show when={!loadingRows() && selectedRequirementId() && filteredRows().length === 0}>
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>No responses found.</div>
</Show>
<Show when={!loadingRows() && filteredRows().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={filteredRows()}>
{(row) => (
<div style={{ ...CARD, padding: '14px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-start', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>
Response #{row.id.slice(0, 8)}
</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Professional: {row.professional_id || '—'} {row.created_at ? `${new Date(row.created_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
{String(row.status || 'NEW').replace(/_/g, ' ')}
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.message || 'No message added by professional.'}</p>
<Show when={props.mode === 'received'}>
<div style={{ display: 'flex', gap: '8px', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
<button type="button" onClick={() => runDecision(row.id, false)} disabled={busyId() === row.id} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
Reject
</button>
<button type="button" onClick={() => runDecision(row.id, true)} disabled={busyId() === row.id} style={{ ...BTN_ORANGE, height: '32px', 'font-size': '12px', padding: '0 12px', opacity: busyId() === row.id ? '0.7' : '1' }}>
Shortlist
</button>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
);
}

View file

@ -0,0 +1,46 @@
import { For } from 'solid-js';
import { 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',
];
export default function ExploreServicesPage() {
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<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>
</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>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD, INPUT } from '~/components/DashboardShell';
import { type RoleKey } from './RoleDashboardShared';
const API = '/api/gateway';
type Props = { roleKey: RoleKey };
type Category = { id: string; name: string; slug: string; description?: string };
type Article = { id: string; title: string; slug: string; summary?: string; category?: string; updatedAt?: string };
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
function normalizeArticle(raw: any): Article {
return {
id: String(raw?.id || ''),
title: String(raw?.title || ''),
slug: String(raw?.slug || ''),
summary: raw?.summary ? String(raw.summary) : '',
category: String(raw?.category || raw?.category_name || ''),
updatedAt: String(raw?.updatedAt || raw?.updated_at || ''),
};
}
export default function HelpCenterDashboardPage(props: Props) {
const [categories, setCategories] = createSignal<Category[]>([]);
const [articles, setArticles] = createSignal<Article[]>([]);
const [loading, setLoading] = createSignal(true);
const [search, setSearch] = createSignal('');
const [err, setErr] = createSignal('');
const loadData = async () => {
setLoading(true);
setErr('');
try {
const [catRes, artRes] = await Promise.all([
apiFetch('/api/kb/categories'),
apiFetch(`/api/kb/articles?role=${encodeURIComponent(props.roleKey)}&page=1&limit=200`),
]);
const catJson = await catRes.json().catch(() => ({}));
const artJson = await artRes.json().catch(() => ({}));
if (catRes.ok) {
const catList = Array.isArray(catJson?.categories)
? catJson.categories
: Array.isArray(catJson)
? catJson
: [];
setCategories(catList);
}
if (artRes.ok) {
const rawArticles = Array.isArray(artJson?.articles)
? artJson.articles
: Array.isArray(artJson)
? artJson
: [];
setArticles(rawArticles.map(normalizeArticle).filter((a: Article) => Boolean(a.id && a.slug && a.title)));
}
if (!catRes.ok && !artRes.ok) setErr('Failed to load help center resources.');
} catch {
setErr('Network error while loading help center.');
} finally {
setLoading(false);
}
};
onMount(loadData);
const filtered = createMemo(() => {
const q = search().trim().toLowerCase();
if (!q) return articles();
return articles().filter((a) =>
String(a.title || '').toLowerCase().includes(q)
|| String(a.summary || '').toLowerCase().includes(q)
|| String(a.category || '').toLowerCase().includes(q));
});
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Help Center</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>Find guides and articles for your role.</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, display: 'grid', gap: '10px' }}>
<div style={{ display: 'flex', gap: '10px', 'align-items': 'center' }}>
<input value={search()} onInput={(e) => setSearch(e.currentTarget.value)} style={INPUT} placeholder="Search help articles" />
<button type="button" onClick={loadData} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading help center...</p>
</Show>
</div>
<Show when={!loading() && categories().length > 0}>
<div style={CARD}>
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Categories</p>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
<For each={categories().slice(0, 6)}>
{(cat) => (
<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' }}>{cat.name}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{cat.description || 'Knowledge base category'}</p>
</div>
)}
</For>
</div>
</div>
</Show>
<div style={CARD}>
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Articles</p>
<Show when={!loading() && filtered().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No articles found.</p>
</Show>
<Show when={filtered().length > 0}>
<div style={{ display: 'grid', gap: '8px' }}>
<For each={filtered()}>
{(a) => (
<a href={`/help-center/article/${a.slug}`} style={{ display: 'block', border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD', color: 'inherit', 'text-decoration': 'none' }}>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{a.title}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{a.summary || 'Open article'}</p>
</a>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
const API = '/api/gateway';
type ApplicationItem = {
id: string;
job_id: string;
status: string;
applied_at?: string;
resume_url?: string | null;
cover_letter?: string | null;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function JobSeekerApplicationsPage() {
const [rows, setRows] = createSignal<ApplicationItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [statusFilter, setStatusFilter] = createSignal('');
const [err, setErr] = createSignal('');
const loadRows = async (status = statusFilter()) => {
setLoading(true);
setErr('');
try {
const query = new URLSearchParams();
query.set('page', '1');
query.set('limit', '100');
if (status) query.set('status', status);
const res = await apiFetch(`/api/jobseeker/applications?${query.toString()}`);
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(payload.error || payload.message || 'Failed to load applications.');
setRows([]);
return;
}
setRows(Array.isArray(payload?.data) ? payload.data : []);
} catch {
setErr('Network error while loading applications.');
setRows([]);
} finally {
setLoading(false);
}
};
onMount(() => loadRows());
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Applications</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>Track all jobs you applied for.</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': 'flex-end', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '600', color: '#374151' }}>Status Filter</p>
<select
value={statusFilter()}
onChange={(e) => {
const next = e.currentTarget.value;
setStatusFilter(next);
loadRows(next);
}}
style={{ height: '38px', 'border-radius': '8px', border: '1px solid #E5E7EB', padding: '0 10px', 'font-size': '13px' }}
>
<option value="">All</option>
<option value="APPLIED">Applied</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="SHORTLISTED">Shortlisted</option>
<option value="REJECTED">Rejected</option>
<option value="WITHDRAWN">Withdrawn</option>
</select>
</div>
<button type="button" onClick={() => loadRows()} style={BTN_GHOST}>Refresh</button>
</div>
</div>
<div style={CARD}>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading applications...</p>
</Show>
<Show when={!loading() && rows().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No applications found.</p>
</Show>
<Show when={!loading() && rows().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={rows()}>
{(row) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>Application #{row.id.slice(0, 8)}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Job: {row.job_id?.slice(0, 8) || '—'} {row.applied_at ? `${new Date(row.applied_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
{String(row.status || 'APPLIED').replace(/_/g, ' ')}
</span>
</div>
<Show when={row.cover_letter}>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.cover_letter}</p>
</Show>
<Show when={row.resume_url}>
<p style={{ margin: '8px 0 0', 'font-size': '12px' }}>
<a href={row.resume_url!} target="_blank" rel="noreferrer" style={{ color: '#1D4ED8', 'font-weight': '600' }}>
View Resume
</a>
</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,148 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT } from '~/components/DashboardShell';
const API = '/api/gateway';
type JobItem = {
id: string;
title?: string;
company_name?: string;
location?: string;
salary_min?: number | null;
salary_max?: number | null;
employment_type?: string;
status?: string;
description?: string | null;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function JobSeekerJobsPage() {
const [rows, setRows] = createSignal<JobItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [busyId, setBusyId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal('');
const [msg, setMsg] = createSignal('');
const [err, setErr] = createSignal('');
const loadRows = async () => {
setLoading(true);
setErr('');
try {
const res = await apiFetch('/api/jobseeker/jobs?page=1&limit=100');
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to load jobs.');
setRows([]);
return;
}
setRows(Array.isArray(data?.data) ? data.data : []);
} catch {
setErr('Network error while loading jobs.');
setRows([]);
} finally {
setLoading(false);
}
};
onMount(loadRows);
const applyJob = async (jobId: string) => {
setBusyId(jobId);
setMsg('');
setErr('');
try {
const res = await apiFetch(`/api/jobseeker/jobs/${jobId}/apply`, {
method: 'POST',
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to apply for job.');
return;
}
setMsg('Application submitted successfully.');
} catch {
setErr('Network error while applying.');
} finally {
setBusyId(null);
}
};
const filtered = () => {
const q = search().trim().toLowerCase();
if (!q) return rows();
return rows().filter((r) =>
String(r.title || '').toLowerCase().includes(q)
|| String(r.company_name || '').toLowerCase().includes(q)
|| String(r.location || '').toLowerCase().includes(q));
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Jobs</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Explore approved job postings and apply directly.
</p>
</div>
<Show when={msg()}>
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{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': '600' }}>{err()}</div>
</Show>
<div style={{ ...CARD, display: 'flex', gap: '10px', 'align-items': 'center' }}>
<input value={search()} onInput={(e) => setSearch(e.currentTarget.value)} style={INPUT} placeholder="Search jobs, company, location" />
<button type="button" onClick={loadRows} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>Loading jobs...</div>
</Show>
<Show when={!loading() && filtered().length === 0}>
<div style={{ ...CARD, 'text-align': 'center', color: '#6B7280' }}>No jobs found.</div>
</Show>
<Show when={!loading() && filtered().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={filtered()}>
{(row) => (
<div style={{ ...CARD, padding: '14px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '16px', 'font-weight': '800', color: '#111827' }}>{row.title || 'Untitled Job'}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{row.company_name || 'Company'} {row.location ? `${row.location}` : ''} {row.employment_type ? `${row.employment_type}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
{String(row.status || 'OPEN').replace(/_/g, ' ')}
</span>
</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}
</p>
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
<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>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
);
}

View file

@ -0,0 +1,92 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
const STORAGE_KEY = 'nxtgauge_saved_jobs_v1';
type SavedJob = {
id: string;
title: string;
company?: string;
location?: string;
salary?: string;
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 persist(list: SavedJob[]) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
} catch {
// ignore
}
}
export default function JobSeekerSavedJobsPage() {
const [rows, setRows] = createSignal<SavedJob[]>([]);
onMount(() => {
setRows(loadSaved());
});
const removeRow = (id: string) => {
const next = rows().filter((row) => row.id !== id);
setRows(next);
persist(next);
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<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.
</p>
</div>
<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>
</div>
<Show when={rows().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No saved jobs yet.</p>
</Show>
<Show when={rows().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={rows()}>
{(row) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.title}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{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')}
</p>
</div>
<button type="button" onClick={() => removeRow(row.id)} style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}>
Remove
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,69 @@
import { createSignal } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, CARD } from '~/components/DashboardShell';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function LogoutPage() {
const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal('');
const doLogout = async () => {
setBusy(true);
setErr('');
try {
await apiFetch('/api/auth/logout', { method: 'POST' });
} catch {
// ignore and clear local state anyway
} finally {
try {
window.localStorage.removeItem('nxtgauge_signup_profile_v1');
window.localStorage.removeItem('nxtgauge_auth_user');
window.localStorage.removeItem('nxtgauge_user');
window.sessionStorage.removeItem('nxtgauge_access_token');
window.sessionStorage.removeItem('nxtgauge_refresh_token');
window.sessionStorage.removeItem('nxtgauge_auth_user');
window.sessionStorage.removeItem('nxtgauge_user');
} catch {
setErr('Could not clear local session completely.');
}
window.location.href = '/login';
setBusy(false);
}
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '760px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Logout</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>End your current session securely.</p>
</div>
<div style={CARD}>
<p style={{ margin: '0 0 12px', 'font-size': '14px', color: '#374151' }}>
Are you sure you want to logout from this account?
</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button type="button" onClick={() => window.history.back()} style={BTN_GHOST}>Cancel</button>
<button type="button" onClick={doLogout} disabled={busy()} style={{ ...BTN_ORANGE, opacity: busy() ? '0.7' : '1' }}>
{busy() ? 'Logging out...' : 'Confirm Logout'}
</button>
</div>
</div>
{err() && (
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2', padding: '12px 14px', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>
{err()}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,162 @@
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';
const API = '/api/gateway';
type Props = {
roleKey: RoleKey;
userName?: string;
};
type Metric = {
title: string;
value: string;
hint: string;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function MyDashboardPage(props: Props) {
const [metrics, setMetrics] = createSignal<Metric[]>([]);
const [loading, setLoading] = createSignal(true);
const [err, setErr] = createSignal('');
const roleLabel = createMemo(() => String(props.roleKey || '').replace(/_/g, ' '));
const loadData = async () => {
setLoading(true);
setErr('');
const next: Metric[] = [];
try {
if (props.roleKey === 'COMPANY') {
const [jobsRes, appsRes] = await Promise.all([
apiFetch('/api/companies/jobs?page=1&limit=100'),
apiFetch('/api/companies/jobs?page=1&limit=1'),
]);
const jobsJson = await jobsRes.json().catch(() => ({}));
const appsJson = await appsRes.json().catch(() => ({}));
const jobs = Array.isArray(jobsJson?.data) ? jobsJson.data : [];
next.push(
{ title: 'Total Jobs', value: String(jobs.length), hint: 'All job posts created' },
{ title: 'Active Jobs', value: String(jobs.filter((j: any) => String(j.status || '').toUpperCase() === 'OPEN').length), hint: 'Open to applications' },
{ title: 'Jobs In Verification', value: String(jobs.filter((j: any) => String(j.status || '').toUpperCase().includes('PENDING')).length), hint: 'Pending verification/approval' },
{ title: 'Latest Sync', value: appsRes.ok ? 'Live' : 'Partial', hint: 'Dashboard data status' },
);
if (!jobsRes.ok && !appsRes.ok) setErr('Some company metrics could not be loaded.');
} else if (props.roleKey === 'CUSTOMER') {
const reqRes = await apiFetch('/api/customers/requirements?page=1&limit=100');
const reqJson = await reqRes.json().catch(() => ({}));
const reqs = Array.isArray(reqJson?.data) ? reqJson.data : [];
next.push(
{ title: 'My Requirements', value: String(reqs.length), hint: 'Total posted requirements' },
{ title: 'Open Requirements', value: String(reqs.filter((r: any) => String(r.status || '').toUpperCase() === 'OPEN').length), hint: 'Visible to professionals' },
{ title: 'In Verification', value: String(reqs.filter((r: any) => String(r.status || '').toUpperCase().includes('PENDING')).length), hint: 'Verification/approval stage' },
{ title: 'Drafts', value: String(reqs.filter((r: any) => String(r.status || '').toUpperCase() === 'DRAFT').length), hint: 'Not yet submitted' },
);
if (!reqRes.ok) setErr('Some customer metrics could not be loaded.');
} else if (props.roleKey === 'JOB_SEEKER') {
const [jobsRes, appsRes] = await Promise.all([
apiFetch('/api/jobseeker/jobs?page=1&limit=100'),
apiFetch('/api/jobseeker/applications?page=1&limit=100'),
]);
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 : [];
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' },
);
if (!jobsRes.ok && !appsRes.ok) 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([
apiFetch(`/api/${prefix}/marketplace?page=1&limit=100`),
apiFetch(`/api/${prefix}/leads/requests/me?page=1&limit=100`),
apiFetch(`/api/${prefix}/wallet/me`),
]);
const marketJson = await marketRes.json().catch(() => ({}));
const reqJson = await reqRes.json().catch(() => ({}));
const walletJson = await walletRes.json().catch(() => ({}));
const market = Array.isArray(marketJson?.data) ? marketJson.data : [];
const requests = Array.isArray(reqJson?.data) ? reqJson.data : [];
next.push(
{ title: 'Open Leads', value: String(market.length), hint: 'Available opportunities' },
{ title: 'My Requests', value: String(requests.length), hint: 'Lead requests sent' },
{ title: 'Accepted Requests', value: String(requests.filter((r: any) => ['APPROVED', 'CONTACT_UNLOCKED'].includes(String(r.status || '').toUpperCase())).length), hint: 'Approved responses' },
{ title: 'Tracecoins', value: String(walletJson?.balance ?? 0), hint: 'Current wallet balance' },
);
if (!marketRes.ok && !reqRes.ok && !walletRes.ok) setErr('Some professional metrics could not be loaded.');
} else {
next.push(
{ title: 'Welcome', value: 'Ready', hint: 'Dashboard initialized' },
{ title: 'Role', value: roleLabel(), hint: 'Current active role' },
);
}
setMetrics(next);
} catch {
setErr('Network error while loading dashboard metrics.');
setMetrics([
{ title: 'Status', value: 'Unavailable', hint: 'Please retry' },
]);
} finally {
setLoading(false);
}
};
onMount(loadData);
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Dashboard</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Welcome {props.userName || 'User'}. Role: {roleLabel()}.
</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' }}>Quick Summary</p>
<button type="button" onClick={loadData} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading dashboard...</p>
</Show>
<Show when={!loading()}>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(4,minmax(0,1fr))', gap: '10px' }}>
<For each={metrics()}>
{(m) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<p style={{ margin: '0', 'font-size': '11px', 'letter-spacing': '0.05em', 'text-transform': 'uppercase', color: '#6B7280' }}>{m.title}</p>
<p style={{ margin: '8px 0 0', 'font-size': '28px', 'line-height': '1', 'font-weight': '800', color: '#111827' }}>{m.value}</p>
<p style={{ margin: '6px 0 0', 'font-size': '12px', color: '#6B7280' }}>{m.hint}</p>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,137 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, CARD } from '~/components/DashboardShell';
import { ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
const API = '/api/gateway';
type Props = { roleKey: RoleKey };
type MarketplaceItem = {
id: string;
title?: string;
location?: string;
budget?: number | null;
profession_key?: string;
description?: string | null;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function ProfessionalLeadsPage(props: Props) {
const [rows, setRows] = createSignal<MarketplaceItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [busyId, setBusyId] = createSignal<string | null>(null);
const [msg, setMsg] = createSignal('');
const [err, setErr] = createSignal('');
const prefix = () => ROLE_PREFIXES[props.roleKey];
const loadRows = async () => {
setLoading(true);
setErr('');
try {
const res = await apiFetch(`/api/${prefix()}/marketplace?page=1&limit=50`);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to load leads.');
setRows([]);
return;
}
setRows(Array.isArray(data?.data) ? data.data : []);
} catch {
setErr('Network error while loading leads.');
setRows([]);
} finally {
setLoading(false);
}
};
onMount(loadRows);
const requestLead = async (requirementId: string) => {
setBusyId(requirementId);
setMsg('');
setErr('');
try {
const res = await apiFetch(`/api/${prefix()}/leads/request`, {
method: 'POST',
body: JSON.stringify({ requirement_id: requirementId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to request lead.');
return;
}
setMsg('Lead request submitted to verification and approval flow.');
} catch {
setErr('Network error while requesting lead.');
} finally {
setBusyId(null);
}
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Leads</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Browse open requirements from customers and request contact access.
</p>
</div>
<Show when={msg()}>
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{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': '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' }}>Open Marketplace Leads</p>
<button type="button" onClick={loadRows} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading leads...</p>
</Show>
<Show when={!loading() && rows().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No leads available right now.</p>
</Show>
<Show when={!loading() && rows().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={rows()}>
{(row) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>{row.title || 'Requirement'}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{row.location || 'Location not set'} {row.profession_key ? `${row.profession_key}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#FFF1EB', color: '#C2410C', 'font-size': '11px', 'font-weight': '700' }}>
{row.budget ? `${row.budget}` : 'Budget N/A'}
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '13px', color: '#374151' }}>{row.description || 'No additional details.'}</p>
<div style={{ display: 'flex', 'justify-content': 'flex-end', 'margin-top': '10px' }}>
<button type="button" onClick={() => requestLead(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 ? 'Requesting...' : 'Request Contact'}
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,107 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
import { ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
const API = '/api/gateway';
type Props = { roleKey: RoleKey };
type LeadRequestItem = {
id: string;
status?: string;
requirement_id?: string;
requested_at?: string;
expires_at?: string;
decision_at?: string;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function ProfessionalResponsesPage(props: Props) {
const [rows, setRows] = createSignal<LeadRequestItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [err, setErr] = createSignal('');
const prefix = () => ROLE_PREFIXES[props.roleKey];
const loadRows = async () => {
setLoading(true);
setErr('');
try {
const res = await apiFetch(`/api/${prefix()}/leads/requests/me?page=1&limit=100`);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to load my responses.');
setRows([]);
return;
}
setRows(Array.isArray(data?.data) ? data.data : []);
} catch {
setErr('Network error while loading my responses.');
setRows([]);
} finally {
setLoading(false);
}
};
onMount(loadRows);
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Responses</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Track your lead requests and current response status.
</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' }}>Requested Leads</p>
<button type="button" onClick={loadRows} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading my responses...</p>
</Show>
<Show when={!loading() && rows().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No lead requests yet.</p>
</Show>
<Show when={!loading() && rows().length > 0}>
<div style={{ display: 'grid', gap: '10px' }}>
<For each={rows()}>
{(row) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '12px', padding: '12px', background: '#FCFCFD' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '800', color: '#111827' }}>Lead Request #{row.id.slice(0, 8)}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Requirement: {row.requirement_id?.slice(0, 8) || '—'} {row.requested_at ? `${new Date(row.requested_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#EEF2FF', color: '#3730A3', 'font-size': '11px', 'font-weight': '700' }}>
{String(row.status || 'REQUESTED').replace(/_/g, ' ')}
</span>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Expires: {row.expires_at ? new Date(row.expires_at).toLocaleString('en-IN') : '—'} {row.decision_at ? `• Decision: ${new Date(row.decision_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
/**
* ProfilePage real My Profile form, wired to backend.
* Supports all 13 roles. Tabs: Basic Info · Documents · Settings.
* Supports all 13 roles. Tabs: Basic Info · Documents.
* User fills and saves freely; "Submit for Verification" locks and queues for admin.
*/
import {
@ -134,7 +134,7 @@ interface Props {
roleKey: string;
}
type Tab = 'basic' | 'documents' | 'settings';
type Tab = 'basic' | 'documents';
export default function ProfilePage(props: Props) {
const [tab, setTab] = createSignal<Tab>('basic');
@ -303,7 +303,6 @@ export default function ProfilePage(props: Props) {
<For each={[
{ key: 'basic', label: 'Basic Information' },
{ key: 'documents', label: 'Documents' },
{ key: 'settings', label: 'Settings' },
] as Array<{ key: Tab; label: string }>}>
{(t) => (
<button
@ -464,63 +463,29 @@ export default function ProfilePage(props: Props) {
</div>
</Match>
{/* Settings */}
<Match when={tab() === 'settings'}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '20px' }}>
<div>
<p style={{ margin: '0 0 4px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Password & Login</p>
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>Change your account password.</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div>
<label style={LABEL}>Current Password</label>
<input type="password" style={INPUT} />
</div>
<div>
<label style={LABEL}>New Password</label>
<input type="password" style={INPUT} />
</div>
</div>
</div>
<div style={{ 'border-top': '1px solid #F3F4F6', 'padding-top': '16px' }}>
<p style={{ margin: '0 0 4px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Notification Preferences</p>
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>Choose how you receive notifications.</p>
<For each={['Email Notifications', 'In-App Notifications', 'SMS Alerts']}>
{(label) => (
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', padding: '10px 0', 'border-bottom': '1px solid #F3F4F6' }}>
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>{label}</span>
<input type="checkbox" checked style={{ width: '16px', height: '16px', cursor: 'pointer' }} />
</div>
)}
</For>
</div>
</div>
</Match>
</Switch>
</div>
{/* ── Save button (not on settings) ─────────────────────────────── */}
<Show when={tab() !== 'settings'}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-top': '16px' }}>
<button
type="button"
onClick={handleSave}
disabled={saving() || isLocked()}
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? '0.6' : '1' }}
>
{saving() ? 'Saving…' : 'Save Changes'}
</button>
<Show when={saveMsg()}>
<span style={{
'font-size': '13px',
'font-weight': '600',
color: saveMsg().includes('success') ? '#10B981' : '#EF4444',
}}>
{saveMsg()}
</span>
</Show>
</div>
</Show>
{/* ── Save button ─────────────────────────────────────────────── */}
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-top': '16px' }}>
<button
type="button"
onClick={handleSave}
disabled={saving() || isLocked()}
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? '0.6' : '1' }}
>
{saving() ? 'Saving…' : 'Save Changes'}
</button>
<Show when={saveMsg()}>
<span style={{
'font-size': '13px',
'font-weight': '600',
color: saveMsg().includes('success') ? '#10B981' : '#EF4444',
}}>
{saveMsg()}
</span>
</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
export 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';
export const ROLE_PREFIXES: Record<RoleKey, 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',
};
export const PROFESSIONAL_ROLE_SET = new Set<RoleKey>([
'PHOTOGRAPHER',
'MAKEUP_ARTIST',
'TUTOR',
'DEVELOPER',
'VIDEO_EDITOR',
'UGC_CONTENT_CREATOR',
'GRAPHIC_DESIGNER',
'SOCIAL_MEDIA_MANAGER',
'FITNESS_TRAINER',
'CATERING_SERVICES',
]);

View file

@ -0,0 +1,297 @@
import { Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, INPUT, LABEL } from '~/components/DashboardShell';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = createSignal('');
const [newPassword, setNewPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [emailNotif, setEmailNotif] = createSignal(true);
const [appNotif, setAppNotif] = createSignal(true);
const [smsNotif, setSmsNotif] = createSignal(false);
const [deleteConfirm, setDeleteConfirm] = createSignal('');
const [deleteReason, setDeleteReason] = createSignal('');
const [loading, setLoading] = createSignal(true);
const [savingPassword, setSavingPassword] = createSignal(false);
const [savingNotifications, setSavingNotifications] = createSignal(false);
const [requestingDelete, setRequestingDelete] = createSignal(false);
const [deleteRequestStatus, setDeleteRequestStatus] = createSignal<'NONE' | 'DELETED'>('NONE');
const [msg, setMsg] = createSignal('');
const [err, setErr] = createSignal('');
onMount(async () => {
setLoading(true);
setErr('');
try {
const [settingsRes, deleteReqRes] = await Promise.all([
apiFetch('/api/me/settings'),
apiFetch('/api/me/settings/delete-account-request'),
]);
if (settingsRes.ok) {
const data = await settingsRes.json().catch(() => ({}));
setEmailNotif(Boolean(data.email_notifications));
setAppNotif(Boolean(data.in_app_notifications));
setSmsNotif(Boolean(data.sms_notifications));
}
if (deleteReqRes.ok) {
const data = await deleteReqRes.json().catch(() => ({}));
const status = String(data.status || 'NONE').toUpperCase();
if (status === 'DELETED') setDeleteRequestStatus('DELETED');
else setDeleteRequestStatus('NONE');
}
} catch {
setErr('Failed to load account settings.');
} finally {
setLoading(false);
}
});
const saveNotificationPrefs = async () => {
setErr('');
setMsg('');
setSavingNotifications(true);
try {
const res = await apiFetch('/api/me/settings/notifications', {
method: 'PATCH',
body: JSON.stringify({
email_notifications: emailNotif(),
in_app_notifications: appNotif(),
sms_notifications: smsNotif(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to save notification preferences.');
return;
}
setMsg('Notification preferences saved.');
} catch {
setErr('Network error while saving notification preferences.');
} finally {
setSavingNotifications(false);
}
};
const changePassword = async () => {
setErr('');
setMsg('');
if (!currentPassword() || !newPassword() || !confirmPassword()) {
setErr('Please fill all password fields.');
return;
}
if (newPassword() !== confirmPassword()) {
setErr('New password and confirm password do not match.');
return;
}
if (newPassword().length < 8) {
setErr('New password must be at least 8 characters.');
return;
}
setSavingPassword(true);
try {
const res = await apiFetch('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword(),
new_password: newPassword(),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to update password.');
return;
}
setMsg('Password updated successfully.');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch {
setErr('Network error while updating password.');
} finally {
setSavingPassword(false);
}
};
const requestDeleteAccount = async () => {
setErr('');
setMsg('');
if (deleteConfirm().trim().toUpperCase() !== 'DELETE') {
setErr('Type DELETE to confirm account deletion request.');
return;
}
setRequestingDelete(true);
try {
const res = await apiFetch('/api/me/settings/delete-account-request', {
method: 'POST',
body: JSON.stringify({
reason: deleteReason().trim() || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || 'Failed to submit delete account request.');
return;
}
setDeleteRequestStatus('DELETED');
setMsg('Your account has been deleted. Redirecting to login...');
setDeleteConfirm('');
setDeleteReason('');
try {
window.localStorage.removeItem('nxtgauge_signup_profile_v1');
window.localStorage.removeItem('nxtgauge_auth_user');
window.localStorage.removeItem('nxtgauge_user');
window.sessionStorage.removeItem('nxtgauge_access_token');
window.sessionStorage.removeItem('nxtgauge_refresh_token');
window.sessionStorage.removeItem('nxtgauge_auth_user');
window.sessionStorage.removeItem('nxtgauge_user');
} catch {
// ignore storage cleanup errors
}
setTimeout(() => {
window.location.href = '/login';
}, 1200);
} catch {
setErr('Network error while submitting delete account request.');
} finally {
setRequestingDelete(false);
}
};
return (
<div style={{ 'max-width': '760px', display: 'grid', gap: '14px' }}>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>
Loading settings...
</div>
</Show>
<div style={CARD}>
<p style={{ margin: '0 0 4px', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>
Settings
</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>
Manage account security, notifications, and privacy controls.
</p>
</div>
<div style={CARD}>
<p style={{ margin: '0 0 6px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>
Password & Login
</p>
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>
Change your account password.
</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Current Password</label>
<input type="password" value={currentPassword()} onInput={(e) => setCurrentPassword(e.currentTarget.value)} style={INPUT} />
</div>
<div>
<label style={LABEL}>New Password</label>
<input type="password" value={newPassword()} onInput={(e) => setNewPassword(e.currentTarget.value)} style={INPUT} />
</div>
<div>
<label style={LABEL}>Confirm New Password</label>
<input type="password" value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} style={INPUT} />
</div>
</div>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '12px' }}>
<button type="button" onClick={changePassword} disabled={savingPassword()} style={{ ...BTN_PRIMARY, opacity: savingPassword() ? '0.7' : '1' }}>
{savingPassword() ? 'Updating...' : 'Update Password'}
</button>
</div>
</div>
<div style={CARD}>
<p style={{ margin: '0 0 6px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>
Notification Preferences
</p>
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>
Choose how you receive notifications.
</p>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
<label style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '8px 0', 'border-bottom': '1px solid #F3F4F6' }}>
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>Email Notifications</span>
<input type="checkbox" checked={emailNotif()} onChange={(e) => setEmailNotif(e.currentTarget.checked)} />
</label>
<label style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '8px 0', 'border-bottom': '1px solid #F3F4F6' }}>
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>In-App Notifications</span>
<input type="checkbox" checked={appNotif()} onChange={(e) => setAppNotif(e.currentTarget.checked)} />
</label>
<label style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '8px 0' }}>
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>SMS Alerts</span>
<input type="checkbox" checked={smsNotif()} onChange={(e) => setSmsNotif(e.currentTarget.checked)} />
</label>
</div>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '12px' }}>
<button type="button" onClick={saveNotificationPrefs} disabled={savingNotifications()} style={{ ...BTN_GHOST, opacity: savingNotifications() ? '0.7' : '1' }}>
{savingNotifications() ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</div>
<div style={{ ...CARD, border: '1px solid #FECACA', background: '#FEF2F2' }}>
<p style={{ margin: '0 0 6px', 'font-size': '15px', 'font-weight': '700', color: '#991B1B' }}>
Delete Account
</p>
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#7F1D1D' }}>
This action is irreversible. Type <strong>DELETE</strong> to confirm.
</p>
<Show when={deleteRequestStatus() === 'DELETED'}>
<p style={{ margin: '0 0 10px', 'font-size': '12px', color: '#92400E', 'font-weight': '700' }}>
Your account is already deleted.
</p>
</Show>
<div style={{ 'margin-bottom': '10px' }}>
<label style={LABEL}>Reason (optional)</label>
<textarea
rows={3}
placeholder="Tell us why you want to delete your account"
value={deleteReason()}
onInput={(e) => setDeleteReason(e.currentTarget.value)}
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
/>
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr auto', gap: '10px' }}>
<input
type="text"
placeholder="Type DELETE"
value={deleteConfirm()}
onInput={(e) => setDeleteConfirm(e.currentTarget.value)}
style={INPUT}
/>
<button
type="button"
onClick={requestDeleteAccount}
disabled={requestingDelete() || deleteRequestStatus() === 'DELETED'}
style={{ ...BTN_ORANGE, background: '#DC2626', opacity: requestingDelete() || deleteRequestStatus() === 'DELETED' ? '0.7' : '1' }}
>
{requestingDelete() ? 'Submitting...' : 'Request Delete'}
</button>
</div>
</div>
<Show when={msg()}>
<div style={{ ...CARD, padding: '12px 14px', border: '1px solid #BBF7D0', background: '#ECFDF5', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>
{msg()}
</div>
</Show>
<Show when={err()}>
<div style={{ ...CARD, padding: '12px 14px', border: '1px solid #FECACA', background: '#FEF2F2', color: '#B91C1C', 'font-size': '13px', 'font-weight': '600' }}>
{err()}
</div>
</Show>
</div>
);
}

View file

@ -0,0 +1,231 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_PRIMARY, CARD } from '~/components/DashboardShell';
const API = '/api/gateway';
type UserRoleItem = {
role_key: string;
role_name?: string;
status?: string;
approved_at?: string | null;
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
});
}
const REGISTER_OPTIONS = [
{ key: 'PHOTOGRAPHER', label: 'Photographer' },
{ key: 'MAKEUP_ARTIST', label: 'Makeup Artist' },
{ key: 'TUTOR', label: 'Tutor' },
{ key: 'DEVELOPER', label: 'Developer' },
{ key: 'VIDEO_EDITOR', label: 'Video Editor' },
{ key: 'UGC_CONTENT_CREATOR', label: 'UGC Content Creator' },
{ key: 'GRAPHIC_DESIGNER', label: 'Graphic Designer' },
{ key: 'SOCIAL_MEDIA_MANAGER', label: 'Social Media Manager' },
{ key: 'FITNESS_TRAINER', label: 'Fitness Trainer' },
{ key: 'CATERING_SERVICES', label: 'Catering Services' },
{ key: 'COMPANY', label: 'Company' },
{ key: 'CUSTOMER', label: 'Customer' },
{ key: 'JOB_SEEKER', label: 'Job Seeker' },
];
export default function SwitchServicesPage() {
const [roles, setRoles] = createSignal<UserRoleItem[]>([]);
const [activeRole, setActiveRole] = createSignal('');
const [loading, setLoading] = createSignal(true);
const [busyRole, setBusyRole] = createSignal<string | null>(null);
const [msg, setMsg] = createSignal('');
const [err, setErr] = createSignal('');
const loadRoles = async () => {
setLoading(true);
setErr('');
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem('nxtgauge_auth_user') : null;
const parsed = raw ? JSON.parse(raw) : null;
const active = String(parsed?.active_role || parsed?.role || '').trim().toUpperCase();
setActiveRole(active);
} catch {
setActiveRole('');
}
try {
const res = await apiFetch('/api/me/roles');
const data = await res.json().catch(() => []);
if (!res.ok) {
setErr('Failed to load roles.');
setRoles([]);
return;
}
setRoles(Array.isArray(data) ? data : []);
} catch {
setErr('Network error while loading roles.');
} finally {
setLoading(false);
}
};
onMount(loadRoles);
const registerRole = async (roleKey: string) => {
setBusyRole(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(data.error || data.message || 'Failed to register role.');
return;
}
setMsg(`${roleKey} registered. You can switch after refresh/login.`);
await loadRoles();
} catch {
setErr('Network error while registering role.');
} finally {
setBusyRole(null);
}
};
const switchRole = async (roleKey: string) => {
setBusyRole(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(data.error || data.message || 'Failed to switch role.');
return;
}
const accessToken = String(data?.access_token || '').trim();
if (accessToken && typeof window !== 'undefined') {
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
}
}
}
setActiveRole(roleKey);
setMsg(`Switched to ${roleKey}. Refreshing dashboard...`);
setTimeout(() => {
window.location.href = '/dashboard';
}, 250);
} catch {
setErr('Network error while switching role.');
} finally {
setBusyRole(null);
}
};
const hasRole = (roleKey: string) => roles().some((r) => String(r.role_key || '').toUpperCase() === roleKey);
const isActive = (roleKey: string) => activeRole() === roleKey;
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>Switch Services</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Manage approved roles and register additional services.
</p>
</div>
<Show when={msg()}>
<div style={{ ...CARD, border: '1px solid #BBF7D0', background: '#ECFDF5', padding: '12px 14px', color: '#065F46', 'font-size': '13px', 'font-weight': '600' }}>{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': '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' }}>My Roles</p>
<button type="button" onClick={loadRoles} style={BTN_GHOST}>Refresh</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', color: '#9CA3AF', 'font-size': '13px' }}>Loading roles...</p>
</Show>
<Show when={!loading() && roles().length === 0}>
<p style={{ margin: '0', color: '#6B7280', 'font-size': '13px' }}>No roles found for this user.</p>
</Show>
<Show when={!loading() && roles().length > 0}>
<div style={{ display: 'grid', gap: '8px' }}>
<For each={roles()}>
{(r) => (
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '10px', background: '#FCFCFD', display: 'flex', 'justify-content': 'space-between', gap: '10px', 'flex-wrap': 'wrap' }}>
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>{r.role_name || r.role_key}</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
Status: {String(r.status || 'APPROVED').replace(/_/g, ' ')} {r.approved_at ? `• Approved ${new Date(r.approved_at).toLocaleString('en-IN')}` : ''}
</p>
</div>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
<span style={{ display: 'inline-flex', height: '24px', 'align-items': 'center', padding: '0 10px', 'border-radius': '999px', background: '#ECFDF5', color: '#15803D', 'font-size': '11px', 'font-weight': '700' }}>Approved</span>
<button
type="button"
onClick={() => switchRole(String(r.role_key || '').toUpperCase())}
disabled={busyRole() === String(r.role_key || '').toUpperCase() || isActive(String(r.role_key || '').toUpperCase())}
style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', opacity: busyRole() === String(r.role_key || '').toUpperCase() || isActive(String(r.role_key || '').toUpperCase()) ? '0.7' : '1' }}
>
{isActive(String(r.role_key || '').toUpperCase()) ? 'Active' : busyRole() === String(r.role_key || '').toUpperCase() ? 'Switching...' : 'Switch'}
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
<div style={CARD}>
<p style={{ margin: '0 0 10px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>Register Another Service</p>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
<For each={REGISTER_OPTIONS}>
{(opt) => (
<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' }}>{opt.label}</p>
<button
type="button"
disabled={hasRole(opt.key) || busyRole() === opt.key}
onClick={() => registerRole(opt.key)}
style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', 'margin-top': '8px', width: '100%', opacity: hasRole(opt.key) || busyRole() === opt.key ? '0.7' : '1' }}
>
{hasRole(opt.key) ? 'Registered' : busyRole() === opt.key ? 'Registering...' : 'Register'}
</button>
</div>
)}
</For>
</div>
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@ const API = '/api/gateway';
function getAuthHeaders(): Record<string, string> {
const token = typeof window !== 'undefined'
? (sessionStorage.getItem('nxtgauge_access_token') || localStorage.getItem('nxtgauge_access_token') || '')
? (sessionStorage.getItem('nxtgauge_access_token') || '')
: '';
return {
'Content-Type': 'application/json',
@ -160,7 +160,7 @@ export async function uploadDocument(rolePrefix: string, file: File, documentTyp
formData.append('document_type', documentType);
const token = typeof window !== 'undefined'
? (sessionStorage.getItem('nxtgauge_access_token') || localStorage.getItem('nxtgauge_access_token') || '')
? (sessionStorage.getItem('nxtgauge_access_token') || '')
: '';
const res = await fetch(`${API}/api/${rolePrefix}/profile/documents`, {

View file

@ -22,10 +22,10 @@ describe('normalizeIntent', () => {
});
describe('intentToOnboardingPath', () => {
it('maps each intent to expected onboarding path', () => {
expect(intentToOnboardingPath('company')).toBe('/users/onboarding/company');
expect(intentToOnboardingPath('job_seeker')).toBe('/users/onboarding/job-seeker');
expect(intentToOnboardingPath('professional')).toBe('/users/onboarding/professional');
expect(intentToOnboardingPath('customer')).toBe('/users/onboarding/customer');
it('maps each intent to signup route with intent query', () => {
expect(intentToOnboardingPath('company')).toBe('/signup?intent=company');
expect(intentToOnboardingPath('job_seeker')).toBe('/signup?intent=job_seeker');
expect(intentToOnboardingPath('professional')).toBe('/signup?intent=professional');
expect(intentToOnboardingPath('customer')).toBe('/signup?intent=customer');
});
});

View file

@ -14,10 +14,10 @@ export function normalizeIntent(value: string | null | undefined): CanonicalInte
}
export function intentToOnboardingPath(intent: CanonicalIntent): string {
if (intent === 'company') return '/users/onboarding/company';
if (intent === 'job_seeker') return '/users/onboarding/job-seeker';
if (intent === 'professional') return '/users/onboarding/professional';
return '/users/onboarding/customer';
if (intent === 'company') return '/signup?intent=company';
if (intent === 'job_seeker') return '/signup?intent=job_seeker';
if (intent === 'professional') return '/signup?intent=professional';
return '/signup?intent=customer';
}
export function saveCanonicalIntent(intent: CanonicalIntent): void {

View file

@ -23,16 +23,13 @@ const AuthContext = createContext<AuthState>();
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return sessionStorage.getItem('nxtgauge_access_token')
|| localStorage.getItem('nxtgauge_access_token')
|| null;
return sessionStorage.getItem('nxtgauge_access_token') || null;
}
function clearAuthStorage() {
if (typeof window === 'undefined') return;
sessionStorage.removeItem('nxtgauge_access_token');
sessionStorage.removeItem('nxtgauge_frontend_access_token');
localStorage.removeItem('nxtgauge_access_token');
localStorage.removeItem('nxtgauge_auth_user');
localStorage.removeItem('nxtgauge_user');
localStorage.removeItem('nxtgauge_signup_profile_v1');

View file

@ -6,9 +6,32 @@ 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 REAL_PAGES = ['my profile', 'my portfolio', 'verification'];
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'];
type RoleKey =
| 'COMPANY'
@ -32,6 +55,7 @@ type RuntimeBundle = {
tabs: string[];
widgets: string[];
fields: string[];
verificationStatus?: string;
source: 'dashboard-config';
};
@ -173,6 +197,42 @@ async function loadRoleBundle(role: RoleKey): Promise<RuntimeBundle | null> {
if (typeof window === 'undefined') {
return null;
}
const runtime = await fetchJson('/api/runtime-config');
if (runtime) {
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,
);
const runtimeWidgetsRaw = Array.isArray(runtime?.dashboard_config?.widgets)
? runtime.dashboard_config.widgets
: (Array.isArray(runtime?.widgets) ? runtime.widgets : []);
const runtimeWidgets = runtimeWidgetsRaw
.map((item: any) => String(typeof item === 'string' ? item : (item?.key || item?.id || '')).trim())
.filter(Boolean);
const runtimeFields = asStringArray(
runtime?.dashboard_config?.fields ?? runtime?.fields,
);
return {
role: runtimeRole,
status: 'ACTIVE',
sidebarItems: runtimeSidebar,
tabs: runtimeTabs,
widgets: runtimeWidgets,
fields: runtimeFields,
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`);
if (!payload) {
const listPayload = await fetchJson('/api/admin/dashboard-config?audience=EXTERNAL');
@ -200,7 +260,7 @@ async function loadRoleBundle(role: RoleKey): Promise<RuntimeBundle | null> {
};
}
function mergeSidebar(role: RoleKey, runtimeSidebar: string[]): string[] {
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 map = new Map<string, string>();
@ -217,6 +277,21 @@ function mergeSidebar(role: RoleKey, runtimeSidebar: string[]): string[] {
if (insertBefore >= 0) merged.splice(insertBefore, 0, 'Explore Nxtgauge');
else merged.push('Explore Nxtgauge');
}
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'] : []),
],
);
merged = merged.filter((item) => restricted.has(item.trim().toLowerCase()));
}
return merged;
}
@ -257,7 +332,12 @@ export default function RuntimeDashboardPage() {
loadRoleBundle,
);
const sidebarItems = createMemo(() => mergeSidebar(role(), bundle()?.sidebarItems || []));
const sidebarItems = createMemo(() => mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.verificationStatus));
createEffect(() => {
const runtimeRole = bundle()?.role;
if (runtimeRole && runtimeRole !== role()) setRole(runtimeRole);
});
createEffect(() => {
const first = sidebarItems()[0] || 'My Dashboard';
@ -286,9 +366,16 @@ export default function RuntimeDashboardPage() {
return { userName: userName(), userId: userId(), rolePrefix: prefix };
});
const isRealPage = createMemo(() =>
REAL_PAGES.includes(activeSidebar().toLowerCase()),
);
const isRealPage = createMemo(() => {
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 (PROFESSIONAL_ROLE_SET.has(role()) && PROFESSIONAL_REAL_PAGES.includes(key)) return true;
return false;
});
return (
<RequireAuth>
@ -310,6 +397,9 @@ export default function RuntimeDashboardPage() {
userName={userName()}
>
<Switch>
<Match when={activeSidebar().toLowerCase() === 'my dashboard'}>
<MyDashboardPage roleKey={role()} userName={userName()} />
</Match>
<Match when={activeSidebar().toLowerCase() === 'my profile'}>
<ProfilePage roleKey={role()} />
</Match>
@ -322,6 +412,57 @@ export default function RuntimeDashboardPage() {
onNavigate={setActiveSidebar}
/>
</Match>
<Match when={activeSidebar().toLowerCase() === 'settings'}>
<SettingsPage />
</Match>
<Match when={activeSidebar().toLowerCase() === 'credits'}>
<CreditsPage roleKey={role()} />
</Match>
<Match when={activeSidebar().toLowerCase() === 'explore nxtgauge'}>
<ExploreServicesPage />
</Match>
<Match when={activeSidebar().toLowerCase() === 'help center'}>
<HelpCenterDashboardPage roleKey={role()} />
</Match>
<Match when={activeSidebar().toLowerCase() === 'switch services'}>
<SwitchServicesPage />
</Match>
<Match when={activeSidebar().toLowerCase() === 'logout'}>
<LogoutPage />
</Match>
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'jobs'}>
<CompanyJobsPage />
</Match>
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'applications'}>
<CompanyApplicationsPage />
</Match>
<Match when={role() === 'COMPANY' && activeSidebar().toLowerCase() === 'shortlisted candidates'}>
<CompanyShortlistedCandidatesPage />
</Match>
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'my requirements'}>
<CustomerRequirementsPage />
</Match>
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'received responses'}>
<CustomerResponsesPage mode="received" />
</Match>
<Match when={role() === 'CUSTOMER' && activeSidebar().toLowerCase() === 'shortlisted responses'}>
<CustomerResponsesPage mode="shortlisted" />
</Match>
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'my applications'}>
<JobSeekerApplicationsPage />
</Match>
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'jobs'}>
<JobSeekerJobsPage />
</Match>
<Match when={role() === 'JOB_SEEKER' && activeSidebar().toLowerCase() === 'saved jobs'}>
<JobSeekerSavedJobsPage />
</Match>
<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'}>
<ProfessionalResponsesPage roleKey={role()} />
</Match>
</Switch>
</DashboardShell>
</Show>

View file

@ -134,7 +134,6 @@ export default function LoginRoute() {
if (typeof window !== 'undefined' && accessToken) {
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
window.localStorage.setItem('nxtgauge_access_token', accessToken);
}
saveUser(data?.user || {});
if (auth.setUser) {

View file

@ -169,7 +169,6 @@ export default function SignupRoute() {
if (typeof window !== 'undefined' && accessToken) {
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
window.localStorage.setItem('nxtgauge_access_token', accessToken);
}
};
@ -199,6 +198,8 @@ export default function SignupRoute() {
email: email().trim().toLowerCase(),
password: password(),
phone: null,
intent: role(),
profession: selectedProfessionalRole() || undefined,
}),
});