From f386b0c29873fd657a5c8ee9b97096975a8f58b4 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 26 Apr 2026 23:58:43 +0200 Subject: [PATCH] chore: checkpoint workspace updates --- frontend-solid.dev.log | 5 + frontend-solid.dev.pid | 2 +- frontend-solid.start.log | 1 + frontend-solid.start.pid | 1 + package-lock.json | 15 + package.json | 15 +- src/components/DashboardShell.tsx | 70 +- .../admin/DashboardDesignPreview.tsx | 358 ++++--- .../dashboard/AdminDashboardPage.tsx | 228 +++++ .../dashboard/CompanyApplicationsPage.tsx | 3 +- src/components/dashboard/CompanyJobsPage.tsx | 135 ++- .../CompanyShortlistedCandidatesPage.tsx | 3 +- src/components/dashboard/CreditsPage.tsx | 596 ++++++++---- .../dashboard/CustomerRequirementsPage.tsx | 126 ++- .../dashboard/CustomerResponsesPage.tsx | 3 +- .../dashboard/ExploreServicesPage.tsx | 608 +++++++++--- .../dashboard/HelpCenterDashboardPage.tsx | 34 +- .../dashboard/JobSeekerApplicationsPage.tsx | 3 +- .../dashboard/JobSeekerJobsPage.tsx | 126 ++- src/components/dashboard/LogoutPage.tsx | 30 +- src/components/dashboard/MyDashboardPage.tsx | 535 +++++++++-- src/components/dashboard/PortfolioPage.tsx | 891 +++++++++++++++--- .../dashboard/ProfessionalLeadsPage.tsx | 184 ++-- .../dashboard/ProfessionalResponsesPage.tsx | 3 +- src/components/dashboard/ProfilePage.tsx | 409 ++++++-- src/components/dashboard/SettingsPage.tsx | 37 +- .../dashboard/SwitchServicesPage.tsx | 32 +- .../dashboard/VerificationStatusPage.tsx | 495 ++++++---- .../dashboard/VerificationSubmissionGuide.tsx | 190 ++++ .../dashboard/widgets/ApplicationsWidget.tsx | 136 +++ .../dashboard/widgets/DashboardWidget.tsx | 84 ++ .../dashboard/widgets/JobsWidget.tsx | 131 +++ .../dashboard/widgets/LeadsWidget.tsx | 132 +++ .../dashboard/widgets/PortfolioWidget.tsx | 72 ++ .../widgets/ProfileCompletionWidget.tsx | 135 +++ .../dashboard/widgets/RequirementsWidget.tsx | 101 ++ .../dashboard/widgets/ShortlistedWidget.tsx | 127 +++ .../dashboard/widgets/VerificationWidget.tsx | 104 ++ .../dashboard/widgets/WalletWidget.tsx | 104 ++ src/lib/api.ts | 8 +- src/lib/auth.tsx | 62 +- src/lib/job-seeker-custom-data.ts | 3 +- src/routes/dashboard.tsx | 233 +++-- .../VerificationSubmissionGuide.stories.tsx | 65 ++ .../e1db048450ac35c61aa05b34c1ed2fef.webm | Bin 0 -> 37460 bytes .../signup-verification-submission.spec.ts | 304 ++++++ ...erification-submission-guide.story.spec.ts | 38 + vite.config.timestamp_1776803609839.js | 24 + vite.config.timestamp_1776891334006.js | 24 + 49 files changed, 5814 insertions(+), 1211 deletions(-) create mode 100644 frontend-solid.start.log create mode 100644 frontend-solid.start.pid create mode 100644 src/components/dashboard/AdminDashboardPage.tsx create mode 100644 src/components/dashboard/VerificationSubmissionGuide.tsx create mode 100644 src/components/dashboard/widgets/ApplicationsWidget.tsx create mode 100644 src/components/dashboard/widgets/DashboardWidget.tsx create mode 100644 src/components/dashboard/widgets/JobsWidget.tsx create mode 100644 src/components/dashboard/widgets/LeadsWidget.tsx create mode 100644 src/components/dashboard/widgets/PortfolioWidget.tsx create mode 100644 src/components/dashboard/widgets/ProfileCompletionWidget.tsx create mode 100644 src/components/dashboard/widgets/RequirementsWidget.tsx create mode 100644 src/components/dashboard/widgets/ShortlistedWidget.tsx create mode 100644 src/components/dashboard/widgets/VerificationWidget.tsx create mode 100644 src/components/dashboard/widgets/WalletWidget.tsx create mode 100644 src/stories/VerificationSubmissionGuide.stories.tsx create mode 100644 test-videos/e1db048450ac35c61aa05b34c1ed2fef.webm create mode 100644 tests/e2e/signup-verification-submission.spec.ts create mode 100644 tests/e2e/visual/verification-submission-guide.story.spec.ts create mode 100644 vite.config.timestamp_1776803609839.js create mode 100644 vite.config.timestamp_1776891334006.js diff --git a/frontend-solid.dev.log b/frontend-solid.dev.log index e69de29..3c65a07 100644 --- a/frontend-solid.dev.log +++ b/frontend-solid.dev.log @@ -0,0 +1,5 @@ + +> dev +> vinxi dev + +vinxi v0.5.11 diff --git a/frontend-solid.dev.pid b/frontend-solid.dev.pid index 3813b54..2419923 100644 --- a/frontend-solid.dev.pid +++ b/frontend-solid.dev.pid @@ -1 +1 @@ -7995 +61048 diff --git a/frontend-solid.start.log b/frontend-solid.start.log new file mode 100644 index 0000000..4705e3b --- /dev/null +++ b/frontend-solid.start.log @@ -0,0 +1 @@ +Listening on http://[::]:3001 diff --git a/frontend-solid.start.pid b/frontend-solid.start.pid new file mode 100644 index 0000000..f79f1f2 --- /dev/null +++ b/frontend-solid.start.pid @@ -0,0 +1 @@ +72152 diff --git a/package-lock.json b/package-lock.json index 183e1e3..054bb52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6682ad4..edecde8 100644 --- a/package.json +++ b/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" diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 880d8a9..ea9e86d 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -23,33 +23,48 @@ import { ChevronRight, } from "lucide-solid"; -// ── Icon map (matches DashboardDesignPreview sidebar keys) ──────────────────── - const ICON_MAP: Record = { - "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 ; } @@ -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; diff --git a/src/components/admin/DashboardDesignPreview.tsx b/src/components/admin/DashboardDesignPreview.tsx index 7597194..0f73dc3 100644 --- a/src/components/admin/DashboardDesignPreview.tsx +++ b/src/components/admin/DashboardDesignPreview.tsx @@ -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 = { + 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 = { + 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([]); 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>({}); const [portfolioFormErrors, setPortfolioFormErrors] = createSignal>({}); - const [userRoles, setUserRoles] = createSignal([]); const [portfolioValidationNotice, setPortfolioValidationNotice] = createSignal(''); const [portfolioServices, setPortfolioServices] = createSignal>([]); 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>({}); const [checkoutPackage, setCheckoutPackage] = createSignal(null); const [paymentStep, setPaymentStep] = createSignal<'idle' | 'processing' | 'verifying' | 'success' | 'error'>('idle'); const [paymentRef, setPaymentRef] = createSignal(null); @@ -2618,7 +2703,7 @@ export default function DashboardDesignPreview(props: {

