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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user