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:
@@ -222,7 +222,7 @@ public class InvoicesController : Controller
|
||||
/// — Whether online payments are allowed: requires plan-level permission AND an active
|
||||
/// Stripe Connect account. Both conditions must be true; per-plan override wins if set.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
@@ -257,6 +257,19 @@ public class InvoicesController : Controller
|
||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is how billing connects back to the job.",
|
||||
Message = "You’ve already seen the shop workflow. From here you can send the invoice, collect payment, or head back to the dashboard.",
|
||||
ActionText = "Go to Dashboard",
|
||||
ActionController = "Dashboard",
|
||||
ActionName = "Index"
|
||||
};
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -285,7 +298,7 @@ public class InvoicesController : Controller
|
||||
/// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to
|
||||
/// account 4000 (default revenue) if no catalog item is linked.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? jobId)
|
||||
public async Task<IActionResult> Create(int? jobId, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -429,6 +442,7 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -459,7 +473,7 @@ public class InvoicesController : Controller
|
||||
/// declared outside the lambda and assigned inside — EF requires this pattern with closures.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto)
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -469,6 +483,7 @@ public class InvoicesController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -476,6 +491,7 @@ public class InvoicesController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -487,6 +503,7 @@ public class InvoicesController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "An invoice already exists for this job.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -643,8 +660,21 @@ public class InvoicesController : Controller
|
||||
var depositMsg = pendingDeposits.Any()
|
||||
? $" {pendingDeposits.Count} deposit(s) totaling {pendingDeposits.Sum(d => d.Amount):C} auto-applied."
|
||||
: "";
|
||||
var workflowJustCompleted = await StampInvoiceCreatedAsync(currentUser.CompanyId);
|
||||
TempData["Success"] = $"Invoice {invoiceNumber} created successfully.{depositMsg}{gcMsg}";
|
||||
return RedirectToAction(nameof(Details), new { id = invoice.Id });
|
||||
if (!string.IsNullOrWhiteSpace(guidedActivation) || workflowJustCompleted)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = invoice!.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = invoice!.Id
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -652,6 +682,7 @@ public class InvoicesController : Controller
|
||||
TempData["Error"] = "An error occurred while creating the invoice.";
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -2387,4 +2418,30 @@ public class InvoicesController : Controller
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task<bool> StampInvoiceCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null)
|
||||
return false;
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (!prefs.FirstInvoiceCreatedAt.HasValue)
|
||||
{
|
||||
prefs.FirstInvoiceCreatedAt = DateTime.UtcNow;
|
||||
changed = true;
|
||||
_logger.LogInformation("Recorded first invoice creation for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user