Files
resolutionflow/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md
Michael Chihlas ab0d40c1e2 docs(plan): self-serve signup & onboarding implementation plans
Adds two phase plans alongside the spec at
docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md:

- Phase 1 (backend foundation, 26 tasks across 8 sub-phases A-H):
  schema migrations, subscription model + new guards, BillingService,
  Stripe webhook handler extension, OAuth callbacks, email verification
  auto-send + email-match enforcement, account-invite extensions,
  GET /billing/state, pilot user backfill. Step-by-step granularity
  with full code blocks per writing-plans skill.

- Phase 2 (frontend + cutover, 21 tasks across 7 sub-phases I-O):
  Phase-1-deferred endpoints, useBillingStore + hooks + gating
  components, register redesign + OAuth buttons + accept-invite,
  welcome wizard, dashboard redesign, pricing page + contact-sales,
  beta-signup deprecation, cutover. Higher-altitude — defines
  contracts, acceptance criteria, integration tests; leaves
  component-detail decisions to implementer.

Each phase ends in a mergeable PR. Cutover is gated behind
SELF_SERVE_ENABLED + VITE_SELF_SERVE_ENABLED. Execution deferred to
a future session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00

969 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Self-Serve Signup & Onboarding — Phase 2: Frontend + Cutover
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
>
> **Granularity note:** Unlike Phase 1, this plan defines *contracts and acceptance criteria* — not every component detail. Implementers exercise judgment on internal structure (hooks vs. props, file splits, CSS organization) as long as the contracts hold and integration tests pass. Steps use checkbox (`- [ ]`) syntax for tracking; each task is one mergeable PR.
**Goal:** Layer the user-facing self-serve flow on top of the Phase 1 backend foundation — pricing page, OAuth buttons + register redesign, welcome wizard, dashboard redesign with trial pill + next-step card + checklist, accept-invite page, sales contact form, billing portal — gated behind `SELF_SERVE_ENABLED` and `VITE_SELF_SERVE_ENABLED` until cutover.
**Architecture:** Frontend reads billing state from a new `useBillingStore` Zustand store fed by `GET /billing/state`. New routes layer on the existing React Router v7 + lazyWithRetry pattern. Wizard state is server-persisted via `PATCH /users/me/onboarding-step`. Authenticated routes mount under existing `AppLayout`; public routes (pricing, contact-sales, accept-invite, verify-email) are top-level. Cutover is two flag flips: backend `SELF_SERVE_ENABLED=true`, frontend `VITE_SELF_SERVE_ENABLED=true`.
**Tech Stack:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config), Zustand (immer + zundo), React Router v7, Axios, Lucide. Backend additions: a few small endpoints (Phase 1 left them out) — see Phase I.
**Spec reference:** `docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md` (commit `bbb01ef`).
**Phase 1 reference:** `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md`.
---
## Phase Sequencing
Each phase ends in a mergeable PR. Frontend gates everything behind `VITE_SELF_SERVE_ENABLED` so the new surfaces stay invisible to public users until Phase O cutover.
| Phase | Tasks | Outcome |
|---|---|---|
| I | 2731 | Backend endpoints Phase 1 deferred + `SELF_SERVE_ENABLED` flag + `/admin/plan-limits` extension |
| J | 3234 | Frontend billing foundation: `useBillingStore`, hooks, gating components — proven against Phase 1 backend |
| K | 3537 | Auth surfaces: register redesign with OAuth buttons, accept-invite page, email-verification surfaces |
| L | 3839 | Welcome wizard — 3 steps with persistence |
| M | 4041 | Dashboard redesign — trial pill, next-step card, checklist redesign |
| N | 4244 | Public surfaces: pricing page, contact-sales form, landing-page CTA, beta-signup 307 |
| O | 4547 | Cutover: Stripe live-mode setup, internal validation, feature-flag flip |
---
## Phase I — Backend endpoints + admin extension + feature flag
### Task 27: BillingService.open_customer_portal + GET /billing/portal-session
**Outcome:** Authed users can request a Stripe-hosted Customer Portal URL for card updates and cancellation.
**Contract:**
```
GET /api/v1/billing/portal-session
→ 200 { url: string }
→ 503 when STRIPE_SECRET_KEY unset
→ 400 when account has no stripe_customer_id (must complete checkout first)
```
`BillingService.open_customer_portal(account)` creates a `stripe.billing_portal.Session` with `return_url=$FRONTEND_URL/account/billing` and returns the session URL.
**Acceptance criteria:**
- [ ] Endpoint mounted at `/billing/portal-session` and is in the `_SUBSCRIPTION_GUARD_ALLOWLIST` and `_EMAIL_VERIFICATION_ALLOWLIST` (so it works for canceled / unverified-past-grace users who need to update billing).
- [ ] Returns 400 with `{"error": "no_stripe_customer"}` when `account.stripe_customer_id is None`.
- [ ] Stripe call mocked via `respx`; happy-path test asserts shape `{url: ...}`.
**Integration test added:**
- `test_billing_portal_returns_url_for_account_with_stripe_customer`
**Commit:** `feat(billing): add BillingService.open_customer_portal + GET endpoint`
---
### Task 28: PATCH /users/me/onboarding-step
**Outcome:** Welcome wizard can persist Step 1/2/3 state to the server.
**Contract:**
```
PATCH /api/v1/users/me/onboarding-step
body: {
step: 1 | 2 | 3,
action: "complete" | "skip",
data?: {
// step 1
company_name?: string,
team_size_bucket?: "1-2"|"3-5"|"6-10"|"11-25"|"26+",
role_at_signup?: "owner"|"lead_tech"|"tech"|"other",
// step 2
primary_psa?: "connectwise"|"autotask"|"halopsa"|"none",
// step 3 has no data — invitations posted separately to /accounts/me/invites/bulk
},
}
→ 200 { onboarding_step_completed: int, onboarding_dismissed: false }
```
Writes:
- step=1 + action=complete → `accounts.name`, `accounts.team_size_bucket`, `users.role_at_signup`, `users.onboarding_step_completed=1`
- step=1 + action=skip → `users.onboarding_step_completed=1` only (no field writes)
- step=2 → `accounts.primary_psa` (only on complete) + `users.onboarding_step_completed=2`
- step=3 → `users.onboarding_step_completed=3` (the actual invites POST is separate)
Validates: `step` cannot decrease; `action="skip"` ignores the `data` payload.
**Endpoint also exposes a sibling:** `POST /users/me/onboarding-dismiss-rest` → sets `users.onboarding_dismissed=TRUE`. Used by "Skip the rest" button.
**Acceptance criteria:**
- [ ] In `_EMAIL_VERIFICATION_ALLOWLIST` (so users can move through the wizard before verifying email).
- [ ] In `_SUBSCRIPTION_GUARD_ALLOWLIST` (wizard runs during the trial; never gated).
- [ ] Refusing to decrease `step` is enforced (a step=2 PATCH followed by step=1 returns 400).
- [ ] Tests cover: complete with data writes fields; skip without data only advances step; idempotent re-PATCH of same step.
**Integration tests added:**
- `test_onboarding_step1_complete_writes_account_name_and_team_size_and_role`
- `test_onboarding_step2_skip_advances_without_psa`
- `test_onboarding_step_cannot_decrease`
- `test_onboarding_dismiss_rest_sets_flag`
**Commit:** `feat(onboarding): add PATCH /users/me/onboarding-step + dismiss-rest`
---
### Task 29: POST /sales-leads endpoint
**Outcome:** Public Talk-to-sales form has somewhere to post.
**Contract:**
```
POST /api/v1/sales-leads
body: {
email: string,
name: string,
company: string,
team_size?: string,
message?: string,
source: "pricing_page" | "register_footer" | "landing_page",
posthog_distinct_id?: string,
}
→ 201 { id: uuid, status: "received" }
```
Public — no auth required. Rate-limit: max 5 submissions per IP per hour (use existing `core.rate_limit`).
Side effects:
1. Insert `sales_leads` row.
2. Fire-and-forget `EmailService.send_sales_lead_notification` to `settings.SALES_LEAD_RECIPIENT_EMAIL` (new env var, default `sales@resolutionflow.com`).
3. Emit PostHog server-side event `talk_to_sales_form_submitted` with `source` property.
**Acceptance criteria:**
- [ ] Anti-spam: rate-limited per IP.
- [ ] Email send failure doesn't fail the request (logged warning).
- [ ] Sales-lead recipient email is configurable; defaults to a placeholder until cutover.
**Integration tests:**
- `test_sales_lead_creates_row_and_sends_notification_email`
- `test_sales_lead_rate_limited_after_5_per_hour`
**Commit:** `feat(sales): add POST /sales-leads public endpoint`
---
### Task 30: Extend /admin/plan-limits to surface plan_billing fields
**Outcome:** Super-admins can manage plan_billing (Stripe IDs, display names, prices, public/archived flags) via the same admin page they already use.
**Contract change:**
```
GET /api/v1/admin/plan-limits → list[PlanLimitWithBillingResponse]
PlanLimitWithBillingResponse extends PlanLimitResponse with:
display_name?: string
description?: string
monthly_price_cents?: int | null
annual_price_cents?: int | null
stripe_product_id?: string | null
stripe_monthly_price_id?: string | null
stripe_annual_price_id?: string | null
is_public?: bool
is_archived?: bool
sort_order?: int
PUT /api/v1/admin/plan-limits accepts the same fields; updates plan_billing
in the same transaction. If a plan_billing row doesn't exist for the plan,
PUT creates it.
```
**Acceptance criteria:**
- [ ] Single PUT round-trips both `plan_limits` and `plan_billing` in one transaction.
- [ ] Cache invalidation: `app.state.billing_cache` flushed for all accounts on the affected plan.
- [ ] No new admin page in v1 — existing `/admin/plan-limits` UI just gets new form fields.
**Integration tests:**
- `test_admin_plan_limits_get_includes_plan_billing_fields_when_present`
- `test_admin_plan_limits_put_creates_plan_billing_row`
- `test_admin_plan_limits_put_invalidates_billing_cache`
**Commit:** `feat(admin): extend /admin/plan-limits to manage plan_billing fields`
---
### Task 31: Wire SELF_SERVE_ENABLED feature flag
**Outcome:** A single flag controls whether the new public-facing self-serve flow is exposed.
**Contract:**
Backend:
- `settings.SELF_SERVE_ENABLED: bool = False` (already added in Phase 1 Task 14).
- New endpoint `GET /api/v1/config/public` (no auth) returns `{self_serve_enabled: bool, oauth_providers: ["google", "microsoft"] | []}` — frontend reads this once at load.
Frontend:
- `VITE_SELF_SERVE_ENABLED` env var (build-time bake-in per Lesson 60).
- New `useAppConfig` hook: prefers backend `/config/public` response, falls back to `VITE_SELF_SERVE_ENABLED` for build-time gating.
- Public routes (`/pricing`, `/contact-sales`, `/accept-invite`, OAuth callbacks) return 404 from the frontend router when `self_serve_enabled === false`.
- Register page hides OAuth buttons + invite-code-removed copy when flag is off (preserves the existing invite-code-required register flow).
**Acceptance criteria:**
- [ ] Flag is OFF by default in all envs except where explicitly enabled.
- [ ] When OFF: existing `/auth/register` invite-code flow still works exactly as today.
- [ ] When ON: new flows are reachable; invite-code requirement is removed (the field still exists in the schema for backward-compat but the gate-check accepts NULL).
**Integration tests:**
- `test_get_config_public_returns_self_serve_flag`
- `test_register_invite_code_required_when_self_serve_disabled` (regression)
- `test_register_invite_code_optional_when_self_serve_enabled`
**Commit:** `feat(config): add SELF_SERVE_ENABLED flag + GET /config/public`
---
## Phase J — Frontend billing foundation
### Task 32: useBillingStore Zustand store + GET /billing/state integration
**Outcome:** Frontend has a single source of truth for subscription / plan / feature state.
**Contract — store shape:**
```typescript
// frontend/src/store/billingStore.ts
interface BillingState {
subscription: {
status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary'
plan: string
current_period_start: string | null // ISO
current_period_end: string | null // ISO
cancel_at_period_end: boolean
seat_limit: number | null
has_pro_entitlement: boolean
is_paid: boolean
} | null
planBilling: {
display_name: string
description: string | null
monthly_price_cents: number | null
annual_price_cents: number | null
} | null
planLimits: Record<string, unknown>
enabledFeatures: Record<string, boolean>
isLoading: boolean
error: string | null
}
interface BillingStore extends BillingState {
fetch: () => Promise<void>
refetch: () => Promise<void>
reset: () => void
}
```
**Behavior:**
- Auto-fetches on auth-store login (subscribe to `authStore`).
- Auto-resets on logout.
- Polls every 60s while the dashboard is mounted (simple `useInterval` in a top-level component is fine — no SSE for v1).
- `refetch()` is exposed for explicit refresh after Stripe Checkout success-redirect.
**Acceptance criteria:**
- [ ] Initial state is null/empty; populates after first successful fetch.
- [ ] 401 from `/billing/state` triggers logout via existing axios interceptor (no special handling needed).
- [ ] Polling disabled when no user is logged in.
**Integration tests (Vitest):**
- `useBillingStore fetches on login and populates subscription`
- `useBillingStore resets on logout`
- `useBillingStore refetch overwrites stale data`
**Commit:** `feat(billing): add useBillingStore and /billing/state integration`
---
### Task 33: useFeature, useFeatureLimit, useTrialBanner hooks
**Outcome:** Components can ask "is this feature on?" / "how many sessions left?" / "what stage is the trial in?" without re-implementing the read.
**Contract — hook signatures:**
```typescript
// useFeature: enabled boolean for a feature key
function useFeature(flagKey: string): boolean
// useFeatureLimit: progress against a quantitative limit
function useFeatureLimit(field: keyof PlanLimits): {
used: number // from /api/v1/usage/{field} (lazy fetch, cached 60s)
limit: number | null
percentage: number | null // null when limit is null (unlimited)
isAtLimit: boolean
isLoading: boolean
}
// useTrialBanner: derives stage from subscription state
function useTrialBanner(): {
stage: 'pristine' | 'warning' | 'urgent' | 'expired' | 'complimentary' | 'paid' | 'past_due' | 'canceled' | null
daysRemaining: number | null
}
```
**Stage derivation:**
- `subscription.status === 'complimentary'``complimentary`
- `subscription.status === 'active'``paid`
- `subscription.status === 'past_due'``past_due`
- `subscription.status === 'canceled'``canceled`
- `subscription.status === 'trialing'` AND `current_period_end > now()``pristine` (>3 days), `warning` (13), `urgent` (<1)
- `subscription.status === 'trialing'` AND `current_period_end <= now()``expired`
**Acceptance criteria:**
- [ ] `useFeatureLimit` does NOT block render — returns `isLoading=true` until usage data arrives.
- [ ] `useTrialBanner` returns `null` when subscription is null (no flicker on initial load).
- [ ] All three hooks subscribe to `useBillingStore` such that updates propagate without manual refetch.
**Integration tests (Vitest):**
- `useFeature returns false when flag absent`
- `useFeatureLimit transitions isLoading → loaded`
- `useTrialBanner stage matches subscription state matrix`
**Commit:** `feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks`
---
### Task 34: FeatureGate, UpgradePrompt, EmailVerificationGate components
**Outcome:** Three drop-in components that handle the most common gating patterns. Component implementation details (props, layout, Tailwind classes) are at implementer's discretion as long as the API holds.
**Contracts:**
```tsx
// FeatureGate: render children if feature enabled, else fallback (default <UpgradePrompt />)
<FeatureGate feature="psa_integration" fallback={<UpgradePrompt feature="psa_integration" />}>
<PsaConfigPanel />
</FeatureGate>
// UpgradePrompt: standardized "this feature is on Pro" affordance with CTA
<UpgradePrompt feature="psa_integration" /> // resolves display name + plan name internally
// EmailVerificationGate: wraps protected content; renders <EmailVerificationWall /> past grace
<EmailVerificationGate>
<DashboardContent />
</EmailVerificationGate>
```
**Behavior:**
- `<FeatureGate>` reads from `useFeature(feature)`. Server-side check via `require_feature` is the security boundary; this is UX.
- `<UpgradePrompt>` CTA links to `/account/billing/select-plan`.
- `<EmailVerificationGate>` reads `users.email_verified_at` + `users.created_at` from `authStore.user`. Day 16 unverified renders children (banner shown elsewhere). Day 7+ unverified renders `<EmailVerificationWall>`.
**Acceptance criteria:**
- [ ] All three components are exported from `frontend/src/components/common/`.
- [ ] No CSS-in-JS — Tailwind classes per existing pattern.
- [ ] Lock icon + greyed style for `<UpgradePrompt>` matches the design system tokens (no `bg-accent` for non-interactive elements per design lessons).
**Integration tests (Vitest + Playwright):**
- `FeatureGate renders children when flag enabled, fallback when disabled` (Vitest)
- `UpgradePrompt CTA navigates to /account/billing/select-plan` (Vitest)
- `EmailVerificationGate renders wall on day 8 unverified user` (Vitest, mocked authStore)
**Commit:** `feat(billing): add FeatureGate, UpgradePrompt, EmailVerificationGate components`
---
## Phase K — Auth surfaces
### Task 35: Register page redesign with OAuth buttons + invite-code-optional
**Outcome:** New register flow supports email+password OR Google OR Microsoft, with promo code field collapsed (deferred per spec) and the legacy invite-code field invisible when `SELF_SERVE_ENABLED`.
**Contract:**
Frontend route stays `/register`. Component lives at `frontend/src/pages/RegisterPage.tsx` (modified, not replaced).
Top-of-page CTAs:
- **"Continue with Google"** button → opens OAuth window → on callback, POSTs `code` to `POST /api/v1/auth/google/callback` → stores tokens via existing auth-store login flow → redirects to `/welcome` (new user) or `/` (returning).
- **"Continue with Microsoft"** button → same shape against `/auth/microsoft/callback`.
- **"or sign up with email"** divider, then existing email + password form.
Removed/conditional:
- **Invite-code field** — hidden when `useAppConfig().self_serve_enabled === true`. When the flag is off, the existing required-invite-code flow runs unchanged.
- **Promo-code field** — not in v1 (deferred per spec). UI should NOT include it.
`/register?plan=pro` query param is captured into `localStorage` (`rf-intended-plan`) so `BillingService.start_trial` (already runs on Pro by default) can later be enriched OR the in-app picker can preselect.
**Acceptance criteria:**
- [ ] Email+password register call still works; auto-sends verification email per Phase 1 Task 20.
- [ ] OAuth callback creates User + Account + Subscription per Phase 1 Task 17/18; lands on `/welcome`.
- [ ] When self-serve disabled: invite-code flow visible, OAuth buttons hidden.
- [ ] When self-serve enabled: invite-code field hidden, OAuth buttons visible.
- [ ] Existing test users (`engineer@resolutionflow.example.com` etc.) can still log in via `/login` unchanged.
**Integration tests (Playwright):**
- `register email+password → verification email queued → land on /welcome`
- `register via Google OAuth (mocked provider) → land on /welcome`
- `register page hides OAuth + shows invite-code field when self_serve_enabled is false`
**Commit:** `feat(auth): redesign /register with OAuth buttons; hide invite-code under flag`
---
### Task 36: AcceptInvitePage at /accept-invite?code=...
**Outcome:** Invitee from email can join an existing account with set-password OR Google OR Microsoft.
**Contract:**
New top-level route `/accept-invite?code=<32-char-code>`. Component at `frontend/src/pages/AcceptInvitePage.tsx`.
Flow:
1. On mount, `GET /api/v1/accounts/invites/{code}/lookup` (NEW endpoint — see acceptance criteria) returns `{account_name, inviter_name, invited_email, role}` or 404/410 (expired/revoked/used).
2. Render: "Join {account_name} on ResolutionFlow" + email locked to `invited_email` + three sign-in options (set password, Google, Microsoft).
3. On submit, POST to existing `/auth/register` with `account_invite_code` and the email matching `invited_email` (per Phase 1 Task 20 enforcement).
4. OAuth path: launch provider with state including the invite code; callback POSTs `{code, account_invite_code, invited_email}` to handle linking.
5. Success → land on `/?welcome=teammate` (suppresses welcome wizard for invitees per spec).
**Backend addition needed (small):**
```
GET /api/v1/accounts/invites/{code}/lookup
→ 200 {account_name, inviter_name, invited_email, role}
→ 404 invite_invalid_or_expired_or_revoked
```
This is a public endpoint (no auth) reading account-scoped data, so uses `_admin_session_factory()` per the Phase 4 RLS pattern.
**Acceptance criteria:**
- [ ] Invalid/expired/revoked codes show a clear "ask {inviter} to resend" message with a link to email the inviter (via `mailto:`).
- [ ] Email field is locked to `invited_email` — frontend doesn't even render an editable input.
- [ ] OAuth path requires the provider's email to match `invited_email`; mismatch returns the same `invite_email_mismatch` error from Phase 1.
- [ ] Successful accept lands on `/?welcome=teammate`; the dashboard shows a "Welcome to {account_name}" toast and a checklist with "Setup shop" + "Invite a teammate" auto-marked done.
**Integration tests (Playwright):**
- `accept invite with email/password → join existing account → land on /?welcome=teammate`
- `accept invite with Google OAuth (matching email) → land on dashboard`
- `accept invite with mismatched email → see invite_email_mismatch error`
- `accept invite with expired code → see resend message`
**Commit:** `feat(auth): add /accept-invite page + lookup endpoint`
---
### Task 37: Email verification surfaces — banner, wall, /verify-email route
**Outcome:** UI for the soft 7-day grace + day-7 wall.
**Contract:**
- **`<EmailVerificationBanner />`** — thin top-of-dashboard bar visible when `users.email_verified_at IS NULL` AND grace not expired. "Resend" link calls existing `POST /auth/email/send-verification`.
- **`<EmailVerificationWall />`** — full-content replacement when grace expired. Same "Resend" CTA + a "Sign out" button.
- **`/verify-email?token=...`** — frontend route that calls existing `POST /auth/email/verify` and shows success/error state. On success, refreshes the auth store and redirects to `/?verified=1` toast.
**Acceptance criteria:**
- [ ] Banner contrasts well in dark theme (use `bg-warning-dim` per design tokens, not custom colors).
- [ ] Wall has a "Sign out" button so a user with a typo'd email can recover.
- [ ] Verification success toast does not double-fire on remount.
- [ ] If user is already verified when hitting `/verify-email`, the page shows "Already verified" rather than failing.
**Integration tests (Playwright):**
- `unverified day-1 user sees banner on dashboard`
- `unverified day-8 user sees wall, can sign out, can resend`
- `clicking verification link verifies and redirects to dashboard with toast`
**Commit:** `feat(auth): add email verification banner, wall, /verify-email page`
---
## Phase L — Welcome wizard
### Task 38: Wizard scaffold + Step 1 (Your shop)
**Outcome:** Authed users at `/welcome` see a deliberate first-impression flow that captures shop context.
**Routing:**
```
/welcome → redirects to next incomplete step or "/" if done
/welcome/step-1 → "Your shop"
/welcome/step-2 → "Your PSA"
/welcome/step-3 → "Invite your team"
```
A top-level `<WelcomeRouter />` reads `users.onboarding_step_completed` + `users.onboarding_dismissed` from authStore and dispatches:
| State | Redirect |
|---|---|
| `onboarding_dismissed === true` | `/` |
| `onboarding_step_completed >= 3` | `/` |
| `onboarding_step_completed === null/0` | `/welcome/step-1` |
| `onboarding_step_completed === 1` | `/welcome/step-2` |
| `onboarding_step_completed === 2` | `/welcome/step-3` |
**Step 1 fields (per spec):**
- Company name (pre-filled from `accounts.name`, editable)
- Team size: select from `1-2 / 3-5 / 6-10 / 11-25 / 26+`
- Your role: select from `Owner / Lead Tech / Tech / Other`
**Step 1 actions:**
- **Continue** → PATCH `/users/me/onboarding-step` `{step: 1, action: "complete", data: {...}}``/welcome/step-2`
- **Skip** → PATCH `{step: 1, action: "skip"}``/welcome/step-2`
- **Skip the rest** → POST `/users/me/onboarding-dismiss-rest``/`
**Acceptance criteria:**
- [ ] Each navigation persists state server-side before transition; refresh resumes correctly.
- [ ] Skip-the-rest is a quiet text link, not a primary button.
- [ ] Email-verification banner is visible above the wizard if user is unverified (banner persists into wizard).
**Integration tests (Playwright):**
- `new user lands on /welcome/step-1 after register`
- `step-1 Continue with all fields filled persists and advances`
- `step-1 Skip-the-rest dismisses and lands on /`
- `refresh in middle of step-1 returns to step-1 with prior data still in form (or empty if not yet saved)`
**Commit:** `feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop)`
---
### Task 39: Wizard Steps 2 (Your PSA) and 3 (Invite team)
**Outcome:** Wizard is complete; users can finish or skip individual steps.
**Step 2 fields (per spec):**
- PSA selection: tiles for `ConnectWise / Autotask / HaloPSA / No PSA yet`. Selecting one shows a quiet inline "Connect now" link that navigates to `/account/integrations` (out of wizard).
**Step 3 fields (per spec):**
- Email input rows × 3, with "+ Add another" up to 10 max
- Per-row role select: default "Tech" (maps to `engineer`), with "Viewer" option
- "Skip" and "Skip the rest" links
**Step 3 submit behavior:**
- POST `/api/v1/accounts/me/invites/bulk` with the populated rows.
- Then PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
- On success → `/?welcome=true` (shows a "You're all set" toast).
- Bulk endpoint's `failed[]` array displayed inline next to the failed email; user can retry.
**Acceptance criteria:**
- [ ] Step 2 default action is "Continue" (not "Connect now"); the inline credential entry is intentionally NOT in the wizard.
- [ ] Step 3 invites are sent (email send happens server-side per Phase 1 Task 22).
- [ ] Empty Step 3 + Skip = no invites sent; step still advances.
- [ ] Each step's persistence is independent — navigating back via browser back button respects `onboarding_step_completed`.
**Integration tests (Playwright):**
- `step-2 select ConnectWise → continue → primary_psa is set in /billing/state-equivalent or /auth/me`
- `step-3 enter 2 emails → invites visible in /accounts/me/invites + emails sent`
- `step-3 with one bad email shows partial success, user can retry`
- `wizard end-to-end: register → step-1 → step-2 → step-3 → dashboard with success toast`
**Commit:** `feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team)`
---
## Phase M — Dashboard redesign
### Task 40: Topbar trial pill + email verification banner integration
**Outcome:** Every authed page shows the right billing-state pill in the topbar.
**Contract — `<TrialPill />` placement:**
Mounts inside `AppLayout` topbar. Reads `useTrialBanner()`:
| Stage | Pill |
|---|---|
| `pristine` | "Pro trial · Nd" — info color |
| `warning` (≤3d) | "Pro trial · Nd" — warning amber |
| `urgent` (≤1d) | "Pro trial · today" — urgent (warning amber, slightly more saturated) |
| `expired` | "Trial expired — pick a plan" — clickable → `/account/billing/select-plan` |
| `paid` | tier display name (e.g., "Pro") — quiet |
| `complimentary` | "Complimentary Pro" — friendly tag, no CTA |
| `past_due` | "Payment failed — update card" — clickable → `/account/billing` |
| `canceled` | "Reactivate" — clickable → `/account/billing/select-plan` |
| `null` | hidden |
**Acceptance criteria:**
- [ ] Color tokens are existing design-system tokens (`--accent` / `--warning` / etc.) — no custom colors.
- [ ] Pill is keyboard-focusable for clickable variants.
- [ ] EmailVerificationBanner from Task 37 sits BELOW the topbar, ABOVE main content. Both can coexist.
- [ ] Mobile: pill collapses to icon + tooltip when topbar is too narrow.
**Integration tests (Playwright):**
- `complimentary user sees "Complimentary Pro" pill`
- `trialing user with 12 days remaining sees "Pro trial · 12d"`
- `expired-trial user sees clickable "Trial expired" pill`
- `past_due user sees clickable "Payment failed" pill`
**Commit:** `feat(dashboard): add TrialPill in AppLayout topbar`
---
### Task 41: Next-step card + checklist redesign + dashboard wiring
**Outcome:** Dashboard surfaces a single "next thing to do" card; full checklist available behind a toggle. Replaces the existing `OnboardingChecklist` component.
**Contract:**
- **`<NextStepCard />`** at top of dashboard content (below banner). Reads from existing `/users/onboarding-status` payload (extended in Phase 1 to drop SOLO/TEAM split — see Phase 1 Task wiring if needed; if not done, do it here).
- Shows the highest-priority incomplete item with a primary CTA button. Items in priority order:
1. Verify your email (only if unverified — hidden for OAuth signups)
2. Set up your shop (`onboarding_step_completed >= 1`)
3. Run your first FlowPilot session (existing `ran_session` check)
4. Connect your PSA (existing `connected_psa` check)
5. Invite a teammate (extend existing `invited_teammate` check)
6. Pick a plan — surfaces near trial end (only when stage is `warning` / `urgent` / `expired`)
- Below the card, "Show all setup steps" toggle expands a full checklist view (single list, no SOLO/TEAM split per spec).
**OnboardingChecklist component changes:**
- Remove `SOLO_ITEMS` / `TEAM_ITEMS` split — single unified list.
- Drop the stale `tried_ai_assistant` / "Check out the Script Builder" item entirely.
- Add "Pick a plan" item that shows when trial-banner stage is `warning` or later.
**Backend addition:**
`/api/v1/users/onboarding-status` (existing endpoint) response shape extended:
```python
class OnboardingStatus(BaseModel):
# existing
created_flow: bool
ran_session: bool
exported_session: bool
invited_teammate: bool
connected_psa: bool
is_team_user: bool # KEEP — internal logic only; no UI bifurcation
dismissed: bool # users.onboarding_dismissed
# NEW
email_verified: bool
shop_setup_done: bool # users.onboarding_step_completed >= 1
# REMOVED from new code paths (kept in payload for backward-compat during deploy):
# tried_ai_assistant: bool
```
**Acceptance criteria:**
- [ ] Old `OnboardingChecklist` widget is replaced wholesale on the dashboard route. Other pages that referenced it (none found in current code, but confirm via grep) are updated or unaffected.
- [ ] Next-step card disappears when all items are done OR `onboarding_dismissed=TRUE`.
- [ ] No SOLO/TEAM bifurcation in the checklist UI.
- [ ] Stale "Script Builder" item is gone.
**Integration tests (Playwright):**
- `dashboard for new user surfaces "Verify your email" as next step`
- `after verifying, next step is "Set up your shop"`
- `after wizard step 1, next step is "Run your first FlowPilot session"`
- `"Show all setup steps" expands to a 6-item list with no SOLO/TEAM headers`
- `Pick-a-plan appears at trial day 12, urgent at day 13, primary at day 14`
**Commit:** `feat(dashboard): replace checklist with next-step card + unified list`
---
## Phase N — Public surfaces
### Task 42: Pricing page (B-style) at /pricing
**Outcome:** Public pricing page lives at `/pricing`, gated by feature flag.
**Contract:**
Public route. Component at `frontend/src/pages/PricingPage.tsx`. Reads `plan_billing` data via a new public endpoint:
```
GET /api/v1/plans/public
→ 200 [
{
plan: string,
display_name: string,
description: string | null,
monthly_price_cents: number | null,
annual_price_cents: number | null,
max_seats: number | null, // from plan_limits
sort_order: number,
is_public: true, // filtered server-side
},
...
]
```
Page sections (per spec B):
1. Hero (one-liner + reverse-trial reassurance)
2. Three plan cards (Starter / Pro recommended / Enterprise) — Pro card has "Recommended" badge; Enterprise card has "Talk to sales" CTA → `/contact-sales`
3. Comparison table (which features in which plan) — driven by feature flag display names
4. Single testimonial slot (placeholder until real testimonial available)
5. Trust strip — security/compliance copy
**Acceptance criteria:**
- [ ] Returns 404 when `self_serve_enabled === false`.
- [ ] Plan cards show prices from `plan_billing.monthly_price_cents`. Enterprise card hides price.
- [ ] "Start free trial" buttons on Starter/Pro link to `/register?plan=pro` (or starter).
- [ ] "Talk to sales" on Enterprise links to `/contact-sales`.
- [ ] Trust strip claims should be honest — see spec open-risks #7 (GDPR DPA) and #7b (SOC2). If those aren't ready by cutover, copy in this task uses softer language (e.g., "Built on Stripe + AWS · Encrypted in transit and at rest").
**Integration tests (Playwright):**
- `unauth user sees pricing page when self_serve_enabled is true`
- `pricing page → "Start free trial" → /register?plan=pro`
- `pricing page → "Talk to sales" → /contact-sales`
- `pricing page returns 404 when self_serve_enabled is false`
**Commit:** `feat(pricing): add /pricing page (B-style)`
---
### Task 43: Talk-to-sales form at /contact-sales + landing-page CTA
**Outcome:** Enterprise prospects have a clear path; `LandingPage.tsx` gets a "See pricing" CTA.
**Contract:**
`/contact-sales` route with form posting to `POST /sales-leads` (Phase I Task 29).
Form fields:
- Name (required)
- Work email (required)
- Company (required)
- Team size (select; same buckets as wizard Step 1 + a "more than 26" option)
- "What brought you here?" (textarea, optional)
- Submit button
After submit:
- Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]"
- Calendly link is a config string (`VITE_CALENDLY_URL`); when unset, link section is hidden.
`LandingPage.tsx` modification:
- Add a prominent "See pricing" CTA near the existing "Get started" CTA.
- Both visible regardless of `self_serve_enabled` (see-pricing 404s if flag off, landing keeps existing behavior). Actually: gate the See-pricing CTA behind `useAppConfig().self_serve_enabled` so we don't show a button that 404s.
**Acceptance criteria:**
- [ ] Form blocks duplicate submissions client-side (disable button while in flight).
- [ ] PostHog `talk_to_sales_form_submitted` event fires with `source: 'pricing_page' | 'landing_page'` based on referrer.
- [ ] Calendly link block hides when `VITE_CALENDLY_URL` unset.
**Integration tests (Playwright):**
- `submit /contact-sales form → see confirmation page → /sales-leads has new row`
- `landing page shows "See pricing" CTA when self_serve_enabled, hides when off`
**Commit:** `feat(sales): add /contact-sales form + landing page CTA`
---
### Task 44: Beta-signup deprecation
**Outcome:** The legacy `beta_signup.py` endpoint redirects to register; existing waitlist gets a heads-up email.
**Contract:**
- `POST /api/v1/beta-signup` (existing) → keep mounted but return `307 Temporary Redirect` to `/register?from=beta`.
- One-off admin script `scripts/email_beta_waitlist.py` that reads existing `beta_signup` table and queues "we've launched" emails to each.
- Don't drop the table; archive in place.
**Acceptance criteria:**
- [ ] Existing tests against `/beta-signup` either updated to expect 307 or removed.
- [ ] Script is idempotent (uses an `email_sent_at` field on the beta-signup row, adding it via migration if needed).
**Integration tests:**
- `POST /beta-signup returns 307 to /register?from=beta`
**Commit:** `feat(sales): redirect beta-signup to /register; queue waitlist emails`
---
## Phase O — Cutover
### Task 45: Stripe live-mode setup checklist (manual)
**Outcome:** Stripe live-mode is configured and matches test mode. Manual step; this task tracks completion.
**Checklist:**
- [ ] In Stripe Dashboard (live mode):
- [ ] Create Products: ResolutionFlow Starter, ResolutionFlow Pro, ResolutionFlow Enterprise.
- [ ] Create monthly + annual recurring Prices for Starter and Pro.
- [ ] Enterprise has no Prices in the catalog (sales-created per customer).
- [ ] Enable Customer Portal: update payment method, cancel subscription, view invoices. Disable plan-switching from the portal.
- [ ] Register webhook endpoint at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`, `invoice.payment_succeeded`.
- [ ] Save the live webhook signing secret.
- [ ] In Railway prod environment variables:
- [ ] `STRIPE_SECRET_KEY` (live mode key, `sk_live_...`)
- [ ] `STRIPE_WEBHOOK_SECRET` (live signing secret)
- [ ] `STRIPE_PUBLISHABLE_KEY` (live publishable key) → `VITE_STRIPE_PUBLISHABLE_KEY` for frontend builds
- [ ] `OAUTH_REDIRECT_BASE` = `https://resolutionflow.com`
- [ ] `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` for prod Google OAuth app (separate from dev/test)
- [ ] `MS_CLIENT_ID` / `MS_CLIENT_SECRET` for prod Microsoft OAuth app
- [ ] Run `python -m scripts.sync_stripe_plan_ids` (Phase 1 Task 6 referenced; create if not existing) to populate `plan_billing` rows with live Stripe IDs:
- [ ] Pro monthly + annual price IDs
- [ ] Starter monthly + annual price IDs (if Starter is in scope; see open risk #14)
- [ ] Enterprise: stripe_product_id only, no price IDs
**Acceptance criteria:**
- [ ] Live webhook receives a test event (use Stripe CLI's `stripe trigger checkout.session.completed` against the live endpoint with a test customer) and is logged in `stripe_events`.
- [ ] `plan_billing` rows query returns expected Stripe IDs for Pro tier.
**No commit** — this is a deploy-time operation.
---
### Task 46: Internal validation pass (test mode → soft cutover via per-email allowlist)
**Outcome:** Real flow exercised end-to-end against the prod backend with `SELF_SERVE_ENABLED=false`, gated to internal testers only.
**Per-email allowlist mechanism:**
Backend reads `INTERNAL_TESTER_EMAILS` env var (comma-separated). When `SELF_SERVE_ENABLED=false` AND `current_user.email` is in the list, treat the user as if the flag were on (e.g., bypass invite-code requirement, expose pricing page via a header check). For frontend, the `/config/public` endpoint returns `self_serve_enabled: true` for these specific authenticated users.
**Validation scenarios:**
- [ ] Email signup → wizard step-by-step → first FlowPilot session run → trial-end synthetic time (DB query: `UPDATE subscriptions SET current_period_end = now() - interval '1 day' WHERE account_id = ...`) → plan picker → Stripe Checkout (test card `4242 4242 4242 4242`) → webhook → status='active'.
- [ ] Google sign-in (real Google account) → `/welcome` → wizard → dashboard.
- [ ] Microsoft sign-in (real M365 account) → same flow.
- [ ] Invite-by-email: existing tester invites a teammate → teammate receives email → clicks link → `/accept-invite` → set password → joins account → lands on `/?welcome=teammate`.
- [ ] Email match enforcement: try to register with `account_invite_code` and a different email → see `invite_email_mismatch`.
- [ ] Past-due simulation: use Stripe test card `4000 0000 0000 0341` → first invoice succeeds, next charge declines → `subscription_status='past_due'` → topbar pill changes → user can update card via Customer Portal.
- [ ] Pilot complimentary: log in as an existing pilot account → see "Complimentary Pro" pill, no walls, no nudges.
- [ ] Webhook signature failure: send a forged webhook → 400 + log entry.
- [ ] OAuth-only user attempts password login: rejected with `use_oauth_provider`.
**Acceptance criteria:**
- [ ] All 9 scenarios pass in prod test mode with internal testers.
- [ ] Errors logged during validation are reviewed and either fixed or documented.
**No commit** — validation is a checklist of test runs.
---
### Task 47: Feature-flag flip + week-1 monitoring
**Outcome:** `SELF_SERVE_ENABLED=true` and `VITE_SELF_SERVE_ENABLED=true` in prod. Public pricing page is live.
**Cutover steps:**
- [ ] Send pre-launch email to all pilot users via `EmailService.send_complimentary_account_announcement` (1-2 days before flip).
- [ ] Schedule the flip during low-traffic hours.
- [ ] Update Railway env vars: `SELF_SERVE_ENABLED=true` (backend), `VITE_SELF_SERVE_ENABLED=true` (frontend, requires redeploy since Vite bakes at build time).
- [ ] Verify prod: pricing page returns 200; new user can register without invite code.
- [ ] Announce launch (founder action; not eng).
**Week-1 monitoring (PostHog dashboards):**
- [ ] Funnel: `pricing_page_viewed → register_started → register_completed → email_verification_completed → welcome_wizard_completed → first_session_started`
- [ ] OAuth method mix
- [ ] Wizard skip rate per step
- [ ] `feature_gate_blocked` count by `flag_key`
- [ ] Trial conversion: `trial_modal_shown → checkout_completed`
- [ ] Stripe webhook error rate (Sentry alert if > 1/hour)
- [ ] `subscriptions.is_paid` audit query (manual SQL): confirm complimentary accounts are NOT counted in MRR
**Rollback plan:**
- Flip both flags back to `false`. Pricing page → 404. Register page → invite-code-required flow. Pilot complimentary status preserved (benign).
- Stripe webhook handler stays live regardless.
- Forward-only schema means nothing to revert at the DB level.
**No commit** — this is a deploy + monitor task.
---
## Self-Review
**Spec coverage check (against `2026-05-05-self-serve-signup-onboarding-design.md`):**
| Spec section | Covered by |
|---|---|
| §3.1 Pricing page | Task 42 |
| §3.2 Register page redesign with OAuth + invite-code-optional | Task 35 |
| §3.3 Welcome wizard (3 steps) | Tasks 38, 39 |
| §3.4 Dashboard with topbar pill + next-step card | Tasks 40, 41 |
| §3.5 Email verification surfaces | Task 37 |
| §3.6 Trial-end conversion (in-app modal day 10, wall day 14) | Task 41 covers checklist; the modal is part of Task 40's TrialPill stage transitions + the dashboard's modal trigger via `useTrialBanner` — implementer's discretion to add a `<TrialEndingModal />` component if it emerges naturally |
| §3.7 Plan picker → Stripe Checkout | Frontend page at `/account/billing/select-plan` lives within the dashboard area; Task 41's "Pick a plan" CTA navigates there. Component exists in scope of Task 40/41 — implementer's call on whether to split into its own file. |
| §3.8 Past-due / dunning | Task 40 (TrialPill `past_due` stage) + Customer Portal link |
| §3.9 Sales lead | Tasks 29, 43 |
| §3.10 Owner transfer (existing) | No new task — surface in Account → Team page during dashboard work, implementer's discretion |
| §4 BillingService.open_customer_portal | Task 27 |
| §4 PATCH /users/me/onboarding-step | Task 28 |
| §4 GET /billing/state consumed by frontend | Task 32 |
| §4 useFeature/useFeatureLimit/useTrialBanner | Task 33 |
| §4 FeatureGate / UpgradePrompt | Task 34 |
| §4 Caching invalidation triggered from /admin/plan-limits | Task 30 |
| §5 Beta-signup deprecation | Task 44 |
| §5 SELF_SERVE_ENABLED dark launch | Task 31 |
| §5 Stripe live-mode setup | Task 45 |
| §5 Internal validation phase | Task 46 |
| §5 Cutover + monitoring | Task 47 |
**Gaps and judgment-calls (called out for implementer):**
- **`<TrialEndingModal />` (day-10 in-app modal)** — left to implementer to decide whether it's its own task or rolled into Task 40. Spec is clear about behavior; component split is style.
- **Plan picker page (`/account/billing/select-plan`)** — frontend page that calls `POST /billing/checkout-session` and redirects. Lives within Task 40/41 area; not its own task. Acceptance: "user can pick Starter/Pro + seats and be redirected to Stripe Checkout."
- **Owner-transfer surface in Account → Team page** — existing endpoint, just needs UI. Implementer's call on which task absorbs this.
- **`<TrialEndedWall />`** — referenced in spec; renders on dashboard route when trial expired. Lives in Task 40/41 area as a render-branch of the dashboard layout.
**Placeholder scan:** none — every "implementer's discretion" call is bounded by a contract and acceptance criteria.
**Type/contract consistency:**
- `BillingState` shape in Task 32 matches `BillingStateResponse` from Phase 1 Task 24.
- `PATCH /users/me/onboarding-step` payload in Task 28 matches the wizard's writes in Tasks 38, 39.
- OAuth callback contract in Task 35 matches Phase 1 Task 17/18 endpoint shapes.
- `<EmailVerificationGate>` in Task 34 reads from authStore; `<TrialPill>` in Task 40 reads from `useBillingStore`. Different sources, intentional (verification is on `User`, trial is on `Subscription`).
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`.**
This plan is intentionally higher-altitude than Phase 1: contracts and acceptance criteria, not component-detail walkthroughs. Implementers exercise judgment on internal structure as long as contracts hold and integration tests pass.
**Recommended execution sequence:**
1. **Phase 1 first** (`2026-05-06-self-serve-signup-phase-1-backend.md`). Phase 2 depends on its endpoints.
2. After Phase 1 lands, **execute Phase 2 phases I → O sequentially**. Each phase is one or a few mergeable PRs.
3. **Cutover (Phase O)** is gated by Phase 1 + Phase 2 both green in prod test mode.
**Two execution options for Phase 2:**
**1. Subagent-Driven (recommended)** — fresh subagent per task with two-stage review. Higher-altitude tasks pair well with this since the subagent has room to make local design decisions inside the contract.
**2. Inline Execution** — execute tasks in a long-running session using executing-plans, with checkpoints between phases.
**Which approach?**