From d4c886ce2f8d6b16acfe7750a66fa55101bc6ce4 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 18 Jun 2026 20:54:18 +0100 Subject: [PATCH] Review the PlotDirector subscription, pricing, signup and account-management areas and implement a complete trial-visibility pass. --- .../Controllers/ManageAccountController.cs | 5 +- PlotLine/Models/SubscriptionModels.cs | 1 + PlotLine/Services/CoreServices.cs | 4 +- PlotLine/ViewModels/AuthViewModels.cs | 68 +++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 1 + PlotLine/Views/Account/Register.cshtml | 11 ++- PlotLine/Views/ManageAccount/Index.cshtml | 31 +++++++ .../Views/ManageAccount/Subscription.cshtml | 34 +++++++- PlotLine/Views/Pricing/Index.cshtml | 28 +++++-- PlotLine/Views/Projects/Index.cshtml | 23 +++++ PlotLine/wwwroot/css/plotline-theme.css | 57 +++++++++++++ PlotLine/wwwroot/css/plotline-theme.min.css | 2 +- PlotLine/wwwroot/css/site.css | 84 +++++++++++++++++++ PlotLine/wwwroot/css/site.min.css | 2 +- PlotLine/wwwroot/js/site.js | 26 ++++++ PlotLine/wwwroot/js/site.min.js | 2 +- 16 files changed, 362 insertions(+), 17 deletions(-) diff --git a/PlotLine/Controllers/ManageAccountController.cs b/PlotLine/Controllers/ManageAccountController.cs index 5c00328..5cecc9b 100644 --- a/PlotLine/Controllers/ManageAccountController.cs +++ b/PlotLine/Controllers/ManageAccountController.cs @@ -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() }); diff --git a/PlotLine/Models/SubscriptionModels.cs b/PlotLine/Models/SubscriptionModels.cs index d7f976b..e52ee26 100644 --- a/PlotLine/Models/SubscriptionModels.cs +++ b/PlotLine/Models/SubscriptionModels.cs @@ -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 diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index da4a13d..1b3676b 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -372,6 +372,7 @@ public sealed class ProjectService( IWarningRepository warnings, IWritingScheduleRepository writingSchedule, IProjectActivityService activity, + ISubscriptionService subscriptions, ICurrentUserService currentUser, IHardDeleteFileCleanupService fileCleanup, ILogger 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) }; } diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index 0b481ca..9132c92 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -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 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 Plans { get; set; } = []; diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 6e996a9..1fbe22b 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -25,6 +25,7 @@ public sealed class ProjectIndexViewModel { public IReadOnlyList ActiveProjects { get; init; } = []; public IReadOnlyList ArchivedProjects { get; init; } = []; + public TrialVisibilityViewModel? Trial { get; init; } } public sealed class ProjectDetailViewModel diff --git a/PlotLine/Views/Account/Register.cshtml b/PlotLine/Views/Account/Register.cshtml index 556f733..47a2654 100644 --- a/PlotLine/Views/Account/Register.cshtml +++ b/PlotLine/Views/Account/Register.cshtml @@ -7,10 +7,19 @@

Account

Create your account

-

Create a PlotDirector account so your workspace can use the new authentication foundation.

+

Create your account to start a free 30-day PlotDirector trial immediately. No payment details are required.

