Review the PlotDirector subscription, pricing, signup and account-management areas and implement a complete trial-visibility pass.

This commit is contained in:
Nick Beckley 2026-06-18 20:54:18 +01:00
parent 61d16be6d7
commit d4c886ce2f
16 changed files with 362 additions and 17 deletions

View File

@ -30,10 +30,12 @@ public sealed class ManageAccountController(
return RedirectToAction("Login", "Account");
}
var subscription = await subscriptions.GetCurrentSubscriptionAsync(user.UserID);
return View(new ManageAccountViewModel
{
User = user,
Subscription = await subscriptions.GetCurrentSubscriptionAsync(user.UserID),
Subscription = subscription,
Trial = TrialVisibilityViewModel.FromSubscription(subscription, DateTime.UtcNow),
HasPaidBillingRisk = await subscriptions.HasPaidBillingRiskAsync(user.UserID)
});
}
@ -104,6 +106,7 @@ public sealed class ManageAccountController(
{
User = user,
CurrentSubscription = subscription,
Trial = TrialVisibilityViewModel.FromSubscription(subscription, DateTime.UtcNow),
Usage = subscription is null ? null : await subscriptions.GetUsageSummaryAsync(user.UserID),
AvailableLevels = await subscriptions.GetActiveSubscriptionLevelsAsync()
});

View File

@ -66,6 +66,7 @@ public sealed class UserSubscriptionDetail : UserSubscription
public int? MonthlyPricePence { get; set; }
public int? AnnualPricePence { get; set; }
public string CurrencyCode { get; set; } = "GBP";
public bool RequiresStripeSubscription { get; set; }
}
public sealed class SubscriptionLimitResult

View File

@ -372,6 +372,7 @@ public sealed class ProjectService(
IWarningRepository warnings,
IWritingScheduleRepository writingSchedule,
IProjectActivityService activity,
ISubscriptionService subscriptions,
ICurrentUserService currentUser,
IHardDeleteFileCleanupService fileCleanup,
ILogger<ProjectService> logger) : IProjectService
@ -386,7 +387,8 @@ public sealed class ProjectService(
return new ProjectIndexViewModel
{
ActiveProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value),
ArchivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value)
ArchivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value),
Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow)
};
}

View File

