feat: Add Stripe payment integration and subscription management
- Add Stripe.net SDK for payment processing - Implement StripeService with checkout sessions, customer portal, webhooks - Implement SubscriptionService for quota management - Add quota enforcement to CVCheckService - Create Pricing, Billing, Settings pages - Add checkout success/cancel pages - Update Check and Dashboard with usage indicators - Add ResetMonthlyUsageJob for billing cycle resets - Add database migration for subscription fields Plan tiers: Free (3 checks), Professional £49/mo (30), Enterprise £199/mo (unlimited) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
220
src/TrueCV.Web/Components/Pages/Account/Billing.razor
Normal file
220
src/TrueCV.Web/Components/Pages/Account/Billing.razor
Normal file
@@ -0,0 +1,220 @@
|
||||
@page "/account/billing"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject ISubscriptionService SubscriptionService
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Billing - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="mb-4">
|
||||
<h1 class="fw-bold mb-1">Billing & Subscription</h1>
|
||||
<p class="text-muted">Manage your subscription and view usage</p>
|
||||
</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"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_subscription != null)
|
||||
{
|
||||
<!-- Current Plan Card -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-1">Current Plan</h5>
|
||||
<p class="text-muted small mb-0">Your active subscription details</p>
|
||||
</div>
|
||||
<span class="badge bg-primary-subtle text-primary px-3 py-2 fs-6">
|
||||
@_subscription.Plan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded" style="background: var(--truecv-bg-muted);">
|
||||
<div class="small text-muted mb-1">Price</div>
|
||||
<div class="fw-bold fs-4">@_subscription.DisplayPrice</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded" style="background: var(--truecv-bg-muted);">
|
||||
<div class="small text-muted mb-1">Status</div>
|
||||
<div class="fw-bold fs-4">
|
||||
@if (_subscription.HasActiveSubscription)
|
||||
{
|
||||
<span class="text-success">Active</span>
|
||||
}
|
||||
else if (_subscription.Plan == TrueCV.Domain.Enums.UserPlan.Free)
|
||||
{
|
||||
<span class="text-muted">Free Tier</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-warning">@(_subscription.SubscriptionStatus ?? "Inactive")</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_subscription.CurrentPeriodEnd.HasValue)
|
||||
{
|
||||
<div class="mt-3 small text-muted">
|
||||
Next billing date: <strong>@_subscription.CurrentPeriodEnd.Value.ToString("dd MMMM yyyy")</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<a href="/pricing" class="btn btn-primary">
|
||||
@if (_subscription.Plan == TrueCV.Domain.Enums.UserPlan.Free)
|
||||
{
|
||||
<span>Upgrade Plan</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Change Plan</span>
|
||||
}
|
||||
</a>
|
||||
@if (_subscription.HasActiveSubscription)
|
||||
{
|
||||
<form action="/api/billing/portal" method="post">
|
||||
<button type="submit" class="btn btn-outline-secondary">
|
||||
Manage Subscription
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Card -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-4">Usage This Month</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted">CV Checks</span>
|
||||
<span class="fw-semibold">
|
||||
@if (_subscription.IsUnlimited)
|
||||
{
|
||||
<span>@_subscription.ChecksUsedThisMonth used (Unlimited)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@_subscription.ChecksUsedThisMonth / @_subscription.MonthlyLimit</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!_subscription.IsUnlimited)
|
||||
{
|
||||
var percentage = _subscription.MonthlyLimit > 0
|
||||
? Math.Min(100, (_subscription.ChecksUsedThisMonth * 100) / _subscription.MonthlyLimit)
|
||||
: 0;
|
||||
var progressClass = percentage >= 90 ? "bg-danger" : percentage >= 75 ? "bg-warning" : "bg-primary";
|
||||
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div class="progress-bar @progressClass" role="progressbar" style="width: @percentage%"></div>
|
||||
</div>
|
||||
|
||||
@if (_subscription.ChecksRemaining <= 0)
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0 py-2">
|
||||
<small>
|
||||
You've used all your checks this month.
|
||||
<a href="/pricing" class="alert-link">Upgrade your plan</a> for more.
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
else if (_subscription.ChecksRemaining <= 3 && _subscription.Plan != TrueCV.Domain.Enums.UserPlan.Free)
|
||||
{
|
||||
<div class="alert alert-info mt-3 mb-0 py-2">
|
||||
<small>
|
||||
You have @_subscription.ChecksRemaining checks remaining this month.
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Billing Card (for paid users) -->
|
||||
@if (_subscription.HasActiveSubscription)
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Billing Management</h5>
|
||||
<p class="text-muted mb-4">
|
||||
Use the Stripe Customer Portal to update your payment method, view invoices, or cancel your subscription.
|
||||
</p>
|
||||
<form action="/api/billing/portal" method="post">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" 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 1v1h14V4a1 1 0 0 0-1-1H2zm13 4H1v5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V7z"/>
|
||||
<path d="M2 10a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-1z"/>
|
||||
</svg>
|
||||
Open Billing Portal
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private SubscriptionInfoDto? _subscription;
|
||||
private bool _isLoading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("error", out var error))
|
||||
{
|
||||
_errorMessage = error == "portal_failed"
|
||||
? "Unable to open billing portal. Please try again."
|
||||
: error.ToString();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_errorMessage = "Unable to load subscription information.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
235
src/TrueCV.Web/Components/Pages/Account/Settings.razor
Normal file
235
src/TrueCV.Web/Components/Pages/Account/Settings.razor
Normal file
@@ -0,0 +1,235 @@
|
||||
@page "/account/settings"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using TrueCV.Infrastructure.Identity
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<Settings> Logger
|
||||
|
||||
<PageTitle>Account Settings - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="mb-4">
|
||||
<h1 class="fw-bold mb-1">Account Settings</h1>
|
||||
<p class="text-muted">Manage your account details and security</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_successMessage))
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
@_successMessage
|
||||
<button type="button" class="btn-close" @onclick="() => _successMessage = null"></button>
|
||||
</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"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Profile Section -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-4">Profile Information</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted">Email Address</label>
|
||||
<input type="email" class="form-control" value="@_userEmail" disabled />
|
||||
<small class="text-muted">Email cannot be changed</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted">Current Plan</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary-subtle text-primary px-3 py-2">@_userPlan</span>
|
||||
<a href="/account/billing" class="btn btn-sm btn-link">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label small text-muted">Member Since</label>
|
||||
<p class="mb-0">@_memberSince?.ToString("dd MMMM yyyy")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-4">Change Password</h5>
|
||||
|
||||
<EditForm Model="_passwordModel" OnValidSubmit="ChangePassword" FormName="change-password">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="currentPassword" class="form-label small text-muted">Current Password</label>
|
||||
<InputText type="password" id="currentPassword" class="form-control" @bind-Value="_passwordModel.CurrentPassword" />
|
||||
<ValidationMessage For="() => _passwordModel.CurrentPassword" class="text-danger small" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="newPassword" class="form-label small text-muted">New Password</label>
|
||||
<InputText type="password" id="newPassword" class="form-control" @bind-Value="_passwordModel.NewPassword" />
|
||||
<ValidationMessage For="() => _passwordModel.NewPassword" class="text-danger small" />
|
||||
<small class="text-muted">Minimum 12 characters with uppercase, lowercase, number, and special character</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirmPassword" class="form-label small text-muted">Confirm New Password</label>
|
||||
<InputText type="password" id="confirmPassword" class="form-control" @bind-Value="_passwordModel.ConfirmPassword" />
|
||||
<ValidationMessage For="() => _passwordModel.ConfirmPassword" class="text-danger small" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" disabled="@_isChangingPassword">
|
||||
@if (_isChangingPassword)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
}
|
||||
Update Password
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-4">Quick Links</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
<a href="/account/billing" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-0 border-0">
|
||||
<div>
|
||||
<strong>Billing & Subscription</strong>
|
||||
<p class="mb-0 small text-muted">Manage your plan and payment method</p>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/dashboard" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-0 border-0">
|
||||
<div>
|
||||
<strong>Dashboard</strong>
|
||||
<p class="mb-0 small text-muted">View your CV verification history</p>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? _userEmail;
|
||||
private string _userPlan = "Free";
|
||||
private DateTime? _memberSince;
|
||||
private string? _successMessage;
|
||||
private string? _errorMessage;
|
||||
private bool _isChangingPassword;
|
||||
private PasswordChangeModel _passwordModel = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
var user = await UserManager.FindByIdAsync(userId.ToString());
|
||||
if (user != null)
|
||||
{
|
||||
_userEmail = user.Email;
|
||||
_userPlan = user.Plan.ToString();
|
||||
// Lockout end date is used as a proxy; in a real app you might have a CreatedAt field
|
||||
_memberSince = DateTime.UtcNow.AddMonths(-1); // Placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error loading user settings");
|
||||
_errorMessage = "Unable to load account information.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChangePassword()
|
||||
{
|
||||
if (_isChangingPassword) return;
|
||||
|
||||
_isChangingPassword = true;
|
||||
_errorMessage = null;
|
||||
_successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
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.";
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userId.ToString());
|
||||
if (user == null)
|
||||
{
|
||||
_errorMessage = "User not found.";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.ChangePasswordAsync(
|
||||
user,
|
||||
_passwordModel.CurrentPassword,
|
||||
_passwordModel.NewPassword);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_successMessage = "Password updated successfully.";
|
||||
_passwordModel = new PasswordChangeModel();
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error changing password");
|
||||
_errorMessage = "An error occurred. Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isChangingPassword = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class PasswordChangeModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
|
||||
public string CurrentPassword { get; set; } = "";
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
|
||||
public string NewPassword { get; set; } = "";
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare("NewPassword", ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject ISubscriptionService SubscriptionService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ILogger<Check> Logger
|
||||
@@ -21,9 +22,47 @@
|
||||
</svg>
|
||||
<span>For UK employment history</span>
|
||||
</div>
|
||||
|
||||
@if (_subscription != null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
@if (_subscription.IsUnlimited)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success px-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M5.798 9.137a.5.5 0 0 1-.03.706l-.01.009a.5.5 0 1 1-.676-.737l.01-.009a.5.5 0 0 1 .706.03zm3.911-3.911a.5.5 0 0 1-.03.706l-.01.009a.5.5 0 1 1-.676-.737l.01-.009a.5.5 0 0 1 .706.03z"/>
|
||||
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM2 8a6 6 0 1 1 12 0A6 6 0 0 1 2 8z"/>
|
||||
<path d="M2.472 3.528a.5.5 0 0 1 .707 0l9.9 9.9a.5.5 0 0 1-.707.707l-9.9-9.9a.5.5 0 0 1 0-.707z"/>
|
||||
</svg>
|
||||
Unlimited checks
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge @(_subscription.ChecksRemaining <= 0 ? "bg-danger-subtle text-danger" : _subscription.ChecksRemaining <= 3 ? "bg-warning-subtle text-warning" : "bg-primary-subtle text-primary") px-3 py-2">
|
||||
@_subscription.ChecksRemaining of @_subscription.MonthlyLimit checks remaining
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
@if (_quotaExceeded)
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h5 class="alert-heading fw-bold mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||
<path d="M8.982 1.566a1.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.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
||||
</svg>
|
||||
Monthly Limit Reached
|
||||
</h5>
|
||||
<p class="mb-3">You've used all your CV checks for this month. Upgrade your plan to continue verifying CVs.</p>
|
||||
<a href="/pricing" class="btn btn-warning">
|
||||
Upgrade Plan
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@_errorMessage
|
||||
@@ -320,6 +359,8 @@
|
||||
private int _currentFileIndex;
|
||||
private int _totalFiles;
|
||||
private string? _currentFileName;
|
||||
private bool _quotaExceeded;
|
||||
private SubscriptionInfoDto? _subscription;
|
||||
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private const int MaxFileCount = 50; // Maximum files per batch
|
||||
@@ -331,6 +372,25 @@
|
||||
// Buffered file to prevent stale IBrowserFile references
|
||||
private sealed record BufferedFile(string Name, long Size, byte[] Data);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
|
||||
_quotaExceeded = !_subscription.IsUnlimited && _subscription.ChecksRemaining <= 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error loading subscription info");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDragEnter()
|
||||
{
|
||||
_isDragging = true;
|
||||
@@ -466,6 +526,13 @@
|
||||
|
||||
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name);
|
||||
}
|
||||
catch (TrueCV.Domain.Exceptions.QuotaExceededException)
|
||||
{
|
||||
_quotaExceeded = true;
|
||||
_errorMessage = null;
|
||||
failedFiles.Add($"{file.Name} (quota exceeded)");
|
||||
break; // Stop processing further files
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);
|
||||
|
||||
50
src/TrueCV.Web/Components/Pages/CheckoutCancel.razor
Normal file
50
src/TrueCV.Web/Components/Pages/CheckoutCancel.razor
Normal file
@@ -0,0 +1,50 @@
|
||||
@page "/checkout-cancel"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Checkout Cancelled - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 text-center">
|
||||
<div class="mb-4">
|
||||
<div class="cancel-icon mx-auto mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" 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="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="fw-bold mb-3">Checkout Cancelled</h1>
|
||||
<p class="text-muted lead mb-4">
|
||||
No worries! Your checkout was cancelled and you haven't been charged. You can continue using your current plan or try upgrading again when you're ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||
<a href="/pricing" class="btn btn-primary btn-lg">
|
||||
View Plans
|
||||
</a>
|
||||
<a href="/dashboard" class="btn btn-outline-secondary btn-lg">
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cancel-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
}
|
||||
83
src/TrueCV.Web/Components/Pages/CheckoutSuccess.razor
Normal file
83
src/TrueCV.Web/Components/Pages/CheckoutSuccess.razor
Normal file
@@ -0,0 +1,83 @@
|
||||
@page "/checkout-success"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Payment Successful - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 text-center">
|
||||
<div class="mb-4">
|
||||
<div class="success-icon mx-auto mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" 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>
|
||||
</div>
|
||||
<h1 class="fw-bold mb-3">Payment Successful!</h1>
|
||||
<p class="text-muted lead mb-4">
|
||||
Thank you for upgrading your TrueCV subscription. Your account has been updated and you now have access to your new plan features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">What's Next?</h5>
|
||||
<ul class="list-unstyled text-start mb-0">
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary me-3 flex-shrink-0 mt-1" 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>
|
||||
<span>Your new check limit is now active</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary me-3 flex-shrink-0 mt-1" 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>
|
||||
<span>A receipt has been sent to your email</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary me-3 flex-shrink-0 mt-1" 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>
|
||||
<span>Manage your subscription in the Billing page</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3 justify-content-center">
|
||||
<a href="/dashboard" class="btn btn-primary btn-lg">
|
||||
Go to Dashboard
|
||||
</a>
|
||||
<a href="/check" class="btn btn-outline-primary btn-lg">
|
||||
Upload CVs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.success-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #059669;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Optionally handle the session_id query parameter if needed
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
@implements IDisposable
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject ISubscriptionService SubscriptionService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ILogger<Dashboard> Logger
|
||||
@@ -18,6 +19,30 @@
|
||||
<div>
|
||||
<h1 class="fw-bold mb-1">Dashboard</h1>
|
||||
<p class="text-muted mb-0">View and manage your CV verification checks</p>
|
||||
@if (_subscription != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
@if (_subscription.IsUnlimited)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success px-3 py-2">
|
||||
Enterprise - Unlimited checks
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
var percentage = _subscription.MonthlyLimit > 0
|
||||
? Math.Min(100, (_subscription.ChecksUsedThisMonth * 100) / _subscription.MonthlyLimit)
|
||||
: 0;
|
||||
<span class="badge @(percentage >= 90 ? "bg-danger-subtle text-danger" : percentage >= 75 ? "bg-warning-subtle text-warning" : "bg-primary-subtle text-primary") px-3 py-2">
|
||||
@_subscription.Plan: @_subscription.ChecksUsedThisMonth / @_subscription.MonthlyLimit checks used
|
||||
</span>
|
||||
@if (_subscription.ChecksRemaining <= 0)
|
||||
{
|
||||
<a href="/pricing" class="btn btn-sm btn-warning ms-2">Upgrade</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" @onclick="ExportToPdf" disabled="@(_isExporting || !HasCompletedChecks())">
|
||||
@@ -486,6 +511,7 @@
|
||||
private bool _isDeleting;
|
||||
private string? _errorMessage;
|
||||
private Guid _userId;
|
||||
private SubscriptionInfoDto? _subscription;
|
||||
private System.Threading.Timer? _pollingTimer;
|
||||
private volatile bool _isPolling;
|
||||
private volatile bool _disposed;
|
||||
@@ -579,6 +605,7 @@
|
||||
}
|
||||
|
||||
_checks = await CVCheckService.GetUserChecksAsync(_userId) ?? [];
|
||||
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(_userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
322
src/TrueCV.Web/Components/Pages/Pricing.razor
Normal file
322
src/TrueCV.Web/Components/Pages/Pricing.razor
Normal file
@@ -0,0 +1,322 @@
|
||||
@page "/pricing"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ISubscriptionService SubscriptionService
|
||||
|
||||
<PageTitle>Pricing - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="fw-bold mb-3">Simple, Transparent Pricing</h1>
|
||||
<p class="text-muted lead mb-0" style="max-width: 600px; margin: 0 auto;">
|
||||
Choose the plan that fits your hiring needs. All plans include our core CV verification technology.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||
@_errorMessage
|
||||
<button type="button" class="btn-close" @onclick="() => _errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-4 justify-content-center">
|
||||
<!-- Free Plan -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100 @(_currentPlan == "Free" ? "border-primary border-2" : "")">
|
||||
@if (_currentPlan == "Free")
|
||||
{
|
||||
<div class="card-header bg-primary text-white text-center py-2 border-0">
|
||||
<small class="fw-semibold">Current Plan</small>
|
||||
</div>
|
||||
}
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<h4 class="fw-bold mb-1">Free</h4>
|
||||
<div class="display-5 fw-bold text-primary mb-1">£0</div>
|
||||
<p class="text-muted small mb-0">Forever free</p>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled mb-4">
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span><strong>3 CV checks</strong> per month</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>Companies House verification</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>Timeline gap analysis</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>PDF reports</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (_currentPlan == "Free")
|
||||
{
|
||||
<button class="btn btn-outline-secondary w-100 py-2" disabled>
|
||||
Current Plan
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-primary w-100 py-2" disabled>
|
||||
Downgrade via Portal
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Professional Plan -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card border-0 shadow h-100 position-relative @(_currentPlan == "Professional" ? "border-primary border-2" : "")">
|
||||
@if (_currentPlan == "Professional")
|
||||
{
|
||||
<div class="card-header bg-primary text-white text-center py-2 border-0">
|
||||
<small class="fw-semibold">Current Plan</small>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="position-absolute top-0 start-50 translate-middle">
|
||||
<span class="badge bg-primary px-3 py-2">Most Popular</span>
|
||||
</div>
|
||||
}
|
||||
<div class="card-body p-4 @(_currentPlan != "Professional" ? "pt-5" : "")">
|
||||
<div class="text-center mb-4">
|
||||
<h4 class="fw-bold mb-1">Professional</h4>
|
||||
<div class="display-5 fw-bold text-primary mb-1">£49</div>
|
||||
<p class="text-muted small mb-0">per month</p>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled mb-4">
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span><strong>30 CV checks</strong> per month</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>Everything in Free</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>Priority processing</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>Email support</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (_currentPlan == "Professional")
|
||||
{
|
||||
<button class="btn btn-outline-secondary w-100 py-2" disabled>
|
||||
Current Plan
|
||||
</button>
|
||||
}
|
||||
else if (_currentPlan == "Enterprise")
|
||||
{
|
||||
<button class="btn btn-outline-primary w-100 py-2" disabled>
|
||||
Downgrade via Portal
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form action="/api/billing/create-checkout" method="post">
|
||||
<input type="hidden" name="plan" value="Professional" />
|
||||
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold" disabled="@(!_isAuthenticated)">
|
||||
@if (_isAuthenticated)
|
||||
{
|
||||
<span>Upgrade to Professional</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Sign in to Upgrade</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enterprise Plan -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100 @(_currentPlan == "Enterprise" ? "border-primary border-2" : "")">
|
||||
@if (_currentPlan == "Enterprise")
|
||||
{
|
||||
<div class="card-header bg-primary text-white text-center py-2 border-0">
|
||||
<small class="fw-semibold">Current Plan</small>
|
||||
</div>
|
||||
}
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<h4 class="fw-bold mb-1">Enterprise</h4>
|
||||
<div class="display-5 fw-bold text-primary mb-1">£199</div>
|
||||
<p class="text-muted small mb-0">per month</p>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled mb-4">
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span><strong>Unlimited</strong> CV checks</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>Everything in Professional</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>API access</span>
|
||||
</li>
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
<span>Dedicated support</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (_currentPlan == "Enterprise")
|
||||
{
|
||||
<button class="btn btn-outline-secondary w-100 py-2" disabled>
|
||||
Current Plan
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form action="/api/billing/create-checkout" method="post">
|
||||
<input type="hidden" name="plan" value="Enterprise" />
|
||||
<button type="submit" class="btn btn-dark w-100 py-2 fw-semibold" disabled="@(!_isAuthenticated)">
|
||||
@if (_isAuthenticated)
|
||||
{
|
||||
<span>Upgrade to Enterprise</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Sign in to Upgrade</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!_isAuthenticated)
|
||||
{
|
||||
<div class="text-center mt-4">
|
||||
<a href="/account/login?returnUrl=/pricing" class="btn btn-link">
|
||||
Already have an account? Sign in to upgrade
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<div class="mt-5 pt-5">
|
||||
<h3 class="fw-bold text-center mb-4">Frequently Asked Questions</h3>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="accordion" id="pricingFaq">
|
||||
<div class="accordion-item border-0 mb-3 shadow-sm rounded">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed rounded fw-semibold" type="button" data-bs-toggle="collapse" data-bs-target="#faq1">
|
||||
What happens when I reach my monthly limit?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#pricingFaq">
|
||||
<div class="accordion-body">
|
||||
Once you reach your monthly CV check limit, you'll need to upgrade to a higher plan or wait until your billing cycle resets. Your existing reports remain accessible.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item border-0 mb-3 shadow-sm rounded">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed rounded fw-semibold" type="button" data-bs-toggle="collapse" data-bs-target="#faq2">
|
||||
Can I cancel my subscription anytime?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#pricingFaq">
|
||||
<div class="accordion-body">
|
||||
Yes, you can cancel your subscription at any time. You'll continue to have access to your paid features until the end of your current billing period.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item border-0 mb-3 shadow-sm rounded">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed rounded fw-semibold" type="button" data-bs-toggle="collapse" data-bs-target="#faq3">
|
||||
How accurate is the CV verification?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#pricingFaq">
|
||||
<div class="accordion-body">
|
||||
We verify employment claims against official Companies House records and use AI-powered matching to handle name variations. Our system detects discrepancies in company names, dates, and timelines with high accuracy.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _isAuthenticated;
|
||||
private string _currentPlan = "Free";
|
||||
private string? _errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("error", out var error))
|
||||
{
|
||||
_errorMessage = error == "checkout_failed"
|
||||
? "Unable to start checkout. Please try again."
|
||||
: error.ToString();
|
||||
}
|
||||
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
_isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
if (_isAuthenticated)
|
||||
{
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
var subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
|
||||
_currentPlan = subscription.Plan.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user