Initial React project
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 på 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>
|
||||
);
|
||||
|
||||
590
src/presentation/ai-agent/pages/ai-agent.css
Normal file
590
src/presentation/ai-agent/pages/ai-agent.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 på 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>
|
||||
);
|
||||
}
|
||||
20
src/presentation/auth/components/AuthInput.tsx
Normal file
20
src/presentation/auth/components/AuthInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/presentation/auth/components/ForgotPasswordView.tsx
Normal file
47
src/presentation/auth/components/ForgotPasswordView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/presentation/auth/components/LoginView.tsx
Normal file
91
src/presentation/auth/components/LoginView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/presentation/auth/components/RegisterView.tsx
Normal file
133
src/presentation/auth/components/RegisterView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
src/presentation/auth/hooks/useAuthPage.ts
Normal file
231
src/presentation/auth/hooks/useAuthPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
105
src/presentation/auth/pages/AuthPage.tsx
Normal file
105
src/presentation/auth/pages/AuthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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, så 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
325
src/presentation/auth/pages/auth.css
Normal file
325
src/presentation/auth/pages/auth.css
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/presentation/auth/types.ts
Normal file
6
src/presentation/auth/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ActionResult {
|
||||
message: string;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export type AuthView = 'forgot' | 'login' | 'register';
|
||||
@@ -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
471
src/presentation/cv/pages/cv.css
Normal file
471
src/presentation/cv/pages/cv.css
Normal 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;
|
||||
}
|
||||
}
|
||||
91
src/presentation/dashboard/components/DashboardSidebar.tsx
Normal file
91
src/presentation/dashboard/components/DashboardSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/presentation/dashboard/components/DashboardTopbar.tsx
Normal file
43
src/presentation/dashboard/components/DashboardTopbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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')}>
|
||||
Gå 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 & 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>
|
||||
|
||||
1123
src/presentation/dashboard/pages/dashboard.css
Normal file
1123
src/presentation/dashboard/pages/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 på 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
373
src/presentation/jobs/pages/job-detail.css
Normal file
373
src/presentation/jobs/pages/job-detail.css
Normal 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;
|
||||
}
|
||||
}
|
||||
773
src/presentation/jobs/pages/jobs.css
Normal file
773
src/presentation/jobs/pages/jobs.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
461
src/presentation/messages/pages/MessagesPage.tsx
Normal file
461
src/presentation/messages/pages/MessagesPage.tsx
Normal 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 på 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>
|
||||
);
|
||||
}
|
||||
638
src/presentation/messages/pages/messages.css
Normal file
638
src/presentation/messages/pages/messages.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user