refactor: Rename TrueCV to RealCV throughout codebase

- Renamed all directories (TrueCV.* -> RealCV.*)
- Renamed all project files (.csproj)
- Renamed solution file (TrueCV.sln -> RealCV.sln)
- Updated all namespaces in C# and Razor files
- Updated project references
- Updated CSS variable names

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 20:47:55 +00:00
parent 6f384f8d09
commit 92a3b60878
107 changed files with 693 additions and 554 deletions

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="RealCV.Web.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,91 @@
@inherits LayoutComponentBase
<div class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-lg navbar-light shadow-sm" style="background-color: var(--truecv-bg-surface);">
<div class="container">
<a class="navbar-brand fw-bold" href="/">
<img src="images/RealCV_Logo.png" alt="RealCV" style="height: 95px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">
Home
</NavLink>
</li>
<AuthorizeView>
<Authorized>
<li class="nav-item">
<NavLink class="nav-link" href="/dashboard">
Dashboard
</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/check">
New Check
</NavLink>
</li>
</Authorized>
</AuthorizeView>
</ul>
<ul class="navbar-nav">
<AuthorizeView>
<Authorized>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-circle me-1" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
@context.User.Identity?.Name
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<form action="/account/logout" method="post">
<AntiforgeryToken />
<button type="submit" class="dropdown-item">Logout</button>
</form>
</li>
</ul>
</li>
</Authorized>
<NotAuthorized>
<li class="nav-item">
<NavLink class="nav-link" href="/account/login">
Login
</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link btn btn-outline-primary ms-2 px-3" href="/account/register">
Register
</NavLink>
</li>
</NotAuthorized>
</AuthorizeView>
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1">
@Body
</main>
<footer class="text-light py-4 mt-auto" style="background-color: var(--truecv-footer-bg);">
<div class="container text-center">
<p class="mb-0">&copy; @DateTime.Now.Year RealCV. All rights reserved.</p>
</div>
</footer>
</div>
<div id="blazor-error-ui" class="alert alert-danger fixed-bottom m-3" style="display: none;">
An unhandled error has occurred.
<a href="" class="alert-link reload">Reload</a>
<button type="button" class="btn-close float-end dismiss" aria-label="Close"></button>
</div>

View File

