Onboarding overhaul: slim wizard, progress widget, guided activation UX

Setup Wizard: reduced from 10 steps to 5 (Company Info → QB Migration →
Pricing Defaults → Named Ovens → Notifications). Removed Doc Numbering,
Job Settings, Payment Terms, Pricing Tiers, and Team Members steps — these
all have sensible defaults and are accessible any time in Company Settings.
Wizard now completes in ~5 minutes instead of 15–20.

Dashboard progress widget (new): "Get the most out of your shop" checklist
appears for Company Admins after wizard completion. Tracks six post-setup
activation tasks with dynamic progress badge, motivating subtitle copy,
collapsed-state persistence via localStorage, and a full completion state
("Your shop is fully set up 🎉") that replaces the checklist at 100%.
The next recommended step is highlighted with a solid CTA button and a
subtle blue row tint. Completed steps show encouraging green subtext instead
of just "Done". Widget disappears from controller when AllDone would have
caused a silent vanish — now renders the completion state instead.

Guided activation (Daily Board): rewrote the BoardIntroStep callout to lead
with "This is your shop in real time" and a plain-English description of the
board's purpose. Added a separate InstructionText field to
GuidedActivationCalloutViewModel so the "Move this job to the next stage"
action prompt renders as a distinct bold line with an arrow icon rather than
being buried in the body copy. After the stage change, the confirmation
callout now reads "Nice — your workflow just updated" to reinforce what just
happened before prompting the invoice step.

