diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 60d4eed..81819c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,39 +8,64 @@ import LandingPage from './pages/LandingPage' import ProfilePage from './pages/ProfilePage' import { AppLayout } from './components/AppLayout' import JobsPage from './pages/JobPage' +import SavedPage from './pages/SavedPage' +import { ToastProvider, BookmarksProvider, ErrorBoundary } from './components/ui' -function App() { +function AppRoutes() { return ( - - - } /> - - - - - + + + + } /> - + + + + + } /> + + + console.log("Navigate to job:", jobId)} /> + + + } /> + + + console.log("Navigate to job:", jobId)} /> + + + } /> + } /> + } /> + - - console.log("Navigate to job:", jobId)} /> - + - } /> - } /> - } /> - - - - } - /> - } /> - - + } + /> + } /> + + ) +} + +function App() { + return ( + + + + + + + + + ) } diff --git a/frontend/src/assets/logo1.svg b/frontend/src/assets/logo1.svg new file mode 100644 index 0000000..3b38613 --- /dev/null +++ b/frontend/src/assets/logo1.svg @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 6b7a584..7e1dbde 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,16 +1,20 @@ import { useUser, useClerk } from "@clerk/react"; import { useState, useRef, useEffect } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; +import { Button } from "./ui/Button"; +import LogoSvg from "../assets/logo1.svg"; export function Header() { - const { user } = useUser(); + const { user, isSignedIn } = useUser(); const { signOut } = useClerk(); + const navigate = useNavigate(); const [open, setOpen] = useState(false); const dropdownRef = useRef(null); const handleLogout = async () => { await signOut(); + navigate("/"); }; // Close dropdown on outside click @@ -25,64 +29,128 @@ export function Header() { }, []); return ( -
-
+
+
+ {/* LEFT: Logo + Branding */} + + CVPilot Logo + CVpilot + - {/* LEFT: Branding */} + {/* CENTER: Navigation (only for logged-in) */} + {isSignedIn && ( + + )} - - CVPILOT — AI JOB INTELLIGENCE - - - - {/* RIGHT: Avatar + Dropdown */} -
- - - {/* Dropdown */} - {open && ( -
- - {/* User Info */} -
-

+ {/* RIGHT: Auth Actions or User Menu */} +

+ {!isSignedIn ? ( + // Anonymous users: Show login/signup buttons +
+ + +
+ ) : ( + // Logged-in users: Show user menu +
+
+ + - {/* Actions */} -
- setOpen(false)} - > - Dashboard - + {/* User Dropdown Menu */} + {open && ( +
+ {/* User Info */} +
+

+ {user?.firstName} {user?.lastName} +

+

+ {user?.primaryEmailAddress?.emailAddress} +

+
- -
+ {/* Menu Items */} + +
+ )}
)}
-
); diff --git a/frontend/src/components/Jobs/JobCard.tsx b/frontend/src/components/Jobs/JobCard.tsx index 762caff..1701bcb 100644 --- a/frontend/src/components/Jobs/JobCard.tsx +++ b/frontend/src/components/Jobs/JobCard.tsx @@ -1,4 +1,5 @@ import { JobMatchBadge } from "./JobMatchBadge"; +import { useBookmarks } from "../ui/BookmarksProvider"; interface JobCardProps { jobId: string; @@ -36,10 +37,22 @@ export function JobCard({ index, onClick, }: JobCardProps) { + const { isBookmarked, addBookmark, removeBookmark } = useBookmarks(); + const bookmarked = isBookmarked(jobId); + + const handleBookmarkClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (bookmarked) { + removeBookmark(jobId); + } else { + addBookmark(jobId); + } + }; + return (
onClick(jobId)} - className="group relative rounded-2xl border border-white/[0.07] bg-white/[0.02] hover:bg-white/[0.05] hover:border-white/[0.14] transition-all duration-200 cursor-pointer overflow-hidden" + className="group relative rounded-2xl border border-white/[0.07] bg-white/[0.02] hover:bg-white/[0.05] hover:border-white/[0.14] transition-all duration-300 cursor-pointer overflow-hidden hover:shadow-lg hover:scale-[1.01]" style={{ animationDelay: `${index * 60}ms` }} > {/* Rank number */} @@ -48,7 +61,7 @@ export function JobCard({
{/* Hover accent line */} -
+
@@ -82,15 +95,31 @@ export function JobCard({

- {/* Score ring */} -
+ {/* Right actions */} +
+ {/* Score ring */} + + {/* Bookmark button */} +
{/* Arrow indicator */} -
+
diff --git a/frontend/src/components/Jobs/JobList.tsx b/frontend/src/components/Jobs/JobList.tsx index 532c213..8e3954e 100644 --- a/frontend/src/components/Jobs/JobList.tsx +++ b/frontend/src/components/Jobs/JobList.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from "react"; import { JobCard } from "./JobCard"; import type { Job } from "../../data/MockJobs"; @@ -6,33 +7,94 @@ interface JobListProps { onJobClick: (jobId: string) => void; } +const JOBS_PER_PAGE = 5; + export function JobList({ jobs, onJobClick }: JobListProps) { + const [currentPage, setCurrentPage] = useState(1); + + // Reset/clamp currentPage when jobs change + useEffect(() => { + const totalPages = jobs.length === 0 ? 1 : Math.ceil(jobs.length / JOBS_PER_PAGE); + if (currentPage > totalPages) { + setCurrentPage(Math.max(1, totalPages)); + } + }, [jobs, currentPage]); + if (jobs.length === 0) { return (
-

No matched jobs found.

-

Upload a resume and set your preferences to see matches.

+

No matched jobs found.

+

Upload a resume and set your preferences to see matches.

); } + const totalPages = Math.ceil(jobs.length / JOBS_PER_PAGE); + const startIndex = (currentPage - 1) * JOBS_PER_PAGE; + const endIndex = startIndex + JOBS_PER_PAGE; + const paginatedJobs = jobs.slice(startIndex, endIndex); + return ( -
- {jobs.slice(0, 10).map((job, index) => ( - - ))} +
+ {/* Job Cards */} +
+ {paginatedJobs.map((job, index) => ( + + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + +
+ {Array.from({ length: totalPages }, (_, i) => ( + + ))} +
+ + + + + Page {currentPage} of {totalPages} + +
+ )}
); } \ No newline at end of file diff --git a/frontend/src/components/Landing/CTAButton.tsx b/frontend/src/components/Landing/CTAButton.tsx index 2a67053..06bf137 100644 --- a/frontend/src/components/Landing/CTAButton.tsx +++ b/frontend/src/components/Landing/CTAButton.tsx @@ -1,41 +1,92 @@ import { useNavigate } from "react-router-dom"; -import { useAuth } from "@clerk/react"; - +import { useAuth, useUser } from "@clerk/react"; +import { useEffect, useState, useRef } from "react"; +import { checkResumeExists } from "../../services/resume"; +import { useApi } from "../../lib/fetcher"; +import { Button } from "../ui/Button"; + export function CTAButton() { const { isSignedIn } = useAuth(); + const { isLoaded } = useUser(); const navigate = useNavigate(); - + const { fetchWithAuth } = useApi(); + + const [hasResume, setHasResume] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Stable ref to prevent effect rerun + const fetchRef = useRef(fetchWithAuth); + useEffect(() => { + fetchRef.current = fetchWithAuth; + }, [fetchWithAuth]); + + // Check resume status only for signed-in users + useEffect(() => { + if (!isLoaded) return; + + if (!isSignedIn) { + setIsLoading(false); + return; + } + + const checkResume = async () => { + try { + const exists = await checkResumeExists(fetchRef.current); + setHasResume(exists); + } catch (err) { + console.error("Failed to check resume:", err); + setHasResume(false); + } finally { + setIsLoading(false); + } + }; + + checkResume(); + }, [isSignedIn, isLoaded]); + + const handleClick = () => { + if (!isSignedIn) { + navigate("/sign-up"); + return; + } + + // Signed in: route based on resume status + if (hasResume) { + navigate("/jobs"); + } else { + navigate("/profile"); + } + }; + + const ArrowIcon = () => ( + + + + ); + return ( - + {isLoading ? "LOADING" : "GET STARTED"} + ); } \ No newline at end of file diff --git a/frontend/src/components/Landing/Hero.tsx b/frontend/src/components/Landing/Hero.tsx index ab63ff0..2453f02 100644 --- a/frontend/src/components/Landing/Hero.tsx +++ b/frontend/src/components/Landing/Hero.tsx @@ -1,22 +1,18 @@ import { CTAButton } from "./CTAButton"; import { AnalyticsPreview } from "./AnalyicsPreview"; +import { Container } from "../ui"; export function Hero() { return ( -
- {/* Top tagline */} -

- CVPILOT — AI JOB INTELLIGENCE + + {/* Tagline */} +

+ CVpilot — AI Job Intelligence

- - {/* Heading */} + + {/* Heading with gradient on "right" */}

Match your resume
@@ -35,53 +31,52 @@ export function Hero() { {" "} jobs.

- + {/* Subtext */} -

- A precision tool for ambitious people. Use neural-matching to bypass - the noise and land high-stakes opportunities. +

+ A precision tool for ambitious people. Use neural-matching to bypass the noise and land high-stakes opportunities.

- - {/* CTA */} + + {/* CTA Button */}
- - {/* Stats */} -
-
-

+ + {/* Stats Grid */} +

+
+

2,400+

-

+

JOBS INDEXED

- -
-

+ +

+

OPTIMIZED

-

+

ATS PROMPTS

- -
-

- + +

+

+ 0.8s

-

+

MATCH SPEED

- + {/* Dashboard Preview */}
-
+ ); } diff --git a/frontend/src/components/Profile/ResumeUpdateSection.tsx b/frontend/src/components/Profile/ResumeUpdateSection.tsx index 4e7aab5..2a8f15f 100644 --- a/frontend/src/components/Profile/ResumeUpdateSection.tsx +++ b/frontend/src/components/Profile/ResumeUpdateSection.tsx @@ -3,6 +3,7 @@ import { useState, useRef, type ChangeEvent, type DragEvent } from "react"; interface ResumeUpdateSectionProps { currentFileName?: string; currentFileUrl?: string | null; + hasExistingResume?: boolean; onReplace?: (file: File) => void; isUploading?: boolean; } @@ -10,6 +11,7 @@ interface ResumeUpdateSectionProps { export function ResumeUpdateSection({ currentFileName, currentFileUrl, + hasExistingResume = false, onReplace, isUploading = false, }: ResumeUpdateSectionProps) { @@ -38,6 +40,12 @@ export function ResumeUpdateSection({ if (inputRef.current) inputRef.current.value = ""; }; + const buttonText = isUploading + ? "Uploading…" + : hasExistingResume + ? "Replace Resume" + : "Upload Resume"; + return (
{/* Section header */} @@ -54,19 +62,22 @@ export function ResumeUpdateSection({
- {/* Current file */} + {/* Current file - only show if we have a filename */} {currentFileName && (
- - {currentFileName} - +
+

Current Resume

+

+ {currentFileName} +

+
{currentFileUrl && ( View ↗ @@ -125,7 +136,7 @@ export function ResumeUpdateSection({ Uploading… ) : ( - "Replace resume" + buttonText )} diff --git a/frontend/src/components/ui/Alert.tsx b/frontend/src/components/ui/Alert.tsx new file mode 100644 index 0000000..d7af91e --- /dev/null +++ b/frontend/src/components/ui/Alert.tsx @@ -0,0 +1,122 @@ +import React, { useEffect } from 'react'; + +type AlertVariant = 'success' | 'error' | 'warning' | 'info'; + +export interface AlertProps extends React.HTMLAttributes { + variant?: AlertVariant; + title?: string; + dismissible?: boolean; + onDismiss?: () => void; + autoDismiss?: number; +} + +const variantClasses: Record = { + success: { + bg: 'bg-status-success/10', + border: 'border-status-success/30', + text: 'text-status-success', + icon: 'text-status-success', + }, + error: { + bg: 'bg-status-danger/10', + border: 'border-status-danger/30', + text: 'text-status-danger', + icon: 'text-status-danger', + }, + warning: { + bg: 'bg-status-warning/10', + border: 'border-status-warning/30', + text: 'text-status-warning', + icon: 'text-status-warning', + }, + info: { + bg: 'bg-status-info/10', + border: 'border-status-info/30', + text: 'text-status-info', + icon: 'text-status-info', + }, +}; + +export const Alert = React.forwardRef( + ( + { + variant = 'info', + title, + dismissible = false, + onDismiss, + autoDismiss = 4000, + className = '', + children, + ...props + }, + ref + ) => { + const [isDismissed, setIsDismissed] = React.useState(false); + + useEffect(() => { + if (isDismissed) return; // Don't set timer if already dismissed + + const timer = setTimeout(() => { + setIsDismissed(true); + onDismiss?.(); + }, autoDismiss); + return () => clearTimeout(timer); + }, [autoDismiss, onDismiss, isDismissed]); + + if (isDismissed) return null; + + const variantClass = variantClasses[variant] ?? variantClasses['info']; + + return ( +
+
+ {variant === 'success' && ( + + + + )} + {variant === 'error' && ( + + + + )} + {variant === 'warning' && ( + + + + )} + {variant === 'info' && ( + + + + )} +
+ +
+ {title &&

{title}

} +
{children}
+
+ + {dismissible && ( + + )} +
+ ); + } +); + +Alert.displayName = 'Alert'; diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..6ed9d60 --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'muted'; + +export interface BadgeProps extends React.HTMLAttributes { + variant?: BadgeVariant; + size?: 'sm' | 'md'; +} + +const variantClasses: Record = { + default: 'bg-accent-primary/10 text-accent-primary border border-accent-primary/20', + success: 'bg-status-success/10 text-status-success border border-status-success/20', + warning: 'bg-status-warning/10 text-status-warning border border-status-warning/20', + danger: 'bg-status-danger/10 text-status-danger border border-status-danger/20', + info: 'bg-status-info/10 text-status-info border border-status-info/20', + muted: 'bg-text-tertiary/10 text-text-tertiary border border-text-tertiary/20', +}; + +const sizeClasses: Record<'sm' | 'md', string> = { + sm: 'px-2 py-0.5 text-xs rounded-md', + md: 'px-3 py-1 text-sm rounded-lg', +}; + +export const Badge = React.forwardRef( + ( + { variant = 'default', size = 'md', className = '', children, ...props }, + ref + ) => { + const variantClass = variantClasses[variant]; + const sizeClass = sizeClasses[size]; + const baseClasses = 'inline-flex items-center font-medium whitespace-nowrap'; + + return ( + + {children} + + ); + } +); + +Badge.displayName = 'Badge'; diff --git a/frontend/src/components/ui/BookmarksProvider.tsx b/frontend/src/components/ui/BookmarksProvider.tsx new file mode 100644 index 0000000..a1d5688 --- /dev/null +++ b/frontend/src/components/ui/BookmarksProvider.tsx @@ -0,0 +1,75 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +interface BookmarksContextType { + bookmarks: Set; + addBookmark: (jobId: string) => void; + removeBookmark: (jobId: string) => void; + isBookmarked: (jobId: string) => boolean; + getBookmarkCount: () => number; +} + +const BookmarksContext = createContext(undefined); + +const STORAGE_KEY = 'cvpilot_bookmarks'; + +export function BookmarksProvider({ children }: { children: React.ReactNode }) { + const [bookmarks, setBookmarks] = useState>(new Set()); + const [isInitialized, setIsInitialized] = useState(false); + + // Load bookmarks from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + setBookmarks(new Set(JSON.parse(stored))); + } + } catch (err) { + console.error('Failed to load bookmarks:', err); + } finally { + setIsInitialized(true); + } + }, []); + + // Save bookmarks to localStorage whenever they change + useEffect(() => { + if (isInitialized) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(bookmarks))); + } catch (err) { + console.error('Failed to save bookmarks:', err); + } + } + }, [bookmarks, isInitialized]); + + const addBookmark = (jobId: string) => { + setBookmarks((prev) => new Set(prev).add(jobId)); + }; + + const removeBookmark = (jobId: string) => { + setBookmarks((prev) => { + const next = new Set(prev); + next.delete(jobId); + return next; + }); + }; + + const isBookmarked = (jobId: string) => bookmarks.has(jobId); + + const getBookmarkCount = () => bookmarks.size; + + return ( + + {children} + + ); +} + +export function useBookmarks() { + const context = useContext(BookmarksContext); + if (!context) { + throw new Error('useBookmarks must be used within BookmarksProvider'); + } + return context; +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..99b0877 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,110 @@ +import React from 'react'; + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + fullWidth?: boolean; + isLoading?: boolean; + icon?: React.ReactNode; + iconPosition?: 'left' | 'right'; +} + +const variantClasses: Record = { + primary: ` + bg-accent-primary hover:bg-indigo-700 + text-white font-semibold + border border-accent-bright/40 hover:border-accent-bright/60 + shadow-glow-sm hover:shadow-glow-md + disabled:opacity-50 disabled:cursor-not-allowed + transition-all duration-base + `, + secondary: ` + bg-surface-hover hover:bg-surface-active + text-text-primary hover:text-text-inverse + border border-border-normal hover:border-border-hover + disabled:opacity-50 disabled:cursor-not-allowed + transition-all duration-base + `, + ghost: ` + bg-transparent hover:bg-surface-hover + text-text-secondary hover:text-text-primary + border border-transparent hover:border-border-light + disabled:opacity-50 disabled:cursor-not-allowed + transition-all duration-base + `, + danger: ` + bg-status-danger hover:bg-red-600 + text-white font-semibold + border border-status-danger/40 hover:border-status-danger/60 + shadow-sm hover:shadow-md + disabled:opacity-50 disabled:cursor-not-allowed + transition-all duration-base + `, + success: ` + bg-status-success hover:bg-emerald-500 + text-white font-semibold + border border-status-success/40 hover:border-status-success/60 + shadow-sm hover:shadow-md + disabled:opacity-50 disabled:cursor-not-allowed + transition-all duration-base + `, +}; + +const sizeClasses: Record = { + sm: 'px-3 py-1.5 text-xs font-medium rounded-md gap-1.5', + md: 'px-4 py-2 text-sm font-medium rounded-lg gap-2', + lg: 'px-6 py-3 text-base font-semibold rounded-xl gap-2.5', +}; + +export const Button = React.forwardRef( + ( + { + variant = 'primary', + size = 'md', + fullWidth = false, + isLoading = false, + icon, + iconPosition = 'right', + className = '', + disabled = false, + children, + ...props + }, + ref + ) => { + const baseClasses = 'inline-flex items-center justify-center font-medium active:scale-95 whitespace-nowrap'; + const variantClass = variantClasses[variant]; + const sizeClass = sizeClasses[size]; + const widthClass = fullWidth ? 'w-full' : ''; + + return ( + + ); + } +); + +Button.displayName = 'Button'; diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..4c92b14 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +type CardVariant = 'default' | 'elevated' | 'bordered'; + +export interface CardProps extends React.HTMLAttributes { + variant?: CardVariant; + interactive?: boolean; +} + +interface CardSubComponent { + Header: React.FC>; + Body: React.FC>; + Footer: React.FC>; +} + +const variantClasses: Record = { + default: 'bg-bg-surface border border-border-light hover:border-border-normal', + elevated: 'bg-bg-surface border border-border-light shadow-md hover:shadow-lg', + bordered: 'bg-transparent border border-border-normal hover:border-border-hover', +}; + +const CardComponent = React.forwardRef( + ( + { variant = 'default', interactive = false, className = '', children, ...props }, + ref + ) => { + const variantClass = variantClasses[variant]; + const interactiveClass = interactive ? 'hover:bg-bg-hover cursor-pointer transition-all duration-base' : ''; + const baseClasses = 'rounded-xl overflow-hidden backdrop-blur-sm'; + + return ( +
+ {children} +
+ ); + } +); + +CardComponent.displayName = 'Card'; + +const CardHeader: React.FC> = ({ className = '', children, ...props }) => ( +
+ {children} +
+); + +CardHeader.displayName = 'CardHeader'; + +const CardBody: React.FC> = ({ className = '', children, ...props }) => ( +
+ {children} +
+); + +CardBody.displayName = 'CardBody'; + +const CardFooter: React.FC> = ({ className = '', children, ...props }) => ( +
+ {children} +
+); + +CardFooter.displayName = 'CardFooter'; + +export const Card: React.FC & CardSubComponent = Object.assign(CardComponent, { + Header: CardHeader, + Body: CardBody, + Footer: CardFooter, +}); diff --git a/frontend/src/components/ui/ErrorBoundary.tsx b/frontend/src/components/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..1efe7d9 --- /dev/null +++ b/frontend/src/components/ui/ErrorBoundary.tsx @@ -0,0 +1,57 @@ +import React, { type ReactNode } from 'react'; +import { Button } from './Button'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + onRetry?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error) { + console.error('Error caught by boundary:', error); + } + + handleRetry = () => { + this.setState({ hasError: false }); + this.props.onRetry?.(); + }; + + render() { + if (this.state.hasError) { + return ( + this.props.fallback || ( +
+
+

+ Something went wrong +

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ +
+
+ ) + ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..3b90cb9 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +export interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; + fullWidth?: boolean; +} + +export const Input = React.forwardRef( + ( + { label, error, helperText, fullWidth = false, className = '', ...props }, + ref + ) => { + const widthClass = fullWidth ? 'w-full' : ''; + const borderClass = error + ? 'border-status-danger focus:border-status-danger focus:ring-status-danger/20' + : 'border-border-normal focus:border-accent-primary focus:ring-accent-primary/20'; + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} + {helperText && !error && ( +

{helperText}

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/frontend/src/components/ui/PageLayout.tsx b/frontend/src/components/ui/PageLayout.tsx new file mode 100644 index 0000000..9e443eb --- /dev/null +++ b/frontend/src/components/ui/PageLayout.tsx @@ -0,0 +1,118 @@ +import React from 'react'; + +interface GridBackgroundProps { + variant?: 'default' | 'light'; +} + +export interface ContainerProps extends React.HTMLAttributes { + fluid?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + noPadding?: boolean; +} + +export interface PageContainerProps extends React.HTMLAttributes { + background?: 'primary' | 'secondary' | 'tertiary'; +} + +export const GridBackground: React.FC = ({ variant = 'default' }) => { + const opacity = variant === 'light' ? 'rgba(99,102,241,0.02)' : 'rgba(99,102,241,0.04)'; + + return ( +
+ ); +}; + +export const Container = React.forwardRef( + ( + { fluid = false, size = 'lg', noPadding = false, className = '', children, ...props }, + ref + ) => { + const sizeClasses: Record = { + sm: 'max-w-container-sm', + md: 'max-w-container-md', + lg: 'max-w-content', + xl: 'max-w-container-xl', + }; + + const paddingClass = noPadding ? '' : 'px-4 sm:px-6'; + const sizeClass = fluid ? 'w-full' : sizeClasses[size]; + + return ( +
+ {children} +
+ ); + } +); + +Container.displayName = 'Container'; + +export const PageContainer = React.forwardRef( + ( + { background = 'secondary', className = '', children, ...props }, + ref + ) => { + const bgClasses: Record = { + primary: 'bg-bg-primary', + secondary: 'bg-bg-secondary', + tertiary: 'bg-bg-tertiary', + }; + + return ( +
+ {children} +
+ ); + } +); + +PageContainer.displayName = 'PageContainer'; + +export interface PageHeaderProps extends Omit, 'title'> { + tagline?: string; + title: React.ReactNode; + description?: string; +} + +export const PageHeader = React.forwardRef( + ( + { tagline, title, description, className = '', ...props }, + ref + ) => { + return ( +
+ {tagline && ( +

+ {tagline} +

+ )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ ); + } +); + +PageHeader.displayName = 'PageHeader'; diff --git a/frontend/src/components/ui/PageTransition.tsx b/frontend/src/components/ui/PageTransition.tsx new file mode 100644 index 0000000..c186027 --- /dev/null +++ b/frontend/src/components/ui/PageTransition.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface PageTransitionProps { + children: React.ReactNode; + className?: string; +} + +export const PageTransition: React.FC = ({ + children, + className = '', +}) => { + return ( +
+ {children} +
+ ); +}; diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..805ab5f --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +export interface SkeletonProps extends React.HTMLAttributes { + width?: string | number; + height?: string | number; + variant?: 'text' | 'circular' | 'rectangular'; + count?: number; +} + +export const Skeleton = React.forwardRef( + ( + { + width = '100%', + height = '1rem', + variant = 'rectangular', + count = 1, + className = '', + ...props + }, + ref + ) => { + const baseClass = 'bg-bg-surface animate-pulse'; + + const variantClasses: Record = { + text: 'rounded-md', + circular: 'rounded-full', + rectangular: 'rounded-lg', + }; + + const skeletons = Array.from({ length: count }).map((_, i) => ( +
+ )); + + return count === 1 ? skeletons[0] :
{skeletons}
; + } +); + +Skeleton.displayName = 'Skeleton'; diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx new file mode 100644 index 0000000..7b7f548 --- /dev/null +++ b/frontend/src/components/ui/Toast.tsx @@ -0,0 +1,120 @@ +import React, { useEffect } from 'react'; + +type ToastVariant = 'success' | 'error' | 'info' | 'warning'; + +export interface ToastProps { + id: string; + message: string; + variant?: ToastVariant; + duration?: number; + onDismiss?: (id: string) => void; +} + +const variantClasses: Record = { + success: { + bg: 'bg-bg-surface border-status-success/30', + border: 'border', + text: 'text-text-primary', + icon: { + bg: 'bg-status-success/10', + text: 'text-status-success', + }, + }, + error: { + bg: 'bg-bg-surface border-status-danger/30', + border: 'border', + text: 'text-text-primary', + icon: { + bg: 'bg-status-danger/10', + text: 'text-status-danger', + }, + }, + info: { + bg: 'bg-bg-surface border-status-info/30', + border: 'border', + text: 'text-text-primary', + icon: { + bg: 'bg-status-info/10', + text: 'text-status-info', + }, + }, + warning: { + bg: 'bg-bg-surface border-status-warning/30', + border: 'border', + text: 'text-text-primary', + icon: { + bg: 'bg-status-warning/10', + text: 'text-status-warning', + }, + }, +}; + +const iconMap: Record = { + success: ( + + + + ), + error: ( + + + + ), + info: ( + + + + ), + warning: ( + + + + ), +}; + +export const Toast: React.FC = ({ + id, + message, + variant = 'info', + duration = 4000, + onDismiss, +}) => { + useEffect(() => { + const timer = setTimeout(() => { + onDismiss?.(id); + }, duration); + return () => clearTimeout(timer); + }, [id, duration, onDismiss]); + + const classes = variantClasses[variant]; + + return ( +
+ {/* Icon */} +
+
+ {iconMap[variant]} +
+
+ + {/* Content */} +
+

{message}

+
+ + {/* Close Button */} + +
+ ); +}; + diff --git a/frontend/src/components/ui/ToastProvider.tsx b/frontend/src/components/ui/ToastProvider.tsx new file mode 100644 index 0000000..5371215 --- /dev/null +++ b/frontend/src/components/ui/ToastProvider.tsx @@ -0,0 +1,55 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { Toast, type ToastProps } from './Toast'; + +interface ToastInput { + message: string; + variant?: 'success' | 'error' | 'info' | 'warning'; + duration?: number; +} + +interface ToastContextType { + addToast: (input: ToastInput) => void; +} + +const ToastContext = createContext(undefined); + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((input: ToastInput) => { + const id = `${Date.now()}-${Math.random()}`; + const toast: ToastProps = { + id, + ...input, + }; + setToasts((prev) => [...prev, toast]); + }, []); + + const dismissToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ +
+ ))} +
+
+ ); +} + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within ToastProvider'); + } + return context; +} diff --git a/frontend/src/components/ui/WelcomeTour.tsx b/frontend/src/components/ui/WelcomeTour.tsx new file mode 100644 index 0000000..9da3fe8 --- /dev/null +++ b/frontend/src/components/ui/WelcomeTour.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react'; +import { Button } from './Button'; + +interface TourStep { + target: string; + title: string; + description: string; + position?: 'top' | 'bottom' | 'left' | 'right'; +} + +interface WelcomeTourProps { + steps: TourStep[]; + onComplete?: () => void; + storageKey?: string; +} + +export function WelcomeTour({ steps, onComplete, storageKey = 'cvpilot_tour_completed' }: WelcomeTourProps) { + const [currentStep, setCurrentStep] = useState(0); + const [isVisible, setIsVisible] = useState(false); + const [targetPos, setTargetPos] = useState({ top: 0, left: 0 }); + + useEffect(() => { + let timerId: ReturnType | null = null; + + try { + const completed = localStorage.getItem(storageKey); + if (!completed && steps.length > 0) { + // Delay showing tour until page is fully loaded + timerId = setTimeout(() => setIsVisible(true), 1000); + } + } catch (err) { + console.error('Failed to check tour completion:', err); + // Proceed with tour even if localStorage fails + if (steps.length > 0) { + timerId = setTimeout(() => setIsVisible(true), 1000); + } + } + + // Cleanup: cancel pending timeout + return () => { + if (timerId !== null) { + clearTimeout(timerId); + } + }; + }, [storageKey, steps]); + + useEffect(() => { + if (!isVisible || currentStep >= steps.length) return; + + const updatePosition = () => { + try { + const target = steps[currentStep]?.target; + if (!target || typeof target !== 'string' || target.trim() === '') { + console.warn('Invalid tour target:', target); + return; + } + + const element = document.querySelector(target); + if (element) { + const rect = element.getBoundingClientRect(); + setTargetPos({ + top: rect.top + window.scrollY - 10, + left: rect.left + window.scrollX - 10, + }); + } + } catch (err) { + console.error('Failed to find tour target element:', err); + } + }; + + updatePosition(); + window.addEventListener('resize', updatePosition); + return () => window.removeEventListener('resize', updatePosition); + }, [currentStep, isVisible, steps]); + + const handleNext = () => { + if (currentStep < steps.length - 1) { + setCurrentStep((prev) => prev + 1); + } else { + handleComplete(); + } + }; + + const handleComplete = () => { + setIsVisible(false); + try { + localStorage.setItem(storageKey, 'true'); + } catch (err) { + console.error('Failed to save tour completion:', err); + // Still proceed if localStorage fails + } + onComplete?.(); + }; + + if (!isVisible || currentStep >= steps.length) return null; + + const step = steps[currentStep]; + const positionClass = step.position === 'top' ? 'bottom-full mb-4' : 'top-full mt-4'; + + return ( + <> + {/* Overlay */} +
+ + {/* Tooltip */} +
+

{step.title}

+

{step.description}

+ +
+
+ {currentStep + 1} of {steps.length} +
+
+ {currentStep > 0 && ( + + )} + +
+
+
+ + ); +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..fdd5718 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,45 @@ +// Button +export { Button } from './Button'; +export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'; + +// Card +export { Card } from './Card'; +export type { CardProps } from './Card'; + +// Badge +export { Badge } from './Badge'; +export type { BadgeProps } from './Badge'; + +// Alert +export { Alert } from './Alert'; +export type { AlertProps } from './Alert'; + +// Input +export { Input } from './Input'; +export type { InputProps } from './Input'; + +// Page Layout Components +export { PageContainer, Container, GridBackground, PageHeader } from './PageLayout'; +export type { PageContainerProps, ContainerProps, PageHeaderProps } from './PageLayout'; + +// Toast Notifications +export { Toast } from './Toast'; +export { ToastProvider, useToast } from './ToastProvider'; +export type { ToastProps } from './Toast'; + +// Skeleton Loading +export { Skeleton } from './Skeleton'; +export type { SkeletonProps } from './Skeleton'; + +// Page Transitions +export { PageTransition } from './PageTransition'; + +// Error Boundary +export { ErrorBoundary } from './ErrorBoundary'; + +// Bookmarks +export { BookmarksProvider, useBookmarks } from './BookmarksProvider'; + +// Welcome Tour +export { WelcomeTour } from './WelcomeTour'; + diff --git a/frontend/src/pages/JobPage.tsx b/frontend/src/pages/JobPage.tsx index f496798..551822f 100644 --- a/frontend/src/pages/JobPage.tsx +++ b/frontend/src/pages/JobPage.tsx @@ -1,97 +1,160 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { JobList } from "../components/Jobs/JobList"; import { MOCK_JOBS } from "../data/MockJobs"; - +import { PageContainer, GridBackground, Container, Button, PageTransition, useToast } from "../components/ui"; +import { checkResumeExists } from "../services/resume"; +import { useApi } from "../lib/fetcher"; + interface JobsPageProps { onNavigateToJob: (jobId: string) => void; } - + export default function JobsPage({ onNavigateToJob }: JobsPageProps) { - const [isLoading] = useState(false); - - const topScore = MOCK_JOBS[0]?.score ?? "0"; - const avgScore = Math.round( - MOCK_JOBS.reduce((acc, j) => acc + parseInt(j.score, 10), 0) / MOCK_JOBS.length - ); - + const [isRefetching, setIsRefetching] = useState(false); + const [hasResume, setHasResume] = useState(false); + const [isCheckingResume, setIsCheckingResume] = useState(true); + const navigate = useNavigate(); + const { addToast } = useToast(); + const { fetchWithAuth } = useApi(); + + // Stable ref to prevent effect rerun + const fetchRef = useRef(fetchWithAuth); + useEffect(() => { + fetchRef.current = fetchWithAuth; + }, [fetchWithAuth]); + + // Check if user has uploaded resume + useEffect(() => { + const checkResume = async () => { + try { + const exists = await checkResumeExists(fetchRef.current); + setHasResume(exists); + } catch (err) { + console.error("Failed to check resume:", err); + setHasResume(false); + } finally { + setIsCheckingResume(false); + } + }; + checkResume(); + }, []); + + // Only show real data: job count + // Note: topScore and avgScore removed - requires actual AI matching we don't have access to + const jobCount = MOCK_JOBS.length; + + const handleRefetchJobs = async () => { + setIsRefetching(true); + // TODO: Implement API call to refetch jobs based on current resume + // This would call the backend to re-run the matching algorithm + setTimeout(() => { + setIsRefetching(false); + addToast({ + message: 'Job list refreshed! New matches may be available.', + variant: 'success', + }); + }, 1500); + }; + return ( -
- {/* Grid background */} -
- -
- {/* Header */} -
-

- CVPILOT — JOB MATCHES -

-

- Your{" "} - - matches. - -

-

- Ranked by neural match score. Top 10 opportunities tailored to your resume and preferences. -

-
- - {/* Stats bar */} -
-
-

- {MOCK_JOBS.length} -

-

MATCHES

-
-
-

- {topScore}% -

-

TOP SCORE

+ + + + + + {/* Page Header - Only show when resume exists */} + {!isCheckingResume && hasResume && ( +
+
+

+ CVpilot — Job Matches +

+

+ Your{" "} + + matches. + +

+

+ Ranked by neural match score. Top 10 opportunities tailored to your resume and preferences. +

+
+ + {/* Action Buttons */} +
+ + +
-
-

- - {avgScore}% -

-

AVG SCORE

+ )} + + {/* Content based on resume status */} + {isCheckingResume ? ( +
+
+ + + + +
-
- - {/* Job list */} - {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} + ) : !hasResume ? ( + /* Show CTA to upload resume */ +
+
+ + + +
+

Upload Your Resume

+

We need your resume to match you with the perfect job opportunities. Let's get started!

+
) : ( - + /* Show job list */ + <> + {/* Stats Card - Only showing realistic data (job count) */} +
+

Available Positions

+

{jobCount}

+
+ + {/* Job List */} + + )} -
-
+
+
+
); } \ No newline at end of file diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index b580875..4c6a1a6 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,25 +1,27 @@ import { Hero } from "../components/Landing/Hero"; import { useUser } from "@clerk/react"; -import { useNavigate } from "react-router-dom"; -import { useEffect } from "react"; +import { PageContainer, GridBackground, PageTransition } from "../components/ui"; + export default function LandingPage() { - const { isSignedIn, isLoaded } = useUser(); - const navigate = useNavigate(); + const { isLoaded } = useUser(); - useEffect(() => { - if (isLoaded && isSignedIn) { - navigate("/profile"); - } - }, [isSignedIn, isLoaded, navigate]); + if (!isLoaded) { + return ( + + +
+
Loading...
+
+
+ ); + } return ( -
- -
+ + + + + + ); } \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index d045125..5379b94 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -4,13 +4,15 @@ import { PreferencesForm } from "../components/Profile/PreferencesForm"; import { PreferencesSummary } from "../components/Profile/PreferencesSummary"; import type { Seniority, LocationType } from "../components/Profile/PreferencesForm"; import { useApi } from "../lib/fetcher"; -import { uploadResume } from "../services/resume"; +import { uploadResume, getResume } from "../services/resume"; import { createPreferences, getPreferences, updatePreferences } from "../services/preferences"; import { useNavigate } from "react-router-dom"; +import { PageContainer, GridBackground, Container, Card, Button, PageHeader, useToast, PageTransition } from "../components/ui"; export default function ProfilePage() { const navigate = useNavigate(); const { fetchWithAuth } = useApi(); + const { addToast } = useToast(); // Keep a stable ref so the useEffect doesn't re-run on every render const fetchRef = useRef(fetchWithAuth); useEffect(() => { fetchRef.current = fetchWithAuth; }, [fetchWithAuth]); @@ -20,6 +22,7 @@ export default function ProfilePage() { // null = no file on server yet, string = filename known const [resumeFileName, setResumeFileName] = useState(null); + const [hasExistingResume, setHasExistingResume] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isUploading, setIsUploading] = useState(false); @@ -27,13 +30,11 @@ export default function ProfilePage() { // Track whether the user already has saved prefs so we know PATCH vs POST const [hasPreferences, setHasPreferences] = useState(false); + const [showEditPrefs, setShowEditPrefs] = useState(false); - // Separate feedback per section so they don't clobber each other - const [resumeError, setResumeError] = useState(null); - const [resumeSuccess, setResumeSuccess] = useState(null); - const [prefsError, setPrefsError] = useState(null); - const [prefsSuccess, setPrefsSuccess] = useState(null); - + // Separate feedback per section + // Note: We now use toasts instead of alerts, no need to track individual state + useEffect(() => { const fetchPrefs = async () => { try { @@ -53,39 +54,73 @@ export default function ProfilePage() { }; fetchPrefs(); }, []); // stable fetchRef means no dep needed - + + // 📄 Load existing resume on mount + useEffect(() => { + const fetchResume = async () => { + try { + const data = await getResume(fetchRef.current); + if (data && data.fileUrl) { + // Use the original filename if available, otherwise use a friendly formatted name + const fileName = data.originalFileName || `Resume_${new Date(data.uploadedAt).toLocaleDateString().replace(/\//g, '-')}.pdf`; + setResumeFileName(fileName); + setHasExistingResume(true); + } else { + setResumeFileName(null); + setHasExistingResume(false); + } + } catch (err) { + console.error("Failed to load resume:", err); + setResumeFileName(null); + setHasExistingResume(false); + } + }; + fetchResume(); + }, []); + // 📄 Resume Upload const handleResumeReplace = useCallback(async (file: File) => { setIsUploading(true); - setResumeError(null); - setResumeSuccess(null); - + try { const data = await uploadResume(file, fetchRef.current); if (data.changed) { setResumeFileName(file.name); - setResumeSuccess("Resume uploaded successfully ✅"); + setHasExistingResume(true); + addToast({ + message: 'Resume uploaded successfully! Ready to find jobs.', + variant: 'success', + }); } else { - setResumeSuccess("Same resume already on file ⚠️"); + // Duplicate file: Show as warning + addToast({ + message: 'This resume was already uploaded.', + variant: 'warning', + }); } } catch (err: any) { - setResumeError(err.message || "Upload failed. Please try again."); + const errorMsg = err.message || "Upload failed. Please try again."; + addToast({ + message: errorMsg, + variant: 'error', + }); } finally { setIsUploading(false); } - }, []); - + }, [addToast]); + + // Auto-dismiss handled by toasts now, no need for extra effects + // Keeping these state vars minimal for potential future use + // ⚙️ Preferences Save — PATCH if exists, POST if new const handlePreferencesSubmit = useCallback(async ( seniority: Seniority, locations: LocationType[] ) => { setIsSaving(true); - setPrefsError(null); - setPrefsSuccess(null); - + const payload = { seniority, locationPreferences: locations }; - + try { let data; if (hasPreferences) { @@ -96,112 +131,125 @@ export default function ProfilePage() { } setSavedSeniority(data.seniority); setSavedLocations(data.locationPreferences); - setPrefsSuccess(hasPreferences ? "Preferences updated ✅" : "Preferences saved ✅"); + const successMsg = hasPreferences ? "Preferences updated ✅" : "Preferences saved ✅"; + addToast({ + message: successMsg, + variant: 'success', + }); } catch (err: any) { - setPrefsError(err.message || "Failed to save preferences. Please try again."); + const errorMsg = err.message || "Failed to save preferences. Please try again."; + addToast({ + message: errorMsg, + variant: 'error', + }); } finally { setIsSaving(false); } - }, [hasPreferences]); + }, [hasPreferences, addToast]); return ( -
- {/* Background grid */} -
- -
- {/* Header */} -
-

- Your{" "} - - profile. - -

-

- Manage your resume and job preferences to get matched with the right opportunities. -

-
- -
- {/* Resume section with its own feedback */} -
- {resumeError && ( -
- {resumeError} -
- )} - {resumeSuccess && ( -
- {resumeSuccess} + + + + + + {/* Page Header */} + Your profile.} + description="Manage your resume and job preferences to get matched with the right opportunities." + className="mb-10" + /> + +
+ {/* Resume Section */} + + +

Upload Your Resume

+

Required to get job matches

+
+ + + +
+ + {/* Preferences Section */} + + +
+

Job Preferences

+

Tell us what you're looking for

- )} - -
- - {/* Preferences section with its own feedback */} -
- {prefsError && ( -
- {prefsError} -
- )} - {prefsSuccess && ( -
- {prefsSuccess} -
- )} - {isLoadingPrefs ? ( -
- - - - Loading preferences… -
- ) : ( - <> - - - - )} + {hasPreferences && ( + + )} + + + {isLoadingPrefs ? ( +
+
+
+
+
+
+
+
+
+
+
+
+ ) : ( + <> + {/* Show form only if no prefs saved OR edit mode is open */} + {(!hasPreferences || showEditPrefs) && ( +
+ +
+ )} + + {/* Show summary if prefs exist */} + {hasPreferences && ( + + )} + + )} + + + + {/* Action Buttons */} +
+
-
-
-
+ + + ); + } \ No newline at end of file diff --git a/frontend/src/pages/SavedPage.tsx b/frontend/src/pages/SavedPage.tsx new file mode 100644 index 0000000..a69bb53 --- /dev/null +++ b/frontend/src/pages/SavedPage.tsx @@ -0,0 +1,105 @@ +import { useNavigate } from "react-router-dom"; +import { JobList } from "../components/Jobs/JobList"; +import { PageContainer, GridBackground, Container, Button, PageTransition, useBookmarks } from "../components/ui"; +import { MOCK_JOBS } from "../data/MockJobs"; + +interface SavedPageProps { + onNavigateToJob?: (jobId: string) => void; +} + +export default function SavedPage({ onNavigateToJob }: SavedPageProps) { + const navigate = useNavigate(); + const { bookmarks } = useBookmarks(); + + // Filter jobs to only show bookmarked ones + const savedJobs = MOCK_JOBS.filter(job => bookmarks.has(job.jobId)); + const savedCount = savedJobs.length; + + const handleNavigateToJob = (jobId: string) => { + onNavigateToJob?.(jobId); + }; + + return ( + + + + + + {/* Page Header */} +
+

+ CVpilot — Saved Opportunities +

+

+ Your + saved + {" "} + opportunities. +

+

+ {savedCount === 0 + ? "Start saving jobs to build your collection of opportunities." + : `${savedCount} ${savedCount === 1 ? 'opportunity' : 'opportunities'} saved and ready for review.`} +

+
+ + {/* Content */} + {savedCount === 0 ? ( + /* Empty state */ +
+
+ + + +
+

No Saved Jobs Yet

+

+ Browse through opportunities and save the ones that interest you most. Your saved jobs will appear here. +

+ +
+ ) : ( + /* Show saved jobs */ + <> + {/* Stats Card */} +
+

Saved Positions

+

{savedCount}

+
+ + {/* Job List */} + + + {/* Back Button */} +
+ +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/services/resume.ts b/frontend/src/services/resume.ts index eee7119..aafd541 100644 --- a/frontend/src/services/resume.ts +++ b/frontend/src/services/resume.ts @@ -10,5 +10,37 @@ export const uploadResume = async ( body: formData, }); + return res.json(); +}; + +/** + * Check if user has uploaded a resume + * Returns true if resume exists and is ready (200) or parsing (202) + * Returns false if no resume (404) or any error occurs + */ +export const checkResumeExists = async ( + fetchWithAuth: (url: string, options?: RequestInit) => Promise +): Promise => { + try { + const res = await fetchWithAuth("/resume"); + // 200 = resume ready, 202 = parsing in progress, both mean hasResume=true + return res.status === 200 || res.status === 202; + } catch (err) { + // If fetch fails or 404, assume no resume + console.error("Failed to check resume status:", err); + return false; + } +}; + +/** + * Fetch the current resume data for the user + * Returns resume metadata or null if not found + */ +export const getResume = async ( + fetchWithAuth: (url: string, options?: RequestInit) => Promise +) => { + const res = await fetchWithAuth("/resume"); + if (res.status === 404) return null; + if (!res.ok) throw new Error("Failed to fetch resume"); return res.json(); }; \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index d37737f..3d9e0b0 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -5,7 +5,153 @@ export default { "./src/**/*.{js,ts,jsx,tsx}", ], theme: { - extend: {}, + fontFamily: { + 'sans': [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + ], + 'display': [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + ], + }, + extend: { + // Color system with semantic names + colors: { + // Background colors + 'bg-primary': '#07090f', // Landing page, hero backgrounds + 'bg-secondary': '#080b14', // Main content areas (Jobs, Profile) + 'bg-tertiary': '#0b0f19', // App layout background + 'bg-surface': '#0d111c', // Cards, elevated surfaces + 'bg-hover': '#0f1427', // Hover states on surfaces + 'bg-interactive': '#131829', // Interactive elements + + // Text colors + 'text-primary': '#e8e8e8', // Headings, primary text + 'text-secondary': '#b3bac2', // Body text, descriptions + 'text-tertiary': '#7a8290', // Labels, muted text + 'text-disabled': '#4f5563', // Disabled text + 'text-inverse': '#ffffff', // White text on dark backgrounds + + // Border colors with opacity + 'border-light': 'rgba(255, 255, 255, 0.08)', + 'border-normal': 'rgba(255, 255, 255, 0.12)', + 'border-hover': 'rgba(255, 255, 255, 0.16)', + 'border-focus': 'rgba(99, 102, 241, 0.3)', + + // Surface hover/active states + 'surface-hover': 'rgba(255, 255, 255, 0.06)', + 'surface-active': 'rgba(255, 255, 255, 0.08)', + + // Status colors + 'status-success': '#34d399', // Emerald - success, remote, positive + 'status-info': '#818cf8', // Indigo - info, primary, strong + 'status-warning': '#fb923c', // Orange - warning, moderate, intern + 'status-danger': '#f87171', // Red - danger, error, negative + 'status-muted': '#94a3b8', // Slate - muted, neutral, fair + + // Accent colors + 'accent-primary': '#6366f1', // Indigo primary + 'accent-light': '#a5b4fc', // Indigo light + 'accent-bright': '#818cf8', // Indigo bright + }, + + // Semantic spacing + spacing: { + 'xs': '4px', + 'sm': '8px', + 'md': '16px', + 'lg': '24px', + 'xl': '32px', + 'xxl': '48px', + }, + + // Typography scale + fontSize: { + 'xs': ['12px', { lineHeight: '16px', letterSpacing: '0em' }], + 'sm': ['14px', { lineHeight: '20px', letterSpacing: '0em' }], + 'base': ['16px', { lineHeight: '24px', letterSpacing: '0em' }], + 'lg': ['18px', { lineHeight: '28px', letterSpacing: '0em' }], + 'xl': ['20px', { lineHeight: '28px', letterSpacing: '0em' }], + '2xl': ['24px', { lineHeight: '32px', letterSpacing: '-0.02em' }], + '3xl': ['30px', { lineHeight: '36px', letterSpacing: '-0.02em' }], + '4xl': ['36px', { lineHeight: '44px', letterSpacing: '-0.02em' }], + '5xl': ['48px', { lineHeight: '56px', letterSpacing: '-0.02em' }], + '6xl': ['60px', { lineHeight: '68px', letterSpacing: '-0.02em' }], + }, + + // Letter spacing + letterSpacing: { + 'tight': '-0.02em', + 'normal': '0em', + 'wide': '0.035em', + 'wider': '0.2em', + 'widest': '0.35em', + }, + + // Border radius + borderRadius: { + 'sm': '6px', + 'md': '8px', + 'lg': '12px', + 'xl': '16px', + '2xl': '24px', + }, + + // Box shadows with glow effects + boxShadow: { + 'xs': '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + 'sm': '0 1px 3px 0 rgba(0, 0, 0, 0.1)', + 'md': '0 4px 6px -1px rgba(0, 0, 0, 0.1)', + 'lg': '0 8px 16px -2px rgba(0, 0, 0, 0.15)', + 'glow-sm': '0 0 12px rgba(99, 102, 241, 0.15)', + 'glow-md': '0 0 20px rgba(99, 102, 241, 0.25)', + 'glow-lg': '0 0 32px rgba(99, 102, 241, 0.35)', + }, + + // Max widths for containers + maxWidth: { + 'container-sm': '640px', + 'container-md': '768px', + 'container-lg': '1024px', + 'container-xl': '1280px', + 'content': '900px', // Main content width + 'form': '600px', // Form content width + }, + + // Animation additions + keyframes: { + 'fade-in': { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + 'slide-down': { + '0%': { transform: 'translateY(-8px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + animation: { + 'fade-in': 'fade-in 200ms ease-in-out', + 'slide-down': 'slide-down 200ms ease-out', + }, + + // Transitions + transitionDuration: { + 'fast': '150ms', + 'base': '200ms', + 'slow': '300ms', + }, + }, }, plugins: [], }