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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>arbejd-react</title>
|
||||
<script type="module" crossorigin src="/assets/index-BFHBmXZt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Crq8u5MZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-yGD4iGEM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-R0YECfZq.css">
|
||||
</head>
|
||||
<body>
|
||||
<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 { AuthPage } from './presentation/auth/pages/AuthPage';
|
||||
import { AiAgentPage } from './presentation/ai-agent/pages/AiAgentPage';
|
||||
import { CareerAgentPage } from './presentation/ai-agent/pages/CareerAgentPage';
|
||||
import { CvPage } from './presentation/cv/pages/CvPage';
|
||||
import type { DashboardNavKey } from './presentation/dashboard/components/DashboardSidebar';
|
||||
import { DashboardPage } from './presentation/dashboard/pages/DashboardPage';
|
||||
import { JobDetailPage } from './presentation/jobs/pages/JobDetailPage';
|
||||
import { JobsPage } from './presentation/jobs/pages/JobsPage';
|
||||
import { MessagesPage } from './presentation/messages/pages/MessagesPage';
|
||||
import { SimulatorPage } from './presentation/simulator/pages/SimulatorPage';
|
||||
import { SubscriptionPage } from './presentation/subscription/pages/SubscriptionPage';
|
||||
|
||||
type AppPage = DashboardNavKey | 'job-detail';
|
||||
|
||||
@@ -36,6 +39,8 @@ function App() {
|
||||
|| target === 'messages'
|
||||
|| target === 'agents'
|
||||
|| target === 'ai-agent'
|
||||
|| target === 'simulator'
|
||||
|| target === 'subscription'
|
||||
) {
|
||||
setActivePage(target);
|
||||
}
|
||||
@@ -89,7 +94,7 @@ function App() {
|
||||
return <MessagesPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
|
||||
}
|
||||
|
||||
if (activePage === 'agents' || activePage === 'ai-agent') {
|
||||
if (activePage === 'agents') {
|
||||
return (
|
||||
<AiAgentPage
|
||||
onLogout={handleLogout}
|
||||
@@ -101,6 +106,25 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (activePage === 'ai-agent') {
|
||||
return (
|
||||
<CareerAgentPage
|
||||
onLogout={handleLogout}
|
||||
onNavigate={handleNavigate}
|
||||
theme={theme}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activePage === 'simulator') {
|
||||
return <SimulatorPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
|
||||
}
|
||||
|
||||
if (activePage === 'subscription') {
|
||||
return <SubscriptionPage onLogout={handleLogout} onNavigate={handleNavigate} theme={theme} onToggleTheme={handleToggleTheme} />;
|
||||
}
|
||||
|
||||
if (activePage === 'job-detail' && jobDetailSelection) {
|
||||
return (
|
||||
<JobDetailPage
|
||||
|
||||
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-3" />
|
||||
|
||||
<DashboardSidebar active="ai-agent" onNavigate={onNavigate} />
|
||||
<DashboardSidebar active="agents" onNavigate={onNavigate} />
|
||||
|
||||
<main className="dash-main custom-scrollbar ai-agent-main">
|
||||
<DashboardTopbar
|
||||
@@ -145,14 +145,14 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
||||
/>
|
||||
|
||||
<div className="ai-head">
|
||||
<h1>AI-agenter</h1>
|
||||
<p>Saet din jobsogning pa autopilot. Lad AI overvage og matche dig med de perfekte jobs.</p>
|
||||
<h1>Jobagenter</h1>
|
||||
<p>Saet din jobsogning pa autopilot. Lad agenter overvage og matche dig med de perfekte jobs.</p>
|
||||
</div>
|
||||
|
||||
<section className="ai-create-card">
|
||||
<div className="ai-create-title">
|
||||
<div className="ai-create-icon"><Bot size={20} strokeWidth={1.8} /></div>
|
||||
<h2>Opret ny AI-agent</h2>
|
||||
<h2>Opret ny jobagent</h2>
|
||||
</div>
|
||||
|
||||
<div className="ai-form-grid">
|
||||
@@ -206,7 +206,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
||||
</div>
|
||||
|
||||
<div className="ai-create-actions">
|
||||
<button type="button" onClick={() => void handleSaveAgent()}><Save size={16} strokeWidth={1.8} /> Gem AI-agent</button>
|
||||
<button type="button" onClick={() => void handleSaveAgent()}><Save size={16} strokeWidth={1.8} /> Gem jobagent</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -255,11 +255,11 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
||||
className="ai-job-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent')}
|
||||
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'agents')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
|
||||
onOpenJobDetail(job.id, job.fromJobnet, 'agents');
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -270,7 +270,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
||||
: <div className="ai-company-logo-fallback">{initials(job.companyName)}</div>}
|
||||
<div className="ai-match-col">
|
||||
<div className="ai-match-pill"><Target size={13} strokeWidth={1.8} /> {deriveMatch(index)}% Match</div>
|
||||
<small>Via: {activeFilters[0]?.escoName || 'AI-agent'}</small>
|
||||
<small>Via: {activeFilters[0]?.escoName || 'Jobagent'}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +291,7 @@ export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleThe
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
|
||||
onOpenJobDetail(job.id, job.fromJobnet, 'agents');
|
||||
}}
|
||||
>
|
||||
Læs mere <ArrowRight size={14} strokeWidth={1.8} />
|
||||
|
||||
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';
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
@@ -6,7 +6,7 @@ interface DashboardSidebarProps {
|
||||
onNavigate?: (target: DashboardNavKey) => void;
|
||||
}
|
||||
|
||||
export type DashboardNavKey = 'dashboard' | 'jobs' | 'cv' | 'messages' | 'agents' | 'ai-agent' | 'simulator';
|
||||
export type DashboardNavKey = 'dashboard' | 'jobs' | 'cv' | 'messages' | 'agents' | 'ai-agent' | 'simulator' | 'subscription';
|
||||
|
||||
interface NavItem {
|
||||
accent?: boolean;
|
||||
@@ -28,6 +28,7 @@ const secondaryItems: NavItem[] = [
|
||||
{ key: 'agents', label: 'Jobagenter', icon: Radar, dot: true },
|
||||
{ key: 'ai-agent', label: 'AI-agent', icon: Bot, accent: true },
|
||||
{ key: 'simulator', label: 'Simulator', icon: Gamepad2 },
|
||||
{ key: 'subscription', label: 'Abonnement', icon: Crown },
|
||||
];
|
||||
|
||||
export function DashboardSidebar({ active = 'dashboard', onNavigate }: DashboardSidebarProps) {
|
||||
|
||||
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