@@ -0,0 +1,18 @@
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,147 @@
@page "/account/login"
@using RealCV.Web.Components.Layout
@layout MainLayout
@using Microsoft.AspNetCore.Identity
@using RealCV.Infrastructure.Identity
@inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager NavigationManager
<PageTitle>Login - RealCV</PageTitle>
<div class="auth-container">
<!-- Left side - Form -->
<div class="auth-form-side">
<div class="auth-form-wrapper">
<div class="text-center mb-4">
<a href="/">
<img src="images/RealCV_Logo.png" alt="RealCV" class="auth-logo" />
</a>
</div>
<h1 class="auth-title">Welcome back</h1>
<p class="auth-subtitle">Sign in to continue verifying CVs</p>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger d-flex align-items-center" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
</svg>
<span>@_errorMessage</span>
</div>
}
<form method="post" action="/account/perform-login">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<div class="input-group-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
<input id="email" name="email" type="email" class="form-control form-control-lg"
placeholder="name@example.com" autocomplete="email" required />
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
</svg>
<input id="password" name="password" type="password" class="form-control form-control-lg"
placeholder="Enter your password" autocomplete="current-password" required />
</div>
</div>
<div class="mb-4 d-flex justify-content-between align-items-center">
<div class="form-check">
<input id="rememberMe" name="rememberMe" type="checkbox" class="form-check-input" value="true" />
<label class="form-check-label" for="rememberMe">
Remember me
</label>
</div>
</div>
<div class="d-grid mb-4">
<button type="submit" class="btn btn-primary btn-lg">
<span>Sign In</span>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="ms-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
</svg>
</button>
</div>
</form>
<div class="auth-divider">
<span>New to RealCV?</span>
</div>
<div class="text-center">
<a href="/account/register" class="btn btn-outline-secondary btn-lg w-100">
Create an account
</a>
</div>
</div>
</div>
<!-- Right side - Branding -->
<div class="auth-brand-side">
<div class="auth-brand-content">
<div class="auth-brand-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
</div>
<h2 class="auth-brand-title">CV Verification Made Simple</h2>
<p class="auth-brand-text">
Upload any CV and get instant AI-powered verification with detailed analysis of qualifications, experience, and company legitimacy.
</p>
<div class="auth-stats">
<div class="auth-stat">
<div class="auth-stat-value">10K+</div>
<div class="auth-stat-label">CVs Verified</div>
</div>
<div class="auth-stat">
<div class="auth-stat-value">98%</div>
<div class="auth-stat-label">Accuracy Rate</div>
</div>
<div class="auth-stat">
<div class="auth-stat-value">&lt;30s</div>
<div class="auth-stat-label">Average Time</div>
</div>
</div>
<div class="auth-testimonial">
<blockquote>
"RealCV has transformed our hiring process. We catch discrepancies we would have missed before."
</blockquote>
<cite>- HR Director, Tech Company</cite>
</div>
</div>
</div>
</div>
@code {
private string? _errorMessage;
[SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
[SupplyParameterFromQuery(Name = "error")]
public string? Error { get; set; }
protected override void OnInitialized()
{
_errorMessage = Error;
}
}

View File

@@ -0,0 +1,232 @@
@page "/account/register"
@using RealCV.Web.Components.Layout
@layout MainLayout
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity
@using RealCV.Infrastructure.Identity
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager NavigationManager
<PageTitle>Register - RealCV</PageTitle>
<div class="auth-container">
<!-- Left side - Form -->
<div class="auth-form-side">
<div class="auth-form-wrapper">
<div class="text-center mb-4">
<a href="/">
<img src="images/RealCV_Logo.png" alt="RealCV" class="auth-logo" />
</a>
</div>
<h1 class="auth-title">Create account</h1>
<p class="auth-subtitle">Start verifying UK-based CVs in minutes</p>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger d-flex align-items-center" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
</svg>
<span>@_errorMessage</span>
</div>
}
<EditForm Model="_model" OnValidSubmit="HandleRegister" FormName="register">
<DataAnnotationsValidator />
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<div class="input-group-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
<InputText id="email" class="form-control form-control-lg" @bind-Value="_model.Email"
placeholder="name@example.com" autocomplete="email" />
</div>
<ValidationMessage For="() => _model.Email" class="text-danger small mt-1" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
</svg>
<InputText id="password" type="password" class="form-control form-control-lg"
@bind-Value="_model.Password" placeholder="Create a password" autocomplete="new-password" />
</div>
<ValidationMessage For="() => _model.Password" class="text-danger small mt-1" />
<div class="form-text">At least 12 characters with uppercase, lowercase, number, and symbol.</div>
</div>
<div class="mb-4">
<label for="confirmPassword" class="form-label">Confirm password</label>
<div class="input-group-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
</svg>
<InputText id="confirmPassword" type="password" class="form-control form-control-lg"
@bind-Value="_model.ConfirmPassword" placeholder="Confirm your password" autocomplete="new-password" />
</div>
<ValidationMessage For="() => _model.ConfirmPassword" class="text-danger small mt-1" />
</div>
<div class="d-grid mb-4">
<button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading">
@if (_isLoading)
{
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Creating account...</span>
}
else
{
<span>Create Account</span>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="ms-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
</svg>
}
</button>
</div>
</EditForm>
<p class="text-center text-muted small mb-4">
By creating an account, you agree to our
<a href="#" class="text-decoration-none">Terms of Service</a>
and
<a href="#" class="text-decoration-none">Privacy Policy</a>
</p>
<div class="auth-divider">
<span>Already have an account?</span>
</div>
<div class="text-center">
<a href="/account/login" class="btn btn-outline-secondary btn-lg w-100">
Sign in
</a>
</div>
</div>
</div>
<!-- Right side - Branding -->
<div class="auth-brand-side">
<div class="auth-brand-content">
<div class="auth-brand-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
</svg>
</div>
<h2 class="auth-brand-title">Start Your Free Trial</h2>
<p class="auth-brand-text">
Get 3 free CV verifications to experience the power of AI-driven credential analysis.
</p>
<div class="auth-features">
<div class="auth-feature">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
<span>AI-powered verification in seconds</span>
</div>
<div class="auth-feature">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
<span>Company legitimacy checks</span>
</div>
<div class="auth-feature">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
<span>Qualification & timeline analysis</span>
</div>
<div class="auth-feature">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7z"/>
</svg>
<span>Detailed PDF reports</span>
</div>
</div>
<div class="auth-testimonial">
<blockquote>
"We reduced bad hires by 40% in the first quarter using RealCV."
</blockquote>
<cite>- Recruitment Manager, Financial Services</cite>
</div>
</div>
</div>
</div>
@code {
private RegisterModel _model = new();
private bool _isLoading;
private string? _errorMessage;
private async Task HandleRegister()
{
_isLoading = true;
_errorMessage = null;
try
{
if (_model.Password != _model.ConfirmPassword)
{
_errorMessage = "Passwords do not match.";
_isLoading = false;
return;
}
var user = new ApplicationUser
{
UserName = _model.Email,
Email = _model.Email,
Plan = Domain.Enums.UserPlan.Free,
ChecksUsedThisMonth = 0
};
var result = await UserManager.CreateAsync(user, _model.Password);
if (result.Succeeded)
{
await SignInManager.SignInAsync(user, isPersistent: false);
NavigationManager.NavigateTo("/dashboard", forceLoad: true);
}
else
{
var errors = result.Errors.Select(e => e.Description);
_errorMessage = string.Join(" ", errors);
}
}
catch (Exception)
{
_errorMessage = "An unexpected error occurred. Please try again.";
}
finally
{
_isLoading = false;
}
}
private sealed class RegisterModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email format")]
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
[System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
public string Password { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,551 @@
@page "/check"
@attribute [Authorize]
@rendermode InteractiveServer
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Check> Logger
<PageTitle>Upload CVs - RealCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="text-center mb-4">
<h1 class="fw-bold">Upload CVs for Verification</h1>
<p class="text-muted lead">Upload one or more CVs in PDF or DOCX format to begin the verification process</p>
<div class="alert alert-info d-inline-flex align-items-center py-2 px-3 mb-0" style="font-size: 0.875rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/>
</svg>
<span>For UK employment history</span>
</div>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage
<button type="button" class="btn-close" @onclick="() => _errorMessage = null" aria-label="Close"></button>
</div>
}
<div class="card border-0 shadow">
<div class="card-body p-4">
@if (_isUploading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Uploading...</span>
</div>
<h5 class="mb-2">Uploading CVs...</h5>
<p class="text-muted">Processing @_currentFileIndex of @_totalFiles files</p>
<div class="progress" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: @(_uploadProgress)%"
aria-valuenow="@_uploadProgress"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
@if (!string.IsNullOrEmpty(_currentFileName))
{
<small class="text-muted mt-2 d-block">@_currentFileName</small>
}
</div>
}
else
{
<div class="upload-area @(_isDragging ? "dragging" : "")"
@ondragenter="HandleDragEnter"
@ondragleave="HandleDragLeave"
@ondragover:preventDefault
@ondrop="HandleDrop"
@ondrop:preventDefault>
<InputFile OnChange="HandleFileSelected"
accept=".pdf,.docx,.json"
multiple
class="d-none"
id="fileInput" />
<label for="fileInput" class="d-block text-center py-4 cursor-pointer">
<div class="upload-icon mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/>
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
</svg>
</div>
<h4 class="fw-bold mb-1">Drop your CVs here</h4>
<p class="text-muted mb-3 small">Drag and drop files, or click to browse</p>
<div class="d-flex justify-content-center gap-2 flex-wrap">
<span class="badge bg-light text-dark border px-2 py-1">PDF</span>
<span class="badge bg-light text-dark border px-2 py-1">DOCX</span>
<span class="badge bg-light text-dark border px-2 py-1">Max 10MB</span>
</div>
</label>
</div>
@if (_isBuffering)
{
<div class="mt-4 text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading files...</span>
</div>
<p class="mt-2 text-muted">Reading files...</p>
</div>
}
@if (_selectedFiles.Count > 0 && !_isBuffering)
{
<div class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Selected Files (@_selectedFiles.Count)</h6>
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearAllFiles">
Clear All
</button>
</div>
<div class="file-list">
@foreach (var file in _selectedFiles)
{
<div class="file-list-item">
<div class="d-flex align-items-center">
<div class="file-type-icon me-3 @GetFileTypeClass(file.Name)">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
</svg>
</div>
<div class="flex-grow-1">
<p class="mb-0 fw-medium">@file.Name</p>
<small class="text-muted">@FormatFileSize(file.Size)</small>
</div>
</div>
<button class="btn btn-sm btn-outline-danger" @onclick="() => RemoveFile(file)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
</svg>
</button>
</div>
}
</div>
</div>
<div class="mt-4 text-center">
<button class="btn btn-primary btn-lg px-5" @onclick="UploadFiles">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload me-2" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg>
Start Verification (@_selectedFiles.Count @(_selectedFiles.Count == 1 ? "CV" : "CVs"))
</button>
</div>
}
}
</div>
</div>
<!-- Security & Process Info -->
<div class="security-info mt-4">
<div class="d-flex justify-content-center flex-wrap gap-4">
<div class="security-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
</svg>
<span>256-bit Encryption</span>
</div>
<div class="security-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
</svg>
<span>Secure Storage</span>
</div>
<div class="security-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/>
</svg>
<span>AI-Powered Analysis</span>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.upload-area {
border: 2px dashed var(--truecv-gray-300);
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(180deg, var(--truecv-bg-surface) 0%, var(--truecv-bg-muted) 100%);
}
.upload-area:hover {
border-color: var(--truecv-primary);
background: linear-gradient(180deg, #e8f1fa 0%, #d4e4f4 100%);
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(59, 111, 212, 0.1);
}
.upload-area.dragging {
border-color: var(--truecv-primary);
background: linear-gradient(180deg, #d4e4f4 0%, #c5d9ef 100%);
border-style: solid;
transform: scale(1.02);
}
.upload-icon {
width: 80px;
height: 80px;
background: linear-gradient(135deg, var(--truecv-primary) 0%, var(--truecv-primary-dark) 100%);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
color: white;
transition: transform 0.3s ease;
}
.upload-area:hover .upload-icon {
transform: scale(1.1) rotate(-5deg);
}
.cursor-pointer {
cursor: pointer;
user-select: none;
}
.file-list-item {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--truecv-gray-200);
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
background: var(--truecv-bg-surface);
transition: all 0.2s ease;
}
.file-list-item:hover {
border-color: var(--truecv-primary);
box-shadow: 0 4px 12px rgba(59, 111, 212, 0.08);
}
.file-type-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.file-type-icon.pdf {
background: linear-gradient(135deg, #fde8e8 0%, #fcd9d9 100%);
color: #dc2626;
}
.file-type-icon.docx {
background: linear-gradient(135deg, #e3ecf7 0%, #d4e4f4 100%);
color: var(--truecv-primary);
}
.file-type-icon.json {
background: linear-gradient(135deg, #fef9c3 0%, #fef08a 100%);
color: #ca8a04;
}
.security-info {
padding: 1rem 0;
}
.security-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--truecv-bg-muted);
border-radius: var(--truecv-radius);
font-size: 0.875rem;
color: var(--truecv-gray-600);
}
.security-badge svg {
color: var(--truecv-verified);
}
@@media (max-width: 576px) {
.upload-icon {
width: 64px;
height: 64px;
}
.upload-icon svg {
width: 32px;
height: 32px;
}
.card-body.p-5 {
padding: 1.5rem !important;
}
.d-flex.justify-content-center.gap-3 {
flex-direction: column;
gap: 0.5rem !important;
}
.security-info .d-flex {
flex-direction: column;
gap: 0.75rem !important;
}
}
</style>
@code {
private List<BufferedFile> _selectedFiles = new();
private bool _isUploading;
private bool _isBuffering;
private bool _isDragging;
private int _uploadProgress;
private string? _errorMessage;
private int _currentFileIndex;
private int _totalFiles;
private string? _currentFileName;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private const int MaxFileCount = 50; // Maximum files per batch
// Magic bytes for file type validation
private static readonly byte[] PdfMagicBytes = [0x25, 0x50, 0x44, 0x46]; // %PDF
private static readonly byte[] DocxMagicBytes = [0x50, 0x4B, 0x03, 0x04]; // PK.. (ZIP signature)
// Buffered file to prevent stale IBrowserFile references
private sealed record BufferedFile(string Name, long Size, byte[] Data);
private void HandleDragEnter()
{
_isDragging = true;
}
private void HandleDragLeave()
{
_isDragging = false;
}
private void HandleDrop()
{
_isDragging = false;
}
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
_isBuffering = true;
StateHasChanged();
var invalidFiles = new List<string>();
var oversizedFiles = new List<string>();
var failedFiles = new List<string>();
try
{
foreach (var file in e.GetMultipleFiles(MaxFileCount))
{
if (!IsValidFileType(file.Name))
{
invalidFiles.Add(file.Name);
continue;
}
if (file.Size > MaxFileSize)
{
oversizedFiles.Add(file.Name);
continue;
}
// Avoid duplicates
if (_selectedFiles.Any(f => f.Name == file.Name && f.Size == file.Size))
{
continue;
}
// Read file data immediately to prevent stale reference issues
try
{
await using var stream = file.OpenReadStream(MaxFileSize);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
_selectedFiles.Add(new BufferedFile(file.Name, file.Size, memoryStream.ToArray()));
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to buffer file: {FileName}", file.Name);
failedFiles.Add(file.Name);
}
}
}
finally
{
_isBuffering = false;
}
// Build error message if any files were rejected
var errors = new List<string>();
if (invalidFiles.Count > 0)
errors.Add($"Invalid file type(s): {string.Join(", ", invalidFiles.Take(3))}{(invalidFiles.Count > 3 ? $" and {invalidFiles.Count - 3} more" : "")}");
if (oversizedFiles.Count > 0)
errors.Add($"File(s) exceed 10MB limit: {string.Join(", ", oversizedFiles.Take(3))}{(oversizedFiles.Count > 3 ? $" and {oversizedFiles.Count - 3} more" : "")}");
if (failedFiles.Count > 0)
errors.Add($"Failed to read file(s): {string.Join(", ", failedFiles.Take(3))}{(failedFiles.Count > 3 ? $" and {failedFiles.Count - 3} more" : "")}");
if (errors.Count > 0)
_errorMessage = string.Join(". ", errors);
}
private void RemoveFile(BufferedFile file)
{
_selectedFiles.Remove(file);
}
private void ClearAllFiles()
{
_selectedFiles.Clear();
_errorMessage = null;
}
private async Task UploadFiles()
{
if (_selectedFiles.Count == 0) return;
try
{
_isUploading = true;
_uploadProgress = 0;
_errorMessage = null;
_totalFiles = _selectedFiles.Count;
_currentFileIndex = 0;
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
_errorMessage = "Unable to identify user. Please log in again.";
_isUploading = false;
return;
}
var failedFiles = new List<string>();
foreach (var file in _selectedFiles.ToList())
{
_currentFileIndex++;
_currentFileName = file.Name;
_uploadProgress = (int)((_currentFileIndex - 1) / (double)_totalFiles * 100);
await InvokeAsync(StateHasChanged);
try
{
using var memoryStream = new MemoryStream(file.Data);
// Validate file content (magic bytes)
if (!await ValidateFileContentAsync(memoryStream, file.Name))
{
failedFiles.Add($"{file.Name} (invalid content)");
continue;
}
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);
failedFiles.Add($"{file.Name} (upload error)");
}
}
_uploadProgress = 100;
_currentFileName = null;
await InvokeAsync(StateHasChanged);
await Task.Delay(500); // Brief pause to show completion
if (failedFiles.Count > 0 && failedFiles.Count < _totalFiles)
{
// Partial success - some files failed
Logger.LogWarning("Some files failed to upload: {FailedFiles}", string.Join(", ", failedFiles));
}
// Navigate to dashboard to see all uploaded CVs
NavigationManager.NavigateTo("/dashboard");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error uploading CVs");
_errorMessage = "An error occurred while uploading. Please try again.";
}
finally
{
_isUploading = false;
_currentFileName = null;
}
}
private async Task<bool> ValidateFileContentAsync(MemoryStream stream, string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var header = new byte[4];
stream.Position = 0;
var bytesRead = await stream.ReadAsync(header.AsMemory(0, 4));
stream.Position = 0;
if (bytesRead < 4)
return false;
return extension switch
{
".pdf" => header.AsSpan().StartsWith(PdfMagicBytes),
".docx" => header.AsSpan().StartsWith(DocxMagicBytes),
".json" => header[0] == '{' || header[0] == '[',
_ => false
};
}
private bool IsValidFileType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension is ".pdf" or ".docx" or ".json";
}
private static string FormatFileSize(long bytes)
{
string[] sizes = ["B", "KB", "MB", "GB"];
int order = 0;
double size = bytes;
while (size >= 1024 && order < sizes.Length - 1)
{
order++;
size /= 1024;
}
return $"{size:0.##} {sizes[order]}";
}
private static string GetFileTypeClass(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return ext switch
{
".pdf" => "pdf",
".docx" => "docx",
".json" => "json",
_ => "docx"
};
}
}

