Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
00c48fc
Add post-subscription onboarding design spec.
paulocastellano Jul 24, 2026
5b8e6ff
Add post-subscription onboarding implementation plan.
paulocastellano Jul 24, 2026
44fb9be
Add account onboarding completed and dismissed timestamps.
paulocastellano Jul 24, 2026
a69b799
Add ResolveOnboardingStatus for activation checklist state.
paulocastellano Jul 24, 2026
abf247e
Fix ResolveOnboardingStatus null-account guard and workspace scoping …
paulocastellano Jul 24, 2026
7655c1f
Rename pre-subscription ICP flow to Welcome routes.
paulocastellano Jul 24, 2026
7786204
Fix Task 3 review: rename SocialController welcome test label.
paulocastellano Jul 24, 2026
9827714
Add Welcome Vue pages and i18n; drop social from subscribe.
paulocastellano Jul 24, 2026
1c13007
Add post-subscription onboarding activation endpoints.
paulocastellano Jul 24, 2026
ea615a6
Add single-page post-subscription onboarding UI.
paulocastellano Jul 24, 2026
4726204
Send post-checkout users to onboarding instead of accounts.
paulocastellano Jul 24, 2026
ec64768
Add dismissible residual onboarding banner in app shell.
paulocastellano Jul 24, 2026
ff462e5
Track welcome and onboarding activation events in PostHog.
paulocastellano Jul 24, 2026
254e7b4
Fix post-subscription onboarding review findings.
paulocastellano Jul 24, 2026
fb7a00b
Remove welcome subscribe step and start Stripe from referral.
paulocastellano Jul 24, 2026
5dd4983
Use AppLayout with sidebar on the onboarding activation page.
paulocastellano Jul 24, 2026
8659da8
Polish welcome and onboarding UX and unify the sidebar account menu.
paulocastellano Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
552 changes: 552 additions & 0 deletions .superpowers/plans/2026-07-24-post-subscription-onboarding.md

Large diffs are not rendered by default.

198 changes: 198 additions & 0 deletions .superpowers/specs/2026-07-24-post-subscription-onboarding-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Post-Subscription Onboarding Design

**Date:** 2026-07-24
**Branch:** `feature/post-subscription-onboarding`
**Status:** Approved for planning

## Problem

TryPost’s current “onboarding” is ICP qualification (persona, goals, referral source) plus a mandatory social connect before Stripe. After payment, `/billing/processing` sends the user to `/accounts`. There is no activation moment — no guided path to connect an MCP agent, connect networks in context, or create a first post.

The ICP for TryPost is people who create content via MCP clients (Claude, ChatGPT). Without a dedicated post-subscription activation experience, users miss the AHA moment.

## Goals

1. Rename and clarify **Welcome** (pre-Stripe ICP) vs **Onboarding** (post-Stripe activation).
2. After Stripe, land on a **single-page** activation checklist at `/onboarding`.
3. AHA = MCP connected + first post created (**draft is enough**; publish optional).
4. Flow is **skippable**, with a dismissible residual checklist in the app.
5. Move social connect out of pre-Stripe into post-Stripe onboarding.
6. v1 MCP clients: **Claude** and **ChatGPT** only. First-post step is dual: MCP primary + UI fallback.

## Non-goals (v1)

- Hard-gating the app until onboarding is complete.
- Multi-step wizard for activation (no `/onboarding/step-*`).
- Cursor (or other MCP clients) as first-class cards.
- Requiring publish as the AHA.
- Changing the pre-Stripe ICP questions themselves (only routes/names/structure).

## End-to-end flow

```
/register
→ /register/success
→ /welcome (redirect → /welcome/persona)
→ /welcome/persona
→ /welcome/goals
→ /welcome/referral-source
→ /welcome/subscribe (plan + Stripe CTA; no social required)
→ Stripe Checkout
→ /billing/processing?session_id=…
→ /onboarding (single-page checklist)
→ Skip or complete → /calendar
```

Self-hosted: skip Welcome and activation Onboarding (same policy as today’s onboarding skip → calendar).

## URL map

### Welcome (ICP, pre-subscription)

