Phase 20A Story Intelligence Onboarding.
This commit is contained in:
parent
0f7b6a8116
commit
c8dc116862
73
PlotLine/Controllers/OnboardingController.cs
Normal file
73
PlotLine/Controllers/OnboardingController.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
82
PlotLine/Data/OnboardingRepository.cs
Normal file
82
PlotLine/Data/OnboardingRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
PlotLine/Models/OnboardingModels.cs
Normal file
40
PlotLine/Models/OnboardingModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -128,6 +128,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
|
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
|
||||||
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
||||||
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
||||||
|
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
||||||
@ -169,6 +170,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
|
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
|
||||||
builder.Services.AddScoped<IImportService, ImportService>();
|
builder.Services.AddScoped<IImportService, ImportService>();
|
||||||
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
|
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
|
||||||
|
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||||
|
|||||||
@ -427,6 +427,7 @@ public sealed class ProjectService(
|
|||||||
IWritingScheduleRepository writingSchedule,
|
IWritingScheduleRepository writingSchedule,
|
||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
|
IOnboardingService onboarding,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IHardDeleteFileCleanupService fileCleanup,
|
IHardDeleteFileCleanupService fileCleanup,
|
||||||
ILogger<ProjectService> logger) : IProjectService
|
ILogger<ProjectService> logger) : IProjectService
|
||||||
@ -449,7 +450,8 @@ public sealed class ProjectService(
|
|||||||
{
|
{
|
||||||
ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
||||||
ArchivedProjects = archivedProjects.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()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
225
PlotLine/Services/OnboardingService.cs
Normal file
225
PlotLine/Services/OnboardingService.cs
Normal 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."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
182
PlotLine/Sql/111_Phase20A_StoryIntelligenceOnboarding.sql
Normal file
182
PlotLine/Sql/111_Phase20A_StoryIntelligenceOnboarding.sql
Normal 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
|
||||||
@ -26,6 +26,7 @@ public sealed class ProjectIndexViewModel
|
|||||||
public IReadOnlyList<ProjectIndexCardViewModel> ActiveProjects { get; init; } = [];
|
public IReadOnlyList<ProjectIndexCardViewModel> ActiveProjects { get; init; } = [];
|
||||||
public IReadOnlyList<ProjectIndexCardViewModel> ArchivedProjects { get; init; } = [];
|
public IReadOnlyList<ProjectIndexCardViewModel> ArchivedProjects { get; init; } = [];
|
||||||
public TrialVisibilityViewModel? Trial { get; init; }
|
public TrialVisibilityViewModel? Trial { get; init; }
|
||||||
|
public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ProjectIndexCardViewModel
|
public sealed class ProjectIndexCardViewModel
|
||||||
|
|||||||
40
PlotLine/ViewModels/OnboardingViewModels.cs
Normal file
40
PlotLine/ViewModels/OnboardingViewModels.cs
Normal 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";
|
||||||
|
}
|
||||||
137
PlotLine/Views/Onboarding/Index.cshtml
Normal file
137
PlotLine/Views/Onboarding/Index.cshtml
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -33,6 +33,18 @@
|
|||||||
</section>
|
</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)
|
@if (TempData["ArchiveMessage"] is string archiveMessage)
|
||||||
{
|
{
|
||||||
<div class="alert alert-success">@archiveMessage</div>
|
<div class="alert alert-success">@archiveMessage</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject IFeatureRequestService FeatureRequestService
|
@inject IFeatureRequestService FeatureRequestService
|
||||||
|
@inject IOnboardingService OnboardingService
|
||||||
@{
|
@{
|
||||||
var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
|
var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
|
||||||
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
|
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
|
||||||
@ -14,6 +15,7 @@
|
|||||||
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
|
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
|
||||||
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
|
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
|
||||||
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
|
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
|
||||||
|
var showWordCompanionHeader = User.Identity?.IsAuthenticated == true && await OnboardingService.ShouldShowWordCompanionHeaderAsync();
|
||||||
var seoMetadata = ViewData["Seo"] as SeoMetadata;
|
var seoMetadata = ViewData["Seo"] as SeoMetadata;
|
||||||
var defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string);
|
var defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string);
|
||||||
var seoTitle = ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
|
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/site.css" asp-append-version="true" />
|
||||||
<link rel="stylesheet" href="~/css/plotline-theme.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/plotline-theme.css" asp-append-version="true" />
|
||||||
</environment>
|
</environment>
|
||||||
|
<link rel="stylesheet" href="~/css/onboarding.css" asp-append-version="true" />
|
||||||
|
|
||||||
<link rel="icon" href="~/favicon.ico" sizes="any" />
|
<link rel="icon" href="~/favicon.ico" sizes="any" />
|
||||||
<link rel="icon" type="image/svg+xml" href="~/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="~/favicon.svg" />
|
||||||
@ -153,9 +156,12 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap me-sm-1">
|
<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">
|
@if (showWordCompanionHeader)
|
||||||
Follow Word Companion
|
{
|
||||||
</button>
|
<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">
|
<a class="btn btn-outline-secondary btn-sm" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
|
||||||
Feature Requests
|
Feature Requests
|
||||||
@if (featureRequestAttentionCount > 0)
|
@if (featureRequestAttentionCount > 0)
|
||||||
@ -180,6 +186,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="accountMenuButton">
|
<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="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><a class="dropdown-item" asp-controller="ManageAccount" asp-action="Subscription">Billing / Subscription</a></li>
|
||||||
<li><hr class="dropdown-divider" /></li>
|
<li><hr class="dropdown-divider" /></li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
183
PlotLine/wwwroot/css/onboarding.css
Normal file
183
PlotLine/wwwroot/css/onboarding.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user