View File

@@ -0,0 +1,804 @@
@page "/dashboard"
@attribute [Authorize]
@rendermode InteractiveServer
@implements IDisposable
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Dashboard> Logger
@inject IJSRuntime JSRuntime
@inject RealCV.Web.Services.IPdfReportService PdfReportService
@inject IAuditService AuditService
<PageTitle>Dashboard - RealCV</PageTitle>
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="fw-bold mb-1">Dashboard</h1>
<p class="text-muted mb-0">View and manage your CV verification checks</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="ExportToPdf" disabled="@(_isExporting || !HasCompletedChecks())">
@if (_isExporting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-pdf me-1" viewBox="0 0 16 16">
<path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H4zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>
<path d="M4.603 12.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.68 7.68 0 0 1 1.482-.645 19.701 19.701 0 0 0 1.062-2.227 7.269 7.269 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.187-.012.395-.047.614-.084.51-.27 1.134-.52 1.794a10.954 10.954 0 0 0 .98 1.686 5.753 5.753 0 0 1 1.334.05c.364.065.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.856.856 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.716 5.716 0 0 1-.911-.95 11.642 11.642 0 0 0-1.997.406 11.311 11.311 0 0 1-1.021 1.51c-.29.35-.608.655-.926.787a.793.793 0 0 1-.58.029zm1.379-1.901c-.166.076-.32.156-.459.238-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361.01.022.02.036.026.044a.27.27 0 0 0 .035-.012c.137-.056.355-.235.635-.572a8.18 8.18 0 0 0 .45-.606zm1.64-1.33a12.647 12.647 0 0 1 1.01-.193 11.666 11.666 0 0 1-.51-.858 20.741 20.741 0 0 1-.5 1.05zm2.446.45c.15.162.296.3.435.41.24.19.407.253.498.256a.107.107 0 0 0 .07-.015.307.307 0 0 0 .094-.125.436.436 0 0 0 .059-.2.095.095 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a3.881 3.881 0 0 0-.612-.053zM8.078 5.8a6.7 6.7 0 0 0 .2-.828c.031-.188.043-.343.038-.465a.613.613 0 0 0-.032-.198.517.517 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822.024.111.054.227.09.346z"/>
</svg>
}
Export PDF
</button>
<a href="/check" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
</svg>
New Check
</a>
</div>
</div>
@if (_isLoading)
{
<div class="text-center py-5">
<div class="placeholder-glow mb-4">
<div class="row g-4 mb-4">
<div class="col-md-4"><div class="placeholder col-12 rounded-4" style="height: 100px;"></div></div>
<div class="col-md-4"><div class="placeholder col-12 rounded-4" style="height: 100px;"></div></div>
<div class="col-md-4"><div class="placeholder col-12 rounded-4" style="height: 100px;"></div></div>
</div>
<div class="placeholder col-12 rounded-4" style="height: 300px;"></div>
</div>
<div class="spinner-border text-primary" role="status" style="width: 2.5rem; height: 2.5rem;">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted fw-medium">Loading your checks...</p>
</div>
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger" role="alert">
@_errorMessage
</div>
}
else if (_checks.Count == 0)
{
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5 px-4">
<div class="empty-state-icon mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-file-earmark-plus" viewBox="0 0 16 16">
<path d="M8 6.5a.5.5 0 0 1 .5.5v1.5H10a.5.5 0 0 1 0 1H8.5V11a.5.5 0 0 1-1 0V9.5H6a.5.5 0 0 1 0-1h1.5V7a.5.5 0 0 1 .5-.5z"/>
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
</svg>
</div>
<h4 class="fw-bold mb-2">No CV Checks Yet</h4>
<p class="text-muted mb-4 mx-auto" style="max-width: 400px;">
Upload your first CV to begin verifying employment history against official company records.
</p>
<a href="/check" class="btn btn-primary btn-lg">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-upload me-2" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg>
Upload Your First CV
</a>
</div>
</div>
}
else
{
<!-- Stats Cards -->
<div class="row mb-4 g-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm stat-card h-100">
<div class="card-body p-4">
<div class="d-flex align-items-center">
<div class="stat-icon stat-icon-primary me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0l3-3z"/>
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
</svg>
</div>
<div>
<div class="stat-value">@_checks.Count</div>
<div class="stat-label">Total Checks</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm stat-card h-100">
<div class="card-body p-4">
<div class="d-flex align-items-center">
<div class="stat-icon stat-icon-success me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>
</div>
<div>
<div class="stat-value">@_checks.Count(c => c.Status == "Completed")</div>
<div class="stat-label">Completed</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm stat-card h-100">
<div class="card-body p-4">
<div class="d-flex align-items-center">
<div class="stat-icon stat-icon-warning me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.5 15a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11zm2-13v1c0 .537.12 1.045.337 1.5h6.326c.216-.455.337-.963.337-1.5V2h-7zm3 6.35c0 .701-.478 1.236-1.011 1.492A3.5 3.5 0 0 0 4.5 13s.866-1.299 3-1.48V8.35zm1 0v3.17c2.134.181 3 1.48 3 1.48a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351z"/>
</svg>
</div>
<div>
<div class="stat-value">@_checks.Count(c => c.Status is "Pending" or "Processing")</div>
<div class="stat-label">In Progress</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Checks List -->
<div class="card border-0 shadow-sm">
<div class="card-header py-3 border-bottom" style="background-color: var(--truecv-bg-surface);">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<h5 class="mb-0 fw-bold">Recent CV Checks</h5>
@if (_selectedIds.Count > 0)
{
<span class="badge bg-primary">@_selectedIds.Count selected</span>
}
</div>
<div class="d-flex align-items-center gap-2">
@if (_selectedIds.Count > 0)
{
<button class="btn btn-sm btn-outline-danger" @onclick="ConfirmDeleteSelected">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash3 me-1" viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
</svg>
Delete Selected
</button>
}
<span class="badge bg-light text-muted">@_checks.Count total</span>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr style="background-color: var(--truecv-bg-muted);">
<th class="border-0 ps-3 py-3" style="width: 40px;">
<input type="checkbox" class="form-check-input"
checked="@IsAllSelected()"
@onchange="ToggleSelectAll"
title="Select all" />
</th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Candidate</th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Uploaded</th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Status</th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Score</th>
<th class="border-0 py-3 pe-4 text-uppercase small fw-semibold text-muted text-end" style="letter-spacing: 0.05em;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var check in _checks)
{
<tr class="@(check.Status == "Completed" ? "cursor-pointer" : "") @(_selectedIds.Contains(check.Id) ? "table-active" : "")"
@onclick="() => ViewReport(check)">
<td class="ps-3 py-3" @onclick:stopPropagation="true">
<input type="checkbox" class="form-check-input"
checked="@_selectedIds.Contains(check.Id)"
@onchange="() => ToggleSelection(check.Id)" />
</td>
<td class="py-3">
<div class="d-flex align-items-center">
<div class="file-icon-wrapper me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-person text-primary" viewBox="0 0 16 16">
<path d="M11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2v9.255S12 12 8 12s-5 1.755-5 1.755V2a1 1 0 0 1 1-1h5.5v2z"/>
</svg>
</div>
<div>
<p class="mb-0 fw-semibold text-dark">@Path.GetFileNameWithoutExtension(check.OriginalFileName)</p>
<small class="text-muted">@Path.GetExtension(check.OriginalFileName).ToUpperInvariant()</small>
</div>
</div>
</td>
<td class="py-3">
<div>
<p class="mb-0 small">@check.CreatedAt.ToString("dd MMM yyyy")</p>
<small class="text-muted">@check.CreatedAt.ToString("HH:mm")</small>
</div>
</td>
<td class="py-3 text-center">
@switch (check.Status)
{
case "Completed":
<span class="badge rounded-pill bg-success-subtle text-success px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
Completed
</span>
break;
case "Processing":
<span class="badge rounded-pill bg-primary-subtle text-primary px-3 py-2">
<span class="spinner-border spinner-border-sm me-1" role="status" style="width: 0.75rem; height: 0.75rem;"></span>
@(check.ProcessingStage ?? "Processing")
</span>
break;
case "Pending":
<span class="badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-clock me-1" viewBox="0 0 16 16">
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
</svg>
Queued
</span>
break;
case "Failed":
<span class="badge rounded-pill bg-danger-subtle text-danger px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-circle-fill me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
Failed
</span>
break;
default:
<span class="badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2">@check.Status</span>
break;
}
</td>
<td class="py-3 text-center">
@if (check.VeracityScore.HasValue)
{
<div class="score-ring-container" title="Veracity Score: @check.VeracityScore%">
<svg class="score-ring" viewBox="0 0 40 40">
<circle class="score-ring-bg" cx="20" cy="20" r="15.9"/>
<circle class="score-ring-progress @GetScoreRingClass(check.VeracityScore.Value)"
cx="20" cy="20" r="15.9"
stroke-dasharray="@GetScoreDashArray(check.VeracityScore.Value), 100"/>
</svg>
<span class="score-ring-value @GetScoreTextClass(check.VeracityScore.Value)">@check.VeracityScore</span>
</div>
}
else
{
<span class="text-muted">--</span>
}
</td>
<td class="py-3 pe-4 text-end">
<div class="d-flex justify-content-end align-items-center gap-2">
@if (check.Status == "Completed")
{
<a href="/report/@check.Id" class="btn btn-sm btn-primary" @onclick:stopPropagation="true">
View Report
</a>
}
else if (check.Status is "Pending" or "Processing")
{
<button class="btn btn-sm btn-outline-secondary" disabled>
Processing...
</button>
}
else
{
<a href="/check" class="btn btn-sm btn-outline-warning" @onclick:stopPropagation="true">
Retry
</a>
}
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(check)" @onclick:stopPropagation="true" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
</svg>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
<!-- Delete Confirmation Modal -->
@if (_showDeleteModal)
{
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0">
<h5 class="modal-title fw-bold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-exclamation-triangle text-danger me-2" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
Delete @(_isBulkDelete ? $"{_checksToDelete.Count} CV Checks" : "CV Check")
</h5>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
@if (_isBulkDelete)
{
<p class="mb-2">Are you sure you want to delete <strong>@_checksToDelete.Count</strong> CV checks?</p>
<div class="bg-light rounded p-3" style="max-height: 200px; overflow-y: auto;">
@foreach (var check in _checksToDelete)
{
<div class="d-flex justify-content-between align-items-center py-1 @(check != _checksToDelete.Last() ? "border-bottom" : "")">
<span>@Path.GetFileNameWithoutExtension(check.OriginalFileName)</span>
<small class="text-muted">@check.CreatedAt.ToString("dd MMM yyyy")</small>
</div>
}
</div>
}
else if (_checkToDelete != null)
{
<p class="mb-2">Are you sure you want to delete this CV check?</p>
<div class="bg-light rounded p-3">
<strong>@Path.GetFileNameWithoutExtension(_checkToDelete.OriginalFileName)</strong>
<br />
<small class="text-muted">Uploaded @_checkToDelete.CreatedAt.ToString("dd MMM yyyy 'at' HH:mm")</small>
</div>
}
<p class="text-danger small mt-3 mb-0">
<strong>This action cannot be undone.</strong> @(_isBulkDelete ? "All selected CVs and their" : "The CV and all") verification data will be permanently removed.
</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelDelete">Cancel</button>
<button type="button" class="btn btn-danger" @onclick="ExecuteDelete" disabled="@_isDeleting">
@if (_isDeleting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
<span>Deleting...</span>
}
else
{
<span>Delete @(_isBulkDelete ? $"{_checksToDelete.Count} Checks" : "")</span>
}
</button>
</div>
</div>
</div>
</div>
}
<style>
.cursor-pointer {
cursor: pointer;
user-select: none;
}
.cursor-pointer:hover {
background-color: rgba(59, 111, 212, 0.04);
}
.empty-state-icon {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #e8f1fa 0%, #d4e4f4 100%);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
color: var(--truecv-primary);
}
.file-icon-wrapper {
width: 44px;
height: 44px;
background: linear-gradient(135deg, #e8f1fa 0%, #d4e4f4 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.score-ring-container {
position: relative;
width: 52px;
height: 52px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.score-ring {
width: 100%;
height: 100%;
}
.score-ring-bg {
fill: none;
stroke: var(--truecv-gray-200);
stroke-width: 3;
}
.score-ring-progress {
fill: none;
stroke-width: 3;
stroke-linecap: round;
transform-origin: center;
transform: rotate(-90deg);
}
.score-ring-progress.high { stroke: var(--truecv-verified); }
.score-ring-progress.medium { stroke: var(--truecv-warning); }
.score-ring-progress.low { stroke: var(--truecv-danger); }
.score-ring-value {
position: absolute;
font-size: 0.875rem;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.text-verified { color: var(--truecv-verified); }
.text-warning-dark { color: var(--truecv-warning-dark); }
.text-danger { color: var(--truecv-danger); }
@@media (max-width: 768px) {
.d-flex.justify-content-between.align-items-center.mb-4 {
flex-direction: column;
align-items: stretch !important;
gap: 1rem;
}
.d-flex.gap-2 {
width: 100%;
justify-content: stretch;
}
.d-flex.gap-2 .btn {
flex: 1;
}
.row.mb-4 .col-md-4 {
margin-bottom: 0.75rem;
}
.score-badge {
width: 40px;
height: 40px;
font-size: 0.875rem;
}
}
</style>
@code {
private List<CVCheckDto> _checks = [];
private bool _isLoading = true;
private bool _isExporting;
private bool _isDeleting;
private string? _errorMessage;
private Guid _userId;
private System.Threading.Timer? _pollingTimer;
private volatile bool _isPolling;
private volatile bool _disposed;
private volatile bool _isOperationInProgress;
// Delete confirmation modal state
private bool _showDeleteModal;
private CVCheckDto? _checkToDelete;
private List<CVCheckDto> _checksToDelete = [];
private bool _isBulkDelete;
// Selection state
private HashSet<Guid> _selectedIds = [];
protected override async Task OnInitializedAsync()
{
await LoadChecks();
StartPollingIfNeeded();
}
private void StartPollingIfNeeded()
{
if (HasProcessingChecks() && !_isPolling && !_disposed)
{
_isPolling = true;
_pollingTimer = new System.Threading.Timer(async _ =>
{
if (_disposed) return;
try
{
await InvokeAsync(async () =>
{
if (_disposed) return;
await LoadChecks();
if (!HasProcessingChecks())
{
StopPolling();
}
StateHasChanged();
});
}
catch (ObjectDisposedException)
{
// Component was disposed, ignore
}
}, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
}
}
private bool HasProcessingChecks()
{
foreach (var c in _checks)
{
if (c.Status == "Processing" || c.Status == "Pending") return true;
}
return false;
}
private void StopPolling()
{
_isPolling = false;
_pollingTimer?.Dispose();
_pollingTimer = null;
}
public void Dispose()
{
_disposed = true;
StopPolling();
}
private async Task LoadChecks()
{
if (_isOperationInProgress) return;
_isOperationInProgress = true;
_isLoading = true;
_errorMessage = null;
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out _userId))
{
_errorMessage = "Unable to identify user. Please log in again.";
return;
}
_checks = await CVCheckService.GetUserChecksAsync(_userId) ?? [];
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading CV checks");
_errorMessage = "An error occurred while loading checks. Please try again.";
}
finally
{
_isLoading = false;
_isOperationInProgress = false;
}
}
private void ViewReport(CVCheckDto check)
{
if (check.Status == "Completed")
{
NavigationManager.NavigateTo($"/report/{check.Id}");
}
}
private static string GetScoreBadgeClass(int score)
{
return score switch
{
> 70 => "bg-success",
>= 50 => "bg-warning text-dark",
_ => "bg-danger"
};
}
private static string GetScoreBadgeColorClass(int score)
{
return score switch
{
> 70 => "score-high",
>= 50 => "score-medium",
_ => "score-low"
};
}
private static string GetScoreRingClass(int score) => score > 70 ? "high" : score >= 50 ? "medium" : "low";
private static string GetScoreTextClass(int score) => score > 70 ? "text-verified" : score >= 50 ? "text-warning-dark" : "text-danger";
private static string GetScoreDashArray(int score) => score.ToString();
private async Task ExportToPdf()
{
if (_isExporting) return;
_isExporting = true;
StateHasChanged();
try
{
var reportDataList = new List<RealCV.Web.Services.PdfReportData>();
foreach (var check in _checks)
{
if (check.Status != "Completed") continue;
var report = await CVCheckService.GetReportAsync(check.Id, _userId);
if (report is null) continue;
int verifiedCount = 0;
int unverifiedCount = 0;
foreach (var v in report.EmploymentVerifications)
{
if (v.IsVerified) verifiedCount++;
else unverifiedCount++;
}
int criticalFlags = 0;
int warningFlags = 0;
foreach (var f in report.Flags)
{
if (f.Severity == "Critical") criticalFlags++;
else if (f.Severity == "Warning") warningFlags++;
}
reportDataList.Add(new RealCV.Web.Services.PdfReportData
{
CandidateName = Path.GetFileNameWithoutExtension(check.OriginalFileName) ?? "Unknown",
UploadDate = check.CreatedAt,
Score = report.OverallScore,
ScoreLabel = report.ScoreLabel,
VerifiedEmployers = verifiedCount,
UnverifiedEmployers = unverifiedCount,
GapMonths = report.TimelineAnalysis.TotalGapMonths,
OverlapMonths = report.TimelineAnalysis.TotalOverlapMonths,
CriticalFlags = criticalFlags,
WarningFlags = warningFlags
});
}
var pdfBytes = PdfReportService.GenerateReport(reportDataList);
var base64 = Convert.ToBase64String(pdfBytes);
var fileName = "RealCV_Report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".pdf";
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64, "application/pdf");
await AuditService.LogAsync(_userId, AuditActions.ReportExported, null, null, $"Exported {reportDataList.Count} reports to PDF");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error exporting PDF");
}
finally
{
_isExporting = false;
StateHasChanged();
}
}
private bool HasCompletedChecks()
{
foreach (var c in _checks)
{
if (c.Status == "Completed") return true;
}
return false;
}
// Selection methods
private void ToggleSelection(Guid id)
{
if (_selectedIds.Contains(id))
_selectedIds.Remove(id);
else
_selectedIds.Add(id);
}
private void ToggleSelectAll()
{
if (IsAllSelected())
_selectedIds.Clear();
else
_selectedIds = _checks.Select(c => c.Id).ToHashSet();
}
private bool IsAllSelected() => _checks.Count > 0 && _selectedIds.Count == _checks.Count;
// Single delete
private void ConfirmDelete(CVCheckDto check)
{
_checkToDelete = check;
_checksToDelete = [];
_isBulkDelete = false;
_showDeleteModal = true;
}
// Bulk delete
private void ConfirmDeleteSelected()
{
_checksToDelete = _checks.Where(c => _selectedIds.Contains(c.Id)).ToList();
_checkToDelete = null;
_isBulkDelete = true;
_showDeleteModal = true;
}
private void CancelDelete()
{
_showDeleteModal = false;
_checkToDelete = null;
_checksToDelete = [];
_isBulkDelete = false;
}
private async Task ExecuteDelete()
{
if (_isDeleting) return;
_isDeleting = true;
try
{
if (_isBulkDelete)
{
var failedCount = 0;
foreach (var check in _checksToDelete)
{
var success = await CVCheckService.DeleteCheckAsync(check.Id, _userId);
if (success)
{
_checks.RemoveAll(c => c.Id == check.Id);
_selectedIds.Remove(check.Id);
}
else
{
failedCount++;
}
}
if (failedCount > 0)
{
_errorMessage = $"Failed to delete {failedCount} CV check(s). Please try again.";
}
}
else if (_checkToDelete != null)
{
var success = await CVCheckService.DeleteCheckAsync(_checkToDelete.Id, _userId);
if (success)
{
_checks.RemoveAll(c => c.Id == _checkToDelete.Id);
_selectedIds.Remove(_checkToDelete.Id);
}
else
{
_errorMessage = "Failed to delete the CV check. Please try again.";
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error deleting CV check(s)");
_errorMessage = "Failed to delete CV check(s). Please try again.";
}
finally
{
_isDeleting = false;
_showDeleteModal = false;
_checkToDelete = null;
_checksToDelete = [];
_isBulkDelete = false;
}
}
}

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,226 @@
@page "/"
<PageTitle>RealCV - Verify CVs with Confidence</PageTitle>
<!-- Hero Section with Gradient -->
<section class="hero-section text-white py-5">
<div class="container hero-content">
<div class="row align-items-center py-5">
<div class="col-lg-7">
<h1 class="display-3 fw-bold mb-4" style="letter-spacing: -0.03em; color: #FFFFFF; text-shadow: 0 2px 10px rgba(0,0,0,0.8), 0 0 40px rgba(255,255,255,0.3);">
Verify CVs with<br />
<span style="color: #60A5FA; text-shadow: 0 2px 8px rgba(0,0,0,0.4);">Confidence</span>
</h1>
<p class="lead mb-3 opacity-90" style="font-size: 1.25rem; line-height: 1.7;">
RealCV uses AI-powered analysis and official company records to verify employment history,
detect timeline inconsistencies, and flag potential issues in candidate CVs.
</p>
<p class="mb-4 d-inline-flex align-items-center px-3 py-2 rounded-pill" style="font-size: 0.85rem; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25);">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/>
</svg>
For UK employment history
</p>
<div class="d-flex gap-3 flex-wrap">
<a href="/check" class="btn btn-lg px-4 py-3" style="background-color: #2563EB; color: white; box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4);">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-shield-check me-2" viewBox="0 0 16 16">
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
</svg>
Start Verification
</a>
<a href="#features" class="btn btn-lg px-4 py-3" style="background: transparent; border: 2px solid rgba(255,255,255,0.3); color: white;">
Learn More
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-down ms-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/>
</svg>
</a>
</div>
</div>
<div class="col-lg-5 text-center mt-5 mt-lg-0">
<div class="position-relative">
<!-- CV Verification Illustration -->
<svg width="280" height="280" viewBox="0 0 280 280" fill="none" xmlns="http://www.w3.org/2000/svg" style="filter: drop-shadow(0 20px 40px rgba(0,0,0,0.3));">
<!-- Document background -->
<rect x="60" y="30" width="160" height="200" rx="8" fill="white" fill-opacity="0.95"/>
<!-- Document lines -->
<rect x="85" y="60" width="80" height="8" rx="4" fill="#CBD5E1"/>
<rect x="85" y="80" width="110" height="6" rx="3" fill="#E2E8F0"/>
<rect x="85" y="95" width="100" height="6" rx="3" fill="#E2E8F0"/>
<rect x="85" y="110" width="90" height="6" rx="3" fill="#E2E8F0"/>
<rect x="85" y="135" width="70" height="8" rx="4" fill="#CBD5E1"/>
<rect x="85" y="155" width="110" height="6" rx="3" fill="#E2E8F0"/>
<rect x="85" y="170" width="95" height="6" rx="3" fill="#E2E8F0"/>
<rect x="85" y="185" width="105" height="6" rx="3" fill="#E2E8F0"/>
<!-- Checkmark circle -->
<circle cx="200" cy="190" r="45" fill="#22C55E"/>
<path d="M180 190L193 203L220 176" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-5" style="background-color: var(--truecv-bg-page);">
<div class="container">
<div class="text-center mb-5">
<h2 class="fw-bold mb-3" style="font-size: 2.25rem;">How RealCV Works</h2>
<p class="text-muted" style="font-size: 1.125rem;">Comprehensive CV verification in three key areas</p>
</div>
<div class="row g-4">
<!-- Employment Verification -->
<div class="col-md-4">
<div class="card h-100 feature-card">
<div class="card-body text-center p-4">
<div class="feature-icon mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm1.679-4.493-1.335 2.226a.75.75 0 0 1-1.174.144l-.774-.773a.5.5 0 0 1 .708-.708l.547.548 1.17-1.951a.5.5 0 1 1 .858.514Z"/>
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6.5a.5.5 0 0 1-1 0V1H3v14h3v-2.5a.5.5 0 0 1 .5-.5H8v4H3a1 1 0 0 1-1-1V1Z"/>
<path d="M4.5 2a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm-6 3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm-6 3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z"/>
</svg>
</div>
<h4 class="card-title fw-bold mb-3">Employment Verification</h4>
<p class="card-text text-muted mb-0">
Cross-reference claimed employers with official records to verify
company existence and match accuracy.
</p>
</div>
</div>
</div>
<!-- Timeline Analysis -->
<div class="col-md-4">
<div class="card h-100 feature-card">
<div class="card-body text-center p-4">
<div class="feature-icon mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M9 7a1 1 0 0 1 1-1h5v2h-5a1 1 0 0 1-1-1zM1 9h4a1 1 0 0 1 0 2H1V9z"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg>
</div>
<h4 class="card-title fw-bold mb-3">Timeline Analysis</h4>
<p class="card-text text-muted mb-0">
Detect unexplained employment gaps and overlapping job periods that may indicate
inconsistencies in the candidate's work history.
</p>
</div>
</div>
</div>
<!-- AI-Powered Parsing -->
<div class="col-md-4">
<div class="card h-100 feature-card">
<div class="card-body text-center p-4">
<div class="feature-icon mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5A.5.5 0 0 1 5 0zm-.5 3A1.5 1.5 0 0 0 3 4.5v7A1.5 1.5 0 0 0 4.5 13h7a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 11.5 3h-7zM5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5v-3zM6.5 6a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z"/>
</svg>
</div>
<h4 class="card-title fw-bold mb-3">AI-Powered Parsing</h4>
<p class="card-text text-muted mb-0">
Advanced AI extracts and structures CV data from PDF and DOCX files, ensuring
accurate information capture for analysis.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- How It Works Section -->
<section class="py-5" style="background-color: white;">
<div class="container">
<div class="text-center mb-5">
<h2 class="fw-bold mb-3" style="font-size: 2.25rem;">Get Started in Minutes</h2>
<p class="text-muted" style="font-size: 1.125rem;">Simple three-step verification process</p>
</div>
<div class="row g-4 justify-content-center">
<div class="col-md-4">
<div class="text-center">
<div class="d-inline-flex align-items-center justify-content-center mb-4" style="width: 72px; height: 72px; border-radius: 16px; background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: white; box-shadow: 0 8px 20px rgba(37, 99, 235, 0.25);">
<span class="fw-bold" style="font-size: 1.75rem; font-family: 'JetBrains Mono', monospace;">1</span>
</div>
<h5 class="fw-bold mb-2">Upload CV</h5>
<p class="text-muted">Upload the candidate's CV in PDF or DOCX format</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="d-inline-flex align-items-center justify-content-center mb-4" style="width: 72px; height: 72px; border-radius: 16px; background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: white; box-shadow: 0 8px 20px rgba(37, 99, 235, 0.25);">
<span class="fw-bold" style="font-size: 1.75rem; font-family: 'JetBrains Mono', monospace;">2</span>
</div>
<h5 class="fw-bold mb-2">AI Analysis</h5>
<p class="text-muted">Our AI parses the CV and verifies against official records</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="d-inline-flex align-items-center justify-content-center mb-4" style="width: 72px; height: 72px; border-radius: 16px; background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); color: white; box-shadow: 0 8px 20px rgba(37, 99, 235, 0.25);">
<span class="fw-bold" style="font-size: 1.75rem; font-family: 'JetBrains Mono', monospace;">3</span>
</div>
<h5 class="fw-bold mb-2">Get Report</h5>
<p class="text-muted">Receive a detailed veracity report with actionable insights</p>
</div>
</div>
</div>
<div class="text-center mt-5">
<a href="/check" class="btn btn-primary btn-lg px-5 py-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-rocket-takeoff me-2" viewBox="0 0 16 16">
<path d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532Z"/>
<path d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z"/>
<path d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 4.189-.736 5.054-1.901Z"/>
</svg>
Start Your First Check
</a>
</div>
</div>
</section>
<!-- Trust indicators -->
<section class="py-4" style="background-color: var(--truecv-bg-muted); border-top: 1px solid var(--truecv-gray-200);">
<div class="container">
<div class="row align-items-center justify-content-center text-center g-4">
<div class="col-6 col-md-3">
<div class="d-flex align-items-center justify-content-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
</svg>
<span class="fw-medium text-muted small">Secure & Encrypted</span>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center justify-content-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z"/>
</svg>
<span class="fw-medium text-muted small">Official Records</span>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center justify-content-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M9.05.435c-.58-.58-1.52-.58-2.1 0L.436 6.95c-.58.58-.58 1.519 0 2.098l6.516 6.516c.58.58 1.519.58 2.098 0l6.516-6.516c.58-.58.58-1.519 0-2.098L9.05.435zM5.495 6a.5.5 0 0 1 .5-.5h5.01a.5.5 0 0 1 0 1H5.995a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h4.01a.5.5 0 0 1 0 1h-4.01a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h3.01a.5.5 0 0 1 0 1h-3.01a.5.5 0 0 1-.5-.5z"/>
</svg>
<span class="fw-medium text-muted small">AI-Powered</span>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center justify-content-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
</svg>
<span class="fw-medium text-muted small">Fast Results</span>
</div>
</div>
</div>
</div>
</section>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true)
{
<RedirectToLogin />
}
else
{
<div class="container py-5">
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">Access Denied</h4>
<p>You are not authorized to access this page.</p>
</div>
</div>
}
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>

View File

@@ -0,0 +1,9 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(NavigationManager.Uri);
NavigationManager.NavigateTo($"/account/login?returnUrl={returnUrl}", forceLoad: true);
}
}

View File

@@ -0,0 +1,21 @@
@using System.IO
@using System.Net.Http
@using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.Extensions.Logging
@using Microsoft.JSInterop
@using RealCV.Web
@using RealCV.Web.Components
@using RealCV.Web.Components.Shared
@using RealCV.Web.Services
@using RealCV.Application.Interfaces
@using RealCV.Application.DTOs
@using RealCV.Application.Models
@using RealCV.Domain.Enums

View File

@@ -0,0 +1,12 @@
using Hangfire.Dashboard;
namespace RealCV.Web;
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
return httpContext.User.Identity?.IsAuthenticated ?? false;
}
}