| Method | Path | Name | Purpose |
|--------|------|------|---------|
| GET | `/welcome` | `app.welcome` | Redirect to `app.welcome.persona` |
| GET/POST | `/welcome/persona` | `app.welcome.persona` / `.store` | Persona |
| GET/POST | `/welcome/goals` | `app.welcome.goals` / `.store` | Goals |
| GET/POST | `/welcome/referral-source` | `app.welcome.referral-source` / `.store` | Referral |
| GET | `/welcome/subscribe` | `app.welcome.subscribe` | Plan summary + subscribe CTA |
| POST | `/welcome/checkout` | `app.welcome.checkout` | Start Stripe Checkout |

Legacy redirects (temporary):

- `/onboarding` → `/welcome/persona` (only while unsubscribed / for old bookmarks; after activation ships, subscribed users hitting old paths should not bounce into Welcome)
- `/onboarding/goals` → `/welcome/goals`
- `/onboarding/referral-source` → `/welcome/referral-source`
- `/onboarding/connect` → `/welcome/subscribe`

`EnsureAccountReady`: unsubscribed users → `app.welcome.persona` (not activation `/onboarding`).

### Onboarding (activation, post-subscription)

| Method | Path | Name | Purpose |
|--------|------|------|---------|
| GET | `/onboarding` | `app.onboarding` | Single-page activation checklist |
| POST | `/onboarding/dismiss` | `app.onboarding.dismiss` | Skip / do later |
| POST | `/onboarding/complete` | `app.onboarding.complete` | Explicit complete when all steps done (optional if auto-complete navigates) |

`/billing/processing` success redirect target changes from `/accounts` to `/onboarding`.

## Code rename (Welcome)

| Today | After |
|-------|-------|
| `OnboardingController` | `WelcomeController` |
| `app/Http/Requests/App/Onboarding/*` | `app/Http/Requests/App/Welcome/*` (`StoreWelcomePersonaRequest`, `StoreWelcomeGoalsRequest`, `StoreWelcomeReferralSourceRequest`) |
| `resources/js/pages/onboarding/{Index,Goals,ReferralSource,Connect}.vue` | `resources/js/pages/welcome/{Persona,Goals,ReferralSource,Subscribe}.vue` |
| `OnboardingLayout.vue` | `WelcomeLayout.vue` |
| `lang/*/onboarding.php` (ICP strings) | `lang/*/welcome.php` |
| `tests/Feature/Onboarding/OnboardingControllerTest.php` | `tests/Feature/Welcome/WelcomeControllerTest.php` |
| `app.onboarding.*` (ICP) | `app.welcome.*` |

`Connect.vue` is removed from Welcome. Social connect lives on the activation page (and remains available at `/accounts`).

Checkout no longer requires an existing social account.

## Onboarding page (single page, not a step form)

One dedicated full-page screen with a **3-item checklist** on the same view:

1. **Connect an agent** — Claude and ChatGPT cards with MCP URL / deep-link / copy instructions (link to docs where needed).
2. **Connect a social network** — reuse existing connect UI patterns (platforms + connected accounts), inlined on this page.
3. **Create your first post** — primary: ready-made MCP prompt (“open Claude/ChatGPT and ask…”); fallback: button to create in TryPost UI. Draft counts.

### Live completion

Steps auto-check from real state (poll or Inertia partial reload):

| Step | Done when |
|------|-----------|
| MCP | User has a non-revoked OAuth access token used for MCP (not a Personal Access Token / API key) |
| Social | Current workspace has ≥ 1 social account |
| First post | Current workspace has ≥ 1 post (any status, any `created_via`) |

Primary CTA: continue into the app when all three are done (or celebrate in place then go to calendar).
Secondary: **Skip / I’ll do this later**.

### Gating policy

- **Not blocking.** After the post-checkout land, users can skip and use the product.
- No middleware that traps subscribed users on `/onboarding` on every request.
- Residual checklist in the app shell until completed or dismissed.

## Persistence

Welcome ICP fields stay on `User`: `persona`, `goals`, `referral_source`.

Activation flags on `Account` (billing/subscription owner — preferred over User so workspace members don’t each own account-level activation):

