feat(onboarding): BB welcome modal + 25-step guided tour

Closes the recurring "I added the printer but it isn't connecting" +
  "where is X feature" cluster that drove ~1/3 of invalid-tagged issues.
  New users now see a friendly welcome modal on first load and can take
  a guided walkthrough that highlights the load-bearing UI surface
  step-by-step.

  Backend: two new nullable columns on users (onboarding_status
  VARCHAR(64), onboarding_snoozed_until TIMESTAMP) added via inline
  migration with a one-shot backfill that marks every existing user as
  dismissed_at_migration -- pre-existing installs never see the welcome
  modal. GET + PATCH /api/v1/users/me/onboarding round-trip the state;
  OnboardingUpdate schema rejects dismissed_at_migration from the API
  (migration-internal only), validates the tour_in_progress:<step_id>
  form, and enforces snooze coherence (snoozed_until required only when
  status is snoozed). _users_column_exists helper mirrors
  _api_keys_column_exists and gates the backfill so restarts after
  new-user signups do not clobber the welcome-eligible NULL state.
  SQLite and Postgres both verified end-to-end.

  Frontend architecture: OnboardingProvider wraps the app inside
  AuthProvider; reads from the backend when auth is on, falls back to
  localStorage (bambuddy.onboarding_status + onboarding_snoozed_until)
  when auth is off so no-auth installs still get the welcome
  experience. A loadFailed gate prevents the welcome modal from popping
  over a backend outage -- we cannot distinguish "new user" from "GET
  errored" so we stay silent. OnboardingFlow driver picks between the
  Phase 0 welcome modal, the Phase 0.2 about modal, and the
  step-by-step TourEngine based on persisted status. Mounted inside
  BrowserRouter so the route guard's useLocation has its context (a
  sibling-of-Router placement crashed at runtime).

  Route guard: the overlay never renders on /setup, /login,
  /spoolbuddy/*, /camera/*, /overlay/*, or while requiresSetup is
  true -- fresh installs walk through the existing /setup flow
  uninterrupted, and the SpoolBuddy kiosk / OBS overlay / camera-popout
  windows never get a modal slapped over them.

  Tour engine: 25 steps targeting existing data-tour anchors --
  add-printer -> verify-connection -> printer-card sub-tour x5 (status
  row, AMS row, camera, controls, customize menu) -> add-spool ->
  bambu-cloud-sync -> sidebar overview x6 -> vp -> slicer-api ->
  makerworld -> obico -> integrations -> notifications -> users ->
  groups -> sso -> outro. Per-step route navigation via useNavigate,
  anchor polling at 100ms intervals with a 3-second cap (pages need a
  beat after navigation; never spin forever), box-shadow dimmed-
  spotlight cutout that pointer-events-through so the user can still
  interact, smart modal positioning (sidebar anchors to the right;
  page anchors below or flipped above based on viewport room),
  Back/Next/Skip with Escape as Skip, persistence on every Back/Next
  so mid-tour reloads resume at the same step.

  Conditional skip: each step exposes a skipIf(ctx) evaluated against
  at least one printer exists; verify-connection + the entire card
  sub-tour skip when no printer exists; makerworld skips when the user
  lacks makerworld:view (the sidebar entry is permission-gated and the
  anchor would not resolve); users / groups / sso skip when auth is
  off. Pre-render gate means the user never sees a flash of a step
  that is about to skip.

  Phase 1.1 "Lock the front door" auth step explicitly removed from
  the live tour -- /setup already prompts for the auth choice on fresh
  installs, and users who deliberately chose no-auth must not be
  nudged to enable it. The auth-card anchor stays for any future
  privacy-checkup surface.

  BB mascot: hero pose + 5 named poses (started / walk / almost /
  allset / help) sliced from the character sheet via Pillow into
  public/img/bb_*.webp. MascotIcon component takes a pose prop;
  per-step pose mapping in tourSteps.ts. TourLauncher sits in the
  sidebar footer as a BB icon that relaunches the tour from step 0
  and consumes the [data-tour="help-icon"] selector.

  WikiHelpIcon component lands the per-page question-mark icon on
  Queue / Archives / Stats / Maintenance / Files / Projects /
  Inventory -- opens the matching wiki.bambuddy.cool/{path}/ page in
  a new tab (chose new-tab over iframe because the wiki sets
  X-Frame-Options: DENY).

  27 new data-tour anchors across PrintersPage / SettingsPage /
  ProfilesPage / InventoryPage / Layout. i18n: 138 new keys under a
  new onboarding.* namespace, real translations in every locale
  (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) -- no
  IDENTICAL_TO_EN_ALLOWED entries added.

  Backend tests: 12 in TestOnboardingAPI cover the round-trips and the
  validator branches. Frontend tests: 61 across 7 files -- anchor
  presence backstop, Phase 0 modal interaction, tour engine + step
  helpers, provider state machine (auth-on/off split, PATCH error
  fall-through, localStorage round-trip), route guard for every
  suppress path, WikiHelpIcon href/target/aria. Backend pytest
  5723/5723; frontend vitest 2154/2154; ESLint clean; frontend build
  clean; i18n parity clean at 5233 leaves x 11 locales.

  Companion docs: docs/onboarding-tour-plan.md carries the design
  (Phase 0-5 step inventory, anchor selector list, state model, asset
  inventory, resolved decisions, Implementation Status section that
  is authoritative for current shipped state).
This commit is contained in:
maziggy
2026-06-10 10:06:42 +02:00
parent e737c84c6e
commit df66924066
62 changed files with 4506 additions and 8870 deletions
+53
View File
@@ -34,6 +34,7 @@ from backend.app.models.user import User
from backend.app.models.user_otp_code import UserOTPCode
from backend.app.models.user_totp import UserTOTP
from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
from backend.app.schemas.onboarding import OnboardingResponse, OnboardingUpdate
from backend.app.services.email_service import (
create_welcome_email_from_template,
generate_secure_password,
@@ -526,3 +527,55 @@ async def change_own_password(
pass # Decode failure is harmless — token is already invalidated by password_changed_at
return {"message": "Password changed successfully"}
@router.get("/me/onboarding", response_model=OnboardingResponse)
async def get_own_onboarding(
current_user: User | None = Depends(get_current_user_optional),
):
"""Return the current user's onboarding tour state.
Frontend polls this on app boot to decide whether to show the welcome
modal, snooze it, or skip straight to the dashboard. See
docs/onboarding-tour-plan.md Appendix B for the state model.
"""
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
return OnboardingResponse(
status=current_user.onboarding_status,
snoozed_until=current_user.onboarding_snoozed_until,
)
@router.patch("/me/onboarding", response_model=OnboardingResponse)
async def update_own_onboarding(
update: OnboardingUpdate,
current_user: User | None = Depends(get_current_user_optional),
db: AsyncSession = Depends(get_db),
):
"""Update the current user's onboarding tour state."""
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user.onboarding_status = update.status
user.onboarding_snoozed_until = update.snoozed_until
await db.commit()
return OnboardingResponse(
status=user.onboarding_status,
snoozed_until=user.onboarding_snoozed_until,
)