Initial React project

This commit is contained in:
Johan
2026-03-08 22:15:03 +01:00
parent f983a9a132
commit 8a4d3eea44
9 changed files with 1166 additions and 32 deletions

View File

@@ -1,8 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type ChangeEvent } from 'react';
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
BadgeCheck,
Briefcase,
Car,
CalendarDays,
CheckCircle2,
FileText,
Globe,
@@ -10,8 +14,10 @@ import {
GraduationCap,
LayoutPanelTop,
PenLine,
Search,
Star,
UserRound,
X,
} from 'lucide-react';
import type {
CertificationInterface,
@@ -85,6 +91,26 @@ const FALLBACK_EDUCATIONS = [
},
];
type CvWizardView = 'menu' | 'experience' | 'education' | 'personal' | 'skills' | 'language' | 'driverLicense' | 'certification';
const ESCO_SKILLS = [
'HTML',
'CSS',
'JavaScript',
'TypeScript',
'React',
'Vue.js',
'Frontend Udvikling',
'Backend Udvikling',
'Node.js',
'Agile/Scrum',
'UI/UX Design',
'Salg',
'Projektledelse',
'Kundeservice',
'SEO',
];
function safeDate(value: Date | string | null | undefined): Date | null {
if (!value) {
return null;
@@ -115,6 +141,17 @@ function formatBirthday(value: Date | string | null | undefined): string {
return new Intl.DateTimeFormat('da-DK', { day: '2-digit', month: 'long', year: 'numeric' }).format(date);
}
function formatDateInput(value: Date | string | null | undefined): string {
const date = safeDate(value);
if (!date) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function sortByFromDateDesc<T extends { fromDate: Date | string }>(list: T[]): T[] {
return [...list].sort((a, b) => {
const left = safeDate(a.fromDate)?.getTime() ?? 0;
@@ -144,6 +181,35 @@ export function CvPage({ onLogout, onNavigate, onToggleTheme, theme }: CvPagePro
const [designMode, setDesignMode] = useState<'standard' | 'reference'>('standard');
const [snapshot, setSnapshot] = useState<CvPageSnapshot>(EMPTY_SNAPSHOT);
const [isLoading, setIsLoading] = useState(true);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [wizardView, setWizardView] = useState<CvWizardView>('menu');
const [jobTitle, setJobTitle] = useState('');
const [companyName, setCompanyName] = useState('');
const [startMonth, setStartMonth] = useState('');
const [endMonth, setEndMonth] = useState('');
const [isCurrentRole, setIsCurrentRole] = useState(false);
const [description, setDescription] = useState('');
const [skillsQuery, setSkillsQuery] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [personalFirstName, setPersonalFirstName] = useState('');
const [personalLastName, setPersonalLastName] = useState('');
const [personalEmail, setPersonalEmail] = useState('');
const [personalPhone, setPersonalPhone] = useState('');
const [personalBirthday, setPersonalBirthday] = useState('');
const [personalGender, setPersonalGender] = useState('');
const [personalZip, setPersonalZip] = useState('');
const [personalCity, setPersonalCity] = useState('');
const [isBirthdayOpen, setIsBirthdayOpen] = useState(false);
const [birthdayViewYear, setBirthdayViewYear] = useState(() => new Date().getFullYear());
const [birthdayViewMonth, setBirthdayViewMonth] = useState(() => new Date().getMonth());
const [personalDescriptionText, setPersonalDescriptionText] = useState('');
const [personalImagePreview, setPersonalImagePreview] = useState('');
const [personalImageFileName, setPersonalImageFileName] = useState('');
const [personalImageObjectUrl, setPersonalImageObjectUrl] = useState<string | null>(null);
const [languageName, setLanguageName] = useState('');
const [languageLevelName, setLanguageLevelName] = useState('');
const [driverLicenseNameInput, setDriverLicenseNameInput] = useState('');
const [certificationInput, setCertificationInput] = useState('');
useEffect(() => {
let active = true;
@@ -166,7 +232,48 @@ export function CvPage({ onLogout, onNavigate, onToggleTheme, theme }: CvPagePro
};
}, [viewModel]);
useEffect(() => {
if (!isWizardOpen) {
return undefined;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsWizardOpen(false);
}
};
window.addEventListener('keydown', handleEscape);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleEscape);
};
}, [isWizardOpen]);
useEffect(() => {
return () => {
if (personalImageObjectUrl) {
URL.revokeObjectURL(personalImageObjectUrl);
}
};
}, [personalImageObjectUrl]);
useEffect(() => {
if (!isBirthdayOpen) {
return undefined;
}
const handleOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (!target?.closest('.cv-birthday-picker')) {
setIsBirthdayOpen(false);
}
};
document.addEventListener('click', handleOutside);
return () => document.removeEventListener('click', handleOutside);
}, [isBirthdayOpen]);
const candidate = snapshot.candidate;
const candidateAddress = candidate?.address;
const name = candidate?.firstName?.trim() || candidate?.name?.trim() || 'Lasse';
const firstName = candidate?.firstName || 'Lasse';
const lastName = candidate?.lastName || 'Hansen';
@@ -187,6 +294,97 @@ export function CvPage({ onLogout, onNavigate, onToggleTheme, theme }: CvPagePro
{ id: 'da', name: 'Dansk', level: 'Modersmal' },
{ id: 'en', name: 'Engelsk', level: 'Flydende' },
];
const canSaveWizard = wizardView === 'experience'
? Boolean(jobTitle.trim() && companyName.trim() && startMonth)
: wizardView === 'personal'
? Boolean(personalFirstName.trim() && personalLastName.trim() && personalEmail.trim())
: wizardView === 'language'
? Boolean(languageName.trim() && languageLevelName.trim())
: wizardView === 'driverLicense'
? Boolean(driverLicenseNameInput.trim())
: wizardView === 'certification'
? Boolean(certificationInput.trim())
: true;
const filteredSkills = ESCO_SKILLS.filter((skill) => !selectedSkills.includes(skill) && skill.toLowerCase().includes(skillsQuery.toLowerCase()));
const openWizard = () => {
setIsWizardOpen(true);
setWizardView('menu');
};
const closeWizard = () => {
setIsWizardOpen(false);
setWizardView('menu');
};
const openWizardView = (view: Exclude<CvWizardView, 'menu'>) => {
setWizardView(view);
};
const removeSkill = (value: string) => {
setSelectedSkills((current) => current.filter((skill) => skill !== value));
};
const selectSkill = (value: string) => {
setSelectedSkills((current) => (current.includes(value) ? current : [...current, value]));
setSkillsQuery('');
};
useEffect(() => {
if (!candidate || isWizardOpen) {
return;
}
setPersonalFirstName(candidate.firstName || '');
setPersonalLastName(candidate.lastName || '');
setPersonalEmail(candidate.email || '');
setPersonalPhone(candidate.phoneNumber || '');
setPersonalBirthday(formatDateInput(candidate.birthday));
setPersonalGender(candidate.gender || '');
setPersonalZip(candidateAddress?.zip || '');
setPersonalCity(candidateAddress?.zipName || candidateAddress?.additionalCityName || '');
setPersonalDescriptionText(candidate.personalDescription || '');
setPersonalImagePreview(profileImage || '');
setPersonalImageFileName('');
}, [candidate, candidateAddress, isWizardOpen, profileImage]);
const handlePersonalImageChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (personalImageObjectUrl) {
URL.revokeObjectURL(personalImageObjectUrl);
}
const objectUrl = URL.createObjectURL(file);
setPersonalImageObjectUrl(objectUrl);
setPersonalImagePreview(objectUrl);
setPersonalImageFileName(file.name);
};
const birthdayDate = personalBirthday ? new Date(`${personalBirthday}T00:00:00`) : null;
const birthdayLabel = birthdayDate
? new Intl.DateTimeFormat('da-DK', { day: '2-digit', month: 'short', year: 'numeric' }).format(birthdayDate)
: 'Vælg dato';
const monthLabel = new Intl.DateTimeFormat('da-DK', { month: 'long' }).format(new Date(birthdayViewYear, birthdayViewMonth, 1));
const monthStartDay = new Date(birthdayViewYear, birthdayViewMonth, 1).getDay();
const normalizedOffset = (monthStartDay + 6) % 7;
const monthDays = new Date(birthdayViewYear, birthdayViewMonth + 1, 0).getDate();
const dayCells = Array.from({ length: normalizedOffset + monthDays }, (_, index) => (index < normalizedOffset ? null : index - normalizedOffset + 1));
const openBirthdayPicker = () => {
if (birthdayDate) {
setBirthdayViewYear(birthdayDate.getFullYear());
setBirthdayViewMonth(birthdayDate.getMonth());
}
setIsBirthdayOpen(true);
};
const selectBirthdayDay = (day: number) => {
const month = String(birthdayViewMonth + 1).padStart(2, '0');
const date = String(day).padStart(2, '0');
setPersonalBirthday(`${birthdayViewYear}-${month}-${date}`);
setIsBirthdayOpen(false);
};
return (
<section className={`dash-root ${theme === 'dark' ? 'theme-dark' : ''}`}>
@@ -215,7 +413,7 @@ export function CvPage({ onLogout, onNavigate, onToggleTheme, theme }: CvPagePro
<div>
<h1>Dit CV</h1>
</div>
<button type="button" className="cv-edit-btn"><PenLine size={16} strokeWidth={1.8} /> Rediger CV</button>
<button type="button" className="cv-edit-btn" onClick={openWizard}><PenLine size={16} strokeWidth={1.8} /> Rediger CV</button>
</div>
{isLoading ? <p className="dash-loading">Indlaeser CV...</p> : null}
@@ -375,6 +573,327 @@ export function CvPage({ onLogout, onNavigate, onToggleTheme, theme }: CvPagePro
</div>
</div>
</main>
{isWizardOpen ? (
<div className="cv-modal-overlay" onClick={closeWizard} role="presentation">
<div className="cv-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label="Rediger CV">
<div className="cv-modal-header">
<div className="cv-modal-title-wrap">
{wizardView !== 'menu' ? (
<button type="button" className="cv-modal-icon-btn" onClick={() => setWizardView('menu')} aria-label="Tilbage">
<ArrowLeft size={18} strokeWidth={1.8} />
</button>
) : null}
<h2>
{wizardView === 'menu' && 'Tilføj til CV'}
{wizardView === 'experience' && 'Tilføj Erhvervserfaring'}
{wizardView === 'education' && 'Tilføj Uddannelse'}
{wizardView === 'personal' && 'Opdater Personlige Oplysninger'}
{wizardView === 'skills' && 'Tilføj Kvalifikationer'}
{wizardView === 'language' && 'Tilføj Sprog'}
{wizardView === 'driverLicense' && 'Tilføj Kørekort'}
{wizardView === 'certification' && 'Tilføj Certifikat'}
</h2>
</div>
<button type="button" className="cv-modal-icon-btn" onClick={closeWizard} aria-label="Luk">
<X size={18} strokeWidth={1.8} />
</button>
</div>
<div className="cv-modal-body custom-scrollbar">
{wizardView === 'menu' ? (
<div className="cv-wizard-grid">
<button type="button" className="cv-wizard-menu-card is-cyan" onClick={() => openWizardView('personal')}>
<span className="cv-wizard-menu-icon"><UserRound size={20} strokeWidth={1.8} /></span>
<strong>Personlige oplysninger</strong>
<p>Opdater kontaktinfo, billede og grundlæggende detaljer.</p>
</button>
<button type="button" className="cv-wizard-menu-card is-teal" onClick={() => openWizardView('experience')}>
<span className="cv-wizard-menu-icon"><Briefcase size={20} strokeWidth={1.8} /></span>
<strong>Erhvervserfaring</strong>
<p>Tilføj tidligere eller nuværende jobs og ansvarsområder.</p>
</button>
<button type="button" className="cv-wizard-menu-card is-indigo" onClick={() => openWizardView('education')}>
<span className="cv-wizard-menu-icon"><GraduationCap size={20} strokeWidth={1.8} /></span>
<strong>Uddannelse</strong>
<p>Tilføj skoler, universiteter og studieretninger.</p>
</button>
<button type="button" className="cv-wizard-menu-card is-amber" onClick={() => openWizardView('skills')}>
<span className="cv-wizard-menu-icon"><Star size={20} strokeWidth={1.8} /></span>
<strong>Kvalifikationer</strong>
<p>Fremhæv dine faglige færdigheder og kompetencer.</p>
</button>
<button type="button" className="cv-wizard-menu-card is-cyan" onClick={() => openWizardView('language')}>
<span className="cv-wizard-menu-icon"><Globe size={20} strokeWidth={1.8} /></span>
<strong>Sprog</strong>
<p>Tilføj sprog og dit niveau.</p>
</button>
<button type="button" className="cv-wizard-menu-card is-indigo" onClick={() => openWizardView('driverLicense')}>
<span className="cv-wizard-menu-icon"><Car size={20} strokeWidth={1.8} /></span>
<strong>Kørekort</strong>
<p>Tilføj de kørekortkategorier du har.</p>
</button>
<button type="button" className="cv-wizard-menu-card is-teal" onClick={() => openWizardView('certification')}>
<span className="cv-wizard-menu-icon"><BadgeCheck size={20} strokeWidth={1.8} /></span>
<strong>Certifikater</strong>
<p>Tilføj relevante certificeringer.</p>
</button>
</div>
) : null}
{wizardView === 'personal' ? (
<form className="cv-wizard-form" onSubmit={(event) => event.preventDefault()}>
<div className="cv-upload-wrap">
<div className="cv-upload-preview">
{personalImagePreview ? <img src={personalImagePreview} alt="Profil" /> : <UserRound size={30} strokeWidth={1.8} />}
</div>
<div className="cv-upload-meta">
<label className="cv-upload-btn">
<input type="file" accept="image/*" onChange={handlePersonalImageChange} />
Upload billede
</label>
<small>{personalImageFileName || 'PNG/JPG op til 5MB'}</small>
</div>
</div>
<div className="cv-wizard-grid-2">
<label className="cv-field">
<span>Fornavn *</span>
<input value={personalFirstName} onChange={(event) => setPersonalFirstName(event.target.value)} placeholder="Fornavn" />
</label>
<label className="cv-field">
<span>Efternavn *</span>
<input value={personalLastName} onChange={(event) => setPersonalLastName(event.target.value)} placeholder="Efternavn" />
</label>
</div>
<div className="cv-wizard-grid-2">
<label className="cv-field">
<span>E-mail *</span>
<input type="email" value={personalEmail} onChange={(event) => setPersonalEmail(event.target.value)} placeholder="mail@eksempel.dk" />
</label>
<label className="cv-field">
<span>Telefon</span>
<input value={personalPhone} onChange={(event) => setPersonalPhone(event.target.value)} placeholder="+45 12 34 56 78" />
</label>
</div>
<div className="cv-wizard-grid-2">
<label className="cv-field">
<span>Fødselsdato</span>
<div className="cv-birthday-picker">
<button type="button" className="cv-birthday-trigger" onClick={openBirthdayPicker}>
<CalendarDays size={16} strokeWidth={1.8} />
<span>{birthdayLabel}</span>
</button>
{isBirthdayOpen ? (
<div className="cv-birthday-popover">
<div className="cv-birthday-header">
<button type="button" onClick={() => {
if (birthdayViewMonth === 0) {
setBirthdayViewMonth(11);
setBirthdayViewYear((year) => year - 1);
return;
}
setBirthdayViewMonth((month) => month - 1);
}}
>
<ChevronLeft size={16} strokeWidth={1.8} />
</button>
<strong>{monthLabel} {birthdayViewYear}</strong>
<button type="button" onClick={() => {
if (birthdayViewMonth === 11) {
setBirthdayViewMonth(0);
setBirthdayViewYear((year) => year + 1);
return;
}
setBirthdayViewMonth((month) => month + 1);
}}
>
<ChevronRight size={16} strokeWidth={1.8} />
</button>
</div>
<div className="cv-birthday-weekdays">
<span>Ma</span><span>Ti</span><span>On</span><span>To</span><span>Fr</span><span></span><span></span>
</div>
<div className="cv-birthday-days">
{dayCells.map((day, index) => {
if (!day) {
return <span key={`empty-${index}`} className="cv-birthday-empty" />;
}
const isSelected = birthdayDate
? birthdayDate.getFullYear() === birthdayViewYear
&& birthdayDate.getMonth() === birthdayViewMonth
&& birthdayDate.getDate() === day
: false;
return (
<button
key={`${birthdayViewYear}-${birthdayViewMonth}-${day}`}
type="button"
className={isSelected ? 'is-selected' : ''}
onClick={() => selectBirthdayDay(day)}
>
{day}
</button>
);
})}
</div>
</div>
) : null}
</div>
</label>
<label className="cv-field">
<span>Køn</span>
<input value={personalGender} onChange={(event) => setPersonalGender(event.target.value)} placeholder="F.eks. Mand/Kvinde/Andet" />
</label>
</div>
<div className="cv-wizard-grid-2">
<label className="cv-field">
<span>Postnummer</span>
<input value={personalZip} onChange={(event) => setPersonalZip(event.target.value)} placeholder="F.eks. 2100" />
</label>
<label className="cv-field">
<span>By</span>
<input value={personalCity} onChange={(event) => setPersonalCity(event.target.value)} placeholder="F.eks. København Ø" />
</label>
</div>
<label className="cv-field">
<span>Personlig beskrivelse</span>
<textarea rows={4} value={personalDescriptionText} onChange={(event) => setPersonalDescriptionText(event.target.value)} placeholder="Kort beskrivelse af dig selv..." />
</label>
</form>
) : null}
{wizardView === 'experience' ? (
<form className="cv-wizard-form" onSubmit={(event) => event.preventDefault()}>
<div className="cv-wizard-grid-2">
<label className="cv-field">
<span>Stillingstitel *</span>
<input value={jobTitle} onChange={(event) => setJobTitle(event.target.value)} placeholder="F.eks. Frontend Udvikler" />
</label>
<label className="cv-field">
<span>Virksomhed *</span>
<input value={companyName} onChange={(event) => setCompanyName(event.target.value)} placeholder="F.eks. Arbejd.com" />
</label>
</div>
<div className="cv-wizard-grid-2">
<label className="cv-field">
<span>Startdato *</span>
<div className="cv-field-icon-wrap">
<CalendarDays size={16} strokeWidth={1.8} />
<input type="month" value={startMonth} onChange={(event) => setStartMonth(event.target.value)} />
</div>
</label>
<label className="cv-field">
<span>Slutdato</span>
<div className="cv-field-icon-wrap">
<CalendarDays size={16} strokeWidth={1.8} />
<input type="month" value={endMonth} onChange={(event) => setEndMonth(event.target.value)} disabled={isCurrentRole} />
</div>
</label>
</div>
<label className="cv-wizard-checkbox">
<input
type="checkbox"
checked={isCurrentRole}
onChange={(event) => {
setIsCurrentRole(event.target.checked);
if (event.target.checked) {
setEndMonth('');
}
}}
/>
<span>Jeg arbejder her stadig</span>
</label>
<label className="cv-field">
<span>Beskrivelse</span>
<textarea
rows={4}
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Beskriv dine primære opgaver og resultater..."
/>
</label>
<div className="cv-field">
<span>Faerdigheder (ESCO)</span>
<div className="cv-skill-pills">
{selectedSkills.map((skill) => (
<span key={skill} className="cv-skill-pill">
{skill}
<button type="button" onClick={() => removeSkill(skill)} aria-label={`Fjern ${skill}`}>
<X size={12} strokeWidth={2} />
</button>
</span>
))}
</div>
<div className="cv-skill-search">
<Search size={16} strokeWidth={1.8} />
<input
value={skillsQuery}
onChange={(event) => setSkillsQuery(event.target.value)}
placeholder="Søg færdigheder (f.eks. JavaScript, Salg...)"
/>
</div>
{skillsQuery.trim().length > 0 ? (
<div className="cv-skill-dropdown custom-scrollbar">
{filteredSkills.length > 0 ? filteredSkills.map((skill) => (
<button key={skill} type="button" onClick={() => selectSkill(skill)}>{skill}</button>
)) : <span className="cv-skill-empty">Ingen resultater fundet.</span>}
</div>
) : null}
</div>
</form>
) : null}
{wizardView === 'education' ? <p className="cv-wizard-placeholder">Uddannelsesformularen bliver tilfoejet i naeste iteration.</p> : null}
{wizardView === 'skills' ? <p className="cv-wizard-placeholder">Kvalifikationsformularen bliver tilfoejet i naeste iteration.</p> : null}
{wizardView === 'language' ? (
<form className="cv-wizard-form" onSubmit={(event) => event.preventDefault()}>
<label className="cv-field">
<span>Sprog *</span>
<input value={languageName} onChange={(event) => setLanguageName(event.target.value)} placeholder="F.eks. Engelsk" />
</label>
<label className="cv-field">
<span>Niveau *</span>
<input value={languageLevelName} onChange={(event) => setLanguageLevelName(event.target.value)} placeholder="F.eks. Flydende" />
</label>
</form>
) : null}
{wizardView === 'driverLicense' ? (
<form className="cv-wizard-form" onSubmit={(event) => event.preventDefault()}>
<label className="cv-field">
<span>Kørekortkategori *</span>
<input value={driverLicenseNameInput} onChange={(event) => setDriverLicenseNameInput(event.target.value)} placeholder="F.eks. B (Almindelig bil)" />
</label>
</form>
) : null}
{wizardView === 'certification' ? (
<form className="cv-wizard-form" onSubmit={(event) => event.preventDefault()}>
<label className="cv-field">
<span>Certifikat *</span>
<input value={certificationInput} onChange={(event) => setCertificationInput(event.target.value)} placeholder="F.eks. AWS Certified Developer" />
</label>
</form>
) : null}
</div>
<div className="cv-modal-footer">
<button type="button" className="cv-modal-cancel" onClick={closeWizard}>Annuller</button>
{wizardView !== 'menu' ? (
<button type="button" className="cv-modal-save" disabled={!canSaveWizard} onClick={closeWizard}>
Gem aendringer
</button>
) : null}
</div>
</div>
</div>
) : null}
</section>
);
}

View File

@@ -352,6 +352,519 @@
background: rgba(255, 255, 255, 0.6);
}
.cv-modal-overlay {
position: fixed;
inset: 0;
z-index: 120;
background: rgba(17, 24, 39, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.cv-modal {
width: min(760px, 100%);
max-height: min(90vh, 920px);
background: rgba(255, 255, 255, 0.42);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 2rem;
overflow: hidden;
box-shadow: 0 28px 60px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
}
.cv-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.18);
}
.cv-modal-title-wrap {
display: flex;
align-items: center;
gap: 0.6rem;
}
.cv-modal-title-wrap h2 {
margin: 0;
font-size: 1.2rem;
letter-spacing: -0.01em;
font-weight: 500;
color: #111827;
}
.cv-modal-icon-btn {
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
background: transparent;
color: #4b5563;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cv-modal-icon-btn:hover {
background: rgba(255, 255, 255, 0.45);
color: #111827;
}
.cv-modal-body {
padding: 1.25rem;
overflow: auto;
flex: 1;
}
.cv-wizard-grid {
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cv-wizard-menu-card {
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.3);
border-radius: 1rem;
padding: 1rem;
text-align: left;
cursor: pointer;
}
.cv-wizard-menu-card:hover {
background: rgba(255, 255, 255, 0.58);
}
.cv-wizard-menu-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 0.8rem;
}
.cv-wizard-menu-card.is-teal .cv-wizard-menu-icon {
background: #f0fdfa;
color: #0f766e;
border: 1px solid #ccfbf1;
}
.cv-wizard-menu-card.is-indigo .cv-wizard-menu-icon {
background: #eef2ff;
color: #4338ca;
border: 1px solid #c7d2fe;
}
.cv-wizard-menu-card.is-cyan .cv-wizard-menu-icon {
background: #ecfeff;
color: #0891b2;
border: 1px solid #bae6fd;
}
.cv-wizard-menu-card.is-amber .cv-wizard-menu-icon {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
.cv-wizard-menu-card strong {
display: block;
color: #111827;
font-size: 0.96rem;
margin-bottom: 0.3rem;
}
.cv-wizard-menu-card p {
margin: 0;
color: #4b5563;
font-size: 0.76rem;
line-height: 1.45;
}
.cv-wizard-form {
display: grid;
gap: 0.9rem;
}
.cv-wizard-grid-2 {
display: grid;
gap: 0.9rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cv-field {
display: grid;
gap: 0.35rem;
}
.cv-field span {
font-size: 0.82rem;
color: #1f2937;
font-weight: 500;
}
.cv-field input,
.cv-field textarea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.38);
border-radius: 0.8rem;
padding: 0.65rem 0.8rem;
color: #111827;
font-size: 0.86rem;
outline: none;
}
.cv-field input:focus,
.cv-field textarea:focus {
border-color: #14b8a6;
box-shadow: 0 0 0 1px rgba(20, 184, 166, 0.5);
background: rgba(255, 255, 255, 0.6);
}
.cv-field textarea {
resize: vertical;
min-height: 96px;
}
.cv-field-icon-wrap {
position: relative;
}
.cv-field-icon-wrap svg {
position: absolute;
left: 0.7rem;
top: 50%;
transform: translateY(-50%);
color: #6b7280;
}
.cv-field-icon-wrap input {
padding-left: 2rem;
}
.cv-birthday-picker {
position: relative;
}
.cv-birthday-trigger {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.38);
border-radius: 0.8rem;
padding: 0.65rem 0.8rem;
color: #111827;
font-size: 0.86rem;
display: inline-flex;
align-items: center;
gap: 0.55rem;
cursor: pointer;
}
.cv-birthday-trigger:hover {
background: rgba(255, 255, 255, 0.6);
}
.cv-birthday-popover {
position: absolute;
top: calc(100% + 0.45rem);
left: 0;
z-index: 30;
width: 268px;
border: 1px solid rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-radius: 0.9rem;
box-shadow: 0 14px 35px rgba(15, 23, 42, 0.14);
padding: 0.7rem;
}
.cv-birthday-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.cv-birthday-header button {
width: 1.8rem;
height: 1.8rem;
border: 0;
background: transparent;
border-radius: 0.55rem;
color: #4b5563;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cv-birthday-header button:hover {
background: rgba(255, 255, 255, 0.75);
color: #111827;
}
.cv-birthday-header strong {
font-size: 0.84rem;
color: #111827;
font-weight: 600;
text-transform: capitalize;
}
.cv-birthday-weekdays,
.cv-birthday-days {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.25rem;
}
.cv-birthday-weekdays span {
text-align: center;
font-size: 0.7rem;
color: #6b7280;
padding: 0.15rem 0;
}
.cv-birthday-days button,
.cv-birthday-empty {
width: 100%;
height: 1.9rem;
border-radius: 0.55rem;
}
.cv-birthday-days button {
border: 0;
background: transparent;
color: #111827;
font-size: 0.78rem;
cursor: pointer;
}
.cv-birthday-days button:hover {
background: rgba(255, 255, 255, 0.8);
}
.cv-birthday-days button.is-selected {
background: #0f766e;
color: #ffffff;
box-shadow: 0 4px 10px rgba(15, 118, 110, 0.28);
}
.cv-upload-wrap {
display: flex;
align-items: center;
gap: 0.9rem;
padding: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.65);
border-radius: 0.95rem;
background: rgba(255, 255, 255, 0.35);
}
.cv-upload-preview {
width: 72px;
height: 72px;
border-radius: 1rem;
border: 2px solid rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.45);
display: grid;
place-items: center;
overflow: hidden;
color: #6b7280;
}
.cv-upload-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cv-upload-meta {
display: grid;
gap: 0.35rem;
}
.cv-upload-meta small {
color: #6b7280;
font-size: 0.72rem;
}
.cv-upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.5);
color: #111827;
padding: 0.48rem 0.72rem;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
width: fit-content;
}
.cv-upload-btn:hover {
background: rgba(255, 255, 255, 0.7);
}
.cv-upload-btn input {
display: none;
}
.cv-wizard-checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #374151;
font-size: 0.8rem;
width: fit-content;
}
.cv-skill-pills {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.cv-skill-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.58rem;
border-radius: 0.6rem;
border: 1px solid #99f6e4;
background: rgba(240, 253, 250, 0.95);
color: #115e59;
font-size: 0.73rem;
font-weight: 500;
}
.cv-skill-pill button {
border: 0;
width: 1rem;
height: 1rem;
border-radius: 999px;
background: transparent;
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cv-skill-search {
position: relative;
}
.cv-skill-search svg {
position: absolute;
top: 50%;
left: 0.72rem;
transform: translateY(-50%);
color: #6b7280;
}
.cv-skill-search input {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.38);
border-radius: 0.8rem;
padding: 0.65rem 0.8rem 0.65rem 2rem;
color: #111827;
font-size: 0.86rem;
outline: none;
}
.cv-skill-dropdown {
display: grid;
gap: 0.2rem;
max-height: 170px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.55);
border-radius: 0.75rem;
padding: 0.3rem;
}
.cv-skill-dropdown button {
border: 0;
text-align: left;
background: transparent;
border-radius: 0.55rem;
padding: 0.45rem 0.55rem;
color: #1f2937;
font-size: 0.83rem;
cursor: pointer;
}
.cv-skill-dropdown button:hover {
background: rgba(255, 255, 255, 0.65);
}
.cv-skill-empty {
color: #6b7280;
font-size: 0.82rem;
padding: 0.45rem 0.55rem;
}
.cv-wizard-placeholder {
margin: 0;
font-size: 0.9rem;
color: #4b5563;
padding: 0.5rem 0.1rem;
}
.cv-modal-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.6rem;
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.18);
}
.cv-modal-cancel,
.cv-modal-save {
border: 0;
border-radius: 0.8rem;
font-size: 0.82rem;
font-weight: 500;
padding: 0.62rem 1rem;
cursor: pointer;
}
.cv-modal-cancel {
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.4);
color: #111827;
}
.cv-modal-cancel:hover {
background: rgba(255, 255, 255, 0.62);
}
.cv-modal-save {
background: #111827;
color: #ffffff;
}
.cv-modal-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.cv-design-reference .cv-card {
border-radius: 28px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
@@ -468,4 +981,12 @@
.cv-design-toggle span {
display: none;
}
.cv-wizard-grid {
grid-template-columns: 1fr;
}
.cv-wizard-grid-2 {
grid-template-columns: 1fr;
}
}

