# 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 | 27–31 | Backend endpoints Phase 1 deferred + `SELF_SERVE_ENABLED` flag + `/admin/plan-limits` extension |
| J | 32–34 | Frontend billing foundation: `useBillingStore`, hooks, gating components — proven against Phase 1 backend |
| K | 35–37 | Auth surfaces: register redesign with OAuth buttons, accept-invite page, email-verification surfaces |
| L | 38–39 | Welcome wizard — 3 steps with persistence |
| M | 40–41 | Dashboard redesign — trial pill, next-step card, checklist redesign |
| N | 42–44 | Public surfaces: pricing page, contact-sales form, landing-page CTA, beta-signup 307 |
| O | 45–47 | 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` (1–3), `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 1–6 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?**
