chore: checkpoint workspace updates
This commit is contained in:
parent
35abb6e7aa
commit
f386b0c298
49 changed files with 5814 additions and 1211 deletions
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
> dev
|
||||
> vinxi dev
|
||||
|
||||
vinxi v0.5.11
|
||||
|
|
@ -1 +1 @@
|
|||
7995
|
||||
61048
|
||||
|
|
|
|||
1
frontend-solid.start.log
Normal file
1
frontend-solid.start.log
Normal file
|
|
@ -0,0 +1 @@
|
|||
Listening on http://[::]:3001
|
||||
1
frontend-solid.start.pid
Normal file
1
frontend-solid.start.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
72152
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
228
src/components/dashboard/AdminDashboardPage.tsx
Normal file
228
src/components/dashboard/AdminDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 (2–3 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 (2–3 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}>
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
190
src/components/dashboard/VerificationSubmissionGuide.tsx
Normal file
190
src/components/dashboard/VerificationSubmissionGuide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
src/components/dashboard/widgets/ApplicationsWidget.tsx
Normal file
136
src/components/dashboard/widgets/ApplicationsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/dashboard/widgets/DashboardWidget.tsx
Normal file
84
src/components/dashboard/widgets/DashboardWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/dashboard/widgets/JobsWidget.tsx
Normal file
131
src/components/dashboard/widgets/JobsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/components/dashboard/widgets/LeadsWidget.tsx
Normal file
132
src/components/dashboard/widgets/LeadsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/dashboard/widgets/PortfolioWidget.tsx
Normal file
72
src/components/dashboard/widgets/PortfolioWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/dashboard/widgets/ProfileCompletionWidget.tsx
Normal file
135
src/components/dashboard/widgets/ProfileCompletionWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/dashboard/widgets/RequirementsWidget.tsx
Normal file
101
src/components/dashboard/widgets/RequirementsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/dashboard/widgets/ShortlistedWidget.tsx
Normal file
127
src/components/dashboard/widgets/ShortlistedWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/dashboard/widgets/VerificationWidget.tsx
Normal file
104
src/components/dashboard/widgets/VerificationWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/dashboard/widgets/WalletWidget.tsx
Normal file
104
src/components/dashboard/widgets/WalletWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ?? {}) },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
65
src/stories/VerificationSubmissionGuide.stories.tsx
Normal file
65
src/stories/VerificationSubmissionGuide.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
BIN
test-videos/e1db048450ac35c61aa05b34c1ed2fef.webm
Normal file
BIN
test-videos/e1db048450ac35c61aa05b34c1ed2fef.webm
Normal file
Binary file not shown.
304
tests/e2e/signup-verification-submission.spec.ts
Normal file
304
tests/e2e/signup-verification-submission.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
38
tests/e2e/visual/verification-submission-guide.story.spec.ts
Normal file
38
tests/e2e/visual/verification-submission-guide.story.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
vite.config.timestamp_1776803609839.js
Normal file
24
vite.config.timestamp_1776803609839.js
Normal 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
|
||||
};
|
||||
24
vite.config.timestamp_1776891334006.js
Normal file
24
vite.config.timestamp_1776891334006.js
Normal 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
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue