diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f9dc7be..a754065 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,6 +23,7 @@ import PrivacyModal from './components/PrivacyModal'; import AuthLinkPage from './components/AuthLinkPage'; import UserNav from './components/UserNav'; import ThemeToggle from './components/ThemeToggle'; +import LandingPage from './components/landing/LandingPage'; import { getResumes, getApplications, @@ -39,12 +40,31 @@ export default function App() { const [authMode, setAuthMode] = useState('login'); const [currentPath, setCurrentPath] = useState(() => (typeof window !== 'undefined' ? window.location.pathname : '/')); + const navigateTo = (path) => { + if (typeof window !== 'undefined') { + window.history.pushState(null, '', path); + } + setCurrentPath(path); + if (typeof window !== 'undefined') { + window.scrollTo(0, 0); + } + }; + useEffect(() => { const onPopState = () => setCurrentPath(window.location.pathname); window.addEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState); }, []); + useEffect(() => { + if (getCurrentUser() && window.location.pathname === '/') { + if (typeof window !== 'undefined') { + window.history.replaceState(null, '', '/app'); + } + setCurrentPath('/app'); + } + }, []); + const [theme, setTheme] = useState(() => { return localStorage.getItem('jobhelperguru_theme') || 'light'; @@ -53,8 +73,10 @@ export default function App() { useEffect(() => { if (theme === 'dark') { document.documentElement.classList.add('dark'); + document.body.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); } localStorage.setItem('jobhelperguru_theme', theme); }, [theme]); @@ -235,9 +257,9 @@ export default function App() { <> setCurrentPath('/')} + onGoHome={() => navigateTo('/')} onOpenAuth={(mode) => { - setCurrentPath('/'); + navigateTo('/'); setAuthMode(mode || 'login'); setIsAuthOpen(true); }} @@ -255,6 +277,57 @@ export default function App() { ); } + if (currentPath !== '/app') { + return ( + <> + { + setAuthMode('login'); + setIsAuthOpen(true); + }} + onGetStarted={() => { + setAuthMode('register'); + setIsAuthOpen(true); + }} + onGoToDashboard={() => navigateTo('/app')} + onExploreGuest={() => navigateTo('/app')} + onOpenPrivacy={() => setIsPrivacyOpen(true)} + /> + setIsAuthOpen(false)} + initialMode={authMode} + onSuccess={async (user, isNewUser = false) => { + sessionRef.current += 1; + resetAccountState(); + setCurrentUser(user); + await loadInitialData(); + try { + const settings = await getSettings(); + if (isNewUser || (!settings?.has_api_key && !settings?.use_offline_mode)) { + setIsOnboardingSettings(true); + setIsSettingsOpen(true); + } + } catch (e) { + if (isNewUser) { + setIsOnboardingSettings(true); + setIsSettingsOpen(true); + } + } + navigateTo('/app'); + }} + /> + setIsPrivacyOpen(false)} + /> + + ); + } + return (
@@ -262,7 +335,11 @@ export default function App() {
{/* Brand Logo */} -
setActiveTab('analyzer')}> +
navigateTo('/')} + >
diff --git a/frontend/src/components/landing/FaqSection.jsx b/frontend/src/components/landing/FaqSection.jsx new file mode 100644 index 0000000..5d0645d --- /dev/null +++ b/frontend/src/components/landing/FaqSection.jsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { ChevronDown, HelpCircle } from 'lucide-react'; + +export default function FaqSection() { + const [openIndex, setOpenIndex] = useState(null); + + const faqs = [ + { + q: 'Is JobHelperGuru free to use?', + a: 'Yes! JobHelperGuru includes a 100% free offline heuristic NLP mode that works immediately with zero API keys required. If you want ultra-fast cloud model inference, you can optionally bring your own API key for Groq, Nvidia NIM, OpenAI, or connect a local Ollama instance.', + }, + { + q: 'What is the BulletCraft Framework?', + a: 'The BulletCraft Framework is an accomplishment engineering formula: Accomplished [X], measured by [Y], by doing [Z]. Instead of listing duties (e.g., "Worked on APIs"), it articulates measurable scale, impact, and tooling (e.g., "Engineered 14 resilient FastAPI microservices handling 2.4M daily requests, cutting p99 latency by 38% using Redis").', + }, + { + q: 'Are my resumes and credentials secure?', + a: 'Absolutely. Your private API keys are encrypted at rest using AES-256 Fernet authenticated symmetric encryption. Resumes are isolated per user in private Cloudflare R2 object storage and retrieved strictly via time-limited presigned URLs. No third-party data selling or public indexing.', + }, + { + q: 'Can I export my pipeline data?', + a: 'Yes. With one click, you can download a formatted, styled Excel (.xlsx) spreadsheet complete with status indicators, interview round notes, and application links for offline archiving and audit tracking.', + }, + ]; + + return ( +
+
+ + Frequently Asked Questions + +

+ Got Questions? We Have Answers. +

+
+ +
+ {faqs.map((faq, idx) => { + const isOpen = openIndex === idx; + return ( +
+ + + {isOpen && ( +
+ {faq.a} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/landing/HowItWorks.jsx b/frontend/src/components/landing/HowItWorks.jsx new file mode 100644 index 0000000..c716500 --- /dev/null +++ b/frontend/src/components/landing/HowItWorks.jsx @@ -0,0 +1,81 @@ +import { FileSearch, Sparkles, FileSpreadsheet, ArrowRight } from 'lucide-react'; + +export default function HowItWorks() { + const steps = [ + { + number: '01', + icon: FileSearch, + title: 'Parse the True Requirements', + description: + 'Drop in a job posting URL or raw description. JobHelperGuru automatically extracts standardized job titles, compensation packages, required vs. preferred tech stacks, and hidden ATS keyword indexes.', + }, + { + number: '02', + icon: Sparkles, + title: 'Optimize Bullets & Pick the Best Resume', + description: + 'Audit your resume library to select the highest-scoring resume. Use the BulletCraft Framework to transform weak bullet points into quantifiable achievements, and generate tailored outreach pitches in seconds.', + }, + { + number: '03', + icon: FileSpreadsheet, + title: 'Track Deadlines & Export to Excel', + description: + 'Seamlessly progress applications across Kanban stages (Wishlist, Applied, Interviewing, Offered). Get automatic follow-up reminders and export clean, styled Excel spreadsheets for offline recordkeeping.', + }, + ]; + + return ( +
+
+
+ + 3-Step Workflow + +

+ How JobHelperGuru Accelerates Your Hunt +

+

+ From discovering a posting to negotiating an offer, streamline every phase of your job search in minutes. +

+
+ +
+ {steps.map((step, idx) => { + const Icon = step.icon; + return ( +
+
+
+ + {step.number} + +
+ +
+
+ +

+ {step.title} +

+

+ {step.description} +

+
+ + {idx < steps.length - 1 && ( +
+ +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/landing/InteractiveHeroPlayground.jsx b/frontend/src/components/landing/InteractiveHeroPlayground.jsx new file mode 100644 index 0000000..7ef466a --- /dev/null +++ b/frontend/src/components/landing/InteractiveHeroPlayground.jsx @@ -0,0 +1,491 @@ +import { useState } from 'react'; +import { + Wand2, + Sparkles, + CheckCircle2, + TrendingUp, + Target, + FileSearch, + Layers, + ArrowRight, + Clock, + DollarSign, + Building2, + MapPin, + GraduationCap, + ShieldCheck, + RefreshCw, +} from 'lucide-react'; + +export default function InteractiveHeroPlayground({ onTryLiveDemo }) { + const [activeTab, setActiveTab] = useState('bulletcraft'); + + // Tab 1: BulletCraft state + const [isTransformed, setIsTransformed] = useState(false); + + // Tab 2: ATS Matcher state + const [selectedRole, setSelectedRole] = useState('backend'); + const [hasAdoptedSkill, setHasAdoptedSkill] = useState(false); + + // Tab 3: Scraper state + const [scraperUrl, setScraperUrl] = useState('https://boards.greenhouse.io/stripe/jobs/5829103'); + + // Tab 4: Pipeline CRM state + const [pipelineStatus, setPipelineStatus] = useState('Applied'); + + return ( +
+ {/* Interactive Tabs Header */} +
+ + + + + + + +
+ + {/* Tab Panels Body */} +
+ {/* TAB 1: BulletCraft Framework */} + {activeTab === 'bulletcraft' && ( +
+
+
+ + Accomplishment Engineering + +

+ Transform Generic Bullets into Quantifiable Impact +

+
+ +
+ + {/* Before / After Comparison Display */} +
+ {/* Weak Bullet Card */} +
+
+ + Weak / Generic Bullet + + ATS Conversion: Low +
+

+ “Responsible for writing backend APIs and optimizing database queries for our services.” +

+
+ ✕ + Lacks quantifiable metrics, scale, and technical depth. +
+
+ + {/* BulletCraft Transformed Card */} +
+
+ + + BulletCraft Formula + + + + ATS Conversion: High + +
+ + {isTransformed ? ( +
+

+ “Engineered 14 resilient FastAPI microservices handling 2.4M daily requests, reducing p99 latency by 38% through Redis caching and PostgreSQL query indexing.” +

+ + {/* Structural XYZ Tags */} +
+
+ + Accomplished [X]: Action +
+
+ + Measured by [Y]: 2.4M reqs & -38% latency +
+
+ + Done by [Z]: Redis & PostgreSQL +
+
+
+ ) : ( +
+

+ Click “Transform with BulletCraft” to see the accomplishment formula in action. +

+ +
+ )} +
+
+
+ )} + + {/* TAB 2: ATS Fit Matcher */} + {activeTab === 'ats' && ( +
+
+
+ + Smart Multi-Resume Scoring + +

+ Match Keywords & Eliminate ATS Gaps +

+
+ {/* Role Switcher */} +
+ + +
+
+ + {/* ATS Score Meter & Keywords Grid */} +
+
+
+
+ ATS Match Score + + Best Fit Resume + +
+
+ {hasAdoptedSkill ? '96%' : '88%'} +
+
+ + +
+ + {/* Keyword Pills */} +
+
+ + + Matched Keywords ({hasAdoptedSkill ? 5 : 4}) + +
+ + Python + + + PostgreSQL + + + Redis + + + Docker + + {hasAdoptedSkill && ( + + Kubernetes (Adopted) + + )} +
+
+ +
+ + + Missing Keywords ({hasAdoptedSkill ? 1 : 2}) + +
+ {!hasAdoptedSkill && ( + + Kubernetes + + )} + + gRPC + +
+
+
+
+
+ )} + + {/* TAB 3: 1-Click Job Scraper */} + {activeTab === 'scraper' && ( +
+
+ + Structured Intelligence + +

+ Parse Any Job Description or ATS URL +

+
+ + {/* Simulated Input Bar */} +
+ + setScraperUrl(e.target.value)} + className="w-full bg-transparent text-xs text-[#000000] dark:text-[#f3f6f8] focus:outline-none" + /> + + Parsed + +
+ + {/* Parsed Output Card */} +
+
+
+

+ Senior Software Engineer - Payments Infrastructure +

+
+ + + Stripe + + + + Hybrid (San Francisco, CA) + + + + $140,000 - $175,000 USD + +
+
+ +
+ + Eligible: 2024-2026 Graduates +
+
+ +
+ + ATS Extracted Taxonomy + +
+ + Distributed Systems + + + FastAPI + + + PostgreSQL + + + Kafka + + + High Availability + +
+
+
+
+ )} + + {/* TAB 4: Pipeline CRM */} + {activeTab === 'crm' && ( +
+
+
+ + Application CRM & Kanban + +

+ Automate Follow-Ups & Never Lose an Application Link +

+
+ +
+ + {/* Kanban Preview Card */} +
+
+
+

+ Stripe - Senior Infrastructure Engineer +

+

+ Current Status: {pipelineStatus} +

+
+
+ + Follow-up alert: Due in 3 days +
+
+ + {/* Progress Flow Steps */} +
+
Wishlist
+
+ Applied +
+
+ Interviewing +
+
+ Offer +
+
+
+
+ )} +
+ + {/* Playground Bottom Action Bar */} +
+
+ + Interactive preview based on real JobHelperGuru NLP capabilities. +
+ +
+
+ ); +} diff --git a/frontend/src/components/landing/LandingFooter.jsx b/frontend/src/components/landing/LandingFooter.jsx new file mode 100644 index 0000000..ed13d38 --- /dev/null +++ b/frontend/src/components/landing/LandingFooter.jsx @@ -0,0 +1,48 @@ +import { Briefcase } from 'lucide-react'; + +export default function LandingFooter({ onOpenPrivacy }) { + return ( +
+
+ {/* Brand info */} +
+
+ +
+
+ JobHelperGuru +

+ Built for smarter, tailored job applications & ATS optimization. +

+
+
+ + {/* Links and privacy */} +
+ + Interactive Demo + + • + + Features + + • + + How It Works + + • + +
+
+ +
+ © {new Date().getFullYear()} JobHelperGuru. All rights reserved. +
+
+ ); +} diff --git a/frontend/src/components/landing/LandingNav.jsx b/frontend/src/components/landing/LandingNav.jsx new file mode 100644 index 0000000..c4fdf61 --- /dev/null +++ b/frontend/src/components/landing/LandingNav.jsx @@ -0,0 +1,85 @@ +import { Briefcase, ArrowRight } from 'lucide-react'; +import ThemeToggle from '../ThemeToggle'; + +export default function LandingNav({ + currentUser, + onSignIn, + onGetStarted, + onGoToDashboard, + theme, + onToggleTheme, +}) { + return ( +
+
+ {/* Brand Logo */} + +
+ +
+
+ + JobHelperGuru + +

+ AI Job Tailoring & BulletCraft Engine +

+
+
+ + {/* Anchor Links (Desktop) */} + + + {/* Action Controls */} +
+ + + {currentUser ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/landing/LandingPage.jsx b/frontend/src/components/landing/LandingPage.jsx new file mode 100644 index 0000000..472e1ff --- /dev/null +++ b/frontend/src/components/landing/LandingPage.jsx @@ -0,0 +1,256 @@ +import { + Sparkles, + ArrowRight, + ShieldCheck, + Zap, + Lock, + Cpu, + Database, + BarChart3, + CheckCircle, +} from 'lucide-react'; +import LandingNav from './LandingNav'; +import InteractiveHeroPlayground from './InteractiveHeroPlayground'; +import ValuePillars from './ValuePillars'; +import HowItWorks from './HowItWorks'; +import FaqSection from './FaqSection'; +import LandingFooter from './LandingFooter'; + +export default function LandingPage({ + currentUser, + theme, + onToggleTheme, + onSignIn, + onGetStarted, + onGoToDashboard, + onExploreGuest, + onOpenPrivacy, +}) { + return ( +
+ {/* Navigation */} + + + {/* Hero Section */} +
+ {/* Subtle background glow */} +
+ +
+ {/* Eyebrow badge */} +
+ + BulletCraft Framework • ATS Fit Ranker • 100% Free Heuristic NLP Option +
+ + {/* Main Headline */} +

+ Turn Job Postings into Interview Calls with{' '} + AI-Tailored Resumes +

+ + {/* Subtitle */} +

+ Extract ATS keywords from any job posting, score your resume portfolio against ATS filters, + engineer quantifiable accomplishment bullets using the BulletCraft Framework, and organize + your entire pipeline in one secure workspace. +

+ + {/* Dual CTAs */} +
+ {currentUser ? ( + + ) : ( + + )} + + +
+ + {/* Impact Stats Strip */} +
+
+
+ 75% +
+

+ Resumes filtered by automated ATS algorithms before human review +

+
+
+
+ 3x +
+

+ Higher interview callbacks with quantifiable BulletCraft metrics +

+
+
+
+ 100% +
+

+ Private with AES-256 Fernet encryption and Cloudflare R2 vaults +

+
+
+
+
+ + {/* Interactive Micro-Demo Section */} +
+
+ + Interactive Playground + +

+ Test the Core Engine Live +

+

+ Try our accomplishment engineering formula, keyword matcher, and job parser below. +

+
+ + +
+ + {/* Core Capabilities */} + + + {/* 3-Step Workflow */} + + + {/* Security Architecture Callout */} +
+
+
+ + Enterprise Data Protection & BYOK + +

+ Your Credentials and Resumes Never Leak +

+

+ Unlike commercial job assistants that monetize candidate data or force proprietary subscriptions, + JobHelperGuru is built with privacy-first isolation from day one. +

+
+ +
+
+
+ +
+
+

+ AES-256 Fernet Encryption +

+

+ API keys for Groq, Nvidia NIM, OpenAI, and Ollama are encrypted at rest with symmetric keys. +

+
+
+ +
+
+ +
+
+

+ Cloudflare R2 Secure Vault +

+

+ Resumes are stored in private user-partitioned object storage with zero egress fees and presigned URLs. +

+
+
+ +
+
+ +
+
+

+ 100% Free Offline Mode +

+

+ Use our built-in heuristic NLP engine for job parsing, keyword audits, and scoring without any API costs. +

+
+
+
+
+
+ + {/* FAQ Section */} + + + {/* Bottom CTA Banner */} +
+
+

+ Ready to Stop Getting Filtered by ATS? +

+

+ Create your free account in seconds or launch the guest workspace with no sign-up required. +

+ +
+ {currentUser ? ( + + ) : ( + + )} + + +
+
+
+ + {/* Footer */} + +
+ ); +} diff --git a/frontend/src/components/landing/ValuePillars.jsx b/frontend/src/components/landing/ValuePillars.jsx new file mode 100644 index 0000000..276db76 --- /dev/null +++ b/frontend/src/components/landing/ValuePillars.jsx @@ -0,0 +1,106 @@ +import { + FileSearch, + ShieldCheck, + Mail, + KeyRound, + CheckCircle2, + Lock, + Cpu, + Database, + ExternalLink, +} from 'lucide-react'; + +export default function ValuePillars() { + const pillars = [ + { + icon: FileSearch, + tag: 'Universal Parser', + title: 'Multi-Format Job Scraping', + description: + 'Instant deep scraping from Workday CXS endpoints, Greenhouse, Lever, SmartRecruiters, or raw text. Automatically extracts salary ranges, required vs. preferred tech stacks, and college new-grad eligibility windows.', + badges: ['Workday CXS', 'Greenhouse', 'Lever', 'SmartRecruiters'], + }, + { + icon: ShieldCheck, + tag: 'Zero Hallucination', + title: 'Context-Aware Fact Verification', + description: + 'Never fabricates false experience. The engine cross-checks candidate resume history before suggesting skill additions. Unverified skills are steered into honest open-source and academic portfolio project bullets.', + badges: ['Evidence Verification', 'BulletCraft Formula', 'Honest Matching'], + }, + { + icon: Mail, + tag: 'High Conversion', + title: 'Recruiter Outreach Generator', + description: + 'Turns top-matching resumes and job requirements into tailored recruiter pitches: cold outreach emails with compelling subject lines, LinkedIn InMails strictly under 300 characters, and portal cover letter opening hooks.', + badges: ['Cold Emails', 'LinkedIn InMails <300ch', 'Cover Letter Hooks'], + }, + { + icon: KeyRound, + tag: 'Privacy First', + title: 'Enterprise Security & BYOK', + description: + 'Bring Your Own Keys (Groq, Nvidia NIM, OpenAI, Ollama) encrypted with AES-256 Fernet at rest, or use our 100% free built-in Offline Heuristic NLP Engine. Resumes are protected in private Cloudflare R2 vaults with presigned URLs.', + badges: ['AES-256 Fernet', 'Cloudflare R2 Vault', '100% Free Offline NLP'], + }, + ]; + + return ( +
+
+ + Core Capabilities + +

+ Engineered for Modern Technical Applicants +

+

+ Everything you need to beat automated ATS filters, tailor resumes at scale, and track your complete application journey without compromising security. +

+
+ +
+ {pillars.map((pillar, idx) => { + const Icon = pillar.icon; + return ( +
+
+
+
+ +
+ + {pillar.tag} + +
+ +

+ {pillar.title} +

+

+ {pillar.description} +

+
+ +
+ {pillar.badges.map((b, bIdx) => ( + + + {b} + + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index bba6994..daa4d74 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,7 @@ @import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); + :root { --bg-canvas: #f3f6f8; --bg-surface: #ffffff; @@ -339,7 +341,7 @@ code, pre, .font-mono, .mono-data { } .dark .bg-\[\#f3f6f8\] { - background-color: #12141a !important; + background-color: var(--bg-canvas) !important; } .dark .bg-\[\#f0f7fe\] { diff --git a/frontend/src/test/AppRouting.test.jsx b/frontend/src/test/AppRouting.test.jsx new file mode 100644 index 0000000..eb6b955 --- /dev/null +++ b/frontend/src/test/AppRouting.test.jsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import App from '../App' + +describe('App Routing', () => { + beforeEach(() => { + window.history.pushState(null, '', '/') + }) + + it('renders LandingPage by default at /', async () => { + render() + expect(screen.getByText(/Turn Job Postings into Interview Calls/i)).toBeInTheDocument() + expect(screen.getByText(/Core Capabilities/i)).toBeInTheDocument() + }) + + it('navigates to /app when clicking Try Guest Workspace', async () => { + render() + const guestBtn = screen.getAllByRole('button', { name: /Try Guest Workspace/i })[0] + fireEvent.click(guestBtn) + await waitFor(() => { + expect(window.location.pathname).toBe('/app') + expect(screen.getByRole('button', { name: /Job Analyzer/i })).toBeInTheDocument() + }) + }) + + it('navigates back to / when clicking brand logo from /app', async () => { + window.history.pushState(null, '', '/app') + render() + expect(screen.getByRole('button', { name: /Job Analyzer/i })).toBeInTheDocument() + const brandLogo = screen.getByTestId('brand-logo') + fireEvent.click(brandLogo) + await waitFor(() => { + expect(window.location.pathname).toBe('/') + expect(screen.getByText(/Turn Job Postings into Interview Calls/i)).toBeInTheDocument() + }) + }) + + it('opens AuthModal from landing page when clicking Get Started Free', async () => { + render() + const getStartedBtn = screen.getAllByRole('button', { name: /Get Started Free/i })[0] + fireEvent.click(getStartedBtn) + await waitFor(() => { + expect(screen.getByText(/Create Account/i)).toBeInTheDocument() + }) + }) + + it('toggles dark and light mode for the whole page from the landing nav', async () => { + localStorage.removeItem('jobhelperguru_theme') + document.documentElement.classList.remove('dark') + document.body.classList.remove('dark') + + render() + + // Initially light mode + expect(document.documentElement.classList.contains('dark')).toBe(false) + expect(document.body.classList.contains('dark')).toBe(false) + + // Click toggle to switch to dark mode + const toggleBtn = screen.getByRole('button', { name: /Switch to dark mode/i }) + fireEvent.click(toggleBtn) + + expect(document.documentElement.classList.contains('dark')).toBe(true) + expect(document.body.classList.contains('dark')).toBe(true) + expect(localStorage.getItem('jobhelperguru_theme')).toBe('dark') + + // Click toggle to switch back to light mode + const switchBackBtn = screen.getByRole('button', { name: /Switch to light mode/i }) + fireEvent.click(switchBackBtn) + + expect(document.documentElement.classList.contains('dark')).toBe(false) + expect(document.body.classList.contains('dark')).toBe(false) + expect(localStorage.getItem('jobhelperguru_theme')).toBe('light') + }) +}) diff --git a/frontend/src/test/InteractiveHeroPlayground.test.jsx b/frontend/src/test/InteractiveHeroPlayground.test.jsx new file mode 100644 index 0000000..753845c --- /dev/null +++ b/frontend/src/test/InteractiveHeroPlayground.test.jsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import InteractiveHeroPlayground from '../components/landing/InteractiveHeroPlayground' + +describe('InteractiveHeroPlayground', () => { + it('renders all 4 tabs and defaults to BulletCraft tab', () => { + render() + expect(screen.getByRole('tab', { name: /BulletCraft/i })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /ATS Fit Matcher/i })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Job Scraper/i })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Pipeline CRM/i })).toBeInTheDocument() + expect(screen.getByText(/Responsible for writing backend APIs/i)).toBeInTheDocument() + }) + + it('transforms weak bullet into BulletCraft formula with XYZ badges', () => { + render() + const transformBtn = screen.getByRole('button', { name: /Transform with BulletCraft/i }) + fireEvent.click(transformBtn) + expect(screen.getByText(/Accomplished \[X\]/i)).toBeInTheDocument() + expect(screen.getByText(/Measured by \[Y\]/i)).toBeInTheDocument() + expect(screen.getByText(/Done by \[Z\]/i)).toBeInTheDocument() + expect(screen.getByText(/Engineered 14 resilient FastAPI microservices/i)).toBeInTheDocument() + }) + + it('switches to ATS Matcher tab and allows skill adoption', () => { + render() + fireEvent.click(screen.getByRole('tab', { name: /ATS Fit Matcher/i })) + expect(screen.getByText(/ATS Match Score/i)).toBeInTheDocument() + expect(screen.getByText(/88%/i)).toBeInTheDocument() + const adoptBtn = screen.getByRole('button', { name: /Adopt Missing Skill/i }) + fireEvent.click(adoptBtn) + expect(screen.getByText(/96%/i)).toBeInTheDocument() + }) + + it('switches to Job Scraper tab and displays parsed job fields', () => { + render() + fireEvent.click(screen.getByRole('tab', { name: /Job Scraper/i })) + expect(screen.getByText(/Senior Software Engineer/i)).toBeInTheDocument() + expect(screen.getByText(/\$140,000 - \$175,000/i)).toBeInTheDocument() + expect(screen.getByText(/Eligible: 2024-2026 Graduates/i)).toBeInTheDocument() + }) + + it('switches to Pipeline CRM tab and advances Kanban card', () => { + render() + fireEvent.click(screen.getByRole('tab', { name: /Pipeline CRM/i })) + expect(screen.getByText(/Stripe - Senior Infrastructure Engineer/i)).toBeInTheDocument() + const moveBtn = screen.getByRole('button', { name: /Advance to Interviewing/i }) + fireEvent.click(moveBtn) + expect(screen.getByText(/Current Status:/i)).toHaveTextContent('Interviewing') + }) + + it('calls onTryLiveDemo when the live demo button is clicked', () => { + const onTryLiveDemo = vi.fn() + render() + const demoBtn = screen.getByRole('button', { name: /Launch Live in Workspace/i }) + fireEvent.click(demoBtn) + expect(onTryLiveDemo).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/test/LandingComponents.test.jsx b/frontend/src/test/LandingComponents.test.jsx new file mode 100644 index 0000000..38abb21 --- /dev/null +++ b/frontend/src/test/LandingComponents.test.jsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import LandingNav from '../components/landing/LandingNav' +import ValuePillars from '../components/landing/ValuePillars' +import HowItWorks from '../components/landing/HowItWorks' +import FaqSection from '../components/landing/FaqSection' +import LandingFooter from '../components/landing/LandingFooter' + +describe('Landing Subcomponents', () => { + it('LandingNav displays Sign In and Get Started when unauthenticated', () => { + const onSignIn = vi.fn() + const onGetStarted = vi.fn() + render( + + ) + expect(screen.getByRole('button', { name: /Sign In/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Get Started Free/i })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Sign In/i })) + expect(onSignIn).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: /Get Started Free/i })) + expect(onGetStarted).toHaveBeenCalledTimes(1) + }) + + it('LandingNav displays Go to Dashboard when authenticated', () => { + const onGoToDashboard = vi.fn() + render( + + ) + expect(screen.getByRole('button', { name: /Go to Dashboard/i })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Go to Dashboard/i })) + expect(onGoToDashboard).toHaveBeenCalledTimes(1) + }) + + it('ValuePillars renders 4 core pillars', () => { + render() + expect(screen.getByText(/Multi-Format Job Scraping/i)).toBeInTheDocument() + expect(screen.getByText(/Context-Aware Fact Verification/i)).toBeInTheDocument() + expect(screen.getByText(/Recruiter Outreach Generator/i)).toBeInTheDocument() + expect(screen.getByText(/Enterprise Security & BYOK/i)).toBeInTheDocument() + }) + + it('HowItWorks renders 3 workflow steps', () => { + render() + expect(screen.getByText(/Parse the True Requirements/i)).toBeInTheDocument() + expect(screen.getByText(/Optimize Bullets & Pick the Best Resume/i)).toBeInTheDocument() + expect(screen.getByText(/Track Deadlines & Export to Excel/i)).toBeInTheDocument() + }) + + it('FaqSection toggles answers on click', () => { + render() + const question = screen.getByText(/Is JobHelperGuru free to use\?/i) + fireEvent.click(question) + expect(screen.getByText(/100% free offline heuristic NLP mode/i)).toBeInTheDocument() + }) + + it('LandingFooter triggers privacy modal', () => { + const onOpenPrivacy = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /Privacy & Terms/i })) + expect(onOpenPrivacy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/test/LandingPage.test.jsx b/frontend/src/test/LandingPage.test.jsx new file mode 100644 index 0000000..9065d14 --- /dev/null +++ b/frontend/src/test/LandingPage.test.jsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import LandingPage from '../components/landing/LandingPage' + +describe('LandingPage', () => { + it('renders hero, CTAs, interactive playground, and footer', () => { + const onGetStarted = vi.fn() + const onExploreGuest = vi.fn() + const onSignIn = vi.fn() + const onGoToDashboard = vi.fn() + const onOpenPrivacy = vi.fn() + + render( + + ) + + // Verify Hero content + expect(screen.getByText(/Turn Job Postings into Interview Calls/i)).toBeInTheDocument() + expect(screen.getAllByText(/BulletCraft Framework/i).length).toBeGreaterThanOrEqual(1) + + // Verify CTAs + const getStartedBtns = screen.getAllByRole('button', { name: /Get Started Free/i }) + expect(getStartedBtns.length).toBeGreaterThanOrEqual(1) + fireEvent.click(getStartedBtns[0]) + expect(onGetStarted).toHaveBeenCalled() + + const guestBtns = screen.getAllByRole('button', { name: /Try Guest Workspace/i }) + expect(guestBtns.length).toBeGreaterThanOrEqual(1) + fireEvent.click(guestBtns[0]) + expect(onExploreGuest).toHaveBeenCalled() + + // Verify sections render + expect(screen.getByText(/Core Capabilities/i)).toBeInTheDocument() + expect(screen.getByText(/3-Step Workflow/i)).toBeInTheDocument() + expect(screen.getByText(/Frequently Asked Questions/i)).toBeInTheDocument() + }) + + it('renders authenticated dashboard button when currentUser is present', () => { + const onGoToDashboard = vi.fn() + render( + + ) + + const dashBtns = screen.getAllByRole('button', { name: /Go to Dashboard/i }) + expect(dashBtns.length).toBeGreaterThanOrEqual(1) + fireEvent.click(dashBtns[0]) + expect(onGoToDashboard).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/test/ThemeToggle.test.jsx b/frontend/src/test/ThemeToggle.test.jsx index bbf865d..ef74287 100644 --- a/frontend/src/test/ThemeToggle.test.jsx +++ b/frontend/src/test/ThemeToggle.test.jsx @@ -11,4 +11,10 @@ describe('ThemeToggle', () => { expect(onToggle).toHaveBeenCalledOnce() expect(fetch).not.toHaveBeenCalled() }) + + it('renders sun icon when dark mode is active', () => { + const onToggle = vi.fn() + render() + expect(screen.getByRole('button', { name: 'Switch to light mode' })).toBeInTheDocument() + }) }) diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js index 79f806a..4cda873 100644 --- a/frontend/src/test/setup.js +++ b/frontend/src/test/setup.js @@ -4,6 +4,9 @@ import { cleanup } from '@testing-library/react' const rejectNetwork = () => Promise.reject(new Error('Unexpected network request in frontend test')) globalThis.fetch = vi.fn(rejectNetwork) +if (typeof window !== 'undefined') { + window.scrollTo = vi.fn() +} afterEach(() => { cleanup() diff --git a/tests/test_spa_fallback.py b/tests/test_spa_fallback.py index 52e3518..57892e4 100644 --- a/tests/test_spa_fallback.py +++ b/tests/test_spa_fallback.py @@ -40,6 +40,11 @@ def get_data(): assert resp_reset.status_code == 200 assert "SPA Root" in resp_reset.text + # Route /app returns index.html + resp_app = client.get("/app") + assert resp_app.status_code == 200 + assert "SPA Root" in resp_app.text + # API 404 does not return index.html resp_api = client.get("/api/not-found") assert resp_api.status_code == 404