@ -7,6 +7,7 @@ public sealed class ManageAccountViewModel
{
public AppUser User { get; set; } = new();
public UserSubscriptionDetail? Subscription { get; set; }
public TrialVisibilityViewModel? Trial { get; set; }
public bool HasPaidBillingRisk { get; set; }
}
@ -14,10 +15,77 @@ public sealed class ManageSubscriptionViewModel
{
public AppUser User { get; set; } = new();
public UserSubscriptionDetail? CurrentSubscription { get; set; }
public TrialVisibilityViewModel? Trial { get; set; }
public SubscriptionUsageSummary? Usage { get; set; }
public IReadOnlyList<SubscriptionLevel> AvailableLevels { get; set; } = [];
}
public sealed class TrialVisibilityViewModel
{
public const int TrialDurationDays = 30;
public string PlanName { get; init; } = string.Empty;
public DateTime StartDateUtc { get; init; }
public DateTime ExpiryDateUtc { get; init; }
public int DaysRemaining { get; init; }
public int DaysElapsed { get; init; }
public int ProgressPercentage { get; init; }
public bool IsExpired { get; init; }
public int? WarningThresholdDays { get; init; }
public string StatusLabel => IsExpired
? "Trial expired"
: DaysRemaining == 1 ? "1 day remaining" : $"{DaysRemaining} days remaining";
public string WarningCssClass => WarningThresholdDays switch
{
<= 1 => "trial-status--one-day",
<= 3 => "trial-status--three-days",
<= 7 => "trial-status--seven-days",
<= 14 => "trial-status--fourteen-days",
_ => "trial-status--steady"
};
public static TrialVisibilityViewModel? FromSubscription(UserSubscriptionDetail? subscription, DateTime utcNow)
{
if (subscription is null)
{
return null;
}
var isTrialPlan = subscription.IsTrial
|| (!subscription.RequiresStripeSubscription && string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId));
if (!isTrialPlan)
{
return null;
}
var start = subscription.StartDateUTC;
var expiry = subscription.TrialExpiryDateUTC ?? start.AddDays(TrialDurationDays);
var totalDays = Math.Max(1, (int)Math.Ceiling((expiry - start).TotalDays));
var rawRemaining = (int)Math.Ceiling((expiry - utcNow).TotalDays);
var daysRemaining = Math.Max(0, rawRemaining);
var daysElapsed = Math.Clamp(totalDays - daysRemaining, 0, totalDays);
var progress = Math.Clamp((int)Math.Round(daysElapsed * 100m / totalDays), 0, 100);
return new TrialVisibilityViewModel
{
PlanName = subscription.DisplayName,
StartDateUtc = start,
ExpiryDateUtc = expiry,
DaysRemaining = daysRemaining,
DaysElapsed = daysElapsed,
ProgressPercentage = progress,
IsExpired = utcNow >= expiry,
WarningThresholdDays = daysRemaining <= 1 ? 1
: daysRemaining <= 3 ? 3
: daysRemaining <= 7 ? 7
: daysRemaining <= 14 ? 14
: null
};
}
}
public sealed class PricingViewModel
{
public IReadOnlyList<SubscriptionLevel> Plans { get; set; } = [];

View File

@ -25,6 +25,7 @@ public sealed class ProjectIndexViewModel
{
public IReadOnlyList<Project> ActiveProjects { get; init; } = [];
public IReadOnlyList<Project> ArchivedProjects { get; init; } = [];
public TrialVisibilityViewModel? Trial { get; init; }
}
public sealed class ProjectDetailViewModel

View File

@ -7,10 +7,19 @@
<div>
<p class="eyebrow">Account</p>
<h1>Create your account</h1>
<p class="lead-text">Create a PlotDirector account so your workspace can use the new authentication foundation.</p>
<p class="lead-text">Create your account to start a free 30-day PlotDirector trial immediately. No payment details are required.</p>
</div>
</div>
<section class="trial-status-panel trial-status-panel--signup">
<div>
<p class="eyebrow">Free trial</p>
<h2>30 days to plan, test and decide</h2>
<p>Your trial begins as soon as registration is complete. When it expires, your account will show that the trial has ended and you can upgrade from the subscription page.</p>
</div>
<a class="btn btn-outline-primary" asp-controller="Pricing" asp-action="Index">Compare plans</a>
</section>
<section class="edit-panel">
<form asp-action="Register" method="post" class="asset-create-form">
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>

View File

@ -2,6 +2,7 @@
@{
ViewData["Title"] = "Manage Account";
var subscription = Model.Subscription;
var trial = Model.Trial;
}
<div class="page-heading compact">
@ -64,11 +65,39 @@
}
else
{
@if (trial is not null)
{
<div class="trial-status-panel @trial.WarningCssClass">
<div class="trial-status-panel__heading">
<div>
<p class="eyebrow">Free trial status</p>
<h3>@trial.StatusLabel</h3>
</div>
<a class="btn btn-outline-primary btn-sm" asp-action="Subscription">Upgrade</a>
</div>
<p>Your 30-day @trial.PlanName trial expires on @FormatDate(trial.ExpiryDateUtc).</p>
<div class="trial-progress" aria-label="Trial progress">
<div class="trial-progress__bar" style="width: @trial.ProgressPercentage%"></div>
</div>
</div>
}
<div class="row g-3">
<div class="col-md-4">
<p class="eyebrow">Current plan</p>
<p>@subscription.DisplayName</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Trial start</p>
<p>@(trial is null ? "Not on trial" : FormatDate(trial.StartDateUtc))</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Trial expiry</p>
<p>@(trial is null ? "Not on trial" : FormatDate(trial.ExpiryDateUtc))</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Days remaining</p>
<p>@(trial is null ? "Not on trial" : trial.StatusLabel)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Books</p>
<p>@FormatLimit(subscription.MaxBooks)</p>
@ -159,6 +188,8 @@
private static string FormatLimit(int value) => value >= 999999 ? "Practical unlimited" : value.ToString("N0");
private static string FormatStorage(int value) => value >= 1024 ? $"{value / 1024:N0} GB" : $"{value:N0} MB";
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
}
@section Scripts {

View File

@ -2,6 +2,7 @@
@{
ViewData["Title"] = "Subscription";
var subscription = Model.CurrentSubscription;
var trial = Model.Trial;
var usage = Model.Usage;
}
@ -34,6 +35,23 @@
}
else
{
@if (trial is not null)
{
<div class="trial-status-panel @trial.WarningCssClass">
<div class="trial-status-panel__heading">
<div>
<p class="eyebrow">Free trial status</p>
<h3>@trial.StatusLabel</h3>
</div>
<a class="btn btn-primary btn-sm" asp-controller="Pricing" asp-action="Index">Upgrade</a>
</div>
<p>Your 30-day @trial.PlanName trial started on @FormatDate(trial.StartDateUtc) and expires on @FormatDate(trial.ExpiryDateUtc).</p>
<div class="trial-progress" aria-label="Trial progress">
<div class="trial-progress__bar" style="width: @trial.ProgressPercentage%"></div>
</div>
<p class="muted mb-0">@trial.DaysElapsed days used, @trial.DaysRemaining days remaining.</p>
</div>
}
<div class="row g-3">
<div class="col-md-4">
<p class="eyebrow">Plan</p>
@ -53,11 +71,19 @@
</div>
<div class="col-md-3">
<p class="eyebrow">Trial</p>
<p>@(subscription.IsTrial ? "Trial" : "No")</p>
<p>@(trial is null ? "No" : "30-day free trial")</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Trial start</p>
<p>@(trial is null ? "Not set" : FormatDate(trial.StartDateUtc))</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Trial expiry</p>
<p>@FormatDate(subscription.TrialExpiryDateUTC)</p>
<p>@(trial is null ? FormatDate(subscription.TrialExpiryDateUTC) : FormatDate(trial.ExpiryDateUtc))</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Days remaining</p>
<p>@(trial is null ? "Not on trial" : trial.StatusLabel)</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Started</p>
@ -160,7 +186,7 @@
<p>@(string.IsNullOrWhiteSpace(level.Description) ? "No description available." : level.Description)</p>
@if (!level.RequiresStripeSubscription)
{
<p class="lead-text">Free / trial / application managed</p>
<p class="lead-text">30-day free trial / application managed</p>
}
else
{
@ -197,7 +223,7 @@
</dl>
@if (!level.RequiresStripeSubscription)
{
<p class="muted">Draft Desk is managed inside PlotDirector.</p>
<p class="muted">Draft Desk is a 30-day free trial. No payment details are required.</p>
}
else
{

View File

@ -35,7 +35,7 @@
<div class="marketing-container pricing-hero__inner">
<p class="marketing-eyebrow">Pricing</p>
<h1>Simple pricing for stories of every size.</h1>
<p class="marketing-lead">Start free, subscribe when you're ready, or choose a plan straight away.</p>
<p class="marketing-lead">Start with a 30-day free trial. No payment details are required until you choose a paid plan.</p>
<div class="marketing-actions">
<a class="marketing-btn marketing-btn--primary" asp-controller="Pricing" asp-action="StartTrial">Start Free Trial</a>
<a class="marketing-btn marketing-btn--secondary" href="#plans">Compare Plans</a>
@ -71,7 +71,12 @@
{
var isCurrent = Model.CurrentSubscriptionLevelID == plan.SubscriptionLevelID;
var isSuggested = suggestedPlanId == plan.SubscriptionLevelID;
<article class="pricing-card @(isSuggested ? "pricing-card--featured" : string.Empty)">
var isTrialPlan = !plan.RequiresStripeSubscription;
<article class="pricing-card @(isSuggested ? "pricing-card--featured" : string.Empty) @(isTrialPlan ? "pricing-card--trial" : string.Empty)">
@if (isTrialPlan)
{
<p class="pricing-badge pricing-badge--trial">30-day free trial</p>
}
@if (isSuggested)
{
<p class="pricing-badge">Most Popular</p>
@ -94,12 +99,17 @@
else
{
<strong>Free</strong>
<span>trial workspace</span>
<small>Assigned when you create an account.</small>
<span>30-day trial workspace</span>
<small>No payment details required. Starts immediately after registration.</small>
}
</div>
<ul class="pricing-feature-list">
@if (!plan.RequiresStripeSubscription)
{
<li>30 days of free access</li>
<li>No credit card required</li>
}
<li>@FormatLimit(plan.MaxBooks) books</li>
<li>@FormatLimit(plan.MaxCharactersPerProject) characters per project</li>
<li>@FormatLimit(plan.MaxScenesPerBook) scenes per book</li>
@ -166,7 +176,7 @@
<div class="faq-grid">
<article>
<h3>Can I start with a free trial?</h3>
<p>Yes. Creating an account starts you on the Draft Desk evaluation plan, so you can try the workspace before choosing a paid subscription.</p>
<p>Yes. Creating an account starts a 30-day Draft Desk trial immediately, so you can try the workspace before choosing a paid subscription.</p>
</article>
<article>
<h3>Can I subscribe without using the trial?</h3>
@ -186,7 +196,11 @@
</article>
<article>
<h3>Do I need a credit card for the trial?</h3>
<p>The registration flow creates the evaluation workspace. Payment details are only handled when you choose a paid subscription through Stripe Checkout.</p>
<p>No. Payment details are not required for the free trial. Stripe Checkout is only used when you choose a paid subscription.</p>
</article>
<article>
<h3>What happens when the trial expires?</h3>
<p>Your trial status will show in your dashboard and subscription pages. When the 30 days are over, choose a paid plan to continue with higher paid-plan access.</p>
</article>
</div>
</div>
@ -196,7 +210,7 @@
<div class="marketing-container final-cta__inner">
<p class="marketing-eyebrow">Begin with clarity</p>
<h2>Ready to take control of your story?</h2>
<p>Try PlotDirector free, or choose the paid plan that fits the novel you are building.</p>
<p>Try PlotDirector free for 30 days with no payment details, or choose the paid plan that fits the novel you are building.</p>
<div class="marketing-actions">
<a class="marketing-btn marketing-btn--primary" asp-controller="Pricing" asp-action="StartTrial">Start Free Trial</a>
<a class="marketing-btn marketing-btn--secondary" href="#plans">View Plans</a>

View File

@ -1,6 +1,7 @@
@model ProjectIndexViewModel
@{
ViewData["Title"] = "Projects";
var trial = Model.Trial;
}
<div class="page-heading">
@ -14,6 +15,24 @@
</div>
</div>
@if (trial is not null)
{
<section class="trial-dashboard-banner @trial.WarningCssClass"
data-trial-banner
data-trial-banner-key="projects:@trial.ExpiryDateUtc.ToString("yyyyMMdd")"
aria-label="Trial status">
<div class="trial-dashboard-banner__body">
<p class="eyebrow">Free trial status</p>
<h2>@trial.StatusLabel on @trial.PlanName</h2>
<p>Your 30-day trial expires on @FormatDate(trial.ExpiryDateUtc). Upgrade whenever you are ready.</p>
</div>
<div class="trial-dashboard-banner__actions">
<a class="btn btn-primary btn-sm" asp-controller="ManageAccount" asp-action="Subscription">Upgrade</a>
<button class="btn btn-outline-secondary btn-sm" type="button" data-trial-banner-dismiss>Dismiss</button>
</div>
</section>
}
@if (TempData["ArchiveMessage"] is string archiveMessage)
{
<div class="alert alert-success">@archiveMessage</div>
@ -140,6 +159,10 @@ else
</div>
}
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
}
@section Scripts {
<script>
(() => {

View File

@ -558,6 +558,49 @@ a:focus-visible {
color: var(--plotline-text);
}
.trial-status-panel,
.trial-dashboard-banner {
border-color: rgba(84, 68, 48, 0.16);
background:
linear-gradient(135deg, rgba(255, 253, 248, 0.96), rgba(242, 226, 207, 0.56)),
var(--plotline-surface);
box-shadow: var(--plotline-shadow-subtle);
}
.trial-status-panel--signup {
border-color: rgba(49, 95, 103, 0.22);
background:
linear-gradient(135deg, rgba(223, 236, 239, 0.72), rgba(255, 253, 248, 0.92)),
var(--plotline-surface);
}
.trial-status-panel h2,
.trial-status-panel h3,
.trial-dashboard-banner h2 {
color: var(--plotline-text);
}
.trial-progress {
box-shadow: inset 0 1px 2px rgba(62, 45, 28, 0.08);
}
.trial-progress__bar {
background: linear-gradient(90deg, var(--plotline-secondary), var(--plotline-accent));
}
.trial-status--fourteen-days {
border-color: rgba(184, 118, 43, 0.28);
}
.trial-status--seven-days,
.trial-status--three-days,
.trial-status--one-day {
border-color: rgba(184, 118, 43, 0.38);
background:
linear-gradient(135deg, rgba(251, 236, 211, 0.84), rgba(255, 253, 248, 0.92)),
var(--plotline-surface);
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--plotline-text);
@ -3649,6 +3692,14 @@ a:focus-visible {
box-shadow: 0 26px 58px rgba(31, 42, 68, 0.13);
}
.pricing-card--trial {
border-color: rgba(95, 120, 108, 0.52);
background:
linear-gradient(180deg, rgba(255, 253, 249, 0.98), rgba(235, 241, 237, 0.9)),
#fff;
box-shadow: 0 24px 54px rgba(31, 42, 68, 0.11);
}
.pricing-badge {
position: absolute;
top: clamp(1.2rem, 2.4vw, 1.65rem);
@ -3666,6 +3717,12 @@ a:focus-visible {
text-transform: uppercase;
}
.pricing-badge--trial {
border-color: rgba(95, 120, 108, 0.48);
color: #314b42;
background: rgba(95, 120, 108, 0.18);
}
.pricing-card__heading {
display: grid;
gap: 0.5rem;

File diff suppressed because one or more lines are too long

View File

@ -169,6 +169,77 @@ td.text-end {
max-width: 980px;
}
.trial-status-panel,
.trial-dashboard-banner {
border: 1px solid var(--plotline-line);
border-radius: 8px;
padding: 18px 20px;
background: var(--plotline-panel);
}
.trial-status-panel {
display: grid;
gap: 12px;
margin-bottom: 18px;
}
.trial-status-panel--signup {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
max-width: 980px;
}
.trial-status-panel h2,
.trial-status-panel h3,
.trial-dashboard-banner h2 {
margin: 0;
}
.trial-status-panel p,
.trial-dashboard-banner p {
margin-bottom: 0;
}
.trial-status-panel__heading,
.trial-dashboard-banner {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.trial-dashboard-banner {
margin-bottom: 18px;
}
.trial-dashboard-banner__body {
min-width: 0;
}
.trial-dashboard-banner__actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 8px;
}
.trial-progress {
height: 10px;
overflow: hidden;
border-radius: 999px;
background: rgba(84, 68, 48, 0.12);
}
.trial-progress__bar {
height: 100%;
border-radius: inherit;
}
.trial-status-panel[hidden],
.trial-dashboard-banner[hidden] {
display: none;
}
.location-page .page-heading.compact,
.location-page .edit-panel,
.location-page .empty-panel,
@ -3974,6 +4045,19 @@ body.dragging-location [data-drag-type="location"] {
grid-template-columns: 1fr;
}
.trial-status-panel--signup,
.trial-status-panel__heading,
.trial-dashboard-banner {
align-items: stretch;
flex-direction: column;
}
.trial-dashboard-banner__actions,
.trial-dashboard-banner__actions .btn,
.trial-status-panel--signup .btn {
width: 100%;
}
.attention-list p {
grid-column: auto;
}

File diff suppressed because one or more lines are too long

View File

@ -37,6 +37,32 @@
applyTheme(readTheme() || "light");
})();
(() => {
const banner = document.querySelector("[data-trial-banner]");
if (!banner) {
return;
}
const key = `plotline.trialBanner.dismissed.${banner.dataset.trialBannerKey || "current"}`;
try {
if (localStorage.getItem(key) === "true") {
banner.hidden = true;
return;
}
} catch {
// The banner can still be dismissed for this page view if storage is unavailable.
}
banner.querySelector("[data-trial-banner-dismiss]")?.addEventListener("click", () => {
banner.hidden = true;
try {
localStorage.setItem(key, "true");
} catch {
// Dismissal persistence is a convenience only.
}
});
})();
(() => {
const modalElement = document.getElementById("plotlineConfirmModal");
const modal = modalElement && window.bootstrap ? new bootstrap.Modal(modalElement) : null;

File diff suppressed because one or more lines are too long