All copy passes the "shop owner, not SaaS" test: no technical jargon,
benefit-driven descriptions, natural language throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:10:47 -04:00
parent 4d27a378ac
commit 8aae30765f
30 changed files with 10870 additions and 333 deletions
@@ -311,31 +311,7 @@ public class SetupWizardController : Controller
ShopCapabilityTier = costs.ShopCapabilityTier
}),
4 => await BuildStep4ViewAsync(GetCompanyId()),
5 => View("Step5", new WizardStep3Dto
{
QuoteNumberPrefix = prefs.QuoteNumberPrefix,
JobNumberPrefix = prefs.JobNumberPrefix,
InvoiceNumberPrefix = !string.IsNullOrWhiteSpace(prefs.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV",
QtAccentColor = prefs.QtAccentColor,
InAccentColor = prefs.InAccentColor,
WoAccentColor = prefs.WoAccentColor
}),
6 => View("Step6", new WizardStep5Dto
{
DefaultJobPriority = prefs.DefaultJobPriority,
RequireCustomerPO = prefs.RequireCustomerPO,
AllowCustomerApproval = prefs.AllowCustomerApproval,
}),
7 => View("Step7", new WizardStep4Dto
{
DefaultPaymentTerms = prefs.DefaultPaymentTerms,
DefaultQuoteValidityDays = prefs.DefaultQuoteValidityDays,
DefaultTurnaroundDays = prefs.DefaultTurnaroundDays,
QtDefaultTerms = prefs.QtDefaultTerms,
QtFooterNote = prefs.QtFooterNote
}),
8 => await BuildStep8ViewAsync(GetCompanyId()),
9 => View("Step9", new WizardStep7Dto
5 => View("Step9", new WizardStep7Dto
{
EmailNotificationsEnabled = prefs.EmailNotificationsEnabled,
EmailFromAddress = prefs.EmailFromAddress,
@@ -351,7 +327,6 @@ public class SetupWizardController : Controller
DueDateWarningDays = prefs.DueDateWarningDays,
MaintenanceAlertDays = prefs.MaintenanceAlertDays
}),
10 => await BuildStep10ViewAsync(GetCompanyId()),
_ => RedirectToAction("Step", new { step = 1 })
};
}
@@ -405,53 +380,6 @@ public class SetupWizardController : Controller
return View("Step4", dto);
}
/// <summary>
/// Builds the view model for Step 8 (Pricing Tiers) by loading existing tiers and serializing
/// them as camelCase JSON for the client-side tier management table.
/// CamelCase serialization is required here because the JavaScript that reads this JSON expects
/// camelCase property names (e.g., <c>tierName</c> not <c>TierName</c>), unlike the oven step
/// which uses PascalCase — a discrepancy inherited from different JS widget implementations.
/// </summary>
private async Task<IActionResult> BuildStep8ViewAsync(int companyId)
{
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted);
var camelCase = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var dto = new WizardPricingTiersStepDto
{
TiersJson = existing.Any()
? JsonSerializer.Serialize(existing.OrderBy(t => t.Id).Select(t => new WizardPricingTierDto
{
Id = t.Id,
TierName = t.TierName,
Description = t.Description,
DiscountPercent = t.DiscountPercent
}), camelCase)
: null
};
return View("Step8", dto);
}
/// <summary>
/// Builds the view model for Step 10 (Team Members) by loading existing non-admin users so they
/// can be displayed as read-only in the view.
/// Only non-admin company users are shown because the wizard's team-member step is designed for
/// adding shop workers and managers; the CompanyAdmin who is running the wizard is already
/// implied. Showing existing members prevents the wizard user from accidentally creating
/// duplicates of accounts that were added outside the wizard flow.
/// </summary>
private async Task<IActionResult> BuildStep10ViewAsync(int companyId)
{
// Load existing non-admin team members so they're shown as read-only in the view
var existingUsers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive
&& u.CompanyRole != AppConstants.CompanyRoles.CompanyAdmin)
.OrderBy(u => u.LastName).ThenBy(u => u.FirstName)
.Select(u => new { u.FirstName, u.LastName, u.Email, u.CompanyRole })
.ToListAsync();
ViewBag.ExistingTeamMembers = existingUsers;
return View("Step10", new WizardStep9Dto());
}
// ─── POST Steps ───────────────────────────────────────────────────────────
/// <summary>
@@ -675,138 +603,15 @@ public class SetupWizardController : Controller
return RedirectToStep(5);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep5(WizardStep3Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5;
if (!ModelState.IsValid) return View("Step5", model);
prefs.QuoteNumberPrefix = model.QuoteNumberPrefix;
prefs.JobNumberPrefix = model.JobNumberPrefix;
prefs.InvoiceNumberPrefix = model.InvoiceNumberPrefix;
prefs.QtAccentColor = model.QtAccentColor;
prefs.InAccentColor = model.InAccentColor;
prefs.WoAccentColor = model.WoAccentColor;
MarkDone(prefs, 5);
await _unitOfWork.CompleteAsync();
return RedirectToStep(6);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep6(WizardStep5Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 6;
if (!ModelState.IsValid) return View("Step6", model);
prefs.DefaultJobPriority = model.DefaultJobPriority;
prefs.RequireCustomerPO = model.RequireCustomerPO;
prefs.AllowCustomerApproval = model.AllowCustomerApproval;
MarkDone(prefs, 6);
await _unitOfWork.CompleteAsync();
return RedirectToStep(7);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep7(WizardStep4Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 7;
if (!ModelState.IsValid) return View("Step7", model);
prefs.DefaultPaymentTerms = model.DefaultPaymentTerms;
prefs.DefaultQuoteValidityDays = model.DefaultQuoteValidityDays;
prefs.DefaultTurnaroundDays = model.DefaultTurnaroundDays;
prefs.QtDefaultTerms = model.QtDefaultTerms;
prefs.QtFooterNote = model.QtFooterNote;
MarkDone(prefs, 7);
await _unitOfWork.CompleteAsync();
return RedirectToStep(8);
}
/// <summary>
/// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as
/// <see cref="PostStep4"/>: existing tiers updated in place, new ones inserted, removed ones
/// soft-deleted. Tiers with a blank <c>TierName</c> are silently ignored so the client-side
/// table's empty placeholder rows do not produce invalid records. JsonException is caught and
/// logged rather than thrown so a malformed JSON payload (e.g., from a broken browser extension)
/// still advances the wizard rather than stopping the admin from completing setup.
/// Saves notification preferences from Step 5 (the final step). Marks the wizard complete
/// and hands off to the Guided Activation flow.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep8(WizardPricingTiersStepDto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.TiersJson))
{
try
{
var tiers = JsonSerializer.Deserialize<List<WizardPricingTierDto>>(model.TiersJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (tiers != null)
{
var validTiers = tiers.Where(t => !string.IsNullOrWhiteSpace(t.TierName)).ToList();
if (validTiers.Count > 0)
{
var existing = (await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted))
.ToDictionary(t => t.Id);
var submittedIds = validTiers.Where(t => t.Id > 0).Select(t => t.Id).ToHashSet();
// Soft-delete tiers that were removed from the list
foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id)))
await _unitOfWork.PricingTiers.SoftDeleteAsync(e.Id);
foreach (var t in validTiers)
{
if (t.Id > 0 && existing.TryGetValue(t.Id, out var record))
{
// Update in place
record.TierName = t.TierName.Trim();
record.Description = t.Description?.Trim();
record.DiscountPercent = t.DiscountPercent;
await _unitOfWork.PricingTiers.UpdateAsync(record);
}
else
{
await _unitOfWork.PricingTiers.AddAsync(new PricingTier
{
CompanyId = companyId,
TierName = t.TierName.Trim(),
Description = t.Description?.Trim(),
DiscountPercent = t.DiscountPercent,
IsActive = true
});
}
}
await _unitOfWork.CompleteAsync();
}
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize pricing tiers JSON in wizard step 8");
}
}
MarkDone(prefs, 8);
await _unitOfWork.CompleteAsync();
return RedirectToStep(9);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep9(WizardStep7Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 9;
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5;
if (!ModelState.IsValid) return View("Step9", model);
@@ -824,83 +629,9 @@ public class SetupWizardController : Controller
prefs.DueDateWarningDays = model.DueDateWarningDays;
prefs.MaintenanceAlertDays = model.MaintenanceAlertDays;
MarkDone(prefs, 9);
await _unitOfWork.CompleteAsync();
return RedirectToStep(10);
}
/// <summary>
/// Creates team member accounts from Step 10 (Invite Team), assigns each user a company role,
/// and also maps them to the legacy ASP.NET Identity role system for policy-based authorization.
/// The dual-role assignment (CompanyRole + Identity role) is required because authorization
/// policies in this app evaluate both the legacy role claim and the <c>CompanyRole</c> property.
/// Users with emails that already exist are silently skipped so re-submitting the wizard after
/// a partial failure does not attempt to create duplicates. Setting <c>SetupWizardCompleted = true</c>
/// here hides the wizard prompt from the dashboard going forward.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep10(WizardStep9Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.MembersJson))
{
try
{
var members = JsonSerializer.Deserialize<List<WizardTeamMemberDto>>(model.MembersJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (members != null)
{
foreach (var m in members.Where(m => !string.IsNullOrWhiteSpace(m.Email)
&& !string.IsNullOrWhiteSpace(m.Password)))
{
var existing = await _userManager.FindByEmailAsync(m.Email);
if (existing != null) continue;
var validRoles = new[] { AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Viewer };
var companyRole = validRoles.Contains(m.CompanyRole) ? m.CompanyRole : AppConstants.CompanyRoles.Worker;
var user = new ApplicationUser
{
UserName = m.Email, Email = m.Email, EmailConfirmed = true,
FirstName = m.FirstName, LastName = m.LastName,
CompanyId = companyId, CompanyRole = companyRole, IsActive = true,
CanManageJobs = true, CanManageCustomers = true, CanCreateQuotes = true,
CanManageCalendar = true, CanViewCalendar = true, CanViewProducts = true
};
var result = await _userManager.CreateAsync(user, m.Password);
if (result.Succeeded)
{
var legacyRole = companyRole switch
{
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
_ => AppConstants.Roles.ReadOnly
};
await _userManager.AddToRoleAsync(user, legacyRole);
}
else
{
_logger.LogWarning("Failed to create wizard user {Email}: {Errors}",
m.Email, string.Join(", ", result.Errors.Select(e => e.Description)));
}
}
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize team members JSON in wizard step 10");
}
}
MarkDone(prefs, 10);
MarkDone(prefs, 5);
prefs.SetupWizardCompleted = true;
// Record who completed the wizard and when so SuperAdmins can see completion status per-user.
var currentUser = await _userManager.GetUserAsync(User);
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
@@ -909,7 +640,7 @@ public class SetupWizardController : Controller
: User.Identity?.Name;
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Complete));
return RedirectToAction("Start", "GuidedActivation");
}
// ─── Skip ─────────────────────────────────────────────────────────────────
@@ -927,6 +658,18 @@ public class SetupWizardController : Controller
{
var (_, prefs, _) = await LoadCompanyDataAsync();
MarkSkipped(prefs, step);
if (step >= WizardProgressDto.TotalSteps)
{
prefs.SetupWizardCompleted = true;
var currentUser = await _userManager.GetUserAsync(User);
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
prefs.SetupWizardCompletedByName = currentUser != null
? $"{currentUser.FirstName} {currentUser.LastName}".Trim()
: User.Identity?.Name;
}
await _unitOfWork.CompleteAsync();
int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1;
return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next);
@@ -975,6 +718,7 @@ public class SetupWizardController : Controller
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs);
ViewBag.ShowGuidedActivationCta = prefs.SetupWizardCompleted && !prefs.FirstWorkflowCompleted;
return View();
}
}