diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 83422bb..59b33a3 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -54,7 +54,112 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro } await onboarding.SaveWritingSoftwareAsync(form.WritingSoftware); - TempData["ArchiveMessage"] = "Setup saved. PlotDirector will use those answers to guide future story setup."; + return RedirectToAction(nameof(Index)); + } + + [HttpPost("project")] + [ValidateAntiForgeryToken] + public async Task Project(ProjectOnboardingForm form) + { + if (string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(form.ProjectName)) + { + ModelState.AddModelError(nameof(form.ProjectName), "Enter a project name."); + } + + if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) + && !form.SelectedProjectID.HasValue) + { + ModelState.AddModelError(nameof(form.SelectedProjectID), "Choose one of your active projects."); + } + + if (!ModelState.IsValid) + { + var invalidModel = await onboarding.GetWizardAsync(); + invalidModel.CurrentStep = OnboardingSteps.Project; + invalidModel.StepNumber = 4; + invalidModel.ProjectForm = form; + return View(nameof(Index), invalidModel); + } + + try + { + await onboarding.SaveProjectAsync(form); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) + ? nameof(form.SelectedProjectID) + : nameof(form.ProjectName), ex.Message); + var invalidModel = await onboarding.GetWizardAsync(); + invalidModel.CurrentStep = OnboardingSteps.Project; + invalidModel.StepNumber = 4; + invalidModel.ProjectForm = form; + return View(nameof(Index), invalidModel); + } + + return RedirectToAction(nameof(Index)); + } + + [HttpPost("book")] + [ValidateAntiForgeryToken] + public async Task Book(BookOnboardingForm form) + { + if (string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(form.BookTitle)) + { + ModelState.AddModelError(nameof(form.BookTitle), "Enter a book title."); + } + + if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) + && !form.SelectedBookID.HasValue) + { + ModelState.AddModelError(nameof(form.SelectedBookID), "Choose a book from the selected project."); + } + + if (!ModelState.IsValid) + { + var invalidModel = await onboarding.GetWizardAsync(); + invalidModel.CurrentStep = OnboardingSteps.Book; + invalidModel.StepNumber = 5; + invalidModel.BookForm = form; + return View(nameof(Index), invalidModel); + } + + try + { + await onboarding.SaveBookAsync(form); + } + catch (SubscriptionLimitException ex) + { + ModelState.AddModelError(nameof(form.BookTitle), ex.Message); + var invalidModel = await onboarding.GetWizardAsync(); + invalidModel.CurrentStep = OnboardingSteps.Book; + invalidModel.StepNumber = 5; + invalidModel.BookForm = form; + return View(nameof(Index), invalidModel); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) + ? nameof(form.SelectedBookID) + : nameof(form.BookTitle), ex.Message); + var invalidModel = await onboarding.GetWizardAsync(); + invalidModel.CurrentStep = OnboardingSteps.Book; + invalidModel.StepNumber = 5; + invalidModel.BookForm = form; + return View(nameof(Index), invalidModel); + } + + return RedirectToAction(nameof(Index)); + } + + [HttpPost("finish")] + [ValidateAntiForgeryToken] + public async Task Finish() + { + await onboarding.MarkCompletedAsync(); + TempData["ArchiveMessage"] = "Setup saved. You can return to setup preferences from your account menu."; return RedirectToAction("Index", "Projects"); } @@ -62,9 +167,15 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro [ValidateAntiForgeryToken] public async Task Back(string currentStep) { - var targetStep = currentStep == OnboardingSteps.WritingSoftware - ? OnboardingSteps.WritingJourney - : OnboardingSteps.Welcome; + var targetStep = currentStep switch + { + OnboardingSteps.WritingJourney => OnboardingSteps.Welcome, + OnboardingSteps.WritingSoftware => OnboardingSteps.WritingJourney, + OnboardingSteps.Project => OnboardingSteps.WritingSoftware, + OnboardingSteps.Book => OnboardingSteps.Project, + OnboardingSteps.NextPathPreview => OnboardingSteps.Book, + _ => OnboardingSteps.Welcome + }; await onboarding.SetCurrentStepAsync(targetStep); diff --git a/PlotLine/Data/OnboardingRepository.cs b/PlotLine/Data/OnboardingRepository.cs index 74f878a..cab4886 100644 --- a/PlotLine/Data/OnboardingRepository.cs +++ b/PlotLine/Data/OnboardingRepository.cs @@ -11,6 +11,8 @@ public interface IOnboardingRepository Task SetCurrentStepAsync(int userId, string currentStep); Task SaveWritingJourneyAsync(int userId, string writingJourney); Task SaveWritingSoftwareAsync(int userId, string writingSoftware); + Task SaveProjectAsync(int userId, int projectId); + Task SaveBookAsync(int userId, int bookId); Task MarkCompletedAsync(int userId); Task ShouldShowWordCompanionHeaderAsync(int userId); } @@ -62,6 +64,24 @@ public sealed class OnboardingRepository(ISqlConnectionFactory connectionFactory commandType: CommandType.StoredProcedure); } + public async Task SaveProjectAsync(int userId, int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_SaveProject", + new { UserID = userId, ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveBookAsync(int userId, int bookId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserOnboarding_SaveBook", + new { UserID = userId, BookID = bookId }, + commandType: CommandType.StoredProcedure); + } + public async Task MarkCompletedAsync(int userId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/OnboardingModels.cs b/PlotLine/Models/OnboardingModels.cs index b891393..f822355 100644 --- a/PlotLine/Models/OnboardingModels.cs +++ b/PlotLine/Models/OnboardingModels.cs @@ -5,6 +5,9 @@ public static class OnboardingSteps public const string Welcome = "Welcome"; public const string WritingJourney = "WritingJourney"; public const string WritingSoftware = "WritingSoftware"; + public const string Project = "Project"; + public const string Book = "Book"; + public const string NextPathPreview = "NextPathPreview"; public const string Complete = "Complete"; } @@ -35,6 +38,8 @@ public sealed class UserOnboardingState public string? WritingJourney { get; set; } public string? WritingSoftware { get; set; } public bool? UsesWordCompanion { get; set; } + public int? ProjectID { get; set; } + public int? BookID { get; set; } public DateTime CreatedUtc { get; set; } public DateTime UpdatedUtc { get; set; } } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index c0a3859..ec6797b 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -15,10 +15,18 @@ public interface IOnboardingService Task SetCurrentStepAsync(string currentStep); Task SaveWritingJourneyAsync(string writingJourney); Task SaveWritingSoftwareAsync(string writingSoftware); + Task SaveProjectAsync(ProjectOnboardingForm form); + Task SaveBookAsync(BookOnboardingForm form); Task MarkCompletedAsync(); } -public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrentUserService currentUser) : IOnboardingService +public sealed class OnboardingService( + IOnboardingRepository onboarding, + IProjectRepository projects, + IBookRepository books, + IBookService bookService, + IProjectActivityService activity, + ICurrentUserService currentUser) : IOnboardingService { private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) { @@ -44,6 +52,16 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent { var state = await GetOrCreateAsync(); var step = ResolveStep(state); + var projectOptions = await BuildProjectOptionsAsync(); + var bookOptions = state.ProjectID.HasValue + ? await BuildBookOptionsAsync(state.ProjectID.Value) + : []; + var selectedProject = state.ProjectID.HasValue + ? await projects.GetForUserAsync(state.ProjectID.Value, RequireUserId()) + : null; + var selectedBook = state.BookID.HasValue + ? await books.GetAsync(state.BookID.Value) + : null; return new OnboardingWizardViewModel { @@ -51,6 +69,22 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent StepNumber = StepNumber(step), WritingJourney = state.WritingJourney, WritingSoftware = state.WritingSoftware, + ProjectID = state.ProjectID, + BookID = state.BookID, + SelectedProjectName = selectedProject?.ProjectName, + SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook), + ProjectOptions = projectOptions, + BookOptions = bookOptions, + ProjectForm = new ProjectOnboardingForm + { + Mode = projectOptions.Count == 0 || !state.ProjectID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select, + SelectedProjectID = state.ProjectID + }, + BookForm = new BookOnboardingForm + { + Mode = bookOptions.Count == 0 || !state.BookID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select, + SelectedBookID = state.BookID + }, Options = step switch { OnboardingSteps.WritingJourney => WritingJourneyOptions(), @@ -68,11 +102,39 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent } var state = await onboarding.GetAsync(currentUser.UserId.Value); - return state?.OnboardingCompletedUtc is null; + return state is null || state.OnboardingCompletedUtc is null; } public async Task GetDashboardNudgeAsync() - => new() { ShouldShow = await ShouldShowOnboardingAsync() }; + { + if (!currentUser.UserId.HasValue) + { + return new OnboardingNudgeViewModel(); + } + + var state = await onboarding.GetAsync(currentUser.UserId.Value); + if (state?.OnboardingCompletedUtc is not null) + { + return new OnboardingNudgeViewModel { ShouldShow = false }; + } + + var hasStarted = state is not null + && (!string.IsNullOrWhiteSpace(state.WritingJourney) + || !string.IsNullOrWhiteSpace(state.WritingSoftware) + || state.ProjectID.HasValue + || state.BookID.HasValue + || state.CurrentStep != OnboardingSteps.Welcome); + + return hasStarted + ? new OnboardingNudgeViewModel + { + ShouldShow = true, + Title = "Continue your PlotDirector setup", + Description = "Pick the project and book PlotDirector should use for your first guided setup.", + ButtonText = "Continue setup" + } + : new OnboardingNudgeViewModel { ShouldShow = true }; + } public async Task ShouldShowWordCompanionHeaderAsync() => currentUser.UserId.HasValue @@ -85,7 +147,10 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent { OnboardingSteps.Welcome => OnboardingSteps.WritingJourney, OnboardingSteps.WritingJourney => OnboardingSteps.WritingSoftware, - _ => OnboardingSteps.Complete + OnboardingSteps.WritingSoftware => OnboardingSteps.Project, + OnboardingSteps.Project => OnboardingSteps.Book, + OnboardingSteps.Book => OnboardingSteps.NextPathPreview, + _ => OnboardingSteps.NextPathPreview }; await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep); @@ -93,7 +158,13 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent public async Task SetCurrentStepAsync(string currentStep) { - if (currentStep is not (OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware or OnboardingSteps.Complete)) + if (currentStep is not (OnboardingSteps.Welcome + or OnboardingSteps.WritingJourney + or OnboardingSteps.WritingSoftware + or OnboardingSteps.Project + or OnboardingSteps.Book + or OnboardingSteps.NextPathPreview + or OnboardingSteps.Complete)) { throw new InvalidOperationException("Choose a valid onboarding step."); } @@ -121,6 +192,91 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent await onboarding.SaveWritingSoftwareAsync(RequireUserId(), writingSoftware); } + public async Task SaveProjectAsync(ProjectOnboardingForm form) + { + var userId = RequireUserId(); + if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal)) + { + var projectId = form.SelectedProjectID.GetValueOrDefault(); + var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null; + if (project is null || !project.IsOwner || project.IsArchived) + { + throw new InvalidOperationException("Choose one of your active projects."); + } + + await onboarding.SaveProjectAsync(userId, project.ProjectID); + return; + } + + if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Choose whether to create or use an existing project."); + } + + var projectName = Clean(form.ProjectName); + if (string.IsNullOrWhiteSpace(projectName)) + { + throw new InvalidOperationException("Enter a project name."); + } + + var projectIdCreated = await projects.SaveAsync(new Project + { + ProjectName = projectName, + Description = CleanOptional(form.Description) + }, userId, "Custom"); + await activity.RecordAsync(projectIdCreated, "Created", "Project", projectIdCreated, projectName); + await onboarding.SaveProjectAsync(userId, projectIdCreated); + } + + public async Task SaveBookAsync(BookOnboardingForm form) + { + var userId = RequireUserId(); + var state = await GetOrCreateAsync(); + var projectId = state.ProjectID.GetValueOrDefault(); + var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null; + if (project is null || !project.IsOwner || project.IsArchived) + { + throw new InvalidOperationException("Choose a project before choosing a book."); + } + + if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal)) + { + var bookId = form.SelectedBookID.GetValueOrDefault(); + var book = bookId > 0 ? await books.GetAsync(bookId) : null; + if (book is null || book.ProjectID != project.ProjectID || book.IsArchived) + { + throw new InvalidOperationException("Choose a book from the selected project."); + } + + await onboarding.SaveBookAsync(userId, book.BookID); + return; + } + + if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Choose whether to create or use an existing book."); + } + + var bookTitle = Clean(form.BookTitle); + if (string.IsNullOrWhiteSpace(bookTitle)) + { + throw new InvalidOperationException("Enter a book title."); + } + + var createModel = await bookService.GetCreateAsync(project.ProjectID) + ?? throw new InvalidOperationException("The selected project could not be used."); + createModel.BookTitle = bookTitle; + createModel.Subtitle = CleanOptional(form.Subtitle); + createModel.TargetWordCount = form.TargetWordCount; + var result = await bookService.SaveAsync(createModel); + if (!result.Succeeded) + { + throw new InvalidOperationException(result.ErrorMessage ?? "The book could not be created."); + } + + await onboarding.SaveBookAsync(userId, result.BookID); + } + public async Task MarkCompletedAsync() => await onboarding.MarkCompletedAsync(RequireUserId()); @@ -131,10 +287,15 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent { if (state.OnboardingCompletedUtc.HasValue) { - return OnboardingSteps.Complete; + return OnboardingSteps.NextPathPreview; } - if (state.CurrentStep is OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware) + if (state.CurrentStep is OnboardingSteps.Welcome + or OnboardingSteps.WritingJourney + or OnboardingSteps.WritingSoftware + or OnboardingSteps.Project + or OnboardingSteps.Book + or OnboardingSteps.NextPathPreview) { return state.CurrentStep; } @@ -149,7 +310,17 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent return OnboardingSteps.WritingSoftware; } - return OnboardingSteps.Complete; + if (!state.ProjectID.HasValue) + { + return OnboardingSteps.Project; + } + + if (!state.BookID.HasValue) + { + return OnboardingSteps.Book; + } + + return OnboardingSteps.NextPathPreview; } private static int StepNumber(string step) => step switch @@ -157,9 +328,75 @@ public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrent OnboardingSteps.Welcome => 1, OnboardingSteps.WritingJourney => 2, OnboardingSteps.WritingSoftware => 3, - _ => 3 + OnboardingSteps.Project => 4, + OnboardingSteps.Book => 5, + _ => 6 }; + private async Task> BuildProjectOptionsAsync() + { + var userId = RequireUserId(); + var rows = await projects.ListActiveForUserAsync(userId); + return rows + .Where(project => project.IsOwner && !project.IsArchived) + .OrderBy(project => project.ProjectName) + .Select(project => new OnboardingProjectOptionViewModel + { + ProjectID = project.ProjectID, + ProjectName = project.ProjectName, + Description = string.IsNullOrWhiteSpace(project.Description) + ? $"Updated {project.UpdatedDate:yyyy-MM-dd}" + : project.Description + }) + .ToList(); + } + + private async Task> BuildBookOptionsAsync(int projectId) + { + var project = await projects.GetForUserAsync(projectId, RequireUserId()); + if (project is null || !project.IsOwner || project.IsArchived) + { + return []; + } + + var rows = await books.ListByProjectAsync(projectId); + return rows + .Where(book => !book.IsArchived) + .OrderBy(book => book.SortOrder) + .ThenBy(book => book.BookNumber) + .ThenBy(book => book.BookTitle) + .Select(book => new OnboardingBookOptionViewModel + { + BookID = book.BookID, + DisplayTitle = BookOptionTitle(book), + Detail = BookOptionDetail(book) + }) + .ToList(); + } + + private static string BookOptionTitle(Book book) + => $"Book {book.BookNumber}: {book.BookDisplayTitle}"; + + private static string BookOptionDetail(Book book) + { + var parts = new List { book.Status }; + if (book.TargetWordCount is > 0) + { + parts.Add($"{book.TargetWordCount.Value:N0} target words"); + } + + parts.Add($"Updated {book.UpdatedDate:yyyy-MM-dd}"); + return string.Join(" / ", parts); + } + + private static string Clean(string? value) => (value ?? string.Empty).Trim(); + + private static string? CleanOptional(string? value) + { + var cleaned = Clean(value); + return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; + } + private static IReadOnlyList WritingJourneyOptions() => [ new() diff --git a/PlotLine/Sql/112_Phase20B_OnboardingProjectBook.sql b/PlotLine/Sql/112_Phase20B_OnboardingProjectBook.sql new file mode 100644 index 0000000..30c782d --- /dev/null +++ b/PlotLine/Sql/112_Phase20B_OnboardingProjectBook.sql @@ -0,0 +1,239 @@ +IF COL_LENGTH(N'dbo.UserOnboardingState', N'ProjectID') IS NULL + ALTER TABLE dbo.UserOnboardingState ADD ProjectID int NULL; +GO + +IF COL_LENGTH(N'dbo.UserOnboardingState', N'BookID') IS NULL + ALTER TABLE dbo.UserOnboardingState ADD BookID int NULL; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_UserOnboardingState_Projects') + ALTER TABLE dbo.UserOnboardingState ADD CONSTRAINT FK_UserOnboardingState_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_UserOnboardingState_Books') + ALTER TABLE dbo.UserOnboardingState ADD CONSTRAINT FK_UserOnboardingState_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID); +GO + +IF EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_UserOnboardingState_CurrentStep' AND parent_object_id = OBJECT_ID(N'dbo.UserOnboardingState')) + ALTER TABLE dbo.UserOnboardingState DROP CONSTRAINT CK_UserOnboardingState_CurrentStep; +GO + +ALTER TABLE dbo.UserOnboardingState ADD CONSTRAINT CK_UserOnboardingState_CurrentStep +CHECK (CurrentStep IN (N'Welcome', N'WritingJourney', N'WritingSoftware', N'Project', N'Book', N'NextPathPreview', N'Complete')); +GO + +UPDATE dbo.UserOnboardingState +SET CurrentStep = N'Project', + OnboardingCompletedUtc = NULL, + UpdatedUtc = SYSUTCDATETIME() +WHERE OnboardingCompletedUtc IS NOT NULL + AND (ProjectID IS NULL OR BookID IS NULL); +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_Get + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserOnboardingStateID, UserID, OnboardingStartedUtc, OnboardingCompletedUtc, + CurrentStep, WritingJourney, WritingSoftware, UsesWordCompanion, ProjectID, BookID, 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', + OnboardingCompletedUtc = NULL, + 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'Project', + OnboardingCompletedUtc = NULL, + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.UserOnboarding_Get @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_SaveProject + @UserID int, + @ProjectID 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 ProjectID = @ProjectID, + BookID = CASE WHEN ProjectID = @ProjectID THEN BookID ELSE NULL END, + CurrentStep = N'Book', + OnboardingCompletedUtc = NULL, + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.UserOnboarding_Get @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserOnboarding_SaveBook + @UserID int, + @BookID 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 BookID = @BookID, + CurrentStep = N'NextPathPreview', + OnboardingCompletedUtc = NULL, + 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/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index b010dd8..42d3a80 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -7,10 +7,19 @@ public sealed class OnboardingWizardViewModel { public string CurrentStep { get; set; } = OnboardingSteps.Welcome; public int StepNumber { get; set; } = 1; - public int TotalSteps { get; set; } = 3; + public int TotalSteps { get; set; } = 6; public string? WritingJourney { get; set; } public string? WritingSoftware { get; set; } + public int? ProjectID { get; set; } + public int? BookID { get; set; } + public string? SelectedProjectName { get; set; } + public string? SelectedBookTitle { get; set; } + public bool IsMicrosoftWordPath => string.Equals(WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal); public IReadOnlyList Options { get; set; } = []; + public IReadOnlyList ProjectOptions { get; set; } = []; + public IReadOnlyList BookOptions { get; set; } = []; + public ProjectOnboardingForm ProjectForm { get; set; } = new(); + public BookOnboardingForm BookForm { get; set; } = new(); } public sealed class OnboardingOptionViewModel @@ -33,8 +42,60 @@ public sealed class WritingSoftwareOnboardingForm public string? WritingSoftware { get; set; } } +public static class OnboardingSelectionModes +{ + public const string Create = "Create"; + public const string Select = "Select"; +} + +public sealed class ProjectOnboardingForm +{ + [Required] + public string Mode { get; set; } = OnboardingSelectionModes.Create; + + public int? SelectedProjectID { get; set; } + + [StringLength(200)] + public string? ProjectName { get; set; } + + public string? Description { get; set; } +} + +public sealed class BookOnboardingForm +{ + [Required] + public string Mode { get; set; } = OnboardingSelectionModes.Create; + + public int? SelectedBookID { get; set; } + + [StringLength(200)] + public string? BookTitle { get; set; } + + [StringLength(200)] + public string? Subtitle { get; set; } + + [Range(1, int.MaxValue, ErrorMessage = "Target word count must be greater than zero.")] + public int? TargetWordCount { get; set; } +} + +public sealed class OnboardingProjectOptionViewModel +{ + public int ProjectID { get; init; } + public string ProjectName { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; +} + +public sealed class OnboardingBookOptionViewModel +{ + public int BookID { get; init; } + public string DisplayTitle { get; init; } = string.Empty; + public string Detail { get; init; } = string.Empty; +} + public sealed class OnboardingNudgeViewModel { public bool ShouldShow { get; init; } public string ButtonText { get; init; } = "Start setup"; + public string Title { get; init; } = "Set up PlotDirector around the way you write"; + public string Description { get; init; } = "Answer a few quick questions so PlotDirector can guide your first project."; } diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index 25c26df..489d694 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -80,16 +80,170 @@
- + +
+ + } + else if (Model.CurrentStep == OnboardingSteps.Project) + { +
+

Story home

+

Create your first project

+

Choose the story space PlotDirector should use for this setup.

+
+
+
+
+ + @if (Model.ProjectOptions.Any()) + { + + } +
+ +
+

Create new project

+
+
+ + + @Html.ValidationMessage("ProjectName", null, new { @class = "text-danger" }) +
+
+ + +
+
+
+ + @if (Model.ProjectOptions.Any()) + { +
+

Use existing project

+
+ @foreach (var project in Model.ProjectOptions) + { + + } +
+ @Html.ValidationMessage("SelectedProjectID", null, new { @class = "text-danger" }) +
+ } + +
+ + +
+
+ } + else if (Model.CurrentStep == OnboardingSteps.Book) + { +
+

@Model.SelectedProjectName

+

Create your first book

+

Choose the book this onboarding setup should prepare for later manuscript guidance.

+
+
+
+
+ + @if (Model.BookOptions.Any()) + { + + } +
+ +
+

Create new book

+
+
+ + + @Html.ValidationMessage("BookTitle", null, new { @class = "text-danger" }) +
+
+ + + @Html.ValidationMessage("Subtitle", null, new { @class = "text-danger" }) +
+
+ + + @Html.ValidationMessage("TargetWordCount", null, new { @class = "text-danger" }) +
+
+
+ + @if (Model.BookOptions.Any()) + { +
+

Use existing book

+
+ @foreach (var book in Model.BookOptions) + { + + } +
+ @Html.ValidationMessage("SelectedBookID", null, new { @class = "text-danger" }) +
+ } + +
+ +
} else {
-

Setup preferences

-

Your setup is saved

-

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

+

Ready for the next phase

+ @if (Model.IsMicrosoftWordPath) + { +

Next: connect the Word Companion

+

In the next phase, PlotDirector will guide you through opening the Word Companion and linking it to @Model.SelectedBookTitle.

+ } + else + { +

Next: upload a Word export

+

In a later phase, PlotDirector will let you upload a .docx export from your writing tool and connect it to @Model.SelectedBookTitle.

+ }
@@ -100,12 +254,26 @@
Writing software
@DisplaySoftware(Model.WritingSoftware)
+
+
Project
+
@(Model.SelectedProjectName ?? "Not set")
+
+
+
Book
+
@(Model.SelectedBookTitle ?? "Not set")
+
- + +
+ @if (Model.ProjectID.HasValue) + { + Go to Project Overview + } +
+
- Back to projects
} diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index 0708ef8..7f636e9 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -60,6 +60,13 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } +.onboarding-choice-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .9rem; + margin-bottom: 1rem; +} + .onboarding-option-card { position: relative; display: block; @@ -123,6 +130,21 @@ flex-wrap: wrap; } +.onboarding-form-section { + display: grid; + gap: .75rem; + margin-top: 1rem; + padding: 1rem; + border: 1px solid rgba(31, 42, 68, .12); + border-radius: 8px; + background: rgba(255, 255, 255, .45); +} + +.onboarding-form-section h2 { + margin: 0; + font-size: 1.05rem; +} + .onboarding-summary { display: grid; gap: .8rem; @@ -171,6 +193,7 @@ } @media (max-width: 720px) { + .onboarding-choice-grid, .onboarding-option-grid--software { grid-template-columns: 1fr; }