230
src/RealCV.Web/Program.cs Normal file
View File

@@ -0,0 +1,230 @@
using System.Threading.RateLimiting;
using Hangfire;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Serilog;
using RealCV.Infrastructure;
using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.Identity;
using RealCV.Web;
using RealCV.Web.Components;
using RealCV.Web.Services;
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting RealCV web application");
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog from appsettings
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
builder.Services.AddInfrastructure(builder.Configuration);
// Add Web services
builder.Services.AddScoped<IPdfReportService, PdfReportService>();
// Add Identity with secure password requirements
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 12;
options.Password.RequiredUniqueChars = 4;
options.SignIn.RequireConfirmedAccount = false;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/account/login";
options.LogoutPath = "/account/logout";
options.AccessDeniedPath = "/account/login";
});
// Add Cascading Authentication State
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<ApplicationUser>>();
// Add health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>("database");
// Add rate limiting for login endpoint
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("login", limiterOptions =>
{
limiterOptions.PermitLimit = 5;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0;
});
});
var app = builder.Build();
// Seed default admin user and clear company cache
using (var scope = app.Services.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var defaultEmail = builder.Configuration["DefaultAdmin:Email"];
var defaultPassword = builder.Configuration["DefaultAdmin:Password"];
if (!string.IsNullOrEmpty(defaultEmail) && !string.IsNullOrEmpty(defaultPassword))
{
if (await userManager.FindByEmailAsync(defaultEmail) == null)
{
var adminUser = new ApplicationUser
{
UserName = defaultEmail,
Email = defaultEmail,
EmailConfirmed = true
};
var result = await userManager.CreateAsync(adminUser, defaultPassword);
if (result.Succeeded)
{
Log.Information("Created default admin user: {Email}", defaultEmail);
}
else
{
Log.Warning("Failed to create admin user: {Errors}", string.Join(", ", result.Errors.Select(e => e.Description)));
}
}
}
else
{
Log.Information("No default admin credentials configured - skipping admin user seeding");
}
// Clear company cache on startup to ensure fresh API lookups
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var cacheCount = await dbContext.CompanyCache.CountAsync();
if (cacheCount > 0)
{
dbContext.CompanyCache.RemoveRange(dbContext.CompanyCache);
await dbContext.SaveChangesAsync();
Log.Information("Cleared {Count} entries from company cache", cacheCount);
}
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
// Only use HTTPS redirection when not running in Docker/container
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")))
{
// Running in container - skip HTTPS redirect (handled by reverse proxy)
}
else
{
app.UseHttpsRedirection();
}
app.UseStaticFiles();
app.UseAntiforgery();
// Add Serilog request logging
app.UseSerilogRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
// Add Hangfire Dashboard (only in development, requires authentication)
if (app.Environment.IsDevelopment())
{
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = [new HangfireAuthorizationFilter()]
});
}
// Login endpoint
app.MapPost("/account/perform-login", async (
HttpContext context,
SignInManager<ApplicationUser> signInManager) =>
{
var form = await context.Request.ReadFormAsync();
var email = form["email"].ToString();
var password = form["password"].ToString();
var rememberMe = form["rememberMe"].ToString() == "true";
var returnUrl = form["returnUrl"].ToString();
Log.Information("Login attempt for {Email}", email);
// Validate returnUrl is local to prevent open redirect attacks
if (string.IsNullOrEmpty(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) || returnUrl.StartsWith("//"))
{
returnUrl = "/dashboard";
}
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
Log.Information("User {Email} logged in successfully", email);
return Results.LocalRedirect(returnUrl);
}
else if (result.IsLockedOut)
{
Log.Warning("User {Email} account is locked out", email);
return Results.Redirect("/account/login?error=Account+locked.+Try+again+later.");
}
else
{
Log.Warning("Failed login attempt for {Email}", email);
return Results.Redirect("/account/login?error=Invalid+email+or+password.");
}
}).RequireRateLimiting("login");
// Logout endpoint
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
{
await signInManager.SignOutAsync();
return Results.Redirect("/");
}).RequireAuthorization();
// Health check endpoint
app.MapHealthChecks("/health");
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:9979",
"sslPort": 44360
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5114",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7176;http://localhost:5114",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\RealCV.Infrastructure\RealCV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.*" />
<PackageReference Include="QuestPDF" Version="2025.12.3" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,52 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace RealCV.Web;
public class RevalidatingIdentityAuthenticationStateProvider<TUser>
: RevalidatingServerAuthenticationStateProvider where TUser : class
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IdentityOptions _options;
public RevalidatingIdentityAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory,
IOptions<IdentityOptions> optionsAccessor)
: base(loggerFactory)
{
_scopeFactory = scopeFactory;
_options = optionsAccessor.Value;
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}
private async Task<bool> ValidateSecurityStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return false;
}
if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}

