Phase 20A Story Intelligence Onboarding.

This commit is contained in:
Nick Beckley 2026-07-03 21:04:06 +01:00
parent 0f7b6a8116
commit c8dc116862
13 changed files with 990 additions and 4 deletions

View File

@ -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<IActionResult> Index()
{
var model = await onboarding.GetWizardAsync();
return View(model);
}
[HttpPost("welcome")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Welcome()
{
await onboarding.MoveToNextStepAsync();
return RedirectToAction(nameof(Index));
}
[HttpPost("writing-journey")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Back(string currentStep)
{
var targetStep = currentStep == OnboardingSteps.WritingSoftware
? OnboardingSteps.WritingJourney
: OnboardingSteps.Welcome;
await onboarding.SetCurrentStepAsync(targetStep);
return RedirectToAction(nameof(Index));
}
}

View File

@ -0,0 +1,82 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IOnboardingRepository
{
Task<UserOnboardingState?> GetAsync(int userId);
Task<UserOnboardingState> GetOrCreateAsync(int userId);
Task<UserOnboardingState> SetCurrentStepAsync(int userId, string currentStep);
Task<UserOnboardingState> SaveWritingJourneyAsync(int userId, string writingJourney);
Task<UserOnboardingState> SaveWritingSoftwareAsync(int userId, string writingSoftware);
Task<UserOnboardingState> MarkCompletedAsync(int userId);
Task<bool> ShouldShowWordCompanionHeaderAsync(int userId);
}
public sealed class OnboardingRepository(ISqlConnectionFactory connectionFactory) : IOnboardingRepository
{
public async Task<UserOnboardingState?> GetAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<UserOnboardingState>(
"dbo.UserOnboarding_Get",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> GetOrCreateAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserOnboardingState>(
"dbo.UserOnboarding_GetOrCreate",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> SetCurrentStepAsync(int userId, string currentStep)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserOnboardingState>(
"dbo.UserOnboarding_SetCurrentStep",
new { UserID = userId, CurrentStep = currentStep },
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> SaveWritingJourneyAsync(int userId, string writingJourney)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserOnboardingState>(
"dbo.UserOnboarding_SaveWritingJourney",
new { UserID = userId, WritingJourney = writingJourney },
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> SaveWritingSoftwareAsync(int userId, string writingSoftware)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserOnboardingState>(
"dbo.UserOnboarding_SaveWritingSoftware",
new { UserID = userId, WritingSoftware = writingSoftware },
commandType: CommandType.StoredProcedure);
}
public async Task<UserOnboardingState> MarkCompletedAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserOnboardingState>(
"dbo.UserOnboarding_MarkCompleted",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> ShouldShowWordCompanionHeaderAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.UserOnboarding_ShouldShowWordCompanionHeader",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
}

View File

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

View File

@ -128,6 +128,7 @@ public class Program
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
builder.Services.AddScoped<IImportRepository, ImportRepository>();
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
@ -169,6 +170,7 @@ public class Program
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();

View File

@ -427,6 +427,7 @@ public sealed class ProjectService(
IWritingScheduleRepository writingSchedule,
IProjectActivityService activity,
ISubscriptionService subscriptions,
IOnboardingService onboarding,
ICurrentUserService currentUser,
IHardDeleteFileCleanupService fileCleanup,
ILogger<ProjectService> 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()
};
}

View File

@ -0,0 +1,225 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IOnboardingService
{
Task<UserOnboardingState> GetOrCreateAsync();
Task<OnboardingWizardViewModel> GetWizardAsync();
Task<bool> ShouldShowOnboardingAsync();
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
Task<bool> 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<string> WritingJourneys = new(StringComparer.Ordinal)
{
WritingJourneyValues.PlanningNewStory,
WritingJourneyValues.AlreadyStarted,
WritingJourneyValues.ManuscriptMostlyComplete
};
private static readonly HashSet<string> WritingSoftware = new(StringComparer.Ordinal)
{
WritingSoftwareValues.MicrosoftWord,
WritingSoftwareValues.GoogleDocs,
WritingSoftwareValues.Scrivener,
WritingSoftwareValues.LibreOfficeOpenOffice,
WritingSoftwareValues.MarkdownObsidian,
WritingSoftwareValues.Other
};
public async Task<UserOnboardingState> GetOrCreateAsync()
=> await onboarding.GetOrCreateAsync(RequireUserId());
public async Task<OnboardingWizardViewModel> 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<bool> ShouldShowOnboardingAsync()
{
if (!currentUser.UserId.HasValue)
{
return false;
}
var state = await onboarding.GetAsync(currentUser.UserId.Value);
return state?.OnboardingCompletedUtc is null;
}
public async Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync()
=> new() { ShouldShow = await ShouldShowOnboardingAsync() };
public async Task<bool> 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<OnboardingOptionViewModel> 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<OnboardingOptionViewModel> 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."
}
];
}

View File

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

View File

@ -26,6 +26,7 @@ public sealed class ProjectIndexViewModel
public IReadOnlyList<ProjectIndexCardViewModel> ActiveProjects { get; init; } = [];
public IReadOnlyList<ProjectIndexCardViewModel> ArchivedProjects { get; init; } = [];
public TrialVisibilityViewModel? Trial { get; init; }
public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new();
}
public sealed class ProjectIndexCardViewModel

View File

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

View File

@ -0,0 +1,137 @@
@model OnboardingWizardViewModel
@using PlotLine.Models
@{
ViewData["Title"] = "Set up PlotDirector";
}
<section class="onboarding-shell" aria-labelledby="onboarding-title">
<div class="onboarding-panel">
<div class="onboarding-progress" aria-label="Setup progress">
<span>Step @Model.StepNumber of @Model.TotalSteps</span>
<div class="onboarding-progress-track">
<span style="width:@((Model.StepNumber * 100) / Model.TotalSteps)%"></span>
</div>
</div>
@if (Model.CurrentStep == OnboardingSteps.Welcome)
{
<div class="onboarding-copy">
<p class="eyebrow">Story intelligence setup</p>
<h1 id="onboarding-title">Welcome to PlotDirector</h1>
<p>Answer a few quick questions so PlotDirector can shape the first setup around the way you write.</p>
</div>
<form asp-controller="Onboarding" asp-action="Welcome" method="post" class="onboarding-actions">
<button class="btn btn-primary btn-lg" type="submit">Continue</button>
</form>
}
else if (Model.CurrentStep == OnboardingSteps.WritingJourney)
{
<div class="onboarding-copy">
<p class="eyebrow">Your starting point</p>
<h1 id="onboarding-title">Where are you in your writing journey?</h1>
</div>
<form asp-controller="Onboarding" asp-action="WritingJourney" method="post">
<div class="text-danger mb-3" asp-validation-summary="ModelOnly"></div>
<div class="onboarding-option-grid">
@foreach (var option in Model.Options)
{
<label class="onboarding-option-card">
<input type="radio" name="WritingJourney" value="@option.Value" checked="@(option.Value == Model.WritingJourney)" required />
<span>
<strong>@option.Title</strong>
<small>@option.Description</small>
</span>
</label>
}
</div>
<span class="text-danger field-validation-valid" data-valmsg-for="WritingJourney" 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.WritingJourney">Back</button>
<button class="btn btn-primary" type="submit">Continue</button>
</div>
</form>
}
else if (Model.CurrentStep == OnboardingSteps.WritingSoftware)
{
<div class="onboarding-copy">
<p class="eyebrow">Writing workflow</p>
<h1 id="onboarding-title">What software do you write in?</h1>
</div>
<form asp-controller="Onboarding" asp-action="WritingSoftware" method="post">
<div class="text-danger mb-3" asp-validation-summary="ModelOnly"></div>
<div class="onboarding-option-grid onboarding-option-grid--software">
@foreach (var option in Model.Options)
{
<label class="onboarding-option-card">
<input type="radio" name="WritingSoftware" value="@option.Value" checked="@(option.Value == Model.WritingSoftware)" required />
<span>
<strong>
@option.Title
@if (!string.IsNullOrWhiteSpace(option.Badge))
{
<em>@option.Badge</em>
}
</strong>
<small>@option.Description</small>
</span>
</label>
}
</div>
<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>
</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>
</div>
<dl class="onboarding-summary">
<div>
<dt>Writing journey</dt>
<dd>@DisplayJourney(Model.WritingJourney)</dd>
</div>
<div>
<dt>Writing software</dt>
<dd>@DisplaySoftware(Model.WritingSoftware)</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>
</form>
<a class="btn btn-primary" asp-controller="Projects" asp-action="Index">Back to projects</a>
</div>
}
</div>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@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"
};
}