{titleCase(selectedPortfolioTab)}

- + {selectedPortfolioFormFields.length} fields
@@ -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'} @@ -3269,7 +3354,7 @@ export default function DashboardDesignPreview(props: { - +
@@ -3286,12 +3371,12 @@ export default function DashboardDesignPreview(props: { - +

Profile Status

-

85%

+

{profileCompletion()}%

Credits Balance

@@ -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`} >

{titleCase(w)}

-

42

+

{dashboardMetrics()[w] ?? '--'}

)} @@ -3440,7 +3525,7 @@ export default function DashboardDesignPreview(props: { @@ -3459,7 +3544,7 @@ export default function DashboardDesignPreview(props: { @@ -3482,7 +3567,7 @@ export default function DashboardDesignPreview(props: {
-
@@ -3508,7 +3593,7 @@ export default function DashboardDesignPreview(props: { @@ -3525,7 +3610,7 @@ export default function DashboardDesignPreview(props: {

{isPreferencesTab ? 'Preference Details' : titleCase(selectedTab)}

- + {fieldsForTab.length} fields
@@ -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'} @@ -3639,7 +3724,7 @@ export default function DashboardDesignPreview(props: { - +
@@ -3666,7 +3751,7 @@ export default function DashboardDesignPreview(props: {
- +
@@ -3695,7 +3780,7 @@ export default function DashboardDesignPreview(props: {

{250 - leadCredits()}

Tracecoins used this month

-
+

Active Requests

{leadCards().filter((card) => card.status === 'requested').length}

@@ -3743,7 +3828,7 @@ export default function DashboardDesignPreview(props: {
- +
@@ -3756,7 +3841,7 @@ export default function DashboardDesignPreview(props: {
- + {['Lead ID', 'Lead Title', 'Request Date', 'Request Status', 'Cost', 'Decision Date', 'Action'].map((h) => ( @@ -3781,7 +3866,7 @@ export default function DashboardDesignPreview(props: { @@ -5266,7 +5351,7 @@ export default function DashboardDesignPreview(props: { - +
@@ -5279,7 +5364,7 @@ export default function DashboardDesignPreview(props: {
{h}
- + @@ -3889,7 +3974,7 @@ export default function DashboardDesignPreview(props: {
Contacted{lead.contactCount}/{lead.maxContacts}
- +
@@ -3961,7 +4046,7 @@ export default function DashboardDesignPreview(props: {
- +
@@ -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 @@ -4084,7 +4169,7 @@ export default function DashboardDesignPreview(props: {

You are about to spend 25 Tracecoins to request and view this service seeker contact when approved. Do you want to continue?

- +
@@ -4118,7 +4203,7 @@ export default function DashboardDesignPreview(props: { { label: 'Shortlisted', value: '03', tone: 'orange' }, { label: 'Interviews', value: '02', tone: 'green' }, ].map((card) => ( -
+

{card.label}

{card.value}

@@ -4169,7 +4254,7 @@ export default function DashboardDesignPreview(props: {

Boost your response rate

Recruiters are more likely to respond to profiles with updated resume and portfolio links.

- + ); @@ -4179,7 +4264,7 @@ export default function DashboardDesignPreview(props: { return (
-
-
+

Total Compensation

{selectedJob().salary} / year

@@ -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) => ( -
+

{card.label}

{card.value}

@@ -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 @@ -4504,7 +4589,7 @@ export default function DashboardDesignPreview(props: {
-
+

Did you know?

Jobs with high-quality company descriptions receive 40% more applications. Take a moment to update your profile.

@@ -4587,7 +4672,7 @@ export default function DashboardDesignPreview(props: {
-
+

Pricing & Approval

Review costs and confirm submission

@@ -4606,7 +4691,7 @@ export default function DashboardDesignPreview(props: {

Approval Required: Yes

Your post will be reviewed by our moderation team within 24 hours.

- +
@@ -4669,7 +4754,7 @@ export default function DashboardDesignPreview(props: {
-
@@ -4943,7 +5028,7 @@ export default function DashboardDesignPreview(props: {
- +
@@ -5060,7 +5145,7 @@ export default function DashboardDesignPreview(props: { @@ -5134,7 +5219,7 @@ export default function DashboardDesignPreview(props: { > View - +
- + {['Lead ID', 'Lead Title', 'Request Date', 'Status', 'Cost', 'Decision Date', 'Action'].map((h) => ( @@ -5370,7 +5455,7 @@ export default function DashboardDesignPreview(props: { @@ -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'} @@ -5524,12 +5609,12 @@ export default function DashboardDesignPreview(props: {
-

Secure Gateway

+

Secure Gateway

Continue to our secure partner gateway to complete the transaction.

@@ -5555,7 +5640,7 @@ export default function DashboardDesignPreview(props: { @@ -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 @@ -5669,7 +5754,7 @@ export default function DashboardDesignPreview(props: { > Manage Credits - + @@ -5694,7 +5779,7 @@ export default function DashboardDesignPreview(props: { @@ -5722,7 +5807,7 @@ export default function DashboardDesignPreview(props: { -
+

Recommended

Start with Standard

Best value for active buying and response unlocks.

@@ -5755,7 +5840,7 @@ export default function DashboardDesignPreview(props: {
{h}
- + {['Invoice No', 'Package', 'Credits', 'Amount Paid', 'Status', 'Date', 'Actions'].map((h) => ( @@ -5770,7 +5855,7 @@ export default function DashboardDesignPreview(props: {
{h}{row[2]} {row[3]} - {row[4]} + {row[4]} {row[5]} @@ -5823,7 +5908,7 @@ export default function DashboardDesignPreview(props: { - + {['Usage ID', 'Action Type', 'Credits Used', 'Related ID', 'Date', 'Remarks'].map((h) => ( @@ -5840,7 +5925,7 @@ export default function DashboardDesignPreview(props: { - + @@ -5852,7 +5937,7 @@ export default function DashboardDesignPreview(props: {

Showing 1 to 4 of 142 results

{[1, 2, 3].map((p) => ( - + ))}
@@ -5882,7 +5967,7 @@ export default function DashboardDesignPreview(props: {
{h}
{row[0]} {row[1]}{row[2]}{row[2]} {row[3]} {row[4]} {row[5]}
- + {['Invoice Number', 'Billing Date', 'Package', 'Total', 'Status'].map((h) => ( @@ -5907,7 +5992,7 @@ export default function DashboardDesignPreview(props: {

Showing 1 to 4 of 24 invoices

{[1, 2, 3].map((p) => ( - + ))}
@@ -5940,13 +6025,13 @@ export default function DashboardDesignPreview(props: {

Pending Invoices

1

- +

Recent Transactions

- +
@@ -5966,7 +6051,7 @@ export default function DashboardDesignPreview(props: {
{h}
- + {['Transaction ID', 'Package', 'Credits', 'Amount Paid', 'Status', 'Date'].map((h) => ( @@ -5981,7 +6066,7 @@ export default function DashboardDesignPreview(props: { @@ -6018,7 +6103,7 @@ export default function DashboardDesignPreview(props: { @@ -6048,7 +6133,7 @@ export default function DashboardDesignPreview(props: { @@ -6117,7 +6202,7 @@ export default function DashboardDesignPreview(props: {

Finish your basic information and required documents, then submit.

- +
@@ -6125,7 +6210,7 @@ export default function DashboardDesignPreview(props: {

Add portfolio details and submit separately for review.

- +
@@ -6173,7 +6258,7 @@ export default function DashboardDesignPreview(props: {

Approved

-

2

+

2

Needs Action

@@ -6186,13 +6271,13 @@ export default function DashboardDesignPreview(props: {

Admin requested missing documents

Required Missing Documents: Address Proof (clear PDF/JPG/PNG).

- +

Documents

- +
{h}{row[2]} {row[3]} - {row[4]} + {row[4]} {row[5]}
@@ -6213,7 +6298,7 @@ export default function DashboardDesignPreview(props: {
{doc} {file} - {state} + {state}
- +
@@ -6271,7 +6356,7 @@ export default function DashboardDesignPreview(props: {

{title}

{time}

- {state} + {state} ))} @@ -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'} @@ -6792,71 +6877,80 @@ export default function DashboardDesignPreview(props: {
-
-

Current View

-

{isCustomerExternalMode() ? customerView().title : titleCase(props.activeSidebar)}

-

{isCustomerExternalMode() ? customerView().subtitle : 'Interactive preview for configured dashboard.'}

- 0 && customerKey() !== 'my portfolio'}> - +
+
+ {(() => { + const IconFn = sidebarIcon(props.activeSidebar || 'dashboard'); + return IconFn && ; + })()} +
+

{isCustomerExternalMode() ? customerView().title : titleCase(props.activeSidebar)}

+

{isCustomerExternalMode() ? customerView().subtitle : 'Interactive preview for configured dashboard.'}

+
+
+
+ 0 && customerKey() !== 'my portfolio'}> + + + {(item) => ( + (() => { + const itemKey = normalizeTabKey(item); + const isLockedTestimonialsTab = customerKey() === 'my portfolio' && itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); + return ( + + ); + })() + )} + +
+ } + > +
- {(item) => ( - (() => { - const itemKey = normalizeTabKey(item); - const isLockedTestimonialsTab = customerKey() === 'my portfolio' && itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); - return ( - - ); - })() - )} + {(item) => { + const itemKey = normalizeTabKey(item); + const isLockedTestimonialsTab = itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); + const isActive = resolvedTabKey() === itemKey; + const Icon = portfolioTabIcon(item); + return ( + + ); + }}
- } - > -
- - {(item) => { - const itemKey = normalizeTabKey(item); - const isLockedTestimonialsTab = itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); - const isActive = resolvedTabKey() === itemKey; - const Icon = portfolioTabIcon(item); - return ( - - ); - }} - -
+ - +
diff --git a/src/components/dashboard/AdminDashboardPage.tsx b/src/components/dashboard/AdminDashboardPage.tsx new file mode 100644 index 0000000..d65f8a0 --- /dev/null +++ b/src/components/dashboard/AdminDashboardPage.tsx @@ -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({ + 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 ( +
+
+

+ Admin Dashboard +

+

+ Platform overview and management metrics. +

+
+ + +
+ {error()} +
+
+ + +
+ Loading admin metrics... +
+
+ + +
+ + {(stat) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+
+ )} +
+
+ +
+

+ Quick Actions +

+
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/dashboard/CompanyApplicationsPage.tsx b/src/components/dashboard/CompanyApplicationsPage.tsx index ec78e18..9a58cb3 100644 --- a/src/components/dashboard/CompanyApplicationsPage.tsx +++ b/src/components/dashboard/CompanyApplicationsPage.tsx @@ -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: { diff --git a/src/components/dashboard/CompanyJobsPage.tsx b/src/components/dashboard/CompanyJobsPage.tsx index 0692aaa..4dce950 100644 --- a/src/components/dashboard/CompanyJobsPage.tsx +++ b/src/components/dashboard/CompanyJobsPage.tsx @@ -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(null); + const [search, setSearch] = createSignal(""); + const [sortBy, setSortBy] = createSignal("newest"); + const [activeTag, setActiveTag] = createSignal(""); + + const rowTags = (row: JobItem) => { + const tags = new Set(); + 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(); + 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.

- @@ -378,11 +428,70 @@ export default function CompanyJobsPage() {
+
+
+

+ My Job Postings +

+ +
+ +
+
+ setSearch(e.currentTarget.value)} + style={INPUT} + placeholder="Search by title, location, type, description, tags" + /> + +
+ 0}> +
+ + + {(tag) => ( + + )} + +
+
+
+
+
Loading jobs…
- +

- No jobs yet + No jobs found

- Create your first draft job to start receiving applications. + Try a different search or create your first draft job.

- 0}> + 0}>
- + {(job) => (
{job.description}

- 0}> + 0}>
- - {(skill) => ( + + {(tag) => ( - {skill} + {tag} )} @@ -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", diff --git a/src/components/dashboard/CompanyShortlistedCandidatesPage.tsx b/src/components/dashboard/CompanyShortlistedCandidatesPage.tsx index b969f93..20a8e30 100644 --- a/src/components/dashboard/CompanyShortlistedCandidatesPage.tsx +++ b/src/components/dashboard/CompanyShortlistedCandidatesPage.tsx @@ -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: { diff --git a/src/components/dashboard/CreditsPage.tsx b/src/components/dashboard/CreditsPage.tsx index 96af5dc..0eb3827 100644 --- a/src/components/dashboard/CreditsPage.tsx +++ b/src/components/dashboard/CreditsPage.tsx @@ -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 | 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({ @@ -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) {
-
-

- Credits & Billing -

-

- Manage your Tracecoin balance, purchase packages, and view transaction history. -

+
+
+

+ Credits & Billing +

+

+ Manage your Tracecoin balance, purchase packages, and view transaction history. +

+
+ + +
@@ -666,39 +720,38 @@ export default function CreditsPage(props: Props) {
- +
@@ -743,44 +816,31 @@ export default function CreditsPage(props: Props) {
-
-
-

- Current Balance -

-

- {wallet()?.balance ?? 0}{" "} - - Tracecoins - -

-
- +
+

+ Current Balance +

+

+ {wallet()?.balance ?? 0}{" "} + + Tracecoins + +

@@ -860,7 +920,7 @@ export default function CreditsPage(props: Props) { {/* Buy Credits Tab */} - +

0}> -

- - {(pkg) => ( -
{ + 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 ( +
+ + {(pkg) => { + const theme = pkgCardTheme(); + const isBestValue = bestValue.pkg.id === pkg.id; + const coinsPerRupee = (pkg.tracecoins_amount / pkg.price).toFixed(1); + + return ( +
+ +
+ + Best Value + +
+
+ +
+
+

+ {pkg.package_type} +

+

+ {pkg.name} +

+
+ +
+ + {formatCurrency(pkg.price)} + + + INR + +
+ +
+
+ 🪙 +
+
+

+ {pkg.tracecoins_amount.toLocaleString()} +

+

+ Tracecoins +

+
+
+ +
+ {coinsPerRupee} coins per ₹1 +
+ + +

+ {pkg.description} +

+
+ + +
+
+ ); }} - > -
-

- {pkg.name} -

-

- {formatCurrency(pkg.price)} -

-
-
- 🪙 - - {pkg.tracecoins_amount} Tracecoins - -
- -

- {pkg.description} -

-
- -
- )} - -
+
+
+ ); + })}
@@ -1080,15 +1266,33 @@ export default function CreditsPage(props: Props) { > {formatCurrency(payment.amount_inr)}

-

- {payment.status === "SUCCESS" ? "✓ Completed" : payment.status} -

+ {payment.status === "SUCCESS" + ? "✓ Completed" + : payment.status === "FAILED" + ? "✕ Failed" + : "◌ Pending"} + 0}>

+{payment.tracecoins_credited} TC @@ -1102,6 +1306,78 @@ export default function CreditsPage(props: Props) {

+ + {/* Usage History Tab */} + +
+

+ Tracecoin Usage History +

+

+ A record of all Tracecoins spent on leads, responses, and platform features. +

+ + +

+ No usage history yet. +

+
+ + 0}> +
+ Number(item.amount) < 0)}> + {(item: any) => ( +
+
+

+ {item.reason || item.type || "Tracecoins Used"} +

+

+ {item.created_at ? formatDate(item.created_at) : "—"} +

+
+

+ {item.amount} TC +

+
+ )} +
+
+
+
+
); } diff --git a/src/components/dashboard/CustomerRequirementsPage.tsx b/src/components/dashboard/CustomerRequirementsPage.tsx index 49723fe..df17ae5 100644 --- a/src/components/dashboard/CustomerRequirementsPage.tsx +++ b/src/components/dashboard/CustomerRequirementsPage.tsx @@ -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("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(); + 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" />
+
+ + setField("tags", e.currentTarget.value)} + style={INPUT} + placeholder="e.g. wedding, candid, weekend" + /> +
@@ -268,6 +325,8 @@ export default function CustomerRequirementsPage() { "justify-content": "space-between", "align-items": "center", "margin-bottom": "10px", + gap: "10px", + "flex-wrap": "wrap", }} >

@@ -277,19 +336,57 @@ export default function CustomerRequirementsPage() { Refresh

+
+
+ setSearch(e.currentTarget.value)} + style={INPUT} + placeholder="Search by title, location, area, description, tags" + /> + +
+ 0}> +
+ + + {(tag) => ( + + )} + +
+
+

Loading requirements...

- +

- No requirements found. + No requirements match your filters.

- 0}> + 0}>
- + {(row) => (

- {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() {

{row.description || "No description added."}

+ 0}> +
+ + {(tag) => ( + {tag} + )} + +
+
@@ -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", diff --git a/src/components/dashboard/CustomerResponsesPage.tsx b/src/components/dashboard/CustomerResponsesPage.tsx index b5259af..ba827aa 100644 --- a/src/components/dashboard/CustomerResponsesPage.tsx +++ b/src/components/dashboard/CustomerResponsesPage.tsx @@ -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: { diff --git a/src/components/dashboard/ExploreServicesPage.tsx b/src/components/dashboard/ExploreServicesPage.tsx index 8adcb72..df9bad0 100644 --- a/src/components/dashboard/ExploreServicesPage.tsx +++ b/src/components/dashboard/ExploreServicesPage.tsx @@ -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 ( + + + {status} + + ); +} + export default function ExploreServicesPage() { - const [activeRoles, setActiveRoles] = createSignal([]); - const [externalRoles, setExternalRoles] = createSignal([]); - const [activeRoleKey, setActiveRoleKey] = createSignal(''); + const [activeRoles, setActiveRoles] = createSignal([]); + const [currentRole, setCurrentRole] = createSignal(""); const [loading, setLoading] = createSignal(true); const [busyRoleKey, setBusyRoleKey] = createSignal(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 ( -
+
+
+ + + +
+

+ Explore Nxtgauge +

+

+ Discover services, connect with verified users, and expand into additional roles using the same dashboard workflow. +

+
+
+ + {/* Main Roles Section */}
-

Explore Nxtgauge

-

- Runtime-driven service catalog based on your account and active role configuration. +

+ Explore opportunities on Nxtgauge

+
+ + {(role) => { + const isCurrentRole = () => currentRole() === role.key; + const isRegistered = () => activeRoleSet().has(role.key); + return ( +
+
+ +
+

+ {role.name} +

+

+ {role.subtitle} +

+ +
+ ); + }} +
+
-
+
{msg()}
-
+
{err()}
-
-
-

Available Services

- -
+ +

+ Loading services... +

+
+ } + > +
+
+

+ Professional Services +

+ +
- -

Loading services...

-
- - -

No services returned by runtime configuration.

-
- - 0}> -
- - {(card) => ( -
-

{card.title}

-

{card.subtitle}

- -
- )} -
+
+
+ +
+ +
+ +
+

+ {card.title} +

+

+ {card.subtitle} +

+
+ + +
+ )} + +
+ + +
+

+ Growth Advantage +

+

+ Why Add More Services? +

+

+ A multi-service profile helps you acquire more opportunities, improve trust, and scale consistently on a single platform. +

+
+
+
+ +
+

Reach More Buyers

+

Get discovered by customers across multiple demand categories.

+
+
+
+ +
+

Increase Revenue Paths

+

Offer additional services and create new income streams.

+
+
+
+ +
+

Strengthen Credibilities

+

Verified multi-service profiles build confidence and improve conversion.

+
+
+
+ +
+

Scale Faster

+

Grow your business from one unified account and workflow.

+
+
+
-
); } diff --git a/src/components/dashboard/HelpCenterDashboardPage.tsx b/src/components/dashboard/HelpCenterDashboardPage.tsx index 9734a8f..25260b1 100644 --- a/src/components/dashboard/HelpCenterDashboardPage.tsx +++ b/src/components/dashboard/HelpCenterDashboardPage.tsx @@ -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 (
-
-

- Help Center -

-

- Find guides and articles for your role. -

+
+ + + +
+

+ Help Center +

+

+ Find guides and articles for your role. +

+
diff --git a/src/components/dashboard/JobSeekerApplicationsPage.tsx b/src/components/dashboard/JobSeekerApplicationsPage.tsx index 9f6ada2..d1ce024 100644 --- a/src/components/dashboard/JobSeekerApplicationsPage.tsx +++ b/src/components/dashboard/JobSeekerApplicationsPage.tsx @@ -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: { diff --git a/src/components/dashboard/JobSeekerJobsPage.tsx b/src/components/dashboard/JobSeekerJobsPage.tsx index 26f6588..4dae4d0 100644 --- a/src/components/dashboard/JobSeekerJobsPage.tsx +++ b/src/components/dashboard/JobSeekerJobsPage.tsx @@ -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(); + 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(null); const [search, setSearch] = createSignal(""); + const [sortBy, setSortBy] = createSignal("newest"); + const [activeTag, setActiveTag] = createSignal(""); const [msg, setMsg] = createSignal(""); const [err, setErr] = createSignal(""); + const availableTags = createMemo(() => { + const tags = new Set(); + 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() {
-
- setSearch(e.currentTarget.value)} - style={INPUT} - placeholder="Search jobs, company, location" - /> - +
+
+ setSearch(e.currentTarget.value)} + style={INPUT} + placeholder="Search jobs, company, location, tags" + /> + + +
+ 0}> +
+ + + {(tag) => ( + + )} + +
+
@@ -298,6 +363,15 @@ export default function JobSeekerJobsPage() {

{row.description || "No description provided."}

+ 0}> +
+ + {(tag) => ( + {tag} + )} + +
+

applyJob(row.id)} disabled={busyId() === row.id} style={{ - ...BTN_ORANGE, + ...BTN_PRIMARY, height: "32px", "font-size": "12px", padding: "0 12px", diff --git a/src/components/dashboard/LogoutPage.tsx b/src/components/dashboard/LogoutPage.tsx index c321f3e..923908a 100644 --- a/src/components/dashboard/LogoutPage.tsx +++ b/src/components/dashboard/LogoutPage.tsx @@ -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 (

-
-

Logout

-

End your current session securely.

+
+ + + +
+

+ Logout +

+

+ End your current session securely. +

+
diff --git a/src/components/dashboard/MyDashboardPage.tsx b/src/components/dashboard/MyDashboardPage.tsx index f3bf826..2da9f22 100644 --- a/src/components/dashboard/MyDashboardPage.tsx +++ b/src/components/dashboard/MyDashboardPage.tsx @@ -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 = { + 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 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([]); const [loading, setLoading] = createSignal(true); const [err, setErr] = createSignal(''); + const [customizeMode, setCustomizeMode] = createSignal(false); + const [widgetOrder, setWidgetOrder] = createSignal([]); + const [draggingIdx, setDraggingIdx] = createSignal(null); + const [visibleWidgets, setVisibleWidgets] = createSignal>(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) - : {}; - 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) - : {}; - 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 = { + 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 ( +
{ + 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', + }} + > + +
+ ); + }; + + const effectiveRole = () => getEffectiveRole(); + return ( -
+
+
+ + + +
+

+ My Dashboard +

+

+ Overview of your {roleLabel()} metrics and activity. +

+
+
+ + +
+
+ + {verificationStatus().replace(/_/g, ' ')} + +

{verificationTone().title}

+

+ {verificationTone().description} +

+
+
+ + + + + +
+
+
+
-

My Dashboard

-

- Welcome {props.userName || 'User'}. Role: {roleLabel()}. -

+
+
+

Dashboard Overview

+

+ Manage widget layout, visibility, sizing, and dashboard presentation +

+
+ +
@@ -176,30 +466,115 @@ export default function MyDashboardPage(props: Props) {
-
-
-

Quick Summary

- -
- - -

Loading dashboard...

-
- - -
- - {(m) => ( -
-

{m.title}

-

{m.value}

-

{m.hint}

-
- )} + +
+

Widget Customization

+

Drag and drop cards below to reorder your dashboard widgets. Click eye icon to show/hide.

+
+ + {(w, idx) => { + const isVisible = () => visibleWidgets().has(w); + const isDragging = () => draggingIdx() === idx(); + return ( +
{ + 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, + }} + > +
+

+ {w.replace(/_/g, ' ')} +

+ +
+

+ {metrics().find(m => m.title.toLowerCase().replace(/ /g, '_').includes(w))?.value || '--'} +

+
+ ); + }}
- -
+
+
+ + +
+ !customizeMode() || visibleWidgets().has(w))}> + {(key) => renderWidget(key, 0)} + +
+
+ + +
+
+

Quick Summary

+ +
+ + +

Loading dashboard...

+
+ + +
+ + {(m) => ( +
+

{m.title}

+

{m.value}

+

{m.hint}

+
+ )} +
+
+
+
+
); } diff --git a/src/components/dashboard/PortfolioPage.tsx b/src/components/dashboard/PortfolioPage.tsx index d6f92a4..c0d2bd9 100644 --- a/src/components/dashboard/PortfolioPage.tsx +++ b/src/components/dashboard/PortfolioPage.tsx @@ -4,11 +4,28 @@ * Job seekers get a dedicated portfolio editor (education/work experience/skills). */ import { For, Show, createSignal, onMount } from 'solid-js'; -import { CARD, BTN_ORANGE, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL } from '~/components/DashboardShell'; +import { Coins, Image, BriefcaseBusiness, UserCircle2, CheckCircle2 } from 'lucide-solid'; +import { CARD, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL, NAVY } from '~/components/DashboardShell'; import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data'; const API = '/api/gateway'; +const BTN_NAVY = { + height: '36px', + 'border-radius': '8px', + border: 'none', + background: NAVY, + color: 'white', + 'font-size': '13px', + 'font-weight': '700', + padding: '0 16px', + cursor: 'pointer', + display: 'inline-flex', + 'align-items': 'center', + 'justify-content': 'center', + gap: '6px', +}; + // ── Role prefix map ─────────────────────────────────────────────────────────── const ROLE_PREFIX: Record = { @@ -38,6 +55,7 @@ interface FormState { title: string; description: string; tags: string; + mediaUrl: string; } interface JobSeekerPortfolioState { @@ -48,7 +66,23 @@ interface JobSeekerPortfolioState { skills: string; } -const EMPTY_FORM: FormState = { title: '', description: '', tags: '' }; +const JOB_SEEKER_SAFE_TABS = ['About', 'Education', 'Work Experience', 'Skills']; + +const PROFESSIONAL_SAFE_TABS: Record = { + PHOTOGRAPHER: ['About', 'Services & Pricing', 'Portfolio Gallery', 'Experience & Equipment', 'FAQs'], + MAKEUP_ARTIST: ['About', 'Services & Pricing', 'Gallery', 'Experience & Certifications', 'FAQs'], + TUTOR: ['About', 'Subjects & Pricing', 'Student Work', 'Qualifications', 'FAQs'], + DEVELOPER: ['About', 'Services & Pricing', 'Projects', 'Tech Stack & Experience', 'FAQs'], + VIDEO_EDITOR: ['About', 'Services & Pricing', 'Showreel', 'Experience & Tools', 'FAQs'], + UGC_CONTENT_CREATOR: ['About', 'Services & Pricing', 'Content Portfolio', 'Experience & Tools', 'FAQs'], + GRAPHIC_DESIGNER: ['About', 'Services & Pricing', 'Portfolio', 'Experience & Tools', 'FAQs'], + SOCIAL_MEDIA_MANAGER: ['About', 'Services & Pricing', 'Case Studies', 'Experience & Tools', 'FAQs'], + FITNESS_TRAINER: ['About', 'Training Plans', 'Client Results', 'Certifications', 'FAQs'], + CATERING_SERVICES: ['About', 'Packages & Pricing', 'Gallery', 'Experience & Certifications', 'FAQs'], + default: ['About', 'Services & Pricing', 'Portfolio', 'Experience', 'FAQs'], +}; + +const EMPTY_FORM: FormState = { title: '', description: '', tags: '', mediaUrl: '' }; const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = { headline: '', summary: '', @@ -56,28 +90,186 @@ const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = { workExperience: '', skills: '', }; -const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills']; -const PROFESSIONAL_PORTFOLIO_TABS: Record = { - DEVELOPER: ['About', 'Services & Pricing', 'Projects', 'Tech Stack & Experience', 'Testimonials', 'FAQs'], - default: ['About', 'Services & Pricing', 'Projects', 'Experience', 'Testimonials', 'FAQs'], +type PortfolioRoleConfig = { + tabs: string[]; + serviceTabLabel: string; + experienceTabLabel: string; + mediaTabLabel?: string; + mediaMode: 'none' | 'visual' | 'text'; + mediaLimit: number; +}; + +type ServiceEntry = { + name: string; + pricingType: string; + amount: string; + details: string; +}; + +type ExperienceMilestone = { + year: string; + description: string; +}; + +type TestimonialEntry = { + name: string; + rating: number; + text: string; +}; + +type FaqEntry = { + question: string; + answer: string; }; type ProfessionalPortfolioState = { about: string; - services: string; - experience: string; - testimonials: string; - faqs: string; + services: ServiceEntry[]; + experience: ExperienceMilestone[]; + testimonials: TestimonialEntry[]; + faqs: FaqEntry[]; + tools: string[]; }; +const EMPTY_SERVICE: ServiceEntry = { name: '', pricingType: 'Fixed', amount: '', details: '' }; +const EMPTY_MILESTONE: ExperienceMilestone = { year: '', description: '' }; +const EMPTY_TESTIMONIAL: TestimonialEntry = { name: '', rating: 5, text: '' }; +const EMPTY_FAQ: FaqEntry = { question: '', answer: '' }; + const EMPTY_PROFESSIONAL_FORM: ProfessionalPortfolioState = { about: '', - services: '', - experience: '', - testimonials: '', - faqs: '', + services: [{ ...EMPTY_SERVICE }], + experience: [{ ...EMPTY_MILESTONE }], + testimonials: [{ ...EMPTY_TESTIMONIAL }], + faqs: [{ ...EMPTY_FAQ }], + tools: [], }; +function isServiceLikeTab(tab: string): boolean { + const key = String(tab || '').toLowerCase(); + return key.includes('service') || key.includes('pricing') || key.includes('package') || key.includes('subject') || key.includes('training'); +} + +function isExperienceLikeTab(tab: string): boolean { + const key = String(tab || '').toLowerCase(); + return key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification') || key.includes('certification'); +} + +function isMediaLikeTab(tab: string): boolean { + const key = String(tab || '').toLowerCase(); + return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel') || key.includes('case studies') || key.includes('case_studies') || key.includes('student work') || key.includes('client results') || key.includes('content portfolio'); +} + +function isVisualMediaTab(tab: string): boolean { + const key = String(tab || '').toLowerCase(); + return key.includes('gallery') || key.includes('showreel') || key.includes('portfolio'); +} + +function parseListFromLegacyText(value: string): string[] { + return String(value || '') + .split(/\n|,/g) + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseServiceEntries(value: unknown): ServiceEntry[] { + if (Array.isArray(value)) { + const parsed = value + .map((item) => ({ + name: String((item as any)?.name || ''), + pricingType: String((item as any)?.pricingType || 'Fixed') || 'Fixed', + amount: String((item as any)?.amount || ''), + details: String((item as any)?.details || ''), + })) + .filter((item) => item.name || item.amount || item.details); + return parsed.length ? parsed : [{ ...EMPTY_SERVICE }]; + } + if (typeof value === 'string' && value.trim()) { + return parseListFromLegacyText(value).map((line) => ({ + name: line, + pricingType: 'Fixed', + amount: '', + details: '', + })); + } + return [{ ...EMPTY_SERVICE }]; +} + +function parseExperienceEntries(value: unknown): ExperienceMilestone[] { + if (Array.isArray(value)) { + const parsed = value + .map((item) => ({ + year: String((item as any)?.year || ''), + description: String((item as any)?.description || ''), + })) + .filter((item) => item.year || item.description); + return parsed.length ? parsed : [{ ...EMPTY_MILESTONE }]; + } + if (typeof value === 'string' && value.trim()) { + return parseListFromLegacyText(value).map((line) => ({ year: '', description: line })); + } + return [{ ...EMPTY_MILESTONE }]; +} + +function parseTestimonials(value: unknown): TestimonialEntry[] { + if (Array.isArray(value)) { + const parsed = value + .map((item) => ({ + name: String((item as any)?.name || ''), + rating: Number((item as any)?.rating || 5) || 5, + text: String((item as any)?.text || ''), + })) + .filter((item) => item.name || item.text); + return parsed.length ? parsed : [{ ...EMPTY_TESTIMONIAL }]; + } + if (typeof value === 'string' && value.trim()) { + return parseListFromLegacyText(value).map((line) => ({ name: '', rating: 5, text: line })); + } + return [{ ...EMPTY_TESTIMONIAL }]; +} + +function parseFaqEntries(value: unknown): FaqEntry[] { + if (Array.isArray(value)) { + const parsed = value + .map((item) => ({ + question: String((item as any)?.question || ''), + answer: String((item as any)?.answer || ''), + })) + .filter((item) => item.question || item.answer); + return parsed.length ? parsed : [{ ...EMPTY_FAQ }]; + } + if (typeof value === 'string' && value.trim()) { + return parseListFromLegacyText(value).map((line) => ({ question: line, answer: '' })); + } + return [{ ...EMPTY_FAQ }]; +} + +function parseTools(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map((item) => String(item || '').trim()).filter(Boolean); + } + if (typeof value === 'string' && value.trim()) { + return parseListFromLegacyText(value); + } + return []; +} + +function parseMediaDescription(raw?: string): { mediaUrl: string; description: string } { + const value = String(raw || ''); + if (!value.startsWith('MEDIA_URL:')) return { mediaUrl: '', description: value }; + const lines = value.split('\n'); + const mediaUrl = String(lines[0] || '').replace('MEDIA_URL:', '').trim(); + const description = lines.slice(1).join('\n').trim(); + return { mediaUrl, description }; +} + +function buildMediaDescription(mediaUrl: string, description: string): string { + const cleanUrl = String(mediaUrl || '').trim(); + const cleanDescription = String(description || '').trim(); + if (!cleanUrl) return cleanDescription; + return `MEDIA_URL:${cleanUrl}${cleanDescription ? `\n${cleanDescription}` : ''}`; +} + // ── Helpers ─────────────────────────────────────────────────────────────────── async function apiFetch(path: string, opts?: RequestInit) { @@ -126,6 +318,7 @@ export default function PortfolioPage(props: Props) { const [professionalTab, setProfessionalTab] = createSignal('About'); const [professionalForm, setProfessionalForm] = createSignal({ ...EMPTY_PROFESSIONAL_FORM }); const [professionalMsg, setProfessionalMsg] = createSignal(''); + const [portfolioTopTab, setPortfolioTopTab] = createSignal<'edit' | 'preview'>('edit'); const loadItems = async () => { if (!isProfessional()) { setLoading(false); return; } @@ -202,7 +395,7 @@ export default function PortfolioPage(props: Props) { }) .filter((tab) => Boolean(tab) && allowed.has(normalizeToken(tab))); const unique = Array.from(new Set(mapped)); - return unique.length ? unique : JOB_SEEKER_FALLBACK_TABS; + return unique.length ? unique : JOB_SEEKER_SAFE_TABS; }; const runtimeFieldsByTab = () => { @@ -235,17 +428,14 @@ export default function PortfolioPage(props: Props) { .map((tab) => { const t = normalizeToken(tab); if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About'; - if (t.includes('service') || t.includes('pricing') || t.includes('package')) return 'Services & Pricing'; - if (t.includes('project') || t.includes('portfolio') || t.includes('gallery') || t.includes('showreel')) return 'Projects'; - if (t.includes('stack') || t.includes('experience') || t.includes('qualification') || t.includes('tool')) return props.roleKey === 'DEVELOPER' ? 'Tech Stack & Experience' : 'Experience'; - if (t.includes('testimonial') || t.includes('review')) return 'Testimonials'; + if (t.includes('testimonial') || t.includes('review')) return ''; if (t.includes('faq') || t.includes('question')) return 'FAQs'; - return ''; + return tab; }) .filter(Boolean); const uniqueRuntime = Array.from(new Set(fromRuntime)); - if (uniqueRuntime.length >= 3) return uniqueRuntime; - return PROFESSIONAL_PORTFOLIO_TABS[props.roleKey] || PROFESSIONAL_PORTFOLIO_TABS.default; + if (uniqueRuntime.length) return uniqueRuntime; + return PROFESSIONAL_SAFE_TABS[props.roleKey] || PROFESSIONAL_SAFE_TABS.default; }; const professionalFormStorageKey = () => `nxtgauge_portfolio_meta_${String(props.roleKey || 'professional').toLowerCase()}`; @@ -257,10 +447,11 @@ export default function PortfolioPage(props: Props) { const parsed = JSON.parse(raw) as Partial; setProfessionalForm({ about: String(parsed?.about || ''), - services: String(parsed?.services || ''), - experience: String(parsed?.experience || ''), - testimonials: String(parsed?.testimonials || ''), - faqs: String(parsed?.faqs || ''), + services: parseServiceEntries(parsed?.services), + experience: parseExperienceEntries(parsed?.experience), + testimonials: parseTestimonials(parsed?.testimonials), + faqs: parseFaqEntries(parsed?.faqs), + tools: parseTools((parsed as any)?.tools), }); } catch { // Ignore malformed local storage payloads. @@ -279,19 +470,25 @@ export default function PortfolioPage(props: Props) { if (isJobSeeker()) { loadJobSeekerPortfolio(); const tabs = runtimePortfolioTabs(); - setJobSeekerTab(tabs[0] || 'About'); + setJobSeekerTab(tabs[0] || ''); setLoading(false); return; } if (isProfessional()) { const tabs = professionalTabs(); - setProfessionalTab(tabs[0] || 'About'); + setProfessionalTab(tabs[0] || ''); loadProfessionalForm(); } void loadItems(); }); const openCreate = () => { + const config = rolePortfolioConfig(); + if (config.mediaMode === 'visual' && isMediaTab() && items().length >= config.mediaLimit) { + setProfessionalMsg(`You can add up to ${config.mediaLimit} showcase images for ${props.roleKey.toLowerCase().replace(/_/g, ' ')}.`); + window.setTimeout(() => setProfessionalMsg(''), 2200); + return; + } setEditId(null); setForm({ ...EMPTY_FORM }); setError(''); @@ -299,11 +496,13 @@ export default function PortfolioPage(props: Props) { }; const openEdit = (item: PortfolioItem) => { + const parsedMedia = parseMediaDescription(item.description); setEditId(item.id); setForm({ title: item.title, - description: item.description ?? '', + description: parsedMedia.description, tags: (item.tags ?? []).join(', '), + mediaUrl: parsedMedia.mediaUrl, }); setError(''); setShowForm(true); @@ -321,11 +520,18 @@ export default function PortfolioPage(props: Props) { const handleSave = async () => { if (!form().title.trim()) { setError('Title is required.'); return; } + if (rolePortfolioConfig().mediaMode === 'visual' && isMediaTab() && !form().mediaUrl.trim()) { + setError('Image URL is required for showcase items.'); + return; + } setSaving(true); setError(''); + const descriptionPayload = rolePortfolioConfig().mediaMode === 'visual' && isMediaTab() + ? buildMediaDescription(form().mediaUrl, form().description) + : form().description.trim(); const payload = { title: form().title.trim(), - description: form().description.trim() || undefined, + description: descriptionPayload || undefined, tags: form().tags .split(',') .map((t) => t.trim()) @@ -386,7 +592,7 @@ export default function PortfolioPage(props: Props) { }; const tabs = runtimePortfolioTabs(); const fieldsByTab = runtimeFieldsByTab(); - const activeTab = () => tabs.includes(jobSeekerTab()) ? jobSeekerTab() : (tabs[0] || 'About'); + const activeTab = () => tabs.includes(jobSeekerTab()) ? jobSeekerTab() : tabs[0]; const activeFields = () => fieldsByTab[activeTab()] || []; const isLongField = (field: string) => { const key = normalizeToken(field); @@ -482,7 +688,7 @@ export default function PortfolioPage(props: Props) { -
@@ -502,35 +708,49 @@ export default function PortfolioPage(props: Props) { ); } + const rolePortfolioConfig = () => { + const tabs = professionalTabs(); + const serviceTabLabel = tabs.find((tab) => isServiceLikeTab(tab)) || ''; + const experienceTabLabel = tabs.find((tab) => isExperienceLikeTab(tab)) || ''; + const mediaTabLabel = tabs.find((tab) => isMediaLikeTab(tab)) || ''; + const mediaMode = mediaTabLabel ? (isVisualMediaTab(mediaTabLabel) ? 'visual' : 'text') : 'none'; + const mediaLimit = mediaMode === 'visual' ? 6 : mediaMode === 'text' ? 8 : 0; + return { + tabs, + serviceTabLabel, + experienceTabLabel, + mediaTabLabel, + mediaMode, + mediaLimit, + } as PortfolioRoleConfig; + }; + + const isMediaTab = () => { + const tab = normalizeToken(professionalTab()); + const mediaLabel = normalizeToken(rolePortfolioConfig().mediaTabLabel || ''); + return tab === mediaLabel || tab.includes('project') || tab.includes('portfolio') || tab.includes('gallery') || tab.includes('showreel') || tab.includes('case_stud') || tab.includes('student_work') || tab.includes('client_result'); + }; + const isProjectsTab = () => { const key = normalizeToken(professionalTab()); - return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel'); + return isMediaTab() || key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel'); }; const isServicesTab = () => { const key = normalizeToken(professionalTab()); - return key.includes('service') || key.includes('pricing') || key.includes('package'); + return key.includes('service') || key.includes('pricing') || key.includes('package') || key.includes('subject') || key.includes('training'); }; const isTestimonialsTab = () => normalizeToken(professionalTab()).includes('testimonial'); const isFaqTab = () => normalizeToken(professionalTab()).includes('faq'); - const sectionFieldKey = () => { - if (isServicesTab()) return 'services' as const; - if (isTestimonialsTab()) return 'testimonials' as const; - if (isFaqTab()) return 'faqs' as const; + const isExperienceTab = () => { const key = normalizeToken(professionalTab()); - if (key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification')) return 'experience' as const; - return 'about' as const; + return key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification') || key.includes('certification'); }; - - const sectionPlaceholder = () => { - if (isServicesTab()) return 'List your plans, pricing slabs, and deliverables...'; - if (isTestimonialsTab()) return 'Add client quotes and project outcomes...'; - if (isFaqTab()) return 'Add common questions and answers...'; - if (sectionFieldKey() === 'experience') return 'Share stack, years of experience, and toolchain...'; - return 'Write a short summary about your profile and strengths...'; + const isAboutTab = () => { + const key = normalizeToken(professionalTab()); + return !isProjectsTab() && !isServicesTab() && !isTestimonialsTab() && !isFaqTab() && !isExperienceTab(); }; - return (
@@ -539,16 +759,16 @@ export default function PortfolioPage(props: Props) { {(tab) => ( )} +

My Portfolio

- Runtime-config driven tab layout aligned with external dashboard preview. + {portfolioTopTab() === 'preview' ? 'Preview how your portfolio appears to customers.' : 'Manage your portfolio content and showcase your work.'}

- -
- - -
- {professionalMsg()} -
-
-

{professionalTab()}

-
- -