View File

@@ -0,0 +1,9 @@
using RealCV.Application.Models;
namespace RealCV.Web.Services;
public interface IPdfReportService
{
byte[] GenerateSingleReport(string candidateName, VeracityReport report);
byte[] GenerateReport(List<PdfReportData> data);
}

View File

@@ -0,0 +1,334 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using RealCV.Application.Helpers;
using RealCV.Application.Models;
namespace RealCV.Web.Services;
public class PdfReportService : IPdfReportService
{
/// <summary>
/// Generates a detailed PDF report for a single CV verification.
/// </summary>
public byte[] GenerateSingleReport(string candidateName, VeracityReport report)
{
QuestPDF.Settings.License = LicenseType.Community;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.DefaultTextStyle(x => x.FontSize(10));
page.Header().Element(c => ComposeSingleReportHeader(c, candidateName, report));
page.Content().Element(c => ComposeSingleReportContent(c, report));
page.Footer().Element(ComposeFooter);
});
});
return document.GeneratePdf();
}
private void ComposeSingleReportHeader(IContainer container, string candidateName, VeracityReport report)
{
var scoreColor = report.OverallScore > ScoreThresholds.High ? Colors.Green.Darken1 :
(report.OverallScore >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1);
container.Column(column =>
{
column.Item().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("RealCV").Bold().FontSize(24).FontColor(Colors.Blue.Darken2);
col.Item().Text("CV Verification Report").FontSize(14).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(100).AlignRight().Column(col =>
{
col.Item().AlignRight().Text(report.GeneratedAt.ToString("dd MMM yyyy")).FontSize(10).FontColor(Colors.Grey.Medium);
col.Item().AlignRight().Text(report.GeneratedAt.ToString("HH:mm")).FontSize(9).FontColor(Colors.Grey.Medium);
});
});
column.Item().PaddingTop(15).Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("Candidate: " + candidateName).Bold().FontSize(12);
});
row.ConstantItem(120).Border(2).BorderColor(scoreColor).Padding(10).AlignCenter().Column(col =>
{
col.Item().AlignCenter().Text(report.OverallScore.ToString()).Bold().FontSize(28).FontColor(scoreColor);
col.Item().AlignCenter().Text("RealCV Score").FontSize(10).FontColor(scoreColor);
});
});
column.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
}
private void ComposeSingleReportContent(IContainer container, VeracityReport report)
{
container.PaddingVertical(15).Column(column =>
{
// Employment Verification Section
column.Item().Text("Employment Verification").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
column.Item().PaddingTop(10).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3); // Claimed
columns.RelativeColumn(2); // Period
columns.RelativeColumn(3); // Matched
columns.ConstantColumn(45); // Score
columns.ConstantColumn(55); // Status
});
table.Header(header =>
{
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Claimed Employer").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Period").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Matched Company").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Match").Bold().FontSize(9).FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Status").Bold().FontSize(9).FontColor(Colors.White);
});
bool alternate = false;
foreach (var emp in report.EmploymentVerifications)
{
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
var period = emp.ClaimedStartDate.HasValue
? $"{emp.ClaimedStartDate.Value:MMM yyyy} - {(emp.ClaimedEndDate.HasValue ? emp.ClaimedEndDate.Value.ToString("MMM yyyy") : "Present")}"
: "Not specified";
table.Cell().Background(bgColor).Padding(4).Text(emp.ClaimedCompany).FontSize(9);
table.Cell().Background(bgColor).Padding(4).Text(period).FontSize(8);
table.Cell().Background(bgColor).Padding(4).Text(emp.MatchedCompanyName ?? "No match").FontSize(9).FontColor(emp.IsVerified ? Colors.Black : Colors.Grey.Medium);
table.Cell().Background(bgColor).Padding(4).AlignCenter().Text(emp.MatchScore + "%").FontSize(9);
table.Cell().Background(bgColor).Padding(4).AlignCenter().Text(emp.IsVerified ? "Verified" : "Unverified").FontSize(8)
.FontColor(emp.IsVerified ? Colors.Green.Darken1 : Colors.Orange.Darken1);
alternate = !alternate;
}
});
// Timeline Section
column.Item().PaddingTop(20).Text("Timeline Analysis").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
column.Item().PaddingTop(10).Row(row =>
{
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(col =>
{
col.Item().Text("Employment Gaps").Bold().FontSize(10);
if (report.TimelineAnalysis.Gaps.Count > 0)
{
foreach (var gap in report.TimelineAnalysis.Gaps)
{
col.Item().PaddingTop(5).Text($"{gap.StartDate:MMM yyyy} - {gap.EndDate:MMM yyyy} ({gap.Months} months)").FontSize(9);
}
col.Item().PaddingTop(5).Text($"Total: {report.TimelineAnalysis.TotalGapMonths} months").Bold().FontSize(9).FontColor(Colors.Orange.Darken1);
}
else
{
col.Item().PaddingTop(5).Text("No significant gaps detected").FontSize(9).FontColor(Colors.Green.Darken1);
}
});
row.ConstantItem(15);
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(col =>
{
col.Item().Text("Concurrent Employment").Bold().FontSize(10);
if (report.TimelineAnalysis.Overlaps.Count > 0)
{
foreach (var overlap in report.TimelineAnalysis.Overlaps)
{
col.Item().PaddingTop(5).Text($"{overlap.Company1} & {overlap.Company2}").FontSize(9);
col.Item().Text($" {overlap.OverlapStart:MMM yyyy} - {overlap.OverlapEnd:MMM yyyy} ({overlap.Months} mo)").FontSize(8).FontColor(Colors.Grey.Darken1);
}
}
else
{
col.Item().PaddingTop(5).Text("No overlapping positions").FontSize(9).FontColor(Colors.Green.Darken1);
}
});
});
// Flags Section
if (report.Flags.Count > 0)
{
column.Item().PaddingTop(20).Text("Flags Raised").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
var criticalFlags = report.Flags.Where(f => f.Severity == "Critical").ToList();
var warningFlags = report.Flags.Where(f => f.Severity == "Warning").ToList();
var infoFlags = report.Flags.Where(f => f.Severity == "Info").ToList();
if (criticalFlags.Count > 0)
{
column.Item().PaddingTop(10).Background(Colors.Red.Lighten5).Border(1).BorderColor(Colors.Red.Lighten3).Padding(10).Column(col =>
{
col.Item().Text("Critical Issues").Bold().FontSize(10).FontColor(Colors.Red.Darken1);
foreach (var flag in criticalFlags)
{
col.Item().PaddingTop(5).Text($"• {flag.Title} ({flag.ScoreImpact} pts)").FontSize(9).Bold();
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
}
});
}
if (warningFlags.Count > 0)
{
column.Item().PaddingTop(10).Background(Colors.Orange.Lighten5).Border(1).BorderColor(Colors.Orange.Lighten3).Padding(10).Column(col =>
{
col.Item().Text("Warnings").Bold().FontSize(10).FontColor(Colors.Orange.Darken1);
foreach (var flag in warningFlags)
{
col.Item().PaddingTop(5).Text($"• {flag.Title} ({flag.ScoreImpact} pts)").FontSize(9).Bold();
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
}
});
}
if (infoFlags.Count > 0)
{
column.Item().PaddingTop(10).Background(Colors.Blue.Lighten5).Border(1).BorderColor(Colors.Blue.Lighten3).Padding(10).Column(col =>
{
col.Item().Text("Information").Bold().FontSize(10).FontColor(Colors.Blue.Darken1);
foreach (var flag in infoFlags)
{
col.Item().PaddingTop(5).Text($"• {flag.Title}").FontSize(9).Bold();
col.Item().Text($" {flag.Description}").FontSize(8).FontColor(Colors.Grey.Darken1);
}
});
}
}
});
}
/// <summary>
/// Generates a summary PDF report for multiple CV verifications (batch report).
/// </summary>
public byte[] GenerateReport(List<PdfReportData> data)
{
QuestPDF.Settings.License = LicenseType.Community;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.DefaultTextStyle(x => x.FontSize(10));
page.Header().Element(ComposeHeader);
page.Content().Element(c => ComposeContent(c, data));
page.Footer().Element(ComposeFooter);
});
});
return document.GeneratePdf();
}
private void ComposeHeader(IContainer container)
{
container.Column(column =>
{
column.Item().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("RealCV").Bold().FontSize(24).FontColor(Colors.Blue.Darken2);
col.Item().Text("CV Verification Report Summary").FontSize(14).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(100).AlignRight().Text(DateTime.Now.ToString("dd MMM yyyy")).FontSize(10).FontColor(Colors.Grey.Medium);
});
column.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
}
private void ComposeContent(IContainer container, List<PdfReportData> data)
{
container.PaddingVertical(20).Column(column =>
{
column.Item().Text("Summary of " + data.Count + " Verified CVs").Bold().FontSize(12);
column.Item().PaddingTop(15);
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3);
columns.RelativeColumn(2);
columns.ConstantColumn(50);
columns.ConstantColumn(45);
columns.ConstantColumn(45);
columns.ConstantColumn(45);
columns.ConstantColumn(45);
});
table.Header(header =>
{
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Candidate").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Date").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Score").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Verified").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Gaps").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Critical").Bold().FontColor(Colors.White);
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignCenter().Text("Warnings").Bold().FontColor(Colors.White);
});
bool alternate = false;
foreach (var item in data)
{
var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White;
var scoreColor = item.Score > ScoreThresholds.High ? Colors.Green.Darken1 : (item.Score >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1);
table.Cell().Background(bgColor).Padding(5).Text(item.CandidateName);
table.Cell().Background(bgColor).Padding(5).Text(item.UploadDate.ToString("dd MMM yy"));
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Score.ToString()).Bold().FontColor(scoreColor);
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.VerifiedEmployers + "/" + (item.VerifiedEmployers + item.UnverifiedEmployers));
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.GapMonths + "mo");
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.CriticalFlags.ToString()).FontColor(item.CriticalFlags > 0 ? Colors.Red.Darken1 : Colors.Grey.Medium);
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.WarningFlags.ToString()).FontColor(item.WarningFlags > 0 ? Colors.Orange.Darken1 : Colors.Grey.Medium);
alternate = !alternate;
}
});
});
}
private void ComposeFooter(IContainer container)
{
container.Column(column =>
{
column.Item().LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
column.Item().PaddingTop(10).Row(row =>
{
row.RelativeItem().Text("Generated by RealCV - CV Verification Platform").FontSize(8).FontColor(Colors.Grey.Medium);
row.RelativeItem().AlignRight().Text(x =>
{
x.Span("Page ").FontSize(8).FontColor(Colors.Grey.Medium);
x.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Medium);
x.Span(" of ").FontSize(8).FontColor(Colors.Grey.Medium);
x.TotalPages().FontSize(8).FontColor(Colors.Grey.Medium);
});
});
});
}
}
public class PdfReportData
{
public string CandidateName { get; set; } = "";
public DateTime UploadDate { get; set; }
public int Score { get; set; }
public string ScoreLabel { get; set; } = "";
public int VerifiedEmployers { get; set; }
public int UnverifiedEmployers { get; set; }
public int GapMonths { get; set; }
public int OverlapMonths { get; set; }
public int CriticalFlags { get; set; }
public int WarningFlags { get; set; }
}

