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
@@ -260,7 +260,7 @@ public class QuotesController : Controller
/// the customer even if operating costs have changed since the quote was created.
/// Also verifies that ConvertedToJobId still points to a live job (clears stale references).
/// </summary>
public async Task<IActionResult> Details(int? id)
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
{
if (id == null)
{
@@ -435,6 +435,21 @@ public class QuotesController : Controller
.OrderBy(c => c.Text)
.ToList();
var quotePrefs = await GetCompanyPreferencesAsync(currentUser!.CompanyId);
if (guidedActivation == AppConstants.GuidedActivation.QuoteCreatedStep
&& quotePrefs?.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath
&& quotePrefs.FirstWorkflowCompleted == false)
{
ViewBag.GuidedActivationMode = AppConstants.GuidedActivation.QuoteFirstPath;
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
{
Show = true,
Title = "This is the quote you would send to your customer.",
Message = "Next, convert it into a job so it moves into your real production workflow.",
ActionText = "Convert to Job"
};
}
return View(quoteDto);
}
catch (Exception ex)
@@ -627,7 +642,7 @@ public class QuotesController : Controller
/// Optionally pre-selects a customer when <paramref name="customerId"/> is provided (e.g. when
/// navigating from the Customer Details page using the "New Quote" shortcut).
/// </summary>
public async Task<IActionResult> Create(int? customerId)
public async Task<IActionResult> Create(int? customerId, string? guidedActivation = null)
{
try
{
@@ -682,6 +697,17 @@ public class QuotesController : Controller
CustomerId = customerId
};
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
{
var draft = GuidedActivationDefaults.BuildQuoteDraft(customerId);
dto.CustomerId = draft.CustomerId;
dto.Description = draft.Description;
dto.Notes = draft.Notes;
dto.QuoteItems = draft.QuoteItems;
}
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
catch (Exception ex)
@@ -703,7 +729,7 @@ public class QuotesController : Controller
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateQuoteDto dto)
public async Task<IActionResult> Create(CreateQuoteDto dto, string? guidedActivation = null)
{
_logger.LogInformation("=== CREATE QUOTE POST ACTION CALLED ===");
_logger.LogInformation("IsForProspect: {IsForProspect}", dto.IsForProspect);
@@ -832,6 +858,7 @@ public class QuotesController : Controller
await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0);
await SetMeasurementViewBagAsync();
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
@@ -1134,7 +1161,18 @@ public class QuotesController : Controller
this.SetNotificationResultToast(quoteCreateNotifLog);
}
await StampQuoteCreatedAsync(currentUser.CompanyId);
this.ToastSuccess($"Quote {quote.QuoteNumber} created successfully!");
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
{
return RedirectToAction(nameof(Details), new
{
id = quote.Id,
guidedActivation = AppConstants.GuidedActivation.QuoteCreatedStep
});
}
return RedirectToAction(nameof(Details), new { id = quote.Id });
}
catch (Exception ex)
@@ -1145,6 +1183,7 @@ public class QuotesController : Controller
var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId);
await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0);
await SetMeasurementViewBagAsync();
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
}
@@ -2100,7 +2139,7 @@ public class QuotesController : Controller
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ConvertToJob(int id)
public async Task<IActionResult> ConvertToJob(int id, string? guidedActivation = null)
{
try
{
@@ -2231,9 +2270,20 @@ public class QuotesController : Controller
this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!");
await StampJobCreatedAsync(currentUser!.CompanyId);
// Redirect to the newly created job's details page
if (quote.ConvertedToJobId.HasValue)
{
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
{
return RedirectToAction("Details", "Jobs", new
{
id = quote.ConvertedToJobId.Value,
guidedActivation = AppConstants.GuidedActivation.JobCreatedStep
});
}
return RedirectToAction("Details", "Jobs", new { id = quote.ConvertedToJobId.Value });
}
@@ -3791,6 +3841,35 @@ public class QuotesController : Controller
return Json(new { success = true });
}
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
{
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
}
private async Task StampQuoteCreatedAsync(int companyId)
{
var prefs = await GetCompanyPreferencesAsync(companyId);
if (prefs == null || prefs.FirstQuoteCreatedAt.HasValue)
return;
prefs.FirstQuoteCreatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Recorded first quote creation for company {CompanyId}", companyId);
}
private async Task StampJobCreatedAsync(int companyId)
{
var prefs = await GetCompanyPreferencesAsync(companyId);
if (prefs == null || prefs.FirstJobCreatedAt.HasValue)
return;
prefs.FirstJobCreatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
}
}
// Request model for AJAX pricing calculation