Phase 20B Extend the onboarding wizard so the user can create or select their first Project and create or select their first Book as part of onboarding.

This commit is contained in:
Nick Beckley 2026-07-03 21:36:03 +01:00
parent c8dc116862
commit 9b1f3e9a8d
8 changed files with 884 additions and 20 deletions

View File

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);

View File

@ -11,6 +11,8 @@ public interface IOnboardingRepository
Task<UserOnboardingState> SetCurrentStepAsync(int userId, string currentStep);
Task<UserOnboardingState> SaveWritingJourneyAsync(int userId, string writingJourney);
Task<UserOnboardingState> SaveWritingSoftwareAsync(int userId, string writingSoftware);
Task<UserOnboardingState> SaveProjectAsync(int userId, int projectId);
Task<UserOnboardingState> SaveBookAsync(int userId, int bookId);
Task<UserOnboardingState> MarkCompletedAsync(int userId);
Task<bool> ShouldShowWordCompanionHeaderAsync(int userId);
}
@ -62,6 +64,24 @@ public sealed class OnboardingRepository(ISqlConnectionFactory connectionFactory
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> SaveProjectAsync(int userId, int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserOnboardingState>(
"dbo.UserOnboarding_SaveProject",
new { UserID = userId, ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> SaveBookAsync(int userId, int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserOnboardingState>(
"dbo.UserOnboarding_SaveBook",
new { UserID = userId, BookID = bookId },
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> MarkCompletedAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -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; }
}

View File

@ -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<string> 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<OnboardingNudgeViewModel> 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<bool> 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<IReadOnlyList<OnboardingProjectOptionViewModel>> 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<IReadOnlyList<OnboardingBookOptionViewModel>> 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<string> { 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<OnboardingOptionViewModel> WritingJourneyOptions() =>
[
new()

View File

@ -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

View File

@ -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<OnboardingOptionViewModel> Options { get; set; } = [];
public IReadOnlyList<OnboardingProjectOptionViewModel> ProjectOptions { get; set; } = [];
public IReadOnlyList<OnboardingBookOptionViewModel> 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.";
}

View File

@ -80,16 +80,170 @@
<span class="text-danger field-validation-valid" data-valmsg-for="WritingSoftware" data-valmsg-replace="true"></span>
<div class="onboarding-actions">
<button class="btn btn-outline-secondary" type="submit" formaction="/onboarding/back" formnovalidate name="currentStep" value="@OnboardingSteps.WritingSoftware">Back</button>
<button class="btn btn-primary" type="submit">Finish setup</button>
<button class="btn btn-primary" type="submit">Continue</button>
</div>
</form>
}
else if (Model.CurrentStep == OnboardingSteps.Project)
{
<div class="onboarding-copy">
<p class="eyebrow">Story home</p>
<h1 id="onboarding-title">Create your first project</h1>
<p>Choose the story space PlotDirector should use for this setup.</p>
</div>
<form asp-controller="Onboarding" asp-action="Project" method="post">
<div class="text-danger mb-3" asp-validation-summary="ModelOnly"></div>
<div class="onboarding-choice-grid">
<label class="onboarding-option-card">
<input type="radio" name="Mode" value="@OnboardingSelectionModes.Create" checked="@(Model.ProjectForm.Mode == OnboardingSelectionModes.Create)" />
<span>
<strong>Create a new project</strong>
<small>Start a fresh series or standalone story space.</small>
</span>
</label>
@if (Model.ProjectOptions.Any())
{
<label class="onboarding-option-card">
<input type="radio" name="Mode" value="@OnboardingSelectionModes.Select" checked="@(Model.ProjectForm.Mode == OnboardingSelectionModes.Select)" />
<span>
<strong>Use an existing project</strong>
<small>Continue setup inside one of your current projects.</small>
</span>
</label>
}
</div>
<section class="onboarding-form-section">
<h2>Create new project</h2>
<div class="row g-3">
<div class="col-md-7">
<label class="form-label" for="ProjectName">Project name</label>
<input class="form-control" id="ProjectName" name="ProjectName" maxlength="200" value="@Model.ProjectForm.ProjectName" />
@Html.ValidationMessage("ProjectName", null, new { @class = "text-danger" })
</div>
<div class="col-12">
<label class="form-label" for="Description">Description</label>
<textarea class="form-control" id="Description" name="Description" rows="3">@Model.ProjectForm.Description</textarea>
</div>
</div>
</section>
@if (Model.ProjectOptions.Any())
{
<section class="onboarding-form-section">
<h2>Use existing project</h2>
<div class="onboarding-option-grid">
@foreach (var project in Model.ProjectOptions)
{
<label class="onboarding-option-card">
<input type="radio" name="SelectedProjectID" value="@project.ProjectID" checked="@(project.ProjectID == Model.ProjectForm.SelectedProjectID)" />
<span>
<strong>@project.ProjectName</strong>
<small>@project.Description</small>
</span>
</label>
}
</div>
@Html.ValidationMessage("SelectedProjectID", null, new { @class = "text-danger" })
</section>
}
<div class="onboarding-actions">
<button class="btn btn-outline-secondary" type="submit" formaction="/onboarding/back" formnovalidate name="currentStep" value="@OnboardingSteps.Project">Back</button>
<button class="btn btn-primary" type="submit">Continue</button>
</div>
</form>
}
else if (Model.CurrentStep == OnboardingSteps.Book)
{
<div class="onboarding-copy">
<p class="eyebrow">@Model.SelectedProjectName</p>
<h1 id="onboarding-title">Create your first book</h1>
<p>Choose the book this onboarding setup should prepare for later manuscript guidance.</p>
</div>
<form asp-controller="Onboarding" asp-action="Book" method="post">
<div class="text-danger mb-3" asp-validation-summary="ModelOnly"></div>
<div class="onboarding-choice-grid">
<label class="onboarding-option-card">
<input type="radio" name="Mode" value="@OnboardingSelectionModes.Create" checked="@(Model.BookForm.Mode == OnboardingSelectionModes.Create)" />
<span>
<strong>Create a new book</strong>
<small>Add a book shell to @Model.SelectedProjectName.</small>
</span>
</label>
@if (Model.BookOptions.Any())
{
<label class="onboarding-option-card">
<input type="radio" name="Mode" value="@OnboardingSelectionModes.Select" checked="@(Model.BookForm.Mode == OnboardingSelectionModes.Select)" />
<span>
<strong>Use an existing book</strong>
<small>Continue with a book already in this project.</small>
</span>
</label>
}
</div>
<section class="onboarding-form-section">
<h2>Create new book</h2>
<div class="row g-3">
<div class="col-md-7">
<label class="form-label" for="BookTitle">Book title</label>
<input class="form-control" id="BookTitle" name="BookTitle" maxlength="200" value="@Model.BookForm.BookTitle" />
@Html.ValidationMessage("BookTitle", null, new { @class = "text-danger" })
</div>
<div class="col-md-5">
<label class="form-label" for="Subtitle">Subtitle</label>
<input class="form-control" id="Subtitle" name="Subtitle" maxlength="200" value="@Model.BookForm.Subtitle" />
@Html.ValidationMessage("Subtitle", null, new { @class = "text-danger" })
</div>
<div class="col-md-5">
<label class="form-label" for="TargetWordCount">Target word count</label>
<input class="form-control" id="TargetWordCount" name="TargetWordCount" type="number" min="1" value="@Model.BookForm.TargetWordCount" />
@Html.ValidationMessage("TargetWordCount", null, new { @class = "text-danger" })
</div>
</div>
</section>
@if (Model.BookOptions.Any())
{
<section class="onboarding-form-section">
<h2>Use existing book</h2>
<div class="onboarding-option-grid">
@foreach (var book in Model.BookOptions)
{
<label class="onboarding-option-card">
<input type="radio" name="SelectedBookID" value="@book.BookID" checked="@(book.BookID == Model.BookForm.SelectedBookID)" />
<span>
<strong>@book.DisplayTitle</strong>
<small>@book.Detail</small>
</span>
</label>
}
</div>
@Html.ValidationMessage("SelectedBookID", null, new { @class = "text-danger" })
</section>
}
<div class="onboarding-actions">
<button class="btn btn-outline-secondary" type="submit" formaction="/onboarding/back" formnovalidate name="currentStep" value="@OnboardingSteps.Book">Back</button>
<button class="btn btn-primary" type="submit">Continue</button>
</div>
</form>
}
else
{
<div class="onboarding-copy">
<p class="eyebrow">Setup preferences</p>
<h1 id="onboarding-title">Your setup is saved</h1>
<p>PlotDirector will use these answers as later story intelligence setup is added.</p>
<p class="eyebrow">Ready for the next phase</p>
@if (Model.IsMicrosoftWordPath)
{
<h1 id="onboarding-title">Next: connect the Word Companion</h1>
<p>In the next phase, PlotDirector will guide you through opening the Word Companion and linking it to @Model.SelectedBookTitle.</p>
}
else
{
<h1 id="onboarding-title">Next: upload a Word export</h1>
<p>In a later phase, PlotDirector will let you upload a .docx export from your writing tool and connect it to @Model.SelectedBookTitle.</p>
}
</div>
<dl class="onboarding-summary">
<div>
@ -100,12 +254,26 @@
<dt>Writing software</dt>
<dd>@DisplaySoftware(Model.WritingSoftware)</dd>
</div>
<div>
<dt>Project</dt>
<dd>@(Model.SelectedProjectName ?? "Not set")</dd>
</div>
<div>
<dt>Book</dt>
<dd>@(Model.SelectedBookTitle ?? "Not set")</dd>
</div>
</dl>
<div class="onboarding-actions">
<form asp-controller="Onboarding" asp-action="Back" method="post">
<button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.WritingSoftware">Update preferences</button>
<button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.NextPathPreview">Back</button>
</form>
@if (Model.ProjectID.HasValue)
{
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Go to Project Overview</a>
}
<form asp-controller="Onboarding" asp-action="Finish" method="post">
<button class="btn btn-primary" type="submit">Finish setup for now</button>
</form>
<a class="btn btn-primary" asp-controller="Projects" asp-action="Index">Back to projects</a>
</div>
}
</div>

View File

@ -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;
}