View File

@@ -0,0 +1,10 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=127.0.0.1;Database=RealCV;User Id=SA;Password=TrueCV_Sql2024!;TrustServerCertificate=True;",
"HangfireConnection": "Server=127.0.0.1;Database=RealCV_Hangfire;User Id=SA;Password=TrueCV_Sql2024!;TrustServerCertificate=True;"
},
"UseLocalStorage": true,
"LocalStorage": {
"StoragePath": "/var/www/realcv/uploads"
}
}

View File

@@ -0,0 +1,34 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=RealCV;Trusted_Connection=True;TrustServerCertificate=True;",
"HangfireConnection": "Server=.;Database=RealCV_Hangfire;Trusted_Connection=True;TrustServerCertificate=True;"
},
"CompaniesHouse": {
"BaseUrl": "https://api.company-information.service.gov.uk",
"ApiKey": ""
},
"Anthropic": {
"ApiKey": ""
},
"DefaultAdmin": {
"Email": "",
"Password": ""
},
"AzureBlob": {
"ConnectionString": "",
"ContainerName": "cv-uploads"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"WriteTo": [
{ "Name": "Console" }
]
},
"AllowedHosts": "*"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,21 @@
// TrueCV JavaScript utilities
function downloadFile(fileName, base64Content, contentType) {
const link = document.createElement('a');
link.download = fileName;
link.href = `data:${contentType};base64,${base64Content}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function downloadCsvFile(fileName, csvContent) {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}