Moved HowItWorks into home page

This commit is contained in:
James Ketr 2025-06-11 10:20:06 -07:00
parent e61e88561e
commit 7c78f39b02
26 changed files with 1218 additions and 1531 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -17,7 +17,8 @@ import {
DialogActions,
Checkbox,
FormControlLabel,
Grid
Grid,
IconButton
} from '@mui/material';
import {
Email as EmailIcon,
@ -25,7 +26,9 @@ import {
CheckCircle as CheckCircleIcon,
ErrorOutline as ErrorIcon,
Refresh as RefreshIcon,
DevicesOther as DevicesIcon
DevicesOther as DevicesIcon,
VisibilityOff,
Visibility
} from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
@ -493,6 +496,7 @@ const LoginForm = () => {
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
if (!error) {
@ -547,14 +551,28 @@ const LoginForm = () => {
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
label="Password"
type="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
placeholder="Create a strong password"
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{errorMessage && (

File diff suppressed because it is too large Load Diff

View File

@ -34,44 +34,36 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const [resume, setResume] = useState<string>('');
const [prompt, setPrompt] = useState<string>('');
const [systemPrompt, setSystemPrompt] = useState<string>('');
const [generating, setGenerating] = useState<boolean>(false);
const [statusMessage, setStatusMessage] = useState<Types.ChatMessageStatus | null>(null);
const [generated, setGenerated] = useState<boolean>(false);
const [tabValue, setTabValue] = useState<string>('resume');
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
}
// State for editing job description
const generateResumeHandlers = {
onStatus: (status: Types.ChatMessageStatus) => {
setStatusMessage({ ...defaultMessage, content: status.content.toLowerCase() });
},
onStreaming: (chunk: Types.ChatMessageStreaming) =>{
setResume(chunk.content);
},
onComplete: () => {
setStatusMessage(null);
}
};
useEffect(() => {
if (!job || !candidate || !skills || resume || generating) {
if (!job || !candidate || !skills || generated) {
return;
}
setGenerated(true);
const generateResumeHandlers = {
onStreaming: (chunk: Types.ChatMessageStreaming) => {
setResume(chunk.content);
}
};
const generateResume = async () => {
const request : any = await apiClient.generateResume(candidate.id || '', skills, generateResumeHandlers);
const result = await request.promise;
setSystemPrompt(result.systemPrompt)
setPrompt(result.prompt)
setResume(result.resume)
};
setGenerating(true);
generateResume().then(() =>{
setGenerating(false);
});
}, [job, candidate, apiClient, resume, skills, generating]);
const request: any = await apiClient.generateResume(candidate.id || '', skills, generateResumeHandlers);
const result = await request.promise;
setSystemPrompt(result.systemPrompt)
setPrompt(result.prompt)
setResume(result.resume)
};
generateResume();
}, [job, candidate, apiClient, resume, skills, generated, setSystemPrompt, setPrompt, setResume]);
return (
<Box
@ -87,8 +79,8 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
<Tab sx={{ display: resume ? "flex" : "none" }} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>}
{statusMessage && <Message message={statusMessage} />}
<Paper elevation={3} sx={{ p: 3, m: 4, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1 }}>
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1 }}>
{tabValue === 'system' && <pre>{systemPrompt}</pre>}
{tabValue === 'prompt' && <pre>{prompt}</pre>}
{tabValue === 'resume' && <StyledMarkdown content={resume} />}

View File

@ -4,7 +4,7 @@ import { Outlet, useLocation, Routes, Route } from "react-router-dom";
import { Box, Container, Paper } from '@mui/material';
import { useNavigate } from "react-router-dom";
import { SxProps, Theme } from '@mui/material';
import { darken } from '@mui/material/styles';
import { Header } from 'components/layout/Header';
import { Scrollable } from 'components/Scrollable';
import { Footer } from 'components/layout/Footer';
@ -156,7 +156,7 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
mt: "72px", /* Needs to be kept in sync with the height of Header */
display: "flex",
flexDirection: "column",
backgroundColor: "background.default",
backgroundColor: (theme) => darken(theme.palette.background.default, 0.4),
height: "100%",
maxHeight: "100%",
minHeight: "100%",

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Box, Link, Typography, Avatar, Grid, SxProps } from '@mui/material';
import React, { useState, useRef, useEffect } from 'react';
import { Box, Link, Typography, Avatar, Grid, SxProps, Tooltip, IconButton } from '@mui/material';
import {
Card,
CardContent,
@ -37,6 +37,20 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
const ai: CandidateAI | null = ('isAI' in candidate) ? candidate as CandidateAI : null;
const isAdmin = user?.isAdmin;
// State for description expansion
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
const descriptionRef = useRef<HTMLDivElement>(null);
// Check if description needs truncation
useEffect(() => {
if (descriptionRef.current && candidate.description) {
const element = descriptionRef.current;
// Check if the scrollHeight is greater than clientHeight (meaning content is truncated)
setShouldShowMoreButton(element.scrollHeight > element.clientHeight);
}
}, [candidate.description]);
const deleteCandidate = async (candidateId: string | undefined) => {
if (candidateId) {
await apiClient.deleteCandidate(candidateId);
@ -51,12 +65,9 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<Box
sx={{
display: "flex",
borderColor: 'transparent',
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease',
flexGrow: 1,
p: isMobile ? 1 : 3,
p: isMobile ? 1 : 2,
height: '100%',
flexDirection: 'column',
alignItems: 'stretch',
@ -114,23 +125,58 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<CopyBubble
onClick={(event: any) => { event.stopPropagation() }}
tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} />
{isAdmin && ai &&
<DeleteConfirmation
onDelete={() => { deleteCandidate(candidate.id); }}
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content", color: "red" }}
action="delete"
label="user"
title="Delete AI user"
icon=<DeleteIcon />
message={`Are you sure you want to delete ${candidate.username}? This action cannot be undone.`}
/>}
</Box>
</Box>
</Box>
</Box>
</Box>
{(!isMobile || variant !== "small") && <Typography variant="body1" color="text.secondary">
{candidate.description}
</Typography>}
<Box>
{(!isMobile || variant !== "small") && (
<Box sx={{ minHeight: "5rem" }}>
<Typography
ref={descriptionRef}
variant="body1"
color="text.secondary"
sx={{
display: '-webkit-box',
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.5,
fontSize: "0.8rem !important",
}}
>
{candidate.description}
</Typography>
{shouldShowMoreButton && (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
sx={{
color: theme.palette.primary.main,
textDecoration: 'none',
cursor: 'pointer',
fontSize: '0.725rem',
fontWeight: 500,
mt: 0.5,
display: 'block',
'&:hover': {
textDecoration: 'underline',
}
}}
>
[{isDescriptionExpanded ? "less" : "more"}]
</Link>
)}
</Box>
)}
{variant !== "small" && <>
<Divider sx={{ my: 2 }} />
@ -151,9 +197,20 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
}
</>}
</Box>
</Box>
{isAdmin && ai &&
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "flex-start" }}>
<Tooltip title="Delete Job">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); deleteCandidate(candidate.id); }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
}
</Box>
);
};
export { CandidateInfo };
export { CandidateInfo };