- `onboarding_completed_at` (nullable timestamp)
- `onboarding_dismissed_at` (nullable timestamp)

Semantics:

- Steps themselves are derived from state, not stored booleans.
- `onboarding_completed_at` set when all three steps are true (server-side on dismiss-complete path or when status is loaded and all true).
- `onboarding_dismissed_at` set on Skip.
- Residual UI shows when account is subscribed and both timestamps are null.
- Dismiss hides residual even if steps remain incomplete.
- Completing all three sets `completed_at` and clears residual.

Existing accounts already subscribed: treat as dismissed or completed (migration strategy: backfill `onboarding_dismissed_at = now()` for accounts that already have an active subscription, so we don’t force legacy users into activation).

## Residual checklist

Dismissible banner or compact checklist in the authenticated app layout (not a modal trap):

- Visible when subscribed + `completed_at` null + `dismissed_at` null.
- Link back to `/onboarding`.
- Dismiss action hits `app.onboarding.dismiss`.

Exact UI placement (sidebar vs top banner) is an implementation detail; prefer the lightest pattern that matches existing app chrome.

## Analytics (PostHog)

Follow existing enum pattern under `App\Enums\PostHog\` (e.g. `WelcomeEvent`, `OnboardingEvent`).

Suggested events:

- Welcome: `welcome.persona_saved`, `welcome.goals_saved`, `welcome.referral_saved`, `welcome.checkout_started`
- Onboarding: `onboarding.viewed`, `onboarding.step_completed` (property `step`: `mcp` | `social` | `first_post`), `onboarding.skipped`, `onboarding.completed`

Step properties when useful: `created_via` (`web` | `mcp`) for first post; MCP client if detectable later (v1 may omit client if unreliable).

## Architecture notes

- **WelcomeController** — ICP + subscribe + checkout only; redirects subscribed users to `/onboarding` or calendar per rules (subscribed + activation incomplete → can visit `/onboarding`; subscribed + complete/dismissed → calendar).
- **OnboardingController** — show checklist props (step statuses, platforms, accounts, MCP server URL, sample prompt), dismiss, complete.
- Prefer a small action/service e.g. `App\Actions\Onboarding\ResolveOnboardingStatus` that returns the three booleans + whether residual should show — shared by page, layout shared props, and tests.
- Frontend: one `resources/js/pages/onboarding/Index.vue`; poll step status while the page is open so MCP/OAuth and MCP-created drafts flip checkmarks without a full navigation.
- Wayfinder regenerate after route/controller rename.
- i18n: all new copy in `lang/*/welcome.php` and `lang/*/onboarding.php` (all locales that already have onboarding strings).

## Testing

- Welcome: renamed feature tests; persona → goals → referral → subscribe; checkout **without** social account succeeds; subscribed users don’t see Welcome ICP.
- Legacy route redirects.
- Billing processing redirects to `/onboarding`.
- Onboarding page renders step statuses from fixtures (MCP token, social, post).
- Dismiss sets `onboarding_dismissed_at`; residual hidden.
- Auto-complete when all three true sets `onboarding_completed_at`.
- `EnsureAccountReady` → `/welcome/persona` when unsubscribed.
- Self-hosted skips both flows.
- Residual shared prop / layout behavior covered at least once.

## Open implementation details (resolved in plan, not product)

- Exact MCP “connected” query against Passport tokens/clients.
- Claude/ChatGPT deep-link vs copy-paste URL UX copy.
- Residual component placement in `AppLayout`.
- Whether `onboarding.complete` is a dedicated POST or automatic on status resolve.

## Success criteria

- New paid users land on `/onboarding` after Stripe, not `/accounts`.
- They can connect Claude or ChatGPT, connect a network, and create a draft (via MCP or UI) on one page.
- They can skip and still use the product, with an optional residual nudge.
- Pre-Stripe flow lives under `/welcome/{step}` with aligned PHP/Vue naming.
- ICP collection no longer requires social connect before payment.
104 changes: 104 additions & 0 deletions app/Actions/Onboarding/ResolveOnboardingStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace App\Actions\Onboarding;

use App\Enums\PostHog\OnboardingEvent;
use App\Models\AccessToken;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Cache;

class ResolveOnboardingStatus
{
public function __construct(
private readonly PostHogService $postHog,
) {}

/**
* @return array{
* mcp_connected: bool,
* social_connected: bool,
* first_post_created: bool,
* all_complete: bool,
* show_residual: bool,
* completed_at: ?string,
* dismissed_at: ?string
* }
*/
public function handle(User $user): array
{
/** @var Account|null $account */
$account = data_get($user, 'account');

if ($account?->onboarding_completed_at !== null) {
return [
'mcp_connected' => true,
'social_connected' => true,
'first_post_created' => true,
'all_complete' => true,
'show_residual' => false,
'completed_at' => $account->onboarding_completed_at->toIso8601String(),
'dismissed_at' => $account->onboarding_dismissed_at?->toIso8601String(),
];
}

/** @var Workspace|null $workspace */
$workspace = data_get($user, 'currentWorkspace');

$mcpConnected = AccessToken::query()
->where('user_id', $user->id)
->whereNull('workspace_id')
->where('revoked', false)
->exists();
$socialConnected = $workspace?->socialAccounts()->exists() ?? false;
$firstPostCreated = $workspace?->posts()->exists() ?? false;
$allComplete = $mcpConnected && $socialConnected && $firstPostCreated;

if ($account?->onboarding_dismissed_at === null) {
$this->captureCompletedStep($user, $account, 'mcp_connected', $mcpConnected);
$this->captureCompletedStep($user, $account, 'social_connected', $socialConnected);
$this->captureCompletedStep($user, $account, 'first_post_created', $firstPostCreated);
}

if ($allComplete && $account !== null && $account->onboarding_completed_at === null) {
$account->update(['onboarding_completed_at' => now()]);
}

$showResidual = ! config('trypost.self_hosted')
&& ($account?->subscribed(Account::SUBSCRIPTION_NAME) ?? false)
&& $account->onboarding_completed_at === null
&& $account->onboarding_dismissed_at === null;

return [
'mcp_connected' => $mcpConnected,
'social_connected' => $socialConnected,
'first_post_created' => $firstPostCreated,
'all_complete' => $allComplete,
'show_residual' => $showResidual,
'completed_at' => $account?->onboarding_completed_at?->toIso8601String(),
'dismissed_at' => $account?->onboarding_dismissed_at?->toIso8601String(),
];
}

private function captureCompletedStep(User $user, ?Account $account, string $step, bool $completed): void
{
if (! $completed || $account === null || ! PostHogService::isEnabled()) {
return;
}

if (! Cache::add("onboarding_step:{$account->id}:{$step}", true, now()->addDays(30))) {
return;
}

$this->postHog->capture(
$user->id,
OnboardingEvent::StepCompleted->value,
['step' => $step],
$account,
);
}
}
13 changes: 13 additions & 0 deletions app/Enums/PostHog/OnboardingEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Enums\PostHog;

enum OnboardingEvent: string
{
case Viewed = 'onboarding.viewed';
case StepCompleted = 'onboarding.step_completed';
case Skipped = 'onboarding.skipped';
case Completed = 'onboarding.completed';
}
13 changes: 13 additions & 0 deletions app/Enums/PostHog/WelcomeEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Enums\PostHog;

enum WelcomeEvent: string
{
case PersonaSaved = 'welcome.persona_saved';
case GoalsSaved = 'welcome.goals_saved';
case ReferralSaved = 'welcome.referral_saved';
case CheckoutStarted = 'welcome.checkout_started';
}
3 changes: 0 additions & 3 deletions app/Enums/User/Goal.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ enum Goal: string
case GrowAudience = 'grow_audience';
case DriveSales = 'drive_sales';
case ManageClients = 'manage_clients';
case TeamCollaboration = 'team_collaboration';
case AutomateApi = 'automate_api';
case TrackPerformance = 'track_performance';
case JustExploring = 'just_exploring';
case Other = 'other';
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/App/BillingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class BillingController extends Controller
{
public function subscribe(): RedirectResponse
{
return redirect()->route('app.onboarding');
return redirect()->route('app.welcome.persona');
}

public function processing(Request $request): Response|RedirectResponse
Expand Down
Loading
Loading