Initial React project
This commit is contained in:
11
dist/assets/index-BFHBmXZt.js
vendored
11
dist/assets/index-BFHBmXZt.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-Crq8u5MZ.css
vendored
1
dist/assets/index-Crq8u5MZ.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-R0YECfZq.css
vendored
Normal file
1
dist/assets/index-R0YECfZq.css
vendored
Normal file
File diff suppressed because one or more lines are too long
11
dist/assets/index-yGD4iGEM.js
vendored
Normal file
11
dist/assets/index-yGD4iGEM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>arbejd-react</title>
|
<title>arbejd-react</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BFHBmXZt.js"></script>
|
<script type="module" crossorigin src="/assets/index-yGD4iGEM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Crq8u5MZ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-R0YECfZq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
2
node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
2
node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
File diff suppressed because one or more lines are too long
26
src/App.tsx
26
src/App.tsx
@@ -2,12 +2,15 @@ import { useMemo, useState } from 'react';
|
|||||||
import { localStorageService } from './mvvm/services/local-storage.service';
|
import { localStorageService } from './mvvm/services/local-storage.service';
|
||||||
import { AuthPage } from './presentation/auth/pages/AuthPage';
|
import { AuthPage } from './presentation/auth/pages/AuthPage';
|
||||||
import { AiAgentPage } from './presentation/ai-agent/pages/AiAgentPage';
|
import { AiAgentPage } from './presentation/ai-agent/pages/AiAgentPage';
|
||||||
|
import { CareerAgentPage } from './presentation/ai-agent/pages/CareerAgentPage';
|
||||||
import { CvPage } from './presentation/cv/pages/CvPage';
|
import { CvPage } from './presentation/cv/pages/CvPage';
|
||||||
import type { DashboardNavKey } from './presentation/dashboard/components/DashboardSidebar';
|
import type { DashboardNavKey } from './presentation/dashboard/components/DashboardSidebar';
|
||||||
import { DashboardPage } from './presentation/dashboard/pages/DashboardPage';
|
import { DashboardPage } from './presentation/dashboard/pages/DashboardPage';
|
||||||
import { JobDetailPage } from './presentation/jobs/pages/JobDetailPage';
|
import { JobDetailPage } from './presentation/jobs/pages/JobDetailPage';
|
||||||
import { JobsPage } from './presentation/jobs/pages/JobsPage';
|
import { JobsPage } from './presentation/jobs/pages/JobsPage';
|
||||||
import { MessagesPage } from './presentation/messages/pages/MessagesPage';
|
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';
|
type AppPage = DashboardNavKey | 'job-detail';
|
||||||
|
|
||||||
@@ -36,6 +39,8 @@ function App() {
|
|||||||
|| target === 'messages'
|
|| target === 'messages'
|
||||||
|| target === 'agents'
|
|| target === 'agents'
|
||||||
|| target === 'ai-agent'
|
|| target === 'ai-agent'
|
||||||
|
|| target === 'simulator'
|
||||||
|
|| target === 'subscription'
|
||||||
) {
|
) {
|
||||||
setActivePage(target);
|
setActivePage(target);
|
||||||
}
|
}
|
||||||
@@ -89,7 +94,7 @@ function App() {
|
|||||||
return <MessagesPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
|
return <MessagesPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePage === 'agents' || activePage === 'ai-agent') {
|
if (activePage === 'agents') {
|
||||||
return (
|
return (
|
||||||
<AiAgentPage
|
<AiAgentPage
|
||||||
onLogout={handleLogout}
|
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) {
|
if (activePage === 'job-detail' && jobDetailSelection) {
|
||||||
return (
|
return (
|
||||||
<JobDetailPage
|
<JobDetailPage
|
||||||
|
|||||||
135
src/mvvm/viewmodels/SimulatorViewModel.ts
Normal file
135
src/mvvm/viewmodels/SimulatorViewModel.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/mvvm/viewmodels/SubscriptionPageViewModel.ts
Normal file
44
src/mvvm/viewmodels/SubscriptionPageViewModel.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
|||||||
<div className="dash-orb dash-orb-2" />
|
<div className="dash-orb dash-orb-2" />
|
||||||
<div className="dash-orb dash-orb-3" />
|
<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">
|
<main className="dash-main custom-scrollbar ai-agent-main">
|
||||||
<DashboardTopbar
|
<DashboardTopbar
|
||||||
@@ -145,14 +145,14 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="ai-head">
|
<div className="ai-head">
|
||||||
<h1>AI-agenter</h1>
|
<h1>Jobagenter</h1>
|
||||||
<p>Saet din jobsogning pa autopilot. Lad AI overvage og matche dig med de perfekte jobs.</p>
|
<p>Saet din jobsogning pa autopilot. Lad agenter overvage og matche dig med de perfekte jobs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="ai-create-card">
|
<section className="ai-create-card">
|
||||||
<div className="ai-create-title">
|
<div className="ai-create-title">
|
||||||
<div className="ai-create-icon"><Bot size={20} strokeWidth={1.8} /></div>
|
<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>
|
||||||
|
|
||||||
<div className="ai-form-grid">
|
<div className="ai-form-grid">
|
||||||
@@ -206,7 +206,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ai-create-actions">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -255,11 +255,11 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
|||||||
className="ai-job-card"
|
className="ai-job-card"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent')}
|
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'agents')}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
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-company-logo-fallback">{initials(job.companyName)}</div>}
|
||||||
<div className="ai-match-col">
|
<div className="ai-match-col">
|
||||||
<div className="ai-match-pill"><Target size={13} strokeWidth={1.8} /> {deriveMatch(index)}% Match</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
|
onOpenJobDetail(job.id, job.fromJobnet, 'agents');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Læs mere <ArrowRight size={14} strokeWidth={1.8} />
|
Læs mere <ArrowRight size={14} strokeWidth={1.8} />
|
||||||
|
|||||||
296
src/presentation/ai-agent/pages/CareerAgentPage.tsx
Normal file
296
src/presentation/ai-agent/pages/CareerAgentPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
src/presentation/ai-agent/pages/career-agent.css
Normal file
375
src/presentation/ai-agent/pages/career-agent.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
interface DashboardSidebarProps {
|
interface DashboardSidebarProps {
|
||||||
@@ -6,7 +6,7 @@ interface DashboardSidebarProps {
|
|||||||
onNavigate?: (target: DashboardNavKey) => void;
|
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 {
|
interface NavItem {
|
||||||
accent?: boolean;
|
accent?: boolean;
|
||||||
@@ -28,6 +28,7 @@ const secondaryItems: NavItem[] = [
|
|||||||
{ key: 'agents', label: 'Jobagenter', icon: Radar, dot: true },
|
{ key: 'agents', label: 'Jobagenter', icon: Radar, dot: true },
|
||||||
{ key: 'ai-agent', label: 'AI-agent', icon: Bot, accent: true },
|
{ key: 'ai-agent', label: 'AI-agent', icon: Bot, accent: true },
|
||||||
{ key: 'simulator', label: 'Simulator', icon: Gamepad2 },
|
{ key: 'simulator', label: 'Simulator', icon: Gamepad2 },
|
||||||
|
{ key: 'subscription', label: 'Abonnement', icon: Crown },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DashboardSidebar({ active = 'dashboard', onNavigate }: DashboardSidebarProps) {
|
export function DashboardSidebar({ active = 'dashboard', onNavigate }: DashboardSidebarProps) {
|
||||||
|
|||||||
459
src/presentation/simulator/pages/SimulatorPage.tsx
Normal file
459
src/presentation/simulator/pages/SimulatorPage.tsx
Normal 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 på 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 på pause</button>
|
||||||
|
<button type="button" className="stop"><StopCircle size={16} strokeWidth={1.8} /> Afslut & Få 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
967
src/presentation/simulator/pages/simulator.css
Normal file
967
src/presentation/simulator/pages/simulator.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
370
src/presentation/subscription/pages/SubscriptionPage.tsx
Normal file
370
src/presentation/subscription/pages/SubscriptionPage.tsx
Normal 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 få 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>Få 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 få 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}>Gå 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 på 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
750
src/presentation/subscription/pages/subscription.css
Normal file
750
src/presentation/subscription/pages/subscription.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user