Initial React project

This commit is contained in:
Johan
2026-03-04 16:57:05 +01:00
parent 20370144fb
commit 689c6e9e15
17 changed files with 3448 additions and 27 deletions

View File

@@ -2,12 +2,15 @@ import { useMemo, useState } from 'react';
import { localStorageService } from './mvvm/services/local-storage.service';
import { AuthPage } from './presentation/auth/pages/AuthPage';
import { AiAgentPage } from './presentation/ai-agent/pages/AiAgentPage';
import { CareerAgentPage } from './presentation/ai-agent/pages/CareerAgentPage';
import { CvPage } from './presentation/cv/pages/CvPage';
import type { DashboardNavKey } from './presentation/dashboard/components/DashboardSidebar';
import { DashboardPage } from './presentation/dashboard/pages/DashboardPage';
import { JobDetailPage } from './presentation/jobs/pages/JobDetailPage';
import { JobsPage } from './presentation/jobs/pages/JobsPage';
import { MessagesPage } from './presentation/messages/pages/MessagesPage';
import { SimulatorPage } from './presentation/simulator/pages/SimulatorPage';
import { SubscriptionPage } from './presentation/subscription/pages/SubscriptionPage';
type AppPage = DashboardNavKey | 'job-detail';
@@ -36,6 +39,8 @@ function App() {
|| target === 'messages'
|| target === 'agents'
|| target === 'ai-agent'
|| target === 'simulator'
|| target === 'subscription'
) {
setActivePage(target);
}
@@ -89,7 +94,7 @@ function App() {
return <MessagesPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
}
if (activePage === 'agents' || activePage === 'ai-agent') {
if (activePage === 'agents') {
return (
<AiAgentPage
onLogout={handleLogout}
@@ -101,6 +106,25 @@ function App() {
);
}
if (activePage === 'ai-agent') {
return (
<CareerAgentPage
onLogout={handleLogout}
onNavigate={handleNavigate}
theme={theme}
onToggleTheme={handleToggleTheme}
/>
);
}
if (activePage === 'simulator') {
return <SimulatorPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
}
if (activePage === 'subscription') {
return <SubscriptionPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
}
if (activePage === 'job-detail' && jobDetailSelection) {
return (
<JobDetailPage

View File

@@ -0,0 +1,135 @@
import { JobsPageViewModel, type JobsListItem } from './JobsPageViewModel';
import { SimulationService } from '../services/simulation.service';
import type { SimulationPersonalityInterface } from '../models/simulation-personality.interface';
export interface SimulatorInterviewItem {
id: string;
title: string;
companyName: string;
dateLabel: string;
completed: boolean;
durationMinutes: number | null;
personality: string;
}
interface RecordLike {
[key: string]: unknown;
}
function asRecord(value: unknown): RecordLike | null {
return typeof value === 'object' && value !== null ? (value as RecordLike) : null;
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
function parseBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.toLowerCase();
if (normalized === 'completed' || normalized === 'done' || normalized === 'true') {
return true;
}
if (normalized === 'incomplete' || normalized === 'pending' || normalized === 'false') {
return false;
}
}
return null;
}
function formatDate(value: string): string {
if (!value) {
return '';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '';
}
return new Intl.DateTimeFormat('da-DK', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(parsed);
}
function mapInterview(item: unknown, index: number): SimulatorInterviewItem | null {
const source = asRecord(item);
if (!source) {
return null;
}
const id = asString(source.id) || asString(source.interview_id) || `interview-${index}`;
const title = asString(source.job_name) || asString(source.job_title) || asString(source.title) || 'Interview';
const companyName = asString(source.company_name) || asString(source.companyName) || 'Ukendt virksomhed';
const dateRaw = asString(source.interview_date)
|| asString(source.created_at)
|| asString(source.updated_at)
|| asString(source.date);
const completed = parseBoolean(source.is_completed) ?? parseBoolean(source.completed) ?? parseBoolean(source.status) ?? true;
const durationMinutes = asNumber(source.duration_minutes) ?? asNumber(source.duration) ?? asNumber(source.length_minutes);
const personality = asString(source.personality_name)
|| asString(source.simulation_personality_name)
|| asString(source.personality)
|| 'Professionel';
return {
id,
title,
companyName,
dateLabel: formatDate(dateRaw),
completed,
durationMinutes,
personality,
};
}
export class SimulatorViewModel {
constructor(
private readonly jobsViewModel: JobsPageViewModel = new JobsPageViewModel(),
private readonly simulationService: SimulationService = new SimulationService(),
) {}
async getCandidateProfile(): Promise<{ imageUrl?: string; name: string }> {
return this.jobsViewModel.getCandidateProfile();
}
async getJobs(): Promise<JobsListItem[]> {
try {
return await this.jobsViewModel.getTabItems('jobs');
} catch {
return [];
}
}
async getPersonalities(): Promise<SimulationPersonalityInterface[]> {
try {
const list = await this.simulationService.listSimulationPersonalities();
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
async getInterviews(limit: number = 12): Promise<SimulatorInterviewItem[]> {
try {
const payload = await this.simulationService.listInterviews(limit, 0);
const root = asRecord(payload);
const list = Array.isArray(root?.interviews) ? root.interviews : (Array.isArray(payload) ? payload : []);
return list
.map((item, index) => mapInterview(item, index))
.filter((item): item is SimulatorInterviewItem => Boolean(item));
} catch {
return [];
}
}
}

View File

@@ -0,0 +1,44 @@
import type { PaymentOverview } from '../models/payment-overview.interface';
import type { SubscriptionProductInterface } from '../models/subscription-product.interface';
import { CandidateService } from '../services/candidate.service';
import { SubscriptionService } from '../services/subscription.service';
export interface SubscriptionSnapshot {
paymentOverview: PaymentOverview | null;
products: SubscriptionProductInterface | null;
}
export class SubscriptionPageViewModel {
constructor(
private readonly candidateService: CandidateService = new CandidateService(),
private readonly subscriptionService: SubscriptionService = new SubscriptionService(),
) {}
async getCandidateProfile(): Promise<{ imageUrl?: string; name: string }> {
try {
const candidate = await this.candidateService.getCandidate();
return {
name: candidate.firstName?.trim() || candidate.name?.trim() || 'Lasse',
imageUrl: candidate.imageUrl || candidate.image || undefined,
};
} catch {
return { name: 'Lasse' };
}
}
async getSnapshot(): Promise<SubscriptionSnapshot> {
const [paymentResult, productsResult] = await Promise.allSettled([
this.subscriptionService.getPaymentOverview(),
this.subscriptionService.getSubscriptionProducts(),
]);
return {
paymentOverview: paymentResult.status === 'fulfilled' ? paymentResult.value : null,
products: productsResult.status === 'fulfilled' ? productsResult.value : null,
};
}
async redeemCode(code: string): Promise<void> {
await this.subscriptionService.redeemCode(code);
}
}

View File

@@ -133,7 +133,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
<div className="dash-orb dash-orb-2" />
<div className="dash-orb dash-orb-3" />
<DashboardSidebar active="ai-agent" onNavigate={onNavigate} />
<DashboardSidebar active="agents" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar ai-agent-main">
<DashboardTopbar
@@ -145,14 +145,14 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
/>
<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>
<h1>Jobagenter</h1>
<p>Saet din jobsogning pa autopilot. Lad agenter overvage og matche dig med de perfekte jobs.</p>
</div>
<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>
<h2>Opret ny jobagent</h2>
</div>
<div className="ai-form-grid">
@@ -206,7 +206,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
</div>
<div className="ai-create-actions">
<button type="button" onClick={() => void handleSaveAgent()}><Save size={16} strokeWidth={1.8} /> Gem AI-agent</button>
<button type="button" onClick={() => void handleSaveAgent()}><Save size={16} strokeWidth={1.8} /> Gem jobagent</button>
</div>
</section>
@@ -255,11 +255,11 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
className="ai-job-card"
role="button"
tabIndex={0}
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent')}
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'agents')}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
onOpenJobDetail(job.id, job.fromJobnet, 'agents');
}
}}
>
@@ -270,7 +270,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
: <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>
<small>Via: {activeFilters[0]?.escoName || 'Jobagent'}</small>
</div>
</div>
@@ -291,7 +291,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
onOpenJobDetail(job.id, job.fromJobnet, 'agents');
}}
>
Læs mere <ArrowRight size={14} strokeWidth={1.8} />

View File