View File

@ -8,6 +8,7 @@ import { CandidateInfo } from 'components/ui/CandidateInfo';
import { Candidate, CandidateAI } from "types/types";
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext';
import { Paper } from '@mui/material';
interface CandidatePickerProps extends BackstoryElementProps {
onSelect?: (candidate: Candidate) => void;
@ -57,16 +58,11 @@ const CandidatePicker = (props: CandidatePickerProps) => {
<Box sx={{ display: "flex", flexDirection: "column", ...sx }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
{candidates?.map((u, i) =>
<Box key={`${u.username}`}
<Paper key={`${u.username}`}
onClick={() => { onSelect ? onSelect(u) : setSelectedCandidate(u); }}
sx={{ cursor: "pointer" }}>
{selectedCandidate?.id === u.id &&
<CandidateInfo sx={{ maxWidth: "320px", "cursor": "pointer", backgroundColor: "#f0f0f0", "&:hover": { border: "2px solid orange" } }} candidate={u} />
}
{selectedCandidate?.id !== u.id &&
<CandidateInfo sx={{ maxWidth: "320px", "cursor": "pointer", border: "2px solid transparent", "&:hover": { border: "2px solid orange" } }} candidate={u} />
}
</Box>
<CandidateInfo variant="small" sx={{ maxWidth: "320px", "cursor": "pointer", backgroundColor: (selectedCandidate?.id === u.id) ? "#f0f0f0" : "inherit", border: "2px solid transparent", "&:hover": { border: "2px solid orange" } }} candidate={u} />
</Paper>
)}
</Box>
</Box>

View File

@ -1,4 +1,4 @@
import React, { JSX, useActionState, useState } from 'react';
import React, { JSX, useActionState, useEffect, useRef, useState } from 'react';
import { Box, Link, Typography, Avatar, Grid, SxProps, CardActions, Chip, Stack, CardHeader, Button, styled, LinearProgress, IconButton, Tooltip } from '@mui/material';
import {
Card,
@ -47,6 +47,19 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
const [adminStatus, setAdminStatus] = useState<string | null>(null);
const [adminStatusType, setAdminStatusType] = useState<Types.ApiActivityType | null>(null);
const [activeJob, setActiveJob] = useState<Types.Job>({ ...job }); /* Copy of job */
// State for description expansion
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
const descriptionRef = useRef<HTMLDivElement>(null);
// Check if description needs truncation
useEffect(() => {
if (descriptionRef.current && job.summary) {
const element = descriptionRef.current;
// Check if the scrollHeight is greater than clientHeight (meaning content is truncated)
setShouldShowMoreButton(element.scrollHeight > element.clientHeight);
}
}, [job.summary]);
const deleteJob = async (jobId: string | undefined) => {
if (jobId) {
@ -203,28 +216,72 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
}}
{...rest}
>
<Box sx={{ display: "flex", flexGrow: 1, p: isMobile ? 1 : 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
<Box sx={{
display: "flex", flexDirection: (isMobile || variant !== "small") ? "column" : "row",
display: "flex", flexDirection: (isMobile || variant === "small") ? "column" : "row",
"& > div > div > :first-of-type": { fontWeight: "bold" },
"& > div > div > :last-of-type": { mb: 0.75, mr: 1 }
}}>
<Box sx={{ display: "flex", flexDirection: isMobile ? "row" : "column", flexGrow: 1 }}>
{
activeJob.title && <Box sx={{ fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", flexDirection: isMobile ? "row" : "column", flexGrow: 1, gap: 1 }}>
{activeJob.company &&
<Box sx={{ fontSize: "0.8rem" }}>
<Box>Company</Box>
<Box>{activeJob.company}</Box>
</Box>
}
{activeJob.title &&
<Box sx={{ fontSize: "0.8rem" }}>
<Box>Title</Box>
<Box>{activeJob.title}</Box>
</Box>
}
{activeJob.company && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Company</Box>
<Box>{activeJob.company}</Box>
</Box>}
</Box>
<Box sx={{ display: "flex", flexDirection: "column", width: "75%" }}>
<Box sx={{ display: "flex", flexDirection: "column", width: (variant !== "small") ? "75%" : "100%" }}>
{!isMobile && activeJob.summary && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Summary</Box>
<Box>{activeJob.summary}</Box>
<Box sx={{ minHeight: "5rem" }}>
<Typography
ref={descriptionRef}
variant="body1"
color="text.secondary"
sx={{
display: '-webkit-box',
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.5,
fontSize: "0.8rem !important",
}}
>
{activeJob.summary}
</Typography>
{shouldShowMoreButton && (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
sx={{
color: theme.palette.primary.main,
textDecoration: 'none',
cursor: 'pointer',
fontSize: '0.725rem',
fontWeight: 500,
mt: 0.5,
display: 'block',
'&:hover': {
textDecoration: 'underline',
}
}}
>
[{isDescriptionExpanded ? "less" : "more"}]
</Link>
)}
</Box>
</Box>}
</Box>
</Box>

View File

@ -8,6 +8,7 @@ import { JobInfo } from 'components/ui/JobInfo';
import { Job } from "types/types";
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { Paper } from '@mui/material';
interface JobPickerProps extends BackstoryElementProps {
onSelect?: (job: Job) => void
@ -48,16 +49,11 @@ const JobPicker = (props: JobPickerProps) => {
<Box sx={{display: "flex", flexDirection: "column", mb: 1}}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
{jobs?.map((j, i) =>
<Box key={`${j.id}`}
onClick={() => { onSelect ? onSelect(j) : setSelectedJob(j); }}
sx={{ cursor: "pointer" }}>
{selectedJob?.id === j.id &&
<JobInfo sx={{ maxWidth: "320px", "cursor": "pointer", backgroundColor: "#f0f0f0", "&:hover": { border: "2px solid orange" } }} job={j} />
}
{selectedJob?.id !== j.id &&
<JobInfo sx={{ maxWidth: "320px", "cursor": "pointer", border: "2px solid transparent", "&:hover": { border: "2px solid orange" } }} job={j} />
}
</Box>
<Paper key={`${j.id}`}
onClick={() => { onSelect ? onSelect(j) : setSelectedJob(j); }}
sx={{ cursor: "pointer" }}>
<JobInfo variant="small" sx={{ maxWidth: "320px", "cursor": "pointer", backgroundColor: (selectedJob?.id === j.id) ? "#f0f0f0" : "inherit", border: "2px solid transparent", "&:hover": { border: "2px solid orange" } }} job={j} />
</Paper>
)}
</Box>
</Box>

View File

@ -24,7 +24,7 @@ import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { HomePage } from 'pages/HomePage';
import { CandidateChatPage } from 'pages/CandidateChatPage';
import { DocsPage } from 'pages/DocsPage';
import { CreateProfilePage } from 'pages/CreateProfilePage';
import { CreateProfilePage } from 'pages/candidate/ProfileWizard';
import { VectorVisualizerPage } from 'pages/VectorVisualizerPage';
import { BetaPage } from 'pages/BetaPage';
import { JobAnalysisPage } from 'pages/JobAnalysisPage';
@ -51,8 +51,8 @@ const SettingsPage = () => (<BetaPage><Typography variant="h4">Settings</Typogra
export const navigationConfig: NavigationConfig = {
items: [
{ id: 'home', label: <BackstoryLogo />, path: '/', component: <HomePage />, userTypes: ['guest', 'candidate', 'employer'], exact: true, },
{ id: 'how-it-works', label: 'How It Works', path: '/how-it-works', icon: <SchoolIcon />, component: <HowItWorks />, userTypes: ['guest', 'candidate', 'employer',], },
{ id: 'home', label: <BackstoryLogo />, path: '/', component: <HowItWorks />, userTypes: ['guest', 'candidate', 'employer'], exact: true, },
// { id: 'how-it-works', label: 'How It Works', path: '/how-it-works', icon: <SchoolIcon />, component: <HowItWorks />, userTypes: ['guest', 'candidate', 'employer',], },
{ id: 'job-analysis', label: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon />, component: <JobAnalysisPage />, userTypes: ['guest', 'candidate', 'employer',], },
{ id: 'chat', label: 'Candidate Chat', path: '/chat', icon: <ChatIcon />, component: <CandidateChatPage />, userTypes: ['guest', 'candidate', 'employer',], }, {
id: 'candidate-menu', label: 'Tools', icon: <PersonIcon />, userTypes: ['candidate'], children: [
@ -105,7 +105,7 @@ export const navigationConfig: NavigationConfig = {
{
id: 'login',
label: 'Login',
path: '/login',
path: '/login/*',
component: <LoginPage />,
userTypes: ['guest', 'candidate', 'employer'],
},

View File

@ -1,110 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<!-- Background gradient -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a2b45;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2a3b55;stop-opacity:1" />
</linearGradient>
<!-- Shadow filter -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="3" dy="3" stdDeviation="5" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Base background -->
<rect width="800" height="500" fill="url(#bgGradient)" rx="5" ry="5"/>
<!-- Abstract connection lines in background -->
<path d="M100,100 C300,50 500,200 700,100" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<path d="M100,200 C300,150 500,300 700,200" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<path d="M100,300 C300,250 500,400 700,300" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<path d="M100,400 C300,350 500,450 700,400" stroke="#ffffff" stroke-width="1" fill="none" stroke-opacity="0.1"/>
<!-- Left person - more photorealistic style -->
<g filter="url(#shadow)">
<!-- Suit/blazer shape -->
<path d="M190,180 L230,170 Q260,230 250,300 L200,320 Q190,250 170,230 Z" fill="#2c3e50"/>
<!-- Shirt collar -->
<path d="M200,175 L230,170 L235,190 L210,195 Z" fill="#f5f5f5"/>
<!-- Head shape -->
<circle cx="210" cy="130" r="50" fill="#e0c4a8"/>
<!-- Hair -->
<path d="M170,115 Q210,80 250,115 L240,135 Q215,110 180,135 Z" fill="#4a3520"/>
<!-- Face features suggestion -->
<ellipse cx="195" cy="120" rx="5" ry="3" fill="#333333"/>
<ellipse cx="225" cy="120" rx="5" ry="3" fill="#333333"/>
<path d="M195,145 Q210,155 225,145" fill="none" stroke="#333333" stroke-width="2"/>
</g>
<!-- Middle elements - digital content -->
<g filter="url(#shadow)">
<!-- Resume/CV element -->
<rect x="310" y="150" width="180" height="240" rx="5" ry="5" fill="#f5f5f5"/>
<!-- Resume content suggestion -->
<line x1="330" y1="180" x2="470" y2="180" stroke="#333" stroke-width="3"/>
<line x1="330" y1="200" x2="470" y2="200" stroke="#333" stroke-width="1"/>
<line x1="330" y1="215" x2="470" y2="215" stroke="#333" stroke-width="1"/>
<line x1="330" y1="230" x2="470" y2="230" stroke="#333" stroke-width="1"/>
<line x1="330" y1="260" x2="390" y2="260" stroke="#333" stroke-width="2"/>
<line x1="330" y1="280" x2="470" y2="280" stroke="#333" stroke-width="1"/>
<line x1="330" y1="295" x2="470" y2="295" stroke="#333" stroke-width="1"/>
<line x1="330" y1="310" x2="470" y2="310" stroke="#333" stroke-width="1"/>
<line x1="330" y1="340" x2="390" y2="340" stroke="#333" stroke-width="2"/>
<line x1="330" y1="360" x2="470" y2="360" stroke="#333" stroke-width="1"/>
</g>
<!-- Digital connecting elements -->
<g>
<path d="M250,200 Q275,210 310,210" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="250" cy="200" r="5" fill="#4f97eb"/>
<circle cx="310" cy="210" r="5" fill="#4f97eb"/>
<path d="M250,250 Q285,240 310,260" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="250" cy="250" r="5" fill="#4f97eb"/>
<circle cx="310" cy="260" r="5" fill="#4f97eb"/>
<path d="M250,300 Q275,320 310,310" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="250" cy="300" r="5" fill="#4f97eb"/>
<circle cx="310" cy="310" r="5" fill="#4f97eb"/>
<path d="M490,200 Q515,210 550,190" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="490" cy="200" r="5" fill="#4f97eb"/>
<circle cx="550" cy="190" r="5" fill="#4f97eb"/>
<path d="M490,250 Q515,240 550,260" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="490" cy="250" r="5" fill="#4f97eb"/>
<circle cx="550" cy="260" r="5" fill="#4f97eb"/>
<path d="M490,300 Q515,320 550,310" stroke="#4f97eb" stroke-width="2" fill="none" stroke-dasharray="3,3"/>
<circle cx="490" cy="300" r="5" fill="#4f97eb"/>
<circle cx="550" cy="310" r="5" fill="#4f97eb"/>
</g>
<!-- Right person - more photorealistic style -->
<g filter="url(#shadow)">
<!-- Suit/blazer shape -->
<path d="M570,180 L610,170 Q630,230 620,300 L580,320 Q560,250 550,230 Z" fill="#2c3e50"/>
<!-- Shirt collar -->
<path d="M580,175 L610,170 L615,190 L590,195 Z" fill="#f5f5f5"/>
<!-- Head shape -->
<circle cx="590" cy="130" r="50" fill="#e0c4a8"/>
<!-- Hair -->
<path d="M550,110 Q590,80 625,110 L615,140 Q585,120 560,140 Z" fill="#774936"/>
<!-- Face features suggestion -->
<ellipse cx="575" cy="120" rx="5" ry="3" fill="#333333"/>
<ellipse cx="605" cy="120" rx="5" ry="3" fill="#333333"/>
<path d="M575,145 Q590,155 605,145" fill="none" stroke="#333333" stroke-width="2"/>
</g>
<!-- Top title and subtitle elements -->
<g>
<text x="400" y="60" font-family="Arial, sans-serif" font-size="24" text-anchor="middle" fill="#ffffff" font-weight="bold">Professional Conversations</text>
<text x="400" y="90" font-family="Arial, sans-serif" font-size="16" text-anchor="middle" fill="#e1e8f0">Discover the depth of your career journey</text>
</g>
<!-- Additional decorative elements -->
<circle cx="150" cy="420" r="30" fill="#3b5998" opacity="0.2"/>
<circle cx="650" cy="420" r="30" fill="#3b5998" opacity="0.2"/>
<circle cx="400" cy="450" r="20" fill="#3b5998" opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -18,7 +18,7 @@ import PersonSearchIcon from '@mui/icons-material/PersonSearch';
import WorkHistoryIcon from '@mui/icons-material/WorkHistory';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import DescriptionIcon from '@mui/icons-material/Description';
import professionalConversationPng from './Conversation.png';
import professionalConversationPng from 'assets/Conversation.png';
import { ComingSoon } from 'components/ui/ComingSoon';
import { useAuth } from 'hooks/AuthContext';

View File

@ -13,6 +13,8 @@ import {
Step,
StepLabel,
Stepper,
Stack,
ButtonProps,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@ -21,14 +23,23 @@ import PersonIcon from '@mui/icons-material/Person';
import WorkIcon from '@mui/icons-material/Work';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import DescriptionIcon from '@mui/icons-material/Description';
import professionalConversationPng from 'assets/Conversation.png';
import selectAJobPng from 'assets/select-a-job.png';
import selectJobAnalysisPng from 'assets/select-job-analysis.png';
import selectACandidatePng from 'assets/select-a-candidate.png';
import selectStartAnalysisPng from 'assets/select-start-analysis.png';
import waitPng from 'assets/wait.png';
import finalResumePng from 'assets/final-resume.png';
import { Beta } from 'components/ui/Beta';
// Styled components matching HomePage patterns
const HeroSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(6, 0),
padding: theme.spacing(3, 0),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
[theme.breakpoints.down('md')]: {
padding: theme.spacing(4, 0),
padding: theme.spacing(2, 0),
},
}));
@ -119,7 +130,7 @@ const StepContent: React.FC<StepContentProps> = ({
<Typography variant="h3" component="h2" sx={{ color: 'primary.main', mb: 1 }}>
{title}
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: { xs: 'center', md: 'flex-start' } }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', justifyContent: { xs: 'center', md: 'flex-start' } }}>
{icon}
<Typography variant="body2" color="text.secondary">
{subtitle}
@ -173,6 +184,34 @@ const StepContent: React.FC<StepContentProps> = ({
</Grid>
);
};
interface HeroButtonProps extends ButtonProps {
children?: string;
path: string;
}
const HeroButton = (props: HeroButtonProps) => {
const { children, onClick, path, ...rest } = props;
const navigate = useNavigate();
const handleClick = () => {
navigate(path);
};
const HeroStyledButton = styled(Button)(({ theme }) => ({
marginTop: theme.spacing(2),
padding: theme.spacing(1, 3),
fontWeight: 500,
backgroundColor: theme.palette.action.active,
color: theme.palette.background.paper,
'&:hover': {
backgroundColor: theme.palette.action.active,
opacity: 0.9,
},
}));
return <HeroStyledButton onClick={onClick ? onClick : handleClick} {...rest}>
{children}
</HeroStyledButton>
}
const HowItWorks: React.FC = () => {
const navigate = useNavigate();
@ -183,10 +222,76 @@ const HowItWorks: React.FC = () => {
return (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{/* Hero Section */}
{/* Hero Section */}
<HeroSection>
<Container>
<Box sx={{ textAlign: 'center', maxWidth: 800, mx: 'auto' }}>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
alignItems: 'center',
flexGrow: 1,
maxWidth: "1024px"
}}>
<Box sx={{ flex: 1, flexGrow: 1 }}>
<Typography
variant="h2"
component="h1"
sx={{
fontWeight: 700,
fontSize: { xs: '2rem', md: '3rem' },
mb: 2,
color: "white"
}}
>
Your complete professional story, beyond a single page
</Typography>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 400 }}>
Let potential employers discover the depth of your experience through interactive Q&A and tailored resumes
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<HeroButton
variant="contained"
size="large"
path="/login/register"
>
Get Started as Candidate
</HeroButton>
{/* <HeroButton
variant="outlined"
size="large"
sx={{
backgroundColor: 'transparent',
border: '2px solid',
borderColor: 'action.active'
}}
>
Recruit Talent
</HeroButton> */}
</Stack>
</Box>
<Box sx={{ justifyContent: "center", display: { xs: 'none', md: 'block' } }}>
<Box
component="img"
src={professionalConversationPng}
alt="Professional conversation"
sx={{
width: '100%',
maxWidth: 200,
height: 'auto',
borderRadius: 2,
boxShadow: 3,
}}
/>
</Box>
</Box>
</Container>
</HeroSection>
<HeroSection sx={{ display: "flex", position: "relative", overflow: "hidden", border: "2px solid orange" }}>
<Beta sx={{ left: "-90px" }} />
<Container sx={{ display: "flex", position: "relative" }}>
<Box sx={{ display: "flex", flexDirection: "column", textAlign: 'center', maxWidth: 800, mx: 'auto', position: "relative" }}>
<Typography
variant="h2"
component="h1"
@ -238,7 +343,7 @@ const HowItWorks: React.FC = () => {
description={[
"Select 'Job Analysis' from the menu. This takes you to the interactive Job Analysis page, where you will get to evaluate a candidate for a selected job."
]}
imageSrc="/select-job-analysis.png"
imageSrc={selectJobAnalysisPng}
imageAlt="Select Job Analysis from menu"
/>
</Container>
@ -255,7 +360,7 @@ const HowItWorks: React.FC = () => {
description={[
"Once on the Job Analysis Page, explore a little bit and then select one of the jobs. The requirements and information provided on Backstory are extracted from job postings that users have pasted as a job description or uploaded from a PDF."
]}
imageSrc="/select-a-job.png"
imageSrc={selectAJobPng}
imageAlt="Select a job from the available options"
note="You can create your own job postings once you create an account. Until then, you need to select one that already exists."
reversed={true}
@ -274,7 +379,7 @@ const HowItWorks: React.FC = () => {
description={[
"Now that you have a Job selected, you need to select a candidate. In addition to myself (James), there are several candidates which AI has generated. Each has a unique skillset and can be used to test out the system."
]}
imageSrc="/select-a-candidate.png"
imageSrc={selectACandidatePng}
imageAlt="Select a candidate from the available profiles"
note="If you create an account, you can opt-in to have your account show up for others to view as well, or keep it private for just your own resume generation and job research."
/>
@ -294,7 +399,7 @@ const HowItWorks: React.FC = () => {
"This could be as little as a simple resume, or as complete as a full work history. Backstory performs similarity searches to identify key elements from the candidate that pertain to a given skill and provides a graded response.",
"To see that in action, click the \"Start Skill Assessment\" button."
]}
imageSrc="/select-start-analysis.png"
imageSrc={selectStartAnalysisPng}
imageAlt="Start the skill assessment process"
reversed={true}
/>
@ -313,7 +418,7 @@ const HowItWorks: React.FC = () => {
"Once you begin that action, the Start Skill Assessment button will grey out and the page will begin updating as it discovers information about the candidate. As it does its thing, you can monitor the progress and explore the different identified skills to see how or why a candidate does or does not have that skill.",
"Once it is done, you can see the final Overall Match. This is a weighted score based on amount of evidence a skill had, whether the skill was required or preferred, and other metrics."
]}
imageSrc="/wait.png"
imageSrc={waitPng}
imageAlt="Wait for the analysis to complete and review results"
/>
</Container>
@ -331,7 +436,7 @@ const HowItWorks: React.FC = () => {
"The final step is creating the custom resume for the Candidate tailored to the particular Job. On the bottom right you can click \"Next\" to have Backstory generate the custom resume.",
"Note that the resume focuses on identifying key areas from the Candidate's work history that align with skills which were extracted from the original job posting."
]}
imageSrc="/final-resume.png"
imageSrc={finalResumePng}
imageAlt="Generated custom resume tailored to the job"
success="Success! You can then click the Copy button to copy the resume into your editor, adjust, and apply for your dream job!"
reversed={true}

View File

@ -324,16 +324,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</Stepper>
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
{analysisState && analysisState.job &&
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{!isMobile && <Box sx={{ ml: 3, fontWeight: "bold" }}>Selected Job</Box>}
<Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}>
{!isMobile &&
<Avatar
sx={{
ml: 1, mt: 1,
bgcolor: theme.palette.primary.main,
color: 'white'
}}
>
<WorkIcon />
</Avatar>
}
<JobInfo variant="small" job={analysisState.job} />
</Box>
}
{isMobile && <Box sx={{ display: "flex", borderBottom: "1px solid grey" }} />}
{!isMobile && <Box sx={{ display: "flex", borderLeft: "1px solid lightgrey" }} />}
{analysisState && analysisState.candidate &&
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{!isMobile && <Box sx={{ ml: 3, fontWeight: "bold" }}>Selected Candidate</Box>}
<Box sx={{ display: "flex", flexDirection: "row", width: "100%" }}>
<CandidateInfo variant="small" candidate={analysisState.candidate} sx={{}} />
</Box>
}

View File

@ -9,6 +9,8 @@ import {
Tab,
Card,
CardContent,
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Person,
@ -23,7 +25,7 @@ import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { LoginForm } from "components/EmailVerificationComponents";
import { CandidateRegistrationForm } from "components/RegistrationForms";
import { CandidateRegistrationForm } from "pages/candidate/RegistrationForms";
import { useNavigate } from 'react-router-dom';
import { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types';
@ -37,7 +39,8 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const { guest, user, login, isLoading, error } = useAuth();
const name = (user?.userType === 'candidate') ? (user as Types.Candidate).username : user?.email || '';
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const showGuest: boolean = false;
useEffect(() => {
@ -69,8 +72,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
}
return (
<Container maxWidth="sm" sx={{ mt: 4 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Paper elevation={3} sx={{ p: isMobile ? 0 : 4 }}>
<BackstoryLogo />
{showGuest && guest && (
@ -89,7 +91,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
</Card>
)}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab icon={<Person />} label="Login" />
<Tab icon={<PersonAdd />} label="Register" />
@ -115,8 +117,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
{tabValue === 1 && (
<CandidateRegistrationForm />
)}
</Paper>
</Container>
</Paper>
);
};

View File

@ -0,0 +1,874 @@
import React, { useState } from 'react';
import {
Paper,
Box,
Typography,
TextField,
Button,
Stack,
Alert,
Select,
MenuItem,
FormControl,
InputLabel,
FormHelperText,
LinearProgress,
CircularProgress,
Link,
Card,
CardContent,
CardActions,
useMediaQuery,
useTheme,
IconButton,
InputAdornment
} from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
import { ApiClient } from 'services/api-client';
import { RegistrationSuccessDialog } from 'components/EmailVerificationComponents';
import { useAuth } from 'hooks/AuthContext';
import { useNavigate } from 'react-router-dom';
// Candidate Registration Form
const CandidateRegistrationForm = () => {
const { apiClient } = useAuth();
const navigate = useNavigate();
const [formData, setFormData] = useState({
email: '',
username: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
phone: ''
});
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [showSuccess, setShowSuccess] = useState(false);
const [registrationResult, setRegistrationResult] = useState<any>(null);
// Password visibility states
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const validateForm = () => {
const newErrors: Record<string, string> = {};
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!emailRegex.test(formData.email)) {
newErrors.email = 'Please enter a valid email address';
}
// Username validation
if (!formData.username) {
newErrors.username = 'Username is required';
} else if (formData.username.length < 3) {
newErrors.username = 'Username must be at least 3 characters';
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
newErrors.username = 'Username can only contain letters, numbers, and underscores';
}
// Password validation
if (!formData.password) {
newErrors.password = 'Password is required';
} else {
const passwordErrors = validatePassword(formData.password);
if (passwordErrors.length > 0) {
newErrors.password = passwordErrors.join(', ');
}
}
// Confirm password
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
// Name validation
if (!formData.firstName.trim()) {
newErrors.firstName = 'First name is required';
}
if (!formData.lastName.trim()) {
newErrors.lastName = 'Last name is required';
}
// Phone validation (optional but must be valid if provided)
if (formData.phone && !/^[+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ''))) {
newErrors.phone = 'Please enter a valid phone number';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validatePassword = (password: string): string[] => {
const errors: string[] = [];
if (password.length < 8) {
errors.push('at least 8 characters');
}
if (!/[a-z]/.test(password)) {
errors.push('one lowercase letter');
}
if (!/[A-Z]/.test(password)) {
errors.push('one uppercase letter');
}
if (!/\d/.test(password)) {
errors.push('one number');
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
errors.push('one special character');
}
return errors.length > 0 ? [`Password must contain ${errors.join(', ')}`] : [];
};
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
setLoading(true);
try {
const result = await apiClient.createCandidate({
email: formData.email,
username: formData.username,
password: formData.password,
firstName: formData.firstName,
lastName: formData.lastName,
phone: formData.phone || undefined
});
// Set pending verification
apiClient.setPendingEmailVerification(formData.email);
setRegistrationResult(result);
setShowSuccess(true);
} catch (error: any) {
if (error.message.includes('already exists')) {
if (error.message.includes('email')) {
setErrors({ email: 'An account with this email already exists' });
} else if (error.message.includes('username')) {
setErrors({ username: 'This username is already taken' });
}
} else {
setErrors({ general: error.message || 'Registration failed. Please try again.' });
}
} finally {
setLoading(false);
}
};
const getPasswordStrength = (password: string) => {
const validations = [
password.length >= 8,
/[a-z]/.test(password),
/[A-Z]/.test(password),
/\d/.test(password),
/[!@#$%^&*(),.?":{}|<>]/.test(password)
];
const strength = validations.filter(Boolean).length;
if (strength < 2) return { level: 'weak', color: 'error', value: 20 };
if (strength < 4) return { level: 'medium', color: 'warning', value: 60 };
return { level: 'strong', color: 'success', value: 100 };
};
const passwordStrength = formData.password ? getPasswordStrength(formData.password) : null;
return (
<Box sx={{ p: isMobile ? 1 : 5 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
Join as a Candidate
</Typography>
<Typography variant="body1" color="text.secondary">
Create your account to start finding your dream job
</Typography>
</Box>
<Stack spacing={3}>
<TextField
fullWidth
label="Email Address"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="your.email@example.com"
error={!!errors.email}
helperText={errors.email}
required
/>
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value.toLowerCase())}
placeholder="johndoe123"
error={!!errors.username}
helperText={errors.username}
required
/>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="First Name"
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="John"
error={!!errors.firstName}
helperText={errors.firstName}
required
/>
<TextField
fullWidth
label="Last Name"
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Doe"
error={!!errors.lastName}
helperText={errors.lastName}
required
/>
</Stack>
<TextField
fullWidth
label="Phone Number"
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+1 (555) 123-4567"
error={!!errors.phone}
helperText={errors.phone || "Optional"}
/>
<Box>
<TextField
fullWidth
label="Password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
placeholder="Create a strong password"
error={!!errors.password}
helperText={errors.password}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{formData.password && passwordStrength && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={passwordStrength.value}
color={passwordStrength.color as any}
sx={{ height: 6, borderRadius: 3 }}
/>
<Typography variant="caption" color={`${passwordStrength.color}.main`} sx={{ mt: 0.5, display: 'block', textTransform: 'capitalize' }}>
Password strength: {passwordStrength.level}
</Typography>
</Box>
)}
</Box>
<TextField
fullWidth
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
placeholder="Confirm your password"
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle confirm password visibility"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
onMouseDown={(e) => e.preventDefault()}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{errors.general && (
<Alert severity="error">
{errors.general}
</Alert>
)}
<Button
fullWidth
variant="contained"
size="large"
onClick={handleSubmit}
disabled={loading}
sx={{ py: 2 }}
>
{loading ? (
<Stack direction="row" alignItems="center" spacing={1}>
<CircularProgress size={20} color="inherit" />
<Typography>Creating Account...</Typography>
</Stack>
) : (
'Create Account'
)}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Link
component="button"
onClick={(e) => { e.preventDefault(); navigate('/login'); }}
sx={{ fontWeight: 600 }}
>
Sign in here
</Link>
</Typography>
</Box>
</Stack>
{showSuccess && registrationResult && (
<RegistrationSuccessDialog
open={showSuccess}
onClose={() => setShowSuccess(false)}
email={registrationResult.email}
userType="candidate"
/>
)}
</Box>
);
};
// Employer Registration Form
const EmployerRegistrationForm = () => {
const [formData, setFormData] = useState({
email: '',
username: '',
password: '',
confirmPassword: '',
companyName: '',
industry: '',
companySize: '',
companyDescription: '',
websiteUrl: '',
phone: ''
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [showSuccess, setShowSuccess] = useState(false);
const [registrationResult, setRegistrationResult] = useState<any>(null);
// Password visibility states
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const apiClient = new ApiClient();
const industryOptions = [
'Technology', 'Healthcare', 'Finance', 'Education', 'Manufacturing',
'Retail', 'Consulting', 'Media', 'Non-profit', 'Government', 'Other'
];
const companySizeOptions = [
'1-10 employees', '11-50 employees', '51-200 employees',
'201-500 employees', '501-1000 employees', '1000+ employees'
];
const validateForm = () => {
const newErrors: Record<string, string> = {};
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!emailRegex.test(formData.email)) {
newErrors.email = 'Please enter a valid email address';
}
// Username validation
if (!formData.username) {
newErrors.username = 'Username is required';
} else if (formData.username.length < 3) {
newErrors.username = 'Username must be at least 3 characters';
}
// Password validation
if (!formData.password) {
newErrors.password = 'Password is required';
} else {
const passwordErrors = validatePassword(formData.password);
if (passwordErrors.length > 0) {
newErrors.password = passwordErrors.join(', ');
}
}
// Confirm password
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
// Company validation
if (!formData.companyName.trim()) {
newErrors.companyName = 'Company name is required';
}
if (!formData.industry) {
newErrors.industry = 'Industry is required';
}
if (!formData.companySize) {
newErrors.companySize = 'Company size is required';
}
if (!formData.companyDescription.trim()) {
newErrors.companyDescription = 'Company description is required';
} else if (formData.companyDescription.length < 50) {
newErrors.companyDescription = 'Company description must be at least 50 characters';
}
// Website URL validation (optional but must be valid if provided)
if (formData.websiteUrl) {
try {
new URL(formData.websiteUrl);
} catch {
newErrors.websiteUrl = 'Please enter a valid website URL';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validatePassword = (password: string): string[] => {
const errors: string[] = [];
if (password.length < 8) {
errors.push('at least 8 characters');
}
if (!/[a-z]/.test(password)) {
errors.push('one lowercase letter');
}
if (!/[A-Z]/.test(password)) {
errors.push('one uppercase letter');
}
if (!/\d/.test(password)) {
errors.push('one number');
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
errors.push('one special character');
}
return errors.length > 0 ? [`Password must contain ${errors.join(', ')}`] : [];
};
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
setLoading(true);
try {
const result = await apiClient.createEmployerWithVerification({
email: formData.email,
username: formData.username,
password: formData.password,
companyName: formData.companyName,
industry: formData.industry,
companySize: formData.companySize,
companyDescription: formData.companyDescription,
websiteUrl: formData.websiteUrl || undefined,
phone: formData.phone || undefined
});
// Set pending verification
apiClient.setPendingEmailVerification(formData.email);
setRegistrationResult(result);
setShowSuccess(true);
} catch (error: any) {
if (error.message.includes('already exists')) {
if (error.message.includes('email')) {
setErrors({ email: 'An account with this email already exists' });
} else if (error.message.includes('username')) {
setErrors({ username: 'This username is already taken' });
}
} else {
setErrors({ general: error.message || 'Registration failed. Please try again.' });
}
} finally {
setLoading(false);
}
};
return (
<Paper elevation={3}>
<Box sx={{ p: 5 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
Join as an Employer
</Typography>
<Typography variant="body1" color="text.secondary">
Create your company account to start hiring top talent
</Typography>
</Box>
<Stack spacing={4}>
{/* Account Information Section */}
<Box sx={{ bgcolor: 'grey.50', p: 3, borderRadius: 2 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Account Information
</Typography>
<Stack spacing={3}>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="Email Address"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="company@example.com"
error={!!errors.email}
helperText={errors.email}
required
/>
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value.toLowerCase())}
placeholder="company123"
error={!!errors.username}
helperText={errors.username}
required
/>
</Stack>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="Password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
placeholder="Create a strong password"
error={!!errors.password}
helperText={errors.password}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(e) => e.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
placeholder="Confirm your password"
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle confirm password visibility"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
onMouseDown={(e) => e.preventDefault()}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</Stack>
</Stack>
</Box>
{/* Company Information Section */}
<Box sx={{ bgcolor: 'primary.50', p: 3, borderRadius: 2 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Company Information
</Typography>
<Stack spacing={3}>
<TextField
fullWidth
label="Company Name"
value={formData.companyName}
onChange={(e) => handleInputChange('companyName', e.target.value)}
placeholder="Your Company Inc."
error={!!errors.companyName}
helperText={errors.companyName}
required
/>
<Stack direction="row" spacing={2}>
<FormControl fullWidth error={!!errors.industry} required>
<InputLabel>Industry</InputLabel>
<Select
value={formData.industry}
onChange={(e) => handleInputChange('industry', e.target.value)}
label="Industry"
>
{industryOptions.map(industry => (
<MenuItem key={industry} value={industry}>{industry}</MenuItem>
))}
</Select>
{errors.industry && <FormHelperText>{errors.industry}</FormHelperText>}
</FormControl>
<FormControl fullWidth error={!!errors.companySize} required>
<InputLabel>Company Size</InputLabel>
<Select
value={formData.companySize}
onChange={(e) => handleInputChange('companySize', e.target.value)}
label="Company Size"
>
{companySizeOptions.map(size => (
<MenuItem key={size} value={size}>{size}</MenuItem>
))}
</Select>
{errors.companySize && <FormHelperText>{errors.companySize}</FormHelperText>}
</FormControl>
</Stack>
<Box>
<TextField
fullWidth
label="Company Description"
multiline
rows={4}
value={formData.companyDescription}
onChange={(e) => handleInputChange('companyDescription', e.target.value)}
placeholder="Tell us about your company, what you do, your mission, and what makes you unique..."
error={!!errors.companyDescription}
helperText={errors.companyDescription || `${formData.companyDescription.length}/50 characters minimum`}
required
/>
</Box>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="Website URL"
type="url"
value={formData.websiteUrl}
onChange={(e) => handleInputChange('websiteUrl', e.target.value)}
placeholder="https://www.yourcompany.com"
error={!!errors.websiteUrl}
helperText={errors.websiteUrl || "Optional"}
/>
<TextField
fullWidth
label="Phone Number"
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+1 (555) 123-4567"
error={!!errors.phone}
helperText={errors.phone || "Optional"}
/>
</Stack>
</Stack>
</Box>
{errors.general && (
<Alert severity="error">
{errors.general}
</Alert>
)}
<Button
fullWidth
variant="contained"
size="large"
onClick={handleSubmit}
disabled={loading}
sx={{ py: 2 }}
>
{loading ? (
<Stack direction="row" alignItems="center" spacing={1}>
<CircularProgress size={20} color="inherit" />
<Typography>Creating Company Account...</Typography>
</Stack>
) : (
'Create Company Account'
)}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Link href="/login" sx={{ fontWeight: 600 }}>
Sign in here
</Link>
</Typography>
</Box>
</Stack>
</Box>
{showSuccess && registrationResult && (
<RegistrationSuccessDialog
open={showSuccess}
onClose={() => setShowSuccess(false)}
email={registrationResult.email}
userType="employer"
/>
)}
</Paper>
);
};
// Registration Type Selector Component
export function RegistrationTypeSelector() {
return (
<Paper elevation={3}>
<Box sx={{ p: 5 }}>
<Box sx={{ textAlign: 'center', mb: 5 }}>
<Typography variant="h3" component="h1" sx={{ mb: 2 }}>
Join Backstory
</Typography>
<Typography variant="h6" color="text.secondary">
Choose how you'd like to get started
</Typography>
</Box>
<Stack direction="row" spacing={3}>
{/* Candidate Option */}
<Card
sx={{
flex: 1,
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '2px solid transparent',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 6,
borderColor: 'primary.main'
}
}}
onClick={() => window.location.href = '/register/candidate'}
>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h1" sx={{ mb: 2 }}>👤</Typography>
<Typography variant="h5" component="h3" sx={{ mb: 1.5 }}>
I'm looking for work
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Create a candidate profile to find your next opportunity
</Typography>
</CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 3 }}>
<Button variant="contained" size="large">
Join as Candidate
</Button>
</CardActions>
</Card>
{/* Employer Option */}
<Card
sx={{
flex: 1,
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '2px solid transparent',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 6,
borderColor: 'primary.main'
}
}}
onClick={() => window.location.href = '/register/employer'}
>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h1" sx={{ mb: 2 }}>🏢</Typography>
<Typography variant="h5" component="h3" sx={{ mb: 1.5 }}>
I'm hiring
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Create a company account to find and hire talent
</Typography>
</CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 3 }}>
<Button variant="contained" size="large">
Join as Employer
</Button>
</CardActions>
</Card>
</Stack>
<Box sx={{ textAlign: 'center', mt: 4 }}>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Link href="/login" sx={{ fontWeight: 600 }}>
Sign in here
</Link>
</Typography>
</Box>
</Box>
</Paper>
);
}
export { CandidateRegistrationForm, EmployerRegistrationForm };