From f1bdaa5074e95983492e2a4d0172479158dfc23d Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 7 Mar 2026 11:57:49 +0200 Subject: [PATCH] feat: restructure Wazivo into production-ready MVP --- .env.example | 18 +- README.md | 306 ++++++------------------------ src/app/api/analyze/route.ts | 122 ++++-------- src/app/api/cover-letter/route.ts | 34 ++++ src/app/api/rewrite/route.ts | 28 +++ src/app/page.tsx | 167 +++++----------- src/components/MissingSkills.tsx | 27 +++ src/components/Report.tsx | 39 ++++ src/components/ResumeUpload.tsx | 223 ++++++++++++++++++++++ src/components/ScoreCard.tsx | 33 ++++ src/components/SkillsList.tsx | 30 +++ src/lib/atsScore.ts | 39 ++++ src/lib/promptTemplates.ts | 62 ++++++ src/lib/reportGenerator.ts | 39 ++++ src/lib/resumeAnalyzer.ts | 202 ++++++++++++++++++++ src/lib/runtime.ts | 141 ++++++++++++++ src/lib/skillExtractor.ts | 70 +++++++ 17 files changed, 1117 insertions(+), 463 deletions(-) create mode 100644 src/app/api/cover-letter/route.ts create mode 100644 src/app/api/rewrite/route.ts create mode 100644 src/components/MissingSkills.tsx create mode 100644 src/components/Report.tsx create mode 100644 src/components/ResumeUpload.tsx create mode 100644 src/components/ScoreCard.tsx create mode 100644 src/components/SkillsList.tsx create mode 100644 src/lib/atsScore.ts create mode 100644 src/lib/promptTemplates.ts create mode 100644 src/lib/reportGenerator.ts create mode 100644 src/lib/resumeAnalyzer.ts create mode 100644 src/lib/runtime.ts create mode 100644 src/lib/skillExtractor.ts diff --git a/.env.example b/.env.example index fe51382..ddde705 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,4 @@ -# REQUIRED - Groq API Key (Lightning-fast inference) -# Get your free API key from: https://console.groq.com -GROQ_API_KEY=gsk_your-groq-api-key-here - -# OPTIONAL - Groq Model (default: llama-3.3-70b-versatile) -# Available models: llama-3.3-70b-versatile, mixtral-8x7b-32768, gemma2-9b-it +GROQ_API_KEY= GROQ_MODEL=llama-3.3-70b-versatile - -# OPTIONAL - Job Search APIs -ADZUNA_APP_ID=your-adzuna-app-id -ADZUNA_APP_KEY=your-adzuna-app-key -RAPIDAPI_KEY=your-rapidapi-key - -# APP CONFIG -NEXT_PUBLIC_APP_URL=http://localhost:3000 -MAX_FILE_SIZE_MB=10 +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/README.md b/README.md index 23d728b..a83891b 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,75 @@ -# Wazivo - AI-Powered Resume Analyzer - -
- -![Wazivo Logo](https://img.shields.io/badge/Wazivo-Get%20Hired-blue?style=for-the-badge&logo=briefcase) - -### **Get Hired, Get Wazivo** ⚡ - -[![CI/CD](https://github.com/SamoTech/Wazivo/actions/workflows/ci.yml/badge.svg)](https://github.com/SamoTech/Wazivo/actions) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Next.js](https://img.shields.io/badge/Next.js-14-black?logo=next.js&logoColor=white)](https://nextjs.org/) -[![Code Quality](https://img.shields.io/badge/Code%20Quality-10%2F10-brightgreen?logo=codacy&logoColor=white)](https://github.com/SamoTech/Wazivo) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) -[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/SamoTech/Wazivo/graphs/commit-activity) -[![GitHub issues](https://img.shields.io/github/issues/SamoTech/Wazivo)](https://github.com/SamoTech/Wazivo/issues) -[![GitHub stars](https://img.shields.io/github/stars/SamoTech/Wazivo?style=social)](https://github.com/SamoTech/Wazivo/stargazers) - -**Lightning-fast AI resume analysis powered by Groq's LLaMA 3.3 70B model** - -Upload your CV or LinkedIn profile and get instant career insights, skill gap analysis, personalized course recommendations, and matching job opportunities. - -[Demo](https://wazivo.vercel.app) • [Documentation](https://github.com/SamoTech/Wazivo/tree/main/docs) • [Report Bug](https://github.com/SamoTech/Wazivo/issues) • [Request Feature](https://github.com/SamoTech/Wazivo/issues) - -
- ---- - -## ✨ Features - -- 📄 **Multi-Format CV Upload** - PDF, DOCX, DOC, or images (OCR supported) -- 🔗 **URL Support** - Direct LinkedIn profiles, Indeed, or file URLs -- 🤖 **AI Analysis** - Powered by Groq's ultra-fast LLaMA 3.3 70B -- 💼 **Job Matching** - Real-time job search via Adzuna & JSearch APIs -- 📚 **Course Recommendations** - Personalized learning paths from top platforms -- 📊 **Market Insights** - Salary ranges, trending skills, career paths -- ⚡ **Lightning Fast** - Sub-3-second AI responses -- 🔒 **Secure** - Rate limiting, CSP headers, input validation - -## 🚀 Quick Start - -### Prerequisites - -[![Node.js](https://img.shields.io/badge/Node.js-18%2B-green?logo=node.js&logoColor=white)](https://nodejs.org/) -[![npm](https://img.shields.io/badge/npm-8%2B-red?logo=npm&logoColor=white)](https://www.npmjs.com/) - -- Node.js 18+ and npm -- Groq API Key ([get one free](https://console.groq.com)) - -### Installation - -```bash -# Clone the repository -git clone https://github.com/SamoTech/Wazivo.git -cd Wazivo - -# Install dependencies -npm install - -# Set up environment variables -cp .env.example .env.local -# Edit .env.local and add your GROQ_API_KEY - -# Run development server -npm run dev +# Wazivo + +Wazivo is a production-ready MVP for AI-powered resume analysis, ATS optimization, and cover-letter generation built with Next.js 14, TypeScript, Tailwind CSS, and Groq. + +## What it does + +- Analyze resumes and return a structured score +- Detect core skills and missing market skills +- Generate strengths, weaknesses, and career insights +- Rewrite resumes for ATS systems +- Generate tailored cover letters from a resume and job description +- Cache resume analysis results and apply API rate limiting + +## Stack + +- Next.js 14 App Router +- React 18 +- TypeScript +- Tailwind CSS +- Groq API (`llama-3.3-70b-versatile` by default) +- Optional Upstash Redis for cache and rate limiting + +## Project structure + +```text +src +├ app +│ ├ api +│ │ ├ analyze/route.ts +│ │ ├ rewrite/route.ts +│ │ └ cover-letter/route.ts +│ └ page.tsx +├ components +│ ├ ResumeUpload.tsx +│ ├ ScoreCard.tsx +│ ├ SkillsList.tsx +│ ├ MissingSkills.tsx +│ └ Report.tsx +└ lib + ├ atsScore.ts + ├ promptTemplates.ts + ├ reportGenerator.ts + ├ resumeAnalyzer.ts + ├ runtime.ts + └ skillExtractor.ts ``` -Visit [http://localhost:3000](http://localhost:3000) - -## 🔧 Environment Variables - -```env -# Required -GROQ_API_KEY=your_groq_api_key_here - -# Optional - Job Search APIs -ADZUNA_APP_ID=your_adzuna_app_id -ADZUNA_APP_KEY=your_adzuna_app_key -RAPIDAPI_KEY=your_rapidapi_key - -# Optional - URL Fetching -JINA_API_KEY=your_jina_reader_key # Higher rate limits - -# Optional - Configuration -GROQ_MODEL=llama-3.3-70b-versatile # Default model -MAX_JOBS_PER_SEARCH=10 -JOB_SEARCH_TIMEOUT=5000 -``` +## Environment variables -## 📁 Project Structure - -``` -Wazivo/ -├── src/ -│ ├── app/ -│ │ ├── api/ -│ │ │ └── analyze/ -│ │ │ └── route.ts # Main API endpoint -│ │ ├── components/ # React components -│ │ ├── config/ # Platform configurations -│ │ ├── lib/ -│ │ │ ├── services/ # Service modules -│ │ │ │ ├── cv-processing.service.ts -│ │ │ │ └── job-enrichment.service.ts -│ │ │ ├── cvParser.ts # CV text extraction -│ │ │ ├── openaiService.ts # AI analysis -│ │ │ ├── jobSearchService.ts # Job search APIs -│ │ │ ├── errors.ts # Custom error classes -│ │ │ ├── logger.ts # Structured logging -│ │ │ └── validation.ts # Input validation -│ │ └── types/ # TypeScript definitions -│ └── middleware.ts # Rate limiting & security -├── public/ # Static assets -├── tests/ # Test files -└── package.json -``` - -## 🏗️ Architecture - -### Tech Stack - -[![Next.js](https://img.shields.io/badge/Next.js-14-black?logo=next.js&logoColor=white)](https://nextjs.org/) -[![React](https://img.shields.io/badge/React-18-blue?logo=react&logoColor=white)](https://reactjs.org/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4-06B6D4?logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) -[![Zod](https://img.shields.io/badge/Zod-3.22-3E67B1?logo=zod&logoColor=white)](https://zod.dev/) -[![Groq](https://img.shields.io/badge/Groq-LLaMA%203.3-orange?logoColor=white)](https://groq.com/) - -### Request Flow - -``` -User Upload → Middleware (Rate Limit) → API Route → Services - ↓ - CV Processing - ↓ - AI Analysis - ↓ - Job Enrichment - ↓ - JSON Response -``` - -### Service Modules - -- **CV Processing Service**: Handles file/URL parsing and text extraction -- **AI Analysis Service**: Groq LLM integration with Zod validation -- **Job Enrichment Service**: Multi-API job search with deduplication - -## 🧪 Testing - -[![Jest](https://img.shields.io/badge/Jest-29-C21325?logo=jest&logoColor=white)](https://jestjs.io/) -[![Playwright](https://img.shields.io/badge/Playwright-1.41-2EAD33?logo=playwright&logoColor=white)](https://playwright.dev/) -[![Testing Library](https://img.shields.io/badge/Testing%20Library-14-E33332?logo=testing-library&logoColor=white)](https://testing-library.com/) +Create `.env.local` and add: ```bash -# Run unit tests -npm test - -# Run with coverage -npm run test:coverage - -# Run E2E tests -npm run test:e2e - -# Type checking -npm run type-check - -# Linting -npm run lint - -# Format code -npm run format +GROQ_API_KEY=your_groq_key +GROQ_MODEL=llama-3.3-70b-versatile +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= ``` -## 🚀 Deployment +`UPSTASH_REDIS_*` is optional. Without it, Wazivo falls back to in-memory caching and rate limiting for local development. -### Vercel (Recommended) - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/SamoTech/Wazivo) - -1. Click the button above -2. Add environment variables -3. Deploy! - -### Docker - -[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) +## Run locally ```bash -docker build -t wazivo . -docker run -p 3000:3000 --env-file .env.local wazivo +npm install +npm run dev ``` -## 📊 Performance - -![Performance](https://img.shields.io/badge/Performance-⚡%20Lightning%20Fast-success) - -- **AI Analysis**: < 3 seconds (Groq LLaMA 3.3) -- **CV Parsing**: < 1 second (local processing) -- **Job Search**: < 5 seconds (parallel API calls) -- **Total Time**: ~5-8 seconds end-to-end - -## 🛡️ Security Features - -[![Security](https://img.shields.io/badge/Security-Hardened-green?logo=security&logoColor=white)](https://github.com/SamoTech/Wazivo) - -- ✅ Rate limiting (10 requests/minute per IP) -- ✅ Content Security Policy headers -- ✅ Input validation and sanitization -- ✅ XSS protection headers -- ✅ CORS configuration -- ✅ Environment-based secrets - -## 🤝 Contributing - -[![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/SamoTech/Wazivo/issues) - -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. - -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request - -## 📝 License - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## 🙏 Acknowledgments - -- [Groq](https://groq.com/) - Ultra-fast AI inference -- [Jina Reader](https://jina.ai/reader) - Web content extraction -- [Adzuna](https://www.adzuna.com/) - Job search API -- [JSearch (RapidAPI)](https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch) - Job aggregation - -## 💬 Support - -[![Email](https://img.shields.io/badge/Email-samo.hossam%40gmail.com-red?logo=gmail&logoColor=white)](mailto:samo.hossam@gmail.com) -[![GitHub Issues](https://img.shields.io/github/issues/SamoTech/Wazivo)](https://github.com/SamoTech/Wazivo/issues) -[![GitHub Discussions](https://img.shields.io/github/discussions/SamoTech/Wazivo)](https://github.com/SamoTech/Wazivo/discussions) - -- 📧 Email: samo.hossam@gmail.com -- 🐛 Issues: [GitHub Issues](https://github.com/SamoTech/Wazivo/issues) -- 💬 Discussions: [GitHub Discussions](https://github.com/SamoTech/Wazivo/discussions) - ---- - -
- -**Built with ❤️ by [SamoTech](https://github.com/SamoTech)** - -[![GitHub followers](https://img.shields.io/github/followers/SamoTech?style=social)](https://github.com/SamoTech) +Open `http://localhost:3000`. -If you find this project helpful, please consider giving it a ⭐! +## Product notes -
+- No user accounts are required in this MVP. +- Resume analysis is cached by a SHA-256 hash of the resume text. +- API routes validate input size, sanitize text, and return safe errors. +- The analyze route is rate limited for anonymous usage and can support a future free/pro tier model. diff --git a/src/app/api/analyze/route.ts b/src/app/api/analyze/route.ts index fa0fa0c..862d67a 100644 --- a/src/app/api/analyze/route.ts +++ b/src/app/api/analyze/route.ts @@ -1,97 +1,53 @@ import { NextRequest, NextResponse } from 'next/server'; -import { analyzeResume } from '@/app/lib/openaiService'; -import { processCVInput } from '@/app/lib/services/cv-processing.service'; -import { enrichWithJobOpportunities } from '@/app/lib/services/job-enrichment.service'; -import { getUserFriendlyMessage } from '@/app/lib/errors'; -import { logger } from '@/app/lib/logger'; -export const runtime = 'nodejs'; -export const maxDuration = 60; +import { analyzeResume, type ResumeAnalysis } from '../../../../lib/resumeAnalyzer'; +import { + ensureTextLength, + getCachedJSON, + getRequesterId, + hashText, + normalizeResumeInput, + rateLimit, + setCachedJSON, +} from '../../../../lib/runtime'; -/** - * Main API route for CV analysis - * - * Flow: - * 1. Parse CV (file or URL) - * 2. AI analysis (resume breakdown) - * 3. Job enrichment (search matching opportunities) - * 4. Return comprehensive report - */ export async function POST(request: NextRequest) { - const requestId = generateRequestId(); - - logger.info('CV analysis request started', { requestId }); - try { - const formData = await request.formData(); - - // Step 1: Parse CV text - const cvText = await processCVInput(formData); + const identifier = getRequesterId(request); + const limit = await rateLimit(identifier, 'analyze', 3, 60 * 60 * 24); + + if (!limit.allowed) { + return NextResponse.json( + { error: 'Daily analysis limit reached. Please try again later.' }, + { status: 429, headers: { 'X-RateLimit-Remaining': String(limit.remaining) } }, + ); + } - // Step 2: AI Analysis - const analysis = await analyzeResume(cvText); + const body = (await request.json().catch(() => null)) as { resumeText?: string } | null; + const resumeText = normalizeResumeInput(body?.resumeText || ''); - // Step 3: Job Enrichment - const { jobs, metadata } = await enrichWithJobOpportunities( - cvText, - analysis.candidateSummary, - (analysis as any).jobSearch - ); + ensureTextLength(resumeText, 'Resume text', 120, 12000); - analysis.jobOpportunities = jobs; + const cacheKey = `analysis:${hashText(resumeText)}`; + const cached = await getCachedJSON(cacheKey); - // Expose search metadata for debugging/display - (analysis as any).jobSearchMeta = metadata; + if (cached) { + return NextResponse.json( + { data: cached, cached: true }, + { headers: { 'X-RateLimit-Remaining': String(limit.remaining) } }, + ); + } - logger.info('CV analysis completed successfully', { - requestId, - jobsFound: jobs.length, - skillsIdentified: analysis.candidateSummary.keySkills.length, - }); + const analysis = await analyzeResume(resumeText); + await setCachedJSON(cacheKey, analysis, 60 * 60 * 24); - return NextResponse.json(analysis); + return NextResponse.json( + { data: analysis, cached: false }, + { headers: { 'X-RateLimit-Remaining': String(limit.remaining) } }, + ); } catch (error) { - return handleError(error, requestId); - } -} - -/** - * Generate unique request ID for tracking - */ -function generateRequestId(): string { - return `req_${Date.now()}_${Math.random().toString(36).substring(7)}`; -} - -/** - * Handle errors and return appropriate responses - */ -function handleError(error: unknown, requestId: string): NextResponse { - logger.error('CV analysis failed', { - requestId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - - // Convert to user-friendly message - const userMessage = - error instanceof Error ? getUserFriendlyMessage(error) : 'An unexpected error occurred'; - - // Determine appropriate status code - let statusCode = 500; - if (error instanceof Error) { - const errorName = error.constructor.name; - if (errorName === 'CVParsingError' || errorName === 'ValidationError') { - statusCode = 400; - } else if (errorName === 'RateLimitError') { - statusCode = 429; - } + const message = error instanceof Error ? error.message : 'Unable to analyze resume.'; + const status = /at most/.test(message) ? 413 : 400; + return NextResponse.json({ error: message }, { status }); } - - return NextResponse.json( - { - error: userMessage, - requestId, - }, - { status: statusCode } - ); } diff --git a/src/app/api/cover-letter/route.ts b/src/app/api/cover-letter/route.ts new file mode 100644 index 0000000..31d2112 --- /dev/null +++ b/src/app/api/cover-letter/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { generateCoverLetter } from '../../../../lib/resumeAnalyzer'; +import { ensureTextLength, getRequesterId, normalizeResumeInput, rateLimit } from '../../../../lib/runtime'; + +export async function POST(request: NextRequest) { + try { + const identifier = getRequesterId(request); + const limit = await rateLimit(identifier, 'cover-letter', 10, 60 * 60); + + if (!limit.allowed) { + return NextResponse.json({ error: 'Too many cover letter requests. Please try again soon.' }, { status: 429 }); + } + + const body = (await request.json().catch(() => null)) as { + resumeText?: string; + jobDescription?: string; + } | null; + + const resumeText = normalizeResumeInput(body?.resumeText || ''); + const jobDescription = normalizeResumeInput(body?.jobDescription || ''); + + ensureTextLength(resumeText, 'Resume text', 120, 12000); + ensureTextLength(jobDescription, 'Job description', 80, 8000); + + const coverLetter = await generateCoverLetter(resumeText, jobDescription); + + return NextResponse.json({ data: { coverLetter } }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to generate cover letter.'; + const status = /at most/.test(message) ? 413 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/rewrite/route.ts b/src/app/api/rewrite/route.ts new file mode 100644 index 0000000..362114c --- /dev/null +++ b/src/app/api/rewrite/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { rewriteResumeForATS } from '../../../../lib/resumeAnalyzer'; +import { ensureTextLength, getRequesterId, normalizeResumeInput, rateLimit } from '../../../../lib/runtime'; + +export async function POST(request: NextRequest) { + try { + const identifier = getRequesterId(request); + const limit = await rateLimit(identifier, 'rewrite', 10, 60 * 60); + + if (!limit.allowed) { + return NextResponse.json({ error: 'Too many rewrite requests. Please try again soon.' }, { status: 429 }); + } + + const body = (await request.json().catch(() => null)) as { resumeText?: string } | null; + const resumeText = normalizeResumeInput(body?.resumeText || ''); + + ensureTextLength(resumeText, 'Resume text', 120, 12000); + + const rewritten = await rewriteResumeForATS(resumeText); + + return NextResponse.json({ data: { rewritten } }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to rewrite resume.'; + const status = /at most/.test(message) ? 413 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f31e788..f504265 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,131 +1,58 @@ -'use client'; -import { useState } from 'react'; -import FileUpload from './components/FileUpload'; -import LoadingState from './components/LoadingState'; -import AnalysisResults from './components/AnalysisResults'; -import ErrorBoundary from './components/ErrorBoundary'; -import { AnalysisReport } from './types'; -import { Briefcase } from 'lucide-react'; +import ResumeUpload from '../components/ResumeUpload'; -export default function Home() { - const [loading, setLoading] = useState(false); - const [progress, setProgress] = useState(0); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - const handleAnalyze = async (formData: FormData) => { - setLoading(true); - setError(null); - setProgress(0); - - try { - console.log('[Home] Starting analysis...'); - - const res = await fetch('/api/analyze', { - method: 'POST', - body: formData, - }); - - setProgress(100); - - if (!res.ok) { - const errorData = await res.json().catch(() => ({ error: 'Analysis failed' })); - const errorMessage = errorData.error || 'Analysis failed'; - console.error('[Home] Analysis failed:', errorMessage, res.status); - throw new Error(errorMessage); - } - - const data = await res.json(); - console.log('[Home] Analysis successful'); - setResult(data); - } catch (e: any) { - const errorMessage = e.message || 'An unexpected error occurred'; - console.error('[Home] Error during analysis:', errorMessage, e); - setError(errorMessage); - - // Send to error monitoring if configured - if (typeof window !== 'undefined' && (window as any).Sentry) { - (window as any).Sentry.captureException(e, { - tags: { component: 'Home', action: 'analyze' }, - }); - } - } finally { - setLoading(false); - setProgress(0); - } - }; - - const handleReset = () => { - setResult(null); - setError(null); - setProgress(0); - }; +const highlights = [ + 'Structured AI resume scoring', + 'ATS rewrite generation', + 'Missing skill detection', + 'Cover letter creation', +]; +export default function HomePage() { return ( - -
-
-
-
-
- - {error && ( -
-

❌ {error}

-

Please try again or contact support

-
- )} - - {/* Progress bar */} - {loading && progress > 0 && ( -
-
+
+ {highlights.map((item) => (
+ key={item} + className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4 text-sm text-slate-200 shadow-[0_0_0_1px_rgba(255,255,255,0.02)] backdrop-blur" + > + {item} +
+ ))} +
+
+
+
+
+

Why teams will trust it

+

Built like a real product MVP

+
    +
  • Typed APIs with safe JSON responses
  • +
  • Groq-backed analysis with heuristic fallback
  • +
  • Hash-based caching and anonymous rate limiting
  • +
  • Responsive dashboard UI for analysis, rewrite, and cover letters
  • +
- )} - - {loading ? ( - - ) : result ? ( - <> - - - - ) : ( - - )} - -
-

Built with ❤️ by SamoTech | Powered by Groq's Lightning-Fast AI ⚡

-
+
-
-
+ + + + ); } diff --git a/src/components/MissingSkills.tsx b/src/components/MissingSkills.tsx new file mode 100644 index 0000000..f4021f2 --- /dev/null +++ b/src/components/MissingSkills.tsx @@ -0,0 +1,27 @@ +type MissingSkillsProps = { + skills: string[]; +}; + +export default function MissingSkills({ skills }: MissingSkillsProps) { + return ( +
+
+

Missing market skills

+ + Priority gaps + +
+
+ {skills.length ? ( + skills.map((skill) => ( +
+ {skill} +
+ )) + ) : ( +

No major missing skills detected for the current profile.

+ )} +
+
+ ); +} diff --git a/src/components/Report.tsx b/src/components/Report.tsx new file mode 100644 index 0000000..b22db40 --- /dev/null +++ b/src/components/Report.tsx @@ -0,0 +1,39 @@ +import { buildReportSections } from '../lib/reportGenerator'; +import type { ResumeAnalysis } from '../lib/resumeAnalyzer'; + +type ReportProps = { + analysis: ResumeAnalysis; +}; + +export default function Report({ analysis }: ReportProps) { + const sections = buildReportSections(analysis); + + return ( +
+
+
+

Professional report

+

Candidate readiness snapshot

+
+
+ Score: {analysis.score}/100 +
+
+ +
+ {sections.map((section) => ( +
+

{section.title}

+
    + {section.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/ResumeUpload.tsx b/src/components/ResumeUpload.tsx new file mode 100644 index 0000000..61ac053 --- /dev/null +++ b/src/components/ResumeUpload.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import MissingSkills from './MissingSkills'; +import Report from './Report'; +import ScoreCard from './ScoreCard'; +import SkillsList from './SkillsList'; +import type { ResumeAnalysis } from '../lib/resumeAnalyzer'; + +type ApiError = { + error: string; +}; + +type AnalyzeResponse = { + data: ResumeAnalysis; + cached?: boolean; +}; + +export default function ResumeUpload() { + const [resumeText, setResumeText] = useState(''); + const [jobDescription, setJobDescription] = useState(''); + const [analysis, setAnalysis] = useState(null); + const [rewrittenResume, setRewrittenResume] = useState(''); + const [coverLetter, setCoverLetter] = useState(''); + const [loadingAction, setLoadingAction] = useState<'analyze' | 'rewrite' | 'cover-letter' | null>(null); + const [error, setError] = useState(''); + + const canAnalyze = useMemo(() => resumeText.trim().length >= 120, [resumeText]); + const canCreateCoverLetter = canAnalyze && jobDescription.trim().length >= 80; + + async function parseJson(response: Response): Promise { + return (await response.json()) as T; + } + + async function handleAnalyze() { + setLoadingAction('analyze'); + setError(''); + + try { + const response = await fetch('/api/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resumeText }), + }); + + const payload = await parseJson(response); + + if (!response.ok || 'error' in payload) { + throw new Error('error' in payload ? payload.error : 'Unable to analyze resume.'); + } + + setAnalysis(payload.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to analyze resume.'); + } finally { + setLoadingAction(null); + } + } + + async function handleRewrite() { + setLoadingAction('rewrite'); + setError(''); + + try { + const response = await fetch('/api/rewrite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resumeText }), + }); + + const payload = (await response.json()) as { data?: { rewritten: string }; error?: string }; + + if (!response.ok || payload.error || !payload.data) { + throw new Error(payload.error || 'Unable to rewrite resume.'); + } + + setRewrittenResume(payload.data.rewritten); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to rewrite resume.'); + } finally { + setLoadingAction(null); + } + } + + async function handleCoverLetter() { + setLoadingAction('cover-letter'); + setError(''); + + try { + const response = await fetch('/api/cover-letter', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resumeText, jobDescription }), + }); + + const payload = (await response.json()) as { data?: { coverLetter: string }; error?: string }; + + if (!response.ok || payload.error || !payload.data) { + throw new Error(payload.error || 'Unable to generate cover letter.'); + } + + setCoverLetter(payload.data.coverLetter); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to generate cover letter.'); + } finally { + setLoadingAction(null); + } + } + + return ( +
+
+
+

Resume workspace

+

Analyze, rewrite, and tailor applications

+

+ Paste a resume to generate a hiring-ready report. Add a job description to create a customized cover letter. +

+
+ +
+
+ +