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<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>();
|
||||
|
||||
@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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> ArchivedProjects { get; init; } = [];
|
||||
public TrialVisibilityViewModel? Trial { get; init; }
|
||||
public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new();
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
@ -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>
|
||||
|
||||
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