chore: checkpoint workspace updates

This commit is contained in:
Tracewebstudio Dev 2026-04-26 23:58:43 +02:00
parent 35abb6e7aa
commit f386b0c298
49 changed files with 5814 additions and 1211 deletions

View file

@ -0,0 +1,5 @@
> dev
> vinxi dev
vinxi v0.5.11

View file

@ -1 +1 @@
7995
61048

1
frontend-solid.start.log Normal file
View file

@ -0,0 +1 @@
Listening on http://[::]:3001

1
frontend-solid.start.pid Normal file
View file

@ -0,0 +1 @@
72152

15
package-lock.json generated
View file

@ -41,6 +41,7 @@
"storybook": "^10.3.3",
"storybook-solidjs-vite": "^10.0.11",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.3",
"visbug": "^0.1.14",
"vitest": "^4.1.1"
},
@ -15024,6 +15025,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",

View file

@ -26,32 +26,33 @@
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@chromatic-com/storybook": "^5.1.0",
"@mswjs/data": "^0.16.2",
"@playwright/test": "^1.58.2",
"@solidjs/testing-library": "^0.8.0",
"@storybook/addon-a11y": "^10.3.3",
"@storybook/addon-docs": "^10.3.3",
"@storybook/addon-vitest": "^10.3.3",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.6.3",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^10.1.0",
"eslint-plugin-solid": "^0.14.5",
"jsdom": "^25.0.1",
"loki": "^0.35.1",
"msw": "^2.7.3",
"@mswjs/data": "^0.16.2",
"pixelmatch": "^7.1.0",
"playwright": "^1.58.2",
"pngjs": "^7.0.0",
"prettier": "^3.0.0",
"storybook": "^10.3.3",
"storybook-solidjs-vite": "^10.0.11",
"@tailwindcss/vite": "^4.2.2",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.3",
"visbug": "^0.1.14",
"vitest": "^4.1.1",
"@typescript-eslint/parser": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"eslint-plugin-solid": "^0.14.5",
"prettier": "^3.0.0"
"vitest": "^4.1.1"
},
"engines": {
"node": ">=20"

View file

@ -23,33 +23,48 @@ import {
ChevronRight,
} from "lucide-solid";
// ── Icon map (matches DashboardDesignPreview sidebar keys) ────────────────────
const ICON_MAP: Record<string, any> = {
"my dashboard": LayoutDashboard,
"my profile": User,
"my portfolio": FolderOpen,
my_dashboard: LayoutDashboard,
my_profile: User,
my_portfolio: FolderOpen,
portfolio: FolderOpen,
leads: MapPin,
"my responses": Star,
my_responses: Star,
responses: Star,
credits: CreditCard,
"explore nxtgauge": Globe,
explore_nxtgauge: Globe,
explore: Globe,
verification: ShieldCheck,
"help center": HelpCircle,
verification_status: ShieldCheck,
help_center: HelpCircle,
help: HelpCircle,
support: HelpCircle,
settings: Settings,
"switch services": RefreshCw,
jobs: Briefcase,
applications: Briefcase,
"shortlisted candidates": User,
"my applications": FolderOpen,
"saved jobs": Star,
"my requirements": FolderOpen,
"received responses": Bell,
"shortlisted responses": Star,
switch_services: RefreshCw,
switch_service: RefreshCw,
logout: LogOut,
jobs: Briefcase,
job_postings: Briefcase,
applications: Briefcase,
my_applications: FolderOpen,
shortlisted_candidates: User,
my_requirements: FolderOpen,
requirements: FolderOpen,
received_responses: Bell,
shortlisted_responses: Star,
saved_jobs: Star,
};
function normalizeSidebarIconKey(value: string): string {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "_")
.replace(/-/g, "_");
}
function SidebarIcon(props: { label: string }) {
const key = props.label.toLowerCase();
const key = normalizeSidebarIconKey(props.label);
const Icon = ICON_MAP[key] || ChevronRight;
return <Icon size={16} />;
}
@ -74,8 +89,8 @@ interface Props {
// ── Brand colours ─────────────────────────────────────────────────────────────
const ORANGE = "#FF5E13";
const NAVY = "#0D0D2A";
export const ORANGE = "#FF5E13";
export const NAVY = "#0D0D2A";
const DARK_INK = "#03004E";
// ── Component ─────────────────────────────────────────────────────────────────
@ -375,3 +390,18 @@ export const LABEL = {
"letter-spacing": "0.01em",
"text-transform": "none",
} as const;
export const PKG_CARD = {
WHITE: "#ffffff",
COIN_BG: "#FFFBF8",
COIN_AVATAR_BG: "linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%)",
BEST_VALUE_BG: `linear-gradient(135deg, ${ORANGE} 0%, #FF8A5C 100%)`,
BORDER_DEFAULT: "#E5E7EB",
TEXT_SECONDARY: "#6B7280",
TEXT_PRIMARY: "#111827",
TEXT_ACCENT: ORANGE,
TEXT_MUTED: "#9CA3AF",
TEXT_SUCCESS: "#16A34A",
SHADOW_DEFAULT: "0 1px 3px rgba(0, 0, 0, 0.08)",
SHADOW_ACCENT: "0 4px 20px rgba(255, 94, 19, 0.15)",
} as const;

View file

@ -539,7 +539,13 @@ function customerViewFor(sidebar: string, roleKey: string): CustomerView {
const key = String(sidebar || '').toLowerCase().trim();
const role = normalizeRoleKey(roleKey);
const isProfessionalRole = role !== 'COMPANY' && role !== 'JOB_SEEKER' && role !== 'CUSTOMER';
if (key === 'my dashboard') return { title: 'Service Seeker Dashboard Overview', subtitle: 'Manage your requirements and track professional responses in real-time.', tabs: ['overview', 'recent requirements', 'quick actions'], cta: 'Post New Requirement' };
if (key === 'my dashboard') {
if (isProfessionalRole) {
const roleLabel = role.replace(/_/g, ' ').toLowerCase();
return { title: `${roleLabel.charAt(0).toUpperCase() + roleLabel.slice(1)} Dashboard`, subtitle: 'Manage your leads, responses, and portfolio performance.', tabs: ['overview', 'recent leads', 'quick actions'], cta: 'View Leads' };
}
return { title: 'Service Seeker Dashboard Overview', subtitle: 'Manage your requirements and track professional responses in real-time.', tabs: ['overview', 'recent requirements', 'quick actions'], cta: 'Post New Requirement' };
}
if (key === 'leads') return { title: 'Leads', subtitle: 'Browse marketplace requirements and request contact access for current opportunities.', tabs: [], cta: 'Buy Credits' };
if (key === 'jobs') {
if (role === 'JOB_SEEKER') return { title: 'Jobs', subtitle: 'Scroll through approved jobs, filter opportunities, and apply with your job seeker profile.', tabs: ['all jobs', 'recommended', 'saved', 'applied', 'expiring soon'], cta: 'Post A Resume' };
@ -1061,9 +1067,85 @@ export default function DashboardDesignPreview(props: {
}
};
const getRolePrefix = (roleKey: string): string => {
const prefixMap: Record<string, string> = {
PHOTOGRAPHER: 'photographers',
TUTOR: 'tutors',
MAKEUP_ARTIST: 'makeup-artists',
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',
};
return prefixMap[roleKey] || roleKey.toLowerCase().replace('_', '-');
};
const loadDashboardData = async () => {
const roleKey = props.roleKey || '';
if (!isProfessionalRoleKey(roleKey)) return;
if (props.mode === 'customer_external') return;
setDashboardLoading(true);
try {
const token = getToken();
if (!token) return;
const prefix = getRolePrefix(roleKey);
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
};
const [marketRes, reqRes, walletRes, profileRes] = await Promise.allSettled([
fetch(`/api/${prefix}/marketplace?page=1&limit=100`, { credentials: 'include', headers }),
fetch(`/api/${prefix}/leads/requests/me?page=1&limit=100`, { credentials: 'include', headers }),
fetch(`/api/${prefix}/wallet/me`, { credentials: 'include', headers }),
fetch(`/api/${prefix}/profile/me`, { credentials: 'include', headers }),
]);
const marketJson = marketRes.status === 'fulfilled' ? await marketRes.value.json().catch(() => ({})) : {};
const reqJson = reqRes.status === 'fulfilled' ? await reqRes.value.json().catch(() => ({})) : {};
const walletJson = walletRes.status === 'fulfilled' ? await walletRes.value.json().catch(() => ({})) : {};
const profileJson = profileRes.status === 'fulfilled' ? await profileRes.value.json().catch(() => ({})) : {};
const market = Array.isArray(marketJson?.data) ? marketJson.data : [];
const requests = Array.isArray(reqJson?.data) ? reqJson.data : [];
const acceptedRequests = requests.filter((r: any) => ['APPROVED', 'CONTACT_UNLOCKED'].includes(String(r.status || '').toUpperCase()));
const metrics: Record<string, number> = {
open_leads: market.length,
my_requests: requests.length,
accepted_requests: acceptedRequests.length,
tracecoins: walletJson?.balance ?? 0,
};
setDashboardMetrics(metrics);
if (walletJson?.balance !== undefined) {
setLeadCredits(walletJson.balance);
}
const profile = profileJson?.data || profileJson || {};
const completionFields = ['full_name', 'email', 'phone', 'city', 'bio', 'experience_years', 'hourly_rate', 'profile_photo'];
let filledCount = 0;
completionFields.forEach((field) => {
if (profile[field]) filledCount++;
});
const completionPercent = Math.round((filledCount / completionFields.length) * 100);
setProfileCompletion(completionPercent > 0 ? completionPercent : 0);
} catch (e) {
console.error('Failed to load dashboard data:', e);
} finally {
setDashboardLoading(false);
}
};
// Start polling on mount
onMount(() => {
fetchUnreadCount();
loadDashboardData();
const interval = setInterval(fetchUnreadCount, 30000);
return () => clearInterval(interval);
});
@ -1088,6 +1170,7 @@ export default function DashboardDesignPreview(props: {
const key = activeTabKey();
return tabs.some((item) => normalizeTabKey(item) === key) ? key : normalizeTabKey(tabs[0] || '');
});
const [userRoles, setUserRoles] = createSignal<any[]>([]);
const previewWidgets = createMemo(() => (props.widgets.length ? props.widgets : ['total_requirements', 'open', 'closed', 'responses', 'saved_pros']));
const previewFields = createMemo(() => (props.fields.length ? props.fields : ['full_name', 'email', 'verification_status', 'approval_status']));
const customerKey = createMemo(() => String(props.activeSidebar || '').toLowerCase().trim());
@ -1175,7 +1258,6 @@ export default function DashboardDesignPreview(props: {
const [profileDocumentType, setProfileDocumentType] = createSignal('Aadhar Card');
const [portfolioFormValues, setPortfolioFormValues] = createSignal<Record<string, string>>({});
const [portfolioFormErrors, setPortfolioFormErrors] = createSignal<Record<string, string>>({});
const [userRoles, setUserRoles] = createSignal<any[]>([]);
const [portfolioValidationNotice, setPortfolioValidationNotice] = createSignal('');
const [portfolioServices, setPortfolioServices] = createSignal<Array<{ name: string; model: string; price: string; details: string }>>([]);
const [portfolioServiceDraft, setPortfolioServiceDraft] = createSignal<{ name: string; model: string; price: string; details: string }>({ name: '', model: 'Flat', price: '', details: '' });
@ -1218,7 +1300,10 @@ export default function DashboardDesignPreview(props: {
const [requestedSortOpen, setRequestedSortOpen] = createSignal(false);
const [requestedFilterOpen, setRequestedFilterOpen] = createSignal(false);
const [requestedPage, setRequestedPage] = createSignal(1);
const [leadCredits, setLeadCredits] = createSignal(250);
const [leadCredits, setLeadCredits] = createSignal(0);
const [profileCompletion, setProfileCompletion] = createSignal(0);
const [dashboardLoading, setDashboardLoading] = createSignal(false);
const [dashboardMetrics, setDashboardMetrics] = createSignal<Record<string, number>>({});
const [checkoutPackage, setCheckoutPackage] = createSignal<any | null>(null);
const [paymentStep, setPaymentStep] = createSignal<'idle' | 'processing' | 'verifying' | 'success' | 'error'>('idle');
const [paymentRef, setPaymentRef] = createSignal<string | null>(null);
@ -2618,7 +2703,7 @@ export default function DashboardDesignPreview(props: {
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;padding:14px;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<p style="margin:0;font-size:13px;font-weight:700;color:#111827">{titleCase(selectedPortfolioTab)}</p>
<span style="height:22px;padding:0 8px;border-radius:999px;border:1px solid #DDEBFF;background:#EEF4FF;display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:#03004E">
<span style="height:22px;padding:0 8px;border-radius:999px;border:1px solid #DDEBFF;background:#EEF4FF;display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:#0D0D2A">
{selectedPortfolioFormFields.length} fields
</span>
</div>
@ -3188,7 +3273,7 @@ export default function DashboardDesignPreview(props: {
}
goToPortfolioStep(activePortfolioStepIndex + 1);
}}
style="height:32px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700"
style="height:32px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700"
>
{activePortfolioStepIndex >= portfolioStepKeys.length - 1 ? 'Final Preview' : 'Next'}
</button>
@ -3269,7 +3354,7 @@ export default function DashboardDesignPreview(props: {
<Show when={isProfessionalRole()}>
<button type="button" onClick={() => { props.onSidebarSelect('My Portfolio'); props.onTabSelect('about'); }} style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151">Open My Portfolio</button>
</Show>
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:32px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700">Open Verification</button>
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:32px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700">Open Verification</button>
</div>
</div>
</Show>
@ -3286,12 +3371,12 @@ export default function DashboardDesignPreview(props: {
<Show when={isProfessionalRole()}>
<button type="button" onClick={() => { props.onSidebarSelect('My Portfolio'); props.onTabSelect('about'); }} style="height:30px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 10px;font-size:11px;font-weight:700;color:#374151">Fill My Portfolio</button>
</Show>
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:30px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 10px;font-size:11px;font-weight:700">Submit For Approval</button>
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:30px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 10px;font-size:11px;font-weight:700">Submit For Approval</button>
</div>
</div>
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;padding:14px;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<p style="margin:0;font-size:11px;letter-spacing:0.04em;color:#6B7280;text-transform:uppercase">Profile Status</p>
<p style="margin:8px 0 0;font-size:34px;font-weight:800;color:#111827">85%</p>
<p style="margin:8px 0 0;font-size:34px;font-weight:800;color:#111827">{profileCompletion()}%</p>
</div>
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;padding:14px;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<p style="margin:0;font-size:11px;letter-spacing:0.04em;text-transform:uppercase;color:#6B7280">Credits Balance</p>
@ -3316,7 +3401,7 @@ export default function DashboardDesignPreview(props: {
style={`border:1px solid #E5E7EB;background:${draggingDashboardWidget() === w ? '#FFF8F4' : 'white'};border-radius:12px;padding:10px;min-height:92px;box-shadow:0 1px 3px rgba(0,0,0,0.05);cursor:grab`}
>
<p style="margin:0;font-size:11px;letter-spacing:0.04em;text-transform:uppercase;color:#6B7280">{titleCase(w)}</p>
<p style="margin:10px 0 0;font-size:22px;font-weight:800;color:#111827">42</p>
<p style="margin:10px 0 0;font-size:22px;font-weight:800;color:#111827">{dashboardMetrics()[w] ?? '--'}</p>
</div>
)}
</For>
@ -3440,7 +3525,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => toggleProfileSetting(String(key))}
style={`height:30px;border-radius:999px;padding:0 12px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${profileSettingToggles()[String(key)] ? '#C7D2FE' : '#D1D5DB'};background:${profileSettingToggles()[String(key)] ? '#EEF2FF' : 'white'};color:${profileSettingToggles()[String(key)] ? '#03004E' : '#374151'}`}
style={`height:30px;border-radius:999px;padding:0 12px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${profileSettingToggles()[String(key)] ? '#C7D2FE' : '#D1D5DB'};background:${profileSettingToggles()[String(key)] ? '#EEF2FF' : 'white'};color:${profileSettingToggles()[String(key)] ? '#0D0D2A' : '#374151'}`}
>
{profileSettingToggles()[String(key)] ? 'On' : 'Off'}
</button>
@ -3459,7 +3544,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => toggleProfileSetting(String(key))}
style={`height:30px;border-radius:999px;padding:0 12px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${profileSettingToggles()[String(key)] ? '#C7D2FE' : '#D1D5DB'};background:${profileSettingToggles()[String(key)] ? '#EEF2FF' : 'white'};color:${profileSettingToggles()[String(key)] ? '#03004E' : '#374151'}`}
style={`height:30px;border-radius:999px;padding:0 12px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${profileSettingToggles()[String(key)] ? '#C7D2FE' : '#D1D5DB'};background:${profileSettingToggles()[String(key)] ? '#EEF2FF' : 'white'};color:${profileSettingToggles()[String(key)] ? '#0D0D2A' : '#374151'}`}
>
{profileSettingToggles()[String(key)] ? 'On' : 'Off'}
</button>
@ -3482,7 +3567,7 @@ export default function DashboardDesignPreview(props: {
</Show>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid #E5E7EB">
<button type="button" style="height:34px;border-radius:8px;border:1px solid #D1D5DB;background:white;color:#374151;padding:0 14px;font-size:12px;font-weight:700">Cancel</button>
<button type="button" style="height:34px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 14px;font-size:12px;font-weight:700">
<button type="button" style="height:34px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 14px;font-size:12px;font-weight:700">
{activeSettingsTab === 'change_password' ? 'Update Password' : 'Save Settings'}
</button>
</div>
@ -3508,7 +3593,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => setShowDeleteAccountModal(false)}
style="height:34px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 14px;font-size:12px;font-weight:700"
style="height:34px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 14px;font-size:12px;font-weight:700"
>
Yes, Delete Account
</button>
@ -3525,7 +3610,7 @@ export default function DashboardDesignPreview(props: {
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;padding:14px;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<p style="margin:0;font-size:13px;font-weight:700;color:#111827">{isPreferencesTab ? 'Preference Details' : titleCase(selectedTab)}</p>
<span style="height:22px;padding:0 8px;border-radius:999px;border:1px solid #DDEBFF;background:#EEF4FF;display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:#03004E">
<span style="height:22px;padding:0 8px;border-radius:999px;border:1px solid #DDEBFF;background:#EEF4FF;display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:#0D0D2A">
{fieldsForTab.length} fields
</span>
</div>
@ -3612,7 +3697,7 @@ export default function DashboardDesignPreview(props: {
setTimeout(() => setProfileSaveStatus('idle'), 2500);
}).catch(() => { setProfileSaving(false); setProfileSaveStatus('error'); });
}}
style={`height:34px;border-radius:8px;border:none;background:${profileSaveStatus() === 'error' ? '#DC2626' : profileSaveStatus() === 'saved' ? '#16A34A' : '#03004E'};color:white;padding:0 14px;font-size:12px;font-weight:700`}
style={`height:34px;border-radius:8px;border:none;background:${profileSaveStatus() === 'error' ? '#DC2626' : profileSaveStatus() === 'saved' ? '#16A34A' : '#0D0D2A'};color:white;padding:0 14px;font-size:12px;font-weight:700`}
>
{profileSaving() ? 'Saving…' : profileSaveStatus() === 'saved' ? 'Saved ✓' : profileSaveStatus() === 'error' ? 'Error — Retry' : 'Save Changes'}
</button>
@ -3639,7 +3724,7 @@ export default function DashboardDesignPreview(props: {
<Show when={isProfessionalRole()}>
<button type="button" onClick={() => { props.onSidebarSelect('My Portfolio'); props.onTabSelect('about'); }} style="height:32px;border:1px solid #D1D5DB;border-radius:8px;background:white;color:#374151;padding:0 12px;font-size:12px;font-weight:700">Fill My Portfolio</button>
</Show>
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:32px;border:none;border-radius:8px;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700">Submit For Approval</button>
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:32px;border:none;border-radius:8px;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700">Submit For Approval</button>
</div>
</div>
</div>
@ -3666,7 +3751,7 @@ export default function DashboardDesignPreview(props: {
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:32px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700">Open Verification</button>
<button type="button" onClick={() => props.onSidebarSelect('Verification')} style="height:32px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700">Open Verification</button>
<button type="button" onClick={() => { props.onSidebarSelect('My Profile'); props.onTabSelect('basic information'); }} style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;color:#374151;padding:0 12px;font-size:12px;font-weight:700">Complete Profile</button>
<button type="button" onClick={() => { props.onSidebarSelect('My Portfolio'); props.onTabSelect('about'); }} style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;color:#374151;padding:0 12px;font-size:12px;font-weight:700">Complete Portfolio</button>
</div>
@ -3695,7 +3780,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:8px 0 0;font-size:30px;line-height:1;font-weight:800;color:#111827">{250 - leadCredits()}</p>
<p style="margin:6px 0 0;font-size:12px;color:#6B7280">Tracecoins used this month</p>
</div>
<div style="border:1px solid #D7DBFF;background:#03004E;border-radius:14px;padding:12px;color:white">
<div style="border:1px solid #D7DBFF;background:#0D0D2A;border-radius:14px;padding:12px;color:white">
<p style="margin:0;font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:#D7DBFF">Active Requests</p>
<p style="margin:8px 0 0;font-size:30px;line-height:1;font-weight:800">{leadCards().filter((card) => card.status === 'requested').length}</p>
<div style="height:4px;border-radius:999px;background:rgba(255,255,255,0.2);overflow:hidden;margin-top:8px">
@ -3743,7 +3828,7 @@ export default function DashboardDesignPreview(props: {
</div>
</Show>
</div>
<button type="button" style="height:32px;border-radius:8px;border:none;background:#03004E;padding:0 10px;font-size:12px;font-weight:700;color:white">Export</button>
<button type="button" style="height:32px;border-radius:8px;border:none;background:#0D0D2A;padding:0 10px;font-size:12px;font-weight:700;color:white">Export</button>
</div>
</div>
<div style="padding:10px 12px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
@ -3756,7 +3841,7 @@ export default function DashboardDesignPreview(props: {
</div>
<div style="max-height:360px;overflow:auto">
<table style="width:100%;border-collapse:collapse">
<thead style="background:#03004E;color:white">
<thead style="background:#0D0D2A;color:white">
<tr>
{['Lead ID', 'Lead Title', 'Request Date', 'Request Status', 'Cost', 'Decision Date', 'Action'].map((h) => (
<th style="padding:10px;text-align:left;font-size:10px;letter-spacing:0.06em;text-transform:uppercase">{h}</th>
@ -3781,7 +3866,7 @@ export default function DashboardDesignPreview(props: {
<td style="padding:10px">
<div style="display:flex;gap:6px;flex-wrap:wrap">
<Show when={row.status === 'request_sent'}>
<button type="button" onClick={() => approveLeadContact(row.id)} style="height:28px;border-radius:8px;border:none;background:#03004E;padding:0 10px;font-size:11px;font-weight:700;color:white">Approve (Demo)</button>
<button type="button" onClick={() => approveLeadContact(row.id)} style="height:28px;border-radius:8px;border:none;background:#0D0D2A;padding:0 10px;font-size:11px;font-weight:700;color:white">Approve (Demo)</button>
<button type="button" onClick={() => cancelLeadRequest(row.id)} style="height:28px;border-radius:8px;border:1px solid #FECACA;background:#FEF2F2;padding:0 10px;font-size:11px;font-weight:700;color:#B91C1C">Cancel Request</button>
<button type="button" onClick={() => refundPendingLead(row.id)} style="height:28px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 10px;font-size:11px;font-weight:700;color:#374151">Refund After 1 Day</button>
</Show>
@ -3889,7 +3974,7 @@ export default function DashboardDesignPreview(props: {
<div style="display:flex;justify-content:space-between;gap:8px;padding-bottom:6px;border-bottom:1px solid #F3F4F6"><span style="font-size:12px;color:#6B7280">Contacted</span><span style="font-size:12px;color:#111827;font-weight:700">{lead.contactCount}/{lead.maxContacts}</span></div>
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap">
<button type="button" onClick={() => { setLeadMarketplaceTab('All Leads'); setActiveLeadDetailId(''); }} style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151">Back to Leads</button>
<button type="button" onClick={() => openLeadContactConfirm(lead.id)} disabled={usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts} style={`height:32px;border-radius:8px;border:none;padding:0 12px;font-size:12px;font-weight:700;color:white;background:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? '#9CA3AF' : '#03004E'}`}>Request Contact</button>
<button type="button" onClick={() => openLeadContactConfirm(lead.id)} disabled={usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts} style={`height:32px;border-radius:8px;border:none;padding:0 12px;font-size:12px;font-weight:700;color:white;background:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? '#9CA3AF' : '#0D0D2A'}`}>Request Contact</button>
</div>
</div>
</div>
@ -3961,7 +4046,7 @@ export default function DashboardDesignPreview(props: {
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px">
<button type="button" onClick={() => { setLeadAreaFilter('All Areas'); setLeadBudgetFilter('All Budgets'); setLeadDateFilter('Any Date'); }} style="height:30px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 10px;font-size:11px;color:#374151;font-weight:700">Reset</button>
<button type="button" onClick={() => setLeadFiltersOpen(false)} style="height:30px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 10px;font-size:11px;font-weight:700">Apply</button>
<button type="button" onClick={() => setLeadFiltersOpen(false)} style="height:30px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 10px;font-size:11px;font-weight:700">Apply</button>
</div>
</div>
</Show>
@ -4040,7 +4125,7 @@ export default function DashboardDesignPreview(props: {
type="button"
onClick={() => openLeadContactConfirm(lead.id)}
disabled={usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts}
style={`height:30px;border-radius:8px;border:none;padding:0 10px;font-size:12px;font-weight:700;color:white;background:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? '#9CA3AF' : '#03004E'};cursor:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? 'not-allowed' : 'pointer'}`}
style={`height:30px;border-radius:8px;border:none;padding:0 10px;font-size:12px;font-weight:700;color:white;background:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? '#9CA3AF' : '#0D0D2A'};cursor:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? 'not-allowed' : 'pointer'}`}
>
Request Contact
</button>
@ -4084,7 +4169,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:10px 0 0;font-size:13px;color:#374151;line-height:1.5">You are about to spend <strong>25 Tracecoins</strong> to request and view this service seeker contact when approved. Do you want to continue?</p>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:14px">
<button type="button" onClick={() => setLeadContactConfirmId('')} style="height:34px;border-radius:8px;border:1px solid #D1D5DB;background:white;color:#374151;padding:0 14px;font-size:12px;font-weight:700">Cancel</button>
<button type="button" onClick={confirmLeadContactRequest} style="height:34px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 14px;font-size:12px;font-weight:700">Yes, Request Contact</button>
<button type="button" onClick={confirmLeadContactRequest} style="height:34px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 14px;font-size:12px;font-weight:700">Yes, Request Contact</button>
</div>
</div>
</div>
@ -4118,7 +4203,7 @@ export default function DashboardDesignPreview(props: {
{ label: 'Shortlisted', value: '03', tone: 'orange' },
{ label: 'Interviews', value: '02', tone: 'green' },
].map((card) => (
<div style={`border:1px solid #E5E7EB;border-radius:12px;padding:10px;background:${card.tone === 'dark' ? '#03004E' : 'white'};color:${card.tone === 'dark' ? 'white' : '#111827'};box-shadow:0 1px 3px rgba(0,0,0,0.05)`}>
<div style={`border:1px solid #E5E7EB;border-radius:12px;padding:10px;background:${card.tone === 'dark' ? '#0D0D2A' : 'white'};color:${card.tone === 'dark' ? 'white' : '#111827'};box-shadow:0 1px 3px rgba(0,0,0,0.05)`}>
<p style={`margin:0;font-size:11px;letter-spacing:0.05em;text-transform:uppercase;color:${card.tone === 'dark' ? '#D7DBFF' : '#9CA3AF'}`}>{card.label}</p>
<p style={`margin:4px 0 0;font-size:32px;line-height:1;font-weight:800;color:${card.tone === 'orange' ? '#C2410C' : card.tone === 'green' ? '#16A34A' : card.tone === 'blue' ? '#2563EB' : card.tone === 'dark' ? 'white' : '#111827'}`}>{card.value}</p>
</div>
@ -4169,7 +4254,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:0;font-size:30px;line-height:1.2;font-weight:800;color:#111827">Boost your response rate</p>
<p style="margin:6px 0 0;font-size:13px;color:#6B7280">Recruiters are more likely to respond to profiles with updated resume and portfolio links.</p>
</div>
<button type="button" onClick={() => setJobSeekerScreen('apply')} style="height:34px;border:none;border-radius:9px;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700;white-space:nowrap">Optimize Profile</button>
<button type="button" onClick={() => setJobSeekerScreen('apply')} style="height:34px;border:none;border-radius:9px;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700;white-space:nowrap">Optimize Profile</button>
</div>
</div>
);
@ -4179,7 +4264,7 @@ export default function DashboardDesignPreview(props: {
return (
<div style="border:1px solid #E5E7EB;border-radius:16px;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="display:grid;grid-template-columns:280px 1fr;min-height:520px">
<aside style="background:#03004E;color:white;padding:14px;display:flex;flex-direction:column;justify-content:space-between">
<aside style="background:#0D0D2A;color:white;padding:14px;display:flex;flex-direction:column;justify-content:space-between">
<div>
<p style="margin:0;font-size:36px;line-height:1.1;font-weight:800">Nxtgauge</p>
<p style="margin:12px 0 0;font-size:10px;letter-spacing:0.08em;text-transform:uppercase;color:#A7B2FF">Applying For</p>
@ -4332,7 +4417,7 @@ export default function DashboardDesignPreview(props: {
].map((item) => <p style="margin:0;font-size:13px;color:#374151;line-height:1.45"> {item}</p>)}
</div>
</div>
<div style="border:1px solid #191970;border-radius:12px;background:#03004E;color:white;padding:12px">
<div style="border:1px solid #191970;border-radius:12px;background:#0D0D2A;color:white;padding:12px">
<p style="margin:0;font-size:10px;letter-spacing:0.08em;text-transform:uppercase;color:#CDD4FF">Total Compensation</p>
<p style="margin:6px 0 0;font-size:24px;line-height:1.2;font-weight:800">{selectedJob().salary} <span style="font-size:12px;font-weight:600">/ year</span></p>
<div style="display:flex;gap:8px;margin-top:10px">
@ -4357,7 +4442,7 @@ export default function DashboardDesignPreview(props: {
{ label: 'Shortlisted', value: '06', accent: '#0EA5E9', hint: '' },
{ label: 'New Today', value: '18', accent: 'white', hint: 'dark' },
].map((card) => (
<div style={`border:1px solid #E5E7EB;border-radius:12px;padding:10px;background:${card.hint === 'dark' ? '#03004E' : 'white'};box-shadow:0 1px 3px rgba(0,0,0,0.05)`}>
<div style={`border:1px solid #E5E7EB;border-radius:12px;padding:10px;background:${card.hint === 'dark' ? '#0D0D2A' : 'white'};box-shadow:0 1px 3px rgba(0,0,0,0.05)`}>
<p style={`margin:0;font-size:10px;letter-spacing:0.06em;text-transform:uppercase;color:${card.hint === 'dark' ? '#D7DBFF' : '#6B7280'}`}>{card.label}</p>
<p style={`margin:5px 0 0;font-size:34px;line-height:1;font-weight:800;color:${card.hint === 'dark' ? 'white' : card.accent}`}>{card.value}</p>
</div>
@ -4411,7 +4496,7 @@ export default function DashboardDesignPreview(props: {
setJobSeekerApplyStep(2);
setJobSeekerScreen('apply');
}}
style="height:32px;border-radius:8px;border:none;background:#03004E;padding:0 12px;font-size:12px;font-weight:700;color:white"
style="height:32px;border-radius:8px;border:none;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:700;color:white"
>
Apply Now
</button>
@ -4504,7 +4589,7 @@ export default function DashboardDesignPreview(props: {
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="border:1px solid #E5E7EB;background:#03004E;border-radius:16px;padding:14px;color:white;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<div style="border:1px solid #E5E7EB;background:#0D0D2A;border-radius:16px;padding:14px;color:white;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<p style="margin:0;font-size:30px;line-height:1.15;font-weight:800">Did you know?</p>
<p style="margin:8px 0 0;font-size:13px;line-height:1.55;color:#D5D8FF">Jobs with high-quality company descriptions receive <strong>40% more applications</strong>. Take a moment to update your profile.</p>
<button type="button" style="margin-top:10px;height:30px;border:none;border-radius:8px;background:#C2410C;padding:0 10px;font-size:12px;font-weight:700;color:white">Enhance Profile</button>
@ -4587,7 +4672,7 @@ export default function DashboardDesignPreview(props: {
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<div style="background:#03004E;padding:12px;color:white">
<div style="background:#0D0D2A;padding:12px;color:white">
<p style="margin:0;font-size:30px;font-weight:800;line-height:1.1">Pricing & Approval</p>
<p style="margin:6px 0 0;font-size:13px;color:#D7DBFF">Review costs and confirm submission</p>
</div>
@ -4606,7 +4691,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:0;font-size:13px;font-weight:700;color:#111827">Approval Required: Yes</p>
<p style="margin:4px 0 0;font-size:12px;color:#6B7280;line-height:1.45">Your post will be reviewed by our moderation team within 24 hours.</p>
</div>
<button type="button" onClick={() => { submitCompanyJobForReview(); setJobPostView('success'); }} style="margin-top:10px;height:38px;width:100%;border:none;border-radius:10px;background:#03004E;color:white;font-size:12px;font-weight:700">Pay 500 Tracecoins & Submit</button>
<button type="button" onClick={() => { submitCompanyJobForReview(); setJobPostView('success'); }} style="margin-top:10px;height:38px;width:100%;border:none;border-radius:10px;background:#0D0D2A;color:white;font-size:12px;font-weight:700">Pay 500 Tracecoins & Submit</button>
</div>
</div>
<div style="border:1px solid #FFD8C2;background:linear-gradient(135deg,#3A1E00 0%,#C2410C 90%);border-radius:16px;padding:14px;color:white;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
@ -4669,7 +4754,7 @@ export default function DashboardDesignPreview(props: {
<button type="button" onClick={goPrevJobStep} disabled={jobPostStep() === 1} style={`height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:${jobPostStep() === 1 ? '#9CA3AF' : '#374151'};cursor:${jobPostStep() === 1 ? 'not-allowed' : 'pointer'}`}>Back</button>
<div style="display:flex;gap:8px">
<button type="button" style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151">Save as Draft</button>
<button type="button" onClick={jobPostStep() === COMPANY_JOB_STEPS.length ? () => setJobPostView('review') : goNextJobStep} style="height:34px;border-radius:8px;border:none;background:#03004E;padding:0 12px;font-size:12px;font-weight:700;color:white">
<button type="button" onClick={jobPostStep() === COMPANY_JOB_STEPS.length ? () => setJobPostView('review') : goNextJobStep} style="height:34px;border-radius:8px;border:none;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:700;color:white">
{jobPostStep() === COMPANY_JOB_STEPS.length ? 'Go To Review' : 'Next: Role & Requirements'}
</button>
</div>
@ -4943,7 +5028,7 @@ export default function DashboardDesignPreview(props: {
</button>
<div style="display:flex;gap:8px">
<button type="button" style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;color:#374151;padding:0 12px;font-size:12px;font-weight:700">Save As Draft</button>
<button type="button" onClick={goNextStep} style="height:34px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700">{nextLabel()}</button>
<button type="button" onClick={goNextStep} style="height:34px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700">{nextLabel()}</button>
</div>
</div>
</div>
@ -5060,7 +5145,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => { setRequirementsView('new'); setRequirementsStep(1); }}
style="height:38px;border-radius:10px;border:none;background:#03004E;color:white;padding:0 14px;font-size:12px;font-weight:700"
style="height:38px;border-radius:10px;border:none;background:#0D0D2A;color:white;padding:0 14px;font-size:12px;font-weight:700"
>
+ Post New Requirement
</button>
@ -5134,7 +5219,7 @@ export default function DashboardDesignPreview(props: {
>
View
</button>
<button type="button" style="height:28px;border-radius:8px;border:none;background:#03004E;padding:0 10px;font-size:11px;font-weight:700;color:white">Edit</button>
<button type="button" style="height:28px;border-radius:8px;border:none;background:#0D0D2A;padding:0 10px;font-size:11px;font-weight:700;color:white">Edit</button>
</div>
</td>
</tr>
@ -5266,7 +5351,7 @@ export default function DashboardDesignPreview(props: {
</div>
</Show>
</div>
<button type="button" style="display:inline-flex;height:32px;align-items:center;gap:6px;border-radius:8px;border:none;background:#03004E;padding:0 10px;font-size:12px;font-weight:700;color:white"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Export</button>
<button type="button" style="display:inline-flex;height:32px;align-items:center;gap:6px;border-radius:8px;border:none;background:#0D0D2A;padding:0 10px;font-size:12px;font-weight:700;color:white"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Export</button>
</div>
</div>
<div style="padding:10px 12px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
@ -5279,7 +5364,7 @@ export default function DashboardDesignPreview(props: {
</div>
<div style="max-height:420px;overflow:auto">
<table style="width:100%;border-collapse:collapse">
<thead style="background:#03004E;color:white">
<thead style="background:#0D0D2A;color:white">
<tr>
{['Lead ID', 'Lead Title', 'Request Date', 'Status', 'Cost', 'Decision Date', 'Action'].map((h) => (
<th style="padding:10px;text-align:left;font-size:10px;letter-spacing:0.06em;text-transform:uppercase">{h}</th>
@ -5370,7 +5455,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={openPortfolioPreviewInline}
style="height:30px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 10px;font-size:12px;font-weight:700;white-space:nowrap"
style="height:30px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 10px;font-size:12px;font-weight:700;white-space:nowrap"
>
Preview Portfolio
</button>
@ -5495,7 +5580,7 @@ export default function DashboardDesignPreview(props: {
type="button"
onClick={() => void applyCoupon()}
disabled={couponLoading()}
style="height:32px;border:none;border-radius:6px;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700;cursor:pointer;opacity:${couponLoading() ? 0.6 : 1}"
style="height:32px;border:none;border-radius:6px;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700;cursor:pointer;opacity:${couponLoading() ? 0.6 : 1}"
>
{couponLoading() ? 'Applying...' : 'Apply'}
</button>
@ -5524,12 +5609,12 @@ export default function DashboardDesignPreview(props: {
<div style="width:56px;height:56px;border-radius:999px;background:white;display:flex;align-items:center;justify-content:center;margin-bottom:14px;box-shadow:0 2px 8px rgba(3,0,78,0.1)">
<img src="/sidebar-icons/security.svg" alt="" style={`width:28px;height:28px;object-fit:contain;filter:${BLUE_ICON_FILTER}`} />
</div>
<h3 style="margin:0;font-size:18px;font-weight:800;color:#03004E">Secure Gateway</h3>
<h3 style="margin:0;font-size:18px;font-weight:800;color:#0D0D2A">Secure Gateway</h3>
<p style="margin:8px 0 20px;font-size:13px;color:#4F4B8A;max-width:240px">Continue to our secure partner gateway to complete the transaction.</p>
<button
type="button"
onClick={() => startPayment(pkg)}
style="height:44px;width:100%;max-width:220px;border:none;border-radius:10px;background:#03004E;color:white;font-size:14px;font-weight:700;cursor:pointer;box-shadow:0 4px 12px rgba(3,0,78,0.2)"
style="height:44px;width:100%;max-width:220px;border:none;border-radius:10px;background:#0D0D2A;color:white;font-size:14px;font-weight:700;cursor:pointer;box-shadow:0 4px 12px rgba(3,0,78,0.2)"
>
Confirm & Pay Now
</button>
@ -5555,7 +5640,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => { setCheckoutPackage(null); setPaymentStep('idle'); }}
style="height:40px;width:100%;max-width:220px;border:none;border-radius:10px;background:#03004E;color:white;font-size:13px;font-weight:700;cursor:pointer"
style="height:40px;width:100%;max-width:220px;border:none;border-radius:10px;background:#0D0D2A;color:white;font-size:13px;font-weight:700;cursor:pointer"
>
Back to Dashboard
</button>
@ -5638,7 +5723,7 @@ export default function DashboardDesignPreview(props: {
if (amtInput) amtInput.value = '';
if (rsnInput) rsnInput.value = '';
}}
style="height:38px;border:none;border-radius:8px;background:#03004E;color:white;font-size:13px;font-weight:700;cursor:pointer"
style="height:38px;border:none;border-radius:8px;background:#0D0D2A;color:white;font-size:13px;font-weight:700;cursor:pointer"
>
Apply Adjustment
</button>
@ -5669,7 +5754,7 @@ export default function DashboardDesignPreview(props: {
>
Manage Credits
</button>
<button type="button" style="height:36px;border:none;border-radius:8px;background:#03004E;color:white;padding:0 14px;font-size:12px;font-weight:700;white-space:nowrap">Buy Credits</button>
<button type="button" style="height:36px;border:none;border-radius:8px;background:#0D0D2A;color:white;padding:0 14px;font-size:12px;font-weight:700;white-space:nowrap">Buy Credits</button>
</div>
</div>
@ -5694,7 +5779,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => setCheckoutPackage(pkg)}
style={`margin-top:10px;height:32px;width:100%;border:none;border-radius:8px;background:${pkg.is_popular ? '#FF5E13' : '#03004E'};color:white;font-size:12px;font-weight:700;cursor:pointer`}
style={`margin-top:10px;height:32px;width:100%;border:none;border-radius:8px;background:${pkg.is_popular ? '#FF5E13' : '#0D0D2A'};color:white;font-size:12px;font-weight:700;cursor:pointer`}
>
Buy Package
</button>
@ -5722,7 +5807,7 @@ export default function DashboardDesignPreview(props: {
</div>
</div>
<div style="border:1px solid #E5E7EB;background:#03004E;border-radius:12px;padding:12px;color:white;box-shadow:0 1px 3px rgba(0,0,0,0.05)">
<div style="border:1px solid #E5E7EB;background:#0D0D2A;border-radius:12px;padding:12px;color:white;box-shadow:0 1px 3px rgba(0,0,0,0.05)">
<p style="margin:0;font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:#C7D2FE">Recommended</p>
<p style="margin:6px 0 0;font-size:20px;font-weight:800;line-height:1.2">Start with Standard</p>
<p style="margin:6px 0 0;font-size:12px;color:#D7DBFF;line-height:1.45">Best value for active buying and response unlocks.</p>
@ -5755,7 +5840,7 @@ export default function DashboardDesignPreview(props: {
</button>
</div>
<table style="width:100%;border-collapse:collapse">
<thead style="background:#03004E;color:white">
<thead style="background:#0D0D2A;color:white">
<tr>
{['Invoice No', 'Package', 'Credits', 'Amount Paid', 'Status', 'Date', 'Actions'].map((h) => (
<th style="padding:10px;font-size:11px;text-align:left;letter-spacing:0.04em;text-transform:uppercase">{h}</th>
@ -5770,7 +5855,7 @@ export default function DashboardDesignPreview(props: {
<td style="padding:10px;font-size:12px;color:#111827">{row[2]}</td>
<td style="padding:10px;font-size:12px;font-weight:700;color:#111827">{row[3]}</td>
<td style="padding:10px;font-size:12px">
<span style={`display:inline-flex;height:22px;padding:0 10px;border-radius:999px;font-size:11px;font-weight:700;align-items:center;background:${row[4] === 'Completed' ? '#FFF3EE' : row[4] === 'Pending' ? '#EEF2FF' : '#F3F4F6'};color:${row[4] === 'Completed' ? '#FF5E13' : row[4] === 'Pending' ? '#03004E' : '#6B7280'}`}>{row[4]}</span>
<span style={`display:inline-flex;height:22px;padding:0 10px;border-radius:999px;font-size:11px;font-weight:700;align-items:center;background:${row[4] === 'Completed' ? '#FFF3EE' : row[4] === 'Pending' ? '#EEF2FF' : '#F3F4F6'};color:${row[4] === 'Completed' ? '#FF5E13' : row[4] === 'Pending' ? '#0D0D2A' : '#6B7280'}`}>{row[4]}</span>
</td>
<td style="padding:10px;font-size:12px;color:#64748B">{row[5]}</td>
<td style="padding:10px">
@ -5823,7 +5908,7 @@ export default function DashboardDesignPreview(props: {
</button>
</div>
<table style="width:100%;border-collapse:collapse">
<thead style="background:#03004E;color:white">
<thead style="background:#0D0D2A;color:white">
<tr>
{['Usage ID', 'Action Type', 'Credits Used', 'Related ID', 'Date', 'Remarks'].map((h) => (
<th style="padding:10px;font-size:11px;text-align:left;letter-spacing:0.04em;text-transform:uppercase">{h}</th>
@ -5840,7 +5925,7 @@ export default function DashboardDesignPreview(props: {
<tr style="border-bottom:1px solid #E5E7EB">
<td style="padding:10px;font-size:12px;font-weight:700;color:#64748B">{row[0]}</td>
<td style="padding:10px;font-size:12px;color:#111827">{row[1]}</td>
<td style="padding:10px;font-size:12px;font-weight:700;color:#03004E">{row[2]}</td>
<td style="padding:10px;font-size:12px;font-weight:700;color:#0D0D2A">{row[2]}</td>
<td style="padding:10px;font-size:12px;color:#64748B">{row[3]}</td>
<td style="padding:10px;font-size:12px;color:#64748B">{row[4]}</td>
<td style="padding:10px;font-size:12px;color:#64748B">{row[5]}</td>
@ -5852,7 +5937,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:0;font-size:12px;color:#64748B">Showing 1 to 4 of 142 results</p>
<div style="display:flex;gap:6px">
{[1, 2, 3].map((p) => (
<button type="button" style={`width:30px;height:30px;border-radius:8px;border:1px solid #E5E7EB;background:${p === 1 ? '#03004E' : '#fff'};color:${p === 1 ? 'white' : '#6B7280'};font-size:12px;font-weight:700`}>{p}</button>
<button type="button" style={`width:30px;height:30px;border-radius:8px;border:1px solid #E5E7EB;background:${p === 1 ? '#0D0D2A' : '#fff'};color:${p === 1 ? 'white' : '#6B7280'};font-size:12px;font-weight:700`}>{p}</button>
))}
</div>
</div>
@ -5882,7 +5967,7 @@ export default function DashboardDesignPreview(props: {
</button>
</div>
<table style="width:100%;border-collapse:collapse">
<thead style="background:#03004E;color:white">
<thead style="background:#0D0D2A;color:white">
<tr>
{['Invoice Number', 'Billing Date', 'Package', 'Total', 'Status'].map((h) => (
<th style="padding:10px;font-size:11px;text-align:left;letter-spacing:0.04em;text-transform:uppercase">{h}</th>
@ -5907,7 +5992,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:0;font-size:12px;color:#64748B">Showing 1 to 4 of 24 invoices</p>
<div style="display:flex;gap:6px">
{[1, 2, 3].map((p) => (
<button type="button" style={`width:30px;height:30px;border-radius:8px;border:1px solid #E5E7EB;background:${p === 1 ? '#03004E' : '#fff'};color:${p === 1 ? 'white' : '#6B7280'};font-size:12px;font-weight:700`}>{p}</button>
<button type="button" style={`width:30px;height:30px;border-radius:8px;border:1px solid #E5E7EB;background:${p === 1 ? '#0D0D2A' : '#fff'};color:${p === 1 ? 'white' : '#6B7280'};font-size:12px;font-weight:700`}>{p}</button>
))}
</div>
</div>
@ -5940,13 +6025,13 @@ export default function DashboardDesignPreview(props: {
<p style="margin:0;font-size:11px;color:#6B7280;text-transform:uppercase">Pending Invoices</p>
</div>
<p style="margin:8px 0 0;font-size:26px;font-weight:800;color:#111827">1</p>
<button type="button" style="margin-top:8px;height:30px;border:none;border-radius:8px;background:#03004E;color:white;padding:0 10px;font-size:12px;font-weight:700">Buy Credits</button>
<button type="button" style="margin-top:8px;height:30px;border:none;border-radius:8px;background:#0D0D2A;color:white;padding:0 10px;font-size:12px;font-weight:700">Buy Credits</button>
</div>
</div>
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="padding:10px 12px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center">
<p style="margin:0;font-size:14px;font-weight:700;color:#111827">Recent Transactions</p>
<button type="button" style="height:30px;border:none;border-radius:8px;background:#03004E;color:white;padding:0 10px;font-size:12px;font-weight:700">Buy Credits</button>
<button type="button" style="height:30px;border:none;border-radius:8px;background:#0D0D2A;color:white;padding:0 10px;font-size:12px;font-weight:700">Buy Credits</button>
</div>
<div style="padding:12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #E5E7EB;background:#F9FAFB">
<div style="display:flex;align-items:center;gap:8px;flex:1">
@ -5966,7 +6051,7 @@ export default function DashboardDesignPreview(props: {
</button>
</div>
<table style="width:100%;border-collapse:collapse">
<thead style="background:#03004E;color:white">
<thead style="background:#0D0D2A;color:white">
<tr>
{['Transaction ID', 'Package', 'Credits', 'Amount Paid', 'Status', 'Date'].map((h) => (
<th style="padding:10px;font-size:11px;text-align:left;letter-spacing:0.04em;text-transform:uppercase">{h}</th>
@ -5981,7 +6066,7 @@ export default function DashboardDesignPreview(props: {
<td style="padding:10px;font-size:12px;color:#111827">{row[2]}</td>
<td style="padding:10px;font-size:12px;font-weight:700;color:#111827">{row[3]}</td>
<td style="padding:10px;font-size:12px">
<span style={`display:inline-flex;height:22px;padding:0 10px;border-radius:999px;font-size:11px;font-weight:700;align-items:center;background:${row[4] === 'Completed' ? '#FFF3EE' : row[4] === 'Pending' ? '#EEF2FF' : '#F3F4F6'};color:${row[4] === 'Completed' ? '#FF5E13' : row[4] === 'Pending' ? '#03004E' : '#6B7280'}`}>{row[4]}</span>
<span style={`display:inline-flex;height:22px;padding:0 10px;border-radius:999px;font-size:11px;font-weight:700;align-items:center;background:${row[4] === 'Completed' ? '#FFF3EE' : row[4] === 'Pending' ? '#EEF2FF' : '#F3F4F6'};color:${row[4] === 'Completed' ? '#FF5E13' : row[4] === 'Pending' ? '#0D0D2A' : '#6B7280'}`}>{row[4]}</span>
</td>
<td style="padding:10px;font-size:12px;color:#64748B">{row[5]}</td>
</tr>
@ -6018,7 +6103,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => isCurrentRole ? null : isRegistered ? switchRole(roleCard.key) : registerRole(roleCard.key)}
style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${isCurrentRole ? '#E5E7EB' : '#03004E'};color:${isCurrentRole ? '#4B5563' : 'white'};padding:0 10px;font-size:12px;font-weight:700`}
style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${isCurrentRole ? '#E5E7EB' : '#0D0D2A'};color:${isCurrentRole ? '#4B5563' : 'white'};padding:0 10px;font-size:12px;font-weight:700`}
>
{isCurrentRole ? 'Current Role' : isRegistered ? 'Switch' : `Register as ${roleCard.title}`}
</button>
@ -6048,7 +6133,7 @@ export default function DashboardDesignPreview(props: {
<button
type="button"
onClick={() => role.action === 'Active' ? null : role.action === 'Switch' ? switchRole(role.key) : registerRole(role.key)}
style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${role.action === 'Register' ? '#03004E' : '#E5E7EB'};color:${role.action === 'Register' ? 'white' : '#4B5563'};padding:0 10px;font-size:12px;font-weight:700`}
style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${role.action === 'Register' ? '#0D0D2A' : '#E5E7EB'};color:${role.action === 'Register' ? 'white' : '#4B5563'};padding:0 10px;font-size:12px;font-weight:700`}
>
{role.action}
</button>
@ -6117,7 +6202,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:6px 0 0;font-size:12px;color:#6B7280">Finish your basic information and required documents, then submit.</p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">
<button type="button" onClick={() => { props.onSidebarSelect('My Profile'); props.onTabSelect('basic information'); }} style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151">Open My Profile</button>
<button type="button" onClick={submitProfileForApproval} disabled={profileApprovalState() === 'IN_REVIEW'} style={`height:32px;border-radius:8px;border:none;background:${profileApprovalState() === 'IN_REVIEW' ? '#9CA3AF' : '#03004E'};color:white;padding:0 12px;font-size:12px;font-weight:700`}>Submit Profile</button>
<button type="button" onClick={submitProfileForApproval} disabled={profileApprovalState() === 'IN_REVIEW'} style={`height:32px;border-radius:8px;border:none;background:${profileApprovalState() === 'IN_REVIEW' ? '#9CA3AF' : '#0D0D2A'};color:white;padding:0 12px;font-size:12px;font-weight:700`}>Submit Profile</button>
</div>
</div>
<div style="border:1px solid #E5E7EB;background:white;border-radius:14px;padding:14px">
@ -6125,7 +6210,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:6px 0 0;font-size:12px;color:#6B7280">Add portfolio details and submit separately for review.</p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">
<button type="button" onClick={() => { props.onSidebarSelect('My Portfolio'); props.onTabSelect('about'); }} style="height:32px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151">Open My Portfolio</button>
<button type="button" onClick={submitPortfolioForApproval} disabled={portfolioApprovalState() === 'IN_REVIEW'} style={`height:32px;border-radius:8px;border:none;background:${portfolioApprovalState() === 'IN_REVIEW' ? '#9CA3AF' : '#03004E'};color:white;padding:0 12px;font-size:12px;font-weight:700`}>Submit Portfolio</button>
<button type="button" onClick={submitPortfolioForApproval} disabled={portfolioApprovalState() === 'IN_REVIEW'} style={`height:32px;border-radius:8px;border:none;background:${portfolioApprovalState() === 'IN_REVIEW' ? '#9CA3AF' : '#0D0D2A'};color:white;padding:0 12px;font-size:12px;font-weight:700`}>Submit Portfolio</button>
</div>
</div>
</div>
@ -6173,7 +6258,7 @@ export default function DashboardDesignPreview(props: {
</div>
<div style="border:1px solid #E5E7EB;background:white;border-radius:12px;padding:12px">
<p style="margin:0;font-size:10px;letter-spacing:0.06em;text-transform:uppercase;color:#9CA3AF">Approved</p>
<p style="margin:6px 0 0;font-size:22px;font-weight:800;color:#03004E">2</p>
<p style="margin:6px 0 0;font-size:22px;font-weight:800;color:#0D0D2A">2</p>
</div>
<div style="border:1px solid #FFE2D3;background:#FFF8F4;border-radius:12px;padding:12px">
<p style="margin:0;font-size:10px;letter-spacing:0.06em;text-transform:uppercase;color:#9CA3AF">Needs Action</p>
@ -6186,13 +6271,13 @@ export default function DashboardDesignPreview(props: {
<p style="margin:0;font-size:12px;font-weight:800;color:#111827">Admin requested missing documents</p>
<p style="margin:4px 0 0;font-size:12px;color:#374151">Required Missing Documents: Address Proof (clear PDF/JPG/PNG).</p>
</div>
<button type="button" style="height:32px;border:none;border-radius:8px;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700;white-space:nowrap">Upload Missing</button>
<button type="button" style="height:32px;border:none;border-radius:8px;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700;white-space:nowrap">Upload Missing</button>
</div>
<div style="border:1px solid #E5E7EB;background:white;border-radius:14px;overflow:hidden">
<div style="padding:12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #E5E7EB">
<p style="margin:0;font-size:18px;font-weight:800;color:#111827">Documents</p>
<button type="button" style="height:32px;border:none;border-radius:8px;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700">Upload New</button>
<button type="button" style="height:32px;border:none;border-radius:8px;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700">Upload New</button>
</div>
<table style="width:100%;border-collapse:collapse">
<thead>
@ -6213,7 +6298,7 @@ export default function DashboardDesignPreview(props: {
<td style="padding:10px;font-size:12px;color:#111827;font-weight:600">{doc}</td>
<td style="padding:10px;font-size:12px;color:#374151">{file}</td>
<td style="padding:10px">
<span style={`height:22px;padding:0 8px;border-radius:999px;border:1px solid ${state === 'Rejected' ? '#FFD8C2' : '#DDEBFF'};background:${state === 'Rejected' ? '#FFF1EB' : '#EEF4FF'};display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:${state === 'Rejected' ? '#FF5E13' : '#03004E'}`}>{state}</span>
<span style={`height:22px;padding:0 8px;border-radius:999px;border:1px solid ${state === 'Rejected' ? '#FFD8C2' : '#DDEBFF'};background:${state === 'Rejected' ? '#FFF1EB' : '#EEF4FF'};display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:${state === 'Rejected' ? '#FF5E13' : '#0D0D2A'}`}>{state}</span>
</td>
<td style="padding:10px;text-align:right">
<Show
@ -6247,7 +6332,7 @@ export default function DashboardDesignPreview(props: {
<input type="file" style="margin-top:10px;font-size:12px;color:#374151" />
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:10px">
<button type="button" style="height:32px;border-radius:8px;border:1px solid #D1D5DB;background:white;color:#374151;padding:0 12px;font-size:12px;font-weight:700">Cancel</button>
<button type="button" style="height:32px;border-radius:8px;border:none;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700">Submit</button>
<button type="button" style="height:32px;border-radius:8px;border:none;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700">Submit</button>
</div>
</div>
</div>
@ -6271,7 +6356,7 @@ export default function DashboardDesignPreview(props: {
<p style="margin:0;font-size:12px;font-weight:700;color:#111827">{title}</p>
<p style="margin:2px 0 0;font-size:11px;color:#6B7280">{time}</p>
</div>
<span style={`height:22px;padding:0 8px;border-radius:999px;border:1px solid ${state === 'Action Needed' ? '#FFD8C2' : '#DDEBFF'};background:${state === 'Action Needed' ? '#FFF1EB' : '#EEF4FF'};display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:${state === 'Action Needed' ? '#FF5E13' : '#03004E'}`}>{state}</span>
<span style={`height:22px;padding:0 8px;border-radius:999px;border:1px solid ${state === 'Action Needed' ? '#FFD8C2' : '#DDEBFF'};background:${state === 'Action Needed' ? '#FFF1EB' : '#EEF4FF'};display:inline-flex;align-items:center;font-size:10px;font-weight:700;color:${state === 'Action Needed' ? '#FF5E13' : '#0D0D2A'}`}>{state}</span>
</div>
))}
</div>
@ -6635,7 +6720,7 @@ export default function DashboardDesignPreview(props: {
type="button"
disabled={isCurrent}
onClick={() => switchRole(roleKey)}
style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${isCurrent ? '#E5E7EB' : '#03004E'};color:${isCurrent ? '#4B5563' : 'white'};padding:0 10px;font-size:12px;font-weight:700;cursor:${isCurrent ? 'default' : 'pointer'}`}
style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${isCurrent ? '#E5E7EB' : '#0D0D2A'};color:${isCurrent ? '#4B5563' : 'white'};padding:0 10px;font-size:12px;font-weight:700;cursor:${isCurrent ? 'default' : 'pointer'}`}
>
{isCurrent ? 'Active' : 'Switch Role'}
</button>
@ -6792,71 +6877,80 @@ export default function DashboardDesignPreview(props: {
<div style="padding:14px">
<Show when={!(isCustomerExternalMode() && (customerKey() === 'help center' || customerKey() === 'support'))}>
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;padding:14px;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<p style="margin:0;font-size:12px;color:#6B7280">Current View</p>
<p style="margin:4px 0 0;font-size:24px;font-weight:800;color:#111827">{isCustomerExternalMode() ? customerView().title : titleCase(props.activeSidebar)}</p>
<p style="margin:4px 0 0;font-size:13px;color:#6B7280">{isCustomerExternalMode() ? customerView().subtitle : 'Interactive preview for configured dashboard.'}</p>
<Show when={previewTabs().length > 0 && customerKey() !== 'my portfolio'}>
<Show
when={customerKey() === 'my portfolio'}
fallback={
<div style="margin-top:12px;display:flex;align-items:center;gap:20px;border-bottom:1px solid #E5E7EB">
<div style="border:1px solid #E5E7EB;background:white;border-radius:16px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.06)">
<div style="background:#0D0D2A;padding:16px 20px;display:flex;align-items:center;gap:12px;">
<span style="color:#FF5E13">{(() => {
const IconFn = sidebarIcon(props.activeSidebar || 'dashboard');
return IconFn && <IconFn size={24} />;
})()}</span>
<div>
<p style="margin:0;font-size:18px;font-weight:800;color:#fff">{isCustomerExternalMode() ? customerView().title : titleCase(props.activeSidebar)}</p>
<p style="margin:4px 0 0;font-size:13px;color:rgba(255,255,255,0.65)">{isCustomerExternalMode() ? customerView().subtitle : 'Interactive preview for configured dashboard.'}</p>
</div>
</div>
<div style="padding:14px">
<Show when={previewTabs().length > 0 && customerKey() !== 'my portfolio'}>
<Show
when={customerKey() === 'my portfolio'}
fallback={
<div style="display:flex;align-items:center;gap:20px;border-bottom:1px solid #E5E7EB">
<For each={previewTabs()}>
{(item) => (
(() => {
const itemKey = normalizeTabKey(item);
const isLockedTestimonialsTab = customerKey() === 'my portfolio' && itemKey === 'testimonials' && !portfolioTestimonialsUnlocked();
return (
<button
type="button"
disabled={isLockedTestimonialsTab}
onClick={() => {
if (isLockedTestimonialsTab) return;
props.onTabSelect(item);
}}
title={isLockedTestimonialsTab ? 'Unlock after 3 completed jobs and 2 customer feedback entries' : ''}
style={`padding-bottom:10px;font-size:13px;font-weight:500;background:none;border:none;cursor:${isLockedTestimonialsTab ? 'not-allowed' : 'pointer'};opacity:${isLockedTestimonialsTab ? 0.5 : 1};${resolvedTabKey() === itemKey ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
>
{titleCase(item)} {isLockedTestimonialsTab ? '• Locked' : ''}
</button>
);
})()
)}
</For>
</div>
}
>
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;overflow-x:auto;padding-bottom:2px">
<For each={previewTabs()}>
{(item) => (
(() => {
const itemKey = normalizeTabKey(item);
const isLockedTestimonialsTab = customerKey() === 'my portfolio' && itemKey === 'testimonials' && !portfolioTestimonialsUnlocked();
return (
<button
type="button"
disabled={isLockedTestimonialsTab}
onClick={() => {
if (isLockedTestimonialsTab) return;
props.onTabSelect(item);
}}
title={isLockedTestimonialsTab ? 'Unlock after 3 completed jobs and 2 customer feedback entries' : ''}
style={`padding-bottom:10px;font-size:13px;font-weight:500;background:none;border:none;cursor:${isLockedTestimonialsTab ? 'not-allowed' : 'pointer'};opacity:${isLockedTestimonialsTab ? 0.5 : 1};${resolvedTabKey() === itemKey ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
>
{titleCase(item)} {isLockedTestimonialsTab ? '• Locked' : ''}
</button>
);
})()
)}
{(item) => {
const itemKey = normalizeTabKey(item);
const isLockedTestimonialsTab = itemKey === 'testimonials' && !portfolioTestimonialsUnlocked();
const isActive = resolvedTabKey() === itemKey;
const Icon = portfolioTabIcon(item);
return (
<button
type="button"
disabled={isLockedTestimonialsTab}
onClick={() => {
if (isLockedTestimonialsTab) return;
props.onTabSelect(item);
}}
title={isLockedTestimonialsTab ? 'Unlock after 3 completed jobs and 2 customer feedback entries' : ''}
style={`min-width:148px;height:40px;border-radius:10px;border:1px solid ${isActive ? '#FFD8C2' : '#E5E7EB'};background:${isActive ? '#FFF8F4' : 'white'};padding:0 10px;display:flex;align-items:center;gap:8px;cursor:${isLockedTestimonialsTab ? 'not-allowed' : 'pointer'};opacity:${isLockedTestimonialsTab ? 0.5 : 1};flex-shrink:0`}
>
<span style={`width:22px;height:22px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid ${isActive ? '#FFD8C2' : '#E5E7EB'};background:${isActive ? '#FFF1EB' : '#F9FAFB'}`}>
<Icon size={12} style={`color:${isActive ? '#C2410C' : '#6B7280'}`} />
</span>
<span style={`font-size:12px;font-weight:700;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:${isActive ? '#111827' : '#374151'}`}>
{titleCase(item)}{isLockedTestimonialsTab ? ' · Locked' : ''}
</span>
</button>
);
}}
</For>
</div>
}
>
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;overflow-x:auto;padding-bottom:2px">
<For each={previewTabs()}>
{(item) => {
const itemKey = normalizeTabKey(item);
const isLockedTestimonialsTab = itemKey === 'testimonials' && !portfolioTestimonialsUnlocked();
const isActive = resolvedTabKey() === itemKey;
const Icon = portfolioTabIcon(item);
return (
<button
type="button"
disabled={isLockedTestimonialsTab}
onClick={() => {
if (isLockedTestimonialsTab) return;
props.onTabSelect(item);
}}
title={isLockedTestimonialsTab ? 'Unlock after 3 completed jobs and 2 customer feedback entries' : ''}
style={`min-width:148px;height:40px;border-radius:10px;border:1px solid ${isActive ? '#FFD8C2' : '#E5E7EB'};background:${isActive ? '#FFF8F4' : 'white'};padding:0 10px;display:flex;align-items:center;gap:8px;cursor:${isLockedTestimonialsTab ? 'not-allowed' : 'pointer'};opacity:${isLockedTestimonialsTab ? 0.5 : 1};flex-shrink:0`}
>
<span style={`width:22px;height:22px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid ${isActive ? '#FFD8C2' : '#E5E7EB'};background:${isActive ? '#FFF1EB' : '#F9FAFB'}`}>
<Icon size={12} style={`color:${isActive ? '#C2410C' : '#6B7280'}`} />
</span>
<span style={`font-size:12px;font-weight:700;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:${isActive ? '#111827' : '#374151'}`}>
{titleCase(item)}{isLockedTestimonialsTab ? ' · Locked' : ''}
</span>
</button>
);
}}
</For>
</div>
</Show>
</Show>
</Show>
</div>
</div>
</Show>

View file

@ -0,0 +1,228 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { CARD, BTN_PRIMARY, BTN_GHOST } from '~/components/DashboardShell';
const API = '/api/gateway';
type AdminMetrics = {
totalUsers: number;
pendingVerifications: number;
activeSessions: number;
totalRoles: number;
totalPhotographers: number;
totalCustomers: number;
totalCompanies: number;
totalJobSeekers: number;
};
async function adminFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
export default function AdminDashboardPage() {
const [metrics, setMetrics] = createSignal<AdminMetrics>({
totalUsers: 0,
pendingVerifications: 0,
activeSessions: 0,
totalRoles: 0,
totalPhotographers: 0,
totalCustomers: 0,
totalCompanies: 0,
totalJobSeekers: 0,
});
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal('');
const loadAdminMetrics = async () => {
setLoading(true);
setError('');
try {
const [usersRes, rolesRes, photographersRes, customersRes, companiesRes, jobSeekersRes] =
await Promise.all([
adminFetch('/api/admin/users?page=1&limit=1'),
adminFetch('/api/admin/roles'),
adminFetch('/api/admin/users?role=photographer&page=1&limit=1'),
adminFetch('/api/admin/users?role=customer&page=1&limit=1'),
adminFetch('/api/admin/users?role=company&page=1&limit=1'),
adminFetch('/api/admin/users?role=jobseeker&page=1&limit=1'),
]);
const usersJson = await usersRes.json().catch(() => ({}));
const rolesJson = await rolesRes.json().catch(() => ({}));
const photographersJson = await photographersRes.json().catch(() => ({}));
const customersJson = await customersRes.json().catch(() => ({}));
const companiesJson = await companiesRes.json().catch(() => ({}));
const jobSeekersJson = await jobSeekersRes.json().catch(() => ({}));
setMetrics({
totalUsers: usersJson?.total ?? usersJson?.count ?? 0,
pendingVerifications: 0,
activeSessions: 0,
totalRoles: Array.isArray(rolesJson) ? rolesJson.length : 0,
totalPhotographers: photographersJson?.total ?? photographersJson?.count ?? 0,
totalCustomers: customersJson?.total ?? customersJson?.count ?? 0,
totalCompanies: companiesJson?.total ?? companiesJson?.count ?? 0,
totalJobSeekers: jobSeekersJson?.total ?? jobSeekersJson?.count ?? 0,
});
} catch (e: any) {
setError('Failed to load admin metrics: ' + e.message);
} finally {
setLoading(false);
}
};
onMount(loadAdminMetrics);
const statCards = createMemo(() => [
{ label: 'Total Users', value: metrics().totalUsers, color: '#0D0D2A' },
{ label: 'Photographers', value: metrics().totalPhotographers, color: '#FF5E13' },
{ label: 'Customers', value: metrics().totalCustomers, color: '#059669' },
{ label: 'Companies', value: metrics().totalCompanies, color: '#7C3AED' },
{ label: 'Job Seekers', value: metrics().totalJobSeekers, color: '#DC2626' },
{ label: 'Pending Verifications', value: metrics().pendingVerifications, color: '#D97706' },
]);
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '1200px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>
Admin Dashboard
</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Platform overview and management metrics.
</p>
</div>
<Show when={error()}>
<div
style={{
...CARD,
border: '1px solid #FECACA',
background: '#FEF2F2',
padding: '12px 14px',
color: '#B91C1C',
'font-size': '13px',
'font-weight': '600',
}}
>
{error()}
</div>
</Show>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', color: '#9CA3AF' }}>
Loading admin metrics...
</div>
</Show>
<Show when={!loading()}>
<div
style={{
display: 'grid',
'grid-template-columns': 'repeat(3, minmax(0, 1fr))',
gap: '14px',
}}
>
<For each={statCards()}>
{(stat) => (
<div
style={{
border: '1px solid #E5E7EB',
background: 'white',
'border-radius': '16px',
padding: '16px',
'box-shadow': '0 1px 4px rgba(0,0,0,0.06)',
}}
>
<p
style={{
margin: '0',
'font-size': '11px',
'letter-spacing': '0.06em',
'text-transform': 'uppercase',
color: '#6B7280',
}}
>
{stat.label}
</p>
<p
style={{
margin: '8px 0 0',
'font-size': '32px',
'font-weight': '800',
color: stat.color,
}}
>
{stat.value}
</p>
</div>
)}
</For>
</div>
<div
style={{
border: '1px solid #E5E7EB',
background: 'white',
'border-radius': '16px',
padding: '16px',
'box-shadow': '0 1px 4px rgba(0,0,0,0.06)',
}}
>
<p
style={{
margin: '0 0 12px',
'font-size': '16px',
'font-weight': '700',
color: '#111827',
}}
>
Quick Actions
</p>
<div style={{ display: 'flex', gap: '10px', 'flex-wrap': 'wrap' }}>
<button
type="button"
onClick={() => (window.location.href = '/admin/users')}
style={BTN_PRIMARY}
>
Manage Users
</button>
<button
type="button"
onClick={() => (window.location.href = '/admin/verifications')}
style={BTN_GHOST}
>
Pending Verifications
</button>
<button
type="button"
onClick={() => (window.location.href = '/admin/roles')}
style={BTN_GHOST}
>
Manage Roles
</button>
<button
type="button"
onClick={loadAdminMetrics}
style={BTN_GHOST}
>
Refresh
</button>
</div>
</div>
</Show>
</div>
);
}

View file

@ -42,7 +42,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {

View file

@ -6,10 +6,9 @@
* PATCH /api/companies/jobs/:id - Update job
* DELETE /api/companies/jobs/:id - Delete job
*/
import { For, Show, createSignal, onMount } from "solid-js";
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import {
BTN_GHOST,
BTN_ORANGE,
BTN_PRIMARY,
CARD,
INPUT,
@ -45,6 +44,8 @@ interface JobFormState {
skills: string;
}
type SortKey = "newest" | "salary_desc" | "salary_asc" | "title_asc";
const EMPTY_FORM: JobFormState = {
title: "",
category: "",
@ -82,6 +83,55 @@ export default function CompanyJobsPage() {
const [error, setError] = createSignal("");
const [actionMsg, setActionMsg] = createSignal("");
const [busyJobId, setBusyJobId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<SortKey>("newest");
const [activeTag, setActiveTag] = createSignal("");
const rowTags = (row: JobItem) => {
const tags = new Set<string>();
if (row.category) tags.add(String(row.category));
if (Array.isArray(row.skills)) {
for (const skill of row.skills) {
const val = String(skill || "").trim();
if (val) tags.add(val);
}
}
return Array.from(tags);
};
const availableTags = createMemo(() => {
const tags = new Set<string>();
for (const row of jobs()) {
for (const tag of rowTags(row)) tags.add(tag);
}
return Array.from(tags).sort((a, b) => a.localeCompare(b));
});
const filteredSortedJobs = createMemo(() => {
const q = search().trim().toLowerCase();
const tag = activeTag().trim().toLowerCase();
const next = jobs().filter((row) => {
const tags = rowTags(row);
const matchesTag = !tag || tags.some((t) => t.toLowerCase() === tag);
if (!matchesTag) return false;
if (!q) return true;
return (
String(row.title || "").toLowerCase().includes(q) ||
String(row.location || "").toLowerCase().includes(q) ||
String(row.description || "").toLowerCase().includes(q) ||
String(row.job_type || "").toLowerCase().includes(q) ||
tags.some((t) => t.toLowerCase().includes(q))
);
});
next.sort((a, b) => {
if (sortBy() === "salary_desc") return Number(b.salary_max || b.salary_min || 0) - Number(a.salary_max || a.salary_min || 0);
if (sortBy() === "salary_asc") return Number(a.salary_min || a.salary_max || 0) - Number(b.salary_min || b.salary_max || 0);
if (sortBy() === "title_asc") return String(a.title || "").localeCompare(String(b.title || ""));
return new Date(String(b.created_at || 0)).getTime() - new Date(String(a.created_at || 0)).getTime();
});
return next;
});
const loadJobs = async () => {
setLoading(true);
@ -232,7 +282,7 @@ export default function CompanyJobsPage() {
Create and manage your job postings.
</p>
</div>
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
<button type="button" onClick={openCreate} style={BTN_PRIMARY}>
+ Create Job
</button>
</div>
@ -378,11 +428,70 @@ export default function CompanyJobsPage() {
</div>
</Show>
<div style={CARD}>
<div
style={{
display: "flex",
"justify-content": "space-between",
"align-items": "center",
"margin-bottom": "10px",
gap: "10px",
"flex-wrap": "wrap",
}}
>
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
My Job Postings
</p>
<button type="button" onClick={loadJobs} style={BTN_GHOST}>
Refresh
</button>
</div>
<div style={{ display: "grid", gap: "10px" }}>
<div style={{ display: "grid", "grid-template-columns": "1fr 180px", gap: "10px" }}>
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style={INPUT}
placeholder="Search by title, location, type, description, tags"
/>
<select value={sortBy()} onChange={(e) => setSortBy(e.currentTarget.value as SortKey)} style={INPUT}>
<option value="newest">Sort: Newest</option>
<option value="salary_desc">Salary High to Low</option>
<option value="salary_asc">Salary Low to High</option>
<option value="title_asc">Title A-Z</option>
</select>
</div>
<Show when={availableTags().length > 0}>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
<button
type="button"
onClick={() => setActiveTag("")}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() ? {} : { border: "1px solid #0D0D2A", color: "#0D0D2A" }) }}
>
All Tags
</button>
<For each={availableTags()}>
{(tag) => (
<button
type="button"
onClick={() => setActiveTag(tag)}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() === tag ? { border: "1px solid #0D0D2A", color: "#0D0D2A" } : {}) }}
>
{tag}
</button>
)}
</For>
</div>
</Show>
</div>
</div>
<Show when={loading()}>
<div style={{ ...CARD, "text-align": "center", color: "#9CA3AF" }}>Loading jobs</div>
</Show>
<Show when={!loading() && jobs().length === 0}>
<Show when={!loading() && filteredSortedJobs().length === 0}>
<div style={{ ...CARD, "text-align": "center", padding: "34px 24px" }}>
<p
style={{
@ -392,17 +501,17 @@ export default function CompanyJobsPage() {
color: "#111827",
}}
>
No jobs yet
No jobs found
</p>
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
Create your first draft job to start receiving applications.
Try a different search or create your first draft job.
</p>
</div>
</Show>
<Show when={!loading() && jobs().length > 0}>
<Show when={!loading() && filteredSortedJobs().length > 0}>
<div style={{ display: "grid", gap: "10px" }}>
<For each={jobs()}>
<For each={filteredSortedJobs()}>
{(job) => (
<div style={{ ...CARD, padding: "16px" }}>
<div
@ -455,7 +564,7 @@ export default function CompanyJobsPage() {
>
{job.description}
</p>
<Show when={(job.skills || []).length > 0}>
<Show when={rowTags(job).length > 0}>
<div
style={{
display: "flex",
@ -464,8 +573,8 @@ export default function CompanyJobsPage() {
"margin-top": "8px",
}}
>
<For each={job.skills || []}>
{(skill) => (
<For each={rowTags(job).slice(0, 8)}>
{(tag) => (
<span
style={{
"font-size": "11px",
@ -476,7 +585,7 @@ export default function CompanyJobsPage() {
padding: "2px 8px",
}}
>
{skill}
{tag}
</span>
)}
</For>
@ -491,7 +600,7 @@ export default function CompanyJobsPage() {
onClick={() => submitJob(job.id)}
disabled={busyJobId() === job.id}
style={{
...BTN_ORANGE,
...BTN_PRIMARY,
height: "32px",
"font-size": "12px",
padding: "0 14px",

View file

@ -18,7 +18,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {

View file

@ -1,11 +1,11 @@
import { For, Show, createSignal, onMount } from "solid-js";
import { Portal } from "solid-js/web";
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD } from "~/components/DashboardShell";
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, ORANGE, NAVY, PKG_CARD } from "~/components/DashboardShell";
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from "./RoleDashboardShared";
const API = "/api/gateway";
type Props = { roleKey: RoleKey };
type Props = { roleKey: RoleKey; dashboardConfig?: Record<string, any> | null };
type Package = {
id: string;
@ -38,7 +38,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {
@ -58,7 +59,7 @@ export default function CreditsPage(props: Props) {
const [loadingPayments, setLoadingPayments] = createSignal(false);
const [err, setErr] = createSignal("");
const [msg, setMsg] = createSignal("");
const [activeTab, setActiveTab] = createSignal<"overview" | "buy" | "transactions">("overview");
const [activeTab, setActiveTab] = createSignal<"overview" | "buy_credits" | "transactions" | "usage_history">("overview");
// Checkout modal state
const [checkout, setCheckout] = createSignal<CheckoutState>({
@ -77,6 +78,24 @@ export default function CreditsPage(props: Props) {
const isProfessional = () => PROFESSIONAL_ROLE_SET.has(props.roleKey);
const prefix = () => ROLE_PREFIXES[props.roleKey];
const pkgCardTheme = () => {
const cfg = props.dashboardConfig;
return {
WHITE: cfg?.pkg_card?.white ?? PKG_CARD.WHITE,
COIN_BG: cfg?.pkg_card?.coin_bg ?? PKG_CARD.COIN_BG,
COIN_AVATAR_BG: cfg?.pkg_card?.coin_avatar_bg ?? PKG_CARD.COIN_AVATAR_BG,
BEST_VALUE_BG: cfg?.pkg_card?.best_value_bg ?? PKG_CARD.BEST_VALUE_BG,
BORDER_DEFAULT: cfg?.pkg_card?.border_default ?? PKG_CARD.BORDER_DEFAULT,
TEXT_SECONDARY: cfg?.pkg_card?.text_secondary ?? PKG_CARD.TEXT_SECONDARY,
TEXT_PRIMARY: cfg?.pkg_card?.text_primary ?? PKG_CARD.TEXT_PRIMARY,
TEXT_ACCENT: cfg?.pkg_card?.text_accent ?? PKG_CARD.TEXT_ACCENT,
TEXT_MUTED: cfg?.pkg_card?.text_muted ?? PKG_CARD.TEXT_MUTED,
TEXT_SUCCESS: cfg?.pkg_card?.text_success ?? PKG_CARD.TEXT_SUCCESS,
SHADOW_DEFAULT: cfg?.pkg_card?.shadow_default ?? PKG_CARD.SHADOW_DEFAULT,
SHADOW_ACCENT: cfg?.pkg_card?.shadow_accent ?? PKG_CARD.SHADOW_ACCENT,
};
};
const loadWalletData = async () => {
if (!isProfessional()) return;
try {
@ -98,12 +117,23 @@ export default function CreditsPage(props: Props) {
const res = await apiFetch(`/api/packages`);
const data = await res.json().catch(() => ({}));
if (res.ok) {
const pkgs = Array.isArray(data?.data)
const allPkgs = Array.isArray(data?.data)
? data.data
: Array.isArray(data?.packages)
? data.packages
: [];
setPackages(pkgs);
const filtered = allPkgs
.filter((p: any) => p.role_key === props.roleKey)
.map((p: any) => ({
id: p.id,
name: p.name,
role_key: p.role_key,
package_type: p.package_type,
tracecoins_amount: p.tracecoins_amount,
price: p.price_inr,
description: p.description,
}));
setPackages(filtered);
}
} catch {
setPackages([]);
@ -235,12 +265,14 @@ export default function CreditsPage(props: Props) {
}
};
const formatCurrency = (amount: number) => {
const formatCurrency = (amount: number | string) => {
const num = typeof amount === "string" ? parseFloat(amount) : amount;
if (isNaN(num)) return "₹—";
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
minimumFractionDigits: 0,
}).format(amount);
}).format(num);
};
const formatCardNumber = (value: string) => {
@ -621,13 +653,35 @@ export default function CreditsPage(props: Props) {
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
<CheckoutModal />
<div style={CARD}>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#111827" }}>
Credits & Billing
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
Manage your Tracecoin balance, purchase packages, and view transaction history.
</p>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
"justify-content": "space-between",
"flex-wrap": "wrap",
gap: "12px",
}}
>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
Credits & Billing
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Manage your Tracecoin balance, purchase packages, and view transaction history.
</p>
</div>
<Show when={isProfessional()}>
<button
type="button"
onClick={() => setActiveTab("buy_credits")}
style={BTN_ORANGE}
>
Buy Credits
</button>
</Show>
</div>
<Show when={err()}>
@ -666,39 +720,38 @@ export default function CreditsPage(props: Props) {
<div
style={{
display: "flex",
gap: "8px",
borderBottom: "1px solid #E5E7EB",
paddingBottom: "10px",
gap: "0",
"border-bottom": "1px solid #E5E7EB",
}}
>
<button
type="button"
onClick={() => setActiveTab("overview")}
style={{
padding: "8px 16px",
borderRadius: "8px",
border: "none",
background: activeTab() === "overview" ? "#FF5E13" : "transparent",
color: activeTab() === "overview" ? "#fff" : "#6B7280",
fontSize: "13px",
fontWeight: "600",
padding: "10px 16px",
"border-bottom": activeTab() === "overview" ? "2px solid #FF5E13" : "2px solid transparent",
background: "transparent",
color: activeTab() === "overview" ? "#FF5E13" : "#6B7280",
"font-size": "14px",
"font-weight": activeTab() === "overview" ? "700" : "500",
cursor: "pointer",
"margin-bottom": "-1px",
}}
>
Overview
</button>
<button
type="button"
onClick={() => setActiveTab("buy")}
onClick={() => setActiveTab("buy_credits")}
style={{
padding: "8px 16px",
borderRadius: "8px",
border: "none",
background: activeTab() === "buy" ? "#FF5E13" : "transparent",
color: activeTab() === "buy" ? "#fff" : "#6B7280",
fontSize: "13px",
fontWeight: "600",
padding: "10px 16px",
"border-bottom": activeTab() === "buy_credits" ? "2px solid #FF5E13" : "2px solid transparent",
background: "transparent",
color: activeTab() === "buy_credits" ? "#FF5E13" : "#6B7280",
"font-size": "14px",
"font-weight": activeTab() === "buy_credits" ? "700" : "500",
cursor: "pointer",
"margin-bottom": "-1px",
}}
>
Buy Credits
@ -707,19 +760,39 @@ export default function CreditsPage(props: Props) {
type="button"
onClick={() => setActiveTab("transactions")}
style={{
padding: "8px 16px",
borderRadius: "8px",
border: "none",
background: activeTab() === "transactions" ? "#FF5E13" : "transparent",
color: activeTab() === "transactions" ? "#fff" : "#6B7280",
fontSize: "13px",
fontWeight: "600",
padding: "10px 16px",
"border-bottom": activeTab() === "transactions" ? "2px solid #FF5E13" : "2px solid transparent",
background: "transparent",
color: activeTab() === "transactions" ? "#FF5E13" : "#6B7280",
"font-size": "14px",
"font-weight": activeTab() === "transactions" ? "700" : "500",
cursor: "pointer",
"margin-bottom": "-1px",
}}
>
Transactions
</button>
<button type="button" onClick={loadAllData} style={{ ...BTN_GHOST, marginLeft: "auto" }}>
<button
type="button"
onClick={() => setActiveTab("usage_history")}
style={{
padding: "10px 16px",
"border-bottom": activeTab() === "usage_history" ? "2px solid #FF5E13" : "2px solid transparent",
background: "transparent",
color: activeTab() === "usage_history" ? "#FF5E13" : "#6B7280",
"font-size": "14px",
"font-weight": activeTab() === "usage_history" ? "700" : "500",
cursor: "pointer",
"margin-bottom": "-1px",
}}
>
Usage History
</button>
<button
type="button"
onClick={loadAllData}
style={{ ...BTN_GHOST, "margin-left": "auto", "margin-bottom": "-1px", "align-self": "flex-end" }}
>
Refresh
</button>
</div>
@ -743,44 +816,31 @@ export default function CreditsPage(props: Props) {
</Show>
<Show when={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": "36px",
"font-weight": "800",
color: "#111827",
}}
>
{wallet()?.balance ?? 0}{" "}
<span style={{ "font-size": "16px", color: "#6B7280", "font-weight": "600" }}>
Tracecoins
</span>
</p>
</div>
<button type="button" onClick={() => setActiveTab("buy")} style={BTN_ORANGE}>
Buy Credits
</button>
<div style={CARD}>
<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": "36px",
"font-weight": "800",
color: NAVY,
}}
>
{wallet()?.balance ?? 0}{" "}
<span style={{ "font-size": "16px", color: "#6B7280", "font-weight": "600" }}>
Tracecoins
</span>
</p>
</div>
<div style={CARD}>
@ -860,7 +920,7 @@ export default function CreditsPage(props: Props) {
</Show>
{/* Buy Credits Tab */}
<Show when={!loading() && activeTab() === "buy"}>
<Show when={!loading() && activeTab() === "buy_credits"}>
<div style={CARD}>
<p
style={{
@ -884,81 +944,207 @@ export default function CreditsPage(props: Props) {
</Show>
<Show when={packages().length > 0}>
<div
style={{
display: "grid",
"grid-template-columns": "repeat(auto-fill, minmax(240px, 1fr))",
gap: "14px",
}}
>
<For each={packages()}>
{(pkg) => (
<div
style={{
border: "2px solid #E5E7EB",
borderRadius: "12px",
padding: "16px",
background: "#fff",
display: "flex",
"flex-direction": "column",
gap: "8px",
{(() => {
const pkgs = packages();
const bestValue = pkgs.reduce((best, p) => {
const ratio = p.tracecoins_amount / p.price;
return ratio > best.ratio ? { pkg: p, ratio } : best;
}, { pkg: pkgs[0], ratio: 0 });
return (
<div
style={{
display: "grid",
"grid-template-columns": "repeat(auto-fill, minmax(260px, 1fr))",
gap: "16px",
}}
>
<For each={pkgs}>
{(pkg) => {
const theme = pkgCardTheme();
const isBestValue = bestValue.pkg.id === pkg.id;
const coinsPerRupee = (pkg.tracecoins_amount / pkg.price).toFixed(1);
return (
<div
style={{
border: isBestValue ? `2px solid ${ORANGE}` : `1px solid ${theme.BORDER_DEFAULT}`,
"border-radius": "16px",
padding: "0",
background: theme.WHITE,
display: "flex",
"flex-direction": "column",
overflow: "hidden",
"box-shadow": isBestValue
? theme.SHADOW_ACCENT
: theme.SHADOW_DEFAULT,
transition: "transform 0.2s, box-shadow 0.2s",
}}
>
<Show when={isBestValue}>
<div
style={{
background: theme.BEST_VALUE_BG,
padding: "6px 12px",
"text-align": "center",
}}
>
<span
style={{
color: theme.WHITE,
"font-size": "11px",
"font-weight": "700",
"text-transform": "uppercase",
"letter-spacing": "0.5px",
}}
>
Best Value
</span>
</div>
</Show>
<div style={{ padding: "20px", display: "flex", "flex-direction": "column", gap: "12px", flex: "1" }}>
<div>
<p
style={{
margin: "0 0 4px",
"font-size": "14px",
"font-weight": "600",
color: theme.TEXT_SECONDARY,
}}
>
{pkg.package_type}
</p>
<p
style={{
margin: "0",
"font-size": "18px",
"font-weight": "800",
color: theme.TEXT_PRIMARY,
}}
>
{pkg.name}
</p>
</div>
<div
style={{
display: "flex",
"align-items": "baseline",
gap: "4px",
}}
>
<span
style={{
"font-size": "32px",
"font-weight": "800",
color: theme.TEXT_ACCENT,
}}
>
{formatCurrency(pkg.price)}
</span>
<span
style={{
"font-size": "13px",
color: theme.TEXT_MUTED,
}}
>
INR
</span>
</div>
<div
style={{
display: "flex",
"align-items": "center",
gap: "8px",
padding: "12px",
background: theme.COIN_BG,
"border-radius": "10px",
}}
>
<div
style={{
width: "36px",
height: "36px",
"border-radius": "50%",
background: theme.COIN_AVATAR_BG,
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<span style={{ fontSize: "18px" }}>🪙</span>
</div>
<div>
<p
style={{
margin: "0",
"font-size": "16px",
"font-weight": "700",
color: theme.TEXT_PRIMARY,
}}
>
{pkg.tracecoins_amount.toLocaleString()}
</p>
<p
style={{
margin: "0",
"font-size": "11px",
color: theme.TEXT_SECONDARY,
}}
>
Tracecoins
</p>
</div>
</div>
<div
style={{
"font-size": "12px",
color: theme.TEXT_SUCCESS,
"font-weight": "600",
}}
>
{coinsPerRupee} coins per 1
</div>
<Show when={pkg.description}>
<p
style={{
margin: "0",
"font-size": "13px",
color: theme.TEXT_SECONDARY,
"line-height": "1.5",
flex: "1",
}}
>
{pkg.description}
</p>
</Show>
<button
type="button"
onClick={() => openCheckout(pkg)}
style={{
...BTN_PRIMARY,
width: "100%",
"margin-top": "auto",
padding: "12px 16px",
"font-size": "14px",
"border-radius": "10px",
}}
>
Buy Now
</button>
</div>
</div>
);
}}
>
<div>
<p
style={{
margin: "0",
"font-size": "16px",
"font-weight": "800",
color: "#111827",
}}
>
{pkg.name}
</p>
<p
style={{
margin: "4px 0 0",
"font-size": "24px",
"font-weight": "800",
color: "#FF5E13",
}}
>
{formatCurrency(pkg.price)}
</p>
</div>
<div
style={{
display: "flex",
"align-items": "center",
gap: "6px",
margin: "8px 0",
}}
>
<span style={{ fontSize: "20px" }}>🪙</span>
<span style={{ fontSize: "15px", "font-weight": "700", color: "#111827" }}>
{pkg.tracecoins_amount} Tracecoins
</span>
</div>
<Show when={pkg.description}>
<p style={{ margin: "0", "font-size": "12px", color: "#6B7280", flex: "1" }}>
{pkg.description}
</p>
</Show>
<button
type="button"
onClick={() => openCheckout(pkg)}
style={{
...BTN_ORANGE,
width: "100%",
"margin-top": "8px",
}}
>
Buy Now
</button>
</div>
)}
</For>
</div>
</For>
</div>
);
})}
</Show>
</div>
</Show>
@ -1080,15 +1266,33 @@ export default function CreditsPage(props: Props) {
>
{formatCurrency(payment.amount_inr)}
</p>
<p
<span
style={{
margin: "2px 0 0",
"font-size": "12px",
color: payment.status === "SUCCESS" ? "#15803D" : "#D97706",
display: "inline-block",
padding: "2px 8px",
"border-radius": "6px",
"font-size": "11px",
"font-weight": "700",
background:
payment.status === "SUCCESS"
? "#ECFDF5"
: payment.status === "FAILED"
? "#FEF2F2"
: "#FFFBEB",
color:
payment.status === "SUCCESS"
? "#15803D"
: payment.status === "FAILED"
? "#B91C1C"
: "#D97706",
}}
>
{payment.status === "SUCCESS" ? "✓ Completed" : payment.status}
</p>
{payment.status === "SUCCESS"
? "✓ Completed"
: payment.status === "FAILED"
? "✕ Failed"
: "◌ Pending"}
</span>
<Show when={payment.tracecoins_credited > 0}>
<p style={{ margin: "2px 0 0", "font-size": "11px", color: "#6B7280" }}>
+{payment.tracecoins_credited} TC
@ -1102,6 +1306,78 @@ export default function CreditsPage(props: Props) {
</Show>
</div>
</Show>
{/* Usage History Tab */}
<Show when={!loading() && activeTab() === "usage_history"}>
<div style={CARD}>
<p
style={{
margin: "0 0 10px",
"font-size": "16px",
"font-weight": "700",
color: "#111827",
}}
>
Tracecoin Usage History
</p>
<p style={{ margin: "0 0 16px", "font-size": "13px", color: "#6B7280" }}>
A record of all Tracecoins spent on leads, responses, and platform features.
</p>
<Show when={ledger().length === 0}>
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
No usage history yet.
</p>
</Show>
<Show when={ledger().length > 0}>
<div style={{ display: "grid", gap: "8px" }}>
<For each={ledger().filter((item: any) => Number(item.amount) < 0)}>
{(item: any) => (
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "10px",
padding: "12px",
background: "#FCFCFD",
display: "flex",
"justify-content": "space-between",
"align-items": "center",
gap: "10px",
}}
>
<div>
<p
style={{
margin: "0",
"font-size": "13px",
"font-weight": "700",
color: "#111827",
}}
>
{item.reason || item.type || "Tracecoins Used"}
</p>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
{item.created_at ? formatDate(item.created_at) : "—"}
</p>
</div>
<p
style={{
margin: "0",
"font-size": "14px",
"font-weight": "800",
color: "#B91C1C",
}}
>
{item.amount} TC
</p>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
);
}

View file

@ -6,8 +6,8 @@
* PATCH /api/customers/requirements/:id - Update requirement
* DELETE /api/customers/requirements/:id - Delete requirement
*/
import { For, Show, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT, LABEL } from "~/components/DashboardShell";
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_PRIMARY, CARD, INPUT, LABEL } from "~/components/DashboardShell";
const API = "/api/gateway";
@ -19,9 +19,12 @@ type RequirementItem = {
budget_inr?: number | null;
area?: string | null;
location?: string | null;
tags?: string[] | null;
created_at?: string;
};
type SortKey = "newest" | "budget_desc" | "budget_asc" | "title_asc";
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
@ -45,6 +48,9 @@ export default function CustomerRequirementsPage() {
const [saving, setSaving] = createSignal(false);
const [msg, setMsg] = createSignal("");
const [err, setErr] = createSignal("");
const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<SortKey>("newest");
const [activeTag, setActiveTag] = createSignal("");
const [form, setForm] = createSignal({
title: "",
description: "",
@ -52,6 +58,46 @@ export default function CustomerRequirementsPage() {
budget_max: "",
area: "",
location: "",
tags: "",
});
const rowTags = (row: RequirementItem) =>
Array.isArray(row.tags)
? row.tags.map((tag) => String(tag || "").trim()).filter(Boolean)
: [];
const availableTags = createMemo(() => {
const tags = new Set<string>();
for (const row of requirements()) {
for (const tag of rowTags(row)) tags.add(tag);
}
return Array.from(tags).sort((a, b) => a.localeCompare(b));
});
const filteredSortedRows = createMemo(() => {
const q = search().trim().toLowerCase();
const tag = activeTag().trim().toLowerCase();
const next = requirements().filter((row) => {
const tags = rowTags(row);
const matchesTag = !tag || tags.some((t) => t.toLowerCase() === tag);
if (!matchesTag) return false;
if (!q) return true;
return (
String(row.title || "").toLowerCase().includes(q) ||
String(row.description || "").toLowerCase().includes(q) ||
String(row.location || "").toLowerCase().includes(q) ||
String(row.area || "").toLowerCase().includes(q) ||
tags.some((t) => t.toLowerCase().includes(q))
);
});
next.sort((a, b) => {
if (sortBy() === "budget_desc") return Number(b.budget_inr || 0) - Number(a.budget_inr || 0);
if (sortBy() === "budget_asc") return Number(a.budget_inr || 0) - Number(b.budget_inr || 0);
if (sortBy() === "title_asc") return String(a.title || "").localeCompare(String(b.title || ""));
return new Date(String(b.created_at || 0)).getTime() - new Date(String(a.created_at || 0)).getTime();
});
return next;
});
const loadRequirements = async () => {
@ -91,7 +137,8 @@ export default function CustomerRequirementsPage() {
? Number(form().budget_min) || Number(form().budget_max)
: undefined,
area: form().area.trim() || undefined,
location: form().city.trim() || undefined,
location: form().location.trim() || undefined,
tags: form().tags.split(",").map((t) => t.trim()).filter(Boolean),
};
const res = await apiFetch("/api/customers/requirements", {
method: "POST",
@ -110,6 +157,7 @@ export default function CustomerRequirementsPage() {
budget_max: "",
area: "",
location: "",
tags: "",
});
await loadRequirements();
} catch {
@ -217,13 +265,22 @@ export default function CustomerRequirementsPage() {
placeholder="Chennai"
/>
</div>
<div style={{ "grid-column": "1 / -1" }}>
<label style={LABEL}>Tags (comma separated)</label>
<input
value={form().tags}
onInput={(e) => setField("tags", e.currentTarget.value)}
style={INPUT}
placeholder="e.g. wedding, candid, weekend"
/>
</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" }}
style={{ ...BTN_PRIMARY, opacity: saving() ? "0.7" : "1" }}
>
{saving() ? "Posting..." : "Post Requirement"}
</button>
@ -268,6 +325,8 @@ export default function CustomerRequirementsPage() {
"justify-content": "space-between",
"align-items": "center",
"margin-bottom": "10px",
gap: "10px",
"flex-wrap": "wrap",
}}
>
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
@ -277,19 +336,57 @@ export default function CustomerRequirementsPage() {
Refresh
</button>
</div>
<div style={{ display: "grid", gap: "10px", "margin-bottom": "12px" }}>
<div style={{ display: "grid", "grid-template-columns": "1fr 180px", gap: "10px" }}>
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style={INPUT}
placeholder="Search by title, location, area, description, tags"
/>
<select value={sortBy()} onChange={(e) => setSortBy(e.currentTarget.value as SortKey)} style={INPUT}>
<option value="newest">Sort: Newest</option>
<option value="budget_desc">Budget High to Low</option>
<option value="budget_asc">Budget Low to High</option>
<option value="title_asc">Title A-Z</option>
</select>
</div>
<Show when={availableTags().length > 0}>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
<button
type="button"
onClick={() => setActiveTag("")}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() ? {} : { border: "1px solid #0D0D2A", color: "#0D0D2A" }) }}
>
All Tags
</button>
<For each={availableTags()}>
{(tag) => (
<button
type="button"
onClick={() => setActiveTag(tag)}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() === tag ? { border: "1px solid #0D0D2A", color: "#0D0D2A" } : {}) }}
>
{tag}
</button>
)}
</For>
</div>
</Show>
</div>
<Show when={loading()}>
<p style={{ margin: "0", color: "#9CA3AF", "font-size": "13px" }}>
Loading requirements...
</p>
</Show>
<Show when={!loading() && requirements().length === 0}>
<Show when={!loading() && filteredSortedRows().length === 0}>
<p style={{ margin: "0", color: "#6B7280", "font-size": "13px" }}>
No requirements found.
No requirements match your filters.
</p>
</Show>
<Show when={!loading() && requirements().length > 0}>
<Show when={!loading() && filteredSortedRows().length > 0}>
<div style={{ display: "grid", gap: "10px" }}>
<For each={requirements()}>
<For each={filteredSortedRows()}>
{(row) => (
<div
style={{
@ -319,7 +416,7 @@ export default function CustomerRequirementsPage() {
{row.title}
</p>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
{row.location || row.city || "—"} {row.area ? `${row.area}` : ""}{" "}
{row.location || "—"} {row.area ? `${row.area}` : ""}{" "}
{row.created_at
? `${new Date(row.created_at).toLocaleString("en-IN")}`
: ""}
@ -344,6 +441,15 @@ export default function CustomerRequirementsPage() {
<p style={{ margin: "8px 0 0", "font-size": "13px", color: "#374151" }}>
{row.description || "No description added."}
</p>
<Show when={rowTags(row).length > 0}>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap", "margin-top": "8px" }}>
<For each={rowTags(row).slice(0, 6)}>
{(tag) => (
<span style={{ height: "22px", display: "inline-flex", "align-items": "center", padding: "0 8px", "border-radius": "999px", border: "1px solid #E5E7EB", background: "#F9FAFB", "font-size": "11px", color: "#374151" }}>{tag}</span>
)}
</For>
</div>
</Show>
<div
style={{ display: "flex", "justify-content": "flex-end", "margin-top": "10px" }}
>
@ -352,7 +458,7 @@ export default function CustomerRequirementsPage() {
onClick={() => submitRequirement(row.id)}
disabled={busyId() === row.id}
style={{
...BTN_ORANGE,
...BTN_PRIMARY,
height: "32px",
"font-size": "12px",
padding: "0 12px",

View file

@ -22,7 +22,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {

View file

@ -1,20 +1,38 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, BTN_PRIMARY, CARD } from '~/components/DashboardShell';
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_PRIMARY, CARD } from "~/components/DashboardShell";
import { Camera, Scissors, GraduationCap, Code2, Clapperboard, PenTool, Megaphone, Dumbbell, UtensilsCrossed, Briefcase, Globe, Users, UserCircle, FileText, TrendingUp, Award, BarChart3, ShieldCheck } from "lucide-solid";
const API = '/api/gateway';
const NAVY = "#0D0D2A";
const ORANGE = "#FF5E13";
type UserRoleItem = {
role_key: string;
role_name?: string;
status?: string;
};
const API = "/api/gateway";
type ExternalRoleItem = {
id: string;
const MAIN_ROLES = [
{ key: "COMPANY", name: "Company", Icon: Users, subtitle: "Hire talent, post jobs, and manage applications in one path." },
{ key: "JOB_SEEKER", name: "Job Seeker", Icon: UserCircle, subtitle: "Explore opportunities and apply to roles with your profile." },
{ key: "CUSTOMER", name: "Service Seeker", Icon: FileText, subtitle: "Post requirements and connect with verified professionals." },
];
const PROFESSIONAL_ROLES = [
{ key: "PHOTOGRAPHER", name: "Photographer", Icon: Camera },
{ key: "MAKEUP_ARTIST", name: "Makeup Artist", Icon: Scissors },
{ key: "TUTOR", name: "Tutor", Icon: GraduationCap },
{ key: "DEVELOPER", name: "Developer", Icon: Code2 },
{ key: "VIDEO_EDITOR", name: "Video Editor", Icon: Clapperboard },
{ key: "UGC_CONTENT_CREATOR", name: "UGC Content Creator", Icon: Clapperboard },
{ key: "GRAPHIC_DESIGNER", name: "Graphic Designer", Icon: PenTool },
{ key: "SOCIAL_MEDIA_MANAGER", name: "Social Media Manager", Icon: Megaphone },
{ key: "FITNESS_TRAINER", name: "Fitness Trainer", Icon: Dumbbell },
{ key: "CATERING_SERVICES", name: "Catering Services", Icon: UtensilsCrossed },
];
type RoleCard = {
key: string;
name: string;
audience?: string;
is_active?: boolean;
title: string;
subtitle: string;
action: string;
Icon: any;
status: "Active" | "Registered" | "Available";
};
async function apiFetch(path: string, opts?: RequestInit) {
@ -22,7 +40,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {
@ -34,94 +53,89 @@ async function apiFetch(path: string, opts?: RequestInit) {
}
function toTitle(value: string): string {
return String(value || '')
return String(value || "")
.toLowerCase()
.replace(/_/g, ' ')
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function StatusBadge(props: { status: "Active" | "Registered" | "Available" }) {
const { status } = props;
const isActive = status === "Active";
const isRegistered = status === "Registered";
return (
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${isActive ? "#FFD8C2" : "#D1D5DB"};background:${isActive ? "#FFF1EB" : isRegistered ? "#F3F4F6" : "#F3F4F6"};color:${isActive ? ORANGE : isRegistered ? "#4B5563" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:500`}
>
<span
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${isActive ? ORANGE : "#9CA3AF"};margin-right:5px;flex-shrink:0`}
/>
{status}
</span>
);
}
export default function ExploreServicesPage() {
const [activeRoles, setActiveRoles] = createSignal<UserRoleItem[]>([]);
const [externalRoles, setExternalRoles] = createSignal<ExternalRoleItem[]>([]);
const [activeRoleKey, setActiveRoleKey] = createSignal('');
const [activeRoles, setActiveRoles] = createSignal<string[]>([]);
const [currentRole, setCurrentRole] = createSignal("");
const [loading, setLoading] = createSignal(true);
const [busyRoleKey, setBusyRoleKey] = createSignal<string | null>(null);
const [msg, setMsg] = createSignal('');
const [err, setErr] = createSignal('');
const [msg, setMsg] = createSignal("");
const [err, setErr] = createSignal("");
const activeRoleSet = createMemo(() => new Set(activeRoles().map((r) => String(r.role_key || '').toUpperCase())));
const activeRoleSet = createMemo(() => new Set(activeRoles()));
const cards = createMemo(() =>
externalRoles().map((r) => {
const key = String(r.key || '').toUpperCase();
const isRegistered = activeRoleSet().has(key);
const isCurrent = activeRoleKey() === key;
const cards = createMemo((): RoleCard[] =>
PROFESSIONAL_ROLES.map(({ key, name, Icon }) => {
const upperKey = key;
const isRegistered = activeRoleSet().has(upperKey);
const isCurrent = currentRole() === upperKey;
let status: "Active" | "Registered" | "Available" = "Available";
if (isCurrent) status = "Active";
else if (isRegistered) status = "Registered";
return {
key,
title: r.name || toTitle(key),
subtitle: isRegistered
? 'Role is already linked to your account. You can switch instantly.'
: 'Add this role to unlock its dashboard and workflows.',
action: isCurrent ? 'Current Role' : (isRegistered ? 'Switch' : 'Register'),
key: upperKey,
title: name,
subtitle: isCurrent
? "This is your current active role."
: isRegistered
? "Role is linked to your account. Switch instantly."
: "Add this role to unlock its dashboard and workflows.",
action: isCurrent ? "Current Role" : isRegistered ? "Switch" : "Register",
Icon,
status,
};
}),
})
);
const load = async () => {
setLoading(true);
setErr('');
setErr("");
try {
if (typeof window !== 'undefined') {
const raw = window.localStorage.getItem('nxtgauge_auth_user') || window.localStorage.getItem('nxtgauge_user');
if (typeof window !== "undefined") {
const raw =
window.localStorage.getItem("nxtgauge_auth_user") ||
window.localStorage.getItem("nxtgauge_user");
if (raw) {
try {
const parsed = JSON.parse(raw);
setActiveRoleKey(String(parsed?.active_role || parsed?.role || '').toUpperCase());
setCurrentRole(String(parsed?.active_role || parsed?.role || "").toUpperCase());
} catch {
setActiveRoleKey('');
setCurrentRole("");
}
}
}
const [rolesRes, externalRes] = await Promise.all([
apiFetch('/api/me/roles'),
apiFetch('/api/admin/roles?audience=EXTERNAL&per_page=200'),
]);
const rolesData = await rolesRes.json().catch(() => []);
const externalData = await externalRes.json().catch(() => ({}));
if (rolesRes.ok) {
setActiveRoles(Array.isArray(rolesData) ? rolesData : []);
const res = await apiFetch("/api/users/roles");
const data = await res.json().catch(() => ({}));
if (res.ok) {
const roles: string[] = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
setActiveRoles(roles.map((r) => String(typeof r === "string" ? r : r.role_key || r.key || "").toUpperCase()));
} else {
setActiveRoles([]);
}
if (externalRes.ok) {
const rows = Array.isArray(externalData)
? externalData
: (Array.isArray(externalData?.roles) ? externalData.roles : []);
setExternalRoles(
rows
.filter((r: any) => String(r?.audience || 'EXTERNAL').toUpperCase() === 'EXTERNAL')
.map((r: any) => ({
id: String(r?.id || ''),
key: String(r?.key || '').toUpperCase(),
name: String(r?.name || toTitle(String(r?.key || ''))),
audience: String(r?.audience || 'EXTERNAL').toUpperCase(),
is_active: r?.is_active !== false,
}))
.filter((r: ExternalRoleItem) => Boolean(r.key))
.sort((a: ExternalRoleItem, b: ExternalRoleItem) => a.name.localeCompare(b.name)),
);
} else {
setExternalRoles([]);
setErr('Unable to load available services right now.');
}
} catch {
setErr('Network error while loading services.');
setActiveRoles([]);
setExternalRoles([]);
} finally {
setLoading(false);
}
@ -133,22 +147,22 @@ export default function ExploreServicesPage() {
const registerRole = async (roleKey: string) => {
setBusyRoleKey(roleKey);
setMsg('');
setErr('');
setMsg("");
setErr("");
try {
const res = await apiFetch('/api/me/roles/register', {
method: 'POST',
const res = await apiFetch("/api/users/roles/register", {
method: "POST",
body: JSON.stringify({ role_key: roleKey }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(String(data?.error || data?.message || 'Failed to register role.'));
setErr(String(data?.error || data?.message || "Failed to register role."));
return;
}
setMsg(`${toTitle(roleKey)} registered successfully.`);
await load();
} catch {
setErr('Network error while registering role.');
setErr("Network error while registering role.");
} finally {
setBusyRoleKey(null);
}
@ -156,115 +170,427 @@ export default function ExploreServicesPage() {
const switchRole = async (roleKey: string) => {
setBusyRoleKey(roleKey);
setMsg('');
setErr('');
setMsg("");
setErr("");
try {
const res = await apiFetch('/api/auth/switch-role', {
method: 'POST',
const res = await apiFetch("/api/auth/switch-role", {
method: "POST",
body: JSON.stringify({ role_key: roleKey }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(String(data?.error || data?.message || 'Failed to switch role.'));
setErr(String(data?.error || data?.message || "Failed to switch role."));
return;
}
const accessToken = String(data?.access_token || '').trim();
if (typeof window !== 'undefined' && accessToken) {
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
const accessToken = String(data?.access_token || "").trim();
if (typeof window !== "undefined" && accessToken) {
window.sessionStorage.setItem("nxtgauge_access_token", accessToken);
window.sessionStorage.setItem("nxtgauge_frontend_access_token", accessToken);
}
if (typeof window !== 'undefined') {
const keys = ['nxtgauge_auth_user', 'nxtgauge_user', 'nxtgauge_signup_profile_v1'];
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() };
const next = {
...parsed,
active_role: roleKey,
role: roleKey.toLowerCase(),
roleKey: roleKey.toLowerCase(),
};
window.localStorage.setItem(key, JSON.stringify(next));
} catch {
// ignore malformed payload
// ignore
}
}
}
setMsg(`Switched to ${toTitle(roleKey)}. Redirecting...`);
setTimeout(() => {
window.location.href = '/dashboard';
window.location.href = "/dashboard";
}, 250);
} catch {
setErr('Network error while switching role.');
setErr("Network error while switching role.");
} finally {
setBusyRoleKey(null);
}
};
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '1080px' }}>
<div style={{ display: "grid", gap: "14px", "max-width": "1080px" }}>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
}}
>
<span style={{ color: ORANGE }}>
<Globe size={24} />
</span>
<div>
<h1
style={{
margin: "0",
"font-size": "18px",
"font-weight": "800",
color: "#fff",
"line-height": "1.2",
}}
>
Explore Nxtgauge
</h1>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Discover services, connect with verified users, and expand into additional roles using the same dashboard workflow.
</p>
</div>
</div>
{/* Main Roles Section */}
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '30px', 'font-weight': '800', color: '#111827', 'line-height': '1.15' }}>Explore Nxtgauge</p>
<p style={{ margin: '8px 0 0', 'font-size': '14px', color: '#6B7280' }}>
Runtime-driven service catalog based on your account and active role configuration.
<p style={{ margin: "0 0 16px", "font-size": "20px", "font-weight": "800", color: "#111827" }}>
Explore opportunities on Nxtgauge
</p>
<div style={{ display: "grid", "grid-template-columns": "repeat(3, minmax(0, 1fr))", gap: "12px" }}>
<For each={MAIN_ROLES}>
{(role) => {
const isCurrentRole = () => currentRole() === role.key;
const isRegistered = () => activeRoleSet().has(role.key);
return (
<div
style={{
border: "1px solid #E5E7EB",
background: "#fff",
"border-radius": "16px",
padding: "16px",
display: "flex",
"flex-direction": "column",
gap: "10px",
"box-shadow": "0 1px 4px rgba(0,0,0,0.06)",
}}
>
<div
style={{
width: "40px",
height: "40px",
"border-radius": "10px",
background: "#FFF3EE",
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<role.Icon size={20} color={ORANGE} strokeWidth={2} />
</div>
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
{role.name}
</p>
<p style={{ margin: "0", "font-size": "12px", color: "#6B7280", "line-height": "1.5" }}>
{role.subtitle}
</p>
<button
type="button"
disabled={isCurrentRole()}
onClick={() => isRegistered() && !isCurrentRole() ? void switchRole(role.key) : void registerRole(role.key)}
style={{
height: "32px",
"border-radius": "8px",
border: "none",
background: isCurrentRole() ? "#E5E7EB" : NAVY,
color: isCurrentRole() ? "#4B5563" : "#fff",
padding: "0 10px",
"font-size": "12px",
"font-weight": "700",
cursor: isCurrentRole() ? "not-allowed" : "pointer",
"margin-top": "auto",
}}
>
{isCurrentRole() ? "Current Role" : isRegistered() ? "Switch" : `Register as ${role.name}`}
</button>
</div>
);
}}
</For>
</div>
</div>
<Show when={msg()}>
<div style={{ ...CARD, border: '1px solid #FFD8C2', background: '#FFF7ED', padding: '12px 14px', color: '#C2410C', 'font-size': '13px', 'font-weight': '700' }}>
<div
style={{
...CARD,
border: "1px solid #BBF7D0",
background: "#ECFDF5",
padding: "12px 16px",
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': '700' }}>
<div
style={{
...CARD,
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "12px 16px",
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': '800', color: '#111827' }}>Available Services</p>
<button type="button" onClick={() => void load()} style={BTN_GHOST}>Refresh</button>
</div>
<Show
when={!loading()}
fallback={
<div
style={{
...CARD,
"text-align": "center",
padding: "48px 24px",
}}
>
<p style={{ margin: "0", "font-size": "15px", "font-weight": "600", color: "#111827" }}>
Loading services...
</p>
</div>
}
>
<div style={CARD}>
<div
style={{
display: "flex",
"justify-content": "space-between",
"align-items": "center",
"margin-bottom": "16px",
}}
>
<p
style={{
margin: "0",
"font-size": "16px",
"font-weight": "700",
color: "#111827",
}}
>
Professional Services
</p>
<button type="button" onClick={() => void load()} style={BTN_GHOST}>
Refresh
</button>
</div>
<Show when={loading()}>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>Loading services...</p>
</Show>
<Show when={!loading() && cards().length === 0}>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>No services returned by runtime configuration.</p>
</Show>
<Show when={!loading() && cards().length > 0}>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '10px' }}>
<For each={cards()}>
{(card) => (
<div style={{ border: '1px solid #E5E7EB', background: '#fff', 'border-radius': '14px', padding: '14px', display: 'flex', 'flex-direction': 'column', gap: '8px', 'box-shadow': '0 1px 4px rgba(0,0,0,0.05)' }}>
<p style={{ margin: '0', 'font-size': '17px', 'font-weight': '800', color: '#111827' }}>{card.title}</p>
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.45' }}>{card.subtitle}</p>
<button
type="button"
disabled={busyRoleKey() === card.key || card.action === 'Current Role'}
onClick={() => card.action === 'Register' ? void registerRole(card.key) : void switchRole(card.key)}
<Show
when={cards().length > 0}
fallback={
<div
style={{
"text-align": "center",
padding: "32px",
color: "#6B7280",
"font-size": "14px",
}}
>
No services available.
</div>
}
>
<div
style={{
display: "grid",
"grid-template-columns": "repeat(auto-fill, minmax(280px, 1fr))",
gap: "14px",
}}
>
<For each={cards()}>
{(card) => (
<div
style={{
...(card.action === 'Register' ? BTN_PRIMARY : BTN_GHOST),
height: '32px',
'font-size': '12px',
padding: '0 10px',
'margin-top': 'auto',
opacity: busyRoleKey() === card.key || card.action === 'Current Role' ? '0.7' : '1',
border: "1px solid #E5E7EB",
background: "#fff",
"border-radius": "16px",
padding: "16px",
display: "flex",
"flex-direction": "column",
gap: "10px",
"box-shadow": "0 1px 4px rgba(0,0,0,0.06)",
}}
>
{busyRoleKey() === card.key
? (card.action === 'Register' ? 'Registering...' : 'Switching...')
: card.action}
</button>
</div>
)}
</For>
<div
style={{
display: "flex",
"align-items": "flex-start",
"justify-content": "space-between",
}}
>
<div
style={{
width: "40px",
height: "40px",
"border-radius": "10px",
background: "#FFF3EE",
display: "flex",
"align-items": "center",
"justify-content": "center",
"flex-shrink": "0",
}}
>
<card.Icon size={20} color={ORANGE} strokeWidth={2} />
</div>
<StatusBadge status={card.status} />
</div>
<div>
<p
style={{
margin: "0",
"font-size": "16px",
"font-weight": "700",
color: "#111827",
}}
>
{card.title}
</p>
<p
style={{
margin: "4px 0 0",
"font-size": "12px",
color: "#6B7280",
"line-height": "1.5",
}}
>
{card.subtitle}
</p>
</div>
<button
type="button"
disabled={
busyRoleKey() === card.key || card.action === "Current Role"
}
onClick={() =>
card.action === "Register"
? void registerRole(card.key)
: void switchRole(card.key)
}
style={
card.action === "Register"
? {
height: "34px",
"border-radius": "8px",
border: "none",
background: NAVY,
color: "#fff",
padding: "0 14px",
"font-size": "12px",
"font-weight": "700",
cursor:
busyRoleKey() === card.key ||
card.action === "Current Role"
? "not-allowed"
: "pointer",
opacity:
busyRoleKey() === card.key ||
card.action === "Current Role"
? "0.6"
: "1",
"margin-top": "auto",
}
: {
height: "34px",
"border-radius": "8px",
border: "1px solid #E5E7EB",
background: "#fff",
color: "#374151",
padding: "0 14px",
"font-size": "12px",
"font-weight": "700",
cursor:
busyRoleKey() === card.key ||
card.action === "Current Role"
? "not-allowed"
: "pointer",
opacity:
busyRoleKey() === card.key ||
card.action === "Current Role"
? "0.6"
: "1",
"margin-top": "auto",
}
}
>
{busyRoleKey() === card.key
? card.action === "Register"
? "Registering..."
: "Switching..."
: card.action}
</button>
</div>
)}
</For>
</div>
</Show>
<div
style={{
border: "1px solid #E5E7EB",
background: "linear-gradient(180deg, #FFFFFF 0%, #FFFAF7 100%)",
"border-radius": "20px",
padding: "18px",
"box-shadow": "0 8px 20px rgba(15,23,42,0.06)",
}}
>
<p style={{ margin: "0", "font-size": "12px", "letter-spacing": "0.08em", "text-transform": "uppercase", "font-weight": "700", color: ORANGE, "text-align": "center" }}>
Growth Advantage
</p>
<p style={{ margin: "4px 0 0", "font-size": "24px", "font-weight": "800", color: "#111827", "text-align": "center", "line-height": "1.1" }}>
Why Add More Services?
</p>
<p style={{ margin: "8px auto 0", "font-size": "13px", "line-height": "1.5", color: "#6B7280", "text-align": "center", "max-width": "760px" }}>
A multi-service profile helps you acquire more opportunities, improve trust, and scale consistently on a single platform.
</p>
<div style={{ display: "grid", "grid-template-columns": "repeat(4, minmax(0, 1fr))", gap: "12px", "margin-top": "14px" }}>
<div style={{ border: "1px solid #E5E7EB", background: "#fff", "border-radius": "14px", padding: "14px", "box-shadow": "0 2px 6px rgba(0,0,0,0.05)" }}>
<div style={{ width: "34px", height: "34px", "border-radius": "999px", background: "#FFF3EE", display: "flex", "align-items": "center", "justify-content": "center" }}>
<TrendingUp size={16} color={ORANGE} />
</div>
<p style={{ margin: "10px 0 0", "font-size": "15px", "font-weight": "800", color: "#111827" }}>Reach More Buyers</p>
<p style={{ margin: "6px 0 0", "font-size": "12px", color: "#6B7280", "line-height": "1.45" }}>Get discovered by customers across multiple demand categories.</p>
</div>
<div style={{ border: "1px solid #E5E7EB", background: "#fff", "border-radius": "14px", padding: "14px", "box-shadow": "0 2px 6px rgba(0,0,0,0.05)" }}>
<div style={{ width: "34px", height: "34px", "border-radius": "999px", background: "#FFF3EE", display: "flex", "align-items": "center", "justify-content": "center" }}>
<Award size={16} color={ORANGE} />
</div>
<p style={{ margin: "10px 0 0", "font-size": "15px", "font-weight": "800", color: "#111827" }}>Increase Revenue Paths</p>
<p style={{ margin: "6px 0 0", "font-size": "12px", color: "#6B7280", "line-height": "1.45" }}>Offer additional services and create new income streams.</p>
</div>
<div style={{ border: "1px solid #E5E7EB", background: "#fff", "border-radius": "14px", padding: "14px", "box-shadow": "0 2px 6px rgba(0,0,0,0.05)" }}>
<div style={{ width: "34px", height: "34px", "border-radius": "999px", background: "#FFF3EE", display: "flex", "align-items": "center", "justify-content": "center" }}>
<ShieldCheck size={16} color={ORANGE} />
</div>
<p style={{ margin: "10px 0 0", "font-size": "15px", "font-weight": "800", color: "#111827" }}>Strengthen Credibilities</p>
<p style={{ margin: "6px 0 0", "font-size": "12px", color: "#6B7280", "line-height": "1.45" }}>Verified multi-service profiles build confidence and improve conversion.</p>
</div>
<div style={{ border: "1px solid #E5E7EB", background: "#fff", "border-radius": "14px", padding: "14px", "box-shadow": "0 2px 6px rgba(0,0,0,0.05)" }}>
<div style={{ width: "34px", height: "34px", "border-radius": "999px", background: "#FFF3EE", display: "flex", "align-items": "center", "justify-content": "center" }}>
<BarChart3 size={16} color={ORANGE} />
</div>
<p style={{ margin: "10px 0 0", "font-size": "15px", "font-weight": "800", color: "#111827" }}>Scale Faster</p>
<p style={{ margin: "6px 0 0", "font-size": "12px", color: "#6B7280", "line-height": "1.45" }}>Grow your business from one unified account and workflow.</p>
</div>
</div>
</div>
</div>
</Show>
</div>
</div>
);
}

View file

@ -1,8 +1,11 @@
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import { HelpCircle } from "lucide-solid";
import { BTN_GHOST, CARD, INPUT } from "~/components/DashboardShell";
import { type RoleKey } from "./RoleDashboardShared";
const API = "/api/gateway";
const NAVY = "#0D0D2A";
const ORANGE = "#FF5E13";
type Props = { roleKey: RoleKey };
@ -21,7 +24,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {
@ -112,13 +116,27 @@ export default function HelpCenterDashboardPage(props: Props) {
return (
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
<div style={CARD}>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#111827" }}>
Help Center
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
Find guides and articles for your role.
</p>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
}}
>
<span style={{ color: ORANGE }}>
<HelpCircle size={24} />
</span>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
Help Center
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Find guides and articles for your role.
</p>
</div>
</div>
<Show when={err()}>

View file

@ -17,7 +17,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {

View file

@ -5,8 +5,8 @@
* POST /api/jobseeker/jobs/:id/apply - Apply for a job
* Custom data: saved_jobs - Bookmarked jobs stored in profile
*/
import { For, Show, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_ORANGE, CARD, INPUT } from "~/components/DashboardShell";
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_PRIMARY, CARD, INPUT } from "~/components/DashboardShell";
import { readJobSeekerProfile, updateJobSeekerCustomData } from "~/lib/job-seeker-custom-data";
const API = "/api/gateway";
@ -21,8 +21,13 @@ type JobItem = {
employment_type?: string;
status?: string;
description?: string | null;
category?: string | null;
skills?: string[] | null;
created_at?: string;
};
type SortKey = "newest" | "salary_desc" | "salary_asc" | "title_asc";
type SavedJob = {
id: string;
title: string;
@ -48,6 +53,18 @@ async function apiFetch(path: string, opts?: RequestInit) {
});
}
function rowTags(row: JobItem): string[] {
const tags = new Set<string>();
if (row.category) tags.add(String(row.category));
if (Array.isArray(row.skills)) {
for (const skill of row.skills) {
const val = String(skill || "").trim();
if (val) tags.add(val);
}
}
return Array.from(tags);
}
function formatSalary(job: JobItem): string {
const min = Number(job.salary_min || 0);
const max = Number(job.salary_max || 0);
@ -81,9 +98,19 @@ export default function JobSeekerJobsPage() {
const [loading, setLoading] = createSignal(true);
const [busyId, setBusyId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<SortKey>("newest");
const [activeTag, setActiveTag] = createSignal("");
const [msg, setMsg] = createSignal("");
const [err, setErr] = createSignal("");
const availableTags = createMemo(() => {
const tags = new Set<string>();
for (const row of rows()) {
for (const tag of rowTags(row)) tags.add(tag);
}
return Array.from(tags).sort((a, b) => a.localeCompare(b));
});
const loadRows = async () => {
setLoading(true);
setErr("");
@ -174,19 +201,27 @@ export default function JobSeekerJobsPage() {
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)
);
const tag = activeTag().trim().toLowerCase();
const next = rows().filter((r) => {
const tags = rowTags(r);
const matchesTag = !tag || tags.some((t) => t.toLowerCase() === tag);
if (!matchesTag) return false;
if (!q) return true;
return (
String(r.title || "").toLowerCase().includes(q) ||
String(r.company_name || "").toLowerCase().includes(q) ||
String(r.location || "").toLowerCase().includes(q) ||
String(r.category || "").toLowerCase().includes(q) ||
tags.some((t) => t.toLowerCase().includes(q))
);
});
next.sort((a, b) => {
if (sortBy() === "salary_desc") return Number(b.salary_max || b.salary_min || 0) - Number(a.salary_max || a.salary_min || 0);
if (sortBy() === "salary_asc") return Number(a.salary_min || a.salary_max || 0) - Number(b.salary_min || b.salary_max || 0);
if (sortBy() === "title_asc") return String(a.title || "").localeCompare(String(b.title || ""));
return new Date(String(b.created_at || 0)).getTime() - new Date(String(a.created_at || 0)).getTime();
});
return next;
};
return (
@ -231,16 +266,46 @@ export default function JobSeekerJobsPage() {
</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 style={{ ...CARD, display: "grid", gap: "10px" }}>
<div style={{ display: "grid", "grid-template-columns": "1fr 180px auto", gap: "10px", "align-items": "center" }}>
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style={INPUT}
placeholder="Search jobs, company, location, tags"
/>
<select value={sortBy()} onChange={(e) => setSortBy(e.currentTarget.value as SortKey)} style={INPUT}>
<option value="newest">Sort: Newest</option>
<option value="salary_desc">Salary High to Low</option>
<option value="salary_asc">Salary Low to High</option>
<option value="title_asc">Title A-Z</option>
</select>
<button type="button" onClick={loadRows} style={BTN_GHOST}>
Refresh
</button>
</div>
<Show when={availableTags().length > 0}>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
<button
type="button"
onClick={() => setActiveTag("")}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() ? {} : { border: "1px solid #0D0D2A", color: "#0D0D2A" }) }}
>
All Tags
</button>
<For each={availableTags()}>
{(tag) => (
<button
type="button"
onClick={() => setActiveTag(tag)}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() === tag ? { border: "1px solid #0D0D2A", color: "#0D0D2A" } : {}) }}
>
{tag}
</button>
)}
</For>
</div>
</Show>
</div>
<Show when={loading()}>
@ -298,6 +363,15 @@ export default function JobSeekerJobsPage() {
<p style={{ margin: "8px 0 0", "font-size": "13px", color: "#374151" }}>
{row.description || "No description provided."}
</p>
<Show when={rowTags(row).length > 0}>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap", "margin-top": "8px" }}>
<For each={rowTags(row).slice(0, 6)}>
{(tag) => (
<span style={{ height: "22px", display: "inline-flex", "align-items": "center", padding: "0 8px", "border-radius": "999px", border: "1px solid #E5E7EB", background: "#F9FAFB", "font-size": "11px", color: "#374151" }}>{tag}</span>
)}
</For>
</div>
</Show>
<p
style={{
margin: "8px 0 0",
@ -335,7 +409,7 @@ export default function JobSeekerJobsPage() {
onClick={() => applyJob(row.id)}
disabled={busyId() === row.id}
style={{
...BTN_ORANGE,
...BTN_PRIMARY,
height: "32px",
"font-size": "12px",
padding: "0 12px",

View file

@ -1,14 +1,18 @@
import { createSignal } from 'solid-js';
import { LogOut } from 'lucide-solid';
import { BTN_GHOST, BTN_ORANGE, CARD } from '~/components/DashboardShell';
const API = '/api/gateway';
const NAVY = '#0D0D2A';
const ORANGE = '#FF5E13';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {
@ -49,9 +53,27 @@ export default function LogoutPage() {
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '760px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>Logout</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>End your current session securely.</p>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
}}
>
<span style={{ color: ORANGE }}>
<LogOut size={24} />
</span>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
Logout
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
End your current session securely.
</p>
</div>
</div>
<div style={CARD}>

View file

@ -1,13 +1,33 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { BTN_GHOST, CARD } from '~/components/DashboardShell';
import { normalizeRole, PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from './RoleDashboardShared';
import { readJobSeekerProfile } from '~/lib/job-seeker-custom-data';
import { For, Show, createMemo, createSignal, onMount, Switch, Match } from 'solid-js';
import { LayoutDashboard } from 'lucide-solid';
import { BTN_GHOST, BTN_PRIMARY, CARD } from '~/components/DashboardShell';
import { normalizeRole, PROFESSIONAL_ROLE_SET, type RoleKey } from './RoleDashboardShared';
import WalletWidget from './widgets/WalletWidget';
import LeadsWidget from './widgets/LeadsWidget';
import JobsWidget from './widgets/JobsWidget';
import ApplicationsWidget from './widgets/ApplicationsWidget';
import RequirementsWidget from './widgets/RequirementsWidget';
import ShortlistedWidget from './widgets/ShortlistedWidget';
import PortfolioWidget from './widgets/PortfolioWidget';
import ProfileCompletionWidget from './widgets/ProfileCompletionWidget';
import VerificationWidget from './widgets/VerificationWidget';
const API = '/api/gateway';
const NAVY = '#0D0D2A';
const ORANGE = '#FF5E13';
type Props = {
roleKey: RoleKey;
userName?: string;
widgetKeys?: string[];
verificationStatus?: string;
onNavigate?: (sidebar: string) => void;
};
const DEFAULT_WIDGETS: Record<string, string[]> = {
PROFESSIONAL: ['tracecoins', 'open_leads', 'my_requests', 'portfolio', 'profile_status', 'verification_status'],
COMPANY: ['tracecoins', 'total_jobs', 'applications_received', 'shortlisted_candidates'],
CUSTOMER: ['credits', 'total_requirements', 'shortlisted_responses'],
JOB_SEEKER: ['credits', 'available_jobs', 'my_applications', 'profile_status'],
};
type Metric = {
@ -16,28 +36,114 @@ type Metric = {
hint: string;
};
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
...opts,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
const WIDGET_COMPONENTS: Record<string, (props: { roleKey: RoleKey }) => any> = {
tracecoins: WalletWidget,
credits: WalletWidget,
open_leads: LeadsWidget,
my_requests: LeadsWidget,
total_requirements: RequirementsWidget,
open_requirements: RequirementsWidget,
closed_requirements: RequirementsWidget,
total_jobs: JobsWidget,
active_jobs: JobsWidget,
pending_jobs: JobsWidget,
available_jobs: JobsWidget,
applications_received: ApplicationsWidget,
my_applications: ApplicationsWidget,
shortlisted: ApplicationsWidget,
shortlisted_candidates: ShortlistedWidget,
shortlisted_responses: ShortlistedWidget,
responses_received: ShortlistedWidget,
portfolio: PortfolioWidget,
profile_status: ProfileCompletionWidget,
verification_status: VerificationWidget,
};
export default function MyDashboardPage(props: Props) {
const [metrics, setMetrics] = createSignal<Metric[]>([]);
const [loading, setLoading] = createSignal(true);
const [err, setErr] = createSignal('');
const [customizeMode, setCustomizeMode] = createSignal(false);
const [widgetOrder, setWidgetOrder] = createSignal<string[]>([]);
const [draggingIdx, setDraggingIdx] = createSignal<number | null>(null);
const [visibleWidgets, setVisibleWidgets] = createSignal<Set<string>>(new Set());
const getRoleType = (): string => {
if (PROFESSIONAL_ROLE_SET.has(props.roleKey)) return 'PROFESSIONAL';
if (props.roleKey === 'COMPANY') return 'COMPANY';
if (props.roleKey === 'CUSTOMER') return 'CUSTOMER';
if (props.roleKey === 'JOB_SEEKER') return 'JOB_SEEKER';
return 'PROFESSIONAL';
};
const widgetKeys = () => {
if (props.widgetKeys && props.widgetKeys.length > 0) return props.widgetKeys;
return DEFAULT_WIDGETS[getRoleType()] || [];
};
const initWidgetOrder = () => {
const keys = widgetKeys();
setWidgetOrder(keys);
setVisibleWidgets(new Set(keys));
};
onMount(() => {
setTimeout(() => loadData(), 0);
initWidgetOrder();
});
const moveWidget = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
const order = [...widgetOrder()];
const [moved] = order.splice(fromIdx, 1);
order.splice(toIdx, 0, moved);
setWidgetOrder(order);
};
const toggleWidget = (key: string) => {
const visible = new Set(visibleWidgets());
if (visible.has(key)) {
visible.delete(key);
} else {
visible.add(key);
}
setVisibleWidgets(visible);
};
const hasWidgets = createMemo(() => widgetKeys().length > 0);
const roleLabel = createMemo(() => String(props.roleKey || '').replace(/_/g, ' '));
const verificationStatus = createMemo(() => String(props.verificationStatus || 'NOT_SUBMITTED').toUpperCase());
const verificationTone = createMemo(() => {
const status = verificationStatus();
if (status === 'DOCUMENTS_REQUESTED' || status === 'REVISION_REQUESTED' || status === 'REJECTED') {
return {
badgeBorder: '#FFD8C2',
badgeBackground: '#FFF1EB',
badgeColor: '#FF5E13',
title: 'Action required: complete verification updates',
description: 'Admin requested changes. Update profile, portfolio, or documents and resubmit verification.',
};
}
if (status === 'PENDING' || status === 'UNDER_REVIEW') {
return {
badgeBorder: '#F6D78F',
badgeBackground: '#FFF3D6',
badgeColor: '#B7791F',
title: 'Verification in progress',
description: 'Your submission is under review. Track status updates in Verification.',
};
}
return {
badgeBorder: '#F6D78F',
badgeBackground: '#FFF3D6',
badgeColor: '#B7791F',
title: 'Complete verification to unlock full access',
description: 'Fill profile and portfolio sections, upload required documents, then submit for verification.',
};
});
const showVerificationPrompt = createMemo(() => verificationStatus() !== 'APPROVED');
const showPortfolioCta = createMemo(() => PROFESSIONAL_ROLE_SET.has(props.roleKey) || props.roleKey === 'JOB_SEEKER');
const getEffectiveRole = (): RoleKey => {
if (typeof window === 'undefined') return props.roleKey;
@ -52,7 +158,6 @@ export default function MyDashboardPage(props: Props) {
const loadData = async () => {
const effectiveRole = getEffectiveRole();
console.log('[MyDashboardPage] loadData called, effectiveRole:', effectiveRole, 'props.roleKey:', props.roleKey);
setLoading(true);
setErr('');
@ -63,8 +168,20 @@ export default function MyDashboardPage(props: Props) {
try {
if (roleKey === 'COMPANY') {
const [jobsRes, appsRes] = await Promise.all([
apiFetch('/api/companies/jobs?page=1&limit=100'),
apiFetch('/api/companies/jobs?page=1&limit=1'),
fetch('/api/companies/jobs?page=1&limit=100', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
}),
fetch('/api/companies/jobs?page=1&limit=1', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
}),
]);
const jobsJson = await jobsRes.json().catch(() => ({}));
const appsJson = await appsRes.json().catch(() => ({}));
@ -77,55 +194,86 @@ export default function MyDashboardPage(props: Props) {
);
if (!jobsRes.ok && !appsRes.ok) setErr('Some company metrics could not be loaded.');
} else if (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 : [];
const res = await fetch('/api/customers/requirements?page=1&limit=100', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
});
const json = await res.json().catch(() => ({}));
const reqs = Array.isArray(json?.data) ? json.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.');
if (!res.ok) setErr('Some customer metrics could not be loaded.');
} else if (roleKey === 'JOB_SEEKER') {
const [jobsRes, appsRes, profile] = await Promise.all([
apiFetch('/api/jobseeker/jobs?page=1&limit=100'),
apiFetch('/api/jobseeker/applications?page=1&limit=100'),
readJobSeekerProfile(),
const [jobsRes, appsRes] = await Promise.all([
fetch('/api/jobseeker/jobs?page=1&limit=100', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
}),
fetch('/api/jobseeker/applications?page=1&limit=100', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
}),
]);
const jobsJson = await jobsRes.json().catch(() => ({}));
const appsJson = await appsRes.json().catch(() => ({}));
const jobs = Array.isArray(jobsJson?.data) ? jobsJson.data : [];
const apps = Array.isArray(appsJson?.data) ? appsJson.data : [];
const customData = (profile?.custom_data && typeof profile.custom_data === 'object')
? (profile.custom_data as Record<string, unknown>)
: {};
const savedJobs = Array.isArray(customData.saved_jobs) ? customData.saved_jobs : [];
const portfolio = (customData.job_seeker_portfolio && typeof profile.job_seeker_portfolio === 'object')
? (profile.job_seeker_portfolio as Record<string, unknown>)
: {};
const profileStatus = String(profile?.status || 'NOT_SUBMITTED').replace(/_/g, ' ');
const portfolioDone = Boolean(
String(portfolio.headline || '').trim()
&& String(portfolio.education || '').trim()
&& String(portfolio.workExperience || '').trim()
&& String(portfolio.skills || '').trim(),
);
next.push(
{ title: 'Available Jobs', value: String(jobs.length), hint: 'Open approved jobs' },
{ title: 'My Applications', value: String(apps.length), hint: 'Total applications submitted' },
{ title: 'Shortlisted', value: String(apps.filter((a: any) => String(a.status || '').toUpperCase() === 'SHORTLISTED').length), hint: 'Moved ahead in process' },
{ title: 'Saved Jobs', value: String(savedJobs.length), hint: 'Bookmarked for later' },
{ title: 'Profile Status', value: profileStatus, hint: 'Verification state' },
{ title: 'Portfolio', value: portfolioDone ? 'Complete' : 'Incomplete', hint: 'Education/work/skills sections' },
{ title: 'Profile Status', value: 'Active', hint: 'Verification state' },
);
if (!jobsRes.ok && !appsRes.ok && !profile) setErr('Some job seeker metrics could not be loaded.');
if (!jobsRes.ok && !appsRes.ok) setErr('Some job seeker metrics could not be loaded.');
} else if (PROFESSIONAL_ROLE_SET.has(roleKey)) {
const prefix = ROLE_PREFIXES[roleKey];
const prefix = roleKey.toLowerCase().replace('_', '');
const prefixMap: Record<string, string> = {
photographer: 'photographers',
tutor: 'tutors',
makeup: 'makeup-artists',
developer: 'developers',
video: 'video-editors',
graphic: 'graphic-designers',
social: 'social-media-managers',
fitness: 'fitness-trainers',
catering: 'catering-services',
};
const p = prefixMap[prefix] || prefix;
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`),
fetch(`/api/${p}/marketplace?page=1&limit=100`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
}),
fetch(`/api/${p}/leads/requests/me?page=1&limit=100`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
}),
fetch(`/api/${p}/wallet/me`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.sessionStorage.getItem('nxtgauge_access_token') || ''}`,
},
}),
]);
const marketJson = await marketRes.json().catch(() => ({}));
const reqJson = await reqRes.json().catch(() => ({}));
@ -161,13 +309,155 @@ export default function MyDashboardPage(props: Props) {
setTimeout(() => loadData(), 0);
});
const renderWidget = (key: string, idx: number) => {
const Component = WIDGET_COMPONENTS[key];
if (!Component) return null;
if (customizeMode() && !visibleWidgets().has(key)) return null;
return (
<div
key={key}
data-widget-key={key}
draggable={true}
onDragStart={(e) => {
setDraggingIdx(idx);
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
}
}}
onDragOver={(e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
const fromIdx = draggingIdx();
if (fromIdx !== null && fromIdx !== idx) {
moveWidget(fromIdx, idx);
}
setDraggingIdx(null);
}}
onDragEnd={() => setDraggingIdx(null)}
style={{
cursor: 'grab',
opacity: draggingIdx() === idx ? 0.5 : 1,
transition: 'opacity 0.2s',
}}
>
<Component roleKey={getEffectiveRole()} />
</div>
);
};
const effectiveRole = () => getEffectiveRole();
return (
<div style={{ display: "grid", gap: "14px", "max-width": "980px" }}>
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
}}
>
<span style={{ color: ORANGE }}>
<LayoutDashboard size={24} />
</span>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
My Dashboard
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Overview of your {roleLabel()} metrics and activity.
</p>
</div>
</div>
<Show when={showVerificationPrompt()}>
<div
style={{
...CARD,
border: '1px solid #E5E7EB',
background: 'white',
display: 'grid',
gap: '10px',
}}
>
<div style={{ display: 'grid', gap: '8px' }}>
<span
style={{
display: 'inline-flex',
'align-items': 'center',
width: 'fit-content',
height: '24px',
padding: '0 10px',
'border-radius': '999px',
border: `1px solid ${verificationTone().badgeBorder}`,
background: verificationTone().badgeBackground,
color: verificationTone().badgeColor,
'font-size': '11px',
'font-weight': '700',
}}
>
{verificationStatus().replace(/_/g, ' ')}
</span>
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '800', color: '#111827' }}>{verificationTone().title}</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>
{verificationTone().description}
</p>
</div>
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap' }}>
<button
type="button"
onClick={() => props.onNavigate?.('My Profile')}
style={{ ...BTN_PRIMARY, height: '32px', 'font-size': '12px', padding: '0 12px' }}
>
Go to My Profile
</button>
<Show when={showPortfolioCta()}>
<button
type="button"
onClick={() => props.onNavigate?.('My Portfolio')}
style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}
>
Go to My Portfolio
</button>
</Show>
<button
type="button"
onClick={() => props.onNavigate?.('Verification')}
style={{ ...BTN_GHOST, height: '32px', 'font-size': '12px', padding: '0 12px' }}
>
Open Verification
</button>
</div>
</div>
</Show>
<div style={CARD}>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#111827" }}>My Dashboard</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "#6B7280" }}>
Welcome {props.userName || 'User'}. Role: {roleLabel()}.
</p>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center' }}>
<div>
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>Dashboard Overview</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Manage widget layout, visibility, sizing, and dashboard presentation
</p>
</div>
<button
type="button"
onClick={() => setCustomizeMode(!customizeMode())}
style={{
...(customizeMode() ? BTN_PRIMARY : BTN_GHOST),
'font-size': '12px',
height: '32px',
padding: '0 12px',
}}
>
{customizeMode() ? 'Done Customizing' : 'Customize Widgets'}
</button>
</div>
</div>
<Show when={err()}>
@ -176,30 +466,115 @@ export default function MyDashboardPage(props: Props) {
</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>
)}
<Show when={customizeMode()}>
<div style={{ ...CARD, border: '1px solid #E5E7EB', background: 'white', padding: '14px 16px' }}>
<p style={{ margin: '0 0 4px', 'font-size': '11px', 'letter-spacing': '0.04em', 'text-transform': 'uppercase', color: '#6B7280' }}>Widget Customization</p>
<p style={{ margin: '0 0 12px', 'font-size': '12px', color: '#374151' }}>Drag and drop cards below to reorder your dashboard widgets. Click eye icon to show/hide.</p>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(auto-fill,minmax(140px,1fr))', gap: '10px' }}>
<For each={widgetOrder()}>
{(w, idx) => {
const isVisible = () => visibleWidgets().has(w);
const isDragging = () => draggingIdx() === idx();
return (
<div
draggable={true}
onDragStart={(e) => {
setDraggingIdx(idx());
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx()));
}
}}
onDragOver={(e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
const fromIdx = draggingIdx();
if (fromIdx !== null) {
moveWidget(fromIdx, idx());
}
setDraggingIdx(null);
}}
onDragEnd={() => setDraggingIdx(null)}
style={{
border: '1px solid #E5E7EB',
background: isDragging() ? '#FFF3EE' : isVisible() ? 'white' : '#F9FAFB',
'border-radius': '12px',
padding: '10px',
'min-height': '80px',
'box-shadow': '0 1px 3px rgba(0,0,0,0.05)',
cursor: 'grab',
opacity: isDragging() ? 0.5 : isVisible() ? 1 : 0.4,
}}
>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-start' }}>
<p style={{ margin: 0, 'font-size': '10px', 'letter-spacing': '0.04em', 'text-transform': 'uppercase', color: '#6B7280' }}>
{w.replace(/_/g, ' ')}
</p>
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleWidget(w); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
'font-size': '12px',
'line-height': 1,
color: isVisible() ? '#0D0D2A' : '#9CA3AF',
}}
title={isVisible() ? 'Hide widget' : 'Show widget'}
>
{isVisible() ? '👁' : '👁‍🗨'}
</button>
</div>
<p style={{ margin: '8px 0 0', 'font-size': '20px', 'font-weight': '800', color: '#111827' }}>
{metrics().find(m => m.title.toLowerCase().replace(/ /g, '_').includes(w))?.value || '--'}
</p>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
</Show>
<Show when={hasWidgets()}>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(3,minmax(0,1fr))', gap: '14px' }}>
<For each={widgetOrder().filter(w => !customizeMode() || visibleWidgets().has(w))}>
{(key) => renderWidget(key, 0)}
</For>
</div>
</Show>
<Show when={!hasWidgets()}>
<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>
</Show>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@
* POST /api/{profession}/leads/request - Request a lead
* Professions: photographers, makeup-artists, tutors, developers, etc.
*/
import { For, Show, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_ORANGE, CARD } from "~/components/DashboardShell";
import { For, Show, createMemo, createSignal, onMount } from "solid-js";
import { BTN_GHOST, BTN_PRIMARY, CARD, INPUT } from "~/components/DashboardShell";
import { ROLE_PREFIXES, type RoleKey } from "./RoleDashboardShared";
const API = "/api/gateway";
@ -20,8 +20,18 @@ type MarketplaceItem = {
budget?: number | null;
profession_key?: string;
description?: string | null;
tags?: string[] | null;
created_at?: string;
};
type SortKey = "newest" | "budget_desc" | "budget_asc" | "title_asc";
function readTags(row: MarketplaceItem): string[] {
const raw = (row as any)?.tags ?? (row as any)?.requirement_tags ?? (row as any)?.skills;
if (!Array.isArray(raw)) return [];
return raw.map((item: any) => String(item || "").trim()).filter(Boolean);
}
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
@ -44,8 +54,45 @@ export default function ProfessionalLeadsPage(props: Props) {
const [busyId, setBusyId] = createSignal<string | null>(null);
const [msg, setMsg] = createSignal("");
const [err, setErr] = createSignal("");
const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<SortKey>("newest");
const [activeTag, setActiveTag] = createSignal("");
const prefix = () => ROLE_PREFIXES[props.roleKey];
const allTags = createMemo(() => {
const tags = new Set<string>();
for (const row of rows()) {
for (const tag of readTags(row)) tags.add(tag);
}
return Array.from(tags).sort((a, b) => a.localeCompare(b));
});
const filteredSortedRows = createMemo(() => {
const q = search().trim().toLowerCase();
const tag = activeTag().trim().toLowerCase();
const next = rows().filter((row) => {
const tags = readTags(row);
const matchesTag = !tag || tags.some((t) => t.toLowerCase() === tag);
if (!matchesTag) return false;
if (!q) return true;
return (
String(row.title || "").toLowerCase().includes(q) ||
String(row.location || "").toLowerCase().includes(q) ||
String(row.profession_key || "").toLowerCase().includes(q) ||
String(row.description || "").toLowerCase().includes(q) ||
tags.some((t) => t.toLowerCase().includes(q))
);
});
next.sort((a, b) => {
if (sortBy() === "budget_desc") return Number(b.budget || 0) - Number(a.budget || 0);
if (sortBy() === "budget_asc") return Number(a.budget || 0) - Number(b.budget || 0);
if (sortBy() === "title_asc") return String(a.title || "").localeCompare(String(b.title || ""));
return new Date(String(b.created_at || 0)).getTime() - new Date(String(a.created_at || 0)).getTime();
});
return next;
});
const loadRows = async () => {
setLoading(true);
setErr("");
@ -139,6 +186,8 @@ export default function ProfessionalLeadsPage(props: Props) {
"justify-content": "space-between",
"align-items": "center",
"margin-bottom": "10px",
gap: "10px",
"flex-wrap": "wrap",
}}
>
<p style={{ margin: "0", "font-size": "16px", "font-weight": "700", color: "#111827" }}>
@ -149,78 +198,103 @@ export default function ProfessionalLeadsPage(props: Props) {
</button>
</div>
<div style={{ display: "grid", gap: "10px", "margin-bottom": "12px" }}>
<div style={{ display: "grid", "grid-template-columns": "1fr 180px", gap: "10px" }}>
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style={INPUT}
placeholder="Search by title, location, description, tags"
/>
<select value={sortBy()} onChange={(e) => setSortBy(e.currentTarget.value as SortKey)} style={INPUT}>
<option value="newest">Sort: Newest</option>
<option value="budget_desc">Budget High to Low</option>
<option value="budget_asc">Budget Low to High</option>
<option value="title_asc">Title A-Z</option>
</select>
</div>
<Show when={allTags().length > 0}>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
<button
type="button"
onClick={() => setActiveTag("")}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() ? {} : { border: "1px solid #0D0D2A", color: "#0D0D2A" }) }}
>
All Tags
</button>
<For each={allTags()}>
{(tag) => (
<button
type="button"
onClick={() => setActiveTag(tag)}
style={{ ...BTN_GHOST, height: "28px", padding: "0 10px", "font-size": "11px", ...(activeTag() === tag ? { border: "1px solid #0D0D2A", color: "#0D0D2A" } : {}) }}
>
{tag}
</button>
)}
</For>
</div>
</Show>
</div>
<Show when={loading()}>
<p style={{ margin: "0", color: "#9CA3AF", "font-size": "13px" }}>Loading leads...</p>
</Show>
<Show when={!loading() && rows().length === 0}>
<Show when={!loading() && filteredSortedRows().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()}>
<Show when={!loading() && filteredSortedRows().length > 0}>
<div style={{ border: "1px solid #E5E7EB", "border-radius": "12px", overflow: "hidden" }}>
<div style={{ display: "grid", "grid-template-columns": "2fr 1fr 1fr 1fr auto", gap: "8px", padding: "10px 12px", background: "#F9FAFB", "font-size": "11px", "font-weight": "700", color: "#6B7280", "text-transform": "uppercase" }}>
<span>Requirement</span>
<span>Location</span>
<span>Budget</span>
<span>Tags</span>
<span style={{ "text-align": "right" }}>Action</span>
</div>
<For each={filteredSortedRows()}>
{(row) => (
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "12px",
display: "grid",
"grid-template-columns": "2fr 1fr 1fr 1fr auto",
gap: "8px",
padding: "12px",
background: "#FCFCFD",
background: "white",
"border-top": "1px solid #F3F4F6",
"align-items": "start",
}}
>
<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: "0", "font-size": "13px", "font-weight": "800", color: "#111827" }}>
{row.title || "Requirement"}
</p>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280", "line-height": "1.4" }}>
{row.description || "No additional details."}
</p>
</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" }}
>
<div style={{ "font-size": "12px", color: "#374151" }}>
{row.location || "Location not set"}
</div>
<div style={{ "font-size": "12px", "font-weight": "700", color: "#111827" }}>
{row.budget ? `${row.budget}` : "Budget N/A"}
</div>
<div style={{ display: "flex", gap: "4px", "flex-wrap": "wrap" }}>
<For each={readTags(row).slice(0, 3)}>
{(tag) => (
<span style={{ height: "22px", display: "inline-flex", "align-items": "center", padding: "0 8px", "border-radius": "999px", border: "1px solid #E5E7EB", background: "#F9FAFB", "font-size": "11px", color: "#374151" }}>{tag}</span>
)}
</For>
</div>
<div style={{ display: "flex", "justify-content": "flex-end" }}>
<button
type="button"
onClick={() => requestLead(row.id)}
disabled={busyId() === row.id}
style={{
...BTN_ORANGE,
...BTN_PRIMARY,
height: "32px",
"font-size": "12px",
padding: "0 12px",

View file

@ -20,7 +20,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {

View file

@ -3,18 +3,31 @@
* Supports all 13 roles. Tabs: Basic Info · Documents.
* User fills and saves freely; "Submit for Verification" locks and queues for admin.
*/
import { For, Match, Show, Switch, createEffect, createSignal, onMount } from "solid-js";
import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";
import {
CARD,
BTN_ORANGE,
BTN_GHOST,
INPUT,
LABEL,
BTN_PRIMARY,
} from "~/components/DashboardShell";
import VerificationSubmissionGuide from "~/components/dashboard/VerificationSubmissionGuide";
const API = "/api/gateway";
const PORTFOLIO_PREFIX: Record<string, string> = {
PHOTOGRAPHER: "photographers",
MAKEUP_ARTIST: "makeup-artists",
TUTOR: "tutors",
DEVELOPER: "developers",
VIDEO_EDITOR: "video-editors",
GRAPHIC_DESIGNER: "graphic-designers",
SOCIAL_MEDIA_MANAGER: "social-media-managers",
FITNESS_TRAINER: "fitness-trainers",
CATERING_SERVICES: "catering-services",
UGC_CONTENT_CREATOR: "ugc-content-creators",
};
// ── Role-specific field definitions ──────────────────────────────────────────
const BASIC_FIELDS: Record<
@ -50,27 +63,20 @@ const BASIC_FIELDS: Record<
PHOTOGRAPHER: [
{ key: "first_name", label: "First Name", required: true },
{ key: "last_name", label: "Last Name", required: true },
{ key: "email", label: "Email Address", required: true },
{ key: "phone", label: "Mobile Number", required: true },
{ key: "location", label: "City", required: true },
{
key: "gender",
label: "Gender",
type: "select",
options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "address_line_1", label: "Address Line 1", required: true },
{ key: "address_line_2", label: "Address Line 2 (Optional)" },
{ key: "city", label: "City", required: true },
{ key: "area", label: "Area", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
{
key: "speciality",
label: "Photography Speciality",
type: "select",
options: [
"Wedding",
"Portrait",
"Commercial",
"Event",
"Wildlife",
"Fashion",
"Product",
"Other",
],
},
{ key: "experience_years", label: "Years of Experience", type: "number" },
{ key: "bio", label: "Short Bio", type: "textarea" },
],
FITNESS_TRAINER: [
{ key: "first_name", label: "First Name", required: true },
@ -114,6 +120,114 @@ const BASIC_FIELDS: Record<
{ key: "cuisine_types", label: "Cuisine Types (comma separated)" },
{ key: "bio", label: "About Your Service", type: "textarea" },
],
MAKEUP_ARTIST: [
{ key: "first_name", label: "First Name", required: true },
{ key: "last_name", label: "Last Name", required: true },
{ key: "email", label: "Email Address", required: true },
{ key: "phone", label: "Mobile Number", required: true },
{
key: "gender",
label: "Gender",
type: "select",
options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "address_line_1", label: "Address Line 1", required: true },
{ key: "address_line_2", label: "Address Line 2 (Optional)" },
{ key: "city", label: "City", required: true },
{ key: "area", label: "Area", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
],
DEVELOPER: [
{ key: "first_name", label: "First Name", required: true },
{ key: "last_name", label: "Last Name", required: true },
{ key: "email", label: "Email Address", required: true },
{ key: "phone", label: "Mobile Number", required: true },
{
key: "gender",
label: "Gender",
type: "select",
options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "address_line_1", label: "Address Line 1", required: true },
{ key: "address_line_2", label: "Address Line 2 (Optional)" },
{ key: "city", label: "City", required: true },
{ key: "area", label: "Area", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
],
VIDEO_EDITOR: [
{ key: "first_name", label: "First Name", required: true },
{ key: "last_name", label: "Last Name", required: true },
{ key: "email", label: "Email Address", required: true },
{ key: "phone", label: "Mobile Number", required: true },
{
key: "gender",
label: "Gender",
type: "select",
options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "address_line_1", label: "Address Line 1", required: true },
{ key: "address_line_2", label: "Address Line 2 (Optional)" },
{ key: "city", label: "City", required: true },
{ key: "area", label: "Area", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
],
UGC_CONTENT_CREATOR: [
{ key: "first_name", label: "First Name", required: true },
{ key: "last_name", label: "Last Name", required: true },
{ key: "email", label: "Email Address", required: true },
{ key: "phone", label: "Mobile Number", required: true },
{
key: "gender",
label: "Gender",
type: "select",
options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "address_line_1", label: "Address Line 1", required: true },
{ key: "address_line_2", label: "Address Line 2 (Optional)" },
{ key: "city", label: "City", required: true },
{ key: "area", label: "Area", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
],
GRAPHIC_DESIGNER: [
{ key: "first_name", label: "First Name", required: true },
{ key: "last_name", label: "Last Name", required: true },
{ key: "email", label: "Email Address", required: true },
{ key: "phone", label: "Mobile Number", required: true },
{
key: "gender",
label: "Gender",
type: "select",
options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "address_line_1", label: "Address Line 1", required: true },
{ key: "address_line_2", label: "Address Line 2 (Optional)" },
{ key: "city", label: "City", required: true },
{ key: "area", label: "Area", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
],
SOCIAL_MEDIA_MANAGER: [
{ key: "first_name", label: "First Name", required: true },
{ key: "last_name", label: "Last Name", required: true },
{ key: "email", label: "Email Address", required: true },
{ key: "phone", label: "Mobile Number", required: true },
{
key: "gender",
label: "Gender",
type: "select",
options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "address_line_1", label: "Address Line 1", required: true },
{ key: "address_line_2", label: "Address Line 2 (Optional)" },
{ key: "city", label: "City", required: true },
{ key: "area", label: "Area", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
],
};
const DOC_FIELDS: Record<
@ -140,41 +254,59 @@ const DOC_FIELDS: Record<
PHOTOGRAPHER: [
{
key: "aadhar_doc",
label: "Aadhar / Government ID",
label: "Identity Proof",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "sample_work",
label: "Sample Work Photos (23 images)",
key: "address_proof",
label: "Address Proof",
required: true,
hint: "JPG or PNG · Max 5MB each",
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "portfolio_ownership_proof",
label: "Portfolio Ownership Proof",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
],
MAKEUP_ARTIST: [
{
key: "aadhar_doc",
label: "Aadhar / Government ID",
label: "Identity Proof",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "sample_work",
label: "Sample Work Photos (23 images)",
key: "address_proof",
label: "Address Proof",
required: true,
hint: "JPG or PNG · Max 5MB each",
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "professional_certifications",
label: "Professional Certifications",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
],
TUTOR: [
{
key: "aadhar_doc",
label: "Aadhar / Government ID",
label: "Identity Proof",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "degree_certificate",
label: "Degree Certificate",
key: "address_proof",
label: "Address Proof",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "qualification_proof",
label: "Qualification Proof",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
@ -215,6 +347,31 @@ function getBasicFields(roleKey: string) {
function getDocFields(roleKey: string) {
return DOC_FIELDS[roleKey] ?? DOC_FIELDS.default;
}
function applyRuntimeFields<T extends { key: string }>(fields: T[], runtimeFields?: string[]): T[] {
if (!runtimeFields || runtimeFields.length === 0) return fields;
const fieldMap = new Map(fields.map(f => [f.key, f]));
return runtimeFields
.filter(key => fieldMap.has(key))
.map(key => fieldMap.get(key)!);
}
function resolveRuntimeFieldKeys(runtimeFields?: string[]): string[] {
if (!runtimeFields || runtimeFields.length === 0) return [];
const FIELD_KEY_MAP: Record<string, string> = {
full_name: 'first_name',
email: 'email',
phone: 'phone',
location: 'city',
verification_status: '',
approval_status: '',
};
const resolved: string[] = [];
for (const key of runtimeFields) {
const mapped = FIELD_KEY_MAP[key];
if (mapped) resolved.push(mapped);
}
return resolved;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -238,6 +395,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
interface Props {
roleKey: string;
runtimeFields?: string[];
onVerificationStatusChange?: (status: string) => void;
}
type Tab = "basic" | "documents";
@ -251,6 +410,88 @@ export default function ProfilePage(props: Props) {
const [docRequest, setDocRequest] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const [submitMsg, setSubmitMsg] = createSignal("");
const [missingPortfolioLabels, setMissingPortfolioLabels] = createSignal<string[]>([]);
const requiresPortfolio = () => props.roleKey === "JOB_SEEKER" || Boolean(PORTFOLIO_PREFIX[props.roleKey]);
const refreshPortfolioSubmission = async (): Promise<string[]> => {
if (props.roleKey === "JOB_SEEKER") {
const missing: string[] = [];
try {
const res = await apiFetch("/api/jobseeker/profile/me");
if (!res.ok) {
setMissingPortfolioLabels(["Portfolio details"]);
return ["Portfolio details"];
}
const data = await res.json().catch(() => ({}));
const portfolio = data?.custom_data?.job_seeker_portfolio || {};
if (!String(portfolio?.headline || "").trim()) missing.push("Professional headline");
if (!String(portfolio?.summary || "").trim()) missing.push("Career summary");
if (!String(portfolio?.education || "").trim()) missing.push("Education");
if (!String(portfolio?.workExperience || "").trim()) missing.push("Work experience");
if (!String(portfolio?.skills || "").trim()) missing.push("Skills");
} catch {
missing.push("Portfolio details");
}
setMissingPortfolioLabels(missing);
return missing;
}
if (!requiresPortfolio()) {
setMissingPortfolioLabels([]);
return [];
}
const missing: string[] = [];
const roleStorageKey = `nxtgauge_portfolio_meta_${String(props.roleKey || "professional").toLowerCase()}`;
let meta: any = {};
if (typeof window !== "undefined") {
try {
const raw = window.localStorage.getItem(roleStorageKey);
meta = raw ? JSON.parse(raw) : {};
} catch {
meta = {};
}
}
const aboutDone = String(meta?.about || "").trim().length > 0;
const servicesDone = Array.isArray(meta?.services)
? meta.services.some((s: any) =>
String(s?.name || "").trim() || String(s?.amount || "").trim() || String(s?.details || "").trim()
)
: false;
const experienceDone = Array.isArray(meta?.experience)
? meta.experience.some((e: any) => String(e?.year || "").trim() || String(e?.description || "").trim())
: false;
const toolsDone = Array.isArray(meta?.tools) ? meta.tools.some((t: any) => String(t || "").trim()) : false;
const faqDone = Array.isArray(meta?.faqs)
? meta.faqs.some((f: any) => String(f?.question || "").trim() && String(f?.answer || "").trim())
: false;
if (!aboutDone) missing.push("About");
if (!servicesDone) missing.push("Services & pricing");
if (!experienceDone && !toolsDone) missing.push("Experience / tools");
if (!faqDone) missing.push("FAQs");
try {
const res = await apiFetch(`/api/${PORTFOLIO_PREFIX[props.roleKey]}/portfolio/me`);
if (!res.ok) {
missing.push("Showcase items");
setMissingPortfolioLabels(Array.from(new Set(missing)));
return Array.from(new Set(missing));
}
const data = await res.json().catch(() => []);
const items = Array.isArray(data) ? data : data?.items ?? [];
if (!Array.isArray(items) || items.length === 0) missing.push("Showcase items");
} catch {
missing.push("Showcase items");
}
const uniqueMissing = Array.from(new Set(missing));
setMissingPortfolioLabels(uniqueMissing);
return uniqueMissing;
};
// Load saved profile + verification status on mount
onMount(async () => {
@ -272,9 +513,13 @@ export default function ProfilePage(props: Props) {
if (statusRes.ok) {
const s = await statusRes.json();
setVerificationStatus(s.status ?? "NOT_SUBMITTED");
const nextStatus = String(s.status ?? "NOT_SUBMITTED");
setVerificationStatus(nextStatus);
props.onVerificationStatusChange?.(nextStatus);
setDocRequest(s.document_request ?? null);
}
void refreshPortfolioSubmission();
});
const isLocked = () => ["PENDING", "UNDER_REVIEW"].includes(verificationStatus());
@ -299,6 +544,19 @@ export default function ProfilePage(props: Props) {
};
const handleSubmitForVerification = async () => {
const portfolioMissing = await refreshPortfolioSubmission();
if (missingBasicLabels().length > 0 || missingDocLabels().length > 0 || portfolioMissing.length > 0) {
if (missingBasicLabels().length > 0) {
setTab("basic");
setSubmitMsg(`Complete required profile fields before submitting: ${missingBasicLabels().join(", ")}`);
} else if (missingDocLabels().length > 0) {
setTab("documents");
setSubmitMsg(`Upload required documents before submitting: ${missingDocLabels().join(", ")}`);
} else {
setSubmitMsg(`Complete portfolio before submitting: ${portfolioMissing.join(", ")}. Go to My Portfolio.`);
}
return;
}
setSubmitting(true);
setSubmitMsg("");
try {
@ -309,6 +567,7 @@ export default function ProfilePage(props: Props) {
const data = await res.json();
if (res.ok) {
setVerificationStatus("PENDING");
props.onVerificationStatusChange?.("PENDING");
setSubmitMsg("Submitted! We will review your profile and notify you.");
} else if (res.status === 409) {
setSubmitMsg(data.error ?? "A verification is already in progress.");
@ -342,53 +601,47 @@ export default function ProfilePage(props: Props) {
NOT_SUBMITTED: "Not Submitted",
};
const basicFields = createMemo(() =>
applyRuntimeFields(getBasicFields(props.roleKey), resolveRuntimeFieldKeys(props.runtimeFields))
);
const requiredBasicFields = createMemo(() => basicFields().filter((field) => field.required));
const requiredDocFields = createMemo(() => getDocFields(props.roleKey).filter((doc) => doc.required));
const missingBasicLabels = createMemo(() =>
requiredBasicFields()
.filter((field) => !String(form()[field.key] || "").trim())
.map((field) => field.label)
);
const missingDocLabels = createMemo(() =>
requiredDocFields()
.filter((doc) => !String(form()[doc.key] || "").trim())
.map((doc) => doc.label)
);
const canSubmitVerification = createMemo(
() =>
!isLocked() &&
verificationStatus() !== "APPROVED" &&
missingBasicLabels().length === 0 &&
missingDocLabels().length === 0 &&
missingPortfolioLabels().length === 0
);
return (
<div style={{ "max-width": "760px" }}>
{/* ── Verification status banner ─────────────────────────────────── */}
<div
style={{
...CARD,
"margin-bottom": "16px",
display: "flex",
"align-items": "center",
"justify-content": "space-between",
gap: "12px",
"flex-wrap": "wrap",
}}
>
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
<span
style={{
display: "inline-flex",
"align-items": "center",
height: "22px",
padding: "0 10px",
"border-radius": "999px",
background: `${statusColor[verificationStatus()] ?? "#9CA3AF"}22`,
color: statusColor[verificationStatus()] ?? "#9CA3AF",
"font-size": "11px",
"font-weight": "700",
}}
>
{statusLabel[verificationStatus()] ?? verificationStatus()}
</span>
<Show when={docRequest()}>
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
<strong style={{ color: "#FF5E13" }}>Action needed:</strong> {docRequest()}
</p>
</Show>
</div>
<Show when={!isLocked() && verificationStatus() !== "APPROVED"}>
<button
type="button"
onClick={handleSubmitForVerification}
disabled={submitting()}
style={{ ...BTN_ORANGE, opacity: submitting() ? "0.7" : "1" }}
>
{submitting() ? "Submitting…" : "Submit for Verification"}
</button>
</Show>
</div>
<VerificationSubmissionGuide
statusLabel={statusLabel[verificationStatus()] ?? verificationStatus()}
statusColor={statusColor[verificationStatus()] ?? "#9CA3AF"}
locked={isLocked()}
approved={verificationStatus() === "APPROVED"}
docRequest={docRequest()}
missingBasicLabels={missingBasicLabels()}
missingDocLabels={missingDocLabels()}
missingPortfolioLabels={missingPortfolioLabels()}
canSubmit={canSubmitVerification()}
submitting={submitting()}
onSubmit={handleSubmitForVerification}
onGoBasic={() => setTab("basic")}
onGoDocuments={() => setTab("documents")}
/>
<Show when={submitMsg()}>
<div
@ -447,7 +700,7 @@ export default function ProfilePage(props: Props) {
{/* Basic Info */}
<Match when={tab() === "basic"}>
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "10px" }}>
<For each={getBasicFields(props.roleKey)}>
<For each={basicFields()}>
{(field) => (
<div style={{ "grid-column": field.type === "textarea" ? "span 2" : "span 1" }}>
<label style={LABEL}>

View file

@ -1,14 +1,18 @@
import { Show, createSignal, onMount } from 'solid-js';
import { Settings as SettingsIcon } from 'lucide-solid';
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, INPUT, LABEL } from '~/components/DashboardShell';
const API = '/api/gateway';
const NAVY = '#0D0D2A';
const ORANGE = '#FF5E13';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {
@ -179,19 +183,34 @@ export default function SettingsPage() {
return (
<div style={{ 'max-width': '760px', display: 'grid', gap: '14px' }}>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
}}
>
<span style={{ color: ORANGE }}>
<SettingsIcon size={24} />
</span>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
Settings
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Manage account security, notifications, and privacy controls.
</p>
</div>
</div>
<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' }}>

View file

@ -1,7 +1,10 @@
import { For, Show, createSignal, onMount } from 'solid-js';
import { RefreshCw } from 'lucide-solid';
import { BTN_GHOST, BTN_PRIMARY, CARD } from '~/components/DashboardShell';
const API = '/api/gateway';
const NAVY = '#0D0D2A';
const ORANGE = '#FF5E13';
type UserRoleItem = {
role_key: string;
@ -15,7 +18,8 @@ async function apiFetch(path: string, opts?: RequestInit) {
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {
@ -160,11 +164,27 @@ export default function SwitchServicesPage() {
return (
<div style={{ display: 'grid', gap: '14px', 'max-width': '980px' }}>
<div style={CARD}>
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>Switch Services</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Manage approved roles and register additional services.
</p>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
}}
>
<span style={{ color: ORANGE }}>
<RefreshCw size={24} />
</span>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
Switch Services
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Manage approved roles and register additional services.
</p>
</div>
</div>
<Show when={msg()}>

View file

@ -2,18 +2,25 @@
* VerificationStatusPage shows the user their current verification state.
* Handles: NOT_SUBMITTED, PENDING, UNDER_REVIEW, DOCUMENTS_REQUESTED,
* REVISION_REQUESTED, APPROVED, REJECTED.
* Tabs: approval status, documents, activity
*/
import { Show, createSignal, onMount } from 'solid-js';
import { For, Show, createSignal, onMount } from 'solid-js';
import { ShieldCheck, FileText, Activity } from 'lucide-solid';
import { CARD, BTN_ORANGE, BTN_GHOST } from '~/components/DashboardShell';
const API = '/api/gateway';
const NAVY = '#0D0D2A';
const ORANGE = '#FF5E13';
type TabKey = 'approval_status' | 'documents' | 'activity';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: "include",
headers: {
@ -93,21 +100,21 @@ const STATUS_CONFIG: Record<string, {
};
const FLOW_STEPS = [
{ key: 'submit', label: 'Submit Profile' },
{ key: 'review', label: 'Under Review' },
{ key: 'verify', label: 'Verified' },
{ key: 'submit', label: 'Submit Profile' },
{ key: 'review', label: 'Under Review' },
{ key: 'verify', label: 'Verified' },
{ key: 'approved', label: 'Approved' },
];
function stepIndex(status: string): number {
switch (status) {
case 'NOT_SUBMITTED': return 0;
case 'PENDING': return 1;
case 'UNDER_REVIEW': return 2;
case 'NOT_SUBMITTED': return 0;
case 'PENDING': return 1;
case 'UNDER_REVIEW': return 2;
case 'DOCUMENTS_REQUESTED':
case 'REVISION_REQUESTED': return 2; // still at review stage
case 'APPROVED': return 4;
default: return 1;
case 'REVISION_REQUESTED': return 2;
case 'APPROVED': return 4;
default: return 1;
}
}
@ -116,9 +123,11 @@ function stepIndex(status: string): number {
interface Props {
roleKey: string;
onNavigate?: (sidebar: string) => void;
onVerificationStatusChange?: (status: string) => void;
}
export default function VerificationStatusPage(props: Props) {
const [activeTab, setActiveTab] = createSignal<TabKey>('approval_status');
const [status, setStatus] = createSignal('NOT_SUBMITTED');
const [docRequest, setDocRequest] = createSignal<string | null>(null);
const [rejectionReason, setRejectionReason] = createSignal<string | null>(null);
@ -126,16 +135,22 @@ export default function VerificationStatusPage(props: Props) {
const [loading, setLoading] = createSignal(true);
const [resubmitting, setResubmitting] = createSignal(false);
const [resubmitMsg, setResubmitMsg] = createSignal('');
const [activityLog, setActivityLog] = createSignal<Array<{ date: string; action: string; details: string }>>([]);
onMount(async () => {
try {
const res = await apiFetch(`/api/me/verification-status?roleKey=${props.roleKey}`);
if (res.ok) {
const d = await res.json();
setStatus(d.status ?? 'NOT_SUBMITTED');
const nextStatus = String(d.status ?? 'NOT_SUBMITTED');
setStatus(nextStatus);
props.onVerificationStatusChange?.(nextStatus);
setDocRequest(d.document_request ?? null);
setRejectionReason(d.rejection_reason ?? null);
setUpdatedAt(d.updated_at ?? null);
if (d.activity_log) {
setActivityLog(d.activity_log);
}
}
} finally {
setLoading(false);
@ -157,6 +172,7 @@ export default function VerificationStatusPage(props: Props) {
const d = await res.json().catch(() => ({}));
if (res.ok) {
setStatus('PENDING');
props.onVerificationStatusChange?.('PENDING');
setDocRequest(null);
setRejectionReason(null);
setResubmitMsg('Resubmitted successfully! We will review your profile.');
@ -172,8 +188,64 @@ export default function VerificationStatusPage(props: Props) {
}
};
const TABS: { key: TabKey; label: string; Icon: any }[] = [
{ key: 'approval_status', label: 'Approval Status', Icon: ShieldCheck },
{ key: 'documents', label: 'Documents', Icon: FileText },
{ key: 'activity', label: 'Activity', Icon: Activity },
];
return (
<div style={{ 'max-width': '640px' }}>
<div
style={{
background: NAVY,
"border-radius": "12px",
padding: "16px 20px",
display: "flex",
"align-items": "center",
gap: "12px",
"margin-bottom": "14px",
}}
>
<span style={{ color: ORANGE }}>
<ShieldCheck size={24} />
</span>
<div>
<p style={{ margin: "0", "font-size": "18px", "font-weight": "800", color: "#fff" }}>
Verification Portal
</p>
<p style={{ margin: "4px 0 0", "font-size": "13px", color: "rgba(255,255,255,0.65)" }}>
Track verification progress, documents, and updates.
</p>
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '0', 'border-bottom': '1px solid #E5E7EB', 'margin-bottom': '14px' }}>
<For each={TABS}>
{(tab) => (
<button
type="button"
onClick={() => setActiveTab(tab.key)}
style={{
padding: "10px 16px",
"border-bottom": activeTab() === tab.key ? '2px solid #FF5E13' : '2px solid transparent',
background: "transparent",
color: activeTab() === tab.key ? "#FF5E13" : "#6B7280",
"font-size": "14px",
"font-weight": activeTab() === tab.key ? "700" : "500",
cursor: "pointer",
display: "flex",
"align-items": "center",
gap: "6px",
}}
>
<tab.Icon size={16} />
{tab.label}
</button>
)}
</For>
</div>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF' }}>
@ -182,189 +254,254 @@ export default function VerificationStatusPage(props: Props) {
</Show>
<Show when={!loading()}>
{/* ── Main status card ─────────────────────────────────────────── */}
<div style={{
...CARD,
background: cfg().bg,
border: `1px solid ${cfg().border}`,
'margin-bottom': '16px',
display: 'flex',
'flex-direction': 'column',
gap: '12px',
}}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}>
<span style={{ 'font-size': '36px', 'line-height': '1' }}>{cfg().emoji}</span>
<div>
<p style={{ margin: '0', 'font-size': '11px', 'text-transform': 'uppercase', 'letter-spacing': '0.08em', color: cfg().color, 'font-weight': '700' }}>
Verification Status
{/* ── Approval Status Tab ─────────────────────────────────────── */}
<Show when={activeTab() === 'approval_status'}>
{/* Main status card */}
<div style={{
...CARD,
background: cfg().bg,
border: `1px solid ${cfg().border}`,
'margin-bottom': '16px',
display: 'flex',
'flex-direction': 'column',
gap: '12px',
}}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}>
<span style={{ 'font-size': '36px', 'line-height': '1' }}>{cfg().emoji}</span>
<div>
<p style={{ margin: '0', 'font-size': '11px', 'text-transform': 'uppercase', 'letter-spacing': '0.08em', color: cfg().color, 'font-weight': '700' }}>
Verification Status
</p>
<p style={{ margin: '2px 0 0', 'font-size': '22px', 'font-weight': '800', color: cfg().color }}>
{cfg().label}
</p>
</div>
</div>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151', 'line-height': '1.6' }}>
{cfg().description}
</p>
<Show when={updatedAt()}>
<p style={{ margin: '0', 'font-size': '11px', color: '#9CA3AF' }}>
Last updated: {new Date(updatedAt()!).toLocaleString('en-IN')}
</p>
<p style={{ margin: '2px 0 0', 'font-size': '22px', 'font-weight': '800', color: cfg().color }}>
{cfg().label}
</Show>
</div>
{/* Doc request / rejection reason */}
<Show when={docRequest()}>
<div style={{ ...CARD, background: '#FFF7ED', border: '1px solid #FED7AA', 'margin-bottom': '16px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#C2410C' }}>
Document Request from Admin
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
{docRequest()}
</p>
</div>
</Show>
<Show when={rejectionReason()}>
<div style={{ ...CARD, background: '#FEF2F2', border: '1px solid #FECACA', 'margin-bottom': '16px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#B91C1C' }}>
Rejection Reason
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
{rejectionReason()}
</p>
</div>
</Show>
{/* Progress timeline */}
<Show when={status() !== 'APPROVED'}>
<div style={{ ...CARD, 'margin-bottom': '16px' }}>
<p style={{ margin: '0 0 14px', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
Verification Progress
</p>
<div style={{ display: 'flex', 'align-items': 'center', gap: '0' }}>
<For each={FLOW_STEPS}>
{(step, idx) => {
const done = currentStep() > idx();
const active = currentStep() === idx() + 1;
return (
<>
<div style={{ display: 'flex', 'flex-direction': 'column', 'align-items': 'center', 'flex-shrink': '0' }}>
<div style={{
width: '28px',
height: '28px',
'border-radius': '999px',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
'font-size': '11px',
'font-weight': '800',
background: done ? '#FF5E13' : active ? '#FFF3EE' : '#F3F4F6',
color: done ? '#fff' : active ? '#FF5E13' : '#9CA3AF',
border: active ? '2px solid #FF5E13' : '2px solid transparent',
}}>
{done ? '✓' : idx() + 1}
</div>
<p style={{ margin: '4px 0 0', 'font-size': '10px', 'font-weight': '600', color: done || active ? '#374151' : '#9CA3AF', 'white-space': 'nowrap', 'text-align': 'center' }}>
{step.label}
</p>
</div>
<Show when={idx() < FLOW_STEPS.length - 1}>
<div style={{ flex: '1', height: '2px', background: done ? '#FF5E13' : '#E5E7EB', 'margin-bottom': '18px' }} />
</Show>
</>
);
}}
</For>
</div>
</div>
</Show>
{/* Actions */}
<div style={{ display: 'flex', gap: '10px', 'flex-wrap': 'wrap' }}>
<Show when={status() === 'NOT_SUBMITTED'}>
<button type="button" onClick={() => props.onNavigate?.('My Profile')} style={BTN_ORANGE}>
Fill My Profile
</button>
<button type="button" onClick={() => props.onNavigate?.('My Portfolio')} style={BTN_GHOST}>
Fill My Portfolio
</button>
</Show>
<Show when={canResubmit()}>
<button type="button" onClick={() => props.onNavigate?.('My Profile')} style={BTN_GHOST}>
Update My Profile
</button>
<button type="button" onClick={handleResubmit} disabled={resubmitting()} style={{ ...BTN_ORANGE, opacity: resubmitting() ? '0.7' : '1' }}>
{resubmitting() ? 'Resubmitting…' : 'Resubmit for Verification'}
</button>
</Show>
</div>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151', 'line-height': '1.6' }}>
{cfg().description}
</p>
<Show when={updatedAt()}>
<p style={{ margin: '0', 'font-size': '11px', color: '#9CA3AF' }}>
Last updated: {new Date(updatedAt()!).toLocaleString('en-IN')}
<Show when={resubmitMsg()}>
<p style={{ margin: '12px 0 0', 'font-size': '13px', 'font-weight': '600', color: resubmitMsg().includes('successfully') ? '#10B981' : '#EF4444' }}>
{resubmitMsg()}
</p>
</Show>
</div>
{/* ── Doc request / rejection reason ──────────────────────────── */}
<Show when={docRequest()}>
<div style={{
...CARD,
background: '#FFF7ED',
border: '1px solid #FED7AA',
'margin-bottom': '16px',
}}>
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#C2410C' }}>
Document Request from Admin
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
{docRequest()}
<Show when={status() === 'APPROVED'}>
<div style={{ ...CARD, background: '#ECFDF5', border: '1px solid #6EE7B7', 'text-align': 'center', padding: '32px' }}>
<p style={{ margin: '0', 'font-size': '48px' }}>🎉</p>
<p style={{ margin: '12px 0 4px', 'font-size': '20px', 'font-weight': '800', color: '#065F46' }}>
You're Verified!
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#047857', 'line-height': '1.6' }}>
Your profile is approved. Start exploring opportunities on Nxtgauge.
</p>
</div>
</Show>
</Show>
{/* ── Documents Tab ───────────────────────────────────────────── */}
<Show when={activeTab() === 'documents'}>
<div style={CARD}>
<p style={{ margin: '0 0 14px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
Submitted Documents
</p>
<Show when={!docRequest() && !rejectionReason() && status() === 'NOT_SUBMITTED'}>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>
No documents submitted yet. Complete your profile to submit documents for verification.
</p>
</Show>
<Show when={docRequest()}>
<div style={{ background: '#FFF7ED', border: '1px solid #FED7AA', 'border-radius': '10px', padding: '14px', 'margin-bottom': '12px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '13px', 'font-weight': '700', color: '#C2410C' }}>
Document Request
</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151' }}>{docRequest()}</p>
</div>
</Show>
<Show when={rejectionReason()}>
<div style={{ background: '#FEF2F2', border: '1px solid #FECACA', 'border-radius': '10px', padding: '14px', 'margin-bottom': '12px' }}>
<p style={{ margin: '0 0 6px', 'font-size': '13px', 'font-weight': '700', color: '#B91C1C' }}>
Rejection Reason
</p>
<p style={{ margin: '0', 'font-size': '13px', color: '#374151' }}>{rejectionReason()}</p>
</div>
</Show>
<Show when={status() !== 'NOT_SUBMITTED'}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '12px', background: '#FCFCFD' }}>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#111827' }}>Identity Proof</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{status() === 'APPROVED' ? '✓ Verified' : status() === 'REJECTED' ? '✕ Rejected' : '◌ Pending review'}
</p>
</div>
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '12px', background: '#FCFCFD' }}>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#111827' }}>Address Proof</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{status() === 'APPROVED' ? '✓ Verified' : status() === 'REJECTED' ? '✕ Rejected' : '◌ Pending review'}
</p>
</div>
<div style={{ border: '1px solid #E5E7EB', 'border-radius': '10px', padding: '12px', background: '#FCFCFD' }}>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#111827' }}>Professional Certifications</p>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>
{status() === 'APPROVED' ? '✓ Verified' : status() === 'REJECTED' ? '✕ Rejected' : '◌ Pending review'}
</p>
</div>
</div>
</Show>
</div>
</Show>
<Show when={rejectionReason()}>
<div style={{
...CARD,
background: '#FEF2F2',
border: '1px solid #FECACA',
'margin-bottom': '16px',
}}>
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#B91C1C' }}>
Rejection Reason
{/* ── Activity Tab ────────────────────────────────────────────── */}
<Show when={activeTab() === 'activity'}>
<div style={CARD}>
<p style={{ margin: '0 0 14px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
Verification Activity
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
{rejectionReason()}
</p>
</div>
</Show>
{/* ── Progress timeline ──────────────────────────────────────── */}
<Show when={status() !== 'APPROVED'}>
<div style={{ ...CARD, 'margin-bottom': '16px' }}>
<p style={{ margin: '0 0 14px', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
Verification Progress
</p>
<div style={{ display: 'flex', 'align-items': 'center', gap: '0' }}>
{FLOW_STEPS.map((step, idx) => {
const done = currentStep() > idx;
const active = currentStep() === idx + 1;
return (
<>
<div style={{ display: 'flex', 'flex-direction': 'column', 'align-items': 'center', 'flex-shrink': '0' }}>
<div style={{
width: '28px',
height: '28px',
'border-radius': '999px',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
'font-size': '11px',
'font-weight': '800',
background: done ? '#FF5E13' : active ? '#FFF3EE' : '#F3F4F6',
color: done ? '#fff' : active ? '#FF5E13' : '#9CA3AF',
border: active ? '2px solid #FF5E13' : '2px solid transparent',
}}>
{done ? '✓' : idx + 1}
</div>
<p style={{
margin: '4px 0 0',
'font-size': '10px',
'font-weight': '600',
color: done || active ? '#374151' : '#9CA3AF',
'white-space': 'nowrap',
'text-align': 'center',
}}>
{step.label}
</p>
<Show when={activityLog().length === 0}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px' }}>
<Show when={status() === 'NOT_SUBMITTED'}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: '#E5E7EB', 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>Profile created</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{updatedAt() ? new Date(updatedAt()!).toLocaleString('en-IN') : 'Just now'}</p>
</div>
{idx < FLOW_STEPS.length - 1 && (
<div style={{
flex: '1',
height: '2px',
background: done ? '#FF5E13' : '#E5E7EB',
'margin-bottom': '18px',
}} />
)}
</>
);
})}
</div>
</div>
</Show>
<Show when={!['NOT_SUBMITTED'].includes(status())}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: '#FDE68A', 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>Profile submitted for verification</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{updatedAt() ? new Date(updatedAt()!).toLocaleString('en-IN') : 'Recently'}</p>
</div>
</div>
</Show>
<Show when={status() === 'APPROVED'}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: '#10B981', 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>Profile approved</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{updatedAt() ? new Date(updatedAt()!).toLocaleString('en-IN') : 'Recently'}</p>
</div>
</div>
</Show>
</div>
</Show>
<Show when={activityLog().length > 0}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<For each={activityLog()}>
{(item) => (
<div style={{ display: 'flex', gap: '12px', 'align-items': 'flex-start' }}>
<div style={{ width: '8px', height: '8px', 'border-radius': '50%', background: ORANGE, 'margin-top': '6px', 'flex-shrink': '0' }} />
<div>
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '600', color: '#374151' }}>{item.action}</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>{item.date}</p>
<Show when={item.details}>
<p style={{ margin: '4px 0 0', 'font-size': '12px', color: '#6B7280' }}>{item.details}</p>
</Show>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
{/* ── Actions ───────────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: '10px', 'flex-wrap': 'wrap' }}>
<Show when={status() === 'NOT_SUBMITTED'}>
<button
type="button"
onClick={() => props.onNavigate?.('My Profile')}
style={BTN_ORANGE}
>
Fill My Profile
</button>
<button
type="button"
onClick={() => props.onNavigate?.('My Portfolio')}
style={BTN_GHOST}
>
Fill My Portfolio
</button>
</Show>
<Show when={canResubmit()}>
<button
type="button"
onClick={() => props.onNavigate?.('My Profile')}
style={BTN_GHOST}
>
Update My Profile
</button>
<button
type="button"
onClick={handleResubmit}
disabled={resubmitting()}
style={{ ...BTN_ORANGE, opacity: resubmitting() ? '0.7' : '1' }}
>
{resubmitting() ? 'Resubmitting…' : 'Resubmit for Verification'}
</button>
</Show>
</div>
<Show when={resubmitMsg()}>
<p style={{
margin: '12px 0 0',
'font-size': '13px',
'font-weight': '600',
color: resubmitMsg().includes('successfully') ? '#10B981' : '#EF4444',
}}>
{resubmitMsg()}
</p>
</Show>
{/* ── Approved state: success message ───────────────────────── */}
<Show when={status() === 'APPROVED'}>
<div style={{ ...CARD, background: '#ECFDF5', border: '1px solid #6EE7B7', 'text-align': 'center', padding: '32px' }}>
<p style={{ margin: '0', 'font-size': '48px' }}>🎉</p>
<p style={{ margin: '12px 0 4px', 'font-size': '20px', 'font-weight': '800', color: '#065F46' }}>
You're Verified!
</p>
<p style={{ margin: '0', 'font-size': '14px', color: '#047857', 'line-height': '1.6' }}>
Your profile is approved. Start exploring opportunities on Nxtgauge.
</p>
</div>
</Show>
</Show>
</div>
);

View file

@ -0,0 +1,190 @@
import { For, Show } from "solid-js";
import { BTN_GHOST, BTN_PRIMARY, CARD } from "../DashboardShell";
type Props = {
statusLabel: string;
statusColor: string;
locked: boolean;
approved: boolean;
docRequest?: string | null;
missingBasicLabels: string[];
missingDocLabels: string[];
missingPortfolioLabels?: string[];
canSubmit: boolean;
submitting: boolean;
onSubmit: () => void;
onGoBasic: () => void;
onGoDocuments: () => void;
};
export default function VerificationSubmissionGuide(props: Props) {
const portfolioMissing = () => props.missingPortfolioLabels ?? [];
const totalMissing = () => props.missingBasicLabels.length + props.missingDocLabels.length + portfolioMissing().length;
return (
<div style={{ ...CARD, "margin-bottom": "16px", display: "grid", gap: "12px" }}>
<div
style={{
display: "flex",
"align-items": "center",
"justify-content": "space-between",
gap: "10px",
"flex-wrap": "wrap",
}}
>
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
<span
style={{
display: "inline-flex",
"align-items": "center",
height: "24px",
padding: "0 10px",
"border-radius": "999px",
border: "1px solid #E5E7EB",
background: "#F9FAFB",
color: "#4B5563",
"font-size": "11px",
"font-weight": "700",
}}
>
{props.statusLabel}
</span>
<span style={{ "font-size": "13px", color: "#6B7280" }}>
{props.approved
? "Your profile is approved."
: props.locked
? "Verification in progress."
: totalMissing() === 0
? "Ready to submit for verification."
: `${totalMissing()} item${totalMissing() > 1 ? "s" : ""} left before submission.`}
</span>
</div>
<Show when={!props.locked && !props.approved}>
<button
type="button"
onClick={props.onSubmit}
disabled={!props.canSubmit || props.submitting}
style={{
...BTN_PRIMARY,
background: "#0D0D2A",
color: "#FFFFFF",
border: "none",
opacity: "1",
cursor: !props.canSubmit || props.submitting ? "not-allowed" : "pointer",
}}
>
{props.submitting ? "Submitting..." : "Submit for Verification"}
</button>
</Show>
</div>
<Show when={props.docRequest}>
<div
style={{
border: "1px solid #FED7AA",
background: "#FFF7ED",
padding: "10px 12px",
"border-radius": "10px",
"font-size": "12px",
color: "#9A3412",
}}
>
<strong>Admin request:</strong> {props.docRequest}
</div>
</Show>
<div style={{ display: "grid", gap: "8px" }}>
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "10px",
padding: "10px 12px",
background: "#FFFFFF",
}}
>
<p style={{ margin: "0", "font-size": "12px", "font-weight": "700", color: "#111827" }}>
1. Complete required basic profile fields
</p>
<Show
when={props.missingBasicLabels.length > 0}
fallback={<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>Done.</p>}
>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
Missing: {props.missingBasicLabels.join(", ")}
</p>
<button type="button" onClick={props.onGoBasic} style={{ ...BTN_GHOST, height: "30px", "margin-top": "8px", "font-size": "12px" }}>
Go to Basic Information
</button>
</Show>
</div>
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "10px",
padding: "10px 12px",
background: "#FFFFFF",
}}
>
<p style={{ margin: "0", "font-size": "12px", "font-weight": "700", color: "#111827" }}>
2. Upload clear required documents
</p>
<Show
when={props.missingDocLabels.length > 0}
fallback={<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>Done.</p>}
>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap", "margin-top": "6px" }}>
<For each={props.missingDocLabels}>
{(item) => (
<span
style={{
height: "22px",
display: "inline-flex",
"align-items": "center",
padding: "0 8px",
"border-radius": "999px",
border: "1px solid #E5E7EB",
background: "#F9FAFB",
"font-size": "11px",
color: "#6B7280",
}}
>
{item}
</span>
)}
</For>
</div>
<button type="button" onClick={props.onGoDocuments} style={{ ...BTN_GHOST, height: "30px", "margin-top": "8px", "font-size": "12px" }}>
Go to Documents
</button>
</Show>
</div>
<div
style={{
border: "1px solid #E5E7EB",
"border-radius": "10px",
padding: "10px 12px",
background: "#FFFFFF",
}}
>
<p style={{ margin: "0", "font-size": "12px", "font-weight": "700", color: "#111827" }}>
3. Add portfolio showcase items
</p>
<Show
when={portfolioMissing().length > 0}
fallback={<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>Done.</p>}
>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
Missing: {portfolioMissing().join(", ")}. Go to <strong>My Portfolio</strong> and complete these sections.
</p>
</Show>
</div>
</div>
<p style={{ margin: "0", "font-size": "12px", color: "#6B7280" }}>
Document tips: use full-page, readable scans, and match name/address exactly with your profile details.
</p>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { createResource, For, Show } from 'solid-js';
import { FileText } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchApplicationsData(roleKey: RoleKey) {
if (roleKey === 'COMPANY') {
const res = await apiFetch('/api/companies/jobs?page=1&limit=1');
if (!res.ok) return null;
const res2 = await apiFetch('/api/companies/applications?page=1&limit=100');
if (!res2.ok) return null;
const json = await res2.json();
return Array.isArray(json?.data) ? json.data : [];
}
if (roleKey === 'JOB_SEEKER') {
const res = await apiFetch('/api/jobseeker/applications?page=1&limit=100');
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
return null;
}
function AppStatRow(props: { label: string; value: number | string; color?: string }) {
return (
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '6px 0',
'border-bottom': '1px solid #F9FAFB',
}}
>
<span style={{ 'font-size': '12px', color: '#6B7280' }}>{props.label}</span>
<span
style={{
'font-size': '15px',
'font-weight': '800',
color: props.color || '#111827',
}}
>
{props.value}
</span>
</div>
);
}
export default function ApplicationsWidget(props: Props) {
const [data] = createResource(() => props.roleKey, fetchApplicationsData);
const stats = () => {
const items = data() || [];
if (props.roleKey === 'COMPANY') {
const total = items.length;
const shortlisted = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'SHORTLISTED'
).length;
const underReview = items.filter(
(a: any) =>
!['SHORTLISTED', 'REJECTED', 'HIRED'].includes(String(a.status || '').toUpperCase())
).length;
const rejected = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'REJECTED'
).length;
return { total, shortlisted, underReview, rejected };
}
if (props.roleKey === 'JOB_SEEKER') {
const total = items.length;
const shortlisted = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'SHORTLISTED'
).length;
const hired = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'HIRED'
).length;
const offered = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'OFFERED'
).length;
return { total, shortlisted, hired, offered };
}
return null;
};
return (
<DashboardWidget
title={props.roleKey === 'COMPANY' ? 'Applications Received' : 'My Applications'}
loading={data.loading}
error={data.error ? 'Failed to load' : undefined}
icon={<FileText size={16} />}
>
<Show when={stats() && props.roleKey === 'COMPANY'}>
<For each={[
['Total', stats()!.total, '#374151'],
['Shortlisted', stats()!.shortlisted, '#059669'],
['Under Review', stats()!.underReview, '#D97706'],
['Rejected', stats()!.rejected, '#6B7280'],
]}>
{([label, value, color]) => <AppStatRow label={label} value={value} color={color} />}
</For>
</Show>
<Show when={stats() && props.roleKey === 'JOB_SEEKER'}>
<For each={[
['Total Applied', stats()!.total, '#374151'],
['Shortlisted', stats()!.shortlisted, '#059669'],
['Hired', stats()!.hired, '#059669'],
['Offered', stats()!.offered, '#7C3AED'],
]}>
{([label, value, color]) => <AppStatRow label={label} value={value} color={color} />}
</For>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,84 @@
import { JSX, Show } from 'solid-js';
import { GripVertical } from 'lucide-solid';
import { CARD } from '~/components/DashboardShell';
export type WidgetSize = 'small' | 'large';
type Props = {
title: string;
loading?: boolean;
error?: string;
action?: JSX.Element;
size?: WidgetSize;
icon?: JSX.Element;
children: JSX.Element;
};
export default function DashboardWidget(props: Props) {
return (
<div style={{ ...CARD, padding: '0', overflow: 'hidden', display: 'flex', 'flex-direction': 'column', 'min-height': '180px', height: '180px' }}>
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '12px 14px',
'border-bottom': '1px solid #F3F4F6',
'flex-shrink': 0,
}}
>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
<span style={{ color: '#FF5E13', 'flex-shrink': 0, cursor: 'grab' }}>
<GripVertical size={14} />
</span>
<Show when={props.icon}>
<span style={{ color: '#FF5E13', 'flex-shrink': 0 }}>{props.icon}</span>
</Show>
<p
style={{
margin: '0',
'font-size': '13px',
'font-weight': '700',
color: '#374151',
}}
>
{props.title}
</p>
</div>
<Show when={props.action}>{props.action}</Show>
</div>
<div style={{ padding: props.size === 'large' ? '16px 14px' : '12px 14px', flex: 1, display: 'flex', 'flex-direction': 'column', 'justify-content': 'center' }}>
<Show when={props.loading}>
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
height: '80px',
}}
>
<span style={{ 'font-size': '12px', color: '#9CA3AF' }}>Loading...</span>
</div>
</Show>
<Show when={!props.loading && props.error}>
<div
style={{
padding: '8px 10px',
background: '#FEF2F2',
border: '1px solid #FECACA',
'border-radius': '8px',
'font-size': '12px',
color: '#B91C1C',
}}
>
{props.error}
</div>
</Show>
<Show when={!props.loading && !props.error}>{props.children}</Show>
</div>
</div>
);
}

View file

@ -0,0 +1,131 @@
import { createResource, For, Show } from 'solid-js';
import { Briefcase } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchJobsData(roleKey: RoleKey) {
if (roleKey === 'COMPANY') {
const res = await apiFetch('/api/companies/jobs?page=1&limit=100');
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
if (roleKey === 'JOB_SEEKER') {
const res = await apiFetch('/api/jobseeker/jobs?page=1&limit=100');
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
return null;
}
function JobStatRow(props: { label: string; value: number | string; color?: string }) {
return (
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '6px 0',
'border-bottom': '1px solid #F9FAFB',
}}
>
<span style={{ 'font-size': '12px', color: '#6B7280' }}>{props.label}</span>
<span
style={{
'font-size': '15px',
'font-weight': '800',
color: props.color || '#111827',
}}
>
{props.value}
</span>
</div>
);
}
export default function JobsWidget(props: Props) {
const [data, { refetch }] = createResource(() => props.roleKey, fetchJobsData);
const stats = () => {
const items = data() || [];
if (props.roleKey === 'COMPANY') {
const total = items.length;
const active = items.filter(
(j: any) => String(j.status || '').toUpperCase() === 'OPEN'
).length;
const pending = items.filter((j: any) =>
String(j.status || '').toUpperCase().includes('PENDING')
).length;
const closed = items.filter(
(j: any) => String(j.status || '').toUpperCase() === 'CLOSED'
).length;
return { total, active, pending, closed };
}
if (props.roleKey === 'JOB_SEEKER') {
const total = items.length;
const active = items.filter(
(j: any) => String(j.status || '').toUpperCase() === 'LIVE'
).length;
const expiring = items.filter((j: any) => {
const exp = j?.expires_at || j?.expiry_date;
if (!exp) return false;
return new Date(exp).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000;
}).length;
return { total, active, expiring };
}
return null;
};
return (
<DashboardWidget
title={props.roleKey === 'COMPANY' ? 'My Job Posts' : 'Available Jobs'}
loading={data.loading}
error={data.error ? 'Failed to load' : undefined}
icon={<Briefcase size={16} />}
>
<Show when={stats() && props.roleKey === 'COMPANY'}>
<For each={[
['Total Jobs', stats()!.total, '#374151'],
['Active', stats()!.active, '#059669'],
['Pending Approval', stats()!.pending, '#D97706'],
['Closed', stats()!.closed, '#6B7280'],
]}>
{([label, value, color]) => <JobStatRow label={label} value={value} color={color} />}
</For>
</Show>
<Show when={stats() && props.roleKey === 'JOB_SEEKER'}>
<For each={[
['Total Jobs', stats()!.total, '#374151'],
['Live Now', stats()!.active, '#059669'],
['Expiring Soon', stats()!.expiring, '#D97706'],
]}>
{([label, value, color]) => <JobStatRow label={label} value={value} color={color} />}
</For>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,132 @@
import { createResource, For, Show } from 'solid-js';
import { MapPin } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES } from '../RoleDashboardShared';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchLeadsData(roleKey: RoleKey) {
if (PROFESSIONAL_ROLE_SET.has(roleKey)) {
const prefix = ROLE_PREFIXES[roleKey];
const res = await apiFetch(`/api/${prefix}/leads/requests/me?page=1&limit=100`);
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
if (roleKey === 'CUSTOMER') {
const res = await apiFetch(`/api/customers/requirements?page=1&limit=100`);
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
return null;
}
function LeadStatRow(props: { label: string; value: number | string; color?: string }) {
return (
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '6px 0',
'border-bottom': '1px solid #F9FAFB',
}}
>
<span style={{ 'font-size': '12px', color: '#6B7280' }}>{props.label}</span>
<span
style={{
'font-size': '15px',
'font-weight': '800',
color: props.color || '#111827',
}}
>
{props.value}
</span>
</div>
);
}
export default function LeadsWidget(props: Props) {
const [data, { refetch }] = createResource(() => props.roleKey, fetchLeadsData);
const isProfessional = () => PROFESSIONAL_ROLE_SET.has(props.roleKey);
const isCustomer = () => props.roleKey === 'CUSTOMER';
const stats = () => {
const items = data() || [];
if (isProfessional()) {
const pending = items.filter(
(r: any) => String(r.status || '').toUpperCase() === 'PENDING'
).length;
const accepted = items.filter((r: any) =>
['APPROVED', 'CONTACT_UNLOCKED'].includes(String(r.status || '').toUpperCase())
).length;
const rejected = items.filter(
(r: any) => String(r.status || '').toUpperCase() === 'REJECTED'
).length;
return { total: items.length, pending, accepted, rejected };
}
if (isCustomer()) {
const total = items.length;
const open = items.filter(
(r: any) => String(r.status || '').toUpperCase() === 'OPEN'
).length;
const closed = items.filter(
(r: any) => String(r.status || '').toUpperCase() === 'CLOSED'
).length;
const pending = items.filter((r: any) =>
String(r.status || '').toUpperCase().includes('PENDING')
).length;
return { total, open, closed, pending };
}
return null;
};
return (
<DashboardWidget
title={isProfessional() ? 'My Lead Requests' : 'My Requirements'}
loading={data.loading}
error={data.error ? 'Failed to load' : undefined}
icon={<MapPin size={16} />}
>
<Show when={stats()}>
<For each={isProfessional() ? [
['Total Requests', stats()!.total, '#374151'],
['Pending', stats()!.pending, '#D97706'],
['Accepted', stats()!.accepted, '#059669'],
['Rejected', stats()!.rejected, '#DC2626'],
] : [
['Total Requirements', stats()!.total, '#374151'],
['Open', stats()!.open, '#059669'],
['In Verification', stats()!.pending, '#D97706'],
['Closed', stats()!.closed, '#6B7280'],
]}>
{([label, value, color]) => <LeadStatRow label={label} value={value} color={color} />}
</For>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,72 @@
import { createResource, For, Show } from 'solid-js';
import { FolderOpen } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES } from '../RoleDashboardShared';
import { fetchPortfolio } from '~/lib/api';
type Props = {
roleKey: RoleKey;
};
async function fetchPortfolioData(roleKey: RoleKey) {
if (PROFESSIONAL_ROLE_SET.has(roleKey)) {
const prefix = ROLE_PREFIXES[roleKey];
if (!prefix) return null;
const data = await fetchPortfolio(prefix);
return { items: Array.isArray(data) ? data : (data?.data || []), total: Array.isArray(data) ? data.length : (data?.data?.length || 0) };
}
return null;
}
function PortfolioStatRow(props: { label: string; value: number | string; color?: string }) {
return (
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '6px 0',
'border-bottom': '1px solid #F9FAFB',
}}
>
<span style={{ 'font-size': '12px', color: '#6B7280' }}>{props.label}</span>
<span
style={{
'font-size': '15px',
'font-weight': '800',
color: props.color || '#111827',
}}
>
{props.value}
</span>
</div>
);
}
export default function PortfolioWidget(props: Props) {
const [data] = createResource(() => props.roleKey, fetchPortfolioData);
const total = () => {
const d = data();
if (!d) return 0;
return typeof d.total === 'number' ? d.total : (Array.isArray(d.items) ? d.items.length : 0);
};
return (
<DashboardWidget
title="Portfolio"
loading={data.loading}
error={data.error ? 'Failed to load' : undefined}
icon={<FolderOpen size={16} />}
>
<Show when={!data.loading && !data.error}>
<For each={[
['Portfolio Items', total(), '#374151'],
]}>
{([label, value, color]) => <PortfolioStatRow label={label} value={value} color={color} />}
</For>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,135 @@
import { createResource, Show } from 'solid-js';
import { User } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES } from '../RoleDashboardShared';
import { fetchProfile } from '~/lib/api';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchProfileData(roleKey: RoleKey) {
if (PROFESSIONAL_ROLE_SET.has(roleKey) || roleKey === 'COMPANY' || roleKey === 'CUSTOMER') {
const prefix = ROLE_PREFIXES[roleKey];
if (!prefix) return null;
try {
return await fetchProfile(prefix);
} catch {
return null;
}
}
if (roleKey === 'JOB_SEEKER') {
const res = await apiFetch('/api/jobseeker/profile/me');
if (!res.ok) return null;
return await res.json();
}
return null;
}
const PROFILE_FIELDS: Record<string, string[]> = {
COMPANY: ['company_name', 'industry', 'description', 'website', 'contact_email'],
CUSTOMER: ['full_name', 'location', 'phone', 'email'],
JOB_SEEKER: ['full_name', 'location', 'summary', 'skills', 'experience_years'],
PROFESSIONAL: ['full_name', 'headline', 'bio', 'location', 'skills'],
};
export default function ProfileCompletionWidget(props: Props) {
const [profile] = createResource(() => props.roleKey, fetchProfileData);
const completion = () => {
const p = profile();
if (!p) return { filled: 0, total: 5, pct: 0 };
const fields = PROFILE_FIELDS[props.roleKey] || PROFILE_FIELDS['PROFESSIONAL'];
let filled = 0;
for (const field of fields) {
const val = (p as any)[field];
if (val !== null && val !== undefined && String(val).trim() !== '') filled++;
}
const total = fields.length;
const pct = total > 0 ? Math.round((filled / total) * 100) : 0;
return { filled, total, pct };
};
const barColor = () => {
const pct = completion().pct;
if (pct >= 80) return '#059669';
if (pct >= 50) return '#D97706';
return '#DC2626';
};
return (
<DashboardWidget
title="Profile Completion"
loading={profile.loading}
error={profile.error ? 'Failed to load' : undefined}
icon={<User size={16} />}
>
<Show when={!profile.loading && !profile.error}>
<div style={{ 'margin-bottom': '8px' }}>
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
'margin-bottom': '6px',
}}
>
<span style={{ 'font-size': '12px', color: '#6B7280' }}>
{completion().filled} of {completion().total} fields
</span>
<span
style={{
'font-size': '15px',
'font-weight': '800',
color: barColor(),
}}
>
{completion().pct}%
</span>
</div>
<div
style={{
height: '8px',
background: '#E5E7EB',
'border-radius': '4px',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${completion().pct}%`,
background: barColor(),
'border-radius': '4px',
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
<p style={{ margin: '0', 'font-size': '11px', color: '#9CA3AF' }}>
Complete your profile to build trust with {props.roleKey === 'JOB_SEEKER' ? 'employers' : 'clients'}
</p>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,101 @@
import { createResource, For, Show } from 'solid-js';
import { ClipboardList } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchRequirementsData(_roleKey: RoleKey) {
const res = await apiFetch('/api/customers/requirements?page=1&limit=100');
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
function ReqStatRow(props: { label: string; value: number | string; color?: string }) {
return (
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '6px 0',
'border-bottom': '1px solid #F9FAFB',
}}
>
<span style={{ 'font-size': '12px', color: '#6B7280' }}>{props.label}</span>
<span
style={{
'font-size': '15px',
'font-weight': '800',
color: props.color || '#111827',
}}
>
{props.value}
</span>
</div>
);
}
export default function RequirementsWidget(props: Props) {
const [data] = createResource(() => props.roleKey, fetchRequirementsData);
const stats = () => {
const items = data() || [];
const total = items.length;
const open = items.filter(
(r: any) => String(r.status || '').toUpperCase() === 'OPEN'
).length;
const pending = items.filter((r: any) =>
String(r.status || '').toUpperCase().includes('PENDING')
).length;
const closed = items.filter(
(r: any) => String(r.status || '').toUpperCase() === 'CLOSED'
).length;
const draft = items.filter(
(r: any) => String(r.status || '').toUpperCase() === 'DRAFT'
).length;
return { total, open, pending, closed, draft };
};
return (
<DashboardWidget
title="My Requirements"
loading={data.loading}
error={data.error ? 'Failed to load' : undefined}
icon={<ClipboardList size={16} />}
>
<Show when={stats()}>
<For each={[
['Total Requirements', stats()!.total, '#374151'],
['Open', stats()!.open, '#059669'],
['In Verification', stats()!.pending, '#D97706'],
['Closed', stats()!.closed, '#6B7280'],
]}>
{([label, value, color]) => <ReqStatRow label={label} value={value} color={color} />}
</For>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,127 @@
import { createResource, For, Show } from 'solid-js';
import { Star } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchShortlistedData(roleKey: RoleKey) {
if (roleKey === 'COMPANY') {
const res = await apiFetch('/api/companies/applications?page=1&limit=100');
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
if (roleKey === 'CUSTOMER') {
const res = await apiFetch('/api/customers/requirements?page=1&limit=100');
if (!res.ok) return null;
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
}
return null;
}
function ShortlistStatRow(props: { label: string; value: number | string; color?: string }) {
return (
<div
style={{
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '6px 0',
'border-bottom': '1px solid #F9FAFB',
}}
>
<span style={{ 'font-size': '12px', color: '#6B7280' }}>{props.label}</span>
<span
style={{
'font-size': '15px',
'font-weight': '800',
color: props.color || '#111827',
}}
>
{props.value}
</span>
</div>
);
}
export default function ShortlistedWidget(props: Props) {
const [data] = createResource(() => props.roleKey, fetchShortlistedData);
const stats = () => {
const items = data() || [];
if (props.roleKey === 'COMPANY') {
const shortlisted = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'SHORTLISTED'
).length;
const interview = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'INTERVIEW_SCHEDULED'
).length;
const offer = items.filter(
(a: any) => String(a.status || '').toUpperCase() === 'OFFER_EXTENDED'
).length;
return { shortlisted, interview, offer };
}
if (props.roleKey === 'CUSTOMER') {
const shortlisted = items.reduce(
(sum: number, r: any) => sum + (Number(r.shortlisted_count) || 0),
0
);
const newResponses = items.reduce(
(sum: number, r: any) => sum + (Number(r.new_responses_count) || 0),
0
);
return { shortlisted, newResponses };
}
return null;
};
return (
<DashboardWidget
title={props.roleKey === 'COMPANY' ? 'Shortlisted Candidates' : 'Shortlisted Responses'}
loading={data.loading}
error={data.error ? 'Failed to load' : undefined}
icon={<Star size={16} />}
>
<Show when={stats() && props.roleKey === 'COMPANY'}>
<For each={[
['Shortlisted', stats()!.shortlisted, '#059669'],
['Interview Scheduled', stats()!.interview, '#7C3AED'],
['Offer Extended', stats()!.offer, '#D97706'],
]}>
{([label, value, color]) => <ShortlistStatRow label={label} value={value} color={color} />}
</For>
</Show>
<Show when={stats() && props.roleKey === 'CUSTOMER'}>
<For each={[
['Total Shortlisted', stats()!.shortlisted, '#059669'],
['New Responses', stats()!.newResponses, '#7C3AED'],
]}>
{([label, value, color]) => <ShortlistStatRow label={label} value={value} color={color} />}
</For>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,104 @@
import { createResource, Show } from 'solid-js';
import { ShieldCheck } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchVerificationStatus(roleKey: RoleKey) {
try {
const res = await apiFetch(`/api/me/verification-status?roleKey=${encodeURIComponent(roleKey)}`);
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
const STATUS_COLORS: Record<string, string> = {
VERIFIED: '#059669',
PENDING: '#D97706',
NOT_SUBMITTED: '#6B7280',
REJECTED: '#DC2626',
UNDER_REVIEW: '#7C3AED',
};
export default function VerificationWidget(props: Props) {
const [status] = createResource(() => props.roleKey, fetchVerificationStatus);
const statusLabel = () => {
const s = status();
if (!s) return 'Unknown';
return String(s.status || s.verification_status || 'NOT_SUBMITTED').replace(/_/g, ' ');
};
const statusColor = () => {
const s = status();
const key = String(s?.status || s?.verification_status || 'NOT_SUBMITTED').toUpperCase();
return STATUS_COLORS[key] || '#6B7280';
};
return (
<DashboardWidget
title="Verification Status"
loading={status.loading}
error={status.error ? 'Failed to load' : undefined}
icon={<ShieldCheck size={16} />}
>
<Show when={!status.loading && !status.error}>
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '10px',
}}
>
<div
style={{
width: '12px',
height: '12px',
'border-radius': '50%',
background: statusColor(),
'flex-shrink': 0,
}}
/>
<span
style={{
'font-size': '14px',
'font-weight': '700',
color: statusColor(),
}}
>
{statusLabel()}
</span>
</div>
<Show when={status()?.document_request}>
<p style={{ margin: '8px 0 0', 'font-size': '11px', color: '#9CA3AF' }}>
Documents requested: {status()?.document_request}
</p>
</Show>
</Show>
</DashboardWidget>
);
}

View file

@ -0,0 +1,104 @@
import { createResource } from 'solid-js';
import { Coins } from 'lucide-solid';
import DashboardWidget from './DashboardWidget';
import type { RoleKey } from '../RoleDashboardShared';
import { ROLE_PREFIXES } from '../RoleDashboardShared';
const API = '/api/gateway';
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== 'undefined'
? window.sessionStorage.getItem('nxtgauge_access_token') || ''
: '';
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
type Props = {
roleKey: RoleKey;
};
async function fetchWallet(roleKey: RoleKey): Promise<{ balance: number; reserved: number } | null> {
const prefix = ROLE_PREFIXES[roleKey];
if (!prefix) return null;
try {
const res = await apiFetch(`/api/${prefix}/wallet/me`);
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default function WalletWidget(props: Props) {
const [wallet] = createResource(() => props.roleKey, fetchWallet);
const isCredits = () =>
props.roleKey === 'COMPANY' || props.roleKey === 'CUSTOMER' || props.roleKey === 'JOB_SEEKER';
return (
<DashboardWidget
title={isCredits() ? 'Credits' : 'Tracecoins'}
loading={wallet.loading}
error={wallet.error ? 'Failed to load' : undefined}
icon={<Coins size={16} />}
>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '10px' }}>
<div>
<p
style={{
margin: '0',
'font-size': '10px',
'text-transform': 'uppercase',
'letter-spacing': '0.05em',
color: '#6B7280',
}}
>
Available
</p>
<p
style={{
margin: '4px 0 0',
'font-size': '22px',
'font-weight': '800',
color: '#111827',
}}
>
{wallet()?.balance ?? '—'}
</p>
</div>
<div>
<p
style={{
margin: '0',
'font-size': '10px',
'text-transform': 'uppercase',
'letter-spacing': '0.05em',
color: '#6B7280',
}}
>
Reserved
</p>
<p
style={{
margin: '4px 0 0',
'font-size': '22px',
'font-weight': '800',
color: '#111827',
}}
>
{wallet()?.reserved ?? '—'}
</p>
</div>
</div>
</DashboardWidget>
);
}

View file

@ -12,7 +12,8 @@ function getAuthHeaders(): Record<string, string> {
}
async function apiFetch(path: string, options?: RequestInit): Promise<any> {
const res = await fetch(`${API}${path}`, {
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
const res = await fetch(`${API}${cleanPath}`, {
headers: getAuthHeaders(),
credentials: 'include',
...options,
@ -46,7 +47,8 @@ async function request<T = any>(
if (options?.body !== undefined) {
init.body = JSON.stringify(options.body);
}
const res = await fetch(`${API}${path}`, init);
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
const res = await fetch(`${API}${cleanPath}`, init);
const raw = await res.text();
const data = raw ? JSON.parse(raw) : null;
if (!res.ok) {
@ -87,7 +89,7 @@ export async function submitProfileForVerification(rolePrefix: string): Promise<
}
export async function fetchPortfolio(rolePrefix: string): Promise<any> {
return apiFetch(`/api/${rolePrefix}/portfolio`);
return apiFetch(`/api/${rolePrefix}/portfolio/me`);
}
export async function createPortfolioItem(rolePrefix: string, payload: any): Promise<any> {

View file

@ -82,7 +82,7 @@ async function fetchSession(): Promise<AuthUser | null> {
const token = getToken();
if (!token) return null;
try {
const res = await fetch(`${API}/api/auth/session`, {
const res = await fetch("/api/auth/session", {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
@ -107,26 +107,30 @@ async function fetchSession(): Promise<AuthUser | null> {
export function AuthProvider(props: ParentProps) {
const [user, setUser] = createSignal<AuthUser | null>(null);
const [session, { refetch: refetchSession }] = createResource(fetchSession);
const isLoading = () => session.loading;
const isAuthenticated = () => !!user() || !!getToken() || (!!session() && !session.error);
createEffect(() => {
const sess = session();
if (sess) {
setUser(sess);
}
});
const [sessionLoading, setSessionLoading] = createSignal(true);
const refreshUser = (userData?: AuthUser | null) => {
if (userData) {
setUser(userData);
} else {
refetchSession();
setSessionLoading(true);
fetchSession().then(data => {
setUser(data);
setSessionLoading(false);
});
}
};
onMount(() => {
fetchSession().then(data => {
setUser(data);
setSessionLoading(false);
});
});
const isLoading = () => sessionLoading();
const isAuthenticated = () => !!user() || !!getToken();
const logout = () => {
clearAuthStorage();
setUser(null);
@ -148,24 +152,32 @@ export function useAuth() {
return ctx;
}
const [authClientReady, setAuthClientReady] = createSignal(false);
export function RequireAuth(props: ParentProps) {
const navigate = useNavigate();
const auth = useAuth();
onMount(() => {
setAuthClientReady(true);
});
const [ready, setReady] = createSignal(false);
createEffect(() => {
if (authClientReady() && !getToken()) {
navigate('/login', { replace: true });
if (ready()) {
if (!getToken()) {
navigate('/login', { replace: true });
}
}
});
onMount(() => {
setReady(true);
});
return (
<Show when={authClientReady() && !!getToken()} fallback={null}>
<Show when={ready()} fallback={
<div style={{ "min-height": "100vh", display: "flex", "align-items": "center", "justify-content": "center", background: "#F3F4F6", "font-family": "Inter, system-ui, sans-serif" }}>
<div style={{ "text-align": "center" }}>
<div style={{ width: "40px", height: "40px", border: "3px solid #FF5E13", "border-top-color": "transparent", "border-radius": "50%", animation: "spin 0.8s linear infinite", margin: "0 auto 12px" }} />
<p style={{ color: "#6B7280", "font-size": "14px", margin: "0" }}>Loading...</p>
</div>
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
</div>
}>
{props.children}
</Show>
);

View file

@ -15,7 +15,8 @@ export type JobSeekerProfile = {
};
async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, {
const cleanPath = path.startsWith('/api/') ? path.slice(4) : path;
return fetch(`${API}${cleanPath}`, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },

View file

@ -12,6 +12,7 @@ import { useNavigate } from "@solidjs/router";
import { useAuth, RequireAuth, getToken } from "~/lib/auth";
import DashboardDesignPreview from "~/components/admin/DashboardDesignPreview";
import DashboardShell from "~/components/DashboardShell";
import AdminDashboardPage from "~/components/dashboard/AdminDashboardPage";
import ProfilePage from "~/components/dashboard/ProfilePage";
import PortfolioPage from "~/components/dashboard/PortfolioPage";
import VerificationStatusPage from "~/components/dashboard/VerificationStatusPage";
@ -72,8 +73,11 @@ type RuntimeBundle = {
widgets: string[];
fields: string[];
verificationStatus?: string;
userRoles?: string[];
source: "dashboard-config";
renderMode?: "preview";
audience?: "INTERNAL" | "EXTERNAL";
dashboardConfig?: Record<string, any>;
};
const API_GATEWAY = "/api/gateway";
@ -110,7 +114,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
MAKEUP_ARTIST: [
@ -124,7 +127,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
TUTOR: [
@ -138,7 +140,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
DEVELOPER: [
@ -152,7 +153,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
VIDEO_EDITOR: [
@ -166,7 +166,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
UGC_CONTENT_CREATOR: [
@ -180,7 +179,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
GRAPHIC_DESIGNER: [
@ -194,7 +192,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
SOCIAL_MEDIA_MANAGER: [
@ -208,7 +205,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
FITNESS_TRAINER: [
@ -222,7 +218,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
CATERING_SERVICES: [
@ -236,7 +231,6 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
COMPANY: [
@ -245,26 +239,10 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Jobs",
"Applications",
"Shortlisted Candidates",
"Credits",
"Explore Nxtgauge",
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
JOB_SEEKER: [
"My Dashboard",
"My Profile",
"My Portfolio",
"Jobs",
"My Applications",
"Saved Jobs",
"Explore Nxtgauge",
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
CUSTOMER: [
@ -278,7 +256,18 @@ const ROLE_BASED_SIDEBAR: Record<RoleKey, string[]> = {
"Verification",
"Help Center",
"Settings",
"Switch Services",
"Logout",
],
JOB_SEEKER: [
"My Dashboard",
"My Profile",
"Jobs",
"My Applications",
"Saved Jobs",
"Explore Nxtgauge",
"Verification",
"Help Center",
"Settings",
"Logout",
],
};
@ -358,7 +347,39 @@ function normalizeRole(value: string): RoleKey {
}
function normalizeSidebarKey(value: string): string {
const SIDEBAR_KEY_MAP: Record<string, string> = {
dashboard: 'my dashboard',
profile: 'my profile',
portfolio: 'my portfolio',
leads: 'leads',
my_responses: 'my responses',
responses: 'my responses',
credits: 'credits',
explore_nxtgauge: 'explore nxtgauge',
explore: 'explore nxtgauge',
verification: 'verification',
verification_status: 'verification',
help_center: 'help center',
support: 'help center',
help: 'help center',
settings: 'settings',
switch_services: 'switch services',
switch_service: 'switch services',
logout: 'logout',
jobs: 'jobs',
job_postings: 'jobs',
applications: 'applications',
my_applications: 'my applications',
shortlisted_candidates: 'shortlisted candidates',
my_requirements: 'my requirements',
requirements: 'my requirements',
received_responses: 'received responses',
shortlisted_responses: 'shortlisted responses',
saved_jobs: 'saved jobs',
};
const key = String(value || "").trim().toLowerCase();
const mapped = SIDEBAR_KEY_MAP[key];
if (mapped) return mapped;
if (!key) return "my dashboard";
if (key === "my dashboard" || key === "dashboard") return "my dashboard";
if (key === "my profile" || key === "profile") return "my profile";
@ -383,6 +404,11 @@ function normalizeSidebarKey(value: string): string {
return key;
}
function resolveRuntimeSidebarKeys(runtimeSidebar: string[]): string[] {
if (!runtimeSidebar || runtimeSidebar.length === 0) return [];
return runtimeSidebar.map(item => normalizeSidebarKey(item));
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item || "").trim()).filter(Boolean);
@ -440,13 +466,17 @@ function resolveRoleForDashboard(rawRole: string | null | undefined): RoleKey {
async function fetchJson(path: string, includeAuth = false): Promise<any | null> {
try {
const isServer = typeof window === "undefined";
const target = isServer ? `${SERVER_API_BASE}${path}` : `${API_GATEWAY}${path}`;
const headers: Record<string, string> = { credentials: "include" } as any;
const cleanPath = path.startsWith("/api/") ? path.slice(4) : path;
const target = isServer ? `${SERVER_API_BASE}${path}` : `${API_GATEWAY}${cleanPath}`;
const headers: Record<string, string> = {};
if (includeAuth) {
const token = typeof getToken === "function" ? getToken() : null;
if (token) headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(target, headers);
const res = await fetch(target, {
headers,
credentials: "include",
});
if (!res.ok) return null;
return await res.json();
} catch {
@ -458,30 +488,40 @@ async function fetchJson(path: string, includeAuth = false): Promise<any | null>
if (typeof window === "undefined") {
return null;
}
const runtime = await fetchJson("/api/runtime-config", true);
const urlParams = new URLSearchParams(window.location.search);
const urlRole = urlParams.get("role");
const effectiveRole = urlRole ? normalizeRole(urlRole) : role;
const apiPath = effectiveRole && effectiveRole !== "JOB_SEEKER"
? `/api/runtime-config?role=${encodeURIComponent(effectiveRole)}`
: "/api/runtime-config";
const runtime = await fetchJson(apiPath, true);
if (!runtime) return null;
const runtimeRole = normalizeRole(String(runtime?.role || runtime?.user?.active_role || role));
const runtimeRenderMode = String(
runtime?.dashboard_config?.render_mode ?? runtime?.render_mode ?? ""
runtime?.dashboard_config?.render_mode ?? runtime?.dashboardConfig?.render_mode ?? runtime?.render_mode ?? ""
).toLowerCase();
const runtimeSidebar = asStringArray(
runtime?.dashboard_config?.sidebar_items ??
runtime?.dashboard_config?.sidebarItems ??
runtime?.dashboardConfig?.sidebar_items ??
runtime?.dashboardConfig?.sidebarItems ??
runtime?.sidebar_items ??
runtime?.sidebarItems
);
const runtimeTabs = asStringArray(runtime?.dashboard_config?.tabs ?? runtime?.tabs);
const runtimeTabs = asStringArray(runtime?.dashboard_config?.tabs ?? runtime?.dashboardConfig?.tabs ?? runtime?.tabs);
const runtimeWidgetsRaw = Array.isArray(runtime?.dashboard_config?.widgets)
? runtime.dashboard_config.widgets
: Array.isArray(runtime?.widgets)
? runtime.widgets
: [];
: Array.isArray(runtime?.dashboardConfig?.widgets)
? runtime.dashboardConfig.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);
const runtimeFields = asStringArray(runtime?.dashboard_config?.fields ?? runtime?.dashboardConfig?.fields ?? runtime?.fields);
return {
role: runtimeRole,
@ -496,59 +536,30 @@ async function fetchJson(path: string, includeAuth = false): Promise<any | null>
).toUpperCase() || undefined,
source: "dashboard-config",
renderMode: runtimeRenderMode === "preview" ? "preview" : undefined,
audience: runtime?.audience === "INTERNAL" ? "INTERNAL" : "EXTERNAL",
dashboardConfig: runtime?.dashboard_config ?? runtime?.dashboardConfig ?? null,
};
}
function mergeSidebar(
role: RoleKey,
_role: RoleKey,
runtimeSidebar: string[],
userRoles: string[] = [],
verificationStatus?: string
): string[] {
const base = ROLE_BASED_SIDEBAR[role] || [
"My Dashboard",
"My Profile",
"Switch Services",
"Logout",
];
const fromRuntime = runtimeSidebar.filter(Boolean);
const base = ROLE_BASED_SIDEBAR[_role] || [];
const fromRuntime = runtimeSidebar.map((item) => String(item || "").trim()).filter(Boolean);
const source = fromRuntime.length ? fromRuntime : base;
const map = new Map<string, string>();
for (const item of [...fromRuntime, ...base]) {
const key = item.trim().toLowerCase();
for (const item of source) {
const key = normalizeSidebarKey(item);
if (!map.has(key)) map.set(key, item);
}
let merged = Array.from(map.values());
const persona: "PROFESSIONAL" | "COMPANY" | "JOB_SEEKER" | "CUSTOMER" =
role === "COMPANY"
? "COMPANY"
: role === "CUSTOMER"
? "CUSTOMER"
: role === "JOB_SEEKER"
? "JOB_SEEKER"
: "PROFESSIONAL";
if (persona === "JOB_SEEKER") {
merged = merged.filter((item) => normalizeSidebarKey(item) !== "credits");
}
const hasExplore = merged.some((item) => normalizeSidebarKey(item) === "explore nxtgauge");
if (!hasExplore) {
const verificationIdx = merged.findIndex(
(item) => normalizeSidebarKey(item) === "verification"
);
if (verificationIdx >= 0) merged.splice(verificationIdx, 0, "Explore Nxtgauge");
else merged.push("Explore Nxtgauge");
}
const hasPortfolio = merged.some((item) => normalizeSidebarKey(item) === "my portfolio");
if (persona === "PROFESSIONAL" || persona === "JOB_SEEKER") {
if (!hasPortfolio) {
const profileIdx = merged.findIndex((item) => normalizeSidebarKey(item) === "my profile");
if (profileIdx >= 0) merged.splice(profileIdx + 1, 0, "My Portfolio");
else merged.push("My Portfolio");
}
} else if (hasPortfolio) {
merged = merged.filter((item) => normalizeSidebarKey(item) !== "my portfolio");
// Runtime config remains source of truth; only hide switch-services when single-role.
if (userRoles.length <= 1) {
merged = merged.filter((item) => normalizeSidebarKey(item) !== "switch services");
}
const status = String(verificationStatus || "").toUpperCase();
@ -559,10 +570,9 @@ function mergeSidebar(
"help center",
"settings",
"verification",
"logout",
...(PROFESSIONAL_ROLE_SET.has(role) || role === "JOB_SEEKER"
? ["my portfolio", "credits"]
: []),
...(PROFESSIONAL_ROLE_SET.has(_role) || _role === "JOB_SEEKER"
? ["my portfolio", "credits"]
: []),
]);
merged = merged.filter((item) => restricted.has(item.trim().toLowerCase()));
}
@ -571,10 +581,6 @@ function mergeSidebar(
// FIX_APPLIED_V13_ROLE_FROM_URL_PROTECTION
export default function RuntimeDashboardPage() {
if (typeof window === 'undefined') {
return <div style={{ "min-height": "100vh" }} />;
}
const navigate = useNavigate();
const auth = useAuth();
const [hydrated, setHydrated] = createSignal(false);
@ -585,6 +591,7 @@ export default function RuntimeDashboardPage() {
const [userId, setUserId] = createSignal("");
const [urlRoleLocked, setUrlRoleLocked] = createSignal(false);
const [roleReconcileAttempted, setRoleReconcileAttempted] = createSignal(false);
const [verificationStatusOverride, setVerificationStatusOverride] = createSignal<string | undefined>(undefined);
onMount(() => {
setHydrated(true);
@ -709,8 +716,17 @@ export default function RuntimeDashboardPage() {
const [bundle] = createResource(() => role(), loadRoleBundle);
const activeSidebarKey = createMemo(() => normalizeSidebarKey(activeSidebar()));
createEffect(() => {
role();
setVerificationStatusOverride(undefined);
});
const effectiveVerificationStatus = createMemo(
() => verificationStatusOverride() ?? bundle()?.verificationStatus
);
const sidebarItems = createMemo(() =>
mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.verificationStatus)
mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.userRoles || [], effectiveVerificationStatus())
);
createEffect(() => {
@ -721,10 +737,12 @@ export default function RuntimeDashboardPage() {
});
createEffect(() => {
const first = sidebarItems()[0] || "My Dashboard";
const first = sidebarItems()[0] || "";
const current = activeSidebar();
const exists = sidebarItems().some((item) => item.toLowerCase() === current.toLowerCase());
if (!exists) setActiveSidebar(first);
const currentKey = current.toLowerCase();
const isDashboard = currentKey === "my dashboard";
const exists = sidebarItems().some((item) => item.toLowerCase() === currentKey);
if (!exists && !isDashboard && first) setActiveSidebar(first);
});
const tabs = createMemo(() => {
@ -749,9 +767,16 @@ export default function RuntimeDashboardPage() {
const forcePreviewFromConfig = createMemo(() => bundle()?.renderMode === "preview");
const isAdminAudience = createMemo(() => bundle()?.audience === "INTERNAL");
const isRealPage = createMemo(() => {
if (forcePreviewFromConfig()) return false;
const key = activeSidebarKey();
if (key === "my dashboard") {
if (isAdminAudience()) return true;
if (PROFESSIONAL_ROLE_SET.has(role())) return true;
if ((bundle()?.widgets?.length ?? 0) > 0) return false;
}
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;
@ -780,10 +805,24 @@ export default function RuntimeDashboardPage() {
>
<Switch>
<Match when={activeSidebarKey() === "my dashboard"}>
<MyDashboardPage roleKey={role()} userName={userName()} />
<Show when={isAdminAudience()} fallback={
<MyDashboardPage
roleKey={role()}
userName={userName()}
widgetKeys={bundle()?.widgets || []}
verificationStatus={effectiveVerificationStatus()}
onNavigate={setActiveSidebar}
/>
}>
<AdminDashboardPage />
</Show>
</Match>
<Match when={activeSidebarKey() === "my profile"}>
<ProfilePage roleKey={role()} />
<ProfilePage
roleKey={role()}
runtimeFields={bundle()?.fields || []}
onVerificationStatusChange={(status) => setVerificationStatusOverride(String(status || "").toUpperCase())}
/>
</Match>
<Match when={activeSidebarKey() === "my portfolio"}>
<PortfolioPage
@ -793,13 +832,17 @@ export default function RuntimeDashboardPage() {
/>
</Match>
<Match when={activeSidebarKey() === "verification"}>
<VerificationStatusPage roleKey={role()} onNavigate={setActiveSidebar} />
<VerificationStatusPage
roleKey={role()}
onNavigate={setActiveSidebar}
onVerificationStatusChange={(status) => setVerificationStatusOverride(String(status || "").toUpperCase())}
/>
</Match>
<Match when={activeSidebarKey() === "settings"}>
<SettingsPage />
</Match>
<Match when={activeSidebarKey() === "credits"}>
<CreditsPage roleKey={role()} />
<CreditsPage roleKey={role()} dashboardConfig={bundle()?.dashboardConfig} />
</Match>
<Match when={activeSidebarKey() === "explore nxtgauge"}>
<ExploreServicesPage />
@ -897,11 +940,11 @@ export default function RuntimeDashboardPage() {
onTabSelect={setActiveTab}
widgets={bundle()?.widgets || []}
fields={bundle()?.fields || []}
mode="customer_external"
roleKey={role()}
exploreRoles={EXPLORE_ROLES}
hidePreviewHeader
liveData={liveData()}
mode={isAdminAudience() ? "customer_external" : undefined}
/>
</Show>
</Show>

View file

@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import VerificationSubmissionGuide from "../components/dashboard/VerificationSubmissionGuide";
const meta = {
title: "Dashboard/Verification Submission Guide",
component: VerificationSubmissionGuide,
parameters: {
layout: "padded",
},
args: {
statusLabel: "Not Submitted",
statusColor: "#9CA3AF",
locked: false,
approved: false,
docRequest: null,
missingBasicLabels: ["Mobile Number", "City"],
missingDocLabels: ["Aadhar / Government ID"],
missingPortfolioLabels: ["About", "Services & pricing", "Showcase items"],
canSubmit: false,
submitting: false,
onSubmit: () => {},
onGoBasic: () => {},
onGoDocuments: () => {},
},
} satisfies Meta<typeof VerificationSubmissionGuide>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NeedsInput: Story = {};
export const ReadyToSubmit: Story = {
args: {
statusLabel: "Not Submitted",
statusColor: "#9CA3AF",
missingBasicLabels: [],
missingDocLabels: [],
missingPortfolioLabels: [],
canSubmit: true,
},
};
export const DocumentsRequested: Story = {
args: {
statusLabel: "Documents Requested",
statusColor: "#FF5E13",
docRequest: "Upload a clearer registration certificate with complete company name visible.",
missingBasicLabels: [],
missingDocLabels: ["Company Registration Certificate"],
missingPortfolioLabels: [],
canSubmit: false,
},
};
export const PendingReview: Story = {
args: {
statusLabel: "Pending Review",
statusColor: "#F59E0B",
locked: true,
missingBasicLabels: [],
missingDocLabels: [],
missingPortfolioLabels: [],
canSubmit: false,
},
};

Binary file not shown.

View file

@ -0,0 +1,304 @@
import { expect, test } from "@playwright/test";
type SignupScenario = {
name: string;
signupUrl: string;
dashboardRole: string;
};
const portfolioPrefixByRole: Record<string, string> = {
PHOTOGRAPHER: "photographers",
MAKEUP_ARTIST: "makeup-artists",
TUTOR: "tutors",
DEVELOPER: "developers",
VIDEO_EDITOR: "video-editors",
UGC_CONTENT_CREATOR: "ugc-content-creators",
GRAPHIC_DESIGNER: "graphic-designers",
SOCIAL_MEDIA_MANAGER: "social-media-managers",
FITNESS_TRAINER: "fitness-trainers",
CATERING_SERVICES: "catering-services",
};
const scenarios: SignupScenario[] = [
{ name: "company", signupUrl: "/signup?intent=company", dashboardRole: "COMPANY" },
{ name: "customer", signupUrl: "/signup?intent=customer", dashboardRole: "CUSTOMER" },
{ name: "job seeker", signupUrl: "/signup?intent=job_seeker", dashboardRole: "JOB_SEEKER" },
{ name: "photographer", signupUrl: "/signup?intent=professional&role=photographer", dashboardRole: "PHOTOGRAPHER" },
{ name: "makeup artist", signupUrl: "/signup?intent=professional&role=makeup_artist", dashboardRole: "MAKEUP_ARTIST" },
{ name: "tutor", signupUrl: "/signup?intent=professional&role=tutor", dashboardRole: "TUTOR" },
{ name: "developer", signupUrl: "/signup?intent=professional&role=developer", dashboardRole: "DEVELOPER" },
{ name: "video editor", signupUrl: "/signup?intent=professional&role=video_editor", dashboardRole: "VIDEO_EDITOR" },
{ name: "ugc content creator", signupUrl: "/signup?intent=professional&role=ugc_content_creator", dashboardRole: "UGC_CONTENT_CREATOR" },
{ name: "graphic designer", signupUrl: "/signup?intent=professional&role=graphic_designer", dashboardRole: "GRAPHIC_DESIGNER" },
{ name: "social media manager", signupUrl: "/signup?intent=professional&role=social_media_manager", dashboardRole: "SOCIAL_MEDIA_MANAGER" },
{ name: "fitness trainer", signupUrl: "/signup?intent=professional&role=fitness_trainer", dashboardRole: "FITNESS_TRAINER" },
{ name: "catering services", signupUrl: "/signup?intent=professional&role=catering_services", dashboardRole: "CATERING_SERVICES" },
];
const profileSeedComplete: Record<string, string> = {
first_name: "Ari",
last_name: "Tester",
email: "ari.tester@nxtgauge.test",
phone: "9999999999",
location: "Chennai",
city: "Chennai",
area: "Anna Nagar",
state: "Tamil Nadu",
address_line_1: "No 12, Lake View",
company_name: "Nxtgauge QA Labs",
business_name: "Nxtgauge Catering",
owner_name: "Ari Tester",
company_email: "ops@nxtgauge.test",
registration_doc: "company-registration.pdf",
aadhar_doc: "id-proof.pdf",
address_proof: "address-proof.pdf",
portfolio_ownership_proof: "portfolio-proof.pdf",
qualification_proof: "qualification-proof.pdf",
professional_certifications: "certifications.pdf",
certification_doc: "trainer-certification.pdf",
fssai_license: "fssai.pdf",
gst_doc: "gst-certificate.pdf",
};
const requiredDocKeyByRole: Record<string, string> = {
COMPANY: "registration_doc",
PHOTOGRAPHER: "aadhar_doc",
MAKEUP_ARTIST: "aadhar_doc",
TUTOR: "aadhar_doc",
FITNESS_TRAINER: "aadhar_doc",
CATERING_SERVICES: "aadhar_doc",
CUSTOMER: "aadhar_doc",
JOB_SEEKER: "aadhar_doc",
};
function missingDocProfile(role: string): Record<string, string> {
const next = { ...profileSeedComplete };
const key = requiredDocKeyByRole[role] || "aadhar_doc";
delete next[key];
return next;
}
async function wireApiMock(
page: any,
scenario: SignupScenario,
unique: number,
profileData: Record<string, string>,
submitRef: { count: number },
portfolioReady: boolean
) {
const portfolioPrefix = portfolioPrefixByRole[scenario.dashboardRole];
await page.route("**/api/**", async (route: any) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path.endsWith("/api/auth/check-email")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ exists: false }) });
return;
}
if (path.endsWith("/api/auth/register")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, user: { id: `${scenario.dashboardRole}-${unique}` } }),
});
return;
}
if (path.endsWith("/api/auth/verify-email")) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }) });
return;
}
if (path.includes("/runtime-config")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
role: scenario.dashboardRole,
dashboard_config: {
sidebar_items: ["My Dashboard", "My Profile", "Verification Status", "Help Center"],
},
}),
});
return;
}
if (path.includes("/api/me/verification-status")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "NOT_SUBMITTED", document_request: null }),
});
return;
}
if (path.includes("/api/profile/submit-for-verification")) {
submitRef.count += 1;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return;
}
if (path.includes("/api/profile") && route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ profile_data: profileData }),
});
return;
}
if (path.includes("/api/profile") && route.request().method() === "PATCH") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return;
}
if (path.includes("/api/jobseeker/profile/me") && route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
custom_data: {
job_seeker_portfolio: portfolioReady
? {
headline: "Frontend Engineer",
summary: "Builds product-grade web apps",
education: "B.Tech CSE",
workExperience: "3 years in SaaS",
skills: "SolidJS, TypeScript",
}
: {},
},
}),
});
return;
}
if (portfolioPrefix && path.includes(`/api/${portfolioPrefix}/portfolio/me`)) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
portfolioReady
? [{ id: `portfolio-${unique}`, title: "Showcase Project", description: "Media sample" }]
: []
),
});
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: [] }) });
});
}
async function runSignup(page: any, scenario: SignupScenario, email: string) {
await page.goto(`http://localhost:3000${scenario.signupUrl}`);
await page.fill("#first-name", "Ari");
await page.fill("#last-name", "Tester");
await page.fill("#email", email);
await page.fill("#password", "TestPassword123!");
await page.fill("#confirm-password", "TestPassword123!");
await page.fill("#captcha", "AAAAAA");
await page.check('input[type="checkbox"]');
await page.click("button.auth-submit-btn");
await expect(page.getByText("Verify Email")).toBeVisible();
await page.fill("#otp-0", "1");
await page.fill("#otp-1", "2");
await page.fill("#otp-2", "3");
await page.fill("#otp-3", "4");
await page.fill("#otp-4", "5");
await page.fill("#otp-5", "6");
await page.click("button.auth-submit-btn");
await page.waitForURL("**/login?verified=1");
}
async function seedSession(page: any, scenario: SignupScenario, email: string) {
await page.evaluate(
({ role, mail }) => {
sessionStorage.setItem("nxtgauge_access_token", "pw-test-token");
const payload = {
email: mail,
roleKey: role,
role: role,
selectedProfessionalRole: role,
fullName: "Ari Tester",
name: "Ari Tester",
};
localStorage.setItem("nxtgauge_signup_profile_v1", JSON.stringify(payload));
localStorage.setItem("nxtgauge_auth_user", JSON.stringify(payload));
localStorage.setItem("nxtgauge_user", JSON.stringify(payload));
const professionalRoles = [
"PHOTOGRAPHER",
"MAKEUP_ARTIST",
"TUTOR",
"DEVELOPER",
"VIDEO_EDITOR",
"UGC_CONTENT_CREATOR",
"GRAPHIC_DESIGNER",
"SOCIAL_MEDIA_MANAGER",
"FITNESS_TRAINER",
"CATERING_SERVICES",
];
if (professionalRoles.includes(role)) {
localStorage.setItem(
`nxtgauge_portfolio_meta_${String(role).toLowerCase()}`,
JSON.stringify({
about: "Experienced professional with strong delivery track record.",
services: [{ name: "Core Service", pricingType: "Fixed", amount: "₹10,000", details: "End-to-end delivery" }],
experience: [{ year: "2024", description: "Handled projects across multiple clients" }],
faqs: [{ question: "Do you provide revisions?", answer: "Yes, revisions are included." }],
tools: ["Industry Tool"],
})
);
}
},
{ role: scenario.dashboardRole, mail: email }
);
}
test.describe("Signup and verification submission by role", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
Math.random = () => 0;
});
});
for (const scenario of scenarios) {
test(`complete profile submits verification for ${scenario.name}`, async ({ page }) => {
const unique = Date.now();
const email = `qa_${scenario.dashboardRole.toLowerCase()}_${unique}@nxtgauge.test`;
const submitRef = { count: 0 };
await wireApiMock(page, scenario, unique, profileSeedComplete, submitRef, true);
await runSignup(page, scenario, email);
await seedSession(page, scenario, email);
await page.goto(`http://localhost:3000/dashboard?role=${scenario.dashboardRole}`);
await page.getByRole("button", { name: /my profile/i }).click();
const submitButton = page.getByRole("button", { name: /submit for verification/i });
await expect(submitButton).toBeVisible();
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(page.getByText(/Submitted! We will review your profile/i)).toBeVisible();
expect(submitRef.count).toBe(1);
});
test(`missing document blocks verification submit for ${scenario.name}`, async ({ page }) => {
const unique = Date.now() + 1000;
const email = `qa_${scenario.dashboardRole.toLowerCase()}_missing_${unique}@nxtgauge.test`;
const submitRef = { count: 0 };
await wireApiMock(page, scenario, unique, missingDocProfile(scenario.dashboardRole), submitRef, false);
await runSignup(page, scenario, email);
await seedSession(page, scenario, email);
await page.goto(`http://localhost:3000/dashboard?role=${scenario.dashboardRole}`);
await page.getByRole("button", { name: /my profile/i }).click();
const submitButton = page.getByRole("button", { name: /submit for verification/i });
await expect(submitButton).toBeVisible();
await expect(submitButton).toBeDisabled();
await expect(page.getByRole("button", { name: /go to documents/i })).toBeVisible();
expect(submitRef.count).toBe(0);
});
}
});

View file

@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
const storybookUrl = process.env.STORYBOOK_URL || "http://localhost:6006";
async function storyIdFor(page: any, storyName: string) {
const res = await page.request.get(`${storybookUrl}/index.json`);
expect(res.ok()).toBeTruthy();
const payload = await res.json();
const entries = payload?.entries || {};
for (const entry of Object.values(entries) as any[]) {
if (
entry?.type === "story" &&
entry?.title === "Dashboard/Verification Submission Guide" &&
String(entry?.name || "").toLowerCase() === storyName.toLowerCase()
) {
return String(entry.id);
}
}
throw new Error(`Story id not found for ${storyName}`);
}
test.describe("Storybook visual - verification submission guide", () => {
test("ready-to-submit state renders with CTA", async ({ page }) => {
const storyId = await storyIdFor(page, "Ready To Submit");
await page.goto(`${storybookUrl}/iframe.html?id=${storyId}&viewMode=story`);
await expect(page.getByText("Ready to submit for verification.")).toBeVisible({ timeout: 15000 });
await expect(page.getByRole("button", { name: /submit for verification/i })).toBeVisible();
});
test("documents-requested state renders admin guidance", async ({ page }) => {
const storyId = await storyIdFor(page, "Documents Requested");
await page.goto(`${storybookUrl}/iframe.html?id=${storyId}&viewMode=story`);
await expect(page.getByText(/Admin request:/i)).toBeVisible({ timeout: 15000 });
await expect(page.getByRole("button", { name: /go to documents/i })).toBeVisible();
});
});

View file

@ -0,0 +1,24 @@
// vite.config.ts
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
var vite_config_default = defineConfig({
vite: {
plugins: [tailwindcss()],
server: {
proxy: {
"/api/gateway": {
target: "http://localhost:9100",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/gateway\/api(\/|$)/, "/api$1").replace(/^\/api\/gateway(\/|$)/, "/api$1")
},
"/api": {
target: "http://localhost:9100",
changeOrigin: true
}
}
}
}
});
export {
vite_config_default as default
};

View file

@ -0,0 +1,24 @@
// vite.config.ts
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
var vite_config_default = defineConfig({
vite: {
plugins: [tailwindcss()],
server: {
proxy: {
"/api/gateway": {
target: "http://localhost:9100",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/gateway\/api(\/|$)/, "/api$1").replace(/^\/api\/gateway(\/|$)/, "/api$1")
},
"/api": {
target: "http://localhost:9100",
changeOrigin: true
}
}
}
}
});
export {
vite_config_default as default
};