Phase 5B Project Subscription Infrastructure
This commit is contained in:
parent
90b2212890
commit
49f239b301
@ -12,7 +12,8 @@ public sealed class ManageAccountController(
|
||||
ICurrentUserService currentUser,
|
||||
IUserRepository users,
|
||||
IAuthService authService,
|
||||
ITwoFactorService twoFactorService) : Controller
|
||||
ITwoFactorService twoFactorService,
|
||||
ISubscriptionService subscriptions) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
@ -23,7 +24,11 @@ public sealed class ManageAccountController(
|
||||
return RedirectToAction("Login", "Account");
|
||||
}
|
||||
|
||||
return View(user);
|
||||
return View(new ManageAccountViewModel
|
||||
{
|
||||
User = user,
|
||||
Subscription = await subscriptions.GetCurrentSubscriptionAsync(user.UserID)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
|
||||
42
PlotLine/Data/SubscriptionRepository.cs
Normal file
42
PlotLine/Data/SubscriptionRepository.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Data;
|
||||
|
||||
public interface ISubscriptionRepository
|
||||
{
|
||||
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
|
||||
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
|
||||
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
|
||||
}
|
||||
|
||||
public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository
|
||||
{
|
||||
public async Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<UserSubscriptionDetail>(
|
||||
"dbo.UserSubscription_GetCurrent",
|
||||
new { UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<SubscriptionLevel>(
|
||||
"dbo.SubscriptionLevel_Get",
|
||||
new { SubscriptionLevelID = subscriptionLevelId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync()
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<SubscriptionLevel>(
|
||||
"dbo.SubscriptionLevel_ListActive",
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
}
|
||||
48
PlotLine/Models/SubscriptionModels.cs
Normal file
48
PlotLine/Models/SubscriptionModels.cs
Normal file
@ -0,0 +1,48 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public sealed class SubscriptionLevel
|
||||
{
|
||||
public int SubscriptionLevelID { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int MaxBooks { get; set; }
|
||||
public int MaxChaptersPerBook { get; set; }
|
||||
public int MaxScenesPerBook { get; set; }
|
||||
public int MaxCharactersPerProject { get; set; }
|
||||
public int MaxStorageMB { get; set; }
|
||||
public int MaxCollaborators { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public DateTime CreatedDateUTC { get; set; }
|
||||
public DateTime ModifiedDateUTC { get; set; }
|
||||
}
|
||||
|
||||
public class UserSubscription
|
||||
{
|
||||
public int UserSubscriptionID { get; set; }
|
||||
public int UserID { get; set; }
|
||||
public int SubscriptionLevelID { get; set; }
|
||||
public DateTime StartDateUTC { get; set; }
|
||||
public DateTime? EndDateUTC { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsTrial { get; set; }
|
||||
public DateTime? TrialExpiryDateUTC { get; set; }
|
||||
public DateTime CreatedDateUTC { get; set; }
|
||||
public DateTime ModifiedDateUTC { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserSubscriptionDetail : UserSubscription
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int MaxBooks { get; set; }
|
||||
public int MaxChaptersPerBook { get; set; }
|
||||
public int MaxScenesPerBook { get; set; }
|
||||
public int MaxCharactersPerProject { get; set; }
|
||||
public int MaxStorageMB { get; set; }
|
||||
public int MaxCollaborators { get; set; }
|
||||
public bool SubscriptionLevelIsActive { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@ -103,6 +103,7 @@ public class Program
|
||||
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
|
||||
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||
builder.Services.AddScoped<IBookService, BookService>();
|
||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||
@ -128,6 +129,7 @@ public class Program
|
||||
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
|
||||
builder.Services.AddScoped<IImportService, ImportService>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddSingleton<IHelpService, HelpService>();
|
||||
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
|
||||
23
PlotLine/Services/SubscriptionServices.cs
Normal file
23
PlotLine/Services/SubscriptionServices.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface ISubscriptionService
|
||||
{
|
||||
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
|
||||
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
|
||||
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
|
||||
}
|
||||
|
||||
public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : ISubscriptionService
|
||||
{
|
||||
public Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId) =>
|
||||
subscriptions.GetCurrentSubscriptionAsync(userId);
|
||||
|
||||
public Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId) =>
|
||||
subscriptions.GetSubscriptionLevelAsync(subscriptionLevelId);
|
||||
|
||||
public Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync() =>
|
||||
subscriptions.GetActiveSubscriptionLevelsAsync();
|
||||
}
|
||||
232
PlotLine/Sql/043_Phase5B_SubscriptionInfrastructure.sql
Normal file
232
PlotLine/Sql/043_Phase5B_SubscriptionInfrastructure.sql
Normal file
@ -0,0 +1,232 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.SubscriptionLevel', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.SubscriptionLevel
|
||||
(
|
||||
SubscriptionLevelID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_SubscriptionLevel PRIMARY KEY,
|
||||
Name nvarchar(100) NOT NULL,
|
||||
DisplayName nvarchar(100) NOT NULL,
|
||||
Description nvarchar(500) NULL,
|
||||
MaxBooks int NOT NULL,
|
||||
MaxChaptersPerBook int NOT NULL,
|
||||
MaxScenesPerBook int NOT NULL,
|
||||
MaxCharactersPerProject int NOT NULL,
|
||||
MaxStorageMB int NOT NULL,
|
||||
MaxCollaborators int NOT NULL,
|
||||
IsActive bit NOT NULL CONSTRAINT DF_SubscriptionLevel_IsActive DEFAULT 1,
|
||||
SortOrder int NOT NULL,
|
||||
CreatedDateUTC datetime2 NOT NULL CONSTRAINT DF_SubscriptionLevel_CreatedDateUTC DEFAULT SYSUTCDATETIME(),
|
||||
ModifiedDateUTC datetime2 NOT NULL CONSTRAINT DF_SubscriptionLevel_ModifiedDateUTC DEFAULT SYSUTCDATETIME()
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SubscriptionLevel_Name' AND object_id = OBJECT_ID(N'dbo.SubscriptionLevel'))
|
||||
CREATE UNIQUE INDEX UX_SubscriptionLevel_Name ON dbo.SubscriptionLevel(Name);
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.UserSubscription', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.UserSubscription
|
||||
(
|
||||
UserSubscriptionID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_UserSubscription PRIMARY KEY,
|
||||
UserID int NOT NULL,
|
||||
SubscriptionLevelID int NOT NULL,
|
||||
StartDateUTC datetime2 NOT NULL CONSTRAINT DF_UserSubscription_StartDateUTC DEFAULT SYSUTCDATETIME(),
|
||||
EndDateUTC datetime2 NULL,
|
||||
IsActive bit NOT NULL CONSTRAINT DF_UserSubscription_IsActive DEFAULT 1,
|
||||
IsTrial bit NOT NULL CONSTRAINT DF_UserSubscription_IsTrial DEFAULT 0,
|
||||
TrialExpiryDateUTC datetime2 NULL,
|
||||
CreatedDateUTC datetime2 NOT NULL CONSTRAINT DF_UserSubscription_CreatedDateUTC DEFAULT SYSUTCDATETIME(),
|
||||
ModifiedDateUTC datetime2 NOT NULL CONSTRAINT DF_UserSubscription_ModifiedDateUTC DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT FK_UserSubscription_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
|
||||
CONSTRAINT FK_UserSubscription_SubscriptionLevel FOREIGN KEY (SubscriptionLevelID) REFERENCES dbo.SubscriptionLevel(SubscriptionLevelID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_UserSubscription_User_Active' AND object_id = OBJECT_ID(N'dbo.UserSubscription'))
|
||||
CREATE UNIQUE INDEX UX_UserSubscription_User_Active ON dbo.UserSubscription(UserID) WHERE IsActive = 1;
|
||||
GO
|
||||
|
||||
MERGE dbo.SubscriptionLevel AS target
|
||||
USING (VALUES
|
||||
(N'DraftDesk', N'Draft Desk', N'Trial and evaluation.', 2, 75, 300, 75, 100, 0, 1, 10),
|
||||
(N'WritingRoom', N'Writing Room', N'Standard subscription for most authors.', 10, 150, 1000, 250, 1024, 1, 1, 20),
|
||||
(N'StoryForge', N'Story Forge', N'Professional authors and co-authors.', 30, 200, 2000, 500, 5120, 3, 1, 30),
|
||||
(N'AuthorStudio', N'Author Studio', N'Advanced users and writing teams.', 999999, 999999, 999999, 999999, 20480, 10, 1, 40)
|
||||
) AS source
|
||||
(
|
||||
Name,
|
||||
DisplayName,
|
||||
Description,
|
||||
MaxBooks,
|
||||
MaxChaptersPerBook,
|
||||
MaxScenesPerBook,
|
||||
MaxCharactersPerProject,
|
||||
MaxStorageMB,
|
||||
MaxCollaborators,
|
||||
IsActive,
|
||||
SortOrder
|
||||
)
|
||||
ON target.Name = source.Name
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
DisplayName = source.DisplayName,
|
||||
Description = source.Description,
|
||||
MaxBooks = source.MaxBooks,
|
||||
MaxChaptersPerBook = source.MaxChaptersPerBook,
|
||||
MaxScenesPerBook = source.MaxScenesPerBook,
|
||||
MaxCharactersPerProject = source.MaxCharactersPerProject,
|
||||
MaxStorageMB = source.MaxStorageMB,
|
||||
MaxCollaborators = source.MaxCollaborators,
|
||||
IsActive = source.IsActive,
|
||||
SortOrder = source.SortOrder,
|
||||
ModifiedDateUTC = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT
|
||||
(
|
||||
Name,
|
||||
DisplayName,
|
||||
Description,
|
||||
MaxBooks,
|
||||
MaxChaptersPerBook,
|
||||
MaxScenesPerBook,
|
||||
MaxCharactersPerProject,
|
||||
MaxStorageMB,
|
||||
MaxCollaborators,
|
||||
IsActive,
|
||||
SortOrder
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
source.Name,
|
||||
source.DisplayName,
|
||||
source.Description,
|
||||
source.MaxBooks,
|
||||
source.MaxChaptersPerBook,
|
||||
source.MaxScenesPerBook,
|
||||
source.MaxCharactersPerProject,
|
||||
source.MaxStorageMB,
|
||||
source.MaxCollaborators,
|
||||
source.IsActive,
|
||||
source.SortOrder
|
||||
);
|
||||
GO
|
||||
|
||||
DECLARE @AuthorStudioID int;
|
||||
SELECT @AuthorStudioID = SubscriptionLevelID
|
||||
FROM dbo.SubscriptionLevel
|
||||
WHERE Name = N'AuthorStudio';
|
||||
|
||||
IF @AuthorStudioID IS NOT NULL
|
||||
BEGIN
|
||||
INSERT INTO dbo.UserSubscription (UserID, SubscriptionLevelID, StartDateUTC, IsActive, IsTrial)
|
||||
SELECT u.UserID, @AuthorStudioID, SYSUTCDATETIME(), 1, 0
|
||||
FROM dbo.AppUser u
|
||||
WHERE NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.UserSubscription us
|
||||
WHERE us.UserID = u.UserID
|
||||
AND us.IsActive = 1
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SubscriptionLevel_ListActive
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT SubscriptionLevelID, Name, DisplayName, Description, MaxBooks, MaxChaptersPerBook,
|
||||
MaxScenesPerBook, MaxCharactersPerProject, MaxStorageMB, MaxCollaborators, IsActive,
|
||||
SortOrder, CreatedDateUTC, ModifiedDateUTC
|
||||
FROM dbo.SubscriptionLevel
|
||||
WHERE IsActive = 1
|
||||
ORDER BY SortOrder, DisplayName;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SubscriptionLevel_Get
|
||||
@SubscriptionLevelID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT SubscriptionLevelID, Name, DisplayName, Description, MaxBooks, MaxChaptersPerBook,
|
||||
MaxScenesPerBook, MaxCharactersPerProject, MaxStorageMB, MaxCollaborators, IsActive,
|
||||
SortOrder, CreatedDateUTC, ModifiedDateUTC
|
||||
FROM dbo.SubscriptionLevel
|
||||
WHERE SubscriptionLevelID = @SubscriptionLevelID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.UserSubscription_GetCurrent
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT us.UserSubscriptionID, us.UserID, us.SubscriptionLevelID, us.StartDateUTC, us.EndDateUTC,
|
||||
us.IsActive, us.IsTrial, us.TrialExpiryDateUTC, us.CreatedDateUTC, us.ModifiedDateUTC,
|
||||
sl.Name, sl.DisplayName, sl.Description, sl.MaxBooks, sl.MaxChaptersPerBook,
|
||||
sl.MaxScenesPerBook, sl.MaxCharactersPerProject, sl.MaxStorageMB, sl.MaxCollaborators,
|
||||
sl.IsActive AS SubscriptionLevelIsActive, sl.SortOrder
|
||||
FROM dbo.UserSubscription us
|
||||
INNER JOIN dbo.SubscriptionLevel sl ON sl.SubscriptionLevelID = us.SubscriptionLevelID
|
||||
WHERE us.UserID = @UserID
|
||||
AND us.IsActive = 1
|
||||
AND (us.EndDateUTC IS NULL OR us.EndDateUTC > SYSUTCDATETIME());
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.UserSubscription_AssignDefaultDraftDesk
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @DraftDeskID int;
|
||||
SELECT @DraftDeskID = SubscriptionLevelID
|
||||
FROM dbo.SubscriptionLevel
|
||||
WHERE Name = N'DraftDesk';
|
||||
|
||||
IF @DraftDeskID IS NULL
|
||||
RETURN;
|
||||
|
||||
IF NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.UserSubscription
|
||||
WHERE UserID = @UserID
|
||||
AND IsActive = 1
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO dbo.UserSubscription (UserID, SubscriptionLevelID, StartDateUTC, IsActive, IsTrial)
|
||||
VALUES (@UserID, @DraftDeskID, SYSUTCDATETIME(), 1, 0);
|
||||
END;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.User_Create
|
||||
@Email nvarchar(256),
|
||||
@DisplayName nvarchar(200),
|
||||
@PasswordHash nvarchar(max),
|
||||
@EmailConfirmed bit = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
INSERT INTO dbo.AppUser (Email, DisplayName, PasswordHash, EmailConfirmed)
|
||||
VALUES (@Email, @DisplayName, @PasswordHash, @EmailConfirmed);
|
||||
|
||||
DECLARE @UserID int = SCOPE_IDENTITY();
|
||||
EXEC dbo.UserSubscription_AssignDefaultDraftDesk @UserID = @UserID;
|
||||
|
||||
EXEC dbo.User_GetById @UserID = @UserID;
|
||||
END;
|
||||
GO
|
||||
@ -1,7 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.ViewModels;
|
||||
|
||||
public sealed class ManageAccountViewModel
|
||||
{
|
||||
public AppUser User { get; set; } = new();
|
||||
public UserSubscriptionDetail? Subscription { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterViewModel
|
||||
{
|
||||
[Required(ErrorMessage = "Enter your email address.")]
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
@model AppUser
|
||||
@model ManageAccountViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Manage Account";
|
||||
var subscription = Model.Subscription;
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Manage Account</h1>
|
||||
<p class="lead-text">@Model.Email</p>
|
||||
<p class="lead-text">@Model.User.Email</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -21,15 +22,15 @@
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Email address</p>
|
||||
<p>@Model.Email</p>
|
||||
<p>@Model.User.Email</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Email verified</p>
|
||||
<p>@(Model.EmailConfirmed ? "Yes" : "No")</p>
|
||||
<p>@(Model.User.EmailConfirmed ? "Yes" : "No")</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Two-factor authentication</p>
|
||||
<p>@(Model.TwoFactorEnabled ? "Enabled" : "Not enabled")</p>
|
||||
<p>@(Model.User.TwoFactorEnabled ? "Enabled" : "Not enabled")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row mb-4">
|
||||
@ -39,7 +40,7 @@
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
<p class="muted">Use an authenticator app to add an extra sign-in step for your account.</p>
|
||||
<div class="button-row">
|
||||
@if (Model.TwoFactorEnabled)
|
||||
@if (Model.User.TwoFactorEnabled)
|
||||
{
|
||||
<a class="btn btn-outline-primary" asp-action="RecoveryCodes">Recovery Codes</a>
|
||||
<a class="btn btn-outline-danger" asp-action="DisableTwoFactor">Disable Two Factor</a>
|
||||
@ -50,3 +51,50 @@
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="edit-panel">
|
||||
<h2>Subscription</h2>
|
||||
@if (subscription is null)
|
||||
{
|
||||
<p class="muted">No active subscription is assigned.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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">Books</p>
|
||||
<p>@FormatLimit(subscription.MaxBooks)</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Storage</p>
|
||||
<p>@FormatStorage(subscription.MaxStorageMB)</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Chapters per book</p>
|
||||
<p>@FormatLimit(subscription.MaxChaptersPerBook)</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Scenes per book</p>
|
||||
<p>@FormatLimit(subscription.MaxScenesPerBook)</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Characters per project</p>
|
||||
<p>@FormatLimit(subscription.MaxCharactersPerProject)</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="eyebrow">Collaborators</p>
|
||||
<p>@subscription.MaxCollaborators</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@functions {
|
||||
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";
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user