+ +
diff --git a/PlotLine/Views/ManageAccount/Index.cshtml b/PlotLine/Views/ManageAccount/Index.cshtml index efaafba..87a9c79 100644 --- a/PlotLine/Views/ManageAccount/Index.cshtml +++ b/PlotLine/Views/ManageAccount/Index.cshtml @@ -2,6 +2,7 @@ @{ ViewData["Title"] = "Manage Account"; var subscription = Model.Subscription; + var trial = Model.Trial; }
@@ -64,11 +65,39 @@ } else { + @if (trial is not null) + { +
+
+
+

Free trial status

+

@trial.StatusLabel

+
+ Upgrade +
+

Your 30-day @trial.PlanName trial expires on @FormatDate(trial.ExpiryDateUtc).

+
+
+
+
+ }

Current plan

@subscription.DisplayName

+
+

Trial start

+

@(trial is null ? "Not on trial" : FormatDate(trial.StartDateUtc))

+
+
+

Trial expiry

+

@(trial is null ? "Not on trial" : FormatDate(trial.ExpiryDateUtc))

+
+
+

Days remaining

+

@(trial is null ? "Not on trial" : trial.StatusLabel)

+

Books

@FormatLimit(subscription.MaxBooks)

@@ -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 { diff --git a/PlotLine/Views/ManageAccount/Subscription.cshtml b/PlotLine/Views/ManageAccount/Subscription.cshtml index 6f8818e..07b8f6a 100644 --- a/PlotLine/Views/ManageAccount/Subscription.cshtml +++ b/PlotLine/Views/ManageAccount/Subscription.cshtml @@ -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) + { +
+
+
+

Free trial status

+

@trial.StatusLabel

+
+ Upgrade +
+

Your 30-day @trial.PlanName trial started on @FormatDate(trial.StartDateUtc) and expires on @FormatDate(trial.ExpiryDateUtc).

+
+
+
+

@trial.DaysElapsed days used, @trial.DaysRemaining days remaining.

+
+ }

Plan

@@ -53,11 +71,19 @@

Trial

-

@(subscription.IsTrial ? "Trial" : "No")

+

@(trial is null ? "No" : "30-day free trial")

+
+
+

Trial start

+

@(trial is null ? "Not set" : FormatDate(trial.StartDateUtc))

Trial expiry

-

@FormatDate(subscription.TrialExpiryDateUTC)

+

@(trial is null ? FormatDate(subscription.TrialExpiryDateUTC) : FormatDate(trial.ExpiryDateUtc))

+
+
+

Days remaining

+

@(trial is null ? "Not on trial" : trial.StatusLabel)

Started

@@ -160,7 +186,7 @@

@(string.IsNullOrWhiteSpace(level.Description) ? "No description available." : level.Description)

@if (!level.RequiresStripeSubscription) { -

Free / trial / application managed

+

30-day free trial / application managed

} else { @@ -197,7 +223,7 @@ @if (!level.RequiresStripeSubscription) { -

Draft Desk is managed inside PlotDirector.

+

Draft Desk is a 30-day free trial. No payment details are required.

} else { diff --git a/PlotLine/Views/Pricing/Index.cshtml b/PlotLine/Views/Pricing/Index.cshtml index 2fcb45e..fce367a 100644 --- a/PlotLine/Views/Pricing/Index.cshtml +++ b/PlotLine/Views/Pricing/Index.cshtml @@ -35,7 +35,7 @@

Pricing

Simple pricing for stories of every size.

-

Start free, subscribe when you're ready, or choose a plan straight away.

+

Start with a 30-day free trial. No payment details are required until you choose a paid plan.

Start Free Trial Compare Plans @@ -71,7 +71,12 @@ { var isCurrent = Model.CurrentSubscriptionLevelID == plan.SubscriptionLevelID; var isSuggested = suggestedPlanId == plan.SubscriptionLevelID; -
+ var isTrialPlan = !plan.RequiresStripeSubscription; +
+ @if (isTrialPlan) + { +

30-day free trial

+ } @if (isSuggested) {

Most Popular

@@ -94,12 +99,17 @@ else { Free - trial workspace - Assigned when you create an account. + 30-day trial workspace + No payment details required. Starts immediately after registration. }
    + @if (!plan.RequiresStripeSubscription) + { +
  • 30 days of free access
  • +
  • No credit card required
  • + }
  • @FormatLimit(plan.MaxBooks) books
  • @FormatLimit(plan.MaxCharactersPerProject) characters per project
  • @FormatLimit(plan.MaxScenesPerBook) scenes per book
  • @@ -166,7 +176,7 @@

    Can I start with a free trial?

    -

    Yes. Creating an account starts you on the Draft Desk evaluation plan, so you can try the workspace before choosing a paid subscription.

    +

    Yes. Creating an account starts a 30-day Draft Desk trial immediately, so you can try the workspace before choosing a paid subscription.

    Can I subscribe without using the trial?

    @@ -186,7 +196,11 @@

    Do I need a credit card for the trial?

    -

    The registration flow creates the evaluation workspace. Payment details are only handled when you choose a paid subscription through Stripe Checkout.

    +

    No. Payment details are not required for the free trial. Stripe Checkout is only used when you choose a paid subscription.

    +
    +
    +

    What happens when the trial expires?

    +

    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.

@@ -196,7 +210,7 @@

Begin with clarity

Ready to take control of your story?

-

Try PlotDirector free, or choose the paid plan that fits the novel you are building.

+

Try PlotDirector free for 30 days with no payment details, or choose the paid plan that fits the novel you are building.

Start Free Trial View Plans diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index fa9c320..09fc4f5 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -1,6 +1,7 @@ @model ProjectIndexViewModel @{ ViewData["Title"] = "Projects"; + var trial = Model.Trial; }
@@ -14,6 +15,24 @@
+@if (trial is not null) +{ +
+
+

Free trial status

+

@trial.StatusLabel on @trial.PlanName

+

Your 30-day trial expires on @FormatDate(trial.ExpiryDateUtc). Upgrade whenever you are ready.

+
+
+ Upgrade + +
+
+} + @if (TempData["ArchiveMessage"] is string archiveMessage) {
@archiveMessage
@@ -140,6 +159,10 @@ else
} +@functions { + private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd"); +} + @section Scripts {