From 864d9089a7dff3f1b9938a2abf8c76b785945d94 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 7 Jun 2026 21:11:44 +0100 Subject: [PATCH] Pricing page creation --- PlotLine/Controllers/PricingController.cs | 79 +++++++ PlotLine/ViewModels/AuthViewModels.cs | 7 + PlotLine/Views/Pricing/Index.cshtml | 211 ++++++++++++++++++ PlotLine/Views/Shared/_Layout.cshtml | 3 + PlotLine/wwwroot/css/plotline-theme.css | 251 +++++++++++++++++++++- 5 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 PlotLine/Controllers/PricingController.cs create mode 100644 PlotLine/Views/Pricing/Index.cshtml diff --git a/PlotLine/Controllers/PricingController.cs b/PlotLine/Controllers/PricingController.cs new file mode 100644 index 0000000..9998ba1 --- /dev/null +++ b/PlotLine/Controllers/PricingController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.Services; +using PlotLine.ViewModels; + +namespace PlotLine.Controllers; + +[AllowAnonymous] +public sealed class PricingController( + ICurrentUserService currentUser, + IUserRepository users, + ISubscriptionService subscriptions, + IStripeBillingService stripeBilling) : Controller +{ + [HttpGet] + public async Task Index() + { + UserSubscriptionDetail? currentSubscription = null; + if (currentUser.UserId is int userId) + { + currentSubscription = await subscriptions.GetCurrentSubscriptionAsync(userId); + } + + return View(new PricingViewModel + { + Plans = await subscriptions.GetActiveSubscriptionLevelsAsync(), + CurrentSubscriptionLevelID = currentSubscription?.SubscriptionLevelID, + IsSignedIn = currentUser.UserId.HasValue + }); + } + + [HttpGet] + public IActionResult StartTrial() + { + if (!currentUser.UserId.HasValue) + { + return RedirectToAction("Register", "Account"); + } + + return RedirectToAction("Subscription", "ManageAccount"); + } + + [HttpGet] + public async Task Checkout(int id, string billingInterval = "Monthly") + { + if (!currentUser.UserId.HasValue) + { + var returnUrl = Url.Action(nameof(Checkout), "Pricing", new { id, billingInterval }) ?? "/Pricing"; + return RedirectToAction("Login", "Account", new { returnUrl }); + } + + var user = await users.GetByIdAsync(currentUser.UserId.Value); + if (user is null) + { + return RedirectToAction("Login", "Account"); + } + + var request = new StripeCheckoutRequest + { + SubscriptionLevelID = id, + BillingInterval = string.Equals(billingInterval, "Annual", StringComparison.OrdinalIgnoreCase) + ? "Annual" + : "Monthly" + }; + + var successUrl = Url.Action("SubscriptionSuccess", "ManageAccount", null, Request.Scheme) ?? string.Empty; + var cancelUrl = Url.Action(nameof(Index), "Pricing", null, Request.Scheme) ?? string.Empty; + var result = await stripeBilling.StartCheckoutOrChangePlanAsync(user, request, successUrl, cancelUrl); + if (result.Succeeded && !string.IsNullOrWhiteSpace(result.RedirectUrl)) + { + return Redirect(result.RedirectUrl); + } + + TempData["PricingMessage"] = result.Message; + return RedirectToAction(nameof(Index)); + } +} diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index 74a05e9..cd9cba3 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -17,6 +17,13 @@ public sealed class ManageSubscriptionViewModel public IReadOnlyList AvailableLevels { get; set; } = []; } +public sealed class PricingViewModel +{ + public IReadOnlyList Plans { get; set; } = []; + public int? CurrentSubscriptionLevelID { get; set; } + public bool IsSignedIn { get; set; } +} + public sealed class RegisterViewModel { [Required(ErrorMessage = "Enter your email address.")] diff --git a/PlotLine/Views/Pricing/Index.cshtml b/PlotLine/Views/Pricing/Index.cshtml new file mode 100644 index 0000000..73f88c5 --- /dev/null +++ b/PlotLine/Views/Pricing/Index.cshtml @@ -0,0 +1,211 @@ +@model PricingViewModel +@{ + ViewData["Title"] = "Pricing"; + ViewData["ShellClass"] = "marketing-shell"; + var paidPlans = Model.Plans.Where(x => x.RequiresStripeSubscription).ToList(); + var suggestedPlanId = paidPlans.Count >= 2 ? paidPlans[paidPlans.Count / 2].SubscriptionLevelID : (int?)null; +} + +
+
+
+

Pricing

+

Simple pricing for stories of every size.

+

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

+ +
+
+ + @if (TempData["PricingMessage"] is string pricingMessage) + { +
+
@pricingMessage
+
+ } + +
+
+
+

Plans

+

Choose the space your story needs.

+
+ + @if (!Model.Plans.Any()) + { +
+

Plans are being prepared.

+

Active subscription levels are not available yet. Please check back soon.

+
+ } + else + { +
+ @foreach (var plan in Model.Plans) + { + var isCurrent = Model.CurrentSubscriptionLevelID == plan.SubscriptionLevelID; + var isSuggested = suggestedPlanId == plan.SubscriptionLevelID; +
+ @if (isSuggested) + { +

Most Popular

+ } +
+

@plan.DisplayName

+

@PlanTagline(plan)

+
+ +
+ @if (plan.RequiresStripeSubscription) + { + @FormatMoney(plan.MonthlyPricePence, plan.CurrencyCode) + per month + @if (plan.AnnualPricePence.HasValue) + { + @FormatMoney(plan.AnnualPricePence, plan.CurrencyCode) billed annually + } + } + else + { + Free + trial workspace + Assigned when you create an account. + } +
+ +
    +
  • @FormatLimit(plan.MaxBooks) books
  • +
  • @FormatLimit(plan.MaxCharactersPerProject) characters per project
  • +
  • @FormatLimit(plan.MaxScenesPerBook) scenes per book
  • +
  • @FormatStorageAllowance(plan.MaxStorageMB) storage
  • +
  • @FormatLimit(plan.MaxCollaborators) collaborators
  • +
+ +
+ @if (!plan.RequiresStripeSubscription) + { + Start Free Trial + } + else + { + + @(isCurrent ? "Current Plan" : "Subscribe Now") + + @if (plan.AnnualPricePence.HasValue) + { + Choose annual billing + } + } +
+
+ } +
+ } +
+
+ +
+
+
+

What is included

+

Everything you need to keep a complex novel coherent.

+
+
+
+

Planning capacity

+

Database-backed limits for books, chapters, scenes, characters, storage, and collaborators scale with the plan you choose.

+
+
+

Story organisation

+

Characters, plot threads, story assets, locations, and the story bible help keep the moving pieces of your manuscript in one place.

+
+
+

Structure and flow

+

Timeline, scene metrics, story health, and analytics views help you understand pacing, pressure, and revision priorities.

+
+
+

Continuity confidence

+

Relationship mapping, continuity warnings, import, export, backups, and restore points support serious drafting and revision work.

+
+
+
+
+ +
+
+
+

Questions

+

Pricing FAQ

+
+
+
+

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.

+
+
+

Can I subscribe without using the trial?

+

Yes. Choose a paid plan from this page. If you are not signed in, PlotWeaver will ask you to log in before continuing to checkout.

+
+
+

Can I change plan later?

+

Yes. Signed-in users can manage subscription changes from the account subscription area.

+
+
+

What happens to my work if I cancel?

+

Your existing story data remains in PlotWeaver. Your account may return to the application-managed Draft Desk level after the paid subscription ends.

+
+
+

Is PlotWeaver suitable for a series?

+

Yes. The higher plan limits are designed for authors managing multiple books, long arcs, and larger casts.

+
+
+

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.

+
+
+
+
+ +
+
+

Begin with clarity

+

Ready to take control of your story?

+

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

+ +
+
+
+ +@functions { + private const int PracticalUnlimited = 999999; + + private static string FormatLimit(int value) => + value >= PracticalUnlimited ? "Unlimited" : value.ToString("N0"); + + private static string FormatStorageAllowance(int megabytes) => + megabytes >= PracticalUnlimited + ? "Unlimited" + : StorageUsage.FormatBytes(megabytes * 1024L * 1024L); + + private static string FormatMoney(int? pence, string? currencyCode) + { + if (!pence.HasValue) + { + return "Contact us"; + } + + return string.Equals(currencyCode, "GBP", StringComparison.OrdinalIgnoreCase) + ? $"GBP {pence.Value / 100m:0.00}" + : $"{pence.Value / 100m:0.00} {(string.IsNullOrWhiteSpace(currencyCode) ? "GBP" : currencyCode.ToUpperInvariant())}"; + } + + private static string PlanTagline(SubscriptionLevel plan) => + string.IsNullOrWhiteSpace(plan.Description) + ? plan.RequiresStripeSubscription ? "For authors ready to build with confidence." : "A calm place to begin." + : plan.Description; +} diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index 3478b52..a6c301a 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -37,6 +37,9 @@ + @if (User.Identity?.IsAuthenticated == true) {