View File

@@ -30,6 +30,8 @@ export function HomePage() {
const [isNavOpen, setIsNavOpen] = useState(false);
const [isTipsOpen, setIsTipsOpen] = useState(false);
const [isHowOpen, setIsHowOpen] = useState(false);
const [isMobileTipsOpen, setIsMobileTipsOpen] = useState(false);
const [isMobileHowOpen, setIsMobileHowOpen] = useState(false);
useEffect(() => {
if (!isNavOpen) {
@@ -56,6 +58,8 @@ export function HomePage() {
function handleResize() {
if (window.innerWidth > 990) {
setIsNavOpen(false);
setIsMobileHowOpen(false);
setIsMobileTipsOpen(false);
}
}
@@ -156,19 +160,60 @@ export function HomePage() {
className="homepage-nav-hamburger"
aria-expanded={isNavOpen}
aria-label={isNavOpen ? 'Luk menu' : 'Åbn menu'}
onClick={() => setIsNavOpen((current) => !current)}
onClick={() => {
setIsNavOpen((current) => {
const next = !current;
if (!next) {
setIsMobileHowOpen(false);
setIsMobileTipsOpen(false);
}
return next;
});
}}
>
<IconifyIcon icon={isNavOpen ? 'solar:close-circle-linear' : 'solar:hamburger-menu-linear'} className="text-xl text-gray-800" style={{ strokeWidth: 1.8 }} />
</button>
<div className={isNavOpen ? 'homepage-nav-popup open' : 'homepage-nav-popup'}>
<a href="/pricing" onClick={() => setIsNavOpen(false)}>Priser</a>
<a href="#" onClick={() => setIsNavOpen(false)}>Sådan virker det: For virksomheder</a>
<a href="#" onClick={() => setIsNavOpen(false)}>Sådan virker det: For jobsøgere</a>
<a href="#" onClick={() => setIsNavOpen(false)}>Sådan virker det: FAQ</a>
<a href="#" onClick={() => setIsNavOpen(false)}>Sådan virker det: Nyhedsbrev</a>
<a href="/stories" onClick={() => setIsNavOpen(false)}>Tips og tricks: Stories</a>
<a href="/jobordbogen" onClick={() => setIsNavOpen(false)}>Tips og tricks: Jobordbogen</a>
<div className="homepage-nav-popup-group">
<button
type="button"
className="homepage-nav-popup-group-trigger"
onClick={() => {
setIsMobileHowOpen((current) => !current);
setIsMobileTipsOpen(false);
}}
aria-expanded={isMobileHowOpen}
>
Sådan virker det
<IconifyIcon icon="solar:alt-arrow-down-linear" className={`text-base transition-transform ${isMobileHowOpen ? 'rotate-180' : ''}`} style={{ strokeWidth: 1.5 }} />
</button>
<div className={isMobileHowOpen ? 'homepage-nav-popup-submenu open' : 'homepage-nav-popup-submenu'}>
<a href="#" onClick={() => setIsNavOpen(false)}>For virksomheder</a>
<a href="#" onClick={() => setIsNavOpen(false)}>For jobsøgere</a>
<a href="#" onClick={() => setIsNavOpen(false)}>FAQ</a>
<a href="#" onClick={() => setIsNavOpen(false)}>Nyhedsbrev</a>
</div>
</div>
<div className="homepage-nav-popup-group">
<button
type="button"
className="homepage-nav-popup-group-trigger"
onClick={() => {
setIsMobileTipsOpen((current) => !current);
setIsMobileHowOpen(false);
}}
aria-expanded={isMobileTipsOpen}
>
Tips og tricks
<IconifyIcon icon="solar:alt-arrow-down-linear" className={`text-base transition-transform ${isMobileTipsOpen ? 'rotate-180' : ''}`} style={{ strokeWidth: 1.5 }} />
</button>
<div className={isMobileTipsOpen ? 'homepage-nav-popup-submenu open' : 'homepage-nav-popup-submenu'}>
<a href="/stories" onClick={() => setIsNavOpen(false)}>Stories</a>
<a href="/jobordbogen" onClick={() => setIsNavOpen(false)}>Jobordbogen</a>
</div>
</div>
<a href="#" onClick={() => setIsNavOpen(false)}>Log ind</a>
<a href="#" className="homepage-nav-popup-cta" onClick={() => setIsNavOpen(false)}>Opret dig</a>
</div>
@@ -224,14 +269,17 @@ export function HomePage() {
<div className="absolute top-1/4 left-[10%] w-24 h-24 rounded-full bg-gradient-to-tr from-white/40 via-white/10 to-teal-50/30 backdrop-blur-xl border border-white/60 shadow-[0_8px_40px_rgba(0,0,0,0.06)] z-0 animate-float-ambient-home" />
<div className="absolute bottom-1/5 right-[10%] w-32 h-32 rounded-[2rem] rotate-12 bg-gradient-to-tr from-white/40 via-white/10 to-indigo-50/30 backdrop-blur-xl border border-white/60 shadow-[0_8px_40px_rgba(0,0,0,0.06)] z-0 animate-float-ambient-home [animation-delay:2s]" />
<div className="relative flex items-center justify-center w-full max-w-5xl mx-auto transform scale-[0.65] sm:scale-75 md:scale-90 lg:scale-100 -space-x-12 sm:-space-x-16 md:-space-x-20">
<div className="homepage-glass-glare relative w-[320px] h-[680px] sm:w-[360px] sm:h-[740px] rounded-[3.5rem] p-3 bg-gradient-to-br from-white/40 to-white/10 backdrop-blur-3xl border border-white/50 shadow-[0_30px_60px_rgba(0,0,0,0.15),inset_0_0_20px_rgba(255,255,255,0.6)] animate-float-1-home z-20">
<div className="relative flex items-center justify-center w-full max-w-5xl mx-auto transform scale-[0.55] sm:scale-75 md:scale-90 lg:scale-100 -space-x-12 sm:-space-x-16 md:-space-x-20">
<div className="homepage-glass-glare relative shrink-0 w-[320px] h-[680px] sm:w-[360px] sm:h-[740px] rounded-[3.5rem] p-3 bg-gradient-to-br from-white/40 to-white/10 backdrop-blur-3xl border border-white/50 shadow-[0_30px_60px_rgba(0,0,0,0.15),inset_0_0_20px_rgba(255,255,255,0.6)] animate-float-1-home z-20">
<div className="absolute -left-[2px] top-28 w-1 h-8 bg-white/40 border border-white/50 rounded-l-md shadow-sm" />
<div className="absolute -left-[2px] top-40 w-1 h-14 bg-white/40 border border-white/50 rounded-l-md shadow-sm" />
<div className="absolute -left-[2px] top-56 w-1 h-14 bg-white/40 border border-white/50 rounded-l-md shadow-sm" />
<div className="absolute -right-[2px] top-44 w-1 h-20 bg-white/40 border border-white/50 rounded-r-md shadow-sm" />
<div className="relative w-full h-full rounded-[2.8rem] bg-white overflow-hidden shadow-[inset_0_0_10px_rgba(255,255,255,0.1)] border border-white/60 isolate">
<div
className="relative w-full h-full rounded-[2.8rem] bg-white overflow-hidden isolate"
style={{ WebkitMaskImage: '-webkit-radial-gradient(white, black)' }}
>
<img src={screen1Image} alt="App UI Design 1" className="absolute inset-0 w-full h-full object-cover z-0" />
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 w-[100px] h-[30px] bg-black rounded-full z-50 shadow-[0_4px_10px_rgba(0,0,0,0.3)] flex items-center justify-between px-3">
@@ -254,13 +302,16 @@ export function HomePage() {
</div>
</div>
<div className="homepage-glass-glare relative w-[320px] h-[680px] sm:w-[360px] sm:h-[740px] rounded-[3.5rem] p-3 bg-gradient-to-br from-white/30 to-white/5 backdrop-blur-2xl border border-white/40 shadow-[0_20px_50px_rgba(0,0,0,0.1),inset_0_0_20px_rgba(255,255,255,0.4)] animate-float-2-home z-10">
<div className="homepage-glass-glare relative shrink-0 w-[320px] h-[680px] sm:w-[360px] sm:h-[740px] rounded-[3.5rem] p-3 bg-gradient-to-br from-white/30 to-white/5 backdrop-blur-2xl border border-white/40 shadow-[0_20px_50px_rgba(0,0,0,0.1),inset_0_0_20px_rgba(255,255,255,0.4)] animate-float-2-home z-10">
<div className="absolute -left-[2px] top-28 w-1 h-8 bg-white/30 border border-white/40 rounded-l-md shadow-sm" />
<div className="absolute -left-[2px] top-40 w-1 h-14 bg-white/30 border border-white/40 rounded-l-md shadow-sm" />
<div className="absolute -left-[2px] top-56 w-1 h-14 bg-white/30 border border-white/40 rounded-l-md shadow-sm" />
<div className="absolute -right-[2px] top-44 w-1 h-20 bg-white/30 border border-white/40 rounded-r-md shadow-sm" />
<div className="relative w-full h-full rounded-[2.8rem] bg-white overflow-hidden shadow-[inset_0_0_10px_rgba(255,255,255,0.1)] border border-white/60 isolate">
<div
className="relative w-full h-full rounded-[2.8rem] bg-white overflow-hidden isolate"
style={{ WebkitMaskImage: '-webkit-radial-gradient(white, black)' }}
>
<img src={screen2Image} alt="App UI Design 2" className="absolute inset-0 w-full h-full object-cover z-0" />
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 w-[100px] h-[30px] bg-black rounded-full z-50 shadow-[0_4px_10px_rgba(0,0,0,0.3)] flex items-center justify-between px-3">
@@ -301,7 +352,7 @@ export function HomePage() {
<IconifyIcon icon="solar:document-text-linear" className="text-2xl text-teal-600" style={{ strokeWidth: 1.5 }} />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">AI-understøttet CV-optimering</h3>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">Smart CV-optimering</h3>
<p className="text-base text-gray-600 font-normal"> skræddersyet dit CV til præcis den stilling du søger, du altid står skarpest muligt.</p>
</div>
</div>
@@ -311,7 +362,7 @@ export function HomePage() {
<IconifyIcon icon="solar:pen-new-square-linear" className="text-2xl text-indigo-600" style={{ strokeWidth: 1.5 }} />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">AI Ansøgninger</h3>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">Målrettede ansøgninger</h3>
<p className="text-base text-gray-600 font-normal">Generer målrettede og personlige ansøgninger, der fanger arbejdsgiverens opmærksomhed.</p>
</div>
</div>
@@ -321,7 +372,7 @@ export function HomePage() {
<IconifyIcon icon="solar:gamepad-linear" className="text-2xl text-cyan-600" style={{ strokeWidth: 1.5 }} />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">AI-interview Simulator</h3>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">Interview-simulator</h3>
<p className="text-base text-gray-600 font-normal">Øv dig til samtalen med vores AI. øjeblikkelig feedback og personlige anbefalinger.</p>
</div>
</div>
@@ -331,7 +382,7 @@ export function HomePage() {
<IconifyIcon icon="solar:radar-linear" className="text-2xl text-amber-600" style={{ strokeWidth: 1.5 }} />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">AI-agenter søger for dig</h3>
<h3 className="text-lg font-medium text-gray-900 tracking-tight mb-1">Agenter søger for dig</h3>
<p className="text-base text-gray-600 font-normal">Lad vores intelligente agenter overvåge markedet og finde det perfekte match til din profil.</p>
</div>
</div>

View File

@@ -231,6 +231,49 @@
color: #111827;
}
.homepage-nav-popup-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.homepage-nav-popup-group-trigger {
width: 100%;
border: 0;
background: transparent;
color: #374151;
font-size: 1rem;
border-radius: 0.85rem;
padding: 0.7rem 0.9rem;
display: inline-flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.homepage-nav-popup-group-trigger:hover {
background: rgba(15, 23, 42, 0.06);
color: #111827;
}
.homepage-nav-popup-submenu {
display: none;
padding-left: 0.4rem;
}
.homepage-nav-popup-submenu.open {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.homepage-nav-popup-submenu a {
font-size: 0.95rem;
padding: 0.55rem 0.85rem;
color: #4b5563;
}
.homepage-nav-popup-cta {
color: #ffffff !important;
background: linear-gradient(to right, #111827, #1f2937);