View File

@ -33,6 +33,18 @@
</section>
}
@if (Model.OnboardingNudge.ShouldShow)
{
<section class="onboarding-dashboard-nudge" aria-label="PlotDirector setup">
<div>
<p class="eyebrow">First setup</p>
<h2>Set up PlotDirector around the way you write</h2>
<p>Answer a few quick questions so PlotDirector can guide your first project.</p>
</div>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="Index">@Model.OnboardingNudge.ButtonText</a>
</section>
}
@if (TempData["ArchiveMessage"] is string archiveMessage)
{
<div class="alert alert-success">@archiveMessage</div>

View File

@ -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 @@
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/plotline-theme.css" asp-append-version="true" />
</environment>
<link rel="stylesheet" href="~/css/onboarding.css" asp-append-version="true" />
<link rel="icon" href="~/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="~/favicon.svg" />
@ -153,9 +156,12 @@
@if (User.Identity?.IsAuthenticated == true)
{
<div class="d-flex align-items-center gap-2 flex-wrap me-sm-1">
<button class="btn btn-outline-secondary btn-sm word-companion-follow-toggle" type="button" data-word-companion-follow-toggle aria-pressed="false">
Follow Word Companion
</button>
@if (showWordCompanionHeader)
{
<button class="btn btn-outline-secondary btn-sm word-companion-follow-toggle" type="button" data-word-companion-follow-toggle aria-pressed="false">
Follow Word Companion
</button>
}
<a class="btn btn-outline-secondary btn-sm" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
Feature Requests
@if (featureRequestAttentionCount > 0)
@ -180,6 +186,7 @@
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="accountMenuButton">
<li><a class="dropdown-item" asp-controller="ManageAccount" asp-action="Index">Manage Account</a></li>
<li><a class="dropdown-item" asp-controller="Onboarding" asp-action="Index">Setup preferences</a></li>
<li><a class="dropdown-item" asp-controller="ManageAccount" asp-action="Subscription">Billing / Subscription</a></li>
<li><hr class="dropdown-divider" /></li>
<li>

View File

@ -0,0 +1,183 @@
.onboarding-shell {
min-height: calc(100vh - 170px);
display: grid;
place-items: start center;
padding: 2rem 0 3rem;
}
.onboarding-panel {
width: min(920px, 100%);
background: var(--bs-body-bg);
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
box-shadow: 0 18px 50px rgba(31, 42, 68, .08);
padding: clamp(1.25rem, 3vw, 2.5rem);
}
.onboarding-progress {
display: grid;
gap: .55rem;
margin-bottom: 2rem;
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-progress-track {
height: .55rem;
overflow: hidden;
border-radius: 999px;
background: rgba(31, 42, 68, .12);
}
.onboarding-progress-track span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--bs-primary);
}
.onboarding-copy {
max-width: 680px;
margin-bottom: 1.5rem;
}
.onboarding-copy h1 {
margin-bottom: .75rem;
font-size: clamp(2rem, 4vw, 3.25rem);
}
.onboarding-copy p:not(.eyebrow) {
font-size: 1.08rem;
color: var(--bs-secondary-color);
}
.onboarding-option-grid {
display: grid;
gap: .9rem;
}
.onboarding-option-grid--software {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.onboarding-option-card {
position: relative;
display: block;
cursor: pointer;
}
.onboarding-option-card input {
position: absolute;
opacity: 0;
}
.onboarding-option-card span {
display: grid;
gap: .35rem;
min-height: 100%;
padding: 1rem 1.1rem;
border: 1px solid rgba(31, 42, 68, .16);
border-radius: 8px;
background: rgba(255, 255, 255, .72);
transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
.onboarding-option-card strong {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
color: var(--bs-body-color);
}
.onboarding-option-card small {
color: var(--bs-secondary-color);
line-height: 1.45;
}
.onboarding-option-card em {
flex: 0 0 auto;
border-radius: 999px;
background: rgba(47, 111, 99, .12);
color: #2f6f63;
font-size: .72rem;
font-style: normal;
padding: .2rem .55rem;
}
.onboarding-option-card input:checked + span {
border-color: var(--bs-primary);
box-shadow: 0 0 0 3px rgba(47, 111, 99, .16);
}
.onboarding-option-card:hover span {
transform: translateY(-1px);
}
.onboarding-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .75rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.onboarding-summary {
display: grid;
gap: .8rem;
margin: 0;
}
.onboarding-summary div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: .9rem 1rem;
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
}
.onboarding-summary dt {
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-summary dd {
margin: 0;
}
.onboarding-dashboard-nudge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
padding: 1.15rem 1.25rem;
border: 1px solid rgba(47, 111, 99, .18);
border-radius: 8px;
background: rgba(47, 111, 99, .08);
}
.onboarding-dashboard-nudge h2 {
margin: 0 0 .35rem;
font-size: 1.25rem;
}
.onboarding-dashboard-nudge p:not(.eyebrow) {
margin: 0;
color: var(--bs-secondary-color);
}
@media (max-width: 720px) {
.onboarding-option-grid--software {
grid-template-columns: 1fr;
}
.onboarding-dashboard-nudge,
.onboarding-summary div {
align-items: stretch;
flex-direction: column;
}
}