# 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 enabledFeatures: Record isLoading: boolean error: string | null } interface BillingStore extends BillingState { fetch: () => Promise refetch: () => Promise 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: standardized "this feature is on Pro" affordance with CTA // resolves display name + plan name internally // EmailVerificationGate: wraps protected content; renders past grace ``` **Behavior:** - `` reads from `useFeature(feature)`. Server-side check via `require_feature` is the security boundary; this is UX. - `` CTA links to `/account/billing/select-plan`. - `` reads `users.email_verified_at` + `users.created_at` from `authStore.user`. Day 1–6 unverified renders children (banner shown elsewhere). Day 7+ unverified renders ``. **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 `` 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:** - **``** — 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`. - **``** — 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 `` 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 — `` 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:** - **``** 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 `` 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):** - **`` (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. - **``** — 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. - `` in Task 34 reads from authStore; `` 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?**