@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from 'react';
import {
BadgeCheck,
CheckCircle2,
Filter,
Globe,
GraduationCap,
PlusCircle,
Car,
Shield,
Sparkles,
Star,
Target,
Trophy,
WandSparkles,
} from 'lucide-react';
import { AiAgentViewModel, type AiAgentInitialData, type SuggestionImprovement } from '../../../mvvm/viewmodels/AiAgentViewModel';
import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar';
import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar';
import '../../dashboard/pages/dashboard.css';
import './career-agent.css';
interface CareerAgentPageProps {
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
const EMPTY_DATA: AiAgentInitialData = {
paymentOverview: null,
jobAgentFilters: [],
cvSuggestions: [],
escos: [],
};
function defaultAgents(): string[] {
return [
'Diamantskærer',
'Ministerialbetjent',
'Kiropraktor',
'System Developer',
'Senior Software Developer',
'Founder and Developer',
'Senior .Net Developer',
'Freelance Programmer',
'Støberichef',
'Softwareudvikler, frontend',
'CSR-ansvarlig',
'Lagerchef inden for råstof',
'Ios developer',
'Kokkeelev',
'IT-kvalitetsmedarbejder',
'Efterretningsofficer',
];
}
function mapTone(chance: number): { text: string; kind: 'strong' | 'neutral' | 'soft' } {
if (chance >= 70) {
return { text: 'Kan styrke dine chancer i ansøgningsbunken', kind: 'strong' };
}
if (chance >= 40) {
return { text: 'Ofte efterspurgt forbedrer dine jobmuligheder markant', kind: 'neutral' };
}
return { text: 'Et godt første skridt mod flere relevante job', kind: 'soft' };
}
function iconForType(type: string) {
if (type === 'education') {
return GraduationCap;
}
if (type === 'language') {
return Globe;
}
if (type === 'driversLicense') {
return Car;
}
if (type === 'certificate') {
return BadgeCheck;
}
if (type === 'qualification') {
return Target;
}
return Star;
}
function colorClass(type: string): string {
if (type === 'education') {
return 'blue';
}
if (type === 'language') {
return 'emerald';
}
if (type === 'driversLicense') {
return 'orange';
}
if (type === 'certificate') {
return 'rose';
}
if (type === 'qualification') {
return 'indigo';
}
return 'slate';
}
export function CareerAgentPage({ onLogout, onNavigate, onToggleTheme, theme }: CareerAgentPageProps) {
const viewModel = useMemo(() => new AiAgentViewModel(), []);
const [name, setName] = useState('Lasse');
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const [data, setData] = useState<AiAgentInitialData>(EMPTY_DATA);
const [isLoading, setIsLoading] = useState(true);
const [selectedAgent, setSelectedAgent] = useState('');
useEffect(() => {
let active = true;
async function load() {
setIsLoading(true);
const [profile, snapshot] = await Promise.all([
viewModel.getCandidateProfile(),
viewModel.loadInitialData(),
]);
if (!active) {
return;
}
setName(profile.name);
setImageUrl(profile.imageUrl);
setData(snapshot);
const firstAgent = snapshot.jobAgentFilters[0]?.escoName || defaultAgents()[1];
setSelectedAgent((current) => current || firstAgent);
setIsLoading(false);
}
void load();
return () => {
active = false;
};
}, [viewModel]);
const leftAgents = data.jobAgentFilters.length > 0
? data.jobAgentFilters.map((item) => item.escoName)
: defaultAgents();
const selectedFilter = data.jobAgentFilters.find((item) => item.escoName === selectedAgent) || data.jobAgentFilters[0];
const selectedSuggestions = (selectedFilter
? data.cvSuggestions.find((item) => item.escoId === selectedFilter.escoId)?.improvements
: data.cvSuggestions[0]?.improvements) || [];
const cards = (selectedSuggestions.length > 0
? selectedSuggestions
: [
{
name: 'Grundkursus i sikkerhed og beredskab',
jobChanceIncrease: 78,
improvementType: 'education',
},
{
name: 'Service og kommunikation i offentlige institutioner',
jobChanceIncrease: 52,
improvementType: 'qualification',
},
{
name: 'Dansk (Flydende)',
jobChanceIncrease: 65,
improvementType: 'language',
},
{
name: 'B - Almindelig bil',
jobChanceIncrease: 34,
improvementType: 'driversLicense',
},
{
name: 'Konflikthåndtering',
jobChanceIncrease: 72,
improvementType: 'qualification',
},
{
name: 'Førstehjælpsbevis',
jobChanceIncrease: 44,
improvementType: 'certificate',
},
{
name: 'Sikkerhedsgodkendelse (PET)',
jobChanceIncrease: 81,
improvementType: 'certificate',
},
]) as SuggestionImprovement[];
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="ai-agent" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar career-main">
<DashboardTopbar
name={name}
imageUrl={imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
actions={(
<div className="career-status-pill">
<CheckCircle2 size={15} strokeWidth={1.8} />
CV analyseret
</div>
)}
/>
<div className="career-head">
<div className="career-head-title-row">
<div className="career-head-icon"><WandSparkles size={19} strokeWidth={1.8} /></div>
<h1>Karriereagent</h1>
</div>
<p className="career-head-kicker">Din Karriereagent Foreslår</p>
<p className="career-head-desc">
Boost din profil ved hjælp af kunstig intelligens. Forslagene er udvalgt til din profil ud fra analyser
af over 100.000+ jobopslag og dit nuværende CV.
</p>
</div>
<div className="career-grid">
<aside className="career-agents-col">
<div className="career-agents-head">
<h2>Mine agenter</h2>
<button type="button"><PlusCircle size={16} strokeWidth={1.8} /> Tilføj ny agent</button>
</div>
<div className="career-agents-list custom-scrollbar">
{leftAgents.map((agent) => (
<button
key={agent}
type="button"
className={selectedAgent === agent ? 'active' : ''}
onClick={() => setSelectedAgent(agent)}
>
<span>{agent}</span>
{selectedAgent === agent ? <i /> : null}
</button>
))}
</div>
</aside>
<section className="career-reco-col">
<div className="career-reco-head">
<h2>
Viser anbefalinger for
<span>{selectedAgent || 'Ministerialbetjent'}</span>
</h2>
<button type="button"><Filter size={14} strokeWidth={1.8} /> Filtrer</button>
</div>
{isLoading ? <p className="dash-loading">Indlaeser anbefalinger...</p> : null}
<div className="career-cards custom-scrollbar">
{cards.map((item, index) => {
const Icon = iconForType(item.improvementType || 'qualification');
const tone = mapTone(item.jobChanceIncrease || 0);
const color = colorClass(item.improvementType || 'qualification');
return (
<article key={`${item.name}-${index}`} className="career-card">
<div className="career-card-glow" />
<div className="career-card-wand"><Sparkles size={16} strokeWidth={1.8} /></div>
<div className="career-card-content">
<div className={`career-card-icon ${color}`}>
<Icon size={18} strokeWidth={1.8} />
</div>
<h3>{item.shortName || item.name}</h3>
<p className={`tone ${tone.kind}`}>
{tone.kind === 'strong'
? <Trophy size={13} strokeWidth={1.8} />
: tone.kind === 'neutral'
? <Target size={13} strokeWidth={1.8} />
: <Shield size={13} strokeWidth={1.8} />}
{tone.text}
</p>
</div>
</article>
);
})}
</div>
</section>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,375 @@
.career-main {
position: relative;
}
.career-status-pill {
border-radius: 999px;
border: 1px solid #99f6e4;
background: rgba(240, 253, 250, 0.8);
color: #0f766e;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
font-size: 0.8rem;
font-weight: 500;
}
.career-head {
max-width: 760px;
margin-bottom: 20px;
}
.career-head-title-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.career-head-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, #2dd4bf, #06b6d4);
color: #ffffff;
display: grid;
place-items: center;
}
.career-head h1 {
margin: 0;
font-size: clamp(2rem, 3.4vw, 2.6rem);
letter-spacing: -0.03em;
color: #111827;
}
.career-head-kicker {
margin: 12px 0 6px;
color: #0f766e;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
font-weight: 600;
}
.career-head-desc {
margin: 0;
color: #6b7280;
line-height: 1.65;
}
.career-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
gap: 18px;
min-height: calc(100vh - 250px);
padding-bottom: 18px;
}
.career-agents-col {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.03);
overflow: hidden;
display: flex;
flex-direction: column;
}
.career-agents-head {
padding: 16px;
border-bottom: 1px solid rgba(229, 231, 235, 0.7);
background: rgba(255, 255, 255, 0.42);
}
.career-agents-head h2 {
margin: 0 0 10px;
color: #111827;
font-size: 0.9rem;
}
.career-agents-head button {
width: 100%;
border: 0;
border-radius: 12px;
background: linear-gradient(to right, #14b8a6, #06b6d4);
color: #ffffff;
padding: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 14px rgba(20, 184, 166, 0.39);
}
.career-agents-list {
flex: 1;
overflow-y: auto;
padding: 10px;
display: grid;
align-content: start;
gap: 4px;
}
.career-agents-list button {
width: 100%;
text-align: left;
border: 0;
border-radius: 10px;
background: transparent;
color: #4b5563;
padding: 10px 11px;
font-size: 0.82rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.career-agents-list button:hover {
background: rgba(249, 250, 251, 0.9);
color: #111827;
}
.career-agents-list button.active {
border: 1px solid #99f6e4;
background: #f0fdfa;
color: #0f766e;
font-weight: 500;
}
.career-agents-list button.active i {
width: 6px;
height: 6px;
border-radius: 999px;
background: #14b8a6;
}
.career-reco-col {
min-width: 0;
display: flex;
flex-direction: column;
}
.career-reco-head {
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.career-reco-head h2 {
margin: 0;
color: #111827;
font-size: 1.03rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 7px;
}
.career-reco-head h2 span {
color: #0f766e;
border: 1px solid #99f6e4;
background: #f0fdfa;
border-radius: 6px;
padding: 2px 8px;
font-size: 0.84rem;
}
.career-reco-head button {
border: 0;
background: transparent;
color: #6b7280;
font-size: 0.75rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.career-cards {
flex: 1;
overflow-y: auto;
padding-right: 6px;
}
.career-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
align-content: start;
grid-auto-rows: min-content;
}
.career-card {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(153, 246, 228, 0.5);
background: linear-gradient(135deg, #ffffff, rgba(240, 253, 250, 0.45));
padding: 12px;
transition: border-color 0.4s ease, box-shadow 0.4s ease;
}
.career-card:hover {
border-color: #5eead4;
box-shadow: 0 8px 20px rgba(20, 184, 166, 0.08);
}
.career-card-glow {
position: absolute;
top: -24px;
right: -24px;
width: 100px;
height: 100px;
border-radius: 999px;
background: linear-gradient(to bottom right, rgba(45, 212, 191, 0.18), rgba(34, 211, 238, 0.16));
filter: blur(22px);
transition: transform 0.5s ease;
}
.career-card:hover .career-card-glow {
transform: scale(1.5);
}
.career-card-wand {
position: absolute;
top: 11px;
right: 11px;
color: #5eead4;
}
.career-card-content {
position: relative;
z-index: 1;
}
.career-card-icon {
width: 34px;
height: 34px;
border-radius: 9px;
display: grid;
place-items: center;
margin-bottom: 10px;
}
.career-card-icon.blue { background: #eff6ff; color: #3b82f6; }
.career-card-icon.indigo { background: #eef2ff; color: #6366f1; }
.career-card-icon.emerald { background: #ecfdf5; color: #10b981; }
.career-card-icon.orange { background: #fff7ed; color: #f97316; }
.career-card-icon.rose { background: #fff1f2; color: #f43f5e; }
.career-card-icon.slate { background: #f1f5f9; color: #475569; }
.career-card h3 {
margin: 0 0 6px;
color: #111827;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.4;
}
.career-card p {
margin: 0;
font-size: 0.7rem;
line-height: 1.4;
display: inline-flex;
align-items: flex-start;
gap: 5px;
}
.career-card p.tone.strong { color: #0f766e; }
.career-card p.tone.neutral { color: #4f46e5; }
.career-card p.tone.soft { color: #6b7280; }
.theme-dark .career-status-pill {
border-color: rgba(20, 184, 166, 0.35);
background: rgba(20, 184, 166, 0.12);
color: #2dd4bf;
}
.theme-dark .career-head h1,
.theme-dark .career-agents-head h2,
.theme-dark .career-reco-head h2,
.theme-dark .career-card h3 {
color: #ffffff;
}
.theme-dark .career-head-desc,
.theme-dark .career-agents-list button,
.theme-dark .career-reco-head button,
.theme-dark .career-card p.tone.soft,
.theme-dark .career-head-kicker {
color: #9ca3af;
}
.theme-dark .career-head-icon,
.theme-dark .career-agents-col,
.theme-dark .career-card {
border-color: rgba(255, 255, 255, 0.08);
}
.theme-dark .career-agents-col,
.theme-dark .career-card {
background: rgba(255, 255, 255, 0.02);
}
.theme-dark .career-agents-head {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.theme-dark .career-agents-list button:hover {
background: rgba(255, 255, 255, 0.06);
color: #f3f4f6;
}
.theme-dark .career-agents-list button.active {
border-color: rgba(20, 184, 166, 0.35);
background: rgba(20, 184, 166, 0.12);
color: #2dd4bf;
}
.theme-dark .career-reco-head h2 span {
border-color: rgba(20, 184, 166, 0.35);
background: rgba(20, 184, 166, 0.12);
color: #2dd4bf;
}
.theme-dark .career-card-glow {
background: linear-gradient(to bottom right, rgba(45, 212, 191, 0.15), rgba(99, 102, 241, 0.14));
}
.theme-dark .career-card-wand {
color: #2dd4bf;
}
@media (max-width: 1180px) {
.career-grid {
grid-template-columns: 1fr;
min-height: auto;
}
.career-agents-col {
max-height: 280px;
}
.career-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.career-cards {
grid-template-columns: 1fr;
}
}

View File

@@ -1,4 +1,4 @@
import { Briefcase, Bot, FileText, Gamepad2, LayoutGrid, MessageCircle, Radar, Sparkles } from 'lucide-react';
import { Briefcase, Bot, Crown, FileText, Gamepad2, LayoutGrid, MessageCircle, Radar, Sparkles } from 'lucide-react';
import type { ComponentType } from 'react';
interface DashboardSidebarProps {
@@ -6,7 +6,7 @@ interface DashboardSidebarProps {
onNavigate?: (target: DashboardNavKey) => void;
}
export type DashboardNavKey = 'dashboard' | 'jobs' | 'cv' | 'messages' | 'agents' | 'ai-agent' | 'simulator';
export type DashboardNavKey = 'dashboard' | 'jobs' | 'cv' | 'messages' | 'agents' | 'ai-agent' | 'simulator' | 'subscription';
interface NavItem {
accent?: boolean;
@@ -28,6 +28,7 @@ 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 },
{ key: 'subscription', label: 'Abonnement', icon: Crown },
];
export function DashboardSidebar({ active = 'dashboard', onNavigate }: DashboardSidebarProps) {

View File

@@ -0,0 +1,459 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowLeft,
ArrowRight,
Briefcase,
ChevronDown,
CheckCircle2,
Clock3,
Code2,
Filter,
Globe,
Lightbulb,
Mic,
MoreHorizontal,
PauseCircle,
Play,
PlayCircle,
Radio,
StopCircle,
Target,
UserRound,
} from 'lucide-react';
import { SimulatorViewModel, type SimulatorInterviewItem } from '../../../mvvm/viewmodels/SimulatorViewModel';
import 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 './simulator.css';
interface SimulatorPageProps {
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
interface InterviewCard {
id: string;
title: string;
companyName: string;
completed: boolean;
durationMinutes: number;
personality: string;
dateLabel: string;
}
const FALLBACK_INTERVIEWS: InterviewCard[] = [
{
id: 'sim-1',
title: 'Senior Frontend-udvikler',
companyName: 'Lunar',
completed: true,
durationMinutes: 15,
personality: 'Professionel',
dateLabel: '12. okt 2023',
},
{
id: 'sim-2',
title: 'Fullstack Developer',
companyName: 'Pleo',
completed: false,
durationMinutes: 20,
personality: 'Afslappet',
dateLabel: '10. okt 2023',
},
{
id: 'sim-3',
title: 'UX Designer',
companyName: 'Trustpilot',
completed: true,
durationMinutes: 10,
personality: 'Sarkastisk',
dateLabel: '05. okt 2023',
},
{
id: 'sim-4',
title: 'Product Manager',
companyName: 'Danske Bank',
completed: true,
durationMinutes: 5,
personality: 'Stress-test',
dateLabel: '01. okt 2023',
},
];
function toInterviewCard(item: SimulatorInterviewItem): InterviewCard {
return {
id: item.id,
title: item.title,
companyName: item.companyName,
completed: item.completed,
durationMinutes: item.durationMinutes ?? 15,
personality: item.personality || 'Professionel',
dateLabel: item.dateLabel || 'Nyligt',
};
}
function jobLabel(job: JobsListItem): string {
return `${job.title || 'Stilling'}${job.companyName ? ` · ${job.companyName}` : ''}`;
}
export function SimulatorPage({ onLogout, onNavigate, onToggleTheme, theme }: SimulatorPageProps) {
const viewModel = useMemo(() => new SimulatorViewModel(), []);
const [name, setName] = useState('Lasse');
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const [jobs, setJobs] = useState<JobsListItem[]>([]);
const [interviews, setInterviews] = useState<InterviewCard[]>([]);
const [personalities, setPersonalities] = useState<Array<{ id: number; name: string }>>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedJobId, setSelectedJobId] = useState('');
const [selectedPersonalityId, setSelectedPersonalityId] = useState('');
const [selectedLanguage, setSelectedLanguage] = useState('Dansk');
const [selectedDuration, setSelectedDuration] = useState('15');
const [isLiveSession, setIsLiveSession] = useState(false);
useEffect(() => {
let active = true;
async function load() {
setIsLoading(true);
const [profile, jobsData, interviewData, personalityData] = await Promise.all([
viewModel.getCandidateProfile(),
viewModel.getJobs(),
viewModel.getInterviews(),
viewModel.getPersonalities(),
]);
if (!active) {
return;
}
setName(profile.name);
setImageUrl(profile.imageUrl);
setJobs(jobsData);
setInterviews(interviewData.map(toInterviewCard));
setPersonalities(personalityData.map((item) => ({ id: item.id, name: item.name })));
if (jobsData.length > 0) {
setSelectedJobId((current) => current || jobsData[0].id);
}
if (personalityData.length > 0) {
setSelectedPersonalityId((current) => current || String(personalityData[0].id));
}
setIsLoading(false);
}
void load();
return () => {
active = false;
};
}, [viewModel]);
const cards = interviews.length > 0 ? interviews : FALLBACK_INTERVIEWS;
const fallbackJob = { id: 'fallback-job', title: 'Senior Frontend-udvikler', companyName: 'Lunar' } as JobsListItem;
const jobOptions = jobs.length > 0 ? jobs : [fallbackJob];
const activeJob = jobOptions.find((job) => job.id === selectedJobId) || jobOptions[0];
const activePersonality = personalities.find((item) => String(item.id) === selectedPersonalityId)?.name || 'Professionel & Grundig';
const liveMessages = [
{
id: 'ai-1',
sender: 'ai',
text:
'Hej Lasse, og velkommen til! Vi er rigtig glade for at have dig til samtalen omkring rollen som '
+ `${activeJob.title || 'Senior Frontend-udvikler'}. Kan du fortælle om et nyligt projekt, `
+ 'hvor din erfaring med React gjorde en stor forskel for slutresultatet?',
},
{
id: 'me-1',
sender: 'me',
text:
'I mit seneste projekt migrerede vi en stor dashboard-løsning til Next.js. '
+ 'Jeg implementerede virtualisering og strammere state management med Zustand, '
+ 'hvilket reducerede load-tid med over 60%.',
},
{
id: 'ai-2',
sender: 'ai',
text:
'Det lyder som en rigtig solid forbedring. Når du nævner Zustand frem for Redux, '
+ 'hvad var overvejelserne bag det valg i jeres use-case?',
},
] as const;
return (
<section className={`dash-root ${theme === 'dark' ? 'theme-dark' : ''}`}>
<div className="dash-orb dash-orb-1" />
<div className="dash-orb dash-orb-2" />
<div className="dash-orb dash-orb-3" />
<DashboardSidebar active="simulator" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar sim-main">
<DashboardTopbar
name={name}
imageUrl={imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
actions={isLiveSession ? (
<button type="button" className="sim-leave-btn" onClick={() => setIsLiveSession(false)}>
<ArrowLeft size={15} strokeWidth={1.8} />
<span>Forlad simulering</span>
</button>
) : undefined}
/>
{isLiveSession ? (
<div className="sim-live-wrap">
<div className="sim-live-head">
<h1>Live Jobsamtale</h1>
<p>Du er i øjeblikket i en simuleret teknisk samtale. Brug mikrofonen til at svare.</p>
</div>
<div className="sim-live-grid">
<section className="sim-live-chat-card">
<div className="sim-live-chat-head">
<div className="sim-live-ai-row">
<div className="sim-live-ai-avatar"><UserRound size={18} strokeWidth={1.8} /></div>
<div>
<h3>Sarah (AI Interviewer)</h3>
<p><Radio size={12} strokeWidth={1.8} /> Venter dit svar...</p>
</div>
</div>
<button type="button" className="sim-live-more-btn"><MoreHorizontal size={16} strokeWidth={1.8} /></button>
</div>
<div className="sim-live-chat-scroll custom-scrollbar">
{liveMessages.map((message) => (
<div
key={message.id}
className={message.sender === 'ai' ? 'sim-live-msg-row ai' : 'sim-live-msg-row me'}
>
<div className={message.sender === 'ai' ? 'sim-live-msg-avatar ai' : 'sim-live-msg-avatar me'}>
{message.sender === 'ai'
? <UserRound size={13} strokeWidth={1.8} />
: (imageUrl ? <img src={imageUrl} alt={name} /> : <span>{name.slice(0, 1).toUpperCase()}</span>)}
</div>
<div className={message.sender === 'ai' ? 'sim-live-msg-bubble ai' : 'sim-live-msg-bubble me'}>
<p>{message.text}</p>
</div>
</div>
))}
</div>
<div className="sim-live-voice">
<div className="sim-live-time-row">
<div className="sim-live-time">
<small>Tid gået</small>
<strong>04:23</strong>
</div>
<div className="sim-live-wave">
{Array.from({ length: 7 }).map((_, index) => (
<span key={`wave-${index}`} style={{ animationDelay: `${index * 0.14}s` }} />
))}
</div>
<div className="sim-live-time">
<small>Tilbage</small>
<strong>10:37</strong>
</div>
</div>
<button type="button" className="sim-live-mic-btn">
<Mic size={22} strokeWidth={1.8} />
</button>
<p>Optager dit svar...</p>
</div>
</section>
<aside className="sim-live-side custom-scrollbar">
<article className="sim-live-side-card">
<h2>Session Status</h2>
<div className="sim-live-side-list">
<div>
<small>Stilling</small>
<p>{activeJob.title || 'Senior Frontend-udvikler'} @ {activeJob.companyName || 'Lunar'}</p>
</div>
<div>
<small>Samtaletype</small>
<p><Code2 size={14} strokeWidth={1.8} /> Teknisk Dybde</p>
</div>
<div>
<small>Interviewer stil</small>
<p><UserRound size={14} strokeWidth={1.8} /> {activePersonality}</p>
</div>
<div>
<div className="sim-live-progress-head">
<small>Fremgang</small>
<strong>Spørgsmål 2 af 5</strong>
</div>
<div className="sim-live-progress-track"><span /></div>
</div>
</div>
</article>
<article className="sim-live-coach-card">
<h2><Lightbulb size={15} strokeWidth={1.8} /> Live Coach</h2>
<div className="sim-live-coach-list">
<div>
<CheckCircle2 size={14} strokeWidth={1.8} />
<div>
<strong>Godt brug af STAR-metoden</strong>
<p>Dit forrige svar beskrev situationen og resultatet meget tydeligt.</p>
</div>
</div>
<div>
<Target size={14} strokeWidth={1.8} />
<div>
<strong>Næste skridt</strong>
<p>Uddyb hvorfor Zustand var bedre end Redux i jeres specifikke use-case.</p>
</div>
</div>
</div>
</article>
<article className="sim-live-side-card">
<div className="sim-live-actions">
<button type="button"><PauseCircle size={16} strokeWidth={1.8} /> Sæt pause</button>
<button type="button" className="stop"><StopCircle size={16} strokeWidth={1.8} /> Afslut & Feedback</button>
</div>
</article>
</aside>
</div>
</div>
) : (
<div className="sim-wrap">
<section className="sim-hero-card">
<div className="sim-hero-glow" />
<div className="sim-hero-left">
<h1>Job Interview Simulator</h1>
<p>
Ov dig pa jobsamtaler med vores AI-drevne simulator. Du far skraeddersyede sporgsmal
baseret pa den jobtype, du soger, og modtager detaljeret feedback pa dine svar.
</p>
<ul className="sim-benefits">
<li><CheckCircle2 size={16} strokeWidth={1.8} /> Personaliserede interviewsporgsmal</li>
<li><CheckCircle2 size={16} strokeWidth={1.8} /> Ojeblikkelig AI-feedback pa dine svar</li>
<li><CheckCircle2 size={16} strokeWidth={1.8} /> Detaljeret evaluering efter interviewet</li>
<li><CheckCircle2 size={16} strokeWidth={1.8} /> Gem og gennemga tidligere interviews</li>
</ul>
<button type="button" className="sim-start-btn" onClick={() => setIsLiveSession(true)}>
<PlayCircle size={18} strokeWidth={1.8} />
Start ny simulering
</button>
</div>
<div className="sim-config-card">
<div className="sim-config-head">
<h3>Simuleringsindstillinger</h3>
<p>Vaelg dine praeferencer for start</p>
</div>
<label>
Gemt job
<div className="sim-select-wrap">
<Briefcase size={16} strokeWidth={1.8} />
<select value={selectedJobId} onChange={(event) => setSelectedJobId(event.target.value)}>
{jobOptions.map((job) => (
<option key={job.id} value={job.id}>{jobLabel(job)}</option>
))}
</select>
<ChevronDown size={15} strokeWidth={1.8} className="sim-caret" />
</div>
</label>
<label>
Personlighed (AI)
<div className="sim-select-wrap">
<UserRound size={16} strokeWidth={1.8} />
<select value={selectedPersonalityId} onChange={(event) => setSelectedPersonalityId(event.target.value)}>
{(personalities.length > 0 ? personalities : [{ id: 1, name: 'Professionel & Grundig' }]).map((personality) => (
<option key={personality.id} value={String(personality.id)}>{personality.name}</option>
))}
</select>
<ChevronDown size={15} strokeWidth={1.8} className="sim-caret" />
</div>
</label>
<div className="sim-mini-grid">
<label>
Sprog
<div className="sim-select-wrap">
<Globe size={16} strokeWidth={1.8} />
<select value={selectedLanguage} onChange={(event) => setSelectedLanguage(event.target.value)}>
<option>Dansk</option>
<option>Engelsk</option>
</select>
<ChevronDown size={15} strokeWidth={1.8} className="sim-caret" />
</div>
</label>
<label>
Varighed
<div className="sim-select-wrap">
<Clock3 size={16} strokeWidth={1.8} />
<select value={selectedDuration} onChange={(event) => setSelectedDuration(event.target.value)}>
<option value="5">5 min</option>
<option value="10">10 min</option>
<option value="15">15 min</option>
<option value="20">20 min</option>
</select>
<ChevronDown size={15} strokeWidth={1.8} className="sim-caret" />
</div>
</label>
</div>
</div>
</section>
<div className="sim-history-head">
<h2>Tidligere simuleringer</h2>
<button type="button"><Filter size={15} strokeWidth={1.8} /> Filtrer</button>
</div>
{isLoading ? <p className="dash-loading">Indlaeser simuleringer...</p> : null}
<section className="sim-history-grid">
{cards.map((item) => (
<article key={item.id} className={item.completed ? 'sim-card done' : 'sim-card draft'}>
<div className="sim-card-head">
<div>
<h3>{item.title}</h3>
<p>{item.companyName}</p>
</div>
<span className={item.completed ? 'sim-status done' : 'sim-status draft'}>
{item.completed ? 'Faerdig' : 'Ikke faerdig'}
</span>
</div>
<div className="sim-tags">
<span><Clock3 size={13} strokeWidth={1.8} /> {item.durationMinutes} min</span>
<span><UserRound size={13} strokeWidth={1.8} /> {item.personality}</span>
</div>
<div className="sim-card-foot">
<small>{item.dateLabel}</small>
{item.completed ? (
<button type="button" className="sim-link-btn">Se evaluering <ArrowRight size={14} strokeWidth={1.8} /></button>
) : (
<button type="button" className="sim-link-btn">Fortsæt <Play size={14} strokeWidth={1.8} /></button>
)}
</div>
</article>
))}
</section>
</div>
)}
</main>
</section>
);
}

View File

@@ -0,0 +1,967 @@
.sim-main {
position: relative;
}
.sim-leave-btn {
border: 1px solid rgba(229, 231, 235, 0.82);
background: rgba(255, 255, 255, 0.62);
border-radius: 999px;
padding: 8px 12px;
display: inline-flex;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
}
.sim-leave-btn:hover {
color: #111827;
background: rgba(255, 255, 255, 0.85);
}
.sim-wrap {
max-width: 1160px;
margin: 0 auto;
padding-bottom: 30px;
}
.sim-live-wrap {
padding-bottom: 24px;
}
.sim-live-head {
margin-bottom: 22px;
}
.sim-live-head h1 {
margin: 0 0 8px;
font-size: clamp(1.9rem, 3vw, 2.3rem);
letter-spacing: -0.03em;
color: #111827;
}
.sim-live-head p {
margin: 0;
color: #6b7280;
}
.sim-live-grid {
min-height: calc(100vh - 260px);
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 22px;
}
.sim-live-chat-card {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.82);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sim-live-chat-head {
padding: 14px 18px;
border-bottom: 1px solid rgba(229, 231, 235, 0.7);
background: rgba(255, 255, 255, 0.42);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.sim-live-ai-row {
display: flex;
align-items: center;
gap: 10px;
}
.sim-live-ai-avatar {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid #c7d2fe;
background: #eef2ff;
color: #4f46e5;
display: grid;
place-items: center;
}
.sim-live-ai-row h3 {
margin: 0 0 2px;
color: #111827;
font-size: 0.86rem;
font-weight: 500;
}
.sim-live-ai-row p {
margin: 0;
color: #6366f1;
font-size: 0.74rem;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sim-live-ai-row p svg {
animation: sim-pulse 1.8s ease-in-out infinite;
}
.sim-live-more-btn {
width: 30px;
height: 30px;
border-radius: 999px;
border: 0;
background: rgba(243, 244, 246, 0.8);
color: #6b7280;
display: grid;
place-items: center;
cursor: pointer;
}
.sim-live-chat-scroll {
flex: 1;
overflow-y: auto;
padding: 20px;
display: grid;
align-content: start;
gap: 18px;
}
.sim-live-msg-row {
display: flex;
align-items: flex-start;
gap: 10px;
max-width: 88%;
}
.sim-live-msg-row.me {
margin-left: auto;
flex-direction: row-reverse;
}
.sim-live-msg-avatar {
width: 30px;
height: 30px;
border-radius: 999px;
flex-shrink: 0;
overflow: hidden;
display: grid;
place-items: center;
}
.sim-live-msg-avatar.ai {
border: 1px solid #c7d2fe;
background: #eef2ff;
color: #4f46e5;
}
.sim-live-msg-avatar.me {
border: 1px solid #d1d5db;
background: #f9fafb;
color: #6b7280;
}
.sim-live-msg-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sim-live-msg-bubble {
padding: 14px 16px;
border-radius: 16px;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
}
.sim-live-msg-bubble.ai {
border-top-left-radius: 4px;
border: 1px solid rgba(229, 231, 235, 0.85);
background: #ffffff;
}
.sim-live-msg-bubble.me {
border-top-right-radius: 4px;
background: #4f46e5;
color: #eef2ff;
}
.sim-live-msg-bubble p {
margin: 0;
font-size: 0.86rem;
line-height: 1.6;
}
.sim-live-msg-bubble.ai p {
color: #4b5563;
}
.sim-live-voice {
border-top: 1px solid rgba(229, 231, 235, 0.72);
background: rgba(255, 255, 255, 0.78);
padding: 18px;
display: grid;
justify-items: center;
gap: 10px;
}
.sim-live-time-row {
width: min(420px, 100%);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.sim-live-time {
text-align: center;
}
.sim-live-time small {
display: block;
color: #9ca3af;
font-size: 0.68rem;
margin-bottom: 4px;
}
.sim-live-time strong {
color: #111827;
font-size: 0.84rem;
font-weight: 600;
}
.sim-live-wave {
flex: 1;
max-width: 180px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.sim-live-wave span {
width: 4px;
height: 28px;
border-radius: 999px;
background: #6366f1;
transform-origin: center;
animation: sim-wave 1.2s ease-in-out infinite;
}
.sim-live-mic-btn {
width: 62px;
height: 62px;
border-radius: 999px;
border: 2px solid #fecdd3;
background: #fff1f2;
color: #e11d48;
display: grid;
place-items: center;
cursor: pointer;
position: relative;
}
.sim-live-mic-btn::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 999px;
background: rgba(251, 113, 133, 0.2);
animation: sim-pulse-soft 2s ease-in-out infinite;
z-index: 0;
}
.sim-live-mic-btn svg {
position: relative;
z-index: 1;
}
.sim-live-voice > p {
margin: 0;
color: #e11d48;
font-size: 0.76rem;
animation: sim-pulse 2s ease-in-out infinite;
}
.sim-live-side {
overflow-y: auto;
padding-right: 2px;
display: grid;
align-content: start;
gap: 14px;
}
.sim-live-side-card {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.82);
background: rgba(255, 255, 255, 0.62);
padding: 16px;
}
.sim-live-side-card h2 {
margin: 0 0 14px;
font-size: 0.78rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.sim-live-side-list {
display: grid;
gap: 14px;
}
.sim-live-side-list small {
display: block;
color: #6b7280;
font-size: 0.7rem;
margin-bottom: 3px;
}
.sim-live-side-list p {
margin: 0;
color: #111827;
font-size: 0.82rem;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sim-live-progress-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.sim-live-progress-head strong {
color: #111827;
font-size: 0.72rem;
}
.sim-live-progress-track {
height: 6px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.sim-live-progress-track > span {
display: block;
width: 40%;
height: 100%;
background: #6366f1;
box-shadow: 0 0 8px rgba(99, 102, 241, 0.5);
}
.sim-live-coach-card {
border-radius: 24px;
border: 1px solid #c7d2fe;
background: linear-gradient(to bottom right, rgba(238, 242, 255, 0.7), rgba(255, 255, 255, 0.8));
padding: 16px;
}
.sim-live-coach-card h2 {
margin: 0 0 12px;
font-size: 0.82rem;
color: #312e81;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sim-live-coach-list {
display: grid;
gap: 10px;
}
.sim-live-coach-list > div {
border-radius: 12px;
border: 1px solid rgba(224, 231, 255, 0.9);
background: #ffffff;
padding: 11px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.sim-live-coach-list > div:first-child svg {
color: #10b981;
}
.sim-live-coach-list > div:last-child svg {
color: #f59e0b;
}
.sim-live-coach-list strong {
display: block;
margin-bottom: 3px;
color: #111827;
font-size: 0.75rem;
}
.sim-live-coach-list p {
margin: 0;
color: #6b7280;
font-size: 0.72rem;
line-height: 1.5;
}
.sim-live-actions {
display: grid;
gap: 10px;
}
.sim-live-actions button {
border-radius: 12px;
border: 1px solid rgba(229, 231, 235, 0.82);
background: #f9fafb;
color: #4b5563;
padding: 10px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
}
.sim-live-actions button:hover {
background: #f3f4f6;
color: #111827;
}
.sim-live-actions button.stop {
border-color: #111827;
background: #111827;
color: #ffffff;
}
.sim-live-actions button.stop:hover {
background: #1f2937;
}
@keyframes sim-wave {
0%, 100% { transform: scaleY(0.35); }
50% { transform: scaleY(1); }
}
@keyframes sim-pulse-soft {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
@keyframes sim-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.sim-hero-card {
position: relative;
overflow: hidden;
border-radius: 30px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
padding: 30px;
margin-bottom: 28px;
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(0, 0.9fr);
gap: 24px;
}
.sim-hero-glow {
position: absolute;
top: -80px;
right: -80px;
width: 260px;
height: 260px;
border-radius: 999px;
background: rgba(99, 102, 241, 0.12);
filter: blur(70px);
pointer-events: none;
}
.sim-hero-left h1 {
margin: 0 0 12px;
font-size: clamp(1.9rem, 3vw, 2.55rem);
letter-spacing: -0.03em;
color: #111827;
}
.sim-hero-left p {
margin: 0 0 22px;
line-height: 1.65;
color: #4b5563;
}
.sim-benefits {
margin: 0 0 24px;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
}
.sim-benefits li {
display: inline-flex;
align-items: center;
gap: 9px;
color: #374151;
font-size: 0.88rem;
}
.sim-benefits li svg {
color: #10b981;
flex-shrink: 0;
}
.sim-start-btn {
border: 0;
border-radius: 12px;
background: #111827;
color: #ffffff;
padding: 11px 16px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.86rem;
font-weight: 500;
cursor: pointer;
}
.sim-start-btn:hover {
background: #1f2937;
}
.sim-config-card {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
padding: 18px;
display: grid;
gap: 12px;
align-content: start;
}
.sim-config-head h3 {
margin: 0;
font-size: 0.92rem;
color: #111827;
}
.sim-config-head p {
margin: 3px 0 0;
color: #6b7280;
font-size: 0.74rem;
}
.sim-config-card label {
display: grid;
gap: 6px;
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
}
.sim-select-wrap {
border: 1px solid rgba(229, 231, 235, 0.8);
background: #ffffff;
border-radius: 12px;
padding: 0 12px;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.sim-select-wrap svg {
color: #9ca3af;
flex-shrink: 0;
transition: color 0.2s ease;
}
.sim-select-wrap select {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: #374151;
font-size: 0.84rem;
font-weight: 500;
padding: 11px 0;
outline: none;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
}
.sim-caret {
color: #9ca3af;
}
.sim-select-wrap:hover {
border-color: rgba(156, 163, 175, 0.8);
box-shadow: 0 5px 12px rgba(15, 23, 42, 0.06);
}
.sim-select-wrap:hover > svg,
.sim-select-wrap:hover .sim-caret {
color: #6366f1;
}
.sim-mini-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.sim-history-head {
margin-bottom: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.sim-history-head h2 {
margin: 0;
color: #111827;
font-size: 1.25rem;
font-weight: 500;
}
.sim-history-head button {
border: 0;
background: transparent;
display: inline-flex;
align-items: center;
gap: 6px;
color: #6b7280;
cursor: pointer;
font-size: 0.84rem;
font-weight: 500;
}
.sim-history-head button:hover {
color: #111827;
}
.sim-history-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.sim-card {
border-radius: 22px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.03);
padding: 18px;
display: flex;
flex-direction: column;
min-height: 100%;
}
.sim-card.draft {
background: rgba(249, 250, 251, 0.8);
opacity: 0.85;
}
.sim-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 14px;
}
.sim-card-head h3 {
margin: 0 0 2px;
color: #111827;
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.sim-card-head p {
margin: 0;
color: #6b7280;
font-size: 0.82rem;
}
.sim-status {
font-size: 0.69rem;
font-weight: 500;
border-radius: 8px;
padding: 4px 8px;
border: 1px solid;
flex-shrink: 0;
}
.sim-status.done {
color: #059669;
background: #ecfdf5;
border-color: #a7f3d0;
}
.sim-status.draft {
color: #4b5563;
background: rgba(229, 231, 235, 0.45);
border-color: rgba(209, 213, 219, 0.9);
}
.sim-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 18px;
}
.sim-tags span {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(249, 250, 251, 0.85);
color: #4b5563;
font-size: 0.72rem;
font-weight: 500;
padding: 6px 9px;
}
.sim-card-foot {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid rgba(229, 231, 235, 0.65);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.sim-card-foot small {
color: #9ca3af;
font-size: 0.72rem;
}
.sim-link-btn {
border: 0;
background: transparent;
display: inline-flex;
align-items: center;
gap: 6px;
color: #4f46e5;
cursor: pointer;
font-size: 0.82rem;
font-weight: 500;
}
.sim-link-btn:hover {
color: #4338ca;
}
.theme-dark .sim-hero-card,
.theme-dark .sim-config-card,
.theme-dark .sim-card,
.theme-dark .sim-live-chat-card,
.theme-dark .sim-live-side-card {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.06);
}
.theme-dark .sim-hero-left h1,
.theme-dark .sim-config-head h3,
.theme-dark .sim-history-head h2,
.theme-dark .sim-card-head h3,
.theme-dark .sim-live-head h1,
.theme-dark .sim-live-ai-row h3,
.theme-dark .sim-live-side-list p,
.theme-dark .sim-live-progress-head strong,
.theme-dark .sim-live-coach-list strong,
.theme-dark .sim-live-time strong {
color: #ffffff;
}
.theme-dark .sim-hero-left p,
.theme-dark .sim-benefits li,
.theme-dark .sim-config-head p,
.theme-dark .sim-config-card label,
.theme-dark .sim-card-head p,
.theme-dark .sim-tags span,
.theme-dark .sim-history-head button,
.theme-dark .sim-live-head p,
.theme-dark .sim-live-side-list small,
.theme-dark .sim-live-coach-list p,
.theme-dark .sim-live-time small,
.theme-dark .sim-leave-btn {
color: #9ca3af;
}
.theme-dark .sim-start-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.theme-dark .sim-select-wrap {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.theme-dark .sim-select-wrap select {
color: #d1d5db;
}
.theme-dark .sim-live-chat-head,
.theme-dark .sim-live-voice {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.theme-dark .sim-live-more-btn {
background: rgba(255, 255, 255, 0.08);
color: #9ca3af;
}
.theme-dark .sim-live-ai-avatar,
.theme-dark .sim-live-msg-avatar.ai {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(129, 140, 248, 0.4);
color: #a5b4fc;
}
.theme-dark .sim-live-msg-avatar.me {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
}
.theme-dark .sim-live-msg-bubble.ai {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
}
.theme-dark .sim-live-msg-bubble.ai p {
color: #d1d5db;
}
.theme-dark .sim-live-msg-bubble.me {
background: #4f46e5;
}
.theme-dark .sim-live-progress-track {
background: rgba(255, 255, 255, 0.1);
}
.theme-dark .sim-live-coach-card {
background: linear-gradient(to bottom right, rgba(79, 70, 229, 0.18), rgba(255, 255, 255, 0.03));
border-color: rgba(129, 140, 248, 0.45);
}
.theme-dark .sim-live-coach-card h2 {
color: #c7d2fe;
}
.theme-dark .sim-live-coach-list > div {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
}
.theme-dark .sim-tags span {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.theme-dark .sim-status.done {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.45);
color: #34d399;
}
.theme-dark .sim-status.draft {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.12);
color: #d1d5db;
}
.theme-dark .sim-card-foot {
border-top-color: rgba(255, 255, 255, 0.08);
}
.theme-dark .sim-link-btn {
color: #818cf8;
}
.theme-dark .sim-live-actions button {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
color: #d1d5db;
}
.theme-dark .sim-live-actions button.stop {
background: #1f2937;
border-color: #374151;
color: #ffffff;
}
@media (max-width: 1180px) {
.sim-live-grid {
grid-template-columns: 1fr;
min-height: auto;
}
.sim-live-chat-card {
min-height: 520px;
}
.sim-live-side {
overflow: visible;
padding-right: 0;
}
.sim-hero-card {
grid-template-columns: 1fr;
}
.sim-history-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.sim-live-chat-card {
min-height: auto;
}
.sim-live-time-row {
flex-direction: column;
gap: 8px;
}
.sim-wrap {
padding-bottom: 18px;
}
.sim-hero-card {
padding: 20px;
border-radius: 24px;
}
.sim-history-grid,
.sim-mini-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,370 @@
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Check, CheckCircle2, Crown, Filter, Gift, Sparkles, Ticket } from 'lucide-react';
import type { PaymentOverview } from '../../../mvvm/models/payment-overview.interface';
import type { SubscriptionProductInterface } from '../../../mvvm/models/subscription-product.interface';
import { SubscriptionPageViewModel } from '../../../mvvm/viewmodels/SubscriptionPageViewModel';
import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar';
import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar';
import '../../dashboard/pages/dashboard.css';
import './subscription.css';
interface SubscriptionPageProps {
onLogout: () => void;
onNavigate: (target: DashboardNavKey) => void;
onToggleTheme: () => void;
theme: 'light' | 'dark';
}
type PlanKey = '30' | '90' | '365';
interface PlanItem {
dailyText: string;
description: string;
priceText: string;
savings?: string;
title: string;
}
const PLAN_FEATURES = [
'Download et professionelt CV - Klar til brug med et enkelt klik',
'Karriereagent med personlige anbefalinger',
'Øget synlighed bliv fundet af virksomheder',
'Lad systemet lave din ansøgning for dig',
'Optimer dit CV med intelligente forslag',
'Job simulatoren træn til din næste jobsamtale',
];
function toDate(value: Date | string | null | undefined): Date | null {
if (!value) {
return null;
}
const parsed = value instanceof Date ? value : new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function formatRenewDate(value: Date | string | null | undefined): string {
const date = toDate(value);
if (!date) {
return 'Ukendt dato';
}
return new Intl.DateTimeFormat('da-DK', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
function isCurrentlySubscribed(paymentOverview: PaymentOverview | null): boolean {
if (!paymentOverview) {
return false;
}
const activeToDate = toDate(paymentOverview.activeToDate);
if (!activeToDate) {
return false;
}
return activeToDate.getTime() > Date.now();
}
function extractPlans(products: SubscriptionProductInterface | null): Record<PlanKey, PlanItem> {
const p30 = products?.premium_30?.price ?? 49;
const p90 = products?.premium_90?.price ?? 99;
const p365 = products?.premium_365?.price ?? 249;
return {
'30': {
title: '30 dage',
priceText: `${p30} kr.`,
dailyText: 'Svarer til 1.63 kr. pr. dag',
description: 'Månedlig adgang til alle premium-funktioner',
},
'90': {
title: '90 dage',
priceText: `${p90} kr.`,
dailyText: 'Svarer til 1.10 kr. pr. dag',
savings: 'Spar 33%',
description: 'Mest populære valg med ekstra besparelse',
},
'365': {
title: '365 dage',
priceText: `${p365} kr.`,
dailyText: 'Svarer til 0.68 kr. pr. dag',
savings: 'Spar 58%',
description: 'Inkluderer gavekode til 3 måneders premium',
},
};
}
export function SubscriptionPage({ onLogout, onNavigate, onToggleTheme, theme }: SubscriptionPageProps) {
const viewModel = useMemo(() => new SubscriptionPageViewModel(), []);
const [name, setName] = useState('Lasse');
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const [paymentOverview, setPaymentOverview] = useState<PaymentOverview | null>(null);
const [products, setProducts] = useState<SubscriptionProductInterface | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [selectedPlan, setSelectedPlan] = useState<PlanKey>('90');
const [acceptedTerms, setAcceptedTerms] = useState(false);
const [redeemCode, setRedeemCode] = useState('');
const [redeemStatus, setRedeemStatus] = useState('');
const [isRedeeming, setIsRedeeming] = useState(false);
const [previewSubscribed, setPreviewSubscribed] = useState<boolean | null>(null);
useEffect(() => {
let active = true;
async function load() {
setIsLoading(true);
const [profile, snapshot] = await Promise.all([
viewModel.getCandidateProfile(),
viewModel.getSnapshot(),
]);
if (!active) {
return;
}
setName(profile.name);
setImageUrl(profile.imageUrl);
setPaymentOverview(snapshot.paymentOverview);
setProducts(snapshot.products);
setIsLoading(false);
}
void load();
return () => {
active = false;
};
}, [viewModel]);
const plans = useMemo(() => extractPlans(products), [products]);
const subscribedFromData = isCurrentlySubscribed(paymentOverview);
const showSubscribed = previewSubscribed ?? subscribedFromData;
async function handleRedeem() {
const code = redeemCode.trim();
if (!code || isRedeeming) {
return;
}
setRedeemStatus('');
setIsRedeeming(true);
try {
await viewModel.redeemCode(code);
setRedeemStatus('Koden blev indløst. Opdaterer abonnement...');
const snapshot = await viewModel.getSnapshot();
setPaymentOverview(snapshot.paymentOverview);
setProducts(snapshot.products);
setRedeemCode('');
} catch {
setRedeemStatus('Kunne ikke indløse kode. Prøv igen.');
} finally {
setIsRedeeming(false);
}
}
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="subscription" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar sub-main">
<DashboardTopbar
name={name}
imageUrl={imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
actions={(
<div className="sub-top-actions">
<div className="sub-crumb-pill"><Crown size={15} strokeWidth={1.8} /> Abonnement</div>
<button
type="button"
className="sub-toggle-btn"
onClick={() => setPreviewSubscribed((current) => {
if (current === null) {
return !subscribedFromData;
}
return !current;
})}
>
{showSubscribed ? 'Vis "Unsubscribed" state' : 'Vis "Subscribed" state'}
</button>
</div>
)}
/>
<div className="sub-head">
<div className="sub-head-title-row">
<div className="sub-head-icon"><Crown size={18} strokeWidth={1.8} /></div>
<h1>Dit Abonnement</h1>
</div>
<p>
Administrer dit medlemskab og lås op for alle de intelligente værktøjer,
der gør din jobsøgning nemmere.
</p>
</div>
<div className="sub-wrap">
<section className="sub-redeem-card">
<div className="sub-redeem-head">
<span>🎟</span>
<h2>Indløs kode</h2>
</div>
<p>Har du en rabatkode eller gavekode? Indtast den her og den aktiveret med det samme</p>
<div className="sub-redeem-row">
<input
type="text"
value={redeemCode}
onChange={(event) => setRedeemCode(event.target.value)}
placeholder="Indtast kode her..."
/>
<button type="button" onClick={() => void handleRedeem()} disabled={isRedeeming}>
{isRedeeming ? 'Indløser...' : 'Indløs'}
</button>
</div>
{redeemStatus ? <small>{redeemStatus}</small> : null}
</section>
{isLoading ? <p className="dash-loading">Indlaeser abonnement...</p> : null}
{!isLoading && !showSubscribed ? (
<>
<section className="sub-plan-grid">
{(['30', '90', '365'] as PlanKey[]).map((planKey) => {
const plan = plans[planKey];
const selected = selectedPlan === planKey;
const popular = planKey === '90';
const yearly = planKey === '365';
return (
<label
key={planKey}
className={[
'sub-plan-card',
selected ? 'selected' : '',
popular ? 'popular' : '',
yearly ? 'yearly' : '',
].join(' ').trim()}
>
{popular ? <div className="sub-popular-badge">Mest populær</div> : null}
<input
type="radio"
name="pricing-plan"
checked={selected}
onChange={() => setSelectedPlan(planKey)}
/>
<div className="sub-radio-indicator">
<i />
</div>
<div className="sub-plan-content">
<div className="sub-plan-title-row">
<h3>{plan.title}</h3>
{plan.savings ? <span>{plan.savings}</span> : null}
</div>
<div className="sub-price">{plan.priceText}</div>
<p className="sub-price-daily">{plan.dailyText}</p>
{yearly ? (
<div className="sub-gift-box">
<Gift size={17} strokeWidth={1.8} />
<div>
<strong>Giv en gave</strong>
<small> en gratis 3-måneders Premium-kode med i købet til en ven.</small>
</div>
</div>
) : null}
<div className="sub-divider" />
<ul>
{PLAN_FEATURES.map((feature) => (
<li key={`${planKey}-${feature}`}>
<CheckCircle2 size={15} strokeWidth={1.8} />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</label>
);
})}
</section>
<section className="sub-checkout-card">
<div>
<h3>Klar til at opgradere?</h3>
<p>Vælg en plan ovenfor og adgang til alle Premium-funktioner med det samme.</p>
</div>
<div className="sub-checkout-actions">
<label>
<input type="checkbox" checked={acceptedTerms} onChange={(event) => setAcceptedTerms(event.target.checked)} />
<span className="sub-checkbox"><Check size={13} strokeWidth={2.2} /></span>
<span>Jeg accepterer handelsbetingelser</span>
</label>
<button type="button" disabled={!acceptedTerms}> til betaling</button>
</div>
</section>
</>
) : null}
{!isLoading && showSubscribed ? (
<div className="sub-active-wrap">
<section className="sub-gift-alert">
<div className="sub-gift-glow" />
<div className="sub-gift-icon">🎁</div>
<div>
<h2>Din gave venter!</h2>
<p>Klik gaven nedenfor for at se indholdet</p>
</div>
<button type="button">Åbn gave</button>
</section>
<section className="sub-active-card">
<div className="sub-active-head">
<span>👑</span>
<h2>Premium abonnement</h2>
</div>
<div className="sub-active-grid">
<div className="sub-active-features">
<ul>
{PLAN_FEATURES.map((feature) => (
<li key={`active-${feature}`}>
<CheckCircle2 size={16} strokeWidth={1.8} />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<div className="sub-active-status-col">
<div className="sub-active-status-box">
<div className="sub-active-check"><CheckCircle2 size={34} strokeWidth={1.8} /></div>
<h3>Dit abonnement er aktivt</h3>
<strong>{plans[selectedPlan].priceText} for {plans[selectedPlan].title}</strong>
<p>{plans[selectedPlan].dailyText}</p>
<div className="sub-renew-pill">
<CalendarDays size={16} strokeWidth={1.8} />
Fornyes d. {formatRenewDate(paymentOverview?.renewDate)}
</div>
</div>
<button type="button" className="sub-manage-link">Administrer betalingsoplysninger</button>
</div>
</div>
</section>
</div>
) : null}
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,750 @@
.sub-main {
position: relative;
}
.sub-top-actions {
display: inline-flex;
align-items: center;
gap: 10px;
}
.sub-crumb-pill {
border-radius: 999px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
padding: 7px 11px;
font-size: 0.8rem;
color: #4b5563;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sub-crumb-pill svg {
color: #14b8a6;
}
.sub-toggle-btn {
border: 0;
border-radius: 999px;
background: #111827;
color: #ffffff;
font-size: 0.72rem;
font-weight: 500;
padding: 8px 12px;
cursor: pointer;
}
.sub-toggle-btn:hover {
background: #1f2937;
}
.sub-head {
margin-bottom: 18px;
max-width: 850px;
}
.sub-head-title-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.sub-head-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, #2dd4bf, #06b6d4);
color: #ffffff;
display: grid;
place-items: center;
}
.sub-head h1 {
margin: 0;
color: #111827;
font-size: clamp(2rem, 3.2vw, 2.5rem);
letter-spacing: -0.03em;
}
.sub-head p {
margin: 0;
color: #6b7280;
line-height: 1.65;
}
.sub-wrap {
max-width: 1200px;
padding-bottom: 20px;
}
.sub-redeem-card {
max-width: 900px;
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.03);
padding: 20px;
margin-bottom: 22px;
}
.sub-redeem-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.sub-redeem-head h2 {
margin: 0;
color: #111827;
font-size: 1.06rem;
}
.sub-redeem-card p {
margin: 0 0 12px;
color: #6b7280;
font-size: 0.86rem;
}
.sub-redeem-row {
max-width: 560px;
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.sub-redeem-row input {
border-radius: 12px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.85);
color: #111827;
font-size: 0.86rem;
padding: 10px 12px;
outline: none;
}
.sub-redeem-row input:focus {
border-color: rgba(20, 184, 166, 0.7);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.12);
}
.sub-redeem-row button {
border: 0;
border-radius: 12px;
background: #111827;
color: #ffffff;
font-size: 0.82rem;
font-weight: 500;
padding: 10px 14px;
cursor: pointer;
}
.sub-redeem-row button:hover {
background: #1f2937;
}
.sub-redeem-row button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.sub-redeem-card small {
margin-top: 8px;
display: block;
color: #0f766e;
}
.sub-plan-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.sub-plan-card {
position: relative;
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
padding: 20px;
cursor: pointer;
}
.sub-plan-card.popular {
background: rgba(255, 255, 255, 0.8);
}
.sub-plan-card.yearly {
border-color: rgba(199, 210, 254, 0.9);
}
.sub-plan-card.selected {
border-color: rgba(20, 184, 166, 0.8);
box-shadow: 0 8px 28px rgba(20, 184, 166, 0.12);
background: rgba(240, 253, 250, 0.42);
}
.sub-plan-card.yearly.selected {
border-color: rgba(99, 102, 241, 0.8);
box-shadow: 0 8px 28px rgba(99, 102, 241, 0.12);
background: rgba(238, 242, 255, 0.35);
}
.sub-plan-card input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.sub-popular-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
border-radius: 999px;
padding: 4px 10px;
background: #111827;
color: #ffffff;
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.sub-radio-indicator {
position: absolute;
top: 20px;
right: 20px;
width: 20px;
height: 20px;
border-radius: 999px;
border: 1px solid #d1d5db;
display: grid;
place-items: center;
}
.sub-radio-indicator i {
width: 8px;
height: 8px;
border-radius: 999px;
background: #ffffff;
opacity: 0;
}
.sub-plan-card.selected .sub-radio-indicator {
border-color: #14b8a6;
background: #14b8a6;
}
.sub-plan-card.selected .sub-radio-indicator i {
opacity: 1;
}
.sub-plan-card.yearly.selected .sub-radio-indicator {
border-color: #6366f1;
background: #6366f1;
}
.sub-plan-content {
display: grid;
align-content: start;
height: 100%;
}
.sub-plan-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.sub-plan-title-row h3 {
margin: 0;
color: #111827;
font-size: 1.08rem;
}
.sub-plan-title-row span {
border-radius: 6px;
border: 1px solid rgba(153, 246, 228, 0.7);
background: rgba(240, 253, 250, 0.9);
color: #0f766e;
font-size: 0.64rem;
font-weight: 600;
padding: 2px 6px;
}
.sub-price {
margin-top: 10px;
color: #111827;
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.03em;
}
.sub-price-daily {
margin: 2px 0 0;
color: #6b7280;
font-size: 0.8rem;
}
.sub-gift-box {
margin-top: 12px;
border-radius: 12px;
border: 1px solid rgba(199, 210, 254, 0.8);
background: linear-gradient(to bottom right, rgba(238, 242, 255, 0.9), rgba(245, 243, 255, 0.8));
padding: 10px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.sub-gift-box strong {
display: block;
color: #111827;
font-size: 0.78rem;
margin-bottom: 2px;
}
.sub-gift-box small {
color: #4b5563;
font-size: 0.72rem;
line-height: 1.45;
}
.sub-divider {
margin: 14px 0;
height: 1px;
background: rgba(229, 231, 235, 0.8);
}
.sub-plan-content ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
}
.sub-plan-content li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.sub-plan-content li svg {
color: #14b8a6;
margin-top: 2px;
flex-shrink: 0;
}
.sub-plan-card.yearly .sub-plan-content li svg {
color: #6366f1;
}
.sub-plan-content li span {
color: #374151;
font-size: 0.8rem;
line-height: 1.5;
}
.sub-checkout-card {
margin-top: 20px;
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.sub-checkout-card h3 {
margin: 0 0 4px;
color: #111827;
font-size: 1.06rem;
}
.sub-checkout-card p {
margin: 0;
color: #6b7280;
font-size: 0.84rem;
}
.sub-checkout-actions {
display: grid;
gap: 10px;
justify-items: end;
}
.sub-checkout-actions label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #4b5563;
font-size: 0.8rem;
}
.sub-checkout-actions input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.sub-checkbox {
width: 18px;
height: 18px;
border-radius: 5px;
border: 1px solid #d1d5db;
background: #ffffff;
display: grid;
place-items: center;
color: #ffffff;
}
.sub-checkout-actions input:checked + .sub-checkbox {
background: #14b8a6;
border-color: #14b8a6;
}
.sub-checkout-actions .sub-checkbox svg {
opacity: 0;
}
.sub-checkout-actions input:checked + .sub-checkbox svg {
opacity: 1;
}
.sub-checkout-actions > button {
border: 0;
border-radius: 12px;
background: #111827;
color: #ffffff;
font-size: 0.92rem;
font-weight: 500;
padding: 11px 22px;
cursor: pointer;
}
.sub-checkout-actions > button:hover {
background: #1f2937;
}
.sub-checkout-actions > button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.sub-active-wrap {
max-width: 940px;
}
.sub-gift-alert {
position: relative;
overflow: hidden;
border-radius: 22px;
background: linear-gradient(to right, #6366f1, #8b5cf6, #6366f1);
color: #ffffff;
padding: 18px;
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 12px;
}
.sub-gift-glow {
position: absolute;
right: -60px;
top: -60px;
width: 180px;
height: 180px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.24);
filter: blur(40px);
}
.sub-gift-icon {
width: 50px;
height: 50px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.18);
display: grid;
place-items: center;
font-size: 1.6rem;
position: relative;
z-index: 1;
}
.sub-gift-alert h2 {
margin: 0 0 2px;
font-size: 1.16rem;
}
.sub-gift-alert p {
margin: 0;
font-size: 0.82rem;
color: #e0e7ff;
}
.sub-gift-alert button {
margin-left: auto;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
font-size: 0.78rem;
font-weight: 500;
padding: 8px 12px;
cursor: pointer;
position: relative;
z-index: 1;
}
.sub-active-card {
border-radius: 24px;
border: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.62);
overflow: hidden;
}
.sub-active-head {
padding: 18px;
border-bottom: 1px solid rgba(229, 231, 235, 0.72);
display: flex;
align-items: center;
gap: 8px;
}
.sub-active-head h2 {
margin: 0;
color: #111827;
font-size: 1.24rem;
}
.sub-active-grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
.sub-active-features {
padding: 18px;
border-right: 1px solid rgba(229, 231, 235, 0.7);
background: rgba(249, 250, 251, 0.4);
}
.sub-active-features ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
}
.sub-active-features li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.sub-active-features li svg {
color: #14b8a6;
margin-top: 2px;
}
.sub-active-features li span {
color: #374151;
font-size: 0.82rem;
line-height: 1.5;
}
.sub-active-status-col {
padding: 18px;
}
.sub-active-status-box {
border-radius: 16px;
border: 1px solid rgba(153, 246, 228, 0.72);
background: linear-gradient(to bottom right, rgba(240, 253, 250, 0.8), rgba(236, 254, 255, 0.7));
padding: 18px;
text-align: center;
}
.sub-active-check {
width: 56px;
height: 56px;
border-radius: 999px;
border: 1px solid rgba(153, 246, 228, 0.8);
background: #ffffff;
color: #14b8a6;
display: grid;
place-items: center;
margin: 0 auto 10px;
}
.sub-active-status-box h3 {
margin: 0 0 5px;
color: #111827;
font-size: 1rem;
}
.sub-active-status-box strong {
display: block;
color: #0f766e;
font-size: 0.95rem;
}
.sub-active-status-box p {
margin: 3px 0 10px;
color: #0f766e;
font-size: 0.82rem;
}
.sub-renew-pill {
border-radius: 12px;
border: 1px solid rgba(153, 246, 228, 0.72);
background: rgba(255, 255, 255, 0.8);
color: #374151;
font-size: 0.82rem;
font-weight: 500;
padding: 9px 11px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.sub-renew-pill svg {
color: #14b8a6;
}
.sub-manage-link {
margin-top: 12px;
width: 100%;
border: 0;
background: transparent;
color: #6b7280;
font-size: 0.8rem;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.theme-dark .sub-crumb-pill,
.theme-dark .sub-redeem-card,
.theme-dark .sub-plan-card,
.theme-dark .sub-checkout-card,
.theme-dark .sub-active-card {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.theme-dark .sub-head h1,
.theme-dark .sub-redeem-head h2,
.theme-dark .sub-plan-title-row h3,
.theme-dark .sub-checkout-card h3,
.theme-dark .sub-active-head h2,
.theme-dark .sub-active-status-box h3 {
color: #ffffff;
}
.theme-dark .sub-head p,
.theme-dark .sub-redeem-card p,
.theme-dark .sub-price-daily,
.theme-dark .sub-plan-content li span,
.theme-dark .sub-checkout-card p,
.theme-dark .sub-checkout-actions label,
.theme-dark .sub-active-features li span,
.theme-dark .sub-manage-link,
.theme-dark .sub-crumb-pill {
color: #9ca3af;
}
.theme-dark .sub-redeem-row input,
.theme-dark .sub-checkbox,
.theme-dark .sub-renew-pill,
.theme-dark .sub-gift-box {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: #d1d5db;
}
.theme-dark .sub-price,
.theme-dark .sub-active-status-box strong,
.theme-dark .sub-active-status-box p,
.theme-dark .sub-renew-pill {
color: #f3f4f6;
}
.theme-dark .sub-divider,
.theme-dark .sub-active-head,
.theme-dark .sub-active-features {
border-color: rgba(255, 255, 255, 0.08);
}
.theme-dark .sub-active-features {
background: rgba(255, 255, 255, 0.02);
}
.theme-dark .sub-toggle-btn,
.theme-dark .sub-redeem-row button,
.theme-dark .sub-checkout-actions > button {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.14);
}
.theme-dark .sub-gift-alert {
background: linear-gradient(to right, rgba(79, 70, 229, 0.78), rgba(124, 58, 237, 0.74), rgba(79, 70, 229, 0.78));
}
@media (max-width: 1200px) {
.sub-plan-grid {
grid-template-columns: 1fr 1fr;
}
.sub-active-grid {
grid-template-columns: 1fr;
}
.sub-active-features {
border-right: 0;
border-bottom: 1px solid rgba(229, 231, 235, 0.72);
}
}
@media (max-width: 860px) {
.sub-top-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.sub-redeem-row {
grid-template-columns: 1fr;
}
.sub-plan-grid {
grid-template-columns: 1fr;
}
.sub-checkout-card {
flex-direction: column;
align-items: stretch;
}
.sub-checkout-actions {
justify-items: start;
}
.sub-gift-alert {
flex-wrap: wrap;
}
.sub-gift-alert button {
margin-left: 0;
}
}