Initial React project

This commit is contained in:
Johan
2026-03-04 22:23:10 +01:00
parent 689c6e9e15
commit 01363820e2
13 changed files with 1550 additions and 84 deletions

1
dist/assets/index-50E0dQjK.css vendored Normal file

File diff suppressed because one or more lines are too long

11
dist/assets/index-NzuLru3R.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>arbejd-react</title>
<script type="module" crossorigin src="/assets/index-yGD4iGEM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-R0YECfZq.css">
<script type="module" crossorigin src="/assets/index-NzuLru3R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-50E0dQjK.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because one or more lines are too long

View File

@@ -9,10 +9,14 @@ import { DashboardPage } from './presentation/dashboard/pages/DashboardPage';
import { JobDetailPage } from './presentation/jobs/pages/JobDetailPage';
import { JobsPage } from './presentation/jobs/pages/JobsPage';
import { MessagesPage } from './presentation/messages/pages/MessagesPage';
import { SimulatorPage } from './presentation/simulator/pages/SimulatorPage';
import {
SimulatorPage,
type SimulatorEvaluationSelection,
} from './presentation/simulator/pages/SimulatorPage';
import { SimulatorEvaluationPage } from './presentation/simulator/pages/SimulatorEvaluationPage';
import { SubscriptionPage } from './presentation/subscription/pages/SubscriptionPage';
type AppPage = DashboardNavKey | 'job-detail';
type AppPage = DashboardNavKey | 'job-detail' | 'simulator-evaluation';
interface JobDetailSelection {
id: string;
@@ -30,6 +34,7 @@ function App() {
const [theme, setTheme] = useState<'light' | 'dark'>(initialTheme);
const [activePage, setActivePage] = useState<AppPage>('dashboard');
const [jobDetailSelection, setJobDetailSelection] = useState<JobDetailSelection | null>(null);
const [evaluationSelection, setEvaluationSelection] = useState<SimulatorEvaluationSelection | null>(null);
function handleNavigate(target: DashboardNavKey) {
if (
@@ -55,10 +60,20 @@ function App() {
setActivePage(jobDetailSelection?.returnPage ?? 'jobs');
}
function handleOpenSimulatorEvaluation(selection: SimulatorEvaluationSelection) {
setEvaluationSelection(selection);
setActivePage('simulator-evaluation');
}
function handleBackFromSimulatorEvaluation() {
setActivePage('simulator');
}
async function handleLogout() {
await localStorageService.clearCredentials();
setActivePage('dashboard');
setJobDetailSelection(null);
setEvaluationSelection(null);
setIsAuthenticated(false);
}
@@ -118,7 +133,28 @@ function App() {
}
if (activePage === 'simulator') {
return <SimulatorPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
return (
<SimulatorPage
onLogout={handleLogout}
onNavigate={handleNavigate}
onOpenEvaluation={handleOpenSimulatorEvaluation}
theme={theme}
onToggleTheme={handleToggleTheme}
/>
);
}
if (activePage === 'simulator-evaluation' && evaluationSelection) {
return (
<SimulatorEvaluationPage
interviewSelection={evaluationSelection}
onBack={handleBackFromSimulatorEvaluation}
onLogout={handleLogout}
onNavigate={handleNavigate}
theme={theme}
onToggleTheme={handleToggleTheme}
/>
);
}
if (activePage === 'subscription') {

View File

@@ -0,0 +1,322 @@
import { SimulationService } from '../services/simulation.service';
interface RecordLike {
[key: string]: unknown;
}
export interface SimulatorEvaluationSelectionContext {
companyName?: string;
dateLabel?: string;
title?: string;
}
export interface EvaluationImprovementItem {
id: string;
title: string;
behavior: string;
effect: string;
nextStep: string;
}
export interface SimulatorEvaluationData {
companyName: string;
dateLabel: string;
evaluationLabel: string;
interviewerEvaluationBody: string;
interviewerEvaluationLead: string;
interviewerEvaluationTitle: string;
interviewerScore: number;
interviewTitle: string;
recommendations: string[];
selfScore: number;
strengths: string[];
suggestions: EvaluationImprovementItem[];
}
function asRecord(value: unknown): RecordLike | null {
return typeof value === 'object' && value !== null ? (value as RecordLike) : null;
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => asString(item).trim()).filter(Boolean);
}
function pickString(root: RecordLike | null, keys: string[]): string {
if (!root) {
return '';
}
for (const key of keys) {
const value = asString(root[key]).trim();
if (value) {
return value;
}
}
return '';
}
function pickNumber(root: RecordLike | null, keys: string[]): number | null {
if (!root) {
return null;
}
for (const key of keys) {
const value = asNumber(root[key]);
if (value !== null) {
return value;
}
}
return null;
}
function formatDateLabel(value: string): string {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat('da-DK', {
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(date);
}
function normalizeScore(value: number | null, fallback: number): number {
if (value === null) {
return fallback;
}
return Math.max(0, Math.min(10, Math.round(value)));
}
const FALLBACK_SUGGESTIONS: EvaluationImprovementItem[] = [
{
id: '1',
title: 'Mere struktur og korthed i dine svar',
behavior: 'Du gav nogle svar, der blev lange og gentagende i stedet for fokuserede pa kernen.',
effect: 'Det kan gore det svaerere for intervieweren hurtigt at vurdere din erfaring og beslutninger.',
nextStep: 'Svar i 3 trin: pointe, konkret eksempel, resultat. Sigt efter 30-60 sekunder pr. svar.',
},
{
id: '2',
title: 'Flere konkrete eksempler pa performance i service',
behavior: 'Du fortalte om ansvar, men gav fa konkrete scenarier med handling og resultat.',
effect: 'Uden konkrete cases bliver niveau og paalidelighed svaerere at validere.',
nextStep: 'Forbered 2-3 STAR-historier med tydelig situation, handling og maelbart resultat.',
},
{
id: '3',
title: 'Gor din motivation mere maelrettet virksomheden',
behavior: 'Motivationen var positiv, men ikke altid koblet direkte til virksomhedens drift og behov.',
effect: 'Du kan fremsta generelt motiveret i stedet for specifikt relevant for rollen.',
nextStep: 'Naevn 2-3 konkrete grunde til, at netop deres setup matcher din erfaring.',
},
{
id: '4',
title: 'Konkretiser kvalitet og sikkerhed i praksis',
behavior: 'Du naevnte standarder og certificeringer, men beskrev fa daglige rutiner.',
effect: 'Det reducerer tydeligheden omkring, hvordan du arbejder sikkert i travle perioder.',
nextStep: 'Beskriv faste rutiner for kontrol, logning og hurtig korrektion under pres.',
},
];
const FALLBACK_STRENGTHS = [
'Du viser relevant erfaring for rollen og kommunikerer ro under pres.',
'Du arbejder struktureret med kvalitet og timing i service.',
'Du har en moden team-tilgang med fokus pa samarbejde.',
'Du kobler dine svar til konkrete arbejdsrutiner og drift.',
'Du fremstar stabil og ansvarlig i hektiske situationer.',
];
const FALLBACK_RECOMMENDATIONS = [
'Forbered 3 korte STAR-historier med maelbare resultater.',
'Lav en 60-sekunders pitch af din profil og vaerdiskabelse.',
'Research virksomheden i 10-15 minutter inden samtalen.',
'Forbered 4-5 konkrete spoergsmaal om rolle og forventninger.',
'Afslut svar med resultat, sa din effekt bliver tydelig.',
];
const FALLBACK_DATA: SimulatorEvaluationData = {
companyName: 'Ukendt virksomhed',
dateLabel: 'Nyligt',
evaluationLabel: 'Interview Feedback',
interviewerEvaluationTitle: 'Interviewer evaluering',
interviewerEvaluationLead: 'Du er godt med. Din praestation viste styrker der matcher stillingen.',
interviewerEvaluationBody:
'Du viste relevante kompetencer og en stabil tilgang under pres. Du kan staerke dit indtryk yderligere ved at svare mere struktureret og bruge flere konkrete resultateksempler.',
interviewTitle: 'Stilling',
interviewerScore: 8,
selfScore: 5,
strengths: FALLBACK_STRENGTHS,
recommendations: FALLBACK_RECOMMENDATIONS,
suggestions: FALLBACK_SUGGESTIONS,
};
function mapSuggestion(item: unknown, index: number): EvaluationImprovementItem | null {
const source = asRecord(item);
if (!source) {
return null;
}
const title = pickString(source, ['title', 'heading', 'name', 'subject']);
const behavior = pickString(source, ['behavior', 'adfaerd', 'observation', 'issue']);
const effect = pickString(source, ['effect', 'impact', 'consequence']);
const nextStep = pickString(source, ['next_step', 'nextStep', 'recommendation', 'suggestion']);
if (!title && !behavior && !effect && !nextStep) {
return null;
}
return {
id: asString(source.id) || String(index + 1),
title: title || `Forbedringspunkt ${index + 1}`,
behavior: behavior || 'Ingen detaljer tilgaengelige.',
effect: effect || 'Ingen detaljer tilgaengelige.',
nextStep: nextStep || 'Ingen detaljer tilgaengelige.',
};
}
function extractSuggestions(root: RecordLike | null): EvaluationImprovementItem[] {
const candidates = [
root?.suggestions,
root?.improvements,
root?.improvement_points,
root?.feedback_points,
asRecord(root?.evaluation)?.suggestions,
asRecord(root?.evaluation)?.improvements,
];
for (const candidate of candidates) {
if (!Array.isArray(candidate)) {
continue;
}
const mapped = candidate
.map((item, index) => mapSuggestion(item, index))
.filter((item): item is EvaluationImprovementItem => Boolean(item));
if (mapped.length > 0) {
return mapped;
}
}
return FALLBACK_SUGGESTIONS;
}
function extractStringList(root: RecordLike | null, keys: string[], fallback: string[]): string[] {
for (const key of keys) {
const values = asStringArray(root?.[key]);
if (values.length > 0) {
return values;
}
}
const evaluation = asRecord(root?.evaluation);
for (const key of keys) {
const values = asStringArray(evaluation?.[key]);
if (values.length > 0) {
return values;
}
}
return fallback;
}
export class SimulatorEvaluationViewModel {
constructor(private readonly simulationService: SimulationService = new SimulationService()) {}
async getEvaluation(interviewId: string, context?: SimulatorEvaluationSelectionContext): Promise<SimulatorEvaluationData> {
try {
const payload = await this.simulationService.getInterviewEvaluation(interviewId);
const root = asRecord(payload);
const evaluation = asRecord(root?.evaluation);
const companyName = pickString(root, ['company_name', 'companyName'])
|| pickString(evaluation, ['company_name', 'companyName'])
|| context?.companyName
|| FALLBACK_DATA.companyName;
const interviewTitle = pickString(root, ['job_title', 'job_name', 'title'])
|| pickString(evaluation, ['job_title', 'job_name', 'title'])
|| context?.title
|| FALLBACK_DATA.interviewTitle;
const rawDate = pickString(root, ['interview_date', 'created_at', 'date'])
|| pickString(evaluation, ['interview_date', 'created_at', 'date']);
const dateLabel = formatDateLabel(rawDate) || context?.dateLabel || FALLBACK_DATA.dateLabel;
const interviewerEvaluationLead = pickString(root, ['interviewer_evaluation_lead', 'lead'])
|| pickString(evaluation, ['interviewer_evaluation_lead', 'lead'])
|| FALLBACK_DATA.interviewerEvaluationLead;
const interviewerEvaluationBody = pickString(root, ['interviewer_evaluation', 'summary', 'feedback'])
|| pickString(evaluation, ['interviewer_evaluation', 'summary', 'feedback'])
|| FALLBACK_DATA.interviewerEvaluationBody;
const interviewerEvaluationTitle = pickString(root, ['evaluation_title', 'interviewer_title'])
|| pickString(evaluation, ['evaluation_title', 'interviewer_title'])
|| FALLBACK_DATA.interviewerEvaluationTitle;
const evaluationLabel = pickString(root, ['label', 'evaluation_label'])
|| pickString(evaluation, ['label', 'evaluation_label'])
|| FALLBACK_DATA.evaluationLabel;
const selfScore = normalizeScore(
pickNumber(root, ['candidate_score', 'self_score', 'self_rating'])
?? pickNumber(evaluation, ['candidate_score', 'self_score', 'self_rating']),
FALLBACK_DATA.selfScore,
);
const interviewerScore = normalizeScore(
pickNumber(root, ['interviewer_score', 'score', 'interviewer_rating'])
?? pickNumber(evaluation, ['interviewer_score', 'score', 'interviewer_rating']),
FALLBACK_DATA.interviewerScore,
);
return {
companyName,
dateLabel,
evaluationLabel,
interviewerEvaluationBody,
interviewerEvaluationLead,
interviewerEvaluationTitle,
interviewerScore,
interviewTitle,
recommendations: extractStringList(root, ['recommendations', 'constructive_recommendations', 'next_steps'], FALLBACK_RECOMMENDATIONS),
selfScore,
strengths: extractStringList(root, ['strengths', 'highlights'], FALLBACK_STRENGTHS),
suggestions: extractSuggestions(root),
};
} catch {
return {
...FALLBACK_DATA,
companyName: context?.companyName || FALLBACK_DATA.companyName,
interviewTitle: context?.title || FALLBACK_DATA.interviewTitle,
dateLabel: context?.dateLabel || FALLBACK_DATA.dateLabel,
};
}
}
async submitRating(interviewId: string, rating: number, comment: string): Promise<void> {
await this.simulationService.submitEvaluationRating(interviewId, rating, comment);
}
}

View File

@@ -1,5 +1,5 @@
import { Briefcase, Bot, Crown, FileText, Gamepad2, LayoutGrid, MessageCircle, Radar, Sparkles } from 'lucide-react';
import type { ComponentType } from 'react';
import { Briefcase, Bot, Crown, FileText, Gamepad2, LayoutGrid, Menu, MessageCircle, Radar, Sparkles, X } from 'lucide-react';
import { useEffect, useState, type ComponentType } from 'react';
interface DashboardSidebarProps {
active?: DashboardNavKey;
@@ -32,8 +32,58 @@ const secondaryItems: NavItem[] = [
];
export function DashboardSidebar({ active = 'dashboard', onNavigate }: DashboardSidebarProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useEffect(() => {
setIsMobileMenuOpen(false);
}, [active]);
useEffect(() => {
if (!isMobileMenuOpen) {
return undefined;
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setIsMobileMenuOpen(false);
}
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [isMobileMenuOpen]);
function handleNavigate(target: DashboardNavKey) {
onNavigate?.(target);
setIsMobileMenuOpen(false);
}
return (
<aside className="dash-sidebar">
<>
<button
type="button"
className="dash-mobile-menu-btn"
aria-label={isMobileMenuOpen ? 'Luk menu' : 'Aabn menu'}
aria-expanded={isMobileMenuOpen}
onClick={() => setIsMobileMenuOpen((current) => !current)}
>
{isMobileMenuOpen ? <X size={18} strokeWidth={1.9} /> : <Menu size={18} strokeWidth={1.9} />}
</button>
<button
type="button"
className={`dash-mobile-overlay ${isMobileMenuOpen ? 'open' : ''}`}
aria-label="Luk menu"
onClick={() => setIsMobileMenuOpen(false)}
/>
<aside className={`dash-sidebar ${isMobileMenuOpen ? 'open' : ''}`}>
<div className="dash-logo-row">
<div className="dash-logo-dot">A</div>
<span className="dash-logo-text">ARBEJD</span>
@@ -48,7 +98,7 @@ export function DashboardSidebar({ active = 'dashboard', onNavigate }: Dashboard
key={item.key}
type="button"
className={isActive ? 'dash-nav-item active' : 'dash-nav-item'}
onClick={() => onNavigate?.(item.key)}
onClick={() => handleNavigate(item.key)}
>
<span className={item.accent ? 'dash-nav-icon accent' : 'dash-nav-icon'}>
<Icon size={19} strokeWidth={1.7} />
@@ -69,7 +119,7 @@ export function DashboardSidebar({ active = 'dashboard', onNavigate }: Dashboard
key={item.key}
type="button"
className={isActive ? 'dash-nav-item active' : 'dash-nav-item'}
onClick={() => onNavigate?.(item.key)}
onClick={() => handleNavigate(item.key)}
>
<span className={item.accent ? 'dash-nav-icon accent' : 'dash-nav-icon'}>
<Icon size={19} strokeWidth={1.7} />
@@ -88,5 +138,6 @@ export function DashboardSidebar({ active = 'dashboard', onNavigate }: Dashboard
<p>Faa ubegrænsede simuleringer</p>
</div>
</aside>
</>
);
}

View File

@@ -63,6 +63,25 @@
flex-shrink: 0;
}
.dash-mobile-menu-btn,
.dash-mobile-overlay {
display: none;
}
.dash-mobile-menu-btn {
border: 1px solid rgba(229, 231, 235, 0.82);
background: rgba(255, 255, 255, 0.75);
width: 40px;
height: 40px;
border-radius: 12px;
color: #111827;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.dash-logo-row {
display: flex;
align-items: center;
@@ -969,6 +988,12 @@
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
}
.theme-dark .dash-mobile-menu-btn {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: #f3f4f6;
}
.theme-dark .dash-logo-text,
.theme-dark .dash-welcome h1,
.theme-dark .dash-card h2,
@@ -1096,17 +1121,6 @@
}
@media (max-width: 980px) {
.dash-root {
display: block;
}
.dash-sidebar,
.dash-main {
width: auto;
height: auto;
margin: 16px;
}
.dash-main {
padding-right: 0;
}
@@ -1116,6 +1130,59 @@
}
}
@media (max-width: 1000px) {
.dash-main {
margin: 16px;
margin-top: 64px;
height: calc(100vh - 80px);
padding-right: 0;
}
.dash-mobile-menu-btn {
display: inline-flex;
position: fixed;
top: 14px;
left: 14px;
z-index: 60;
}
.dash-mobile-overlay {
display: block;
position: fixed;
inset: 0;
z-index: 49;
border: 0;
margin: 0;
padding: 0;
background: rgba(2, 6, 23, 0.34);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.dash-mobile-overlay.open {
opacity: 1;
pointer-events: auto;
}
.dash-sidebar {
position: fixed;
top: 0;
left: 0;
margin: 0;
width: min(320px, calc(100vw - 28px));
height: 100vh;
border-radius: 0 24px 24px 0;
transform: translateX(-110%);
transition: transform 0.24s ease;
z-index: 50;
}
.dash-sidebar.open {
transform: translateX(0);
}
}
@media (min-width: 1280px) {
.dash-ai-xl-only {
display: flex;

View File

@@ -0,0 +1,328 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowRight,
Calendar,
CheckCircle2,
ClipboardList,
Gamepad2,
Lightbulb,
MoveRight,
Route,
Star,
Target,
TrendingUp,
Trophy,
} from 'lucide-react';
import {
SimulatorEvaluationViewModel,
type EvaluationImprovementItem,
type SimulatorEvaluationData,
} from '../../../mvvm/viewmodels/SimulatorEvaluationViewModel';
import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar';
import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar';
import type { SimulatorEvaluationSelection } from './SimulatorPage';
import '../../dashboard/pages/dashboard.css';
import './simulator-evaluation.css';
interface SimulatorEvaluationPageProps {
interviewSelection: SimulatorEvaluationSelection;
onBack: () => void;
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
function scoreGap(selfScore: number, interviewerScore: number): number {
return interviewerScore - selfScore;
}
function scoreGapLabel(selfScore: number, interviewerScore: number): string {
const gap = scoreGap(selfScore, interviewerScore);
if (gap > 0) {
return `Gab: +${gap} point`;
}
if (gap < 0) {
return `Gab: ${gap} point`;
}
return 'Gab: 0 point';
}
function scoreGapHint(selfScore: number, interviewerScore: number): string {
const gap = scoreGap(selfScore, interviewerScore);
if (gap > 0) {
return 'Du undervurderede dig selv i forhold til interviewerens vurdering.';
}
if (gap < 0) {
return 'Du vurderede dig selv hojere end intervieweren gjorde i denne session.';
}
return 'Din selvvurdering matcher interviewerens vurdering godt.';
}
function asDateLabel(value: string): string {
if (!value) {
return 'Nyligt';
}
return value;
}
export function SimulatorEvaluationPage({
interviewSelection,
onBack,
onLogout,
onNavigate,
onToggleTheme,
theme,
}: SimulatorEvaluationPageProps) {
const viewModel = useMemo(() => new SimulatorEvaluationViewModel(), []);
const [name] = useState('Lasse');
const [imageUrl] = useState<string | undefined>(undefined);
const [evaluation, setEvaluation] = useState<SimulatorEvaluationData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [rating, setRating] = useState(0);
const [comment, setComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState('');
useEffect(() => {
let active = true;
async function load() {
setIsLoading(true);
const data = await viewModel.getEvaluation(interviewSelection.interviewId, {
companyName: interviewSelection.companyName,
dateLabel: interviewSelection.dateLabel,
title: interviewSelection.title,
});
if (!active) {
return;
}
setEvaluation(data);
setIsLoading(false);
}
void load();
return () => {
active = false;
};
}, [interviewSelection.companyName, interviewSelection.dateLabel, interviewSelection.interviewId, interviewSelection.title, viewModel]);
async function handleSubmitRating() {
if (!rating || isSubmitting) {
return;
}
setIsSubmitting(true);
setSubmitStatus('');
try {
await viewModel.submitRating(interviewSelection.interviewId, rating, comment.trim());
setSubmitStatus('Tak. Din feedback er sendt.');
} catch {
setSubmitStatus('Kunne ikke sende feedback lige nu. Prov igen.');
} finally {
setIsSubmitting(false);
}
}
const data = evaluation;
const items: EvaluationImprovementItem[] = data?.suggestions ?? [];
return (
<section className={`dash-root ${theme === 'dark' ? 'theme-dark' : ''}`}>
<div className="dash-orb dash-orb-1" />
<div className="dash-orb dash-orb-2" />
<div className="dash-orb dash-orb-3" />
<DashboardSidebar active="simulator" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar sim-eval-main">
<DashboardTopbar
name={name}
imageUrl={imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
actions={(
<div className="sim-eval-top-actions">
<button type="button" className="sim-eval-back-btn" onClick={onBack}>Tilbage</button>
<div className="sim-eval-crumb-pill">
<Gamepad2 size={14} strokeWidth={1.8} />
<span>Simulator</span>
<ArrowRight size={13} strokeWidth={1.8} />
<strong>Evaluering</strong>
</div>
</div>
)}
/>
<div className="sim-eval-wrap">
<header className="sim-eval-head">
<div className="sim-eval-label">
<ClipboardList size={14} strokeWidth={1.8} />
<span>{data?.evaluationLabel ?? 'Interview Feedback'}</span>
</div>
<h1>Evalueringsfeedback</h1>
<div className="sim-eval-meta">
<strong>{data?.companyName ?? interviewSelection.companyName}</strong>
<span />
<p>{data?.interviewTitle ?? interviewSelection.title}</p>
<span />
<small><Calendar size={14} strokeWidth={1.8} /> {asDateLabel(data?.dateLabel ?? interviewSelection.dateLabel)}</small>
</div>
</header>
{isLoading ? <p className="dash-loading">Indlaeser evaluering...</p> : null}
{!isLoading && data ? (
<>
<section className="sim-eval-overview-card">
<div className="sim-eval-overview-text">
<div className="sim-eval-overview-title">
<Trophy size={20} strokeWidth={1.8} />
<h2>{data.interviewerEvaluationTitle}</h2>
</div>
<p className="lead">{data.interviewerEvaluationLead}</p>
<p>{data.interviewerEvaluationBody}</p>
</div>
<aside className="sim-eval-score-card">
<div className="sim-eval-score-head">
<h3>Performance vurdering</h3>
<Target size={16} strokeWidth={1.8} />
</div>
<div className="sim-eval-score-rows">
<div>
<span>Din udfyldelse</span>
<strong>{data.selfScore}/10</strong>
</div>
<div>
<span>Interviewers vurdering</span>
<strong className="good">{data.interviewerScore}/10</strong>
</div>
</div>
<div className="sim-eval-gap-block">
<span><TrendingUp size={13} strokeWidth={1.8} /> {scoreGapLabel(data.selfScore, data.interviewerScore)}</span>
<p>{scoreGapHint(data.selfScore, data.interviewerScore)}</p>
</div>
</aside>
</section>
<section className="sim-eval-improvements">
<div className="sim-eval-section-title">
<TrendingUp size={20} strokeWidth={1.8} />
<h2>Hvordan kan du blive bedre?</h2>
</div>
<div className="sim-eval-improvement-list">
{items.map((item, index) => (
<article key={item.id} className="sim-eval-improvement-card">
<div className="sim-eval-improvement-index">{index + 1}</div>
<div className="sim-eval-improvement-content">
<h3>{item.title}</h3>
<div className="sim-eval-improvement-grid">
<div>
<small>Adfaerd</small>
<p>{item.behavior}</p>
</div>
<div className="impact">
<small>Effekt</small>
<p>{item.effect}</p>
</div>
<div className="next">
<small>Naeste gang</small>
<p>{item.nextStep}</p>
</div>
</div>
</div>
</article>
))}
</div>
</section>
<section className="sim-eval-split-grid">
<article className="sim-eval-list-card">
<div className="sim-eval-card-title">
<CheckCircle2 size={20} strokeWidth={1.8} />
<h2>Dine styrker</h2>
</div>
<ul>
{data.strengths.map((item, index) => (
<li key={`strength-${index}`}>
<CheckCircle2 size={17} strokeWidth={1.8} />
<span>{item}</span>
</li>
))}
</ul>
</article>
<article className="sim-eval-list-card recommendations">
<div className="sim-eval-card-title">
<Route size={20} strokeWidth={1.8} />
<h2>Konstruktiv anbefaling</h2>
</div>
<p className="hint">Fokuser pa disse handlinger for at staerke din naeste samtale.</p>
<ul>
{data.recommendations.map((item, index) => (
<li key={`recommendation-${index}`}>
<div><MoveRight size={13} strokeWidth={2} /></div>
<span>{item}</span>
</li>
))}
</ul>
</article>
</section>
<section className="sim-eval-rating-card">
<p>Husk: Hvert interview er en laeringsmulighed. Bliv ved med at ove.</p>
<div className="sim-eval-rating-box">
<h3>Bedom denne evaluering</h3>
<small>Din feedback hjaelper os med at forbedre oplevelsen.</small>
<div className="sim-eval-stars">
{Array.from({ length: 5 }).map((_, index) => {
const value = index + 1;
const selected = value <= rating;
return (
<button
key={`star-${value}`}
type="button"
aria-label={`Vaelg ${value} stjerner`}
className={selected ? 'selected' : ''}
onClick={() => setRating(value)}
>
<Star size={27} fill={selected ? 'currentColor' : 'none'} strokeWidth={1.8} />
</button>
);
})}
</div>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={3}
placeholder="Kommentar (valgfrit)"
/>
<button type="button" onClick={() => void handleSubmitRating()} disabled={isSubmitting || rating === 0}>
{isSubmitting ? 'Sender...' : 'Send feedback'}
</button>
{submitStatus ? <div className="sim-eval-submit-status">{submitStatus}</div> : null}
</div>
</section>
</>
) : null}
</div>
</main>
</section>
);
}

View File

@@ -30,6 +30,7 @@ import './simulator.css';
interface SimulatorPageProps {
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onOpenEvaluation: (selection: SimulatorEvaluationSelection) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
@@ -44,6 +45,13 @@ interface InterviewCard {
dateLabel: string;
}
export interface SimulatorEvaluationSelection {
interviewId: string;
title: string;
companyName: string;
dateLabel: string;
}
const FALLBACK_INTERVIEWS: InterviewCard[] = [
{
id: 'sim-1',
@@ -99,7 +107,7 @@ function jobLabel(job: JobsListItem): string {
return `${job.title || 'Stilling'}${job.companyName ? ` · ${job.companyName}` : ''}`;
}
export function SimulatorPage({ onLogout, onNavigate, onToggleTheme, theme }: SimulatorPageProps) {
export function SimulatorPage({ onLogout, onNavigate, onOpenEvaluation, onToggleTheme, theme }: SimulatorPageProps) {
const viewModel = useMemo(() => new SimulatorViewModel(), []);
const [name, setName] = useState('Lasse');
@@ -443,7 +451,18 @@ export function SimulatorPage({ onLogout, onNavigate, onToggleTheme, theme }: Si
<div className="sim-card-foot">
<small>{item.dateLabel}</small>
{item.completed ? (
<button type="button" className="sim-link-btn">Se evaluering <ArrowRight size={14} strokeWidth={1.8} /></button>
<button
type="button"
className="sim-link-btn"
onClick={() => onOpenEvaluation({
interviewId: item.id,
title: item.title,
companyName: item.companyName,
dateLabel: item.dateLabel,
})}
>
Se evaluering <ArrowRight size={14} strokeWidth={1.8} />
</button>
) : (
<button type="button" className="sim-link-btn">Fortsæt <Play size={14} strokeWidth={1.8} /></button>
)}

View File

@@ -0,0 +1,643 @@
.sim-eval-main {
position: relative;
}
.sim-eval-top-actions {
display: inline-flex;
align-items: center;
gap: 10px;
}
.sim-eval-back-btn {
border: 1px solid rgba(229, 231, 235, 0.82);
background: rgba(255, 255, 255, 0.65);
border-radius: 999px;
padding: 8px 12px;
color: #6b7280;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
}
.sim-eval-back-btn:hover {
background: #ffffff;
color: #111827;
}
.sim-eval-crumb-pill {
border: 1px solid rgba(229, 231, 235, 0.75);
background: rgba(255, 255, 255, 0.62);
border-radius: 999px;
padding: 7px 12px;
display: inline-flex;
align-items: center;
gap: 7px;
color: #6b7280;
font-size: 0.78rem;
}
.sim-eval-crumb-pill strong {
color: #0f766e;
font-weight: 600;
}
.sim-eval-wrap {
max-width: 1160px;
margin: 0 auto;
padding-bottom: 26px;
}
.sim-eval-head {
margin-bottom: 24px;
}
.sim-eval-label {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
padding: 6px 10px;
border: 1px solid #99f6e4;
background: #f0fdfa;
color: #0f766e;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
margin-bottom: 12px;
}
.sim-eval-head h1 {
margin: 0 0 12px;
color: #111827;
font-size: clamp(1.9rem, 3vw, 2.4rem);
letter-spacing: -0.03em;
}
.sim-eval-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 9px;
color: #6b7280;
font-size: 0.86rem;
}
.sim-eval-meta strong {
color: #111827;
font-weight: 600;
}
.sim-eval-meta p,
.sim-eval-meta small {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sim-eval-meta span {
width: 4px;
height: 4px;
border-radius: 999px;
background: #d1d5db;
}
.sim-eval-overview-card {
border-radius: 28px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.04);
padding: 24px;
margin-bottom: 26px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 300px);
gap: 20px;
}
.sim-eval-overview-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.sim-eval-overview-title h2 {
margin: 0;
color: #111827;
font-size: 1.08rem;
}
.sim-eval-overview-title svg {
color: #14b8a6;
}
.sim-eval-overview-text .lead {
margin: 0 0 10px;
color: #374151;
font-size: 1rem;
}
.sim-eval-overview-text p {
margin: 0;
color: #4b5563;
line-height: 1.65;
}
.sim-eval-score-card {
border-radius: 18px;
border: 1px solid rgba(229, 231, 235, 0.82);
background: linear-gradient(to bottom, #f9fafb, #ffffff);
padding: 18px;
}
.sim-eval-score-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.sim-eval-score-head h3 {
margin: 0;
color: #111827;
font-size: 0.88rem;
font-weight: 500;
}
.sim-eval-score-head svg {
color: #9ca3af;
}
.sim-eval-score-rows {
display: grid;
gap: 10px;
margin-bottom: 14px;
}
.sim-eval-score-rows > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sim-eval-score-rows span {
color: #6b7280;
font-size: 0.84rem;
}
.sim-eval-score-rows strong {
border-radius: 7px;
background: #f3f4f6;
color: #111827;
font-size: 0.85rem;
padding: 4px 8px;
}
.sim-eval-score-rows strong.good {
color: #0f766e;
border: 1px solid #99f6e4;
background: #f0fdfa;
}
.sim-eval-gap-block {
border-top: 1px solid rgba(229, 231, 235, 0.9);
padding-top: 12px;
}
.sim-eval-gap-block > span {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
padding: 5px 8px;
background: #f0fdfa;
color: #0f766e;
font-size: 0.72rem;
font-weight: 600;
margin-bottom: 7px;
}
.sim-eval-gap-block p {
margin: 0;
color: #6b7280;
font-size: 0.76rem;
line-height: 1.5;
}
.sim-eval-improvements {
margin-bottom: 26px;
}
.sim-eval-section-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.sim-eval-section-title h2 {
margin: 0;
color: #111827;
font-size: 1.25rem;
}
.sim-eval-improvement-list {
display: grid;
gap: 12px;
}
.sim-eval-improvement-card {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
padding: 18px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.sim-eval-improvement-index {
width: 30px;
height: 30px;
border-radius: 999px;
background: #111827;
color: #ffffff;
display: grid;
place-items: center;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.sim-eval-improvement-content {
flex: 1;
}
.sim-eval-improvement-content h3 {
margin: 3px 0 14px;
color: #111827;
font-size: 1rem;
}
.sim-eval-improvement-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.sim-eval-improvement-grid > div {
border-radius: 14px;
border: 1px solid #f3f4f6;
background: rgba(249, 250, 251, 0.85);
padding: 12px;
}
.sim-eval-improvement-grid > div.impact {
border-color: #fecdd3;
background: rgba(255, 241, 242, 0.72);
}
.sim-eval-improvement-grid > div.next {
border-color: #99f6e4;
background: rgba(240, 253, 250, 0.72);
}
.sim-eval-improvement-grid small {
display: block;
margin-bottom: 8px;
color: #6b7280;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.sim-eval-improvement-grid > div.impact small {
color: #be123c;
}
.sim-eval-improvement-grid > div.next small {
color: #0f766e;
}
.sim-eval-improvement-grid p {
margin: 0;
color: #4b5563;
font-size: 0.82rem;
line-height: 1.55;
}
.sim-eval-split-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 24px;
}
.sim-eval-list-card {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
padding: 20px;
}
.sim-eval-card-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.sim-eval-card-title h2 {
margin: 0;
color: #111827;
font-size: 1.04rem;
}
.sim-eval-list-card ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
}
.sim-eval-list-card li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.sim-eval-list-card li svg {
margin-top: 2px;
color: #14b8a6;
flex-shrink: 0;
}
.sim-eval-list-card li span {
color: #4b5563;
line-height: 1.58;
font-size: 0.84rem;
}
.sim-eval-list-card.recommendations .sim-eval-card-title svg {
color: #6366f1;
}
.sim-eval-list-card .hint {
margin: 0 0 12px;
color: #6b7280;
font-size: 0.8rem;
}
.sim-eval-list-card.recommendations li > div {
width: 18px;
height: 18px;
border-radius: 999px;
border: 1px solid #c7d2fe;
background: #eef2ff;
color: #4f46e5;
display: grid;
place-items: center;
margin-top: 2px;
flex-shrink: 0;
}
.sim-eval-rating-card {
border-radius: 24px;
border: 1px solid rgba(20, 184, 166, 0.18);
background: linear-gradient(to bottom right, rgba(20, 184, 166, 0.06), rgba(6, 182, 212, 0.08));
padding: 22px;
text-align: center;
}
.sim-eval-rating-card > p {
margin: 0 0 16px;
color: rgba(13, 89, 83, 0.95);
font-size: 0.95rem;
font-weight: 500;
}
.sim-eval-rating-box {
max-width: 460px;
margin: 0 auto;
border-radius: 18px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: #ffffff;
padding: 18px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.04);
}
.sim-eval-rating-box h3 {
margin: 0 0 3px;
color: #111827;
font-size: 1rem;
}
.sim-eval-rating-box small {
display: block;
color: #6b7280;
margin-bottom: 12px;
font-size: 0.74rem;
}
.sim-eval-stars {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
}
.sim-eval-stars button {
border: 0;
background: transparent;
color: #d1d5db;
padding: 0;
display: inline-flex;
cursor: pointer;
}
.sim-eval-stars button:hover,
.sim-eval-stars button.selected {
color: #f59e0b;
}
.sim-eval-rating-box textarea {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(229, 231, 235, 0.9);
background: rgba(249, 250, 251, 0.85);
color: #111827;
padding: 11px;
resize: none;
margin-bottom: 10px;
font-size: 0.84rem;
font-family: inherit;
}
.sim-eval-rating-box textarea:focus {
outline: none;
border-color: #14b8a6;
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.14);
}
.sim-eval-rating-box > button {
width: 100%;
border: 0;
border-radius: 11px;
background: #111827;
color: #ffffff;
padding: 10px;
font-size: 0.84rem;
font-weight: 500;
cursor: pointer;
}
.sim-eval-rating-box > button:hover {
background: #1f2937;
}
.sim-eval-rating-box > button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.sim-eval-submit-status {
margin-top: 8px;
color: #0f766e;
font-size: 0.78rem;
}
.theme-dark .sim-eval-back-btn,
.theme-dark .sim-eval-crumb-pill,
.theme-dark .sim-eval-overview-card,
.theme-dark .sim-eval-improvement-card,
.theme-dark .sim-eval-list-card,
.theme-dark .sim-eval-rating-box {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.09);
}
.theme-dark .sim-eval-head h1,
.theme-dark .sim-eval-meta strong,
.theme-dark .sim-eval-overview-title h2,
.theme-dark .sim-eval-score-head h3,
.theme-dark .sim-eval-section-title h2,
.theme-dark .sim-eval-improvement-content h3,
.theme-dark .sim-eval-card-title h2,
.theme-dark .sim-eval-rating-box h3 {
color: #ffffff;
}
.theme-dark .sim-eval-meta,
.theme-dark .sim-eval-overview-text p,
.theme-dark .sim-eval-overview-text .lead,
.theme-dark .sim-eval-gap-block p,
.theme-dark .sim-eval-improvement-grid p,
.theme-dark .sim-eval-list-card li span,
.theme-dark .sim-eval-list-card .hint,
.theme-dark .sim-eval-rating-box small,
.theme-dark .sim-eval-back-btn,
.theme-dark .sim-eval-score-rows span {
color: #9ca3af;
}
.theme-dark .sim-eval-score-card,
.theme-dark .sim-eval-improvement-grid > div {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
}
.theme-dark .sim-eval-improvement-grid > div.impact {
background: rgba(190, 24, 93, 0.08);
border-color: rgba(244, 114, 182, 0.32);
}
.theme-dark .sim-eval-improvement-grid > div.next {
background: rgba(20, 184, 166, 0.08);
border-color: rgba(45, 212, 191, 0.3);
}
.theme-dark .sim-eval-score-rows strong {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
}
.theme-dark .sim-eval-score-rows strong.good,
.theme-dark .sim-eval-gap-block > span,
.theme-dark .sim-eval-label {
border-color: rgba(45, 212, 191, 0.35);
background: rgba(20, 184, 166, 0.12);
color: #5eead4;
}
.theme-dark .sim-eval-list-card.recommendations li > div {
border-color: rgba(129, 140, 248, 0.35);
background: rgba(79, 70, 229, 0.22);
color: #a5b4fc;
}
.theme-dark .sim-eval-rating-card {
border-color: rgba(45, 212, 191, 0.22);
background: linear-gradient(to bottom right, rgba(20, 184, 166, 0.1), rgba(6, 182, 212, 0.1));
}
.theme-dark .sim-eval-rating-card > p {
color: #99f6e4;
}
.theme-dark .sim-eval-rating-box textarea {
border-color: rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.03);
color: #ffffff;
}
.theme-dark .sim-eval-rating-box > button {
background: #1f2937;
}
@media (max-width: 1080px) {
.sim-eval-overview-card {
grid-template-columns: 1fr;
}
.sim-eval-improvement-grid,
.sim-eval-split-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.sim-eval-wrap {
padding-bottom: 18px;
}
.sim-eval-top-actions {
width: 100%;
justify-content: space-between;
}
.sim-eval-crumb-pill {
display: none;
}
.sim-eval-meta {
gap: 7px;
}
.sim-eval-overview-card,
.sim-eval-improvement-card,
.sim-eval-list-card,
.sim-eval-rating-card {
padding: 16px;
border-radius: 20px;
}
}