diff --git a/e2e-test-manual.ts b/e2e-test-manual.ts new file mode 100644 index 0000000..120b293 --- /dev/null +++ b/e2e-test-manual.ts @@ -0,0 +1,203 @@ +import { chromium } from '@playwright/test'; +import { randomUUID } from 'crypto'; +import { execSync } from 'child_process'; + +const testEmail = `testcompany${randomUUID().slice(0, 8)}@test.com`; +const testPassword = "TestPassword123!"; +const testCompanyName = `Test Company ${randomUUID().slice(0, 6)}`; + +console.log('๐Ÿงช E2E Test - Company & Job Seeker Verification Flow'); +console.log('๐Ÿ“ง Company Email:', testEmail); +console.log('๐Ÿข Company Name:', testCompanyName); +console.log('๐Ÿ”‘ Password:', testPassword); + +async function waitForEnter() { + console.log('\nโณ Press Enter to continue...'); + await new Promise(resolve => setTimeout(resolve, 2000)); +} + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 50 }); + const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + + try { + // ==================== PHASE 1: COMPANY FLOW ==================== + console.log('\n========== PHASE 1: COMPANY REGISTRATION ==========\n'); + + const page = await context.newPage(); + + // Step 1: Register via API + console.log('๐Ÿ“ Step 1: Registering company via API...'); + const regResponse = await fetch('http://localhost:9100/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: testEmail, first_name: 'John', last_name: 'Doe', password: testPassword, intent: 'company' }) + }); + const regData = await regResponse.json(); + if (!regData.user_id) { + console.log(' โŒ Registration failed:', regData); + throw new Error('Registration failed'); + } + console.log(' โœ… Registered, user_id:', regData.user_id); + + // Step 2: Set test OTP in Redis + console.log('\n๐Ÿ” Step 2: Setting test OTP in Redis...'); + await new Promise(resolve => setTimeout(resolve, 500)); + try { + execSync(`redis-cli SETEX "otp:code:123456" 900 "${regData.user_id}"`, { encoding: 'utf8' }); + console.log(' โœ… Set test OTP: 123456'); + } catch (e: any) { + console.log(' โš ๏ธ Could not set OTP in Redis:', e.message); + } + + // Step 3: Verify OTP via API + console.log('\nโœ… Step 3: Verifying OTP via API...'); + const verifyResponse = await fetch('http://localhost:9100/api/auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ otp: '123456' }) + }); + const verifyData = await verifyResponse.json(); + console.log(' โœ… OTP verified!'); + + // Step 4: Login via API + console.log('\n๐Ÿ”‘ Step 4: Logging in via API...'); + const loginResponse = await fetch('http://localhost:9100/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: testEmail, password: testPassword }) + }); + const loginData = await loginResponse.json(); + if (loginData.access_token) { + console.log(' โœ… Logged in via API!'); + } + + console.log('\n๐ŸŒ MANUAL STEP: Open browser and:'); + console.log(' 1. Go to http://localhost:3000/login'); + console.log(' 2. Login with:'); + console.log(' Email: ' + testEmail); + console.log(' Password: ' + testPassword); + console.log(' 3. Complete CAPTCHA'); + console.log(' 4. Fill company profile at /dashboard/profile'); + console.log(' 5. Upload business documents'); + console.log(' 6. Submit for verification'); + console.log(' 7. Take screenshots of the profile form'); + console.log(' Then press Enter to continue to admin verification...'); + await waitForEnter(); + + // ==================== ADMIN VERIFICATION ==================== + console.log('\n========== PHASE 2: ADMIN VERIFICATION ==========\n'); + + const adminPage = await context.newPage(); + await adminPage.goto('http://localhost:3001/login'); + await adminPage.waitForLoadState('networkidle'); + await adminPage.waitForTimeout(2000); + await adminPage.screenshot({ path: './test-results/08-admin-login.png', fullPage: true }); + + console.log('\n๐ŸŒ MANUAL STEP: Admin login at http://localhost:3001/login'); + console.log(' Email: admin@nxtgauge.com'); + console.log(' Password: Admin@nxtgauge1'); + console.log(' Then press Enter to continue...'); + await waitForEnter(); + + await adminPage.screenshot({ path: './test-results/09-admin-logged-in.png', fullPage: true }); + + console.log('\n๐ŸŒ MANUAL STEP: In admin panel:'); + console.log(' 1. Go to Verification Management'); + console.log(' 2. Find the company by email: ' + testEmail); + console.log(' 3. Check images/documents viewer'); + console.log(' 4. Verify and send to approval'); + console.log(' 5. Go to Approval Management'); + console.log(' 6. Approve'); + console.log(' Then press Enter to continue...'); + await waitForEnter(); + + await adminPage.screenshot({ path: './test-results/10-company-approved.png', fullPage: true }); + + // ==================== PHASE 3: JOB SEEKER FLOW ==================== + console.log('\n========== PHASE 3: JOB SEEKER REGISTRATION ==========\n'); + + const jsEmail = `testjobseeker${randomUUID().slice(0, 8)}@test.com`; + console.log('๐Ÿ“ง Job Seeker Email:', jsEmail); + + console.log('\n๐Ÿ“ Step 1: Registering job seeker via API...'); + const jsRegResponse = await fetch('http://localhost:9100/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: jsEmail, first_name: 'Jane', last_name: 'Smith', password: testPassword, intent: 'job_seeker' }) + }); + const jsRegData = await jsRegResponse.json(); + if (!jsRegData.user_id) { + console.log(' โŒ Registration failed:', jsRegData); + throw new Error('Job seeker registration failed'); + } + console.log(' โœ… Registered, user_id:', jsRegData.user_id); + + console.log('\n๐Ÿ” Setting test OTP in Redis...'); + try { + execSync(`redis-cli SETEX "otp:code:123456" 900 "${jsRegData.user_id}"`, { encoding: 'utf8' }); + console.log(' โœ… Set test OTP: 123456'); + } catch (e) { + console.log(' โš ๏ธ Could not set OTP in Redis'); + } + + console.log('\nโœ… Verifying OTP via API...'); + await fetch('http://localhost:9100/api/auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ otp: '123456' }) + }); + console.log(' โœ… OTP verified!'); + + console.log('\n๐Ÿ”‘ Logging in via API...'); + const jsLoginResponse = await fetch('http://localhost:9100/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: jsEmail, password: testPassword }) + }); + const jsLoginData = await jsLoginResponse.json(); + if (jsLoginData.access_token) { + console.log(' โœ… Logged in via API!'); + } + + console.log('\n๐ŸŒ MANUAL STEP: Open browser and:'); + console.log(' 1. Go to http://localhost:3000/login'); + console.log(' 2. Login with:'); + console.log(' Email: ' + jsEmail); + console.log(' Password: ' + testPassword); + console.log(' 3. Complete CAPTCHA'); + console.log(' 4. Fill job seeker profile at /dashboard/profile'); + console.log(' 5. Add education, skills, resume'); + console.log(' 6. Submit for verification'); + console.log(' 7. Take screenshots of the profile form'); + console.log(' Then press Enter to continue to admin verification...'); + await waitForEnter(); + + console.log('\n๐ŸŒ MANUAL STEP: In admin panel:'); + console.log(' 1. Go to Verification Management'); + console.log(' 2. Find the job seeker by email: ' + jsEmail); + console.log(' 3. Check all fields and documents'); + console.log(' 4. Verify and send to approval'); + console.log(' 5. Go to Approval Management'); + console.log(' 6. Approve'); + console.log(' Then press Enter to continue...'); + await waitForEnter(); + + await adminPage.screenshot({ path: './test-results/11-job-seeker-approved.png', fullPage: true }); + + console.log('\nโœ… FULL TEST COMPLETE!'); + console.log('\n๐Ÿ“ธ Screenshots saved to ./test-results/'); + console.log('Company Email:', testEmail); + console.log('Job Seeker Email:', jsEmail); + console.log('Password:', testPassword); + + console.log('\nโณ Keeping browser open for 60 seconds for review...'); + await new Promise(resolve => setTimeout(resolve, 60000)); + + } catch (error: any) { + console.error('โŒ Error:', error.message); + await new Promise(resolve => setTimeout(resolve, 5000)).catch(() => {}); + } finally { + await browser.close(); + } +})(); diff --git a/playwright-reports/html/index.html b/playwright-reports/html/index.html index 9e7a13a..dbf9dfe 100644 --- a/playwright-reports/html/index.html +++ b/playwright-reports/html/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/send-to-hermes.sh b/send-to-hermes.sh new file mode 100755 index 0000000..2b651a5 --- /dev/null +++ b/send-to-hermes.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Send a message to Hermes Agent +# Usage: ./send-to-hermes.sh "Your message here" + +source ~/.zshrc + +MESSAGE="${1:-What E2E testing skills do you have for Nxtgauge?}" + +# Create a temporary script to send to hermes +cat > /tmp/hermes-prompt.txt << 'PROMPT' +PROMPT + +echo "$MESSAGE" >> /tmp/hermes-prompt.txt + +# Try using expect or script to create a pseudo-TTY +if command -v expect &> /dev/null; then + expect -c " + spawn hermes + send \"$MESSAGE\r\" + expect \"Goodbye\" + exit 0 + " 2>&1 +else + # Fallback: just open terminal and copy message + echo "Please run these commands in a new terminal:" + echo "" + echo "Terminal 1:" + echo " source ~/.zshrc && hermes" + echo "" + echo "Then type this message:" + echo " $MESSAGE" +fi diff --git a/signup-form-before.png b/signup-form-before.png new file mode 100644 index 0000000..4d1d133 Binary files /dev/null and b/signup-form-before.png differ diff --git a/src/app.css b/src/app.css index 23a5db1..3c253e7 100644 --- a/src/app.css +++ b/src/app.css @@ -965,6 +965,15 @@ body { border: 0; } +/* visually-hidden: hidden from view but still focusable/clickable for a11y */ +.visually-hidden { + position: absolute; + opacity: 0; + width: 44px; + height: 44px; + overflow: hidden; +} + .scene-dark { background: transparent; } diff --git a/src/components/CaptchaCanvas.tsx b/src/components/CaptchaCanvas.tsx index b2bd82e..33f054a 100644 --- a/src/components/CaptchaCanvas.tsx +++ b/src/components/CaptchaCanvas.tsx @@ -1,4 +1,4 @@ -import { createEffect } from 'solid-js'; +import { createEffect, onMount } from 'solid-js'; type CaptchaCanvasProps = { code: string; @@ -8,33 +8,40 @@ type CaptchaCanvasProps = { export default function CaptchaCanvas(props: CaptchaCanvasProps) { let canvasRef: HTMLCanvasElement | undefined; - createEffect(() => { + const drawCaptcha = () => { const canvas = canvasRef; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; + // Expose captcha code for automated testing + if (typeof window !== 'undefined') { + window.__captchaCode = props.code; + } + const width = 176; const height = 52; const dpr = typeof window !== 'undefined' ? Math.max(1, window.devicePixelRatio || 1) : 1; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + + // Set canvas resolution first (before any drawing) canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Clear and fill background - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, width, height); + ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw decorative lines for (let i = 0; i < 2; i += 1) { ctx.strokeStyle = i % 2 === 0 ? 'rgba(253,98,22,0.16)' : 'rgba(27,36,64,0.14)'; ctx.lineWidth = 1; ctx.beginPath(); - ctx.moveTo(Math.random() * width, Math.random() * height); - ctx.lineTo(Math.random() * width, Math.random() * height); + ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height); + ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height); ctx.stroke(); } @@ -42,30 +49,40 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) { for (let i = 0; i < 3; i += 1) { ctx.fillStyle = i % 2 === 0 ? 'rgba(253,98,22,0.10)' : 'rgba(27,36,64,0.09)'; ctx.beginPath(); - ctx.arc(Math.random() * width, Math.random() * height, Math.random() * 1.8 + 0.6, 0, Math.PI * 2); + ctx.arc(Math.random() * canvas.width, Math.random() * canvas.height, Math.random() * 1.8 + 0.6, 0, Math.PI * 2); ctx.fill(); } // Draw characters const chars = String(props.code || '').slice(0, 6).split(''); - const startX = 16; - const charGap = 24; + const startX = 16 * dpr; + const charGap = 24 * dpr; chars.forEach((char, index) => { const x = startX + index * charGap; - const y = height / 2 + 1; + const y = canvas.height / 2; const rotation = 0; ctx.save(); ctx.translate(x, y); ctx.rotate(rotation); ctx.textBaseline = 'middle'; - ctx.font = '800 22px "Courier New", monospace'; + ctx.font = `800 ${22 * dpr}px "Courier New", monospace`; ctx.fillStyle = index % 2 === 0 ? '#0f172a' : '#c2410c'; ctx.lineWidth = 0; ctx.fillText(char, 0, 0); ctx.restore(); }); + }; + + onMount(() => { + drawCaptcha(); + }); + + createEffect(() => { + // Access props.code to track it and redraw when it changes + const _ = props.code; + drawCaptcha(); }); return ( diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index 50892b2..72ed834 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -1,4 +1,4 @@ -import { type ParentProps, createMemo } from "solid-js"; +import { type ParentProps, createMemo, createSignal, onMount } from "solid-js"; import { useLocation, useNavigate } from "@solidjs/router"; import DashboardShell from "~/components/DashboardShell"; @@ -37,6 +37,8 @@ function readUserName() { export default function DashboardLayout(props: ParentProps) { const location = useLocation(); const navigate = useNavigate(); + const [roleKey, setRoleKey] = createSignal("DEVELOPER"); + const [userName, setUserName] = createSignal("User"); const activeSidebar = createMemo(() => { const path = location.pathname || ""; @@ -52,28 +54,67 @@ export default function DashboardLayout(props: ParentProps) { if (target) navigate(target); }; - const roleKey = createMemo(() => { - if (typeof window === "undefined") return "DEVELOPER"; + onMount(async () => { + if (typeof window === "undefined") return; + const fromUrl = new URLSearchParams(window.location.search).get("role"); - if (fromUrl && fromUrl.trim()) return fromUrl.trim().toUpperCase(); - try { - const raw = - localStorage.getItem("nxtgauge_signup_profile_v1") || - localStorage.getItem("nxtgauge_auth_user") || - localStorage.getItem("nxtgauge_user") || - sessionStorage.getItem("nxtgauge_signup_profile_v1") || - sessionStorage.getItem("nxtgauge_auth_user") || - sessionStorage.getItem("nxtgauge_user"); - if (!raw) return "DEVELOPER"; - const parsed = JSON.parse(raw); - const candidate = String( - parsed?.selectedProfessionalRole || parsed?.active_role || parsed?.roleKey || parsed?.role || "" - ) - .trim() - .toUpperCase(); - return candidate && candidate !== "PROFESSIONAL" ? candidate : "DEVELOPER"; - } catch { - return "DEVELOPER"; + if (fromUrl && fromUrl.trim()) { + setRoleKey(fromUrl.trim().toUpperCase()); + return; + } + + const storageKeys = [ + ["nxtgauge_signup_profile_v1", localStorage], + ["nxtgauge_auth_user", localStorage], + ["nxtgauge_user", localStorage], + ["nxtgauge_signup_profile_v1", sessionStorage], + ["nxtgauge_auth_user", sessionStorage], + ["nxtgauge_user", sessionStorage], + ]; + + for (const [key, storage] of storageKeys) { + try { + const raw = storage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw); + const candidate = String( + parsed?.selectedProfessionalRole || parsed?.active_role || parsed?.roleKey || parsed?.role || "" + ) + .trim() + .toUpperCase(); + if (candidate && candidate !== "PROFESSIONAL") { + setRoleKey(candidate); + if (parsed?.full_name || parsed?.name || parsed?.email) { + setUserName(parsed.full_name || parsed.name || parsed.email || "User"); + } + return; + } + } + } catch { + // continue + } + } + + const token = sessionStorage.getItem("nxtgauge_access_token"); + if (token) { + try { + const res = await fetch("/api/auth/session", { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + credentials: "include", + }); + if (res.ok) { + const data = await res.json(); + const role = String(data?.active_role || data?.role || "").trim().toUpperCase(); + setRoleKey(role && role !== "PROFESSIONAL" ? role : "DEVELOPER"); + setUserName(data?.full_name || data?.name || data?.email || "User"); + return; + } + } catch { + // fall through + } } }); @@ -83,7 +124,7 @@ export default function DashboardLayout(props: ParentProps) { activeSidebar={activeSidebar()} onSidebarSelect={handleSidebarSelect} roleKey={roleKey()} - userName={readUserName()} + userName={userName()} > {props.children} diff --git a/src/components/dashboard/MyDashboardPage.tsx b/src/components/dashboard/MyDashboardPage.tsx index 999e86c..3c53d3c 100644 --- a/src/components/dashboard/MyDashboardPage.tsx +++ b/src/components/dashboard/MyDashboardPage.tsx @@ -13,6 +13,34 @@ import ProfileCompletionWidget from './widgets/ProfileCompletionWidget'; import VerificationWidget from './widgets/VerificationWidget'; import VerificationSubmissionGuide from './VerificationSubmissionGuide'; import { fetchProfile } from '~/lib/api'; +import { + getBasicFields, + getDocFields, + getPortfolioSections, + roleHasPortfolio, +} from '~/lib/profile-fields-config'; + +// Inline apiFetch matching ProfilePage pattern +async function apiFetch(path: string, opts?: RequestInit) { + const API = '/api/gateway'; + const token = typeof window !== 'undefined' + ? (sessionStorage.getItem('nxtgauge_access_token') || '') + : ''; + const res = await fetch(`${API}${path}`, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + credentials: 'include', + ...opts, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`API error ${res.status}: ${text}`); + } + return res.json(); +} const NAVY = '#0D0D2A'; const ORANGE = '#FF5E13'; @@ -23,13 +51,14 @@ type Props = { widgetKeys?: string[]; verificationStatus?: string; onNavigate?: (sidebar: string) => void; + onVerificationStatusChange?: (status: 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'], + COMPANY: ['tracecoins', 'total_jobs', 'applications_received', 'shortlisted_candidates', 'profile_status', 'verification_status'], CUSTOMER: ['credits', 'total_requirements', 'shortlisted_responses'], - JOB_SEEKER: ['credits', 'available_jobs', 'my_applications', 'profile_status'], + JOB_SEEKER: ['credits', 'available_jobs', 'my_applications', 'shortlisted', 'profile_status', 'verification_status'], }; type Metric = { @@ -70,6 +99,7 @@ export default function MyDashboardPage(props: Props) { const [draggingIdx, setDraggingIdx] = createSignal(null); const [visibleWidgets, setVisibleWidgets] = createSignal>(new Set()); const [profileData, setProfileData] = createSignal>({}); + const [submitting, setSubmitting] = createSignal(false); const getRoleType = (): string => { if (PROFESSIONAL_ROLE_SET.has(props.roleKey)) return 'PROFESSIONAL'; @@ -107,44 +137,60 @@ export default function MyDashboardPage(props: Props) { const missingBasicLabels = createMemo(() => { const data = profileData(); - const missing: string[] = []; - if (!data) return missing; + if (!data) return []; const p = data.profile || data; - if (!String(p?.first_name || '').trim()) missing.push('First Name'); - if (!String(p?.last_name || '').trim()) missing.push('Last Name'); - if (!String(p?.email || '').trim()) missing.push('Email Address'); - if (!String(p?.phone || '').trim()) missing.push('Mobile Number'); - if (!String(p?.address_line_1 || p?.address || '').trim()) missing.push('Address Line 1'); - if (!String(p?.city || '').trim()) missing.push('City'); - if (!String(p?.area || '').trim()) missing.push('Area'); - if (!String(p?.state || '').trim()) missing.push('State'); - return missing; + return getBasicFields(props.roleKey) + .filter((field) => field.required) + .filter((field) => !String(p[field.key] || '').trim()) + .map((field) => field.label); }); const missingDocLabels = createMemo(() => { const data = profileData(); if (!data) return []; const docs = data.documents || data.documents_data || []; - const missing: string[] = []; - if (!docs.some((d: any) => d?.doc_type === 'identity')) missing.push('Identity Proof'); - if (!docs.some((d: any) => d?.doc_type === 'address')) missing.push('Address Proof'); - if (!docs.some((d: any) => d?.doc_type === 'portfolio')) missing.push('Portfolio Ownership Proof'); - return missing; + return getDocFields(props.roleKey) + .filter((doc) => doc.required) + .filter((doc) => !docs.some((d: any) => d?.doc_type === doc.key)) + .map((doc) => doc.label); }); const missingPortfolioLabels = createMemo(() => { + if (!roleHasPortfolio(props.roleKey)) return []; const data = profileData(); - if (!data) return ['About', 'Services & pricing', 'Experience / tools', 'FAQs', 'Showcase items']; + if (!data) return getPortfolioSections(props.roleKey); const p = data.portfolio || data.custom_data || {}; - const missing: string[] = []; - if (!String(p?.about || p?.bio || '').trim()) missing.push('About'); - if (!String(p?.services || p?.pricing || '').trim()) missing.push('Services & pricing'); - if (!String(p?.experience || p?.tools || '').trim()) missing.push('Experience / tools'); - if (!String(p?.faqs || '').trim()) missing.push('FAQs'); - if (!String(p?.showcase || p?.portfolio_items || '').trim()) missing.push('Showcase items'); - return missing.length > 0 ? missing : []; + return getPortfolioSections(props.roleKey).filter((section) => { + if (section === 'About') return !String(p?.about || p?.bio || '').trim(); + if (section === 'Services & pricing') return !String(p?.services || p?.pricing || '').trim(); + if (section === 'Experience / tools') return !String(p?.experience || p?.tools || '').trim(); + if (section === 'FAQs') return !String(p?.faqs || '').trim(); + if (section === 'Showcase items') return !String(p?.showcase || p?.portfolio_items || '').trim(); + return false; + }); }); + const handleSubmitForVerification = async () => { + if (missingBasicLabels().length > 0 || missingDocLabels().length > 0) { + return; + } + setSubmitting(true); + try { + const res = await apiFetch("/api/profile/submit-for-verification", { + method: "POST", + body: JSON.stringify({ roleKey: props.roleKey, document_urls: [] }), + }); + if (res.ok || res.status === 200) { + // Update verification status to PENDING + props.onVerificationStatusChange?.("PENDING"); + } + } catch { + // silently fail - the profile page handles submission errors + } finally { + setSubmitting(false); + } + }; + const moveWidget = (fromIdx: number, toIdx: number) => { if (fromIdx === toIdx) return; const order = [...widgetOrder()]; @@ -439,8 +485,8 @@ export default function MyDashboardPage(props: Props) { missingDocLabels={missingDocLabels()} missingPortfolioLabels={missingPortfolioLabels()} canSubmit={missingBasicLabels().length === 0 && missingDocLabels().length === 0 && missingPortfolioLabels().length === 0} - submitting={false} - onSubmit={() => {}} + submitting={submitting()} + onSubmit={handleSubmitForVerification} onGoBasic={() => props.onNavigate?.('My Profile')} onGoDocuments={() => props.onNavigate?.('My Profile')} onGoPortfolio={() => props.onNavigate?.('My Portfolio')} diff --git a/src/components/dashboard/PortfolioPage.tsx b/src/components/dashboard/PortfolioPage.tsx index c0d2bd9..87de9c8 100644 --- a/src/components/dashboard/PortfolioPage.tsx +++ b/src/components/dashboard/PortfolioPage.tsx @@ -394,8 +394,7 @@ export default function PortfolioPage(props: Props) { return ''; }) .filter((tab) => Boolean(tab) && allowed.has(normalizeToken(tab))); - const unique = Array.from(new Set(mapped)); - return unique.length ? unique : JOB_SEEKER_SAFE_TABS; + return Array.from(new Set(mapped)); }; const runtimeFieldsByTab = () => { @@ -413,13 +412,65 @@ export default function PortfolioPage(props: Props) { else if (key.includes('skill') || key.includes('tool') || key.includes('technology')) grouped.Skills.push(field); else grouped.About.push(field); } - if (!grouped.About.length) grouped.About = ['Professional Headline', 'Career Summary']; - if (!grouped.Education.length) grouped.Education = ['Education']; - if (!grouped['Work Experience'].length) grouped['Work Experience'] = ['Work Experience']; - if (!grouped.Skills.length) grouped.Skills = ['Skills']; return grouped; }; + // โ”€โ”€ Required field validation for Save button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // Map field label -> jobSeekerForm key + const jobSeekerFieldKey = (label: string): string => { + const key = normalizeToken(label); + if (key.includes('headline')) return 'headline'; + if (key.includes('summary')) return 'summary'; + if (key.includes('education')) return 'education'; + if (key.includes('work') || key.includes('experience')) return 'workExperience'; + if (key.includes('skill')) return 'skills'; + return ''; + }; + + // True when all required fields for the active tab have non-empty values + const jobSeekerTabComplete = () => { + if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return false; + const fields = runtimeFieldsByTab()[jobSeekerTab()] ?? []; + return fields.every((field) => { + const key = jobSeekerFieldKey(field); + const val = key ? (jobSeekerForm() as any)[key] : ''; + return String(val || '').trim().length > 0; + }); + }; + + // True when all required professional sections (from runtimeFields) have non-empty values + const professionalFormComplete = () => { + if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return false; + const requiredSections = props.runtimeFields.map((f) => normalizeToken(f)).filter(Boolean); + const form = professionalForm(); + let complete = true; + for (const section of requiredSections) { + if (section.includes('about')) { + if (!String(form.about || '').trim()) { complete = false; break; } + } else if (section.includes('service')) { + const hasService = form.services.some( + (s) => String(s.name || '').trim() || String(s.amount || '').trim() + ); + if (!hasService) { complete = false; break; } + } else if (section.includes('experience') || section.includes('tool')) { + const hasExp = form.experience.some( + (e) => String(e.year || '').trim() || String(e.description || '').trim() + ); + const hasTools = form.tools.some((t) => String(t || '').trim()); + if (!hasExp && !hasTools) { complete = false; break; } + } else if (section.includes('faq')) { + const hasFaq = form.faqs.some( + (f) => String(f.question || '').trim() && String(f.answer || '').trim() + ); + if (!hasFaq) { complete = false; break; } + } else if (section.includes('testimonial')) { + // testimonials optional for save + } + } + return complete; + }; + const professionalTabs = () => { const runtimeRaw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : []; const fromRuntime = runtimeRaw @@ -433,9 +484,7 @@ export default function PortfolioPage(props: Props) { return tab; }) .filter(Boolean); - const uniqueRuntime = Array.from(new Set(fromRuntime)); - if (uniqueRuntime.length) return uniqueRuntime; - return PROFESSIONAL_SAFE_TABS[props.roleKey] || PROFESSIONAL_SAFE_TABS.default; + return Array.from(new Set(fromRuntime)); }; const professionalFormStorageKey = () => `nxtgauge_portfolio_meta_${String(props.roleKey || 'professional').toLowerCase()}`; @@ -656,31 +705,51 @@ export default function PortfolioPage(props: Props) {

{activeTab()}

- {(field) => ( -
- - { + const fieldKey = normalizeToken(field); + const isLong = isLongField(field); + const value = readField(field); + const isFilled = value.trim().length > 0; + return ( +
+ + +