Initial React project

This commit is contained in:
Johan
2026-03-03 00:56:54 +01:00
parent 6c1f178ba9
commit 20370144fb
4012 changed files with 287867 additions and 9843 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,245 +1,128 @@
import './App.css';
import { useEffect } from 'react';
import { ForgotPasswordPage } from './presentation/auth/pages/ForgotPasswordPage';
import { LoginPage } from './presentation/auth/pages/LoginPage';
import { RegisterPage } from './presentation/auth/pages/RegisterPage';
import { DashboardPage } from './presentation/dashboard/pages/DashboardPage';
import { CvPage } from './presentation/cv/pages/CvPage';
import { JobsPage } from './presentation/jobs/pages/JobsPage';
import { JobDetailPage } from './presentation/jobs/pages/JobDetailPage';
import { BeskederPage } from './presentation/messages/pages/BeskederPage';
import { AiAgentPage } from './presentation/ai-agent/pages/AiAgentPage';
import { AiJobAgentPage } from './presentation/ai-jobagent/pages/AiJobAgentPage';
import { JobSimulatorPage } from './presentation/simulator/pages/JobSimulatorPage';
import { SubscriptionPage } from './presentation/subscription/pages/SubscriptionPage';
import { ThemeToggle } from './presentation/layout/components/ThemeToggle';
import { useAuthViewModel } from './presentation/auth/hooks/useAuthViewModel';
import { useBrowserRoute } from './presentation/router/useBrowserRoute';
import { useMemo, useState } from 'react';
import { localStorageService } from './mvvm/services/local-storage.service';
import { AuthPage } from './presentation/auth/pages/AuthPage';
import { AiAgentPage } from './presentation/ai-agent/pages/AiAgentPage';
import { CvPage } from './presentation/cv/pages/CvPage';
import type { DashboardNavKey } from './presentation/dashboard/components/DashboardSidebar';
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';
type AppPage = DashboardNavKey | 'job-detail';
interface JobDetailSelection {
id: string;
fromJobnet: boolean;
returnPage: DashboardNavKey;
}
function App() {
const { path, navigate } = useBrowserRoute();
const { isLoading, result, login, register, forgotPassword } = useAuthViewModel();
const isJobDetail = path.startsWith('/jobs/');
const jobDetailMatch = isJobDetail ? path.match(/^\/jobs\/([^/]+)\/(jobnet|arbejd)$/) : null;
const jobIdFromPath = jobDetailMatch ? decodeURIComponent(jobDetailMatch[1]) : '';
const fromJobnetFromPath = jobDetailMatch ? jobDetailMatch[2] === 'jobnet' : false;
const mode =
path === '/register'
? 'register'
: path === '/forgot-password'
? 'forgot'
: path === '/dashboard'
? 'dashboard'
: path === '/cv'
? 'cv'
: path === '/jobs'
? 'jobs'
: path === '/beskeder'
? 'beskeder'
: path === '/ai-jobagent'
? 'ai-jobagent'
: path === '/ai-agent'
? 'ai-agent'
: path === '/simulator'
? 'simulator'
: path === '/abonnement'
? 'abonnement'
: isJobDetail
? 'job-detail'
: 'login';
const initialAuthenticated = useMemo(() => Boolean(window.localStorage.getItem('token')), []);
const initialTheme = useMemo<'light' | 'dark'>(() => {
const stored = window.localStorage.getItem('theme');
return stored === 'dark' ? 'dark' : 'light';
}, []);
const [isAuthenticated, setIsAuthenticated] = useState(initialAuthenticated);
const [theme, setTheme] = useState<'light' | 'dark'>(initialTheme);
const [activePage, setActivePage] = useState<AppPage>('dashboard');
const [jobDetailSelection, setJobDetailSelection] = useState<JobDetailSelection | null>(null);
useEffect(() => {
const token = window.localStorage.getItem('token');
const isAuthPage = path === '/login' || path === '/register' || path === '/forgot-password' || path === '/';
if ((path === '/dashboard' || path === '/cv' || path === '/jobs' || path === '/beskeder' || path === '/ai-jobagent' || path === '/ai-agent' || path === '/simulator' || path === '/abonnement' || isJobDetail) && !token) {
navigate('/login', true);
return;
function handleNavigate(target: DashboardNavKey) {
if (
target === 'dashboard'
|| target === 'jobs'
|| target === 'cv'
|| target === 'messages'
|| target === 'agents'
|| target === 'ai-agent'
) {
setActivePage(target);
}
}
if (isAuthPage && token) {
navigate('/dashboard', true);
}
}, [path, navigate, isJobDetail]);
function handleOpenJobDetail(id: string, fromJobnet: boolean, returnPage: DashboardNavKey = 'jobs') {
setJobDetailSelection({ id, fromJobnet, returnPage });
setActivePage('job-detail');
}
async function logout() {
function handleBackFromJobDetail() {
setActivePage(jobDetailSelection?.returnPage ?? 'jobs');
}
async function handleLogout() {
await localStorageService.clearCredentials();
navigate('/login', true);
setActivePage('dashboard');
setJobDetailSelection(null);
setIsAuthenticated(false);
}
function navigateFromSidebar(key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') {
if (key === 'dashboard') {
navigate('/dashboard');
return;
}
if (key === 'cv') {
navigate('/cv');
return;
}
if (key === 'jobs') {
navigate('/jobs');
return;
}
if (key === 'ai-jobagent') {
navigate('/ai-jobagent');
return;
}
if (key === 'ai-agent') {
navigate('/ai-agent');
return;
}
if (key === 'simulator') {
navigate('/simulator');
return;
}
if (key === 'abonnement') {
navigate('/abonnement');
return;
}
navigate('/beskeder');
function handleToggleTheme() {
setTheme((current) => {
const next = current === 'light' ? 'dark' : 'light';
window.localStorage.setItem('theme', next);
return next;
});
}
const isAppLayoutMode = mode === 'dashboard' || mode === 'cv' || mode === 'jobs' || mode === 'job-detail' || mode === 'beskeder' || mode === 'ai-jobagent' || mode === 'ai-agent' || mode === 'simulator' || mode === 'abonnement';
if (!isAuthenticated) {
return <AuthPage onAuthenticated={() => setIsAuthenticated(true)} />;
}
if (activePage === 'jobs') {
return (
<JobsPage
onLogout={handleLogout}
onNavigate={handleNavigate}
onOpenJobDetail={handleOpenJobDetail}
theme={theme}
onToggleTheme={handleToggleTheme}
/>
);
}
if (activePage === 'cv') {
return <CvPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
}
if (activePage === 'messages') {
return <MessagesPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
}
if (activePage === 'agents' || activePage === 'ai-agent') {
return (
<AiAgentPage
onLogout={handleLogout}
onNavigate={handleNavigate}
onOpenJobDetail={handleOpenJobDetail}
theme={theme}
onToggleTheme={handleToggleTheme}
/>
);
}
if (activePage === 'job-detail' && jobDetailSelection) {
return (
<JobDetailPage
jobId={jobDetailSelection.id}
fromJobnet={jobDetailSelection.fromJobnet}
onBack={handleBackFromJobDetail}
onLogout={handleLogout}
onNavigate={handleNavigate}
theme={theme}
onToggleTheme={handleToggleTheme}
/>
);
}
return (
<main className={isAppLayoutMode ? 'auth-root dashboard-mode' : 'auth-root'}>
<div className="orb orb-1" />
<div className="orb orb-2" />
<div className="orb orb-3" />
{mode === 'dashboard' ? (
<DashboardPage
onLogout={logout}
onNavigate={navigateFromSidebar}
onOpenJob={(jobId, fromJobnet) =>
navigate(`/jobs/${encodeURIComponent(jobId)}/${fromJobnet ? 'jobnet' : 'arbejd'}`)
}
/>
) : mode === 'cv' ? (
<CvPage onLogout={logout} onNavigate={navigateFromSidebar} />
) : mode === 'beskeder' ? (
<BeskederPage onLogout={logout} onNavigate={navigateFromSidebar} />
) : mode === 'ai-jobagent' ? (
<AiJobAgentPage
onLogout={logout}
onNavigate={navigateFromSidebar}
onOpenJob={(jobId, fromJobnet) =>
navigate(`/jobs/${encodeURIComponent(jobId)}/${fromJobnet ? 'jobnet' : 'arbejd'}`)
}
/>
) : mode === 'ai-agent' ? (
<AiAgentPage onLogout={logout} onNavigate={navigateFromSidebar} activeNavKey="ai-agent" />
) : mode === 'simulator' ? (
<JobSimulatorPage onLogout={logout} onNavigate={navigateFromSidebar} />
) : mode === 'abonnement' ? (
<SubscriptionPage onLogout={logout} onNavigate={navigateFromSidebar} />
) : mode === 'job-detail' && jobDetailMatch ? (
<JobDetailPage
jobId={jobIdFromPath}
fromJobnet={fromJobnetFromPath}
onLogout={logout}
onNavigate={navigateFromSidebar}
/>
) : mode === 'jobs' ? (
<JobsPage
onLogout={logout}
onNavigate={navigateFromSidebar}
onOpenJob={(jobId, fromJobnet) =>
navigate(`/jobs/${encodeURIComponent(jobId)}/${fromJobnet ? 'jobnet' : 'arbejd'}`)
}
/>
) : (
<section className="auth-shell glass-panel">
<aside className="brand-panel">
<div className="brand-chip">
<span>Ar</span>
</div>
<h1>Arbejd.com</h1>
<p>
AI-assisteret jobsøgning med glasdesign og fokus flow.
</p>
<ul className="brand-list">
<li>Log ind</li>
<li>Opret konto</li>
<li>Glemt kodeord</li>
</ul>
</aside>
<section className="form-panel glass-panel">
<div className="auth-theme-row">
<ThemeToggle />
</div>
<div className="mode-tabs">
<button
className={mode === 'login' ? 'tab-btn active' : 'tab-btn'}
onClick={() => navigate('/login')}
type="button"
>
Log ind
</button>
<button
className={mode === 'register' ? 'tab-btn active' : 'tab-btn'}
onClick={() => navigate('/register')}
type="button"
>
Opret konto
</button>
<button
className={mode === 'forgot' ? 'tab-btn active' : 'tab-btn'}
onClick={() => navigate('/forgot-password')}
type="button"
>
Glemt kode
</button>
</div>
{mode === 'login' && (
<LoginPage
isLoading={isLoading}
onSubmit={async (email, password, rememberMe) => {
const response = await login(email, password, rememberMe);
if (response.ok) {
navigate('/dashboard');
}
}}
/>
)}
{mode === 'register' && (
<RegisterPage
isLoading={isLoading}
onSubmit={async (payload) => {
const response = await register(payload);
if (response.ok) {
navigate('/login');
}
}}
/>
)}
{mode === 'forgot' && (
<ForgotPasswordPage
isLoading={isLoading}
onSubmit={async (email) => {
const response = await forgotPassword(email);
if (response.ok) {
navigate('/login');
}
}}
/>
)}
{result && (
<p className={result.ok ? 'status success' : 'status error'}>
{result.message}
</p>
)}
</section>
</section>
)}
</main>
<DashboardPage
onLogout={handleLogout}
onNavigate={handleNavigate}
onOpenJobDetail={handleOpenJobDetail}
theme={theme}
onToggleTheme={handleToggleTheme}
/>
);
}

View File

@@ -1,7 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
:root {
font-family: 'Inter', sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
@@ -19,10 +17,16 @@ body,
body {
margin: 0;
min-width: 320px;
color: #475569;
overflow: hidden;
}
button {
::selection {
background: #99f6e4;
color: #134e4a;
}
button,
input,
textarea,
select {
font-family: inherit;
}

View File

@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
);

View File

@@ -20,6 +20,11 @@ export interface AiAgentInitialData {
escos: EscoInterface[];
}
interface UserProfilePreview {
imageUrl?: string;
name: string;
}
export class AiAgentViewModel {
constructor(
private candidateService: CandidateService = new CandidateService(),
@@ -44,6 +49,17 @@ export class AiAgentViewModel {
};
}
async getCandidateProfile(): Promise<UserProfilePreview> {
try {
const candidate = await this.candidateService.getCandidate();
const name = candidate.firstName?.trim() || candidate.name?.trim() || 'Lasse';
const imageUrl = candidate.imageUrl || candidate.image || undefined;
return { name, imageUrl };
} catch {
return { name: 'Lasse' };
}
}
async addEscoToFilter(escoId: number): Promise<void> {
await this.jobAgentService.addEscoToJobAgent(escoId);
}

View File

@@ -1,4 +1,5 @@
import { CandidateSearchFilterService } from '../services/candidate-search-filter.service';
import { CandidateService } from '../services/candidate.service';
import { JobService } from '../services/job.service';
import { PlacesService } from '../services/places.service';
import type { AppliedJobInterface } from '../models/applied-job.interface';
@@ -54,6 +55,16 @@ export interface PlaceSelection {
longitude: number | null;
}
export interface JobsSearchQuery {
desiredTitles?: string[];
searchText?: string;
}
interface UserProfilePreview {
imageUrl?: string;
name: string;
}
const DEFAULT_FILTER: JobsFilterDraft = {
escoIds: [],
workTypePermanent: false,
@@ -118,6 +129,31 @@ function toNumber(source: Record<string, unknown> | null, key: string): number |
return typeof value === 'number' ? value : null;
}
function normalizeTerm(value: string): string {
return value.trim();
}
function buildTerms(query?: JobsSearchQuery): string[] {
if (!query) {
return [];
}
const terms = new Set<string>();
const searchText = query.searchText?.trim();
if (searchText && searchText.length > 0) {
terms.add(searchText);
}
for (const item of query.desiredTitles ?? []) {
const normalized = normalizeTerm(item);
if (normalized.length > 0) {
terms.add(normalized);
}
}
return Array.from(terms);
}
function postingToListItem(posting: JobPostingInterface, matchPercent?: number): JobsListItem {
return {
id: normalizeText(posting.id),
@@ -169,8 +205,20 @@ export class JobsPageViewModel {
private jobService: JobService = new JobService(),
private filterService: CandidateSearchFilterService = new CandidateSearchFilterService(),
private placesService: PlacesService = new PlacesService(),
private candidateService: CandidateService = new CandidateService(),
) {}
async getCandidateProfile(): Promise<UserProfilePreview> {
try {
const candidate = await this.candidateService.getCandidate();
const name = candidate.firstName?.trim() || candidate.name?.trim() || 'Lasse';
const imageUrl = candidate.imageUrl || candidate.image || undefined;
return { name, imageUrl };
} catch {
return { name: 'Lasse' };
}
}
async getOccupationOptions(): Promise<OccupationOption[]> {
const categorizations: OccupationCategorizationInterface[] = await this.jobService.getOccupationCategorizations();
const options: OccupationOption[] = [];
@@ -300,7 +348,13 @@ export class JobsPageViewModel {
return appliedArray.map((job) => savedOrAppliedToListItem(job as AppliedJobInterface));
}
return this.getJobsFeedItems(searchTerm);
return this.getJobsFeedItems(searchTerm ? [searchTerm] : undefined);
}
async applyFiltersAndGetJobs(filter: JobsFilterDraft, query?: JobsSearchQuery): Promise<JobsListItem[]> {
await this.saveFilter(filter);
const terms = buildTerms(query);
return this.getJobsFeedItems(terms);
}
async toggleBookmark(item: Pick<JobsListItem, 'id' | 'fromJobnet'>, save: boolean): Promise<void> {
@@ -308,7 +362,7 @@ export class JobsPageViewModel {
await this.jobService.bookmarkJobV2(item.id, save, jobType);
}
private async getJobsFeedItems(searchTerm?: string): Promise<JobsListItem[]> {
private async getJobsFeedItems(preferredTerms?: string[]): Promise<JobsListItem[]> {
const limit = 20;
let level = 10;
let offset = 0;
@@ -317,10 +371,12 @@ export class JobsPageViewModel {
const seenIds = new Set<string>();
const collected: JobsListItem[] = [];
const trimmedSearch = searchTerm?.trim() ?? '';
let searchWords: string[] = [];
if (trimmedSearch.length > 0) {
searchWords = [trimmedSearch];
const terms = (preferredTerms ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (terms.length > 0) {
searchWords = terms;
} else {
try {
const words = await this.jobService.getSearchWords();

View File

@@ -1,5 +1,6 @@
import type { ChatMessageInterface } from '../models/chat-message.interface';
import type { ChatMessageThreadInterface } from '../models/chat-message-thread.interface';
import { CandidateService } from '../services/candidate.service';
import { ChatMessagesService } from '../services/chat-messages.service';
import { MessageService } from '../services/message.service';
@@ -8,6 +9,11 @@ export interface MessageThreadItem extends ChatMessageThreadInterface {
latestMessage: ChatMessageInterface;
}
interface UserProfilePreview {
imageUrl?: string;
name: string;
}
function toMillis(value?: Date | string): number {
if (!value) {
return 0;
@@ -35,8 +41,20 @@ export class MessagesViewModel {
constructor(
private readonly chatMessagesService: ChatMessagesService = new ChatMessagesService(),
private readonly messageService: MessageService = new MessageService(),
private readonly candidateService: CandidateService = new CandidateService(),
) {}
async getCandidateProfile(): Promise<UserProfilePreview> {
try {
const candidate = await this.candidateService.getCandidate();
const name = candidate.firstName?.trim() || candidate.name?.trim() || 'Lasse';
const imageUrl = candidate.imageUrl || candidate.image || undefined;
return { name, imageUrl };
} catch {
return { name: 'Lasse' };
}
}
async getThreads(): Promise<MessageThreadItem[]> {
const threads = await this.chatMessagesService.getChatMessages();

View File

@@ -1,109 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type { CvSuggestionInterface } from '../../../mvvm/models/cv-suggestion.interface';
import type { EscoInterface } from '../../../mvvm/models/esco.interface';
import type { JobAgentFilterInterface } from '../../../mvvm/models/job-agent-filter.interface';
import type { PaymentOverview } from '../../../mvvm/models/payment-overview.interface';
import { AiAgentViewModel } from '../../../mvvm/viewmodels/AiAgentViewModel';
interface AiAgentState {
paymentOverview: PaymentOverview | null;
jobAgentFilters: JobAgentFilterInterface[];
cvSuggestions: CvSuggestionInterface[];
escos: EscoInterface[];
}
const INITIAL_STATE: AiAgentState = {
paymentOverview: null,
jobAgentFilters: [],
cvSuggestions: [],
escos: [],
};
export function useAiAgentViewModel() {
const viewModel = useMemo(() => new AiAgentViewModel(), []);
const [data, setData] = useState<AiAgentState>(INITIAL_STATE);
const [isLoading, setIsLoading] = useState(false);
const [isMutating, setIsMutating] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const next = await viewModel.loadInitialData();
setData(next);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load AI Agent data.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
const addEscoToFilter = useCallback(
async (escoId: number) => {
setIsMutating(true);
setError(null);
try {
await viewModel.addEscoToFilter(escoId);
const next = await viewModel.loadInitialData();
setData(next);
} catch (mutationError) {
setError(mutationError instanceof Error ? mutationError.message : 'Could not add AI filter.');
} finally {
setIsMutating(false);
}
},
[viewModel],
);
const removeFilter = useCallback(
async (filterId: number) => {
setIsMutating(true);
setError(null);
try {
await viewModel.removeFilter(filterId);
const next = await viewModel.loadInitialData();
setData(next);
} catch (mutationError) {
setError(mutationError instanceof Error ? mutationError.message : 'Could not remove AI filter.');
} finally {
setIsMutating(false);
}
},
[viewModel],
);
const setFilterVisibility = useCallback(
async (filter: JobAgentFilterInterface, visible: boolean) => {
setIsMutating(true);
setError(null);
try {
await viewModel.setFilterVisibility(filter, visible);
setData((prev) => ({
...prev,
jobAgentFilters: prev.jobAgentFilters.map((existing) =>
existing.id === filter.id ? { ...existing, visible } : existing,
),
}));
} catch (mutationError) {
setError(mutationError instanceof Error ? mutationError.message : 'Could not update AI filter visibility.');
} finally {
setIsMutating(false);
}
},
[viewModel],
);
return {
...data,
isLoading,
isMutating,
error,
load,
addEscoToFilter,
removeFilter,
setFilterVisibility,
getEscoSuggestions: (query: string) => viewModel.getEscoSuggestions(query, data.escos, data.jobAgentFilters),
getSuggestionText: (value: number) => viewModel.getSuggestionText(value),
};
}

View File

@@ -1,264 +1,306 @@
import { useEffect, useMemo, useState } from 'react';
import type { ImprovementInterface } from '../../../mvvm/models/cv-suggestion.interface';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { useAiAgentViewModel } from '../hooks/useAiAgentViewModel';
import {
ArrowRight,
Bot,
MapPin,
Monitor,
PenSquare,
Save,
Sparkles,
Target,
} from 'lucide-react';
import type { EscoInterface } from '../../../mvvm/models/esco.interface';
import type { JobAgentFilterInterface } from '../../../mvvm/models/job-agent-filter.interface';
import { AiAgentViewModel, type AiAgentInitialData } from '../../../mvvm/viewmodels/AiAgentViewModel';
import { JobsPageViewModel, type JobsListItem } from '../../../mvvm/viewmodels/JobsPageViewModel';
import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar';
import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar';
import '../../dashboard/pages/dashboard.css';
import './ai-agent.css';
interface AiAgentPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
activeNavKey?: 'ai-jobagent' | 'ai-agent';
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onOpenJobDetail: (jobId: string, fromJobnet: boolean, returnPage?: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
type ImprovementType = 'education' | 'language' | 'driversLicense' | 'qualification' | 'certificate';
const EMPTY_DATA: AiAgentInitialData = {
paymentOverview: null,
jobAgentFilters: [],
cvSuggestions: [],
escos: [],
};
function iconForImprovement(type: ImprovementType): string {
if (type === 'qualification') {
return '★';
}
if (type === 'driversLicense') {
return '↗';
}
if (type === 'certificate') {
return '✓';
}
if (type === 'education') {
return '▦';
}
return '◉';
function initials(value: string): string {
return value.trim().slice(0, 1).toUpperCase() || 'A';
}
function classForImprovement(type: ImprovementType): string {
if (type === 'qualification') {
return 'ai-notification-card qualification';
}
if (type === 'driversLicense') {
return 'ai-notification-card drivers';
}
if (type === 'certificate') {
return 'ai-notification-card certificate';
}
if (type === 'education') {
return 'ai-notification-card education';
}
return 'ai-notification-card language';
function deriveMatch(index: number): number {
return Math.max(68, 98 - (index * 4));
}
function withImprovementType(value: ImprovementInterface): value is ImprovementInterface & { improvementType: ImprovementType } {
return typeof (value as { improvementType?: string }).improvementType === 'string';
function byLabelEscos(data: EscoInterface[], query: string): EscoInterface[] {
const trimmed = query.trim().toLowerCase();
if (!trimmed) {
return [];
}
return data.filter((item) => item.preferedLabelDa.toLowerCase().includes(trimmed)).slice(0, 8);
}
export function AiAgentPage({ onLogout, onNavigate, activeNavKey = 'ai-agent' }: AiAgentPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [showAddFilter, setShowAddFilter] = useState(false);
const [searchText, setSearchText] = useState('');
const [selectedSuggestionId, setSelectedSuggestionId] = useState<number | null>(null);
const [showAllNotifications, setShowAllNotifications] = useState(false);
const [expandedNotificationKey, setExpandedNotificationKey] = useState<string | null>(null);
const {
paymentOverview,
jobAgentFilters,
cvSuggestions,
isLoading,
isMutating,
error,
load,
addEscoToFilter,
getEscoSuggestions,
getSuggestionText,
} = useAiAgentViewModel();
export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleTheme, theme }: AiAgentPageProps) {
const aiViewModel = useMemo(() => new AiAgentViewModel(), []);
const jobsViewModel = useMemo(() => new JobsPageViewModel(), []);
const [name, setName] = useState('Lasse');
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const [data, setData] = useState<AiAgentInitialData>(EMPTY_DATA);
const [jobs, setJobs] = useState<JobsListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [agentName, setAgentName] = useState('');
const [keywords, setKeywords] = useState('');
const [workArea, setWorkArea] = useState('');
const [workType, setWorkType] = useState('');
const [workLocation, setWorkLocation] = useState('');
const [distance, setDistance] = useState(25);
useEffect(() => {
let active = true;
async function load() {
setIsLoading(true);
const [profile, initialData, jobsData] = await Promise.all([
aiViewModel.getCandidateProfile(),
aiViewModel.loadInitialData(),
jobsViewModel.getTabItems('jobs'),
]);
if (!active) {
return;
}
setName(profile.name);
setImageUrl(profile.imageUrl);
setData(initialData);
setJobs(jobsData);
setIsLoading(false);
}
void load();
}, [load]);
useEffect(() => {
if (!selectedSuggestionId && cvSuggestions.length > 0) {
setSelectedSuggestionId(cvSuggestions[0].escoId);
return () => {
active = false;
};
}, [aiViewModel, jobsViewModel]);
async function refreshAgents() {
const next = await aiViewModel.loadInitialData();
setData(next);
}
async function handleSaveAgent() {
const query = keywords.trim() || agentName.trim() || workArea.trim();
const suggestion = aiViewModel.getEscoSuggestions(query, data.escos, data.jobAgentFilters)[0] || byLabelEscos(data.escos, query)[0];
if (!suggestion) {
return;
}
if (selectedSuggestionId && !cvSuggestions.some((entry) => entry.escoId === selectedSuggestionId)) {
setSelectedSuggestionId(cvSuggestions[0]?.escoId ?? null);
}
}, [selectedSuggestionId, cvSuggestions]);
await aiViewModel.addEscoToFilter(suggestion.id);
await refreshAgents();
const suggestions = useMemo(() => getEscoSuggestions(searchText), [getEscoSuggestions, searchText]);
const selectedSuggestion = useMemo(
() => cvSuggestions.find((entry) => entry.escoId === selectedSuggestionId) ?? cvSuggestions[0] ?? null,
[cvSuggestions, selectedSuggestionId],
);
const improvements = useMemo(() => {
const list = selectedSuggestion?.improvements ?? [];
const typed = list.filter(withImprovementType);
return showAllNotifications ? typed : typed.slice(0, 8);
}, [selectedSuggestion, showAllNotifications]);
const hasMoreNotifications = (selectedSuggestion?.improvements?.length ?? 0) > 8;
const careerAgentEnabled = Boolean(paymentOverview?.careerAgent);
async function handleAddFilter(escoId: number) {
await addEscoToFilter(escoId);
setSearchText('');
setAgentName('');
setKeywords('');
setWorkArea('');
setWorkType('');
setWorkLocation('');
setDistance(25);
}
async function handleToggleVisibility(filter: JobAgentFilterInterface) {
await aiViewModel.setFilterVisibility(filter, !filter.visible);
await refreshAgents();
}
const activeFilters = data.jobAgentFilters;
const recommendedJobs = (jobs.length > 0 ? jobs : []).slice(0, 6);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey={activeNavKey}
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
onSelect={(key) => {
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
onNavigate(key);
}
}}
/>
<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" />
<main className="dashboard-main">
<Topbar title="AI Agent" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<DashboardSidebar active="ai-agent" onNavigate={onNavigate} />
<div className="dashboard-scroll">
<article className="glass-panel dash-card ai-agent-hero">
<h3>Din AI Agent</h3>
<p>
Din AI Agent analyserer dit CV og giver dig anbefalinger til, hvordan du kan forbedre
dit CV og styrke dine jobmuligheder.
</p>
</article>
<main className="dash-main custom-scrollbar ai-agent-main">
<DashboardTopbar
name={name}
imageUrl={imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
/>
{error ? <p className="status error">{error}</p> : null}
<div className="ai-head">
<h1>AI-agenter</h1>
<p>Saet din jobsogning pa autopilot. Lad AI overvage og matche dig med de perfekte jobs.</p>
</div>
<article className="glass-panel dash-card ai-notification-section">
<div className="ai-notification-head">
<h4>Karriereagent</h4>
<strong className="ai-notification-kicker">DIN KARRIEREAGENT FORESLÅR</strong>
<p>Boost din profil ved hjælp af kunstig intelligens. Forslagene er udvalgt til din profil, ud fra 100.000+ jobopslag</p>
<section className="ai-create-card">
<div className="ai-create-title">
<div className="ai-create-icon"><Bot size={20} strokeWidth={1.8} /></div>
<h2>Opret ny AI-agent</h2>
</div>
<div className="ai-form-grid">
<div className="ai-field">
<label>Agentens navn</label>
<input value={agentName} onChange={(event) => setAgentName(event.target.value)} placeholder="F.eks. Frontend Udvikler CPH" />
</div>
<div className="ai-inline-controls">
<button
type="button"
className="primary-btn ai-add-filter-btn"
onClick={() => setShowAddFilter((prev) => !prev)}
disabled={isMutating}
>
{showAddFilter ? 'Luk filter' : '+ Tilføj filter'}
</button>
<div className="ai-field">
<label>Sogetekst / Nogleord</label>
<input value={keywords} onChange={(event) => setKeywords(event.target.value)} placeholder="F.eks. React, TypeScript, Tailwind" />
</div>
{showAddFilter ? (
<div className="ai-filter-search-wrap">
<input
className="field-input ai-filter-search"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
placeholder="Søg stilling (ESCO)..."
/>
{searchText.trim().length > 0 && suggestions.length > 0 ? (
<div className="ai-filter-suggestions glass-panel">
{suggestions.map((esco) => (
<button
key={esco.id}
type="button"
className="ai-filter-suggestion-item"
onClick={() => void handleAddFilter(esco.id)}
disabled={isMutating}
>
{esco.preferedLabelDa}
</button>
))}
</div>
) : null}
<div className="ai-field">
<label>Arbejdsomrade</label>
<select value={workArea} onChange={(event) => setWorkArea(event.target.value)}>
<option value="">Vaelg branche</option>
<option value="IT & Udvikling">IT & Udvikling</option>
<option value="Design & UX">Design & UX</option>
<option value="Salg & Marketing">Salg & Marketing</option>
<option value="HR & Ledelse">HR & Ledelse</option>
</select>
</div>
<div className="ai-field">
<label>Arbejdstype</label>
<select value={workType} onChange={(event) => setWorkType(event.target.value)}>
<option value="">Vaelg type</option>
<option value="Fuldtid">Fuldtid</option>
<option value="Deltid">Deltid</option>
<option value="Freelance">Freelance / Konsulent</option>
<option value="Studiejob">Studiejob</option>
</select>
</div>
<div className="ai-field">
<label>Arbejdssted</label>
<div className="ai-location-wrap">
<MapPin size={16} strokeWidth={1.8} />
<input value={workLocation} onChange={(event) => setWorkLocation(event.target.value)} placeholder="By eller postnummer" />
</div>
) : null}
</div>
{isLoading ? <p>Indlæser AI filtre...</p> : null}
{!isLoading && jobAgentFilters.length === 0 ? (
<p className="helper-text">Ingen aktive AI filtre endnu. Tilføj en stilling for at starte.</p>
) : null}
{!careerAgentEnabled ? (
<p className="helper-text">Denne funktion kræver et aktivt abonnement med Karriereagent.</p>
) : null}
{careerAgentEnabled && cvSuggestions.length > 0 ? (
<div className="ai-notification-source-tabs">
{cvSuggestions.map((suggestion) => (
<button
key={suggestion.escoId}
type="button"
className={selectedSuggestion?.escoId === suggestion.escoId ? 'tab-btn active' : 'tab-btn'}
onClick={() => {
setSelectedSuggestionId(suggestion.escoId);
setShowAllNotifications(false);
}}
>
{suggestion.escoName}
</button>
))}
<div className="ai-field ai-distance-field">
<div className="ai-distance-head">
<label>Maks. distance</label>
<span>{distance} km</span>
</div>
) : null}
<input type="range" min={0} max={100} value={distance} onChange={(event) => setDistance(Number(event.target.value))} />
</div>
</div>
{careerAgentEnabled && cvSuggestions.length === 0 && !isLoading ? (
<p className="helper-text">Systemet beregner stadig dine AI filtre. Kom tilbage om lidt.</p>
) : null}
<div className="ai-create-actions">
<button type="button" onClick={() => void handleSaveAgent()}><Save size={16} strokeWidth={1.8} /> Gem AI-agent</button>
</div>
</section>
<div className="ai-notification-grid">
{improvements.map((improvement) => {
const notificationKey = `${improvement.escoId}-${improvement.improvementType}-${improvement.name}`;
const expanded = expandedNotificationKey === notificationKey;
return (
<article
key={notificationKey}
className={expanded ? `${classForImprovement(improvement.improvementType)} expanded` : classForImprovement(improvement.improvementType)}
role="button"
tabIndex={0}
onClick={() =>
setExpandedNotificationKey((current) => (current === notificationKey ? null : notificationKey))
}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setExpandedNotificationKey((current) => (current === notificationKey ? null : notificationKey));
}
}}
>
<div className="ai-notification-icon" aria-hidden>
{iconForImprovement(improvement.improvementType)}
<section className="ai-agents-section">
<h3>Dine aktive agenter</h3>
<div className="ai-agents-row custom-scrollbar">
{activeFilters.length === 0 ? <p className="dash-loading">Ingen aktive agenter endnu.</p> : null}
{activeFilters.map((filter, index) => (
<article key={filter.id} className="ai-agent-chip-card">
<div className="ai-agent-card-head">
<div className="ai-agent-chip-left">
<div className={`ai-agent-mini-icon ${index % 2 === 0 ? 'teal' : 'indigo'}`}>
{index % 2 === 0 ? <Monitor size={16} strokeWidth={1.8} /> : <PenSquare size={16} strokeWidth={1.8} />}
</div>
<div>
<strong>{improvement.name}</strong>
<p>{getSuggestionText(improvement.jobChanceIncrease)}</p>
<span>+{improvement.jobChanceIncrease}% relevans</span>
<div className={expanded ? 'ai-notification-extra-wrap expanded' : 'ai-notification-extra-wrap'}>
<div className="ai-notification-extra">
<p>{improvement.description || 'Ingen ekstra beskrivelse tilgængelig endnu.'}</p>
{typeof improvement.estimatedDurationMonths === 'number' ? (
<small>Estimeret varighed: {improvement.estimatedDurationMonths} måneder</small>
) : null}
</div>
</div>
<h4>{filter.escoName}</h4>
<p>{filter.isCalculated ? 'Aktiv siden i går' : 'Aktiv'}</p>
</div>
</article>
);
})}
</div>
</div>
<button type="button" className={filter.visible ? 'ai-toggle on' : 'ai-toggle'} onClick={() => void handleToggleVisibility(filter)}>
<span />
</button>
</div>
<div className="ai-tags">
<span>{filter.escoName}</span>
<span>{workLocation || 'København'}</span>
<span>{distance} km</span>
</div>
</article>
))}
</div>
</section>
{hasMoreNotifications ? (
<button
type="button"
className="secondary-btn ai-show-more-btn"
onClick={() => setShowAllNotifications((prev) => !prev)}
<section className="ai-jobs-section">
<div className="ai-jobs-head">
<h3><Sparkles size={16} strokeWidth={1.8} /> Anbefalede jobs til dig</h3>
<span>Opdateret for 5 min siden</span>
</div>
<div className="ai-jobs-grid">
{isLoading ? <p className="dash-loading">Indlaeser anbefalinger...</p> : null}
{!isLoading && recommendedJobs.length === 0 ? <p className="dash-loading">Ingen jobanbefalinger fundet endnu.</p> : null}
{recommendedJobs.map((job, index) => (
<article
key={job.id}
className="ai-job-card"
role="button"
tabIndex={0}
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent')}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
}
}}
>
{showAllNotifications ? 'Vis færre' : 'Vis flere'}
</button>
) : null}
</article>
</div>
<div className={`ai-job-rail ${index % 3 === 2 ? 'indigo' : 'teal'}`} />
<div className="ai-job-top">
{job.companyLogoImage || job.logoUrl
? <img src={job.companyLogoImage || job.logoUrl} alt={job.companyName} className="ai-company-logo" />
: <div className="ai-company-logo-fallback">{initials(job.companyName)}</div>}
<div className="ai-match-col">
<div className="ai-match-pill"><Target size={13} strokeWidth={1.8} /> {deriveMatch(index)}% Match</div>
<small>Via: {activeFilters[0]?.escoName || 'AI-agent'}</small>
</div>
</div>
<div className="ai-job-title-wrap">
<h4>{job.title}</h4>
<p>{job.companyName} {job.address || 'Lokation'}</p>
</div>
<div className="ai-job-tags">
<span>{job.occupationName || 'Frontend'}</span>
<span>{job.fromJobnet ? 'Jobnet' : 'Arbejd.com'}</span>
<span>{job.candidateDistance != null ? `${Math.round(job.candidateDistance)} km` : 'Remote'}</span>
</div>
<div className="ai-job-bottom">
<span>Slået op for nyligt</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
}}
>
Læs mere <ArrowRight size={14} strokeWidth={1.8} />
</button>
</div>
</article>
))}
</div>
</section>
</main>
</section>
);

View File

@@ -0,0 +1,590 @@
.ai-agent-main {
display: flex;
flex-direction: column;
}
.ai-head {
margin-bottom: 20px;
}
.ai-head h1 {
margin: 0 0 8px;
font-size: clamp(2rem, 4vw, 2.9rem);
font-weight: 500;
letter-spacing: -0.03em;
}
.ai-head p {
margin: 0;
color: #6b7280;
font-size: 1.05rem;
}
.theme-dark .ai-head h1,
.theme-dark .ai-head p,
.theme-dark .ai-jobs-head h3,
.theme-dark .ai-agents-section h3,
.theme-dark .ai-create-title h2 {
color: #ffffff;
}
.theme-dark .ai-head p,
.theme-dark .ai-jobs-head span,
.theme-dark .ai-agent-chip-left p,
.theme-dark .ai-job-title-wrap p,
.theme-dark .ai-job-bottom span {
color: #9ca3af;
}
.ai-create-card {
margin-bottom: 26px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.03);
padding: 24px;
}
.theme-dark .ai-create-card,
.theme-dark .ai-agent-chip-card,
.theme-dark .ai-job-card {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.ai-create-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.ai-create-icon {
width: 40px;
height: 40px;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #ccfbf1;
color: #0f766e;
display: grid;
place-items: center;
}
.ai-create-title h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 500;
}
.ai-form-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.ai-field {
display: grid;
gap: 6px;
}
.ai-field label {
margin-left: 4px;
font-size: 0.83rem;
font-weight: 500;
color: #374151;
}
.theme-dark .ai-field label,
.theme-dark .ai-distance-head label {
color: #d1d5db;
}
.ai-field input,
.ai-field select {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.5);
padding: 10px 12px;
font-size: 0.84rem;
color: #111827;
}
.theme-dark .ai-field input,
.theme-dark .ai-field select,
.theme-dark .ai-location-wrap input {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
}
.ai-field input:focus,
.ai-field select:focus {
outline: none;
border-color: rgba(45, 212, 191, 0.9);
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.1);
}
.ai-location-wrap {
position: relative;
}
.ai-location-wrap svg {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
}
.ai-location-wrap input {
padding-left: 34px;
}
.ai-distance-field {
align-content: center;
}
.ai-distance-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.ai-distance-head span {
font-size: 0.72rem;
color: #0f766e;
border: 1px solid #ccfbf1;
border-radius: 8px;
background: #f0fdfa;
padding: 3px 8px;
font-weight: 500;
}
.ai-distance-field input[type='range'] {
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.9);
}
.ai-distance-field input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: #14b8a6;
border-radius: 50%;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.ai-distance-field input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
background: #14b8a6;
border-radius: 50%;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.ai-create-actions {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.ai-create-actions button {
border: 0;
border-radius: 12px;
background: #111827;
color: #fff;
padding: 10px 16px;
font-size: 0.84rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.ai-create-actions button:hover {
background: #1f2937;
}
.theme-dark .ai-create-actions button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ai-agents-section {
margin-bottom: 20px;
}
.ai-agents-section h3 {
margin: 0 0 10px;
padding-left: 4px;
font-size: 1.08rem;
font-weight: 500;
}
.ai-agents-row {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px;
}
.ai-agent-chip-card {
min-width: 280px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 16px;
padding: 14px;
position: relative;
overflow: hidden;
}
.ai-agent-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.ai-agent-chip-left {
display: flex;
align-items: center;
gap: 8px;
}
.ai-agent-mini-icon {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
color: #fff;
}
.ai-agent-mini-icon.teal {
background: #14b8a6;
}
.ai-agent-mini-icon.indigo {
background: #6366f1;
}
.ai-agent-chip-left h4 {
margin: 0;
font-size: 0.84rem;
font-weight: 500;
}
.ai-agent-chip-left p {
margin: 1px 0 0;
font-size: 0.72rem;
color: #6b7280;
}
.ai-toggle {
width: 40px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(156, 163, 175, 0.3);
background: #d1d5db;
padding: 0;
position: relative;
cursor: pointer;
}
.ai-toggle span {
width: 14px;
height: 14px;
border-radius: 999px;
background: #fff;
position: absolute;
left: 2px;
top: 2px;
transition: transform 0.2s ease;
}
.ai-toggle.on {
background: #14b8a6;
}
.ai-toggle.on span {
transform: translateX(20px);
}
.ai-tags {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ai-tags span {
font-size: 0.68rem;
color: #4b5563;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(229, 231, 235, 0.85);
padding: 3px 8px;
}
.theme-dark .ai-agent-chip-left h4,
.theme-dark .ai-job-title-wrap h4 {
color: #ffffff;
}
.theme-dark .ai-tags span,
.theme-dark .ai-job-tags span {
color: #d1d5db;
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.ai-jobs-head {
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ai-jobs-head h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 1.08rem;
font-weight: 500;
}
.ai-jobs-head h3 svg {
color: #14b8a6;
}
.ai-jobs-head span {
color: #6b7280;
font-size: 0.7rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.5);
padding: 5px 8px;
}
.ai-jobs-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
margin-bottom: 10px;
}
.ai-job-card {
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 22px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.03);
padding: 16px;
display: flex;
flex-direction: column;
cursor: pointer;
}
.ai-job-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.07);
}
.ai-job-card:focus-visible {
outline: 2px solid rgba(20, 184, 166, 0.45);
outline-offset: 2px;
}
.ai-job-rail {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 100%;
}
.ai-job-rail.teal {
background: rgba(20, 184, 166, 0.2);
}
.ai-job-rail.indigo {
background: rgba(99, 102, 241, 0.2);
}
.ai-job-top {
padding-right: 10px;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.ai-company-logo,
.ai-company-logo-fallback {
width: 46px;
height: 46px;
border-radius: 10px;
object-fit: cover;
border: 1px solid rgba(229, 231, 235, 0.85);
background: #fff;
}
.ai-company-logo-fallback {
display: grid;
place-items: center;
color: #111827;
font-weight: 600;
}
.ai-match-col {
display: grid;
justify-items: end;
gap: 4px;
}
.ai-match-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 8px;
border: 1px solid #ccfbf1;
background: #f0fdfa;
color: #0f766e;
padding: 4px 8px;
font-size: 0.7rem;
font-weight: 500;
}
.ai-match-col small {
font-size: 0.62rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-job-title-wrap {
margin-bottom: 10px;
}
.ai-job-title-wrap h4 {
margin: 0;
font-size: 0.94rem;
font-weight: 500;
color: #111827;
}
.ai-job-title-wrap p {
margin: 2px 0 0;
color: #6b7280;
font-size: 0.78rem;
}
.ai-job-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.ai-job-tags span {
font-size: 0.66rem;
color: #4b5563;
border: 1px solid rgba(229, 231, 235, 0.85);
background: rgba(255, 255, 255, 0.82);
border-radius: 8px;
padding: 4px 8px;
}
.ai-job-bottom {
margin-top: auto;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.82);
display: flex;
justify-content: space-between;
align-items: center;
}
.theme-dark .ai-job-bottom {
border-top-color: rgba(255, 255, 255, 0.08);
}
.ai-job-bottom span {
font-size: 0.66rem;
color: #9ca3af;
}
.ai-job-bottom button {
border: 0;
background: transparent;
color: #111827;
font-size: 0.78rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.ai-job-bottom button:hover {
color: #0f766e;
}
.theme-dark .ai-job-bottom button {
color: #f3f4f6;
}
.theme-dark .ai-job-bottom button:hover {
color: #2dd4bf;
}
@media (max-width: 1200px) {
.ai-form-grid {
grid-template-columns: 1fr 1fr;
}
.ai-jobs-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 860px) {
.ai-form-grid {
grid-template-columns: 1fr;
}
.ai-create-actions {
justify-content: stretch;
}
.ai-create-actions button {
width: 100%;
justify-content: center;
}
.ai-jobs-head {
flex-direction: column;
align-items: flex-start;
}
.ai-jobs-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,909 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import type { NotificationInterface } from '../../../mvvm/models/notification.interface';
import type { NotificationSettingInterface } from '../../../mvvm/models/notification-setting.interface';
import type { OccupationCategorizationInterface, SubAreaInterface } from '../../../mvvm/models/occupation-categorization.interface';
import type { OccupationInterface } from '../../../mvvm/models/occupation.interface';
import { AiJobAgentViewModel } from '../../../mvvm/viewmodels/AiJobAgentViewModel';
interface AiJobAgentPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
onOpenJob: (jobId: string, fromJobnet: boolean) => void;
}
type OccupationTree = OccupationCategorizationInterface[];
const PAGE_LIMIT = 20;
function createEmptySetting(): NotificationSettingInterface {
return {
id: null,
jobAgentName: '',
workTimeDay: false,
workTimeEvening: false,
workTimeNight: false,
workTimeWeekend: false,
workTypePermanent: false,
workTypeFreelance: false,
workTypePartTime: false,
workTypeSubstitute: false,
workTypeTemporary: false,
workDistance: 50,
distanceCenterName: '',
latitude: null,
longitude: null,
partTimeHours: null,
notifyOnPush: false,
notifyOnSms: false,
searchText: '',
escoIds: [],
};
}
function normalizeSetting(setting: NotificationSettingInterface): NotificationSettingInterface {
return {
...createEmptySetting(),
...setting,
id: setting.id ?? null,
jobAgentName: setting.jobAgentName ?? '',
distanceCenterName: setting.distanceCenterName ?? '',
searchText: setting.searchText ?? '',
escoIds: Array.isArray(setting.escoIds) ? setting.escoIds : [],
workDistance: typeof setting.workDistance === 'number' ? setting.workDistance : 50,
};
}
function mapTree(source: OccupationCategorizationInterface[]): OccupationTree {
return source.map((area) => ({
...area,
expanded: Boolean(area.expanded),
activated: Boolean(area.activated),
someIsActive: Boolean(area.someIsActive),
subAreas: area.subAreas.map((subArea) => ({
...subArea,
expanded: Boolean(subArea.expanded),
activated: Boolean(subArea.activated),
someIsActive: Boolean(subArea.someIsActive),
occupations: subArea.occupations.map((occupation) => ({
...occupation,
activated: Boolean(occupation.activated),
})),
})),
}));
}
function applySelectedEscos(tree: OccupationTree, escoIds: number[]): OccupationTree {
const selected = new Set(escoIds);
return tree.map((area) => {
const subAreas = area.subAreas.map((subArea) => {
const occupations = subArea.occupations.map((occupation) => ({
...occupation,
activated: selected.has(occupation.id),
}));
const activeCount = occupations.filter((occ) => occ.activated).length;
return {
...subArea,
occupations,
activated: activeCount > 0 && activeCount === occupations.length,
someIsActive: activeCount > 0 && activeCount < occupations.length,
};
});
const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated);
const someActive = subAreas.some((item) => item.activated || item.someIsActive);
return {
...area,
subAreas,
activated: allActive,
someIsActive: someActive && !allActive,
};
});
}
function collectSelectedEscos(tree: OccupationTree): number[] {
const ids: number[] = [];
for (const area of tree) {
for (const subArea of area.subAreas) {
for (const occupation of subArea.occupations) {
if (occupation.activated) {
ids.push(occupation.id);
}
}
}
}
return ids;
}
function formatDate(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleDateString('da-DK', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function companyInitial(value: string | null | undefined): string {
if (!value?.trim()) {
return 'Ar';
}
return value.trim().slice(0, 1).toUpperCase();
}
function hasAnyActiveJobAgent(settings: NotificationSettingInterface[]): boolean {
return settings.some((setting) => (setting.escoIds?.length ?? 0) > 0);
}
export function AiJobAgentPage({ onLogout, onNavigate, onOpenJob }: AiJobAgentPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [showSettings, setShowSettings] = useState(false);
const [showWorkAreas, setShowWorkAreas] = useState(false);
const [isLoadingSettings, setIsLoadingSettings] = useState(true);
const [isLoadingNotifications, setIsLoadingNotifications] = useState(true);
const [isLoadingMoreNotifications, setIsLoadingMoreNotifications] = useState(false);
const [isSavingSetting, setIsSavingSetting] = useState(false);
const [isSearchingPlaces, setIsSearchingPlaces] = useState(false);
const [hasMoreNotifications, setHasMoreNotifications] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notificationSettings, setNotificationSettings] = useState<NotificationSettingInterface[]>([]);
const [notifications, setNotifications] = useState<NotificationInterface[]>([]);
const [selectedJobAgentId, setSelectedJobAgentId] = useState<number | 'new'>('new');
const [editingSetting, setEditingSetting] = useState<NotificationSettingInterface>(createEmptySetting());
const [occupationTree, setOccupationTree] = useState<OccupationTree>([]);
const [searchOccupationWord, setSearchOccupationWord] = useState('');
const [placeSuggestions, setPlaceSuggestions] = useState<Array<{ place_id?: string; description?: string }>>([]);
const viewModel = useMemo(() => new AiJobAgentViewModel(), []);
useEffect(() => {
let active = true;
async function bootstrap() {
setError(null);
setIsLoadingSettings(true);
setIsLoadingNotifications(true);
try {
const [settings, rawTree, firstNotifications] = await Promise.all([
viewModel.getNotificationSettings(),
viewModel.getOccupationTree(),
viewModel.getNotifications(0, PAGE_LIMIT),
]);
if (!active) {
return;
}
const normalizedSettings = settings.map(normalizeSetting);
const mappedTree = mapTree(rawTree);
const initialSetting = normalizedSettings[0] ?? createEmptySetting();
const initialId = normalizedSettings[0]?.id ?? 'new';
setNotificationSettings(normalizedSettings);
setSelectedJobAgentId(initialId);
setEditingSetting(initialSetting);
setOccupationTree(applySelectedEscos(mappedTree, initialSetting.escoIds));
setNotifications(firstNotifications);
setHasMoreNotifications(firstNotifications.length === PAGE_LIMIT);
} catch (loadError) {
if (!active) {
return;
}
setError(loadError instanceof Error ? loadError.message : 'Kunne ikke indlæse AI JobAgent.');
} finally {
if (active) {
setIsLoadingSettings(false);
setIsLoadingNotifications(false);
}
}
}
void bootstrap();
return () => {
active = false;
};
}, [viewModel]);
useEffect(() => {
const query = editingSetting.distanceCenterName?.trim() ?? '';
if (!showSettings || query.length < 3) {
setPlaceSuggestions([]);
return;
}
const timeout = window.setTimeout(() => {
setIsSearchingPlaces(true);
void viewModel
.searchPlaces(query)
.then((suggestions) => {
setPlaceSuggestions(suggestions);
})
.catch(() => {
setPlaceSuggestions([]);
})
.finally(() => setIsSearchingPlaces(false));
}, 350);
return () => window.clearTimeout(timeout);
}, [editingSetting.distanceCenterName, showSettings, viewModel]);
const filteredOccupations = useMemo(() => {
const query = searchOccupationWord.trim().toLowerCase();
if (!query) {
return [];
}
const list: Array<{ areaCode: number; subAreaCode: number; occupation: OccupationInterface }> = [];
for (const area of occupationTree) {
for (const subArea of area.subAreas) {
for (const occupation of subArea.occupations) {
if (occupation.name.toLowerCase().includes(query)) {
list.push({ areaCode: area.areaCode, subAreaCode: subArea.subAreaCode, occupation });
}
}
}
}
return list.slice(0, 40);
}, [occupationTree, searchOccupationWord]);
const selectedEscoCount = useMemo(() => collectSelectedEscos(occupationTree).length, [occupationTree]);
const unseenCount = useMemo(
() => notifications.filter((notification) => !notification.seenByUser).length,
[notifications],
);
function selectJobAgent(value: number | 'new') {
setSelectedJobAgentId(value);
setShowWorkAreas(false);
setSearchOccupationWord('');
if (value === 'new') {
const empty = createEmptySetting();
setEditingSetting(empty);
setOccupationTree((prev) => applySelectedEscos(prev, []));
return;
}
const setting = notificationSettings.find((item) => item.id === value);
const normalized = normalizeSetting(setting ?? createEmptySetting());
setEditingSetting(normalized);
setOccupationTree((prev) => applySelectedEscos(prev, normalized.escoIds));
}
function updateTreeByArea(areaCode: number, active: boolean) {
setOccupationTree((prev) =>
prev.map((area) => {
if (area.areaCode !== areaCode) {
return area;
}
const subAreas = area.subAreas.map((subArea) => ({
...subArea,
activated: active,
someIsActive: false,
occupations: subArea.occupations.map((occupation) => ({
...occupation,
activated: active,
})),
}));
return {
...area,
subAreas,
activated: active,
someIsActive: false,
};
}),
);
}
function updateTreeBySubArea(areaCode: number, subAreaCode: number, active: boolean) {
setOccupationTree((prev) =>
prev.map((area) => {
if (area.areaCode !== areaCode) {
return area;
}
const subAreas = area.subAreas.map((subArea) => {
if (subArea.subAreaCode !== subAreaCode) {
return subArea;
}
return {
...subArea,
activated: active,
someIsActive: false,
occupations: subArea.occupations.map((occupation) => ({
...occupation,
activated: active,
})),
};
});
const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated);
const someActive = subAreas.some((item) => item.activated || item.someIsActive);
return {
...area,
subAreas,
activated: allActive,
someIsActive: someActive && !allActive,
};
}),
);
}
function updateTreeByOccupation(areaCode: number, subAreaCode: number, occupationId: number) {
setOccupationTree((prev) =>
prev.map((area) => {
if (area.areaCode !== areaCode) {
return area;
}
const subAreas = area.subAreas.map((subArea) => {
if (subArea.subAreaCode !== subAreaCode) {
return subArea;
}
const occupations = subArea.occupations.map((occupation) =>
occupation.id === occupationId
? { ...occupation, activated: !occupation.activated }
: occupation,
);
const activeCount = occupations.filter((occupation) => occupation.activated).length;
return {
...subArea,
occupations,
activated: activeCount > 0 && activeCount === occupations.length,
someIsActive: activeCount > 0 && activeCount < occupations.length,
};
});
const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated);
const someActive = subAreas.some((item) => item.activated || item.someIsActive);
return {
...area,
subAreas,
activated: allActive,
someIsActive: someActive && !allActive,
};
}),
);
}
async function saveSetting() {
if (!editingSetting.jobAgentName?.trim()) {
return;
}
setIsSavingSetting(true);
setError(null);
try {
const payload: NotificationSettingInterface = {
...editingSetting,
jobAgentName: editingSetting.jobAgentName.trim(),
searchText: editingSetting.searchText?.trim() || null,
distanceCenterName: editingSetting.distanceCenterName?.trim() || null,
escoIds: collectSelectedEscos(occupationTree),
};
await viewModel.saveNotificationSetting(selectedJobAgentId, payload);
const settings = (await viewModel.getNotificationSettings()).map(normalizeSetting);
setNotificationSettings(settings);
if (selectedJobAgentId === 'new') {
const created = settings.find((item) => item.jobAgentName === payload.jobAgentName) ?? settings[0];
if (created?.id != null) {
setSelectedJobAgentId(created.id);
setEditingSetting(created);
setOccupationTree((prev) => applySelectedEscos(prev, created.escoIds));
}
} else {
const updated = settings.find((item) => item.id === selectedJobAgentId);
if (updated) {
setEditingSetting(updated);
setOccupationTree((prev) => applySelectedEscos(prev, updated.escoIds));
}
}
setShowSettings(false);
setShowWorkAreas(false);
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Kunne ikke gemme jobagent indstillinger.');
} finally {
setIsSavingSetting(false);
}
}
async function deleteCurrentSetting() {
if (selectedJobAgentId === 'new') {
return;
}
setIsSavingSetting(true);
setError(null);
try {
await viewModel.deleteNotificationSetting(selectedJobAgentId);
const settings = (await viewModel.getNotificationSettings()).map(normalizeSetting);
setNotificationSettings(settings);
const next = settings[0] ?? createEmptySetting();
setSelectedJobAgentId(settings[0]?.id ?? 'new');
setEditingSetting(next);
setOccupationTree((prev) => applySelectedEscos(prev, next.escoIds));
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : 'Kunne ikke slette jobagent.');
} finally {
setIsSavingSetting(false);
}
}
async function selectPlaceSuggestion(placeId: string) {
const details = await viewModel.getPlaceDetails(placeId);
if (!details) {
return;
}
setEditingSetting((prev) => ({
...prev,
distanceCenterName: details.address,
latitude: details.latitude,
longitude: details.longitude,
}));
setPlaceSuggestions([]);
}
async function openNotificationJob(notification: NotificationInterface) {
if (!notification.seenByUser) {
void viewModel.markNotificationSeen(notification.id);
setNotifications((prev) =>
prev.map((item) => (item.id === notification.id ? { ...item, seenByUser: true } : item)),
);
}
const fromJobnet = Boolean(notification.jobnetPostingId);
const jobId = fromJobnet ? notification.jobnetPostingId : notification.jobPostingId;
if (jobId) {
onOpenJob(jobId, fromJobnet);
}
}
async function toggleNotificationBookmark(notification: NotificationInterface) {
const save = !notification.saved;
try {
await viewModel.toggleNotificationBookmark(notification, save);
setNotifications((prev) =>
prev.map((item) => (item.id === notification.id ? { ...item, saved: save } : item)),
);
} catch {
setError('Kunne ikke opdatere gemt status på notifikation.');
}
}
async function loadMoreNotifications() {
if (!hasMoreNotifications || isLoadingMoreNotifications) {
return;
}
setIsLoadingMoreNotifications(true);
setError(null);
try {
const next = await viewModel.getNotifications(notifications.length, PAGE_LIMIT);
setNotifications((prev) => [...prev, ...next]);
setHasMoreNotifications(next.length === PAGE_LIMIT);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Kunne ikke indlæse flere notifikationer.');
} finally {
setIsLoadingMoreNotifications(false);
}
}
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="ai-jobagent"
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
onSelect={(key) => {
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
onNavigate(key);
}
}}
/>
<main className="dashboard-main">
<Topbar title="AI JobAgent" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
{error ? <p className="status error">{error}</p> : null}
<article className="glass-panel dash-card jobagent-hero-card">
<div className="jobagent-hero-top">
<div>
<span className="jobagent-kicker">AI JobAgent</span>
<h3>Automatisk jobmatch med dit CV</h3>
<p>
Jobagenten følger nye opslag og fremhæver relevante job baseret dine valgte områder,
arbejdstype og afstand.
</p>
</div>
<button
type="button"
className="primary-btn"
onClick={() => setShowSettings((prev) => !prev)}
disabled={isLoadingSettings}
>
{showSettings ? 'Luk indstillinger' : 'Åbn indstillinger'}
</button>
</div>
{!isLoadingSettings ? (
<div className="jobagent-hero-metrics">
<div className="jobagent-metric-pill">
<span>Jobagenter</span>
<strong>{notificationSettings.length}</strong>
</div>
<div className="jobagent-metric-pill">
<span>Aktive filtre</span>
<strong>{selectedEscoCount}</strong>
</div>
<div className="jobagent-metric-pill">
<span>Nye notifikationer</span>
<strong>{unseenCount}</strong>
</div>
<p className="jobagent-summary-note">
{hasAnyActiveJobAgent(notificationSettings)
? 'Mindst én jobagent er aktiv.'
: 'Vælg stillingstyper og områder for at aktivere en jobagent.'}
</p>
</div>
) : (
<p className="jobagent-summary-note">Indlæser jobagenter...</p>
)}
</article>
<section className="jobagent-layout-grid">
{showSettings ? (
<article className="glass-panel dash-card jobagent-settings-card">
<div className="jobagent-settings-top">
<div>
<span className="jobagent-kicker">Opsaetning</span>
<h4>Jobagent indstillinger</h4>
</div>
<div className="jobagent-settings-actions">
{selectedJobAgentId !== 'new' ? (
<button type="button" className="secondary-btn danger" onClick={() => void deleteCurrentSetting()} disabled={isSavingSetting}>
Slet
</button>
) : null}
<button type="button" className="primary-btn" onClick={() => void saveSetting()} disabled={isSavingSetting || !editingSetting.jobAgentName?.trim()}>
{isSavingSetting ? 'Gemmer...' : 'Gem'}
</button>
</div>
</div>
<div className="jobagent-settings-grid">
<label className="jobagent-field">
<span>Vælg jobagent</span>
<select
className="field-input"
value={selectedJobAgentId === 'new' ? 'new' : String(selectedJobAgentId)}
onChange={(event) => {
const value = event.target.value;
selectJobAgent(value === 'new' ? 'new' : Number(value));
}}
>
{notificationSettings.map((setting) => (
<option key={setting.id ?? Math.random()} value={String(setting.id)}>
{setting.jobAgentName?.trim() || 'Uden navn'}
</option>
))}
<option value="new">Opret ny jobagent</option>
</select>
</label>
<label className="jobagent-field">
<span>Jobagent navn</span>
<input
className="field-input"
value={editingSetting.jobAgentName ?? ''}
onChange={(event) => setEditingSetting((prev) => ({ ...prev, jobAgentName: event.target.value }))}
placeholder="Fx. Min jobagent"
/>
</label>
<label className="jobagent-field">
<span>Søgetekst</span>
<input
className="field-input"
value={editingSetting.searchText ?? ''}
onChange={(event) => setEditingSetting((prev) => ({ ...prev, searchText: event.target.value }))}
placeholder="Fritekst til søgning"
/>
</label>
<div className="jobagent-field">
<span>Arbejdsområder</span>
<button type="button" className="secondary-btn" onClick={() => setShowWorkAreas((prev) => !prev)}>
{showWorkAreas ? 'Luk arbejdsområder' : 'Åbn arbejdsområder'}
</button>
</div>
<div className="jobagent-field">
<span>Arbejdstype</span>
<div className="jobagent-toggle-row">
<button
type="button"
className={editingSetting.workTypePermanent ? 'tab-btn active' : 'tab-btn'}
onClick={() => setEditingSetting((prev) => ({ ...prev, workTypePermanent: !prev.workTypePermanent }))}
>
Fast
</button>
<button
type="button"
className={editingSetting.workTypePartTime ? 'tab-btn active' : 'tab-btn'}
onClick={() => setEditingSetting((prev) => ({ ...prev, workTypePartTime: !prev.workTypePartTime }))}
>
Deltid
</button>
</div>
{editingSetting.workTypePartTime ? (
<input
type="number"
min={0}
max={37}
className="field-input"
value={editingSetting.partTimeHours ?? ''}
onChange={(event) =>
setEditingSetting((prev) => ({
...prev,
partTimeHours: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="Timer pr. uge"
/>
) : null}
</div>
<div className="jobagent-field jobagent-location-field">
<span>Center for afstand</span>
<input
className="field-input"
value={editingSetting.distanceCenterName ?? ''}
onChange={(event) =>
setEditingSetting((prev) => ({
...prev,
distanceCenterName: event.target.value,
latitude: null,
longitude: null,
}))
}
placeholder="Søg adresse"
/>
{isSearchingPlaces ? <small>Søger adresser...</small> : null}
{placeSuggestions.length > 0 ? (
<div className="jobagent-place-suggestions glass-panel">
{placeSuggestions.map((suggestion) => (
<button
key={`${suggestion.place_id}-${suggestion.description}`}
type="button"
onClick={() => suggestion.place_id && void selectPlaceSuggestion(suggestion.place_id)}
>
{suggestion.description}
</button>
))}
</div>
) : null}
</div>
<div className="jobagent-field jobagent-distance-field">
<span>Arbejdsafstand: {editingSetting.workDistance ?? 50} km</span>
<input
type="range"
min={0}
max={500}
value={editingSetting.workDistance ?? 50}
onChange={(event) => setEditingSetting((prev) => ({ ...prev, workDistance: Number(event.target.value) }))}
/>
</div>
</div>
{showWorkAreas ? (
<section className="jobagent-workareas-card">
<div className="jobagent-workareas-top">
<h5>Arbejdsområder</h5>
<span>{selectedEscoCount} valgt</span>
</div>
<div className="jobagent-workareas">
<input
className="field-input"
value={searchOccupationWord}
onChange={(event) => setSearchOccupationWord(event.target.value)}
placeholder="Søg arbejdsområde"
/>
{searchOccupationWord.trim() && filteredOccupations.length > 0 ? (
<div className="jobagent-workareas-search-list">
{filteredOccupations.map((entry) => (
<label key={`${entry.areaCode}-${entry.subAreaCode}-${entry.occupation.id}`} className="jobagent-checkline">
<input
type="checkbox"
checked={Boolean(entry.occupation.activated)}
onChange={() => updateTreeByOccupation(entry.areaCode, entry.subAreaCode, entry.occupation.id)}
/>
<span>{entry.occupation.name}</span>
</label>
))}
</div>
) : (
<div className="jobagent-workareas-tree">
{occupationTree.map((area) => (
<div key={area.areaCode} className="jobagent-workarea-node">
<div className="jobagent-workarea-row">
<button type="button" className="jobagent-expand-btn" onClick={() =>
setOccupationTree((prev) => prev.map((item) => item.areaCode === area.areaCode ? { ...item, expanded: !item.expanded } : item))
}>
{area.expanded ? '▾' : '▸'}
</button>
<label className="jobagent-checkline">
<input
type="checkbox"
checked={area.activated || area.someIsActive}
onChange={() => updateTreeByArea(area.areaCode, !(area.activated || area.someIsActive))}
/>
<span>{area.areaName}</span>
</label>
</div>
{area.expanded ? (
<div className="jobagent-workarea-children">
{area.subAreas.map((subArea: SubAreaInterface) => (
<div key={`${area.areaCode}-${subArea.subAreaCode}`} className="jobagent-workarea-node">
<div className="jobagent-workarea-row">
<button type="button" className="jobagent-expand-btn" onClick={() =>
setOccupationTree((prev) =>
prev.map((item) =>
item.areaCode === area.areaCode
? {
...item,
subAreas: item.subAreas.map((child) =>
child.subAreaCode === subArea.subAreaCode ? { ...child, expanded: !child.expanded } : child,
),
}
: item,
),
)
}>
{subArea.expanded ? '▾' : '▸'}
</button>
<label className="jobagent-checkline">
<input
type="checkbox"
checked={subArea.activated || subArea.someIsActive}
onChange={() => updateTreeBySubArea(area.areaCode, subArea.subAreaCode, !(subArea.activated || subArea.someIsActive))}
/>
<span>{subArea.subAreaName}</span>
</label>
</div>
{subArea.expanded ? (
<div className="jobagent-workarea-children">
{subArea.occupations.map((occupation) => (
<label key={occupation.id} className="jobagent-checkline">
<input
type="checkbox"
checked={Boolean(occupation.activated)}
onChange={() => updateTreeByOccupation(area.areaCode, subArea.subAreaCode, occupation.id)}
/>
<span>{occupation.name}</span>
</label>
))}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
))}
</div>
)}
</div>
</section>
) : null}
</article>
) : null}
<article className="glass-panel dash-card jobagent-notifications-card">
<div className="jobagent-notifications-top">
<div>
<span className="jobagent-kicker">Live feed</span>
<h4>Notifikationer</h4>
</div>
<span className="jobagent-notification-count">{notifications.length}</span>
</div>
{isLoadingNotifications ? <p>Indlæser notifikationer...</p> : null}
{!isLoadingNotifications && notifications.length === 0 ? (
<p>Ingen notifikationer endnu.</p>
) : null}
<div className="jobagent-notification-list">
<div className="jobs-results-grid jobagent-results-grid">
{notifications.map((notification) => (
<article
key={notification.id}
className={notification.seenByUser ? 'glass-panel jobs-result-card jobagent-result-card' : 'glass-panel jobs-result-card jobagent-result-card unseen'}
role="button"
tabIndex={0}
onClick={() => void openNotificationJob(notification)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void openNotificationJob(notification);
}
}}
>
<div className="jobs-result-top">
<div className="jobs-result-brand">
{notification.logoUrl ? (
<img className="jobs-result-logo-img" src={notification.logoUrl} alt={notification.companyName || 'Virksomhed'} />
) : (
<div className="jobs-result-logo">{companyInitial(notification.companyName)}</div>
)}
<div>
<p className="jobs-result-company">{notification.companyName || 'Ukendt virksomhed'}</p>
<p className="jobs-result-address">
{notification.city ? `${notification.city} ${notification.zip || ''}` : 'Ukendt lokation'}
</p>
</div>
</div>
<button
type="button"
className={notification.saved ? 'jobs-bookmark-icon active' : 'jobs-bookmark-icon'}
onMouseDown={(event) => event.stopPropagation()}
onClickCapture={(event) => event.stopPropagation()}
onClick={() => void toggleNotificationBookmark(notification)}
aria-label={notification.saved ? 'Fjern gemt job' : 'Gem job'}
title={notification.saved ? 'Fjern gemt' : 'Gem job'}
>
{notification.saved ? '★' : '☆'}
</button>
</div>
<h5 className="jobs-result-title">{notification.jobTitle || 'Jobagent match'}</h5>
<p className="jobs-result-occupation">{notification.escoTitle || 'AI JobAgent forslag'}</p>
<p className="jobs-result-description">
{notification.seenByUser ? 'Match fra din jobagent baseret på dine valgte filtre.' : 'Nyt match fra din jobagent.'}
</p>
<div className="jobs-result-tags">
<span className="chip">{formatDate(notification.notificationDate)}</span>
{notification.distance ? <span className="chip">{Number(notification.distance).toFixed(1)} km</span> : null}
<span className="chip">{notification.seenByUser ? 'Set' : 'Ny'}</span>
</div>
<div className="jobs-result-footer">
<button
type="button"
className="primary-btn jobs-card-primary-btn"
onClick={(event) => {
event.stopPropagation();
void openNotificationJob(notification);
}}
>
Åbn job
</button>
</div>
</article>
))}
</div>
</div>
{hasMoreNotifications ? (
<button type="button" className="secondary-btn jobagent-load-more" onClick={() => void loadMoreNotifications()} disabled={isLoadingMoreNotifications}>
{isLoadingMoreNotifications ? 'Indlæser...' : 'Indlæs flere'}
</button>
) : null}
</article>
</section>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,20 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
interface AuthInputProps extends InputHTMLAttributes<HTMLInputElement> {
icon: ReactNode;
label: string;
}
export function AuthInput({ icon, label, ...inputProps }: AuthInputProps) {
return (
<label className="auth-field">
<span>{label}</span>
<div className="auth-input-wrap">
<span className="auth-input-icon" aria-hidden>
{icon}
</span>
<input {...inputProps} />
</div>
</label>
);
}

View File

@@ -0,0 +1,47 @@
import type { FormEvent } from 'react';
import { Mail } from 'lucide-react';
import { AuthInput } from './AuthInput';
interface ForgotPasswordViewProps {
email: string;
loading: boolean;
onBackToLogin: () => void;
onChangeEmail: (value: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
export function ForgotPasswordView({
email,
loading,
onBackToLogin,
onChangeEmail,
onSubmit,
}: ForgotPasswordViewProps) {
return (
<div className="auth-view view-enter">
<div className="auth-head">
<button className="link-btn back-link" type="button" onClick={onBackToLogin}>
Tilbage
</button>
<h1>Glemt kodeord?</h1>
<p>Indtast din e-mail, sa sender vi instruktioner til at nulstille din kode.</p>
</div>
<form className="auth-form" onSubmit={onSubmit}>
<AuthInput
icon={<Mail size={16} strokeWidth={1.8} />}
label="E-mail"
type="email"
placeholder="navn@eksempel.dk"
value={email}
onChange={(event) => onChangeEmail(event.target.value)}
required
/>
<button className="submit-btn" type="submit" disabled={loading}>
{loading ? 'Sender...' : 'Send nulstillingslink'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import type { FormEvent } from 'react';
import { LockKeyhole, Mail } from 'lucide-react';
import { AuthInput } from './AuthInput';
interface LoginViewProps {
email: string;
loading: boolean;
onChangeEmail: (value: string) => void;
onChangePassword: (value: string) => void;
onChangeRememberMe: (value: boolean) => void;
onForgotPassword: () => void;
onRegister: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
password: string;
rememberMe: boolean;
}
export function LoginView({
email,
loading,
onChangeEmail,
onChangePassword,
onChangeRememberMe,
onForgotPassword,
onRegister,
onSubmit,
password,
rememberMe,
}: LoginViewProps) {
return (
<div className="auth-view view-enter">
<div className="auth-head auth-head-center">
<h1>Velkommen tilbage</h1>
<p>Indtast dine oplysninger for at logge ind pa din konto.</p>
</div>
<form className="auth-form" onSubmit={onSubmit}>
<AuthInput
icon={<Mail size={16} strokeWidth={1.8} />}
label="E-mail"
type="email"
placeholder="navn@eksempel.dk"
value={email}
onChange={(event) => onChangeEmail(event.target.value)}
required
/>
<label className="auth-field">
<div className="auth-field-row">
<span>Adgangskode</span>
<button className="link-btn" type="button" onClick={onForgotPassword}>
Glemt adgangskode?
</button>
</div>
<div className="auth-input-wrap">
<span className="auth-input-icon" aria-hidden>
<LockKeyhole size={16} strokeWidth={1.8} />
</span>
<input
type="password"
placeholder="••••••••"
value={password}
onChange={(event) => onChangePassword(event.target.value)}
required
/>
</div>
</label>
<label className="check-row">
<input
type="checkbox"
checked={rememberMe}
onChange={(event) => onChangeRememberMe(event.target.checked)}
/>
<span>Husk mig i 30 dage</span>
</label>
<button className="submit-btn" type="submit" disabled={loading}>
{loading ? 'Logger ind...' : 'Log ind'}
</button>
</form>
<p className="auth-foot">
Har du ikke en konto?
<button className="link-btn" type="button" onClick={onRegister}>
Opret bruger
</button>
</p>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import type { FormEvent } from 'react';
import { LockKeyhole, Mail, MapPin, User } from 'lucide-react';
import { AuthInput } from './AuthInput';
interface RegisterViewProps {
email: string;
firstName: string;
lastName: string;
loading: boolean;
locationQuery: string;
locationSuggestions: Array<{ description: string; placeId: string }>;
onBackToLogin: () => void;
onChangeEmail: (value: string) => void;
onChangeFirstName: (value: string) => void;
onChangeLastName: (value: string) => void;
onChangeLocationQuery: (value: string) => void;
onChangePassword: (value: string) => void;
onSelectLocation: (placeId: string, fallbackDescription: string) => Promise<void>;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
password: string;
}
export function RegisterView({
email,
firstName,
lastName,
loading,
locationQuery,
locationSuggestions,
onBackToLogin,
onChangeEmail,
onChangeFirstName,
onChangeLastName,
onChangeLocationQuery,
onChangePassword,
onSelectLocation,
onSubmit,
password,
}: RegisterViewProps) {
return (
<div className="auth-view view-enter">
<div className="auth-head auth-head-center">
<h1>Opret konto</h1>
<p>Start din karriere-rejse med os i dag.</p>
</div>
<form className="auth-form" onSubmit={onSubmit}>
<AuthInput
icon={<User size={16} strokeWidth={1.8} />}
label="Fornavn"
type="text"
placeholder="Lasse"
value={firstName}
onChange={(event) => onChangeFirstName(event.target.value)}
required
/>
<AuthInput
icon={<User size={16} strokeWidth={1.8} />}
label="Efternavn"
type="text"
placeholder="Hansen"
value={lastName}
onChange={(event) => onChangeLastName(event.target.value)}
required
/>
<AuthInput
icon={<Mail size={16} strokeWidth={1.8} />}
label="E-mail"
type="email"
placeholder="navn@eksempel.dk"
value={email}
onChange={(event) => onChangeEmail(event.target.value)}
required
/>
<AuthInput
icon={<LockKeyhole size={16} strokeWidth={1.8} />}
label="Adgangskode"
type="password"
placeholder="Skab en staerk kode"
value={password}
onChange={(event) => onChangePassword(event.target.value)}
required
minLength={8}
/>
<label className="auth-field">
<span>Lokation</span>
<div className="auth-input-wrap">
<span className="auth-input-icon" aria-hidden>
<MapPin size={16} strokeWidth={1.8} />
</span>
<input
type="text"
placeholder="Soeg by eller adresse"
value={locationQuery}
onChange={(event) => onChangeLocationQuery(event.target.value)}
autoComplete="off"
required
/>
</div>
{locationSuggestions.length > 0 ? (
<div className="location-suggestions">
{locationSuggestions.map((item) => (
<button
key={item.placeId}
type="button"
className="location-suggestion-item"
onClick={() => void onSelectLocation(item.placeId, item.description)}
>
{item.description}
</button>
))}
</div>
) : null}
</label>
<button className="submit-btn" type="submit" disabled={loading}>
{loading ? 'Opretter...' : 'Opret bruger'}
</button>
</form>
<p className="auth-foot">
Har du allerede en konto?
<button className="link-btn" type="button" onClick={onBackToLogin}>
Log ind
</button>
</p>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import { useMemo, useState, type FormEvent } from 'react';
import { AuthViewModel } from '../../../mvvm/viewmodels/AuthViewModel';
import { PlacesService } from '../../../mvvm/services/places.service';
import type { ActionResult, AuthView } from '../types';
interface UseAuthPageResult {
forgotEmail: string;
handleForgotSubmit: (event: FormEvent<HTMLFormElement>) => Promise<void>;
handleLoginSubmit: (event: FormEvent<HTMLFormElement>) => Promise<void>;
handleRegisterSubmit: (event: FormEvent<HTMLFormElement>) => Promise<void>;
loading: boolean;
loginEmail: string;
loginPassword: string;
registerFirstName: string;
registerEmail: string;
registerLastName: string;
registerLocationQuery: string;
registerLocationSuggestions: Array<{ description: string; placeId: string }>;
registerPassword: string;
rememberMe: boolean;
result: ActionResult | null;
setForgotEmail: (value: string) => void;
setLoginEmail: (value: string) => void;
setLoginPassword: (value: string) => void;
setRegisterEmail: (value: string) => void;
setRegisterFirstName: (value: string) => void;
setRegisterLastName: (value: string) => void;
setRegisterLocationQuery: (value: string) => void;
setRegisterPassword: (value: string) => void;
setRememberMe: (value: boolean) => void;
selectRegisterLocation: (placeId: string, fallbackDescription: string) => Promise<void>;
switchView: (next: AuthView) => void;
view: AuthView;
}
export function useAuthPage(onAuthenticated?: () => void): UseAuthPageResult {
const authViewModel = useMemo(() => new AuthViewModel(), []);
const placesService = useMemo(() => new PlacesService(), []);
const [forgotEmail, setForgotEmail] = useState('');
const [loading, setLoading] = useState(false);
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [registerFirstName, setRegisterFirstName] = useState('');
const [registerEmail, setRegisterEmail] = useState('');
const [registerLastName, setRegisterLastName] = useState('');
const [registerLocationQuery, setRegisterLocationQuery] = useState('');
const [registerLocationSuggestions, setRegisterLocationSuggestions] = useState<Array<{ description: string; placeId: string }>>([]);
const [registerLocationSelection, setRegisterLocationSelection] = useState<{ cityName: string; description: string; zip: string } | null>(null);
const [registerPassword, setRegisterPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [result, setResult] = useState<ActionResult | null>(null);
const [view, setView] = useState<AuthView>('login');
function switchView(next: AuthView) {
setResult(null);
setView(next);
}
async function searchRegisterLocation(query: string) {
const trimmed = query.trim();
if (trimmed.length < 3) {
setRegisterLocationSuggestions([]);
return;
}
try {
const response = (await placesService.searchPlaces(trimmed)) as {
predictions?: Array<{ description?: string; place_id?: string }>;
};
const mapped = (response.predictions ?? [])
.filter((item) => typeof item.place_id === 'string' && typeof item.description === 'string')
.map((item) => ({
description: item.description as string,
placeId: item.place_id as string,
}));
setRegisterLocationSuggestions(mapped);
} catch {
setRegisterLocationSuggestions([]);
}
}
function parseZipAndCity(input: string): { cityName: string; zip: string } | null {
const zipMatch = input.match(/\b(\d{4})\b/);
if (!zipMatch) {
return null;
}
const zip = zipMatch[1];
const afterZip = input.slice(input.indexOf(zip) + zip.length).trim();
const cityName = afterZip.split(',')[0]?.trim() || '';
if (!cityName) {
return null;
}
return { cityName, zip };
}
async function selectRegisterLocation(placeId: string, fallbackDescription: string) {
let description = fallbackDescription;
try {
const response = (await placesService.getPlaceDetails(placeId)) as {
result?: { formatted_address?: string };
};
if (typeof response.result?.formatted_address === 'string' && response.result.formatted_address.trim()) {
description = response.result.formatted_address.trim();
}
} catch {
// Keep fallback description.
}
const parsed = parseZipAndCity(description) ?? parseZipAndCity(fallbackDescription);
setRegisterLocationQuery(description);
setRegisterLocationSuggestions([]);
if (!parsed) {
setRegisterLocationSelection(null);
return;
}
setRegisterLocationSelection({
cityName: parsed.cityName,
description,
zip: parsed.zip,
});
}
async function handleLoginSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setResult(null);
try {
const response = await authViewModel.login(loginEmail.trim(), loginPassword, rememberMe);
setResult(response);
if (response.ok) {
onAuthenticated?.();
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Login mislykkedes.';
setResult({ ok: false, message });
} finally {
setLoading(false);
}
}
async function handleRegisterSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setResult(null);
try {
if (!registerLocationSelection) {
setResult({ ok: false, message: 'Vaelg en lokation fra listen (med postnummer).' });
return;
}
const response = await authViewModel.register({
email: registerEmail.trim(),
firstName: registerFirstName.trim(),
lastName: registerLastName.trim(),
password: registerPassword,
subscribe: true,
zip: registerLocationSelection.zip,
zipName: registerLocationSelection.cityName,
});
setResult(response);
if (response.ok) {
setView('login');
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Oprettelse mislykkedes.';
setResult({ ok: false, message });
} finally {
setLoading(false);
}
}
async function handleForgotSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setResult(null);
try {
const response = await authViewModel.forgotPassword(forgotEmail.trim());
setResult(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Kunne ikke sende nulstillingslink.';
setResult({ ok: false, message });
} finally {
setLoading(false);
}
}
return {
forgotEmail,
handleForgotSubmit,
handleLoginSubmit,
handleRegisterSubmit,
loading,
loginEmail,
loginPassword,
registerFirstName,
registerEmail,
registerLastName,
registerLocationQuery,
registerLocationSuggestions,
registerPassword,
rememberMe,
result,
setForgotEmail,
setLoginEmail,
setLoginPassword,
setRegisterEmail,
setRegisterFirstName,
setRegisterLastName,
setRegisterLocationQuery: (value: string) => {
setRegisterLocationQuery(value);
setRegisterLocationSelection(null);
void searchRegisterLocation(value);
},
setRegisterPassword,
setRememberMe,
selectRegisterLocation,
switchView,
view,
};
}

View File

@@ -1,47 +0,0 @@
import { useMemo, useState } from 'react';
import { AuthViewModel, type AuthActionResult, type RegisterInput } from '../../../mvvm/viewmodels/AuthViewModel';
export function useAuthViewModel() {
const viewModel = useMemo(() => new AuthViewModel(), []);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<AuthActionResult | null>(null);
async function runAction(action: () => Promise<AuthActionResult>) {
setIsLoading(true);
setResult(null);
try {
const next = await action();
setResult(next);
return next;
} catch (error) {
const failed: AuthActionResult = {
ok: false,
message: error instanceof Error ? error.message : 'Noget gik galt.',
};
setResult(failed);
return failed;
} finally {
setIsLoading(false);
}
}
function login(email: string, password: string, rememberMe: boolean) {
return runAction(() => viewModel.login(email, password, rememberMe));
}
function register(input: RegisterInput) {
return runAction(() => viewModel.register(input));
}
function forgotPassword(email: string) {
return runAction(() => viewModel.forgotPassword(email));
}
return {
isLoading,
result,
login,
register,
forgotPassword,
};
}

View File

@@ -0,0 +1,105 @@
import { ForgotPasswordView } from '../components/ForgotPasswordView';
import { LoginView } from '../components/LoginView';
import { RegisterView } from '../components/RegisterView';
import { useAuthPage } from '../hooks/useAuthPage';
import './auth.css';
interface AuthPageProps {
onAuthenticated?: () => void;
}
export function AuthPage({ onAuthenticated }: AuthPageProps) {
const {
forgotEmail,
handleForgotSubmit,
handleLoginSubmit,
handleRegisterSubmit,
loading,
loginEmail,
loginPassword,
registerFirstName,
registerEmail,
registerLastName,
registerLocationQuery,
registerLocationSuggestions,
registerPassword,
rememberMe,
result,
setForgotEmail,
setLoginEmail,
setLoginPassword,
setRegisterEmail,
setRegisterFirstName,
setRegisterLastName,
setRegisterLocationQuery,
setRegisterPassword,
setRememberMe,
selectRegisterLocation,
switchView,
view,
} = useAuthPage(onAuthenticated);
return (
<main className="auth-page">
<div className="orb orb-1" />
<div className="orb orb-2" />
<div className="orb orb-3" />
<div className="auth-logo-wrap">
<div className="auth-logo-dot">A</div>
<span className="auth-logo-text">ARBEJD</span>
</div>
<section className="auth-card" key={view}>
{view === 'login' ? (
<LoginView
email={loginEmail}
loading={loading}
onChangeEmail={setLoginEmail}
onChangePassword={setLoginPassword}
onChangeRememberMe={setRememberMe}
onForgotPassword={() => switchView('forgot')}
onRegister={() => switchView('register')}
onSubmit={handleLoginSubmit}
password={loginPassword}
rememberMe={rememberMe}
/>
) : null}
{view === 'register' ? (
<RegisterView
email={registerEmail}
firstName={registerFirstName}
lastName={registerLastName}
loading={loading}
locationQuery={registerLocationQuery}
locationSuggestions={registerLocationSuggestions}
onBackToLogin={() => switchView('login')}
onChangeEmail={setRegisterEmail}
onChangeFirstName={setRegisterFirstName}
onChangeLastName={setRegisterLastName}
onChangeLocationQuery={setRegisterLocationQuery}
onChangePassword={setRegisterPassword}
onSelectLocation={selectRegisterLocation}
onSubmit={handleRegisterSubmit}
password={registerPassword}
/>
) : null}
{view === 'forgot' ? (
<ForgotPasswordView
email={forgotEmail}
loading={loading}
onBackToLogin={() => switchView('login')}
onChangeEmail={setForgotEmail}
onSubmit={handleForgotSubmit}
/>
) : null}
{result ? (
<p className={result.ok ? 'status success' : 'status error'}>{result.message}</p>
) : null}
</section>
</main>
);
}

View File

@@ -1,37 +0,0 @@
import { useState } from 'react';
interface ForgotPasswordPageProps {
isLoading: boolean;
onSubmit: (email: string) => Promise<void>;
}
export function ForgotPasswordPage({ isLoading, onSubmit }: ForgotPasswordPageProps) {
const [email, setEmail] = useState('');
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(email);
}
return (
<form className="auth-form" onSubmit={handleSubmit}>
<p className="helper-text">
Indtast din email, sender vi en anmodning om nulstilling af kodeord.
</p>
<label className="field-label" htmlFor="forgot-email">Email</label>
<input
id="forgot-email"
className="field-input"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@arbejd.com"
required
/>
<button className="primary-btn" type="submit" disabled={isLoading}>
{isLoading ? 'Sender...' : 'Send anmodning'}
</button>
</form>
);
}

View File

@@ -1,56 +0,0 @@
import { useState } from 'react';
interface LoginPageProps {
isLoading: boolean;
onSubmit: (email: string, password: string, rememberMe: boolean) => Promise<void>;
}
export function LoginPage({ isLoading, onSubmit }: LoginPageProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(true);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(email, password, rememberMe);
}
return (
<form className="auth-form" onSubmit={handleSubmit}>
<label className="field-label" htmlFor="login-email">Email</label>
<input
id="login-email"
className="field-input"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@arbejd.com"
required
/>
<label className="field-label" htmlFor="login-password">Adgangskode</label>
<input
id="login-password"
className="field-input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="••••••••"
required
/>
<label className="check-row">
<input
type="checkbox"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
/>
<span>Husk mig</span>
</label>
<button className="primary-btn" type="submit" disabled={isLoading}>
{isLoading ? 'Logger ind...' : 'Log ind'}
</button>
</form>
);
}

View File

@@ -1,111 +0,0 @@
import { useState } from 'react';
import type { RegisterInput } from '../../../mvvm/viewmodels/AuthViewModel';
interface RegisterPageProps {
isLoading: boolean;
onSubmit: (payload: RegisterInput) => Promise<void>;
}
export function RegisterPage({ isLoading, onSubmit }: RegisterPageProps) {
const [form, setForm] = useState<RegisterInput>({
firstName: '',
lastName: '',
email: '',
password: '',
zip: '',
zipName: '',
subscribe: true,
});
function update<K extends keyof RegisterInput>(key: K, value: RegisterInput[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(form);
}
return (
<form className="auth-form" onSubmit={handleSubmit}>
<div className="field-grid">
<div>
<label className="field-label" htmlFor="register-first-name">Fornavn</label>
<input
id="register-first-name"
className="field-input"
value={form.firstName}
onChange={(event) => update('firstName', event.target.value)}
required
/>
</div>
<div>
<label className="field-label" htmlFor="register-last-name">Efternavn</label>
<input
id="register-last-name"
className="field-input"
value={form.lastName}
onChange={(event) => update('lastName', event.target.value)}
required
/>
</div>
</div>
<label className="field-label" htmlFor="register-email">Email</label>
<input
id="register-email"
className="field-input"
type="email"
value={form.email}
onChange={(event) => update('email', event.target.value)}
required
/>
<label className="field-label" htmlFor="register-password">Adgangskode</label>
<input
id="register-password"
className="field-input"
type="password"
value={form.password}
onChange={(event) => update('password', event.target.value)}
required
/>
<div className="field-grid">
<div>
<label className="field-label" htmlFor="register-zip">Postnummer</label>
<input
id="register-zip"
className="field-input"
value={form.zip}
onChange={(event) => update('zip', event.target.value)}
required
/>
</div>
<div>
<label className="field-label" htmlFor="register-zip-name">By</label>
<input
id="register-zip-name"
className="field-input"
value={form.zipName}
onChange={(event) => update('zipName', event.target.value)}
required
/>
</div>
</div>
<label className="check-row">
<input
type="checkbox"
checked={form.subscribe}
onChange={(event) => update('subscribe', event.target.checked)}
/>
<span>Modtag opdateringer</span>
</label>
<button className="primary-btn" type="submit" disabled={isLoading}>
{isLoading ? 'Opretter konto...' : 'Opret konto'}
</button>
</form>
);
}

View File

@@ -0,0 +1,325 @@
.auth-page {
position: relative;
min-height: 100vh;
background: #ecf0f0;
color: #1f2937;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow: hidden;
}
.auth-page * {
box-sizing: border-box;
}
.orb {
position: fixed;
border-radius: 999px;
pointer-events: none;
z-index: 0;
}
.orb-1 {
top: -10%;
left: -10%;
width: 50vw;
height: 50vw;
background: rgba(45, 212, 191, 0.3);
filter: blur(120px);
}
.orb-2 {
right: -10%;
bottom: -10%;
width: 60vw;
height: 60vw;
background: rgba(103, 232, 249, 0.4);
filter: blur(150px);
}
.orb-3 {
top: 30%;
right: 20%;
width: 30vw;
height: 30vw;
background: rgba(52, 211, 153, 0.2);
filter: blur(100px);
}
.auth-logo-wrap {
position: absolute;
top: 32px;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.auth-logo-dot {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
color: #fff;
font-weight: 700;
background: linear-gradient(135deg, #0f766e, #06b6d4);
box-shadow: 0 12px 22px rgba(13, 148, 136, 0.3);
}
.auth-logo-text {
font-size: 1.25rem;
font-weight: 500;
letter-spacing: -0.03em;
}
.auth-card {
width: min(420px, 100%);
position: relative;
z-index: 5;
border-radius: 32px;
padding: 32px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
}
.auth-view {
display: grid;
gap: 24px;
}
.view-enter {
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.auth-head {
display: grid;
gap: 8px;
}
.auth-head-center {
text-align: center;
}
.auth-head h1 {
margin: 0;
font-size: 1.55rem;
font-weight: 500;
letter-spacing: -0.03em;
color: #111827;
}
.auth-head p {
margin: 0;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.5;
}
.auth-form {
display: grid;
gap: 18px;
}
.auth-field {
display: grid;
gap: 7px;
}
.auth-field span {
font-size: 0.88rem;
font-weight: 500;
color: #374151;
padding-left: 4px;
}
.auth-field-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.auth-field input {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.75);
border-radius: 12px;
padding: 10px 14px 10px 40px;
background: rgba(255, 255, 255, 0.55);
color: #111827;
font-size: 0.9rem;
outline: none;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.auth-field input::placeholder {
color: #9ca3af;
}
.auth-field input:focus {
border-color: rgba(20, 184, 166, 0.5);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.18), 0 6px 18px rgba(13, 148, 136, 0.08);
background: rgba(255, 255, 255, 0.85);
}
.auth-input-wrap {
position: relative;
}
.auth-input-icon {
position: absolute;
top: 50%;
left: 12px;
transform: translateY(-50%);
width: 18px;
height: 18px;
border-radius: 999px;
display: grid;
place-items: center;
color: #9ca3af;
pointer-events: none;
}
.auth-input-icon svg {
width: 16px;
height: 16px;
display: block;
}
.location-suggestions {
margin-top: 8px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
overflow: hidden;
max-height: 180px;
overflow-y: auto;
}
.location-suggestion-item {
width: 100%;
border: 0;
border-bottom: 1px solid rgba(229, 231, 235, 0.7);
background: transparent;
padding: 10px 12px;
text-align: left;
color: #374151;
font-size: 0.86rem;
cursor: pointer;
}
.location-suggestion-item:last-child {
border-bottom: 0;
}
.location-suggestion-item:hover {
background: rgba(20, 184, 166, 0.1);
color: #115e59;
}
.check-row {
display: flex;
align-items: center;
gap: 8px;
color: #4b5563;
font-size: 0.9rem;
}
.submit-btn {
margin-top: 2px;
width: 100%;
border: 0;
border-radius: 12px;
padding: 10px 14px;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: #111827;
cursor: pointer;
transition: background 0.2s ease;
}
.submit-btn:hover:not(:disabled) {
background: #1f2937;
}
.submit-btn:disabled {
opacity: 0.65;
cursor: default;
}
.auth-foot {
margin: 0;
text-align: center;
font-size: 0.9rem;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: wrap;
}
.link-btn {
border: 0;
background: transparent;
color: #0f766e;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.link-btn:hover {
color: #115e59;
}
.back-link {
justify-self: start;
font-size: 0.8rem;
}
.status {
margin: 18px 0 0;
border-radius: 12px;
padding: 10px 12px;
font-size: 0.88rem;
}
.status.success {
background: rgba(16, 185, 129, 0.12);
color: #047857;
}
.status.error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
@media (max-width: 520px) {
.auth-card {
padding: 24px 20px;
border-radius: 24px;
}
}

View File

@@ -0,0 +1,6 @@
export interface ActionResult {
message: string;
ok: boolean;
}
export type AuthView = 'forgot' | 'login' | 'register';

View File

@@ -1,459 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type { PlainLanguageInterface } from '../../../mvvm/models/all-language.interface';
import type { AiGeneratedCVDescription } from '../../../mvvm/models/ai-generated-cv-description.interface';
import type { CandidateInterface, CertificationInterface, DriversLicenseInterface, EducationInterface, ExperienceInterface, LanguageInterface, SkillInterface } from '../../../mvvm/models/candidate.interface';
import type { CvUploadDataInterface } from '../../../mvvm/models/cv-upload-data.interface';
import type { DriverLicenseTypeInterface } from '../../../mvvm/models/driver-license-type.interface';
import type { EducationSearchInterface } from '../../../mvvm/models/education-search.interface';
import type { EscoInterface } from '../../../mvvm/models/esco.interface';
import type { PaymentOverview } from '../../../mvvm/models/payment-overview.interface';
import type { QualificationSearchInterface } from '../../../mvvm/models/qualification-search.interface';
import type { SchoolInterface } from '../../../mvvm/models/school.interface';
import type { SearchedCertificationInterface } from '../../../mvvm/models/searched-certification.interface';
import { CvPageViewModel } from '../../../mvvm/viewmodels/CvPageViewModel';
type ActionKey =
| 'generate'
| 'download'
| 'upload'
| 'optimize'
| 'toggle-active'
| 'update-profile'
| 'remove-entry'
| 'update-entry'
| 'create-entry';
export function useCvPageViewModel() {
const viewModel = useMemo(() => new CvPageViewModel(), []);
const [candidate, setCandidate] = useState<CandidateInterface | null>(null);
const [experiences, setExperiences] = useState<ExperienceInterface[]>([]);
const [educations, setEducations] = useState<EducationInterface[]>([]);
const [skills, setSkills] = useState<SkillInterface[]>([]);
const [certifications, setCertifications] = useState<CertificationInterface[]>([]);
const [languages, setLanguages] = useState<LanguageInterface[]>([]);
const [driverLicenses, setDriverLicenses] = useState<DriversLicenseInterface[]>([]);
const [paymentOverview, setPaymentOverview] = useState<PaymentOverview | null>(null);
const [cvUploadData, setCvUploadData] = useState<CvUploadDataInterface | null>(null);
const [aiGeneratedCVDescription, setAiGeneratedCVDescription] = useState<AiGeneratedCVDescription | null>(null);
const [languageOptions, setLanguageOptions] = useState<PlainLanguageInterface[]>([]);
const [driverLicenseOptions, setDriverLicenseOptions] = useState<DriverLicenseTypeInterface[]>([]);
const [escoSuggestions, setEscoSuggestions] = useState<EscoInterface[]>([]);
const [qualificationSuggestions, setQualificationSuggestions] = useState<QualificationSearchInterface[]>([]);
const [educationSuggestions, setEducationSuggestions] = useState<EducationSearchInterface[]>([]);
const [schoolSuggestions, setSchoolSuggestions] = useState<SchoolInterface[]>([]);
const [certificationSuggestions, setCertificationSuggestions] = useState<SearchedCertificationInterface[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<Record<ActionKey, boolean>>({
generate: false,
download: false,
upload: false,
optimize: false,
'toggle-active': false,
'update-profile': false,
'remove-entry': false,
'update-entry': false,
'create-entry': false,
});
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const snapshot = await viewModel.getSnapshot();
setCandidate(snapshot.candidate);
setExperiences(snapshot.experiences);
setEducations(snapshot.educations);
setSkills(snapshot.skills);
setCertifications(snapshot.certifications);
setLanguages(snapshot.languages);
setDriverLicenses(snapshot.driverLicenses);
setPaymentOverview(snapshot.paymentOverview);
setCvUploadData(snapshot.cvUploadData);
setAiGeneratedCVDescription(snapshot.aiGeneratedCVDescription);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load CV data.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
async function withAction<T>(key: ActionKey, fn: () => Promise<T>): Promise<T> {
setActionLoading((prev) => ({ ...prev, [key]: true }));
setError(null);
setInfo(null);
try {
return await fn();
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'An action failed.');
throw actionError;
} finally {
setActionLoading((prev) => ({ ...prev, [key]: false }));
}
}
const setActiveSeeker = useCallback(
async (isActive: boolean, language: string) => {
if (!candidate) {
return;
}
await withAction('toggle-active', async () => {
const updated = await viewModel.setActiveSeeker(candidate, isActive, language);
setCandidate(updated);
});
},
[candidate, viewModel],
);
const generateCv = useCallback(
async (language: string) => {
await withAction('generate', async () => {
await viewModel.generateCv(language);
setInfo('CV-generering er startet.');
await load();
});
},
[load, viewModel],
);
const downloadCv = useCallback(
async (language: string) => {
await withAction('download', async () => {
const url = await viewModel.getCvDownloadUrl(language);
window.open(url, '_blank', 'noopener,noreferrer');
});
},
[viewModel],
);
const uploadCv = useCallback(
async (base64File: string, fileType: 'pdf' | 'docx') => {
await withAction('upload', async () => {
await viewModel.uploadCv(base64File, fileType);
setInfo('CV er uploadet og behandles nu.');
await load();
});
},
[load, viewModel],
);
const optimizeCv = useCallback(
async (language: string) => {
await withAction('optimize', async () => {
await viewModel.optimizeCv(language);
setInfo('CV-optimering er sat i gang.');
await load();
});
},
[load, viewModel],
);
const updateCandidate = useCallback(
async (nextCandidate: CandidateInterface, language: string) => {
await withAction('update-profile', async () => {
const updated = await viewModel.updateCandidate(nextCandidate, language);
setCandidate(updated);
setInfo('Profiloplysninger er opdateret.');
});
},
[viewModel],
);
const updateExperience = useCallback(
async (experience: ExperienceInterface, language: string) => {
await withAction('update-entry', async () => {
await viewModel.updateExperience(experience, language);
await load();
});
},
[load, viewModel],
);
const updateEducation = useCallback(
async (education: EducationInterface, language: string) => {
await withAction('update-entry', async () => {
await viewModel.updateEducation(education, language);
await load();
});
},
[load, viewModel],
);
const updateCertification = useCallback(
async (certification: CertificationInterface) => {
await withAction('update-entry', async () => {
await viewModel.updateCertification(certification);
await load();
});
},
[load, viewModel],
);
const updateLanguage = useCallback(
async (languageItem: LanguageInterface) => {
await withAction('update-entry', async () => {
await viewModel.updateLanguage(languageItem);
await load();
});
},
[load, viewModel],
);
const removeExperience = useCallback(
async (experienceId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeExperience(experienceId);
await load();
});
},
[load, viewModel],
);
const removeEducation = useCallback(
async (educationId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeEducation(educationId);
await load();
});
},
[load, viewModel],
);
const removeQualification = useCallback(
async (skillId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeQualification(skillId);
await load();
});
},
[load, viewModel],
);
const removeCertification = useCallback(
async (certificationId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeCertification(certificationId);
await load();
});
},
[load, viewModel],
);
const removeLanguage = useCallback(
async (languageId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeLanguage(languageId);
await load();
});
},
[load, viewModel],
);
const removeDriverLicense = useCallback(
async (driverLicenseId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeDriverLicense(driverLicenseId);
await load();
});
},
[load, viewModel],
);
const searchEscoSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getEscoSuggestions(query);
setEscoSuggestions(list);
} catch {
setEscoSuggestions([]);
}
},
[viewModel],
);
const searchQualificationSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getQualificationSuggestions(query);
setQualificationSuggestions(list);
} catch {
setQualificationSuggestions([]);
}
},
[viewModel],
);
const searchEducationSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getEducationSuggestions(query);
setEducationSuggestions(list);
} catch {
setEducationSuggestions([]);
}
},
[viewModel],
);
const searchSchoolSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getSchoolSuggestions(query);
setSchoolSuggestions(list);
} catch {
setSchoolSuggestions([]);
}
},
[viewModel],
);
const searchCertificationSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getCertificationSuggestions(query);
setCertificationSuggestions(list);
} catch {
setCertificationSuggestions([]);
}
},
[viewModel],
);
const loadCreateOptions = useCallback(async () => {
try {
const [languagesData, driverLicensesData] = await Promise.all([
viewModel.getLanguageOptions(),
viewModel.getDriverLicenseOptions(),
]);
setLanguageOptions(languagesData);
setDriverLicenseOptions(driverLicensesData);
} catch {
setLanguageOptions([]);
setDriverLicenseOptions([]);
}
}, [viewModel]);
const createExperience = useCallback(
async (payload: { companyName: string; comments: string; fromDate: Date | null; toDate: Date | null; isCurrent: boolean; escoId?: number | null; occupationName?: string }, language: string) => {
await withAction('create-entry', async () => {
await viewModel.createExperience(payload, language);
setInfo('Erfaring er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createEducation = useCallback(
async (payload: { comments: string; fromDate: Date | null; toDate: Date | null; isCurrent: boolean; educationName?: string; educationDisced15?: number | null; institutionName?: string; institutionNumber: number | null }, language: string) => {
await withAction('create-entry', async () => {
await viewModel.createEducation(payload, language);
setInfo('Uddannelse er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createCertification = useCallback(
async (payload: { certificateId?: string | null; certificateName?: string }) => {
await withAction('create-entry', async () => {
await viewModel.createCertification(payload);
setInfo('Certifikat er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createLanguage = useCallback(
async (languageId: string, level: number) => {
await withAction('create-entry', async () => {
await viewModel.createLanguage(languageId, level);
setInfo('Sprog er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createQualification = useCallback(
async (payload: { qualificationId?: string; qualificationName?: string; level: number }) => {
await withAction('create-entry', async () => {
await viewModel.createQualification(payload);
setInfo('Kvalifikation er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createDriverLicense = useCallback(
async (driversLicenseId: string, level: number) => {
await withAction('create-entry', async () => {
await viewModel.createDriverLicense(driversLicenseId, level);
setInfo('Kørekort er tilføjet.');
await load();
});
},
[load, viewModel],
);
return {
candidate,
experiences,
educations,
skills,
certifications,
languages,
driverLicenses,
paymentOverview,
cvUploadData,
aiGeneratedCVDescription,
languageOptions,
driverLicenseOptions,
escoSuggestions,
qualificationSuggestions,
educationSuggestions,
schoolSuggestions,
certificationSuggestions,
isLoading,
actionLoading,
error,
info,
load,
setActiveSeeker,
updateCandidate,
updateExperience,
updateEducation,
updateCertification,
updateLanguage,
generateCv,
downloadCv,
uploadCv,
optimizeCv,
removeExperience,
removeEducation,
removeQualification,
removeCertification,
removeLanguage,
removeDriverLicense,
searchEscoSuggestions,
searchQualificationSuggestions,
searchEducationSuggestions,
searchSchoolSuggestions,
searchCertificationSuggestions,
loadCreateOptions,
createExperience,
createEducation,
createCertification,
createLanguage,
createQualification,
createDriverLicense,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,471 @@
.cv-head {
margin-bottom: 22px;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.cv-design-toggle {
border: 1px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.62);
border-radius: 999px;
padding: 8px 12px;
display: inline-flex;
align-items: center;
gap: 8px;
color: #111827;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 0.8rem;
font-weight: 500;
}
.cv-design-toggle:hover {
background: rgba(255, 255, 255, 0.84);
}
.cv-head h1 {
margin: 0 0 8px;
font-size: clamp(2rem, 4vw, 2.9rem);
font-weight: 500;
letter-spacing: -0.03em;
}
.cv-head p {
margin: 0;
color: #6b7280;
font-size: 1.1rem;
}
.cv-edit-btn {
border: 0;
border-radius: 12px;
background: #111827;
color: #fff;
padding: 10px 16px;
font-size: 0.88rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.16);
}
.cv-edit-btn:hover {
background: #1f2937;
}
.cv-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 24px;
padding-bottom: 24px;
}
.cv-left,
.cv-right {
display: grid;
gap: 24px;
align-content: start;
}
.cv-card {
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.03);
padding: 24px;
}
.cv-avatar-wrap {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.cv-avatar {
width: 96px;
height: 96px;
border-radius: 22px;
object-fit: cover;
border: 4px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
}
.cv-avatar-fallback {
background: linear-gradient(135deg, #0f766e, #06b6d4);
color: #fff;
display: grid;
place-items: center;
font-size: 2rem;
font-weight: 600;
}
.cv-section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.cv-section-head svg {
color: #0f766e;
}
.cv-section-head h2 {
margin: 0;
font-size: 1.08rem;
font-weight: 500;
letter-spacing: -0.01em;
}
.cv-personal-list {
display: grid;
gap: 10px;
}
.cv-personal-list div {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.62);
padding-bottom: 8px;
}
.cv-personal-list div:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.cv-personal-list span {
color: #6b7280;
font-size: 0.84rem;
}
.cv-personal-list strong {
color: #111827;
font-size: 0.84rem;
font-weight: 500;
text-align: right;
}
.cv-chip-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cv-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 10px;
background: #fff;
border: 1px solid rgba(229, 231, 235, 0.85);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
color: #374151;
font-size: 0.74rem;
font-weight: 500;
}
.cv-language-list {
display: grid;
gap: 10px;
}
.cv-language-list div {
display: flex;
justify-content: space-between;
align-items: center;
}
.cv-language-list strong {
font-size: 0.86rem;
font-weight: 500;
color: #111827;
}
.cv-language-list span {
font-size: 0.72rem;
color: #0f766e;
background: #f0fdfa;
border: 1px solid #ccfbf1;
border-radius: 8px;
padding: 4px 10px;
}
.cv-mini-grid {
display: grid;
gap: 24px;
}
.cv-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.cv-list li {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.84rem;
color: #374151;
}
.cv-list li svg {
color: #14b8a6;
}
.cv-timeline-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.cv-timeline-icon {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.8);
display: grid;
place-items: center;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.cv-timeline-icon svg {
color: #0f766e;
}
.cv-timeline-head h2 {
margin: 0;
font-size: 1.45rem;
letter-spacing: -0.01em;
font-weight: 500;
}
.cv-timeline {
position: relative;
display: grid;
gap: 18px;
}
.cv-timeline::before {
content: '';
position: absolute;
left: 19px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, #5eead4, #a5f3fc, transparent);
}
.cv-timeline-item {
display: flex;
align-items: flex-start;
gap: 10px;
position: relative;
}
.cv-timeline-dot {
width: 40px;
height: 40px;
border-radius: 999px;
border: 4px solid #ecf0f0;
background: #fff;
display: grid;
place-items: center;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
z-index: 2;
flex-shrink: 0;
}
.cv-timeline-dot svg {
color: #0f766e;
}
.cv-timeline-card {
width: calc(100% - 50px);
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.03);
padding: 18px;
transition: 0.2s ease;
}
.cv-timeline-card:hover {
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.cv-timeline-card h3 {
margin: 0 0 8px;
font-size: 1rem;
font-weight: 500;
color: #111827;
}
.cv-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.cv-meta strong {
color: #0f766e;
font-size: 0.84rem;
font-weight: 500;
}
.cv-meta span {
color: #6b7280;
font-size: 0.72rem;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 6px;
padding: 2px 8px;
}
.cv-timeline-card p {
margin: 0;
color: #4b5563;
font-size: 0.84rem;
line-height: 1.55;
}
.cv-divider {
height: 1px;
background: rgba(255, 255, 255, 0.6);
}
.cv-design-reference .cv-card {
border-radius: 28px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
}
.cv-design-reference .cv-timeline-head h2 {
font-size: 1.55rem;
}
.theme-dark .cv-head h1,
.theme-dark .cv-head p,
.theme-dark .cv-timeline-head h2,
.theme-dark .cv-section-head h2,
.theme-dark .cv-personal-list strong,
.theme-dark .cv-language-list strong,
.theme-dark .cv-timeline-card h3 {
color: #ffffff;
}
.theme-dark .cv-head p,
.theme-dark .cv-personal-list span,
.theme-dark .cv-list li,
.theme-dark .cv-meta span,
.theme-dark .cv-timeline-card p {
color: #9ca3af;
}
.theme-dark .cv-design-toggle {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: #f3f4f6;
}
.theme-dark .cv-design-toggle:hover {
background: rgba(255, 255, 255, 0.08);
}
.theme-dark .cv-card,
.theme-dark .cv-timeline-card {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.theme-dark .cv-chip {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #d1d5db;
}
.theme-dark .cv-language-list span {
color: #2dd4bf;
background: rgba(20, 184, 166, 0.1);
border-color: rgba(20, 184, 166, 0.3);
}
.theme-dark .cv-timeline::before {
background: linear-gradient(to bottom, rgba(20, 184, 166, 0.5), rgba(6, 182, 212, 0.3), transparent);
}
.theme-dark .cv-timeline-dot {
background: #111827;
border-color: #0a0a0a;
}
.theme-dark .cv-meta strong {
color: #2dd4bf;
}
.theme-dark .cv-divider {
background: rgba(255, 255, 255, 0.08);
}
@media (max-width: 1200px) {
.cv-layout {
grid-template-columns: 1fr;
}
.cv-head {
flex-direction: column;
align-items: flex-start;
}
}
@media (min-width: 980px) {
.cv-design-reference .cv-timeline::before {
left: 50%;
transform: translateX(-50%);
background: linear-gradient(to bottom, #86efac, #67e8f9, transparent);
}
.cv-design-reference .cv-timeline-item {
justify-content: space-between;
}
.cv-design-reference .cv-timeline-item:nth-child(odd) {
flex-direction: row-reverse;
}
.cv-design-reference .cv-timeline-dot {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.cv-design-reference .cv-timeline-card {
width: calc(50% - 2.5rem);
padding: 24px;
border-radius: 28px;
}
}
@media (max-width: 860px) {
.cv-design-toggle span {
display: none;
}
}

View File

@@ -0,0 +1,91 @@
import { Briefcase, Bot, FileText, Gamepad2, LayoutGrid, MessageCircle, Radar, Sparkles } from 'lucide-react';
import type { ComponentType } from 'react';
interface DashboardSidebarProps {
active?: DashboardNavKey;
onNavigate?: (target: DashboardNavKey) => void;
}
export type DashboardNavKey = 'dashboard' | 'jobs' | 'cv' | 'messages' | 'agents' | 'ai-agent' | 'simulator';
interface NavItem {
accent?: boolean;
badge?: string;
dot?: boolean;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
key: DashboardNavKey;
label: string;
}
const primaryItems: NavItem[] = [
{ key: 'dashboard', label: 'Dashboard', icon: LayoutGrid },
{ key: 'jobs', label: 'Jobs', icon: Briefcase },
{ key: 'cv', label: 'CV', icon: FileText },
{ key: 'messages', label: 'Beskeder', icon: MessageCircle, badge: '3' },
];
const secondaryItems: NavItem[] = [
{ key: 'agents', label: 'Jobagenter', icon: Radar, dot: true },
{ key: 'ai-agent', label: 'AI-agent', icon: Bot, accent: true },
{ key: 'simulator', label: 'Simulator', icon: Gamepad2 },
];
export function DashboardSidebar({ active = 'dashboard', onNavigate }: DashboardSidebarProps) {
return (
<aside className="dash-sidebar">
<div className="dash-logo-row">
<div className="dash-logo-dot">A</div>
<span className="dash-logo-text">ARBEJD</span>
</div>
<nav className="dash-nav">
{primaryItems.map((item) => {
const Icon = item.icon;
const isActive = item.key === active;
return (
<button
key={item.key}
type="button"
className={isActive ? 'dash-nav-item active' : 'dash-nav-item'}
onClick={() => onNavigate?.(item.key)}
>
<span className={item.accent ? 'dash-nav-icon accent' : 'dash-nav-icon'}>
<Icon size={19} strokeWidth={1.7} />
</span>
<span className="dash-nav-label">{item.label}</span>
{item.badge ? <span className="dash-nav-badge">{item.badge}</span> : null}
</button>
);
})}
<div className="dash-nav-divider" />
{secondaryItems.map((item) => {
const Icon = item.icon;
const isActive = item.key === active;
return (
<button
key={item.key}
type="button"
className={isActive ? 'dash-nav-item active' : 'dash-nav-item'}
onClick={() => onNavigate?.(item.key)}
>
<span className={item.accent ? 'dash-nav-icon accent' : 'dash-nav-icon'}>
<Icon size={19} strokeWidth={1.7} />
</span>
<span className="dash-nav-label">{item.label}</span>
{item.dot ? <span className="dash-nav-dot" /> : null}
</button>
);
})}
</nav>
<div className="dash-sidebar-pro">
<div className="dash-sidebar-pro-glow" />
<Sparkles size={19} strokeWidth={1.8} />
<h4>Pro-medlemskab</h4>
<p>Faa ubegrænsede simuleringer</p>
</div>
</aside>
);
}

View File

@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { ChevronDown, LogOut, Moon, Settings, Sun, UserCircle } from 'lucide-react';
interface DashboardTopbarProps {
actions?: ReactNode;
imageUrl?: string;
name: string;
onLogout: () => void;
onToggleTheme?: () => void;
theme?: 'light' | 'dark';
}
export function DashboardTopbar({ actions, imageUrl, name, onLogout, onToggleTheme, theme = 'light' }: DashboardTopbarProps) {
return (
<header className="dash-topbar">
{onToggleTheme ? (
<button type="button" className="dash-theme-btn" onClick={onToggleTheme}>
{theme === 'dark' ? <Sun size={15} strokeWidth={1.8} /> : <Moon size={15} strokeWidth={1.8} />}
<span>{theme === 'dark' ? 'Light' : 'Dark'}</span>
</button>
) : null}
{actions ? <div className="dash-topbar-actions">{actions}</div> : null}
<div className="dash-profile-wrap">
<button className="dash-profile-btn" type="button">
{imageUrl ? (
<img src={imageUrl} alt={name} className="dash-profile-avatar" />
) : (
<div className="dash-profile-avatar dash-profile-avatar-fallback">{name.slice(0, 1).toUpperCase()}</div>
)}
<span>{name}</span>
<ChevronDown size={15} strokeWidth={1.8} />
</button>
<div className="dash-profile-menu">
<button type="button"><UserCircle size={16} strokeWidth={1.8} /> Profil</button>
<button type="button"><Settings size={16} strokeWidth={1.8} /> Indstillinger</button>
<div className="dash-profile-divider" />
<button type="button" className="danger" onClick={onLogout}><LogOut size={16} strokeWidth={1.8} /> Log ud</button>
</div>
</div>
</header>
);
}

View File

@@ -1,39 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { DashboardViewModel, type DashboardInitialData } from '../../../mvvm/viewmodels/DashboardViewModel';
const INITIAL_DATA: DashboardInitialData = {
candidate: null,
notifications: [],
messages: [],
bestJobs: [],
subscription: null,
evaluations: [],
};
export function useDashboardViewModel() {
const viewModel = useMemo(() => new DashboardViewModel(), []);
const [data, setData] = useState<DashboardInitialData>(INITIAL_DATA);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const next = await viewModel.loadInitialData();
setData(next);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load dashboard data.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
return {
...data,
isLoading,
error,
load,
};
}

View File

@@ -1,248 +1,281 @@
import { useEffect, useMemo, useState } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { useDashboardViewModel } from '../hooks/useDashboardViewModel';
import {
ArrowRight,
ArrowUpDown,
Bolt,
Code2,
FilePlus2,
Laptop,
Link2,
MapPin,
MessageCircle,
PenSquare,
Plus,
Presentation,
Settings,
Sparkles,
Users,
} from 'lucide-react';
import { DashboardViewModel, type DashboardInitialData } from '../../../mvvm/viewmodels/DashboardViewModel';
import { DashboardSidebar, type DashboardNavKey } from '../components/DashboardSidebar';
import { DashboardTopbar } from '../components/DashboardTopbar';
import './dashboard.css';
interface DashboardPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
onOpenJob: (jobId: string, fromJobnet: boolean) => void;
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onOpenJobDetail: (jobId: string, fromJobnet: boolean, returnPage?: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
function formatDate(value?: string | Date | null): string {
if (!value) {
return 'Ingen dato';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Ingen dato';
}
return date.toLocaleDateString('da-DK', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
const EMPTY_DATA: DashboardInitialData = {
bestJobs: [],
candidate: null,
evaluations: [],
messages: [],
notifications: [],
subscription: null,
};
function companyInitial(value: string): string {
return value.trim().slice(0, 1).toUpperCase() || 'A';
}
function getRecommendationText(value: string | null): string {
const labelMap: Record<string, string> = {
proceed_to_second_interview: 'Anbefalet til 2. samtale',
consider: 'Har potentiale',
reject: 'Afvist',
needs_followup: 'Kræver opfølgning',
hire: 'Stærk kandidat',
};
if (!value) {
return 'Afventer evaluering';
}
return labelMap[value] ?? value;
}
const DAILY_TIPS = [
'Tilpas de første 3 linjer i dit CV til stillingsopslaget for højere svarrate.',
'Skriv en kort motivation med konkrete resultater fra dit seneste job.',
'Gem interessante jobs med det samme, så du kan sammenligne dem senere.',
'Hold din profil opdateret med nye certificeringer og erfaringer.',
'Brug 10 minutter dagligt på at svare hurtigt på nye beskeder.',
'Prioriter jobs med tydelig rollebeskrivelse og realistisk afstand.',
'Gennemgå dine seneste evalueringer og anvend ét konkret forbedringspunkt.',
];
export function DashboardPage({ onLogout, onNavigate, onOpenJob }: DashboardPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const { candidate, notifications, messages, bestJobs, subscription, evaluations, isLoading, error, load } = useDashboardViewModel();
const candidateName = candidate?.firstName?.trim() || candidate?.name?.trim() || 'Anders';
const newJobsCount = notifications.length > 0 ? notifications.length : 12;
const dailyTip = useMemo(() => {
const dayIndex = Math.floor(Date.now() / (1000 * 60 * 60 * 24));
return DAILY_TIPS[dayIndex % DAILY_TIPS.length];
}, []);
export function DashboardPage({ onLogout, onNavigate, onOpenJobDetail, onToggleTheme, theme }: DashboardPageProps) {
const viewModel = useMemo(() => new DashboardViewModel(), []);
const [data, setData] = useState<DashboardInitialData>(EMPTY_DATA);
const [loading, setLoading] = useState(true);
useEffect(() => {
void load();
}, [load]);
let active = true;
void viewModel.loadInitialData()
.then((response) => {
if (!active) {
return;
}
setData(response);
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [viewModel]);
const name = data.candidate?.firstName?.trim() || data.candidate?.name?.trim() || 'Lasse';
const avatar = data.candidate?.imageUrl || data.candidate?.image || '';
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="dashboard"
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
onSelect={(key) => {
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
onNavigate(key);
}
}}
/>
<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" />
<main className="dashboard-main">
<Topbar
title="Oversigt"
userName={candidateName}
planLabel={subscription?.productTypeName || 'Jobseeker Pro'}
<DashboardSidebar active="dashboard" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar">
<DashboardTopbar
name={name}
imageUrl={avatar || undefined}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
/>
<div className="dashboard-scroll">
{error ? <p className="status error">{error}</p> : null}
<div className="dash-welcome">
<h1>Velkommen tilbage {name} <span>👋</span></h1>
<p>Her er, hvad der sker med din jobsøgning i dag.</p>
</div>
<section className="dashboard-hero-grid">
<article className="glass-panel dash-card hero dashboard-main-hero">
<h3>Velkommen tilbage, {candidateName}</h3>
<p>
Din AI Agent har arbejdet i baggrunden. Vi har fundet
<strong> {newJobsCount} nye jobs</strong>, der matcher din profil, og optimeret dit CV.
</p>
<div className="dashboard-hero-status">
<div className="dashboard-hero-progress">
<span style={{ width: `${Math.min(100, Math.max(24, newJobsCount * 8))}%` }} />
</div>
<small>AI Agent: Aktiv</small>
</div>
<p className="dashboard-tip">Dagens tip: {dailyTip}</p>
<button className="glass-button hero-cta" type="button" onClick={() => onNavigate('jobs')}>
til jobs
</button>
</article>
{loading ? <p className="dash-loading">Indlaeser dashboard...</p> : null}
<article className="glass-panel dash-card dashboard-quick-actions-card">
<h4>Quick Actions</h4>
<div className="dashboard-quick-actions-grid">
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('simulator')}>
Start Interview Simulator
</button>
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('beskeder')}>
Send a message
</button>
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('ai-agent')}>
Set AI Agent
</button>
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('abonnement')}>
Check Abonnement
</button>
<div className="dash-grid">
<div className="dash-grid-main">
<article className="dash-card">
<div className="dash-card-head">
<h2>Anbefalet til dig</h2>
</div>
</article>
</section>
<div className="dashboard-overview-grid">
<article className="glass-panel dash-card dashboard-feed-card">
<div className="dash-header">
<h4>5 nyeste notifikationer</h4>
<span className="dashboard-count-pill">{notifications.length}</span>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && notifications.length === 0 ? <p>Ingen notifikationer endnu.</p> : null}
<ul className="dashboard-feed-list">
{notifications.map((item) => (
<li key={item.id}>
<button
type="button"
className="dashboard-feed-item"
onClick={() => {
const fromJobnet = Boolean(item.jobnetPostingId);
const jobId = fromJobnet ? item.jobnetPostingId : item.jobPostingId;
if (jobId) {
onOpenJob(jobId, fromJobnet);
}
}}
>
<strong>{item.jobTitle || 'Jobagent match'}</strong>
<span>{item.companyName || 'Ukendt virksomhed'}</span>
</button>
</li>
))}
</ul>
</article>
<article className="glass-panel dash-card dashboard-feed-card dashboard-evaluations-card">
<div className="dash-header">
<h4>Seneste evalueringer</h4>
<span className="dashboard-count-pill">{evaluations.length}</span>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && evaluations.length === 0 ? <p>Ingen evalueringer endnu.</p> : null}
<ul className="dashboard-feed-list">
{evaluations.map((evaluation) => (
<li key={evaluation.id}>
<button type="button" className="dashboard-feed-item" onClick={() => onNavigate('ai-agent')}>
<strong>{evaluation.jobName}</strong>
<span>
{evaluation.companyName || 'Ukendt virksomhed'} {getRecommendationText(evaluation.recommendation)}
</span>
</button>
</li>
))}
</ul>
</article>
<article className="glass-panel dash-card dashboard-feed-card dashboard-messages-card">
<div className="dash-header">
<h4>Seneste 5 beskeder</h4>
<button className="primary-btn jobs-apply-btn" type="button" onClick={() => onNavigate('beskeder')}>
Åbn
</button>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && messages.length === 0 ? <p>Ingen beskeder endnu.</p> : null}
<ul className="dashboard-message-list">
{messages.map((thread) => (
<li key={thread.id}>
<button type="button" className="dashboard-message-item" onClick={() => onNavigate('beskeder')}>
<span className="dashboard-message-avatar">
{(thread.companyName || 'S').trim().slice(0, 1).toUpperCase()}
</span>
<span className="dashboard-message-main">
<strong>{thread.companyName || 'Samtale'}</strong>
<span>{thread.latestMessage?.text || 'Ingen besked'}</span>
</span>
</button>
</li>
))}
</ul>
</article>
<article className="glass-panel dash-card dashboard-feed-card dashboard-best-card">
<div className="dash-header">
<h4>Seneste 5 bedste jobs</h4>
<button className="primary-btn jobs-apply-btn" type="button" onClick={() => onNavigate('jobs')}>
Alle jobs
</button>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && bestJobs.length === 0 ? <p>Ingen jobforslag endnu.</p> : null}
<div className="dashboard-best-list">
{bestJobs.map((job) => (
<button
type="button"
<div className="dash-job-list">
{(data.bestJobs.length > 0 ? data.bestJobs : [
{ id: 'd1', title: 'Senior Frontend-udvikler', companyName: 'Lunar', address: 'Kobenhavn, DK', applicationDeadline: '', candidateDistance: null, fromJobnet: false, logoUrl: '', companyLogoImage: '' },
{ id: 'd2', title: 'React-udvikler', companyName: 'Pleo', address: 'Remote', applicationDeadline: '', candidateDistance: null, fromJobnet: false, logoUrl: '', companyLogoImage: '' },
]).slice(0, 5).map((job) => (
<div
key={job.id}
className="dashboard-best-item"
onClick={() => onOpenJob(job.id, job.fromJobnet)}
className="dash-job-item"
role="button"
tabIndex={0}
onClick={() => onOpenJobDetail(job.id, Boolean(job.fromJobnet), 'dashboard')}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpenJobDetail(job.id, Boolean(job.fromJobnet), 'dashboard');
}
}}
>
<div className="dashboard-best-brand">
{job.logoUrl || job.companyLogoImage ? (
<img src={job.logoUrl || job.companyLogoImage} alt={job.companyName} className="jobs-result-logo-img" />
) : (
<div className="jobs-result-logo">{companyInitial(job.companyName)}</div>
)}
<div className="dash-job-left">
<div className="dash-company-chip">{companyInitial(job.companyName)}</div>
<div>
<strong>{job.title || 'Stilling'}</strong>
<span>{job.companyName || 'Ukendt virksomhed'}</span>
<h4>{job.title}</h4>
<p>{job.companyName} {job.address || 'Lokation ikke angivet'}</p>
</div>
</div>
<div className="dashboard-best-meta">
<span>{job.candidateDistance != null ? `${job.candidateDistance.toFixed(1)} km` : 'Distance ukendt'}</span>
<span>Frist: {formatDate(job.applicationDeadline)}</span>
</div>
</button>
<button
type="button"
className="dash-job-arrow-btn"
aria-label="Se job"
onClick={(event) => {
event.stopPropagation();
onOpenJobDetail(job.id, Boolean(job.fromJobnet), 'dashboard');
}}
>
<ArrowRight size={16} strokeWidth={1.8} />
</button>
</div>
))}
</div>
</article>
<div className="dash-split-grid">
<article className="dash-card">
<h3>Seneste beskeder</h3>
<div className="dash-message-list">
{(data.messages.length > 0 ? data.messages : []).slice(0, 5).map((thread) => (
<div key={thread.id} className="dash-message-item">
<div className="dash-avatar">{companyInitial(thread.companyName || 'A')}</div>
<div>
<h4>{thread.companyName || 'Samtale'}</h4>
<p>{thread.latestMessage?.text || 'Ingen besked endnu'}</p>
</div>
</div>
))}
{data.messages.length === 0 ? <p className="dash-muted">Ingen beskeder endnu.</p> : null}
</div>
</article>
<article className="dash-card">
<div className="dash-card-head dash-card-head-inline">
<h3>Seneste simuleringer</h3>
<button type="button" className="dash-icon-btn"><Plus size={16} strokeWidth={1.8} /></button>
</div>
<div className="dash-sim-list">
<div className="dash-sim-item">
<div className="dash-sim-left">
<span className="dash-sim-icon teal"><Code2 size={16} strokeWidth={1.8} /></span>
<div><h4>Teknisk samtale</h4><p>Frontend-fokus</p></div>
</div>
<div className="dash-sim-right"><strong>92/100</strong><div className="dash-progress"><span style={{ width: '92%' }} /></div></div>
</div>
<div className="dash-sim-item">
<div className="dash-sim-left">
<span className="dash-sim-icon purple"><Users size={16} strokeWidth={1.8} /></span>
<div><h4>Kulturelt match</h4><p>Lunar Bank</p></div>
</div>
<div className="dash-sim-right"><strong>88/100</strong><div className="dash-progress"><span style={{ width: '88%' }} /></div></div>
</div>
<div className="dash-sim-item">
<div className="dash-sim-left">
<span className="dash-sim-icon amber"><Presentation size={16} strokeWidth={1.8} /></span>
<div><h4>Systemdesign</h4><p>Arkitektur</p></div>
</div>
<div className="dash-sim-right"><strong className="warn">65/100</strong><div className="dash-progress"><span className="warn" style={{ width: '65%' }} /></div></div>
</div>
<div className="dash-sim-item">
<div className="dash-sim-left">
<span className="dash-sim-icon blue"><Code2 size={16} strokeWidth={1.8} /></span>
<div><h4>Live-kodning</h4><p>React.js</p></div>
</div>
<div className="dash-sim-right"><strong>95/100</strong><div className="dash-progress"><span style={{ width: '95%' }} /></div></div>
</div>
<div className="dash-sim-item">
<div className="dash-sim-left">
<span className="dash-sim-icon gray"><MessageCircle size={16} strokeWidth={1.8} /></span>
<div><h4>HR-screening</h4><p>Generelt</p></div>
</div>
<div className="dash-sim-right"><strong className="na">N/A</strong><div className="dash-progress" /></div>
</div>
</div>
</article>
</div>
</div>
<div className="dash-grid-side">
<article className="dash-card dash-ai-card dash-ai-card-group">
<div className="dash-ai-peel" />
<div className="dash-ai-content">
<div className="dash-ai-head">
<Sparkles size={22} strokeWidth={1.8} />
<h3>AI-indsigter til dit CV</h3>
</div>
<p>Vi analyserede dit seneste CV op imod dine målroller.</p>
<ul className="dash-ai-list">
<li className="dash-ai-item">
<span className="dash-ai-item-icon"><Bolt size={13} strokeWidth={2} /></span>
<div>
<strong>Kvantificer dine resultater</strong>
<small>Tilføj tal til din rolle hos TechCorp (f.eks. \"Forbedrede loadhastighed med 40%\").</small>
</div>
</li>
<li className="dash-ai-item">
<span className="dash-ai-item-icon"><ArrowUpDown size={13} strokeWidth={2} /></span>
<div>
<strong>Omorganiser dine færdigheder</strong>
<small>Flyt React &amp; TypeScript til toppen baseret på aktive Jobagenter.</small>
</div>
</li>
<li className="dash-ai-item">
<span className="dash-ai-item-icon"><FilePlus2 size={13} strokeWidth={2} /></span>
<div>
<strong>Tilføj manglende nøgleord</strong>
<small>Inkluder \"Tailwind CSS\" for at matche 85% af dine anbefalede jobs.</small>
</div>
</li>
<li className="dash-ai-item dash-ai-xl-only">
<span className="dash-ai-item-icon"><PenSquare size={13} strokeWidth={2} /></span>
<div>
<strong>Omskriv dit resumé</strong>
<small>Gør din målsætning mere handlingsorienteret.</small>
</div>
</li>
<li className="dash-ai-item dash-ai-xl-only">
<span className="dash-ai-item-icon"><Link2 size={13} strokeWidth={2} /></span>
<div>
<strong>Opdater porteføljelink</strong>
<small>Dit GitHub-link gav en 404-fejl i vores test.</small>
</div>
</li>
</ul>
<button type="button">Anvend alle ændringer</button>
</div>
</article>
<article className="dash-card">
<div className="dash-card-head dash-card-head-inline">
<h3>Aktive Jobagenter</h3>
<button type="button" className="dash-icon-btn"><Settings size={16} strokeWidth={1.8} /></button>
</div>
<div className="dash-agent-list">
<div className="dash-agent-item">
<div><span>Frontend-udvikler</span><small><MapPin size={13} strokeWidth={1.8} /> Kobenhavn</small></div>
<label className="dash-switch"><input type="checkbox" defaultChecked /><span /></label>
</div>
<div className="dash-agent-item">
<div><span>React-udvikler</span><small><Laptop size={13} strokeWidth={1.8} /> Remote (EU)</small></div>
<label className="dash-switch"><input type="checkbox" defaultChecked /><span /></label>
</div>
<div className="dash-agent-item muted">
<div><span>UI/UX-designer</span><small><MapPin size={13} strokeWidth={1.8} /> Aarhus</small></div>
<label className="dash-switch"><input type="checkbox" /><span /></label>
</div>
</div>
<button type="button" className="dash-outline-btn">Opret ny agent</button>
</article>
</div>
</div>
</main>

File diff suppressed because it is too large Load Diff

View File

@@ -1,170 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import {
JobsPageViewModel,
type JobsFilterDraft,
type JobsListItem,
type OccupationOption,
type PlaceSuggestion,
type JobsTabKey,
} from '../../../mvvm/viewmodels/JobsPageViewModel';
export function useJobsPageViewModel() {
const viewModel = useMemo(() => new JobsPageViewModel(), []);
const [isLoading, setIsLoading] = useState(false);
const [isSavingFilter, setIsSavingFilter] = useState(false);
const [items, setItems] = useState<JobsListItem[]>([]);
const [bookmarkingIds, setBookmarkingIds] = useState<Record<string, boolean>>({});
const [occupationOptions, setOccupationOptions] = useState<OccupationOption[]>([]);
const [jobSearchWords, setJobSearchWords] = useState<string[]>([]);
const [placeSuggestions, setPlaceSuggestions] = useState<PlaceSuggestion[]>([]);
const [filter, setFilter] = useState<JobsFilterDraft | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
async (tab: JobsTabKey, currentFilter?: JobsFilterDraft, searchTerm?: string) => {
setIsLoading(true);
setError(null);
try {
const nextFilter = currentFilter ?? (await viewModel.getSavedFilter());
const [nextOccupationOptions, nextSearchWords] = await Promise.all([
viewModel.getOccupationOptions(),
viewModel.getJobSearchWords(),
]);
setFilter(nextFilter);
setOccupationOptions(nextOccupationOptions);
setJobSearchWords(nextSearchWords);
try {
const nextItems = await viewModel.getTabItems(tab, searchTerm);
setItems(nextItems);
} catch (itemsError) {
setItems([]);
setError(itemsError instanceof Error ? itemsError.message : 'Could not load jobs list.');
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load jobs data.');
} finally {
setIsLoading(false);
}
},
[viewModel],
);
const applyFilter = useCallback(
async (tab: JobsTabKey, nextFilter: JobsFilterDraft, searchTerm?: string) => {
setIsSavingFilter(true);
setError(null);
try {
await viewModel.saveFilter(nextFilter);
setFilter(nextFilter);
const nextItems = await viewModel.getTabItems(tab, searchTerm);
setItems(nextItems);
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Could not save filter.');
} finally {
setIsSavingFilter(false);
}
},
[viewModel],
);
const resetFilter = useCallback(
async (tab: JobsTabKey, searchTerm?: string) => {
setIsSavingFilter(true);
setError(null);
try {
const reset = await viewModel.resetFilter();
setFilter(reset);
const nextItems = await viewModel.getTabItems(tab, searchTerm);
setItems(nextItems);
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : 'Could not reset filter.');
} finally {
setIsSavingFilter(false);
}
},
[viewModel],
);
const searchPlaceSuggestions = useCallback(
async (query: string) => {
try {
const next = await viewModel.searchPlaceSuggestions(query);
setPlaceSuggestions(next);
} catch {
setPlaceSuggestions([]);
}
},
[viewModel],
);
const choosePlaceSuggestion = useCallback(
async (placeId: string) => {
const place = await viewModel.getPlaceSelection(placeId);
if (!place) {
return;
}
setFilter((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
distanceCenterName: place.description,
latitude: place.latitude,
longitude: place.longitude,
};
});
setPlaceSuggestions([]);
},
[viewModel],
);
const toggleBookmark = useCallback(
async (item: JobsListItem, save: boolean, removeFromList = false) => {
setBookmarkingIds((prev) => ({ ...prev, [item.id]: true }));
setError(null);
try {
await viewModel.toggleBookmark(item, save);
setItems((prev) =>
prev
.map((existing) =>
existing.id === item.id ? { ...existing, isSaved: save } : existing,
)
.filter((existing) => !(removeFromList && existing.id === item.id)),
);
} catch (bookmarkError) {
setError(bookmarkError instanceof Error ? bookmarkError.message : 'Could not update bookmark.');
} finally {
setBookmarkingIds((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
}
},
[viewModel],
);
return {
isLoading,
isSavingFilter,
items,
occupationOptions,
jobSearchWords,
placeSuggestions,
filter,
error,
load,
applyFilter,
resetFilter,
searchPlaceSuggestions,
choosePlaceSuggestion,
toggleBookmark,
bookmarkingIds,
setFilter,
};
}

View File

@@ -1,278 +1,357 @@
import { useEffect, useMemo, useState } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import {
ArrowLeft,
ArrowRight,
Bookmark,
BriefcaseBusiness,
CheckCircle2,
Clock3,
Globe,
Mail,
MapPin,
Rocket,
Sparkles,
} from 'lucide-react';
import { JobDetailViewModel, type JobDetailData } from '../../../mvvm/viewmodels/JobDetailViewModel';
import { JobsPageViewModel } from '../../../mvvm/viewmodels/JobsPageViewModel';
import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar';
import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar';
import '../../dashboard/pages/dashboard.css';
import './job-detail.css';
interface JobDetailPageProps {
jobId: string;
fromJobnet: boolean;
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
jobId: string;
onBack: () => void;
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
function companyInitial(value: string): string {
return value.trim().slice(0, 1).toUpperCase() || 'A';
}
function formatDate(value: string): string {
if (!value) {
return 'Ingen frist';
return 'Ikke angivet';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return date.toLocaleDateString('da-DK', {
return new Intl.DateTimeFormat('da-DK', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}).format(parsed);
}
function looksLikeHtml(value: string): boolean {
return /<[^>]+>/.test(value);
}
function workTimeString(detail: JobDetailData): string {
if (detail.workTimes.length === 0) {
return 'Ikke opgivet';
function sanitizeHtml(input: string): string {
if (!input.trim()) {
return '';
}
const labels: Record<number, string> = {
1: 'Dag',
2: 'Aften',
3: 'Nat',
4: 'Weekend',
};
return detail.workTimes
.map((value) => labels[value] ?? `Type ${value}`)
.join(', ');
}
function employmentDateString(detail: JobDetailData): string {
if (detail.startAsSoonAsPossible) {
return 'Snarest muligt';
if (typeof window === 'undefined') {
return input;
}
if (!detail.employmentDate) {
return 'Ikke opgivet';
const parser = new DOMParser();
const doc = parser.parseFromString(input, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, link, meta').forEach((node) => node.remove());
for (const element of Array.from(doc.body.querySelectorAll('*'))) {
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
const lowerValue = value.toLowerCase();
if (name.startsWith('on')) {
element.removeAttribute(attr.name);
continue;
}
if ((name === 'href' || name === 'src') && lowerValue.startsWith('javascript:')) {
element.removeAttribute(attr.name);
continue;
}
if (name === 'style' || name === 'srcdoc') {
element.removeAttribute(attr.name);
}
}
}
return formatDate(detail.employmentDate);
return doc.body.innerHTML;
}
function infoValue(value: string): string {
const normalized = value.trim();
return normalized.length > 0 ? normalized : 'Ikke opgivet';
function workTypeLabel(detail: JobDetailData): string {
if (detail.isFullTime === true) {
return 'Fuldtid';
}
if (detail.isFullTime === false) {
return 'Deltid';
}
if (detail.workTimes.length > 0) {
return 'Fleksibel arbejdstid';
}
return 'Ikke oplyst';
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="job-info-row">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function JobDetailPage({
fromJobnet,
jobId,
onBack,
onLogout,
onNavigate,
onToggleTheme,
theme,
}: JobDetailPageProps) {
const detailViewModel = useMemo(() => new JobDetailViewModel(), []);
const jobsViewModel = useMemo(() => new JobsPageViewModel(), []);
export function JobDetailPage({ jobId, fromJobnet, onLogout, onNavigate }: JobDetailPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [candidate, setCandidate] = useState<{ imageUrl?: string; name: string }>({ name: 'Lasse' });
const [detail, setDetail] = useState<JobDetailData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [errorText, setErrorText] = useState('');
const [isSaved, setIsSaved] = useState(false);
const [isApplied, setIsApplied] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const viewModel = useMemo(() => new JobDetailViewModel(), []);
useEffect(() => {
let active = true;
setIsLoading(true);
setError(null);
void viewModel
.getDetail(jobId, fromJobnet)
.then((result) => {
if (!active) {
return;
}
setDetail(result);
})
.catch((loadError) => {
if (!active) {
return;
}
setError(loadError instanceof Error ? loadError.message : 'Could not load job detail.');
})
.finally(() => {
if (active) {
setIsLoading(false);
}
});
async function load() {
setIsLoading(true);
setErrorText('');
const [profileResult, detailResult] = await Promise.allSettled([
jobsViewModel.getCandidateProfile(),
detailViewModel.getDetail(jobId, fromJobnet),
]);
if (!active) {
return;
}
if (profileResult.status === 'fulfilled') {
setCandidate(profileResult.value);
}
if (detailResult.status === 'fulfilled') {
setDetail(detailResult.value);
setIsSaved(Boolean(detailResult.value.isSaved));
setIsApplied(Boolean(detailResult.value.isApplied));
} else {
setDetail(null);
setErrorText('Kunne ikke hente jobdetaljer. Proev igen.');
}
setIsLoading(false);
}
void load();
return () => {
active = false;
};
}, [jobId, fromJobnet, viewModel]);
}, [detailViewModel, fromJobnet, jobId, jobsViewModel]);
async function handleToggleSave() {
if (!detail) {
async function handleToggleSaved() {
if (!detail || isSaving) {
return;
}
setIsSaving(true);
setError(null);
try {
await viewModel.toggleBookmark(detail.id, detail.fromJobnet, !detail.isSaved);
setDetail({ ...detail, isSaved: !detail.isSaved });
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Could not update saved state.');
await detailViewModel.toggleBookmark(detail.id, detail.fromJobnet, !isSaved);
setIsSaved((current) => !current);
} finally {
setIsSaving(false);
}
}
async function handleMarkAsApplied() {
if (!detail) {
async function handleMarkApplied() {
if (!detail || isApplied || isApplying) {
return;
}
setIsApplying(true);
setError(null);
try {
await viewModel.markAsApplied(detail.id, detail.fromJobnet);
setDetail({ ...detail, isApplied: true });
} catch (applyError) {
setError(applyError instanceof Error ? applyError.message : 'Could not mark as applied.');
await detailViewModel.markAsApplied(detail.id, detail.fromJobnet);
setIsApplied(true);
} finally {
setIsApplying(false);
}
}
const sanitizedDescription = useMemo(() => sanitizeHtml(detail?.description ?? ''), [detail?.description]);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="jobs"
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
onSelect={(key) => {
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
onNavigate(key);
}
}}
/>
<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" />
<main className="dashboard-main">
<Topbar title="Jobdetaljer" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<DashboardSidebar active="jobs" onNavigate={onNavigate} />
<div className="dashboard-scroll">
<section className="job-detail-layout">
<article className="glass-panel dash-card job-detail-main">
{isLoading ? <p>Indlæser jobdetaljer...</p> : null}
{error ? <p className="status error">{error}</p> : null}
<main className="dash-main custom-scrollbar">
<DashboardTopbar
name={candidate.name}
imageUrl={candidate.imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
/>
{!isLoading && !error && detail ? (
<>
<div className="job-detail-head">
<div className="job-detail-logo-wrap">
{detail.logoUrl ? (
<img src={detail.logoUrl} alt={detail.companyName} className="job-detail-logo" />
) : (
<div className="job-detail-logo-fallback">
{detail.companyName.slice(0, 1).toUpperCase() || 'A'}
</div>
)}
<div className="job-detail-back-row">
<button type="button" className="job-detail-back-btn" onClick={onBack}>
<ArrowLeft size={15} strokeWidth={1.8} />
<span>Tilbage til jobs</span>
</button>
</div>
{isLoading ? <p className="dash-loading">Indlaeser jobdetaljer...</p> : null}
{!isLoading && errorText ? <p className="dash-loading">{errorText}</p> : null}
{!isLoading && detail ? (
<>
<div className="job-detail-header">
<div className="job-detail-logo-wrap">
{detail.logoUrl ? (
<img src={detail.logoUrl} alt={detail.companyName} className="job-detail-logo-image" />
) : (
<div className="job-detail-logo-fallback">{companyInitial(detail.companyName)}</div>
)}
</div>
<div className="job-detail-heading">
<h1>{detail.title || 'Jobdetaljer'}</h1>
<div className="job-detail-meta">
<span><BriefcaseBusiness size={14} strokeWidth={1.8} /> {detail.companyName || 'Virksomhed'}</span>
<span><MapPin size={14} strokeWidth={1.8} /> {detail.address || 'Lokation ikke angivet'}</span>
<span><Clock3 size={14} strokeWidth={1.8} /> {workTypeLabel(detail)}</span>
</div>
</div>
</div>
<div className="job-detail-grid">
<section className="job-detail-main-card dash-card">
<div className="job-detail-section">
<h2>Om rollen</h2>
{sanitizedDescription ? (
<div className="job-detail-rich-html" dangerouslySetInnerHTML={{ __html: sanitizedDescription }} />
) : (
<p>Ingen jobbeskrivelse er tilgaengelig endnu.</p>
)}
</div>
<div className="job-detail-section">
<h3>Jobinformation</h3>
<div className="job-detail-info-grid">
<div>
<span>Ansøgningsfrist</span>
<strong>{formatDate(detail.applicationDeadline)}</strong>
</div>
<div>
<h3>{detail.title}</h3>
<p className="job-detail-company">{detail.companyName}</p>
{detail.occupationName ? <p className="job-detail-meta">{detail.occupationName}</p> : null}
{detail.address ? <p className="job-detail-meta">{detail.address}</p> : null}
<p className="job-detail-meta">Ansøgningsfrist: {formatDate(detail.applicationDeadline)}</p>
<span>Opslået</span>
<strong>{formatDate(detail.datePosted)}</strong>
</div>
<div>
<span>Startdato</span>
<strong>{formatDate(detail.employmentDate)}</strong>
</div>
<div>
<span>Stillinger</span>
<strong>{detail.numberOfPositions ?? 'Ikke angivet'}</strong>
</div>
<div>
<span>Kontaktperson</span>
<strong>{detail.contactName || 'Ikke angivet'}</strong>
</div>
<div>
<span>Kilde</span>
<strong>{detail.fromJobnet ? 'Jobnet' : 'Arbejd.com'}</strong>
</div>
</div>
</div>
</section>
<div className="job-detail-description">
<h4>Om jobbet</h4>
{detail.description ? (
looksLikeHtml(detail.description) ? (
<div
className="job-detail-richtext"
dangerouslySetInnerHTML={{ __html: detail.description }}
/>
) : (
<p>{detail.description}</p>
)
) : (
<p>Ingen beskrivelse tilgængelig.</p>
)}
</div>
</>
) : null}
</article>
<aside className="job-detail-side-col">
<article className="dash-card job-detail-actions-card">
<h2>Handlinger</h2>
<aside className="job-detail-side">
{detail ? (
<article className="glass-panel dash-card job-detail-info-card">
<h4>Info</h4>
<div className="job-info-grid">
<InfoRow label="Arbejdstype" value={detail.isFullTime == null ? 'Ikke opgivet' : detail.isFullTime ? 'Fuldtid' : 'Deltid'} />
<InfoRow label="Arbejdstid" value={workTimeString(detail)} />
<InfoRow label="Kontaktperson" value={infoValue(detail.contactName)} />
<InfoRow label="Arbejdsgiver" value={detail.hiringCompanyName?.trim() ? detail.hiringCompanyName : 'Anonym'} />
<InfoRow label="Oprettet" value={detail.datePosted ? formatDate(detail.datePosted) : 'Ikke opgivet'} />
<InfoRow label="Ansøgningsfrist" value={detail.applicationDeadline ? formatDate(detail.applicationDeadline) : 'Ikke opgivet'} />
<InfoRow label="Ansættelsesdato" value={employmentDateString(detail)} />
<InfoRow label="Antal stillinger" value={detail.numberOfPositions == null ? 'Ikke opgivet' : String(detail.numberOfPositions)} />
<button type="button" className="job-detail-action-primary">
<span><Sparkles size={16} strokeWidth={1.8} /> Generer ansøgning</span>
<ArrowRight size={14} strokeWidth={1.8} />
</button>
<button type="button" className="job-detail-action-secondary">
<span><Rocket size={16} strokeWidth={1.8} /> Simuler jobsamtale</span>
<ArrowRight size={14} strokeWidth={1.8} />
</button>
<div className="job-detail-action-divider" />
<button
type="button"
className="job-detail-text-action"
onClick={() => void handleToggleSaved()}
disabled={isSaving}
>
<Bookmark size={16} strokeWidth={1.8} />
{isSaved ? 'Fjern fra gemte jobs' : 'Gem job'}
</button>
<button type="button" className="job-detail-text-action">
<Mail size={16} strokeWidth={1.8} />
Del via email
</button>
{detail.websiteUrl ? (
<a href={detail.websiteUrl} target="_blank" rel="noreferrer" className="job-detail-text-action link">
<Globe size={16} strokeWidth={1.8} />
Åbn nettet
</a>
) : null}
<button
type="button"
className={isApplied ? 'job-detail-text-action success is-done' : 'job-detail-text-action success'}
onClick={() => void handleMarkApplied()}
disabled={isApplied || isApplying}
>
<CheckCircle2 size={16} strokeWidth={1.8} />
{isApplied ? 'Markeret som søgt' : 'Marker som søgt'}
</button>
</article>
<article className="dash-card job-detail-company-card">
<h3>Om virksomheden</h3>
<div className="job-detail-company-list">
<div>
<span>Virksomhed</span>
<strong>{detail.hiringCompanyName || detail.companyName || 'Ukendt'}</strong>
</div>
<div>
<span>Stilling</span>
<strong>{detail.occupationName || 'Ikke angivet'}</strong>
</div>
<div>
<span>Website</span>
<strong>{detail.websiteUrl || 'Ikke angivet'}</strong>
</div>
</div>
</article>
) : null}
<article className="glass-panel dash-card job-detail-actions">
<h4>Handlinger</h4>
<button
type="button"
className={detail?.isSaved ? 'job-action-btn save active' : 'job-action-btn save'}
onClick={() => void handleToggleSave()}
disabled={!detail || isSaving}
>
<span className="job-action-icon" aria-hidden></span>
<span>{isSaving ? 'Gemmer...' : detail?.isSaved ? 'Fjern gemt job' : 'Gem job'}</span>
</button>
<button
type="button"
className="job-action-btn website"
onClick={() => {
if (detail?.websiteUrl) {
window.open(detail.websiteUrl, '_blank', 'noopener,noreferrer');
}
}}
disabled={!detail?.websiteUrl}
>
<span className="job-action-icon" aria-hidden></span>
<span>Åbn hjemmeside</span>
</button>
<button
type="button"
className={detail?.isApplied ? 'job-action-btn applied active' : 'job-action-btn applied'}
onClick={() => void handleMarkAsApplied()}
disabled={!detail || isApplying || Boolean(detail?.isApplied)}
>
<span className="job-action-icon" aria-hidden></span>
<span>{isApplying ? 'Opdaterer...' : detail?.isApplied ? 'Markeret som søgt' : 'Marker som søgt'}</span>
</button>
<button
type="button"
className="job-action-btn simulator"
onClick={() => window.alert('Interview-træning kobles på i næste step.')}
>
<span className="job-action-icon" aria-hidden></span>
<span>Træn jobsamtale</span>
</button>
<button
type="button"
className="job-action-btn application"
onClick={() => window.alert('Ansøgningsgenerator kobles på i næste step.')}
>
<span className="job-action-icon" aria-hidden></span>
<span>Generer ansøgning</span>
</button>
</article>
</aside>
</section>
</div>
</aside>
</div>
</>
) : null}
</main>
</section>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,373 @@
.job-detail-back-btn {
border: 1px solid rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.62);
border-radius: 999px;
padding: 8px 13px;
display: inline-flex;
align-items: center;
gap: 8px;
color: #4b5563;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
}
.job-detail-back-btn:hover {
background: rgba(255, 255, 255, 0.84);
color: #111827;
}
.job-detail-back-row {
margin-bottom: 16px;
}
.job-detail-header {
margin-bottom: 24px;
display: flex;
align-items: flex-start;
gap: 18px;
}
.job-detail-logo-wrap {
width: 80px;
height: 80px;
border-radius: 18px;
overflow: hidden;
flex-shrink: 0;
}
.job-detail-logo-image,
.job-detail-logo-fallback {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 18px;
border: 1px solid rgba(229, 231, 235, 0.9);
}
.job-detail-logo-fallback {
background: #ffffff;
display: grid;
place-items: center;
color: #111827;
font-size: 1.9rem;
font-weight: 600;
}
.job-detail-heading h1 {
margin: 0 0 10px;
font-size: clamp(2rem, 3.8vw, 2.8rem);
letter-spacing: -0.03em;
color: #111827;
}
.job-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
}
.job-detail-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
color: #6b7280;
font-size: 0.86rem;
}
.job-detail-grid {
display: grid;
grid-template-columns: minmax(0, 2.35fr) minmax(0, 0.65fr);
gap: 24px;
padding-bottom: 20px;
}
.job-detail-main-card {
display: grid;
gap: 20px;
}
.job-detail-section h2,
.job-detail-section h3 {
margin: 0 0 10px;
color: #111827;
font-weight: 500;
letter-spacing: -0.01em;
}
.job-detail-section h2 {
font-size: 1.2rem;
}
.job-detail-section h3 {
font-size: 1rem;
}
.job-detail-section p {
margin: 0;
color: #4b5563;
line-height: 1.65;
font-size: 0.95rem;
}
.job-detail-description-list {
display: grid;
gap: 10px;
}
.job-detail-rich-html {
color: #4b5563;
line-height: 1.65;
font-size: 0.95rem;
}
.job-detail-rich-html p,
.job-detail-rich-html ul,
.job-detail-rich-html ol {
margin: 0 0 12px;
}
.job-detail-rich-html ul,
.job-detail-rich-html ol {
padding-left: 20px;
}
.job-detail-rich-html li {
margin-bottom: 6px;
}
.job-detail-rich-html a {
color: #0f766e;
}
.job-detail-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.job-detail-info-grid > div {
border: 1px solid rgba(229, 231, 235, 0.82);
background: rgba(255, 255, 255, 0.64);
border-radius: 12px;
padding: 11px 12px;
display: grid;
gap: 4px;
}
.job-detail-info-grid span {
color: #6b7280;
font-size: 0.76rem;
}
.job-detail-info-grid strong {
color: #111827;
font-size: 0.86rem;
font-weight: 600;
}
.job-detail-side-col {
display: grid;
gap: 16px;
align-content: start;
}
.job-detail-actions-card {
display: grid;
gap: 10px;
position: static;
}
.job-detail-actions-card h2 {
margin: 0 0 4px;
color: #111827;
font-size: 1.03rem;
font-weight: 500;
}
.job-detail-action-primary,
.job-detail-action-secondary {
border-radius: 14px;
border: 1px solid;
padding: 11px 12px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.86rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
.job-detail-action-primary {
color: #0f766e;
border-color: #99f6e4;
background: linear-gradient(to right, #ecfeff, #f0fdfa);
}
.job-detail-action-primary:hover {
background: linear-gradient(to right, #cffafe, #ccfbf1);
}
.job-detail-action-secondary {
color: #3730a3;
border-color: #c7d2fe;
background: linear-gradient(to right, #eef2ff, #f5f3ff);
}
.job-detail-action-secondary:hover {
background: linear-gradient(to right, #e0e7ff, #ede9fe);
}
.job-detail-action-primary span,
.job-detail-action-secondary span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.job-detail-action-divider {
height: 1px;
background: rgba(229, 231, 235, 0.84);
margin: 3px 0;
}
.job-detail-text-action {
border: 0;
background: transparent;
display: inline-flex;
align-items: center;
gap: 8px;
color: #4b5563;
border-radius: 10px;
padding: 9px 10px;
font-size: 0.84rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.job-detail-text-action:hover {
background: rgba(249, 250, 251, 0.92);
color: #111827;
}
.job-detail-text-action:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.job-detail-text-action.link {
justify-content: flex-start;
}
.job-detail-text-action.success:hover {
background: #ecfdf5;
color: #047857;
}
.job-detail-text-action.success.is-done {
color: #059669;
}
.job-detail-company-card h3 {
margin: 0 0 12px;
color: #111827;
font-size: 1rem;
font-weight: 500;
}
.job-detail-company-list {
display: grid;
gap: 10px;
}
.job-detail-company-list > div {
border: 1px solid rgba(229, 231, 235, 0.82);
background: rgba(255, 255, 255, 0.64);
border-radius: 12px;
padding: 10px 12px;
display: grid;
gap: 4px;
}
.job-detail-company-list span {
color: #6b7280;
font-size: 0.74rem;
}
.job-detail-company-list strong {
color: #111827;
font-size: 0.86rem;
}
.theme-dark .job-detail-back-btn {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: #d1d5db;
}
.theme-dark .job-detail-back-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
}
.theme-dark .job-detail-heading h1,
.theme-dark .job-detail-section h2,
.theme-dark .job-detail-section h3,
.theme-dark .job-detail-actions-card h2,
.theme-dark .job-detail-company-card h3,
.theme-dark .job-detail-info-grid strong,
.theme-dark .job-detail-company-list strong {
color: #ffffff;
}
.theme-dark .job-detail-meta span,
.theme-dark .job-detail-section p,
.theme-dark .job-detail-rich-html,
.theme-dark .job-detail-info-grid span,
.theme-dark .job-detail-company-list span,
.theme-dark .job-detail-text-action {
color: #9ca3af;
}
.theme-dark .job-detail-rich-html a {
color: #2dd4bf;
}
.theme-dark .job-detail-info-grid > div,
.theme-dark .job-detail-company-list > div {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.theme-dark .job-detail-action-divider {
background: rgba(255, 255, 255, 0.08);
}
.theme-dark .job-detail-text-action:hover {
background: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
}
@media (max-width: 1180px) {
.job-detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.job-detail-header {
flex-direction: column;
gap: 12px;
}
.job-detail-logo-wrap {
width: 68px;
height: 68px;
}
.job-detail-info-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,773 @@
.jobs-page-head {
margin-bottom: 28px;
}
.jobs-page-head h1 {
margin: 0 0 8px;
font-size: clamp(2rem, 4vw, 2.9rem);
font-weight: 500;
letter-spacing: -0.03em;
color: #111827;
}
.jobs-page-head p {
margin: 0;
color: #6b7280;
font-size: 1.1rem;
}
.theme-dark .jobs-page-head h1,
.theme-dark .jobs-page-head p,
.theme-dark .jobs-content-head h2 {
color: #ffffff;
}
.theme-dark .jobs-page-head p,
.theme-dark .jobs-content-head span,
.theme-dark .jobs-card-title p,
.theme-dark .jobs-card-description,
.theme-dark .jobs-card-distance {
color: #9ca3af;
}
.jobs-layout-toggle {
border: 1px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.62);
border-radius: 999px;
padding: 8px 12px;
display: inline-flex;
align-items: center;
gap: 8px;
color: #111827;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 0.8rem;
font-weight: 500;
}
.jobs-layout-toggle:hover {
background: rgba(255, 255, 255, 0.84);
}
.theme-dark .jobs-layout-toggle {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: #f3f4f6;
}
.theme-dark .jobs-layout-toggle:hover {
background: rgba(255, 255, 255, 0.08);
}
.jobs-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 24px;
padding-bottom: 24px;
}
.jobs-filter {
position: sticky;
top: 0;
height: fit-content;
}
.theme-dark .jobs-filter,
.theme-dark .jobs-card,
.theme-dark .jobs-top-filters {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.jobs-filter h2 {
margin: 0 0 24px;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 8px;
}
.jobs-filter h2 svg {
color: #0f766e;
}
.theme-dark .jobs-filter h2,
.theme-dark .jobs-top-filter-title h2,
.theme-dark .jobs-card-title h3 {
color: #ffffff;
}
.jobs-filter-block {
margin-bottom: 24px;
}
.jobs-filter-block > label {
display: block;
margin-bottom: 8px;
font-size: 0.9rem;
font-weight: 500;
color: #374151;
}
.theme-dark .jobs-filter-block > label,
.theme-dark .jobs-range-head label,
.theme-dark .jobs-hours-row > div > label {
color: #d1d5db;
}
.jobs-filter-block > p {
margin: 0 0 12px;
font-size: 0.74rem;
color: #6b7280;
}
.jobs-search-wrap {
position: relative;
}
.jobs-search-wrap svg {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
}
.jobs-search-wrap input {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.5);
padding: 10px 14px 10px 40px;
font-size: 0.87rem;
color: #111827;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.theme-dark .jobs-search-wrap input,
.theme-dark .jobs-title-input-wrap,
.theme-dark .jobs-title-input-wrap input,
.theme-dark .jobs-radio-btn,
.theme-dark .jobs-hour-btn {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
color: #d1d5db;
}
.theme-dark .jobs-search-wrap input::placeholder,
.theme-dark .jobs-title-input-wrap input::placeholder {
color: #6b7280;
}
.jobs-search-wrap input::placeholder {
color: #9ca3af;
}
.jobs-search-wrap input:focus {
outline: none;
border-color: rgba(45, 212, 191, 0.9);
background: #ffffff;
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.1);
}
.jobs-separator {
height: 1px;
background: rgba(255, 255, 255, 0.6);
margin-bottom: 24px;
}
.jobs-radio-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.jobs-radio-btn {
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.5);
color: #6b7280;
font-size: 0.84rem;
font-weight: 500;
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
transition: 0.2s ease;
}
.jobs-radio-btn:hover {
background: rgba(255, 255, 255, 0.82);
}
.jobs-radio-btn.active {
color: #0f766e;
background: #f0fdfa;
border-color: #bae6fd;
}
.theme-dark .jobs-radio-btn.active {
color: #2dd4bf;
background: rgba(20, 184, 166, 0.1);
border-color: rgba(20, 184, 166, 0.3);
}
.jobs-range-block {
margin-bottom: 28px;
}
.jobs-range-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.jobs-range-head span {
font-size: 0.84rem;
font-weight: 500;
color: #0f766e;
background: #f0fdfa;
border: 1px solid #ccfbf1;
border-radius: 6px;
padding: 2px 8px;
}
.jobs-filter input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
background: transparent;
}
.jobs-filter input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 20px;
width: 20px;
border-radius: 999px;
background: #ffffff;
border: 2px solid #14b8a6;
cursor: pointer;
margin-top: -8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.jobs-filter input[type='range']::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
border-radius: 2px;
background: rgba(20, 184, 166, 0.2);
}
.jobs-filter input[type='range']:focus {
outline: none;
}
.jobs-range-labels {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.74rem;
color: #9ca3af;
}
.jobs-hours-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.jobs-hour-btn {
width: 40px;
height: 40px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.5);
color: #6b7280;
font-size: 0.84rem;
font-weight: 500;
cursor: pointer;
transition: 0.2s ease;
}
.jobs-hour-btn:hover {
background: rgba(255, 255, 255, 0.82);
}
.jobs-hour-btn.active {
background: #14b8a6;
color: #ffffff;
border-color: #0f766e;
}
.theme-dark .jobs-hour-btn.active {
background: #14b8a6;
border-color: rgba(20, 184, 166, 0.5);
}
.jobs-apply-btn {
width: 100%;
margin-top: 8px;
border: 0;
border-radius: 12px;
background: #111827;
color: #ffffff;
padding: 12px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.16);
}
.theme-dark .jobs-apply-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.jobs-apply-btn:hover {
background: #1f2937;
}
.jobs-content-head {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.jobs-content-head h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 500;
letter-spacing: -0.01em;
color: #111827;
}
.jobs-content-head span {
font-size: 0.9rem;
color: #6b7280;
}
.jobs-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.jobs-card {
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.03);
padding: 24px;
transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
min-height: 100%;
cursor: pointer;
}
.jobs-card:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.jobs-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.jobs-card-logo {
width: 56px;
height: 56px;
border-radius: 16px;
background: #111827;
color: #ffffff;
font-size: 1.4rem;
font-weight: 500;
display: grid;
place-items: center;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12);
}
.jobs-card-logo-image-wrap {
background: #ffffff;
border: 1px solid rgba(229, 231, 235, 0.85);
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.jobs-card-logo-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.jobs-card-title {
margin-bottom: 8px;
}
.jobs-card-title h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.01em;
color: #111827;
transition: color 0.2s ease;
}
.theme-dark .jobs-card-title h3 {
color: #ffffff;
}
.jobs-card:hover .jobs-card-title h3 {
color: #0f766e;
}
.jobs-card-title p {
margin: 2px 0 0;
font-size: 0.86rem;
color: #6b7280;
}
.jobs-card-description {
margin: 0 0 22px;
color: #4b5563;
font-size: 0.86rem;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.jobs-card-bottom {
margin-top: auto;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.65);
display: flex;
align-items: center;
justify-content: space-between;
}
.theme-dark .jobs-card-bottom {
border-top-color: rgba(255, 255, 255, 0.08);
}
.jobs-card-distance {
display: inline-flex;
align-items: center;
gap: 6px;
color: #6b7280;
font-size: 0.78rem;
}
.jobs-card-distance svg {
color: #0f766e;
}
.jobs-card-arrow {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid #e5e7eb;
color: #9ca3af;
background: #ffffff;
display: grid;
place-items: center;
cursor: pointer;
transition: 0.2s ease;
}
.theme-dark .jobs-card-arrow {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #6b7280;
}
.jobs-card:hover .jobs-card-arrow {
background: #f0fdfa;
color: #0f766e;
border-color: #99f6e4;
}
.jobs-load-more-wrap {
margin-top: 28px;
display: flex;
justify-content: center;
}
.jobs-load-more {
border: 1px solid #d1d5db;
border-radius: 12px;
background: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: #4b5563;
padding: 10px 24px;
font-size: 0.86rem;
font-weight: 500;
cursor: pointer;
transition: 0.2s ease;
}
.jobs-load-more:hover {
background: rgba(255, 255, 255, 0.6);
color: #111827;
border-color: #9ca3af;
}
.jobs-top-layout {
display: grid;
gap: 24px;
padding-bottom: 24px;
}
.jobs-top-filters {
padding: 24px;
}
.jobs-top-filter-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.jobs-top-filter-title h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.01em;
}
.jobs-top-filter-title svg {
color: #0f766e;
}
.jobs-top-controls {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr auto;
gap: 20px;
align-items: end;
margin-bottom: 20px;
}
.jobs-filter-block.no-margin {
margin-bottom: 0;
}
.jobs-top-range {
padding-bottom: 4px;
}
.jobs-top-apply {
margin-top: 0;
min-width: 180px;
}
.jobs-title-picker {
position: relative;
}
.jobs-title-input-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 46px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.5);
padding: 6px;
align-items: center;
}
.jobs-title-input-wrap:focus-within {
border-color: rgba(45, 212, 191, 0.9);
background: #ffffff;
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.1);
}
.jobs-title-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border-radius: 10px;
background: #ffffff;
border: 1px solid rgba(229, 231, 235, 0.8);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
font-size: 0.72rem;
font-weight: 500;
color: #374151;
}
.jobs-title-chip button {
border: 0;
background: transparent;
color: #9ca3af;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.jobs-title-chip button:hover {
color: #111827;
}
.jobs-title-input-wrap input {
flex: 1;
min-width: 180px;
border: 0;
background: transparent;
outline: none;
font-size: 0.86rem;
color: #374151;
padding: 6px 8px;
}
.jobs-title-input-wrap input::placeholder {
color: #9ca3af;
}
.jobs-title-suggestions {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(100%, 340px);
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 16px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.1);
padding: 8px;
display: grid;
gap: 4px;
opacity: 0;
visibility: hidden;
transform: scale(0.98);
transform-origin: top left;
transition: 0.18s ease;
z-index: 40;
}
.jobs-title-picker:focus-within .jobs-title-suggestions {
opacity: 1;
visibility: visible;
transform: scale(1);
}
.jobs-title-option {
border: 0;
border-radius: 10px;
background: transparent;
color: #4b5563;
font-size: 0.84rem;
font-weight: 500;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.jobs-title-option:hover {
background: rgba(255, 255, 255, 0.85);
color: #111827;
}
.jobs-title-option.active {
color: #0f766e;
background: rgba(20, 184, 166, 0.1);
}
.jobs-title-option.active svg {
color: #14b8a6;
}
.jobs-separator.top-margin {
margin: 24px 0;
}
.jobs-hours-row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.jobs-hours-row > div > label {
display: block;
margin-bottom: 3px;
font-size: 0.9rem;
font-weight: 500;
color: #374151;
}
.jobs-hours-row > div > p {
margin: 0;
font-size: 0.74rem;
color: #6b7280;
}
.jobs-cards.jobs-cards-top {
grid-template-columns: 1fr 1fr 1fr;
}
@media (max-width: 1200px) {
.jobs-grid {
grid-template-columns: 1fr;
}
.jobs-filter {
position: static;
}
.jobs-top-controls {
grid-template-columns: 1fr 1fr;
}
.jobs-top-apply {
width: 100%;
}
.jobs-hours-row {
flex-direction: column;
align-items: flex-start;
}
.jobs-cards.jobs-cards-top {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 860px) {
.jobs-cards {
grid-template-columns: 1fr;
}
.jobs-top-controls {
grid-template-columns: 1fr;
}
.jobs-cards.jobs-cards-top {
grid-template-columns: 1fr;
}
.jobs-layout-toggle span {
display: none;
}
}

View File

@@ -1,126 +0,0 @@
import { useMemo } from 'react';
interface SidebarItem {
key: string;
label: string;
description: string;
badge?: boolean;
accent?: boolean;
}
interface SidebarProps {
collapsed: boolean;
activeKey: string;
onToggle: () => void;
onSelect?: (key: string) => void;
}
function NavIcon({ itemKey }: { itemKey: string }) {
if (itemKey === 'dashboard') {
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="3" width="8" height="8" rx="2" /><rect x="13" y="3" width="8" height="5" rx="2" /><rect x="13" y="10" width="8" height="11" rx="2" /><rect x="3" y="13" width="8" height="8" rx="2" /></svg>;
}
if (itemKey === 'cv') {
return <svg viewBox="0 0 24 24" aria-hidden><path d="M8 3h8l5 5v13H8z" /><path d="M16 3v5h5" /><path d="M11 13h7" /><path d="M11 17h7" /><circle cx="7" cy="16" r="3" /></svg>;
}
if (itemKey === 'jobs') {
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="7" width="18" height="13" rx="3" /><path d="M9 7V5a3 3 0 0 1 6 0v2" /><path d="M3 12h18" /></svg>;
}
if (itemKey === 'beskeder') {
return <svg viewBox="0 0 24 24" aria-hidden><path d="M4 5h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H9l-5 4v-4H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z" /></svg>;
}
if (itemKey === 'ai-jobagent') {
return (
<svg viewBox="0 0 24 24" aria-hidden>
<path d="M10 6l1.8-3L13.6 6l3.4 1.4-3.4 1.4-1.8 3-1.8-3L6.6 7.4z" />
<path d="M16 12l1-1.8 1 1.8 1.8 1-1.8 1-1 1.8-1-1.8-1.8-1z" />
<path d="M4 18h8" />
<path d="M8 14v8" />
</svg>
);
}
if (itemKey === 'ai-agent') {
return (
<svg viewBox="0 0 24 24" aria-hidden>
<rect x="5" y="7" width="14" height="10" rx="4" />
<path d="M12 3v4" />
<circle cx="10" cy="12" r="1" />
<circle cx="14" cy="12" r="1" />
<path d="M9 15h6" />
</svg>
);
}
if (itemKey === 'simulator') {
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="8" width="18" height="10" rx="4" /><circle cx="8" cy="13" r="1.5" /><path d="M8 11.5v3" /><path d="M6.5 13h3" /><circle cx="16.5" cy="12" r="1.2" /><circle cx="18.8" cy="14.3" r="1.2" /></svg>;
}
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="5" width="18" height="14" rx="3" /><path d="M3 10h18" /><path d="M7 15h4" /></svg>;
}
export function Sidebar({ collapsed, activeKey, onToggle, onSelect }: SidebarProps) {
const items = useMemo<SidebarItem[]>(
() => [
{ key: 'dashboard', label: 'Dashboard', description: 'Oversigt over aktivitet, jobs, beskeder og evalueringer.' },
{ key: 'cv', label: 'CV', description: 'Administrer profil, erfaring, uddannelse og kvalifikationer.' },
{ key: 'jobs', label: 'Jobs', description: 'Find nye job, filtrer resultater og gem relevante stillinger.' },
{ key: 'beskeder', label: 'Beskeder', description: 'Læs og send beskeder med virksomheder og support.', badge: true },
{ key: 'ai-jobagent', label: 'AI JobAgent', description: 'Opsæt jobagent og få AI-baserede jobnotifikationer.', accent: true },
{ key: 'ai-agent', label: 'AI Agent', description: 'Få forslag til at forbedre dit CV og profil-match.', accent: true },
{ key: 'simulator', label: 'Simulator', description: 'Træn jobsamtaler og se interviewforløb.', },
{ key: 'abonnement', label: 'Abonnement', description: 'Se plan, funktioner og status for dit abonnement.' },
],
[],
);
return (
<aside id="sidebar" className={collapsed ? 'dashboard-sidebar collapsed glass-panel' : 'dashboard-sidebar glass-panel'}>
<div className="sidebar-header">
<div className="brand-chip">
<span>Ar</span>
</div>
<span className="sidebar-logo-text">Arbejd</span>
</div>
<nav className="sidebar-nav">
{items.map((item) => {
const active = item.key === activeKey;
const isAiItem = item.key === 'ai-jobagent' || item.key === 'ai-agent';
return (
<button
key={item.key}
type="button"
className={active ? 'sidebar-item nav-item active' : 'sidebar-item nav-item'}
title={item.label}
onClick={() => onSelect?.(item.key)}
>
<span
className={[
'sidebar-icon',
item.accent ? 'accent' : '',
isAiItem ? 'ai-spark' : '',
].join(' ').trim()}
>
<NavIcon itemKey={item.key} />
</span>
<span className="sidebar-label nav-text">{item.label}</span>
{item.badge && <span className="sidebar-badge" />}
<span className="sidebar-tooltip" aria-hidden>
<strong>
<span className={['sidebar-tooltip-icon', item.accent ? 'accent' : '', isAiItem ? 'ai-spark' : ''].join(' ').trim()}>
<NavIcon itemKey={item.key} />
</span>
{item.label}
</strong>
<small>{item.description}</small>
</span>
</button>
);
})}
</nav>
<div className="sidebar-footer">
<button type="button" className="sidebar-toggle" onClick={onToggle}>
{collapsed ? '→' : '←'}
</button>
</div>
</aside>
);
}

View File

@@ -1,35 +0,0 @@
import { useEffect, useState } from 'react';
interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const [isDark, setIsDark] = useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem('ui-theme') === 'dark';
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const { body } = window.document;
body.classList.toggle('theme-dark', isDark);
window.localStorage.setItem('ui-theme', isDark ? 'dark' : 'light');
}, [isDark]);
return (
<button
className={className ? `topbar-theme-toggle ${className}` : 'topbar-theme-toggle'}
type="button"
aria-label={isDark ? 'Skift til lyst tema' : 'Skift til mørkt tema'}
onClick={() => setIsDark((prev) => !prev)}
>
<span className={!isDark ? 'is-active' : undefined}></span>
<span className={isDark ? 'is-active' : undefined}></span>
</button>
);
}

View File

@@ -1,107 +0,0 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ThemeToggle } from './ThemeToggle';
import { CandidateService } from '../../../mvvm/services/candidate.service';
import type { CandidateInterface } from '../../../mvvm/models/candidate.interface';
interface TopbarProps {
title: string;
userName: string;
planLabel: string;
onLogout: () => Promise<void>;
actions?: ReactNode;
}
let cachedCandidate: CandidateInterface | null = null;
function buildFullName(candidate: CandidateInterface): string {
const first = candidate.firstName?.trim() ?? '';
const last = candidate.lastName?.trim() ?? '';
const combined = `${first} ${last}`.trim();
if (combined) {
return combined;
}
return candidate.name?.trim() || '';
}
function resolveImage(candidate: CandidateInterface): string | null {
const imageUrl = candidate.imageUrl?.trim();
if (imageUrl) {
return imageUrl;
}
const image = candidate.image?.trim();
return image || null;
}
export function Topbar({ title, userName, planLabel, onLogout, actions }: TopbarProps) {
const [candidate, setCandidate] = useState<CandidateInterface | null>(cachedCandidate);
const candidateService = useMemo(() => new CandidateService(), []);
useEffect(() => {
let active = true;
if (cachedCandidate) {
setCandidate(cachedCandidate);
return () => {
active = false;
};
}
void candidateService.getCandidate()
.then((response) => {
if (!active || !response) {
return;
}
cachedCandidate = response;
setCandidate(response);
})
.catch(() => {
// Keep existing fallback display values.
});
return () => {
active = false;
};
}, [candidateService]);
const fullName = candidate ? buildFullName(candidate) : userName;
const avatarSrc = candidate ? resolveImage(candidate) : null;
return (
<header className="dashboard-topbar">
<div className="topbar-left">
<div className="topbar-home-dot"></div>
<h2>{title}</h2>
</div>
<div className="topbar-right">
{actions}
<ThemeToggle />
<div className="topbar-profile-wrap">
<button className="topbar-profile glass-button" type="button">
{avatarSrc ? (
<img className="avatar-img" src={avatarSrc} alt={fullName} />
) : (
<div className="avatar">{fullName.trim().slice(0, 1).toUpperCase() || 'A'}</div>
)}
<div className="profile-text">
<span>{fullName}</span>
<small>{planLabel}</small>
</div>
<span className="profile-caret"></span>
</button>
<div className="topbar-dropdown">
<button className="dropdown-item" type="button">Profile</button>
<button className="dropdown-item" type="button">Settings</button>
<button className="dropdown-item" type="button">Notifications</button>
<div className="dropdown-divider" />
<button className="dropdown-item danger" type="button" onClick={() => void onLogout()}>
Log ud
</button>
</div>
</div>
</div>
</header>
);
}

View File

@@ -1,89 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { MessagesViewModel, type MessageThreadItem } from '../../../mvvm/viewmodels/MessagesViewModel';
export function useMessagesViewModel() {
const viewModel = useMemo(() => new MessagesViewModel(), []);
const [threads, setThreads] = useState<MessageThreadItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSending, setIsSending] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [nextThreads, nextUnreadCount] = await Promise.all([
viewModel.getThreads(),
viewModel.getUnreadCount().catch(() => 0),
]);
setThreads(nextThreads);
setUnreadCount(nextUnreadCount);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load messages.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
const sendMessage = useCallback(
async (threadId: string, text: string) => {
if (!text.trim()) {
return;
}
setIsSending(true);
setError(null);
try {
await viewModel.sendMessage(threadId, text);
await load();
} catch (sendError) {
setError(sendError instanceof Error ? sendError.message : 'Could not send message.');
} finally {
setIsSending(false);
}
},
[load, viewModel],
);
const markThreadRead = useCallback(
async (messageId?: string) => {
try {
await viewModel.markThreadReadByMessageId(messageId);
setThreads((prev) =>
prev.map((thread) => {
if (!messageId) {
return thread;
}
const updatedMessages = thread.allMessages.map((message) =>
message.id === messageId || (!message.fromCandidate && !message.seen)
? { ...message, seen: new Date() }
: message,
);
return {
...thread,
allMessages: updatedMessages,
latestMessage:
thread.latestMessage?.id === messageId
? { ...thread.latestMessage, seen: new Date() }
: thread.latestMessage,
};
}),
);
} catch {
// no-op: read receipt should not block UI
}
},
[viewModel],
);
return {
threads,
unreadCount,
isLoading,
isSending,
error,
load,
sendMessage,
markThreadRead,
};
}

View File

@@ -1,202 +0,0 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { useMessagesViewModel } from '../hooks/useMessagesViewModel';
interface BeskederPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
}
function formatTime(value?: Date | string): string {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString('da-DK', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
function companyInitial(name?: string): string {
const trimmed = (name ?? '').trim();
return trimmed.length > 0 ? trimmed.slice(0, 1).toUpperCase() : 'A';
}
export function BeskederPage({ onLogout, onNavigate }: BeskederPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [selectedThreadId, setSelectedThreadId] = useState<string>('');
const [draftMessage, setDraftMessage] = useState('');
const { threads, unreadCount, isLoading, isSending, error, load, sendMessage, markThreadRead } = useMessagesViewModel();
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (threads.length === 0) {
setSelectedThreadId('');
return;
}
setSelectedThreadId((current) => {
if (current && threads.some((item) => item.id === current)) {
return current;
}
return threads[0].id;
});
}, [threads]);
const selectedThread = useMemo(
() => threads.find((item) => item.id === selectedThreadId) ?? null,
[threads, selectedThreadId],
);
const hasMessages = (selectedThread?.allMessages?.length ?? 0) > 0;
async function handleSelectThread(threadId: string) {
setSelectedThreadId(threadId);
const thread = threads.find((item) => item.id === threadId);
const firstUnreadIncoming = thread?.allMessages.find((msg) => !msg.fromCandidate && !msg.seen && msg.id);
if (firstUnreadIncoming?.id) {
await markThreadRead(firstUnreadIncoming.id);
}
}
async function handleSubmitMessage(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedThread || !draftMessage.trim() || isSending) {
return;
}
const threadId = selectedThread.id;
const text = draftMessage;
setDraftMessage('');
await sendMessage(threadId, text);
}
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="beskeder"
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
onSelect={(key) => {
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
onNavigate(key);
}
}}
/>
<main className="dashboard-main">
<Topbar title="Beskeder" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<section className="messages-layout">
<aside className="glass-panel dash-card messages-threads-card">
<div className="messages-list-head">
<h4>Samtaler</h4>
<span className="messages-unread-chip">Ulæste: {unreadCount}</span>
</div>
{isLoading ? <p>Indlæser beskeder...</p> : null}
{error ? <p className="status error">{error}</p> : null}
{!isLoading && threads.length === 0 ? <p>Ingen beskeder endnu.</p> : null}
<div className="messages-thread-list">
{threads.map((thread) => {
const active = thread.id === selectedThreadId;
const unseenIncoming = thread.allMessages.some((message) => !message.fromCandidate && !message.seen);
return (
<button
key={thread.id}
type="button"
className={active ? 'messages-thread-item active' : 'messages-thread-item'}
onClick={() => void handleSelectThread(thread.id)}
>
<div className="messages-thread-avatar-wrap">
{thread.companyLogoUrl ? (
<img src={thread.companyLogoUrl} alt={thread.companyName} className="messages-thread-avatar" />
) : (
<div className="messages-thread-avatar messages-thread-avatar-fallback">
{companyInitial(thread.companyName)}
</div>
)}
{unseenIncoming ? <span className="messages-thread-dot" /> : null}
</div>
<div className="messages-thread-content">
<div className="messages-thread-row">
<strong>{thread.companyName || 'Firma'}</strong>
<span>{formatTime(thread.latestMessage?.timeSent)}</span>
</div>
<p className="messages-thread-title">{thread.title || thread.jobPosting?.title || 'Samtale'}</p>
<p className="messages-thread-preview">{thread.latestMessage?.text || 'Ingen besked'}</p>
</div>
</button>
);
})}
</div>
</aside>
<article className="glass-panel dash-card messages-chat-card">
{selectedThread ? (
<>
<header className="messages-chat-head">
<div>
<h4>{selectedThread.companyName}</h4>
<p>{selectedThread.title || selectedThread.jobPosting?.title || 'Samtale om job'}</p>
</div>
<button className="secondary-btn" type="button" onClick={() => void load()}>
Opdater
</button>
</header>
<div className="messages-chat-scroll">
{hasMessages ? (
selectedThread.allMessages.map((message, index) => {
const own = message.fromCandidate;
return (
<div
key={message.id ?? `${selectedThread.id}-${index}`}
className={own ? 'message-bubble message-bubble-own' : 'message-bubble'}
>
<p>{message.text}</p>
<span>{formatTime(message.timeSent)}</span>
</div>
);
})
) : (
<p>Ingen beskeder i denne tråd endnu.</p>
)}
</div>
<form className="messages-composer" onSubmit={(event) => void handleSubmitMessage(event)}>
<input
className="field-input messages-composer-input"
value={draftMessage}
onChange={(event) => setDraftMessage(event.target.value)}
placeholder="Skriv en besked..."
/>
<button className="primary-btn" type="submit" disabled={isSending || !draftMessage.trim()}>
{isSending ? 'Sender...' : 'Send'}
</button>
</form>
</>
) : (
<p>Vælg en samtale for at se beskeder.</p>
)}
</article>
</section>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,461 @@
import { useEffect, useMemo, useState } from 'react';
import {
CheckCheck,
Info,
Paperclip,
Phone,
Search,
Send,
Smile,
} from 'lucide-react';
import type { ChatMessageInterface } from '../../../mvvm/models/chat-message.interface';
import { MessagesViewModel, type MessageThreadItem } from '../../../mvvm/viewmodels/MessagesViewModel';
import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar';
import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar';
import '../../dashboard/pages/dashboard.css';
import './messages.css';
interface MessagesPageProps {
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
type ThreadFilter = 'all' | 'unread' | 'companies';
function toMillis(value?: Date | string): number {
if (!value) {
return 0;
}
const date = value instanceof Date ? value : new Date(value);
const millis = date.getTime();
return Number.isNaN(millis) ? 0 : millis;
}
function formatTime(value?: Date | string): string {
if (!value) {
return '--:--';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '--:--';
}
return new Intl.DateTimeFormat('da-DK', { hour: '2-digit', minute: '2-digit' }).format(date);
}
function formatThreadDate(value?: Date | string): string {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const now = new Date();
const dayMs = 24 * 60 * 60 * 1000;
const diffDays = Math.floor((new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() - new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()) / dayMs);
if (diffDays === 0) {
return formatTime(date);
}
if (diffDays === 1) {
return 'I går';
}
return new Intl.DateTimeFormat('da-DK', { day: '2-digit', month: 'short' }).format(date);
}
function dayLabel(value: Date): string {
const now = new Date();
const dateOnly = new Date(value.getFullYear(), value.getMonth(), value.getDate());
const nowOnly = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diff = Math.floor((nowOnly.getTime() - dateOnly.getTime()) / (24 * 60 * 60 * 1000));
if (diff === 0) {
return 'I dag';
}
if (diff === 1) {
return 'I går';
}
return new Intl.DateTimeFormat('da-DK', { day: '2-digit', month: 'short' }).format(value);
}
function isUnreadMessage(message: ChatMessageInterface): boolean {
return !message.fromCandidate && !message.seen;
}
function threadUnreadCount(thread: MessageThreadItem): number {
return thread.allMessages.filter(isUnreadMessage).length;
}
function threadAvatar(thread: MessageThreadItem): string {
return thread.companyLogoUrl || thread.companyLogo || '';
}
function normalizeThread(thread: MessageThreadItem): MessageThreadItem {
return {
...thread,
allMessages: [...(thread.allMessages ?? [])].sort((a, b) => toMillis(a.timeSent) - toMillis(b.timeSent)),
};
}
function fallbackThreads(): MessageThreadItem[] {
const now = new Date();
const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000);
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
const makeMessage = (threadId: string, text: string, fromCandidate: boolean, when: Date, seen?: Date): ChatMessageInterface => ({
threadId,
text,
fromCandidate,
timeSent: when,
seen,
});
const t1Messages = [
makeMessage('thread-techcorp', 'Hej Lasse! Mange tak for din ansøgning.', false, twoHoursAgo),
makeMessage('thread-techcorp', 'Mange tak, det lyder rigtig spændende.', true, new Date(twoHoursAgo.getTime() + 20 * 60 * 1000), new Date(twoHoursAgo.getTime() + 30 * 60 * 1000)),
makeMessage('thread-techcorp', 'Vi vil gerne invitere dig til samtale.', false, tenMinutesAgo),
];
const t2Messages = [
makeMessage('thread-lunar', 'Mange tak for din opdaterede portefølje.', false, new Date(now.getTime() - 26 * 60 * 60 * 1000), new Date(now.getTime() - 25 * 60 * 60 * 1000)),
];
return [
{
id: 'thread-techcorp',
companyLogo: '',
companyLogoUrl: 'https://i.pravatar.cc/150?img=33',
companyName: 'TechCorp A/S',
candidateFirstName: 'Lasse',
candidateLastName: 'Hansen',
candidateImage: 'https://i.pravatar.cc/150?img=11',
allMessages: t1Messages,
latestMessage: t1Messages[t1Messages.length - 1],
title: 'Frontend Udvikler',
messagesLoaded: true,
jobPostingId: 'job-1',
jobPosting: undefined as never,
isFromSupport: false,
},
{
id: 'thread-lunar',
companyLogo: '',
companyLogoUrl: 'https://i.pravatar.cc/150?img=12',
companyName: 'Lunar Bank',
candidateFirstName: 'Lasse',
candidateLastName: 'Hansen',
candidateImage: 'https://i.pravatar.cc/150?img=11',
allMessages: t2Messages,
latestMessage: t2Messages[t2Messages.length - 1],
title: 'Senior UX Designer',
messagesLoaded: true,
jobPostingId: 'job-2',
jobPosting: undefined as never,
isFromSupport: false,
},
];
}
export function MessagesPage({ onLogout, onNavigate, onToggleTheme, theme }: MessagesPageProps) {
const viewModel = useMemo(() => new MessagesViewModel(), []);
const [name, setName] = useState('Lasse');
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const [threads, setThreads] = useState<MessageThreadItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ThreadFilter>('all');
const [activeThreadId, setActiveThreadId] = useState<string>('');
const [draft, setDraft] = useState('');
useEffect(() => {
let active = true;
async function loadData() {
setIsLoading(true);
try {
const profile = await viewModel.getCandidateProfile();
if (active) {
setName(profile.name);
setImageUrl(profile.imageUrl);
}
const loadedThreads = await viewModel.getThreads();
if (!active) {
return;
}
const normalized = (loadedThreads.length > 0 ? loadedThreads : fallbackThreads()).map(normalizeThread);
setThreads(normalized);
setActiveThreadId(normalized[0]?.id || '');
} catch {
if (!active) {
return;
}
const fallback = fallbackThreads();
setThreads(fallback);
setActiveThreadId(fallback[0]?.id || '');
} finally {
if (active) {
setIsLoading(false);
}
}
}
void loadData();
return () => {
active = false;
};
}, [viewModel]);
const filteredThreads = useMemo(() => {
const term = search.trim().toLowerCase();
return threads.filter((thread) => {
if (filter === 'unread' && threadUnreadCount(thread) === 0) {
return false;
}
if (filter === 'companies' && thread.isFromSupport) {
return false;
}
if (!term) {
return true;
}
return thread.companyName.toLowerCase().includes(term) || (thread.latestMessage?.text || '').toLowerCase().includes(term);
});
}, [filter, search, threads]);
const activeThread = useMemo(
() => threads.find((thread) => thread.id === activeThreadId) || filteredThreads[0],
[activeThreadId, filteredThreads, threads],
);
const messages = useMemo(
() => [...(activeThread?.allMessages || [])].sort((a, b) => toMillis(a.timeSent) - toMillis(b.timeSent)),
[activeThread],
);
async function handleSelectThread(thread: MessageThreadItem) {
setActiveThreadId(thread.id);
const lastUnread = [...thread.allMessages].reverse().find((item) => isUnreadMessage(item));
if (lastUnread?.id) {
void viewModel.markThreadReadByMessageId(lastUnread.id);
setThreads((current) => current.map((entry) => {
if (entry.id !== thread.id) {
return entry;
}
return {
...entry,
allMessages: entry.allMessages.map((item) => (isUnreadMessage(item) ? { ...item, seen: new Date() } : item)),
};
}));
}
}
async function handleMarkAllRead() {
const latestUnread = threads
.flatMap((thread) => thread.allMessages)
.filter((item) => isUnreadMessage(item) && Boolean(item.id));
await Promise.all(latestUnread.map((item) => viewModel.markThreadReadByMessageId(item.id)));
setThreads((current) => current.map((thread) => ({
...thread,
allMessages: thread.allMessages.map((item) => (isUnreadMessage(item) ? { ...item, seen: new Date() } : item)),
})));
}
async function handleSendMessage() {
const text = draft.trim();
if (!activeThread || !text) {
return;
}
const optimistic: ChatMessageInterface = {
threadId: activeThread.id,
text,
fromCandidate: true,
timeSent: new Date(),
};
setDraft('');
setThreads((current) => current.map((thread) => {
if (thread.id !== activeThread.id) {
return thread;
}
const nextMessages = [...thread.allMessages, optimistic];
return {
...thread,
allMessages: nextMessages,
latestMessage: optimistic,
};
}));
try {
const created = await viewModel.sendMessage(activeThread.id, text);
setThreads((current) => current.map((thread) => {
if (thread.id !== activeThread.id) {
return thread;
}
const withoutOptimistic = thread.allMessages.filter((item) => item !== optimistic);
const nextMessages = [...withoutOptimistic, created];
return {
...thread,
allMessages: nextMessages,
latestMessage: created,
};
}));
} catch {
// Keep optimistic message visible if backend send fails.
}
}
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="messages" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar msg-main">
<DashboardTopbar
name={name}
imageUrl={imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
/>
<div className="msg-head">
<div>
<h1>Beskeder</h1>
<p>Kommuniker med virksomheder og hold styr dine ansøgninger.</p>
</div>
<button type="button" className="msg-mark-btn" onClick={() => void handleMarkAllRead()}>
<CheckCheck size={16} strokeWidth={1.8} /> Marker alle som læst
</button>
</div>
<div className="msg-layout">
<section className="msg-threads">
<div className="msg-threads-head">
<div className="msg-search-wrap">
<Search size={16} strokeWidth={1.8} />
<input value={search} onChange={(event) => setSearch(event.target.value)} type="text" placeholder="Søg i beskeder..." />
</div>
<div className="msg-filter-row">
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
<button type="button" className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>Ulæste</button>
<button type="button" className={filter === 'companies' ? 'active' : ''} onClick={() => setFilter('companies')}>Virksomheder</button>
</div>
</div>
<div className="msg-thread-list custom-scrollbar">
{isLoading ? <p className="dash-loading">Indlaeser beskeder...</p> : null}
{!isLoading && filteredThreads.length === 0 ? <p className="dash-loading">Ingen tråde fundet.</p> : null}
{filteredThreads.map((thread) => {
const unread = threadUnreadCount(thread);
const isActive = activeThread?.id === thread.id;
const avatar = threadAvatar(thread);
return (
<button type="button" key={thread.id} className={isActive ? 'msg-thread-item active' : 'msg-thread-item'} onClick={() => void handleSelectThread(thread)}>
<div className="msg-thread-avatar-wrap">
{avatar ? <img src={avatar} alt={thread.companyName} className="msg-thread-avatar" /> : <div className="msg-thread-avatar-fallback">{thread.companyName.slice(0, 1).toUpperCase()}</div>}
<span className="msg-thread-online" />
</div>
<div className="msg-thread-content">
<div className="msg-thread-row">
<h3>{thread.companyName}</h3>
<span>{formatThreadDate(thread.latestMessage?.timeSent)}</span>
</div>
<p className={unread > 0 ? 'unread' : ''}>{thread.latestMessage?.text || 'Ingen beskeder endnu'}</p>
<small>{thread.title || 'Stilling'}</small>
</div>
{unread > 0 ? <div className="msg-thread-unread">{unread}</div> : null}
</button>
);
})}
</div>
</section>
<section className="msg-chat">
<div className="msg-chat-head">
{activeThread ? (
<>
<div className="msg-chat-company">
{threadAvatar(activeThread)
? <img src={threadAvatar(activeThread)} alt={activeThread.companyName} className="msg-chat-avatar" />
: <div className="msg-chat-avatar-fallback">{activeThread.companyName.slice(0, 1).toUpperCase()}</div>}
<div>
<h2>{activeThread.companyName}</h2>
<p>{activeThread.title || 'Rekruttering'}</p>
</div>
</div>
<div className="msg-chat-actions">
<button type="button" aria-label="Ring"><Phone size={16} strokeWidth={1.8} /></button>
<button type="button" aria-label="Info"><Info size={16} strokeWidth={1.8} /></button>
</div>
</>
) : (
<h2>Vælg en samtale</h2>
)}
</div>
<div className="msg-chat-body custom-scrollbar">
{messages.map((message, index) => {
const currentTime = message.timeSent instanceof Date ? message.timeSent : new Date(message.timeSent);
const prev = index > 0 ? messages[index - 1] : undefined;
const prevTime = prev?.timeSent instanceof Date ? prev.timeSent : (prev?.timeSent ? new Date(prev.timeSent) : undefined);
const showDay = !prevTime || currentTime.toDateString() !== prevTime.toDateString();
return (
<div key={`${message.threadId}-${index}`}>
{showDay ? <div className="msg-day-sep">{dayLabel(currentTime)}</div> : null}
<div className={message.fromCandidate ? 'msg-bubble-row mine' : 'msg-bubble-row'}>
{!message.fromCandidate ? (
threadAvatar(activeThread as MessageThreadItem)
? <img src={threadAvatar(activeThread as MessageThreadItem)} alt={(activeThread as MessageThreadItem).companyName} className="msg-mini-avatar" />
: <div className="msg-mini-avatar msg-mini-avatar-fallback">{(activeThread as MessageThreadItem).companyName.slice(0, 1).toUpperCase()}</div>
) : null}
<div className="msg-bubble-wrap">
<span className="msg-time">{formatTime(message.timeSent)}</span>
<div className={message.fromCandidate ? 'msg-bubble mine' : 'msg-bubble'}>{message.text}</div>
</div>
</div>
</div>
);
})}
</div>
<div className="msg-input-area">
<div className="msg-input-wrap">
<button type="button" aria-label="Vedhæft"><Paperclip size={18} strokeWidth={1.8} /></button>
<textarea
rows={1}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSendMessage();
}
}}
placeholder="Skriv din besked her..."
/>
<button type="button" aria-label="Emoji"><Smile size={18} strokeWidth={1.8} /></button>
<button type="button" className="msg-send-btn" onClick={() => void handleSendMessage()}>
Send <Send size={15} strokeWidth={1.8} />
</button>
</div>
</div>
</section>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,638 @@
.msg-main {
display: flex;
flex-direction: column;
}
.msg-head {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-end;
}
.msg-head h1 {
margin: 0 0 8px;
font-size: clamp(2rem, 4vw, 2.9rem);
font-weight: 500;
letter-spacing: -0.03em;
}
.msg-head p {
margin: 0;
color: #6b7280;
font-size: 1.05rem;
}
.theme-dark .msg-head h1,
.theme-dark .msg-head p {
color: #ffffff;
}
.theme-dark .msg-head p {
color: #9ca3af;
}
.msg-mark-btn {
border: 1px solid rgba(229, 231, 235, 0.85);
background: #fff;
color: #374151;
border-radius: 12px;
padding: 10px 16px;
font-size: 0.84rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
.msg-mark-btn:hover {
background: #f9fafb;
}
.theme-dark .msg-mark-btn {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: #d1d5db;
}
.theme-dark .msg-mark-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.msg-layout {
flex: 1;
min-height: 500px;
margin-bottom: 4px;
display: flex;
gap: 24px;
}
.msg-threads {
width: 33.333%;
min-width: 330px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
overflow: hidden;
}
.theme-dark .msg-threads,
.theme-dark .msg-chat {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.msg-threads-head {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.62);
}
.msg-search-wrap {
position: relative;
margin-bottom: 10px;
}
.msg-search-wrap svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
}
.msg-search-wrap input {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.5);
padding: 10px 12px 10px 38px;
font-size: 0.86rem;
color: #111827;
}
.theme-dark .msg-search-wrap input {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
}
.msg-search-wrap input:focus {
outline: none;
border-color: rgba(45, 212, 191, 0.9);
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.1);
}
.msg-filter-row {
display: flex;
gap: 8px;
}
.msg-filter-row button {
border: 1px solid rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.65);
color: #4b5563;
border-radius: 10px;
padding: 6px 12px;
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
}
.msg-filter-row button.active {
background: #f0fdfa;
border-color: #ccfbf1;
color: #0f766e;
}
.theme-dark .msg-filter-row button {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.08);
color: #9ca3af;
}
.theme-dark .msg-filter-row button.active {
background: rgba(20, 184, 166, 0.1);
border-color: rgba(20, 184, 166, 0.3);
color: #2dd4bf;
}
.msg-thread-list {
padding: 10px;
overflow-y: auto;
display: grid;
gap: 6px;
}
.msg-thread-item {
width: 100%;
border: 1px solid transparent;
background: transparent;
border-radius: 16px;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
text-align: left;
cursor: pointer;
position: relative;
}
.msg-thread-item:hover {
background: rgba(255, 255, 255, 0.42);
border-color: rgba(255, 255, 255, 0.62);
}
.theme-dark .msg-thread-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.msg-thread-item.active {
background: rgba(255, 255, 255, 0.82);
border-color: rgba(255, 255, 255, 1);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
}
.theme-dark .msg-thread-item.active {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.12);
}
.msg-thread-item.active::before {
content: '';
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 4px;
border-radius: 999px;
background: #14b8a6;
}
.msg-thread-avatar-wrap {
width: 44px;
height: 44px;
position: relative;
flex-shrink: 0;
}
.msg-thread-avatar,
.msg-thread-avatar-fallback {
width: 44px;
height: 44px;
border-radius: 999px;
object-fit: cover;
}
.msg-thread-avatar-fallback {
background: linear-gradient(135deg, #c7d2fe, #e9d5ff);
color: #4338ca;
display: grid;
place-items: center;
font-size: 0.84rem;
font-weight: 600;
}
.msg-thread-online {
position: absolute;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
border-radius: 999px;
border: 2px solid #fff;
background: #22c55e;
}
.msg-thread-content {
min-width: 0;
flex: 1;
}
.msg-thread-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.msg-thread-row h3 {
margin: 0;
font-size: 0.86rem;
font-weight: 500;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theme-dark .msg-thread-row h3,
.theme-dark .msg-chat-head h2 {
color: #ffffff;
}
.msg-thread-row span {
font-size: 0.72rem;
color: #9ca3af;
flex-shrink: 0;
}
.msg-thread-content p {
margin: 0;
color: #6b7280;
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theme-dark .msg-thread-content p,
.theme-dark .msg-thread-content small,
.theme-dark .msg-thread-row span {
color: #9ca3af;
}
.msg-thread-content p.unread {
color: #1f2937;
font-weight: 500;
}
.msg-thread-content small {
margin-top: 2px;
display: inline-block;
color: #9ca3af;
font-size: 0.72rem;
}
.msg-thread-unread {
width: 20px;
height: 20px;
border-radius: 999px;
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
font-size: 0.64rem;
font-weight: 600;
flex-shrink: 0;
margin-top: 2px;
}
.msg-chat {
width: 66.667%;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 24px;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
overflow: hidden;
}
.msg-chat-head {
padding: 14px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.62);
background: rgba(255, 255, 255, 0.3);
display: flex;
justify-content: space-between;
align-items: center;
}
.theme-dark .msg-chat-head {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.msg-chat-head h2 {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: #111827;
}
.msg-chat-company {
display: flex;
align-items: center;
gap: 12px;
}
.msg-chat-company p {
margin: 2px 0 0;
color: #0f766e;
font-size: 0.74rem;
font-weight: 500;
}
.theme-dark .msg-chat-company p {
color: #2dd4bf;
}
.msg-chat-avatar,
.msg-chat-avatar-fallback {
width: 40px;
height: 40px;
border-radius: 999px;
object-fit: cover;
}
.msg-chat-avatar-fallback {
display: grid;
place-items: center;
background: linear-gradient(135deg, #c7d2fe, #e9d5ff);
color: #4338ca;
font-size: 0.84rem;
font-weight: 600;
}
.msg-chat-actions {
display: flex;
gap: 8px;
}
.msg-chat-actions button {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.55);
color: #4b5563;
display: grid;
place-items: center;
cursor: pointer;
}
.theme-dark .msg-chat-actions button {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
.msg-chat-actions button:hover {
background: #fff;
}
.msg-chat-body {
flex: 1;
overflow-y: auto;
padding: 20px;
display: grid;
align-content: start;
gap: 10px;
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.2));
}
.theme-dark .msg-chat-body {
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.03));
}
.msg-day-sep {
display: table;
width: fit-content;
margin: 0 auto;
font-size: 0.72rem;
color: #9ca3af;
border: 1px solid rgba(255, 255, 255, 0.68);
background: rgba(255, 255, 255, 0.52);
padding: 3px 10px;
border-radius: 999px;
}
.theme-dark .msg-day-sep {
color: #9ca3af;
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
}
.msg-bubble-row {
display: flex;
gap: 8px;
max-width: 75%;
}
.msg-bubble-row.mine {
margin-left: auto;
flex-direction: row-reverse;
}
.msg-mini-avatar,
.msg-mini-avatar-fallback {
width: 30px;
height: 30px;
border-radius: 999px;
object-fit: cover;
margin-top: auto;
}
.msg-mini-avatar-fallback {
display: grid;
place-items: center;
background: #e5e7eb;
color: #374151;
font-size: 0.7rem;
font-weight: 600;
}
.msg-bubble-wrap {
display: grid;
gap: 3px;
}
.msg-time {
color: #9ca3af;
font-size: 0.68rem;
}
.msg-bubble {
border-radius: 16px;
border-bottom-left-radius: 6px;
background: #fff;
border: 1px solid rgba(255, 255, 255, 0.85);
color: #374151;
padding: 10px 12px;
font-size: 0.84rem;
line-height: 1.45;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.theme-dark .msg-bubble {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
.msg-bubble.mine {
background: #14b8a6;
border-color: #14b8a6;
color: #fff;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 6px;
}
.msg-input-area {
padding: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.62);
background: rgba(255, 255, 255, 0.35);
}
.theme-dark .msg-input-area {
border-top-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.msg-input-wrap {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
padding: 6px;
display: flex;
align-items: flex-end;
gap: 6px;
}
.theme-dark .msg-input-wrap {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.theme-dark .msg-input-wrap textarea {
color: #ffffff;
}
.msg-input-wrap > button {
width: 34px;
height: 34px;
border: 0;
background: transparent;
color: #9ca3af;
border-radius: 10px;
display: grid;
place-items: center;
cursor: pointer;
}
.msg-input-wrap > button:hover {
color: #0f766e;
background: #f0fdfa;
}
.msg-input-wrap textarea {
flex: 1;
border: 0;
background: transparent;
resize: none;
outline: none;
padding: 8px;
font-size: 0.84rem;
color: #111827;
max-height: 120px;
}
.msg-send-btn {
min-width: 84px;
width: auto;
padding: 0 12px;
border-radius: 10px;
border: 0;
background: #111827;
color: #fff;
font-size: 0.82rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
}
.msg-send-btn:hover {
background: #1f2937;
}
@media (max-width: 1200px) {
.msg-layout {
flex-direction: column;
min-height: 0;
}
.msg-threads,
.msg-chat {
width: 100%;
min-width: 0;
}
.msg-threads {
height: 360px;
}
.msg-chat {
height: 500px;
}
}
@media (max-width: 860px) {
.msg-head {
flex-direction: column;
align-items: flex-start;
}
.msg-mark-btn {
width: 100%;
justify-content: center;
}
.msg-bubble-row {
max-width: 88%;
}
}

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
function normalizePath(pathname: string): string {
if (!pathname || pathname === '/') {
return '/login';
}
if (pathname.endsWith('/') && pathname.length > 1) {
return pathname.slice(0, -1);
}
return pathname;
}
export function useBrowserRoute() {
const [path, setPath] = useState(() => normalizePath(window.location.pathname));
useEffect(() => {
const onPopState = () => {
setPath(normalizePath(window.location.pathname));
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const navigate = useCallback((nextPath: string, replace = false) => {
const normalized = normalizePath(nextPath);
if (replace) {
window.history.replaceState({}, '', normalized);
} else {
window.history.pushState({}, '', normalized);
}
setPath(normalized);
}, []);
return { path, navigate };
}

View File

@@ -1,133 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { SimulationService } from '../../../mvvm/services/simulation.service';
interface JobSimulatorPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
}
interface InterviewItem {
id: string;
job_name: string;
company_name: string | null;
interview_date: string | null;
is_completed: boolean;
}
function asInterviews(value: unknown): InterviewItem[] {
if (!value || typeof value !== 'object') {
return [];
}
const root = value as { interviews?: unknown[] };
if (!Array.isArray(root.interviews)) {
return [];
}
return root.interviews
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const source = item as Record<string, unknown>;
const id = typeof source.id === 'string' ? source.id : '';
if (!id) {
return null;
}
return {
id,
job_name: typeof source.job_name === 'string' ? source.job_name : 'Interview',
company_name: typeof source.company_name === 'string' ? source.company_name : null,
interview_date: typeof source.interview_date === 'string' ? source.interview_date : null,
is_completed: Boolean(source.is_completed),
};
})
.filter((item): item is InterviewItem => Boolean(item))
.slice(0, 6);
}
function formatDate(value: string | null): string {
if (!value) {
return 'Ingen dato';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Ingen dato';
}
return date.toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' });
}
export function JobSimulatorPage({ onLogout, onNavigate }: JobSimulatorPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [items, setItems] = useState<InterviewItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const simulationService = useMemo(() => new SimulationService(), []);
useEffect(() => {
let active = true;
setIsLoading(true);
void simulationService
.listInterviews(10, 0)
.then((response) => {
if (!active) {
return;
}
setItems(asInterviews(response));
})
.catch(() => {
if (!active) {
return;
}
setItems([]);
})
.finally(() => {
if (active) {
setIsLoading(false);
}
});
return () => {
active = false;
};
}, [simulationService]);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="simulator"
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
onSelect={(key) => {
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
onNavigate(key);
}
}}
/>
<main className="dashboard-main">
<Topbar title="Interview Simulator" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<article className="glass-panel dash-card">
<div className="dash-header">
<h4>Seneste interviews</h4>
<button type="button" className="primary-btn jobs-apply-btn">Start nyt interview</button>
</div>
{isLoading ? <p>Indlæser interviews...</p> : null}
{!isLoading && items.length === 0 ? <p>Ingen interviews endnu.</p> : null}
<ul className="dashboard-feed-list">
{items.map((item) => (
<li key={item.id}>
<div className="dashboard-feed-item">
<strong>{item.job_name}</strong>
<span>{item.company_name || 'Ukendt virksomhed'} {formatDate(item.interview_date)} {item.is_completed ? 'Gennemført' : 'Ikke færdig'}</span>
</div>
</li>
))}
</ul>
</article>
</div>
</main>
</section>
);
}

View File

@@ -1,95 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import type { PaymentOverview } from '../../../mvvm/models/payment-overview.interface';
import { SubscriptionService } from '../../../mvvm/services/subscription.service';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
interface SubscriptionPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
}
function formatDate(value: string | Date | undefined): string {
if (!value) {
return 'Ikke tilgængelig';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Ikke tilgængelig';
}
return date.toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' });
}
export function SubscriptionPage({ onLogout, onNavigate }: SubscriptionPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [paymentOverview, setPaymentOverview] = useState<PaymentOverview | null>(null);
const [isLoading, setIsLoading] = useState(true);
const subscriptionService = useMemo(() => new SubscriptionService(), []);
useEffect(() => {
let active = true;
setIsLoading(true);
void subscriptionService
.getPaymentOverview()
.then((response) => {
if (!active) {
return;
}
setPaymentOverview(response);
})
.catch(() => {
if (!active) {
return;
}
setPaymentOverview(null);
})
.finally(() => {
if (active) {
setIsLoading(false);
}
});
return () => {
active = false;
};
}, [subscriptionService]);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="abonnement"
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
onSelect={(key) => {
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
onNavigate(key);
}
}}
/>
<main className="dashboard-main">
<Topbar title="Abonnement" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<article className="glass-panel dash-card">
<h4>Din plan</h4>
{isLoading ? <p>Indlæser abonnement...</p> : null}
{!isLoading && !paymentOverview ? <p>Kunne ikke hente abonnement.</p> : null}
{paymentOverview ? (
<div className="dashboard-subscription-content">
<p>Produkt: {paymentOverview.productTypeName || paymentOverview.productType || 'Ukendt'}</p>
<p>Fornyes: {formatDate(paymentOverview.renewDate)}</p>
<p>Aktiv til: {formatDate(paymentOverview.activeToDate)}</p>
<div className="dashboard-feature-pills">
{paymentOverview.generateApplication ? <span className="chip">Ansøgninger</span> : null}
{paymentOverview.careerAgent ? <span className="chip">Karriereagent</span> : null}
{paymentOverview.downloadCv ? <span className="chip">CV download</span> : null}
{paymentOverview.jobInterviewSimulation ? <span className="chip">Simulator</span> : null}
</div>
</div>
) : null}
</article>
</div>
</main>
</section>
);
}