From c8dc116862a356e05bac2a35cb193748d1b914c8 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 3 Jul 2026 21:04:06 +0100 Subject: [PATCH] Phase 20A Story Intelligence Onboarding. --- PlotLine/Controllers/OnboardingController.cs | 73 ++++++ PlotLine/Data/OnboardingRepository.cs | 82 +++++++ PlotLine/Models/OnboardingModels.cs | 40 ++++ PlotLine/Program.cs | 2 + PlotLine/Services/CoreServices.cs | 4 +- PlotLine/Services/OnboardingService.cs | 225 ++++++++++++++++++ ...1_Phase20A_StoryIntelligenceOnboarding.sql | 182 ++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 1 + PlotLine/ViewModels/OnboardingViewModels.cs | 40 ++++ PlotLine/Views/Onboarding/Index.cshtml | 137 +++++++++++ PlotLine/Views/Projects/Index.cshtml | 12 + PlotLine/Views/Shared/_Layout.cshtml | 13 +- PlotLine/wwwroot/css/onboarding.css | 183 ++++++++++++++ 13 files changed, 990 insertions(+), 4 deletions(-) create mode 100644 PlotLine/Controllers/OnboardingController.cs create mode 100644 PlotLine/Data/OnboardingRepository.cs create mode 100644 PlotLine/Models/OnboardingModels.cs create mode 100644 PlotLine/Services/OnboardingService.cs create mode 100644 PlotLine/Sql/111_Phase20A_StoryIntelligenceOnboarding.sql create mode 100644 PlotLine/ViewModels/OnboardingViewModels.cs create mode 100644 PlotLine/Views/Onboarding/Index.cshtml create mode 100644 PlotLine/wwwroot/css/onboarding.css diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs new file mode 100644 index 0000000..83422bb --- /dev/null +++ b/PlotLine/Controllers/OnboardingController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PlotLine.Models; +using PlotLine.Services; +using PlotLine.ViewModels; + +namespace PlotLine.Controllers; + +[Authorize] +[Route("onboarding")] +public sealed class OnboardingController(IOnboardingService onboarding) : Controller +{ + [HttpGet("")] + public async Task Index() + { + var model = await onboarding.GetWizardAsync(); + return View(model); + } + + [HttpPost("welcome")] + [ValidateAntiForgeryToken] + public async Task Welcome() + { + await onboarding.MoveToNextStepAsync(); + return RedirectToAction(nameof(Index)); + } + + [HttpPost("writing-journey")] + [ValidateAntiForgeryToken] + public async Task WritingJourney(WritingJourneyOnboardingForm form) + { + if (!ModelState.IsValid || string.IsNullOrWhiteSpace(form.WritingJourney)) + { + var model = await onboarding.GetWizardAsync(); + model.CurrentStep = OnboardingSteps.WritingJourney; + model.StepNumber = 2; + return View(nameof(Index), model); + } + + await onboarding.SaveWritingJourneyAsync(form.WritingJourney); + return RedirectToAction(nameof(Index)); + } + + [HttpPost("writing-software")] + [ValidateAntiForgeryToken] + public async Task WritingSoftware(WritingSoftwareOnboardingForm form) + { + if (!ModelState.IsValid || string.IsNullOrWhiteSpace(form.WritingSoftware)) + { + var model = await onboarding.GetWizardAsync(); + model.CurrentStep = OnboardingSteps.WritingSoftware; + model.StepNumber = 3; + return View(nameof(Index), model); + } + + await onboarding.SaveWritingSoftwareAsync(form.WritingSoftware); + TempData["ArchiveMessage"] = "Setup saved. PlotDirector will use those answers to guide future story setup."; + return RedirectToAction("Index", "Projects"); + } + + [HttpPost("back")] + [ValidateAntiForgeryToken] + public async Task Back(string currentStep) + { + var targetStep = currentStep == OnboardingSteps.WritingSoftware + ? OnboardingSteps.WritingJourney + : OnboardingSteps.Welcome; + + await onboarding.SetCurrentStepAsync(targetStep); + + return RedirectToAction(nameof(Index)); + } +} diff --git a/PlotLine/Data/OnboardingRepository.cs b/PlotLine/Data/OnboardingRepository.cs new file mode 100644 index 0000000..74f878a --- /dev/null +++ b/PlotLine/Data/OnboardingRepository.cs @@ -0,0 +1,82 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IOnboardingRepository +{ + Task GetAsync(int userId); + Task GetOrCreateAsync(int userId); + Task SetCurrentStepAsync(int userId, string currentStep); + Task SaveWritingJourneyAsync(int userId, string writingJourney); + Task SaveWritingSoftwareAsync(int userId, string writingSoftware); + Task MarkCompletedAsync(int userId); + Task ShouldShowWordCompanionHeaderAsync(int userId); +} + +public sealed class OnboardingRepository(ISqlConnectionFactory connectionFactory) : IOnboardingRepository +{ + public async Task GetAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.UserOnboarding_Get", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetOrCreateAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_GetOrCreate", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task SetCurrentStepAsync(int userId, string currentStep) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_SetCurrentStep", + new { UserID = userId, CurrentStep = currentStep }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveWritingJourneyAsync(int userId, string writingJourney) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_SaveWritingJourney", + new { UserID = userId, WritingJourney = writingJourney }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveWritingSoftwareAsync(int userId, string writingSoftware) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_SaveWritingSoftware", + new { UserID = userId, WritingSoftware = writingSoftware }, + commandType: CommandType.StoredProcedure); + } + + public async Task MarkCompletedAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_MarkCompleted", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task ShouldShowWordCompanionHeaderAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_ShouldShowWordCompanionHeader", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Models/OnboardingModels.cs b/PlotLine/Models/OnboardingModels.cs new file mode 100644 index 0000000..b891393 --- /dev/null +++ b/PlotLine/Models/OnboardingModels.cs @@ -0,0 +1,40 @@ +namespace PlotLine.Models; + +public static class OnboardingSteps +{ + public const string Welcome = "Welcome"; + public const string WritingJourney = "WritingJourney"; + public const string WritingSoftware = "WritingSoftware"; + public const string Complete = "Complete"; +} + +public static class WritingJourneyValues +{ + public const string PlanningNewStory = "PlanningNewStory"; + public const string AlreadyStarted = "AlreadyStarted"; + public const string ManuscriptMostlyComplete = "ManuscriptMostlyComplete"; +} + +public static class WritingSoftwareValues +{ + public const string MicrosoftWord = "MicrosoftWord"; + public const string GoogleDocs = "GoogleDocs"; + public const string Scrivener = "Scrivener"; + public const string LibreOfficeOpenOffice = "LibreOfficeOpenOffice"; + public const string MarkdownObsidian = "MarkdownObsidian"; + public const string Other = "Other"; +} + +public sealed class UserOnboardingState +{ + public int UserOnboardingStateID { get; set; } + public int UserID { get; set; } + public DateTime OnboardingStartedUtc { get; set; } + public DateTime? OnboardingCompletedUtc { get; set; } + public string CurrentStep { get; set; } = OnboardingSteps.Welcome; + public string? WritingJourney { get; set; } + public string? WritingSoftware { get; set; } + public bool? UsesWordCompanion { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 7804d24..9cb1cca 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -128,6 +128,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -169,6 +170,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 9a4f625..90fa11c 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -427,6 +427,7 @@ public sealed class ProjectService( IWritingScheduleRepository writingSchedule, IProjectActivityService activity, ISubscriptionService subscriptions, + IOnboardingService onboarding, ICurrentUserService currentUser, IHardDeleteFileCleanupService fileCleanup, ILogger logger) : IProjectService @@ -449,7 +450,8 @@ public sealed class ProjectService( { ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(), ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(), - Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow) + Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow), + OnboardingNudge = await onboarding.GetDashboardNudgeAsync() }; } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs new file mode 100644 index 0000000..c0a3859 --- /dev/null +++ b/PlotLine/Services/OnboardingService.cs @@ -0,0 +1,225 @@ +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IOnboardingService +{ + Task GetOrCreateAsync(); + Task GetWizardAsync(); + Task ShouldShowOnboardingAsync(); + Task GetDashboardNudgeAsync(); + Task ShouldShowWordCompanionHeaderAsync(); + Task MoveToNextStepAsync(); + Task SetCurrentStepAsync(string currentStep); + Task SaveWritingJourneyAsync(string writingJourney); + Task SaveWritingSoftwareAsync(string writingSoftware); + Task MarkCompletedAsync(); +} + +public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrentUserService currentUser) : IOnboardingService +{ + private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) + { + WritingJourneyValues.PlanningNewStory, + WritingJourneyValues.AlreadyStarted, + WritingJourneyValues.ManuscriptMostlyComplete + }; + + private static readonly HashSet WritingSoftware = new(StringComparer.Ordinal) + { + WritingSoftwareValues.MicrosoftWord, + WritingSoftwareValues.GoogleDocs, + WritingSoftwareValues.Scrivener, + WritingSoftwareValues.LibreOfficeOpenOffice, + WritingSoftwareValues.MarkdownObsidian, + WritingSoftwareValues.Other + }; + + public async Task GetOrCreateAsync() + => await onboarding.GetOrCreateAsync(RequireUserId()); + + public async Task GetWizardAsync() + { + var state = await GetOrCreateAsync(); + var step = ResolveStep(state); + + return new OnboardingWizardViewModel + { + CurrentStep = step, + StepNumber = StepNumber(step), + WritingJourney = state.WritingJourney, + WritingSoftware = state.WritingSoftware, + Options = step switch + { + OnboardingSteps.WritingJourney => WritingJourneyOptions(), + OnboardingSteps.WritingSoftware => WritingSoftwareOptions(), + _ => [] + } + }; + } + + public async Task ShouldShowOnboardingAsync() + { + if (!currentUser.UserId.HasValue) + { + return false; + } + + var state = await onboarding.GetAsync(currentUser.UserId.Value); + return state?.OnboardingCompletedUtc is null; + } + + public async Task GetDashboardNudgeAsync() + => new() { ShouldShow = await ShouldShowOnboardingAsync() }; + + public async Task ShouldShowWordCompanionHeaderAsync() + => currentUser.UserId.HasValue + && await onboarding.ShouldShowWordCompanionHeaderAsync(currentUser.UserId.Value); + + public async Task MoveToNextStepAsync() + { + var state = await GetOrCreateAsync(); + var nextStep = ResolveStep(state) switch + { + OnboardingSteps.Welcome => OnboardingSteps.WritingJourney, + OnboardingSteps.WritingJourney => OnboardingSteps.WritingSoftware, + _ => OnboardingSteps.Complete + }; + + await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep); + } + + public async Task SetCurrentStepAsync(string currentStep) + { + if (currentStep is not (OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware or OnboardingSteps.Complete)) + { + throw new InvalidOperationException("Choose a valid onboarding step."); + } + + await onboarding.SetCurrentStepAsync(RequireUserId(), currentStep); + } + + public async Task SaveWritingJourneyAsync(string writingJourney) + { + if (!WritingJourneys.Contains(writingJourney)) + { + throw new InvalidOperationException("Choose a writing journey option."); + } + + await onboarding.SaveWritingJourneyAsync(RequireUserId(), writingJourney); + } + + public async Task SaveWritingSoftwareAsync(string writingSoftware) + { + if (!WritingSoftware.Contains(writingSoftware)) + { + throw new InvalidOperationException("Choose a writing software option."); + } + + await onboarding.SaveWritingSoftwareAsync(RequireUserId(), writingSoftware); + } + + public async Task MarkCompletedAsync() + => await onboarding.MarkCompletedAsync(RequireUserId()); + + private int RequireUserId() + => currentUser.UserId ?? throw new InvalidOperationException("Sign in to use onboarding."); + + private static string ResolveStep(UserOnboardingState state) + { + if (state.OnboardingCompletedUtc.HasValue) + { + return OnboardingSteps.Complete; + } + + if (state.CurrentStep is OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware) + { + return state.CurrentStep; + } + + if (string.IsNullOrWhiteSpace(state.WritingJourney)) + { + return OnboardingSteps.WritingJourney; + } + + if (string.IsNullOrWhiteSpace(state.WritingSoftware)) + { + return OnboardingSteps.WritingSoftware; + } + + return OnboardingSteps.Complete; + } + + private static int StepNumber(string step) => step switch + { + OnboardingSteps.Welcome => 1, + OnboardingSteps.WritingJourney => 2, + OnboardingSteps.WritingSoftware => 3, + _ => 3 + }; + + private static IReadOnlyList WritingJourneyOptions() => + [ + new() + { + Value = WritingJourneyValues.PlanningNewStory, + Title = "I am planning a brand new story", + Description = "You are shaping the idea, structure, characters, or world before drafting." + }, + new() + { + Value = WritingJourneyValues.AlreadyStarted, + Title = "I have already started writing", + Description = "You have draft material and want PlotDirector to help you organise what exists." + }, + new() + { + Value = WritingJourneyValues.ManuscriptMostlyComplete, + Title = "My manuscript is largely complete", + Description = "You are ready to review structure, continuity, and story detail across the manuscript." + } + ]; + + private static IReadOnlyList WritingSoftwareOptions() => + [ + new() + { + Value = WritingSoftwareValues.MicrosoftWord, + Title = "Microsoft Word", + Description = "Best fit for the PlotDirector Word Companion workflow.", + Badge = "Recommended" + }, + new() + { + Value = WritingSoftwareValues.GoogleDocs, + Title = "Google Docs", + Description = "Use exports later when manuscript import is added." + }, + new() + { + Value = WritingSoftwareValues.Scrivener, + Title = "Scrivener", + Description = "Use a manuscript export later when import support arrives." + }, + new() + { + Value = WritingSoftwareValues.LibreOfficeOpenOffice, + Title = "LibreOffice / OpenOffice", + Description = "Good for document-based workflows outside Microsoft Word." + }, + new() + { + Value = WritingSoftwareValues.MarkdownObsidian, + Title = "Markdown / Obsidian", + Description = "For plain-text writing systems and linked-note workflows." + }, + new() + { + Value = WritingSoftwareValues.Other, + Title = "Other", + Description = "PlotDirector can still tailor future setup around your tools." + } + ]; +} diff --git a/PlotLine/Sql/111_Phase20A_StoryIntelligenceOnboarding.sql b/PlotLine/Sql/111_Phase20A_StoryIntelligenceOnboarding.sql new file mode 100644 index 0000000..9b0be8c --- /dev/null +++ b/PlotLine/Sql/111_Phase20A_StoryIntelligenceOnboarding.sql @@ -0,0 +1,182 @@ +IF OBJECT_ID(N'dbo.UserOnboardingState', N'U') IS NULL +BEGIN + CREATE TABLE dbo.UserOnboardingState + ( + UserOnboardingStateID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_UserOnboardingState PRIMARY KEY, + UserID int NOT NULL, + OnboardingStartedUtc datetime2 NOT NULL CONSTRAINT DF_UserOnboardingState_Started DEFAULT SYSUTCDATETIME(), + OnboardingCompletedUtc datetime2 NULL, + CurrentStep nvarchar(50) NOT NULL CONSTRAINT DF_UserOnboardingState_CurrentStep DEFAULT N'Welcome', + WritingJourney nvarchar(50) NULL, + WritingSoftware nvarchar(50) NULL, + UsesWordCompanion bit NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_UserOnboardingState_Created DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_UserOnboardingState_Updated DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_UserOnboardingState_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID), + CONSTRAINT CK_UserOnboardingState_CurrentStep CHECK (CurrentStep IN (N'Welcome', N'WritingJourney', N'WritingSoftware', N'Complete')), + CONSTRAINT CK_UserOnboardingState_WritingJourney CHECK (WritingJourney IS NULL OR WritingJourney IN (N'PlanningNewStory', N'AlreadyStarted', N'ManuscriptMostlyComplete')), + CONSTRAINT CK_UserOnboardingState_WritingSoftware CHECK (WritingSoftware IS NULL OR WritingSoftware IN (N'MicrosoftWord', N'GoogleDocs', N'Scrivener', N'LibreOfficeOpenOffice', N'MarkdownObsidian', N'Other')) + ); +END; + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_UserOnboardingState_UserID' AND object_id = OBJECT_ID(N'dbo.UserOnboardingState')) + CREATE UNIQUE INDEX UX_UserOnboardingState_UserID ON dbo.UserOnboardingState(UserID); +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_Get + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserOnboardingStateID, UserID, OnboardingStartedUtc, OnboardingCompletedUtc, + CurrentStep, WritingJourney, WritingSoftware, UsesWordCompanion, CreatedUtc, UpdatedUtc + FROM dbo.UserOnboardingState + WHERE UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_GetOrCreate + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS (SELECT 1 FROM dbo.UserOnboardingState WHERE UserID = @UserID) + BEGIN + INSERT INTO dbo.UserOnboardingState (UserID) + VALUES (@UserID); + END; + + EXEC dbo.UserOnboarding_Get @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_SetCurrentStep + @UserID int, + @CurrentStep nvarchar(50) +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS (SELECT 1 FROM dbo.UserOnboardingState WHERE UserID = @UserID) + BEGIN + INSERT INTO dbo.UserOnboardingState (UserID) + VALUES (@UserID); + END; + + UPDATE dbo.UserOnboardingState + SET CurrentStep = @CurrentStep, + OnboardingCompletedUtc = CASE WHEN @CurrentStep = N'Complete' THEN COALESCE(OnboardingCompletedUtc, SYSUTCDATETIME()) ELSE NULL END, + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.UserOnboarding_Get @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_SaveWritingJourney + @UserID int, + @WritingJourney nvarchar(50) +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS (SELECT 1 FROM dbo.UserOnboardingState WHERE UserID = @UserID) + BEGIN + INSERT INTO dbo.UserOnboardingState (UserID) + VALUES (@UserID); + END; + + UPDATE dbo.UserOnboardingState + SET WritingJourney = @WritingJourney, + CurrentStep = N'WritingSoftware', + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.UserOnboarding_Get @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_SaveWritingSoftware + @UserID int, + @WritingSoftware nvarchar(50) +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS (SELECT 1 FROM dbo.UserOnboardingState WHERE UserID = @UserID) + BEGIN + INSERT INTO dbo.UserOnboardingState (UserID) + VALUES (@UserID); + END; + + UPDATE dbo.UserOnboardingState + SET WritingSoftware = @WritingSoftware, + UsesWordCompanion = CASE WHEN @WritingSoftware = N'MicrosoftWord' THEN 1 ELSE 0 END, + CurrentStep = N'Complete', + OnboardingCompletedUtc = COALESCE(OnboardingCompletedUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.UserOnboarding_Get @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_MarkCompleted + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS (SELECT 1 FROM dbo.UserOnboardingState WHERE UserID = @UserID) + BEGIN + INSERT INTO dbo.UserOnboardingState (UserID) + VALUES (@UserID); + END; + + UPDATE dbo.UserOnboardingState + SET CurrentStep = N'Complete', + OnboardingCompletedUtc = COALESCE(OnboardingCompletedUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.UserOnboarding_Get @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_ShouldShowWordCompanionHeader + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @HasWordPreference bit = 0; + DECLARE @HasCompanionUsage bit = 0; + + IF EXISTS + ( + SELECT 1 + FROM dbo.UserOnboardingState + WHERE UserID = @UserID + AND WritingSoftware = N'MicrosoftWord' + AND UsesWordCompanion = 1 + ) + SET @HasWordPreference = 1; + + IF OBJECT_ID(N'dbo.ManuscriptDocuments', N'U') IS NOT NULL + AND EXISTS + ( + SELECT 1 + FROM dbo.ManuscriptDocuments md + LEFT JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = md.ProjectID + AND pua.UserID = @UserID + AND pua.IsActive = 1 + WHERE md.IsActive = 1 + AND (md.CreatedByUserID = @UserID OR pua.ProjectUserAccessID IS NOT NULL) + ) + SET @HasCompanionUsage = 1; + + SELECT CAST(CASE WHEN @HasWordPreference = 1 OR @HasCompanionUsage = 1 THEN 1 ELSE 0 END AS bit); +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index fdf2bf1..bf15c6e 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -26,6 +26,7 @@ public sealed class ProjectIndexViewModel public IReadOnlyList ActiveProjects { get; init; } = []; public IReadOnlyList ArchivedProjects { get; init; } = []; public TrialVisibilityViewModel? Trial { get; init; } + public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new(); } public sealed class ProjectIndexCardViewModel diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs new file mode 100644 index 0000000..b010dd8 --- /dev/null +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using PlotLine.Models; + +namespace PlotLine.ViewModels; + +public sealed class OnboardingWizardViewModel +{ + public string CurrentStep { get; set; } = OnboardingSteps.Welcome; + public int StepNumber { get; set; } = 1; + public int TotalSteps { get; set; } = 3; + public string? WritingJourney { get; set; } + public string? WritingSoftware { get; set; } + public IReadOnlyList Options { get; set; } = []; +} + +public sealed class OnboardingOptionViewModel +{ + public string Value { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string? Badge { get; init; } +} + +public sealed class WritingJourneyOnboardingForm +{ + [Required(ErrorMessage = "Choose the closest writing stage before continuing.")] + public string? WritingJourney { get; set; } +} + +public sealed class WritingSoftwareOnboardingForm +{ + [Required(ErrorMessage = "Choose the software you write in before continuing.")] + public string? WritingSoftware { get; set; } +} + +public sealed class OnboardingNudgeViewModel +{ + public bool ShouldShow { get; init; } + public string ButtonText { get; init; } = "Start setup"; +} diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml new file mode 100644 index 0000000..25c26df --- /dev/null +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -0,0 +1,137 @@ +@model OnboardingWizardViewModel +@using PlotLine.Models +@{ + ViewData["Title"] = "Set up PlotDirector"; +} + +
+
+
+ Step @Model.StepNumber of @Model.TotalSteps +
+ +
+
+ + @if (Model.CurrentStep == OnboardingSteps.Welcome) + { +
+

Story intelligence setup

+

Welcome to PlotDirector

+

Answer a few quick questions so PlotDirector can shape the first setup around the way you write.

+
+
+ +
+ } + else if (Model.CurrentStep == OnboardingSteps.WritingJourney) + { +
+

Your starting point

+

Where are you in your writing journey?

+
+
+
+
+ @foreach (var option in Model.Options) + { + + } +
+ +
+ + +
+
+ } + else if (Model.CurrentStep == OnboardingSteps.WritingSoftware) + { +
+

Writing workflow

+

What software do you write in?

+
+
+
+
+ @foreach (var option in Model.Options) + { + + } +
+ +
+ + +
+
+ } + else + { +
+

Setup preferences

+

Your setup is saved

+

PlotDirector will use these answers as later story intelligence setup is added.

+
+
+
+
Writing journey
+
@DisplayJourney(Model.WritingJourney)
+
+
+
Writing software
+
@DisplaySoftware(Model.WritingSoftware)
+
+
+
+
+ +
+ Back to projects +
+ } +
+
+ +@section Scripts { + +} + +@functions { + private static string DisplayJourney(string? value) => value switch + { + WritingJourneyValues.PlanningNewStory => "Planning a brand new story", + WritingJourneyValues.AlreadyStarted => "Already started writing", + WritingJourneyValues.ManuscriptMostlyComplete => "Manuscript largely complete", + _ => "Not set" + }; + + private static string DisplaySoftware(string? value) => value switch + { + WritingSoftwareValues.MicrosoftWord => "Microsoft Word", + WritingSoftwareValues.GoogleDocs => "Google Docs", + WritingSoftwareValues.Scrivener => "Scrivener", + WritingSoftwareValues.LibreOfficeOpenOffice => "LibreOffice / OpenOffice", + WritingSoftwareValues.MarkdownObsidian => "Markdown / Obsidian", + WritingSoftwareValues.Other => "Other", + _ => "Not set" + }; +} diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index e51c2db..f2ccaf7 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -33,6 +33,18 @@ } +@if (Model.OnboardingNudge.ShouldShow) +{ +
+
+

First setup

+

Set up PlotDirector around the way you write

+

Answer a few quick questions so PlotDirector can guide your first project.

+
+ @Model.OnboardingNudge.ButtonText +
+} + @if (TempData["ArchiveMessage"] is string archiveMessage) {
@archiveMessage
diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index acbf440..cb24d16 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -3,6 +3,7 @@ @using System.Security.Claims @inject IConfiguration Configuration @inject IFeatureRequestService FeatureRequestService +@inject IOnboardingService OnboardingService @{ var shellClass = ViewData["ShellClass"] as string ?? string.Empty; var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase); @@ -14,6 +15,7 @@ && adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase)); var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0; var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0; + var showWordCompanionHeader = User.Identity?.IsAuthenticated == true && await OnboardingService.ShouldShowWordCompanionHeaderAsync(); var seoMetadata = ViewData["Seo"] as SeoMetadata; var defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string); var seoTitle = ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle; @@ -96,6 +98,7 @@ + @@ -153,9 +156,12 @@ @if (User.Identity?.IsAuthenticated == true) {
- + @if (showWordCompanionHeader) + { + + } Feature Requests @if (featureRequestAttentionCount > 0) @@ -180,6 +186,7 @@