Add social connect step to welcome before Stripe (#293)

* feat: add social connect step to welcome before Stripe checkout

Ask new owners to connect a network after referral source so we can track welcome.connect in PostHog and still let them continue to checkout without a connection.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Nest welcome connect copy under a connect array.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a connected social account before welcome checkout.

Skip is no longer allowed, and the welcome layout takes a Tailwind size so the connect grid can sit two rows of six.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden the welcome connect step after review.

Track connect only after Stripe creates a session, restore a missing workspace before showing networks, and cover the remaining checkout and analytics cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Refactor social account status handling across components

Updated the SocialAccountsGrid, NetworkConnectGrid, onboarding, and welcome connect components to utilize the new SocialAccountStatus enum for improved clarity and maintainability. This change replaces string literals for account statuses with the enum values, enhancing type safety and consistency throughout the application.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Refactor workspace resolution in WelcomeController and StoreWelcomeConnectRequest

Updated the WelcomeController and StoreWelcomeConnectRequest to directly access the user's current workspace, simplifying the code by removing the resolveCurrentWorkspace method. This change enhances readability and maintains functionality by ensuring the current workspace is correctly utilized in the connection process. Additionally, removed outdated test cases related to workspace restoration.

* Inline welcome connect PostHog platforms from the current workspace.

Drop the extra helper — the grid already loads accounts the same way as onboarding and accounts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Inline Stripe checkout into the welcome connect store.

startCheckout was a one-caller wrapper; storeConnect now matches the other welcome steps.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move welcome connect validation into the controller.

The FormRequest had no input to validate and duplicated step-gating. Require a connected account in storeConnect, and drop the dead owner abort plus the always-true PostHog connected flag.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Show welcome toasts and cover remaining connect cases.

Mount the app Toast host on WelcomeLayout so OAuth, Telegram, and disconnect feedback is visible. Add tests for stale goals, an empty workspace grid, accounts on another workspace, and skipped identify when Stripe fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Assume a welcome workspace, validate connect in the FormRequest, and add browser tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Rename WelcomeEvent::dashboardFunnel() to funnel().

Co-authored-by: Cursor <cursoragent@cursor.com>

* Identify connected platforms from the social account observer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Queue connected-platform identify on the posthog queue.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden welcome connect: 404 without a workspace, and keep step redirects ahead of connect validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Identify connected platforms on workspace and account groups, and keep the account union on the owner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Share hasCurrentGoals on User and keep Stripe checkout when PostHog capture fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Skip welcome connect validation when the controller would redirect the user away.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move current-goal membership onto the Goal enum.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-08-17 19:37:43 -03:00 committed by GitHub
parent eb2b345163
commit 15f87aebe4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1255 additions and 135 deletions

View file

@ -9,4 +9,21 @@ enum WelcomeEvent: string
case Persona = 'welcome.persona';
case Goals = 'welcome.goals';
case Referral = 'welcome.referral';
case Connect = 'welcome.connect';
/**
* Welcome capture order through Stripe Checkout.
*
* @return list<string>
*/
public static function funnel(): array
{
return [
self::Persona->value,
self::Goals->value,
self::Referral->value,
self::Connect->value,
CheckoutEvent::Started->value,
];
}
}

View file

@ -16,4 +16,22 @@ enum Goal: string
case ManageClients = 'manage_clients';
case JustExploring = 'just_exploring';
case Other = 'other';
/**
* True when at least one stored goal still exists as a Goal case.
* Dropped values must not count users mid-funnel would otherwise
* skip re-selecting after we slim the list.
*
* @param list<string>|null $goals
*/
public static function containsCurrent(?array $goals): bool
{
if (! is_array($goals) || $goals === []) {
return false;
}
$allowed = array_map(fn (self $goal): string => $goal->value, self::cases());
return array_intersect($goals, $allowed) !== [];
}
}

View file

@ -8,20 +8,23 @@
use App\Enums\Plan\Slug;
use App\Enums\PostHog\CheckoutEvent;
use App\Enums\PostHog\WelcomeEvent;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\User\Goal;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Http\Requests\App\Welcome\StoreWelcomeConnectRequest;
use App\Http\Requests\App\Welcome\StoreWelcomeGoalsRequest;
use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest;
use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\Plan;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class WelcomeController extends Controller
{
@ -106,31 +109,22 @@ public function referralSource(Request $request): InertiaResponse|RedirectRespon
}
$user = $request->user();
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return Inertia::render('welcome/ReferralSource', [
'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()),
'selected' => $user->referral_source?->value,
'plan' => [
'name' => $plan->name,
'interval' => 'monthly',
],
]);
}
public function storeReferralSource(
StoreWelcomeReferralSourceRequest $request,
StartSubscriptionCheckout $checkout,
PostHogService $postHog,
): Response|RedirectResponse {
): RedirectResponse {
if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) {
return $redirect;
}
$user = $request->user();
abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN);
$referralSource = (string) $request->validated('referral_source');
$user->update(['referral_source' => $referralSource]);
@ -145,6 +139,41 @@ public function storeReferralSource(
$user->account,
);
return redirect()->route('app.welcome.connect');
}
public function connect(Request $request): InertiaResponse|RedirectResponse
{
if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) {
return $redirect;
}
$workspace = $request->user()->currentWorkspace;
abort_unless($workspace !== null, Response::HTTP_NOT_FOUND);
return Inertia::render('welcome/Connect', [
'platforms' => SocialPlatform::connectableOptions(),
'accounts' => SocialAccountResource::collection(
$workspace->socialAccounts()->orderBy('id')->get(),
)->resolve(),
]);
}
public function storeConnect(
StoreWelcomeConnectRequest $request,
StartSubscriptionCheckout $checkout,
PostHogService $postHog,
): Response|RedirectResponse {
if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) {
return $redirect;
}
abort_unless($request->user()->currentWorkspace !== null, Response::HTTP_NOT_FOUND);
$user = $request->user();
$platforms = $request->connectedPlatforms();
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
$priceId = $plan->stripe_monthly_price_id;
@ -153,15 +182,25 @@ public function storeReferralSource(
$response = $checkout->redirect(
$user->account,
$priceId,
route('app.welcome.referral-source'),
route('app.welcome.connect'),
);
$postHog->capture(
$user->id,
CheckoutEvent::Started->value,
['plan_name' => $plan->name, 'interval' => 'monthly'],
$user->account,
);
try {
$postHog->capture(
$user->id,
WelcomeEvent::Connect->value,
['platforms' => $platforms],
$user->account,
);
$postHog->capture(
$user->id,
CheckoutEvent::Started->value,
['plan_name' => $plan->name, 'interval' => 'monthly'],
$user->account,
);
} catch (Throwable $e) {
report($e);
}
return $response;
}
@ -183,8 +222,11 @@ public function subscriptionRequired(Request $request): InertiaResponse|Redirect
]);
}
private function redirectIfStepIncomplete(Request $request, bool $requireGoals = false): ?RedirectResponse
{
private function redirectIfStepIncomplete(
Request $request,
bool $requireGoals = false,
bool $requireReferral = false,
): ?RedirectResponse {
if ($redirect = $this->redirectIfUnavailable($request)) {
return $redirect;
}
@ -195,29 +237,15 @@ private function redirectIfStepIncomplete(Request $request, bool $requireGoals =
return redirect()->route('app.welcome.persona');
}
if ($requireGoals && ! $this->hasCurrentGoals($user)) {
if ($requireGoals && ! Goal::containsCurrent($user->goals)) {
return redirect()->route('app.welcome.goals');
}
return null;
}
/**
* True when the user has at least one goal that still exists in Goal.
* Dropped enum values must not satisfy the gate or users mid-funnel can
* skip re-selecting after we slim the list.
*/
private function hasCurrentGoals(User $user): bool
{
$goals = $user->goals;
if (! is_array($goals) || $goals === []) {
return false;
if ($requireReferral && ! $user->referral_source) {
return redirect()->route('app.welcome.referral-source');
}
$allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases());
return array_intersect($goals, $allowed) !== [];
return null;
}
private function redirectIfUnavailable(Request $request): ?RedirectResponse

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Welcome;
use App\Enums\SocialAccount\Status;
use App\Enums\User\Goal;
use App\Models\SocialAccount;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class StoreWelcomeConnectRequest extends FormRequest
{
/**
* @var list<string>|null
*/
private ?array $connectedPlatforms = null;
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
/**
* @return list<string>
*/
public function connectedPlatforms(): array
{
return $this->connectedPlatforms ??= $this->user()->currentWorkspace->socialAccounts()
->where('status', Status::Connected)
->orderBy('id')
->get()
->map(fn (SocialAccount $account): string => $account->platform->value)
->unique()
->values()
->all();
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$user = $this->user();
if ($user->currentWorkspace === null) {
return;
}
if ($user->account?->hasAppAccess() || ! $user->isAccountOwner()) {
return;
}
if (! $user->persona || ! Goal::containsCurrent($user->goals) || ! $user->referral_source) {
return;
}
if ($this->connectedPlatforms() === []) {
$validator->errors()->add('connect', __('welcome.connect.required'));
}
});
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Enums\SocialAccount\Status;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\PostHogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class IdentifyConnectedPlatforms implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public string $workspaceId)
{
$this->onQueue('posthog');
}
public function handle(PostHogService $postHog): void
{
if (! PostHogService::isEnabled()) {
return;
}
$workspace = Workspace::query()
->with('account.owner')
->find($this->workspaceId);
$account = $workspace?->account;
if ($account === null) {
return;
}
$workspacePlatforms = $this->connectedPlatformSlugs(
SocialAccount::query()->where('workspace_id', $workspace->id),
);
$accountPlatforms = $this->connectedPlatformSlugs(
SocialAccount::query()->whereIn('workspace_id', $account->workspaces()->select('id')),
);
$postHog->groupIdentify('workspace', (string) $workspace->id, [
'connected_platforms' => $workspacePlatforms,
]);
$postHog->groupIdentify('account', (string) $account->id, [
'connected_platforms' => $accountPlatforms,
]);
$owner = $account->owner;
if ($owner === null) {
return;
}
$postHog->identify($owner->id, [
'connected_platforms' => $accountPlatforms,
]);
}
/**
* @param Builder<SocialAccount> $query
* @return list<string>
*/
private function connectedPlatformSlugs(Builder $query): array
{
return $query
->where('status', Status::Connected)
->orderBy('id')
->get()
->map(fn (SocialAccount $account): string => $account->platform->value)
->unique()
->values()
->all();
}
}

View file

@ -8,6 +8,7 @@
use App\Enums\SocialAccount\Status;
use App\Events\OnboardingStatusUpdated;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Jobs\PostHog\IdentifyConnectedPlatforms;
use App\Jobs\PostHog\SyncAccountUsage;
use App\Models\SocialAccount;
use App\Services\PostHogService;
@ -57,6 +58,7 @@ public function updated(SocialAccount $socialAccount): void
$isConnected = $socialAccount->status === Status::Connected;
if ($wasConnected !== $isConnected) {
$this->identifyConnectedPlatforms($socialAccount);
$this->notifyOnboarding($socialAccount);
}
}
@ -64,12 +66,22 @@ public function updated(SocialAccount $socialAccount): void
private function syncUsageAndOnboarding(SocialAccount $socialAccount): void
{
$this->syncUsage($socialAccount);
$this->identifyConnectedPlatforms($socialAccount);
if ($socialAccount->status === Status::Connected) {
$this->notifyOnboarding($socialAccount);
}
}
private function identifyConnectedPlatforms(SocialAccount $socialAccount): void
{
if (! PostHogService::isEnabled()) {
return;
}
IdentifyConnectedPlatforms::dispatch((string) $socialAccount->workspace_id);
}
/**
* First usable connect / last disconnect for the account.
* Actor-less syncAndNotify falls back to the account owner.

View file

@ -33,22 +33,29 @@ public static function shouldTrack(): bool
*/
public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void
{
$payload = [
'distinctId' => $distinctId,
'event' => $event,
'properties' => $properties,
];
try {
$payload = [
'distinctId' => $distinctId,
'event' => $event,
'properties' => $properties,
];
if ($account) {
$payload['properties']['$groups'] = ['account' => (string) $account->id];
$payload['properties']['account_id'] = (string) $account->id;
$payload['properties']['plan'] = $account->plan?->name;
}
if ($account) {
$payload['properties']['$groups'] = ['account' => (string) $account->id];
$payload['properties']['account_id'] = (string) $account->id;
$payload['properties']['plan'] = $account->plan?->name;
}
$this->logLocally('capture', $payload);
$this->logLocally('capture', $payload);
if (self::isEnabled()) {
$this->dispatch('capture', $payload);
if (self::isEnabled()) {
$this->dispatch('capture', $payload);
}
} catch (Throwable $e) {
Log::warning('PostHogService: failed to capture event', [
'event' => $event,
'error' => $e->getMessage(),
]);
}
}
@ -57,15 +64,21 @@ public function capture(string $distinctId, string $event, array $properties = [
*/
public function identify(string $distinctId, array $properties = []): void
{
$payload = [
'distinctId' => $distinctId,
'properties' => $properties,
];
try {
$payload = [
'distinctId' => $distinctId,
'properties' => $properties,
];
$this->logLocally('identify', $payload);
$this->logLocally('identify', $payload);
if (self::isEnabled()) {
$this->dispatch('identify', $payload);
if (self::isEnabled()) {
$this->dispatch('identify', $payload);
}
} catch (Throwable $e) {
Log::warning('PostHogService: failed to identify', [
'error' => $e->getMessage(),
]);
}
}
@ -74,16 +87,22 @@ public function identify(string $distinctId, array $properties = []): void
*/
public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void
{
$payload = [
'groupType' => $groupType,
'groupKey' => $groupKey,
'properties' => $properties,
];
try {
$payload = [
'groupType' => $groupType,
'groupKey' => $groupKey,
'properties' => $properties,
];
$this->logLocally('groupIdentify', $payload);
$this->logLocally('groupIdentify', $payload);
if (self::isEnabled()) {
$this->dispatch('groupIdentify', $payload);
if (self::isEnabled()) {
$this->dispatch('groupIdentify', $payload);
}
} catch (Throwable $e) {
Log::warning('PostHogService: failed to group identify', [
'error' => $e->getMessage(),
]);
}
}

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'شيء آخر',
],
'connect' => [
'title' => 'اربط حسابًا اجتماعيًا',
'description' => 'اختر شبكة واحدة على الأقل يمكن لـ TryPost النشر عليها.',
'required' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل للمتابعة.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Etwas anderes',
],
'connect' => [
'title' => 'Verbinde ein soziales Konto',
'description' => 'Wähle mindestens ein Netzwerk, auf dem TryPost deine Inhalte veröffentlichen kann.',
'required' => 'Verbinde mindestens ein soziales Konto, um fortzufahren.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Κάτι άλλο',
],
'connect' => [
'title' => 'Σύνδεσε έναν λογαριασμό κοινωνικής δικτύωσης',
'description' => 'Επίλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου.',
'required' => 'Σύνδεσε τουλάχιστον έναν λογαριασμό κοινωνικής δικτύωσης για να συνεχίσεις.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Something else',
],
'connect' => [
'title' => 'Connect a social account',
'description' => 'Choose at least one network where TryPost can publish your content.',
'required' => 'Connect at least one social account to continue.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Otra cosa',
],
'connect' => [
'title' => 'Conecta una red social',
'description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido.',
'required' => 'Conecta al menos una red social para continuar.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Autre chose',
],
'connect' => [
'title' => 'Connectez un réseau social',
'description' => 'Choisissez au moins un réseau sur lequel TryPost peut publier votre contenu.',
'required' => 'Connectez au moins un réseau social pour continuer.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Qualcos\'altro',
],
'connect' => [
'title' => 'Collega un account social',
'description' => 'Scegli almeno una rete su cui TryPost può pubblicare i tuoi contenuti.',
'required' => 'Collega almeno un account social per continuare.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'その他',
],
'connect' => [
'title' => 'SNSアカウントを接続',
'description' => 'TryPostが投稿できるネットワークを少なくとも1つ選んでください。',
'required' => '続けるには、少なくとも1つのSNSアカウントを接続してください。',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => '기타',
],
'connect' => [
'title' => '소셜 계정을 연결하세요',
'description' => 'TryPost가 콘텐츠를 게시할 네트워크를 하나 이상 선택하세요.',
'required' => '계속하려면 소셜 계정을 하나 이상 연결하세요.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Iets anders',
],
'connect' => [
'title' => 'Verbind een social account',
'description' => 'Kies minstens één netwerk waarop TryPost je content kan plaatsen.',
'required' => 'Verbind minstens één social account om door te gaan.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Coś innego',
],
'connect' => [
'title' => 'Połącz konto społecznościowe',
'description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści.',
'required' => 'Połącz co najmniej jedno konto społecznościowe, aby kontynuować.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Outra coisa',
],
'connect' => [
'title' => 'Conecte uma rede social',
'description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo.',
'required' => 'Conecte pelo menos uma rede social para continuar.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Что-то другое',
],
'connect' => [
'title' => 'Подключите соцсеть',
'description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент.',
'required' => 'Подключите хотя бы одну соцсеть, чтобы продолжить.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Başka bir şey',
],
'connect' => [
'title' => 'Bir sosyal hesap bağla',
'description' => 'TryPostun içeriğini yayınlayabileceği en az bir ağ seç.',
'required' => 'Devam etmek için en az bir sosyal hesap bağla.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => 'Щось інше',
],
'connect' => [
'title' => 'Підключіть соцмережу',
'description' => 'Оберіть принаймні одну мережу, де TryPost зможе публікувати ваш контент.',
'required' => 'Підключіть принаймні одну соцмережу, щоб продовжити.',
],
];

View file

@ -59,4 +59,9 @@
'blog' => 'Blog / newsletter',
'other' => '其他',
],
'connect' => [
'title' => '连接社交账号',
'description' => '选择至少一个 TryPost 可以发布内容的平台。',
'required' => '请至少连接一个社交账号后再继续。',
],
];

View file

@ -23,6 +23,10 @@ import { getInitials } from '@/composables/useInitials';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { toggle as toggleAccount } from '@/routes/app/accounts';
import {
SocialAccountStatus,
type SocialAccountStatusValue,
} from '@/types/social-account-status';
export interface SocialAccount {
id: string;
@ -33,7 +37,7 @@ export interface SocialAccount {
display_label: string;
handle_label: string;
avatar_url: string;
status: 'connected' | 'disconnected' | 'token_expired' | null;
status: SocialAccountStatusValue | null;
is_active: boolean;
error_message: string | null;
}
@ -120,7 +124,8 @@ const getProfileUrl = (
const isDisconnected = (account: SocialAccount | null): boolean => {
if (!account) return false;
return (
account.status === 'disconnected' || account.status === 'token_expired'
account.status === SocialAccountStatus.Disconnected ||
account.status === SocialAccountStatus.TokenExpired
);
};
</script>

View file

@ -12,6 +12,10 @@ import { Button } from '@/components/ui/button';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { disconnect } from '@/routes/app/accounts';
import { Platform } from '@/types/platform';
import {
SocialAccountStatus,
type SocialAccountStatusValue,
} from '@/types/social-account-status';
export interface AvailablePlatform {
value: string;
@ -30,7 +34,7 @@ export interface ConnectedAccount {
display_label: string;
handle_label: string;
avatar_url: string | null;
status: 'connected' | 'disconnected' | 'token_expired' | null;
status: SocialAccountStatusValue | null;
}
const props = withDefaults(
@ -177,7 +181,8 @@ const disconnectAccount = (account: ConnectedAccount) => {
};
const needsReconnect = (account: ConnectedAccount): boolean =>
account.status === 'disconnected' || account.status === 'token_expired';
account.status === SocialAccountStatus.Disconnected ||
account.status === SocialAccountStatus.TokenExpired;
const connectEntryFor = (platformValue: string): string =>
platformValue === Platform.LinkedInPage ? Platform.LinkedIn : platformValue;

View file

@ -2,26 +2,43 @@
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import Toast from '@/components/Toast.vue';
import {
connect as connectRoute,
goals as goalsRoute,
persona as personaRoute,
referralSource as referralSourceRoute,
} from '@/routes/app/welcome';
const maxWidthClass = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
} as const;
type MaxWidthSize = keyof typeof maxWidthClass;
const props = withDefaults(
defineProps<{
title?: string;
description?: string;
step?: number;
totalSteps?: number;
wide?: boolean;
size?: MaxWidthSize;
}>(),
{
title: undefined,
description: undefined,
step: undefined,
totalSteps: 3,
wide: false,
totalSteps: 4,
size: 'xl',
},
);
@ -29,6 +46,7 @@ const stepRoutes = computed(() => [
personaRoute(),
goalsRoute(),
referralSourceRoute(),
connectRoute(),
]);
const canNavigateTo = (stepNumber: number): boolean =>
@ -39,7 +57,7 @@ const canNavigateTo = (stepNumber: number): boolean =>
<div
class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10"
>
<div class="w-full" :class="wide ? 'max-w-4xl' : 'max-w-xl'">
<div class="w-full" :class="maxWidthClass[size]">
<div class="flex flex-col gap-8">
<div class="flex flex-col items-center gap-4">
<Link
@ -77,6 +95,7 @@ const canNavigateTo = (stepNumber: number): boolean =>
})
"
:data-testid="`welcome-step-${stepNumber}`"
:dusk="`welcome-step-${stepNumber}`"
>
<span
class="h-2 w-full rounded-full bg-primary transition-opacity hover:opacity-70 motion-reduce:transition-none"
@ -84,12 +103,9 @@ const canNavigateTo = (stepNumber: number): boolean =>
</Link>
<div
v-else
:class="[
'h-2 w-8 rounded-full transition-colors',
stepNumber <= step
? 'bg-primary'
: 'bg-muted',
]"
class="flex h-6 w-8 items-center"
:data-testid="`welcome-step-${stepNumber}`"
:dusk="`welcome-step-${stepNumber}`"
:aria-current="
stepNumber === step ? 'step' : undefined
"
@ -100,7 +116,16 @@ const canNavigateTo = (stepNumber: number): boolean =>
})
: undefined
"
/>
>
<span
:class="[
'h-2 w-full rounded-full transition-colors',
stepNumber <= step
? 'bg-primary'
: 'bg-muted',
]"
/>
</div>
</template>
</nav>
@ -114,5 +139,6 @@ const canNavigateTo = (stepNumber: number): boolean =>
<slot />
</div>
</div>
<Toast />
</div>
</template>

View file

@ -16,6 +16,7 @@ import { copyToClipboard } from '@/lib/utils';
import { complete } from '@/routes/app/onboarding';
import { skip as skipMcpRoute } from '@/routes/app/onboarding/mcp';
import { create as createPost } from '@/routes/app/posts';
import { SocialAccountStatus } from '@/types/social-account-status';
interface OnboardingStatus {
mcp_connected: boolean;
@ -49,7 +50,9 @@ const maxCompleteAttempts = 3;
const socialConnectedElsewhere = computed(
() =>
props.status.social_connected &&
!props.accounts.some((account) => account.status === 'connected'),
!props.accounts.some(
(account) => account.status === SocialAccountStatus.Connected,
),
);
// Keep listening until completion is stamped all_complete alone is not enough

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import NetworkConnectGrid, {
type AvailablePlatform,
type ConnectedAccount,
} from '@/components/accounts/NetworkConnectGrid.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import WelcomeLayout from '@/layouts/WelcomeLayout.vue';
import { store } from '@/routes/app/welcome/connect';
import { SocialAccountStatus } from '@/types/social-account-status';
const props = defineProps<{
platforms: AvailablePlatform[];
accounts: ConnectedAccount[];
}>();
const form = useForm({});
const hasConnectedAccount = computed((): boolean =>
props.accounts.some(
(account) => account.status === SocialAccountStatus.Connected,
),
);
const submit = (): void => {
if (form.processing || !hasConnectedAccount.value) {
return;
}
form.submit(store());
};
</script>
<template>
<Head :title="$t('welcome.connect.title')" />
<WelcomeLayout
:title="$t('welcome.connect.title')"
:description="$t('welcome.connect.description')"
:step="4"
size="7xl"
>
<NetworkConnectGrid
v-if="platforms.length > 0"
:platforms="platforms"
:connected-accounts="accounts"
grid-class="grid-cols-2 sm:grid-cols-3 xl:grid-cols-6"
data-testid="welcome-connect-grid"
dusk="welcome-connect-grid"
/>
<div class="mx-auto flex w-full max-w-sm flex-col items-center gap-3">
<InputError
:message="form.errors.connect"
dusk="welcome-connect-error"
/>
<Button as-child size="lg" class="w-full rounded-full">
<button
type="button"
data-testid="welcome-start-checkout"
dusk="welcome-start-checkout"
:disabled="form.processing || !hasConnectedAccount"
@click="submit"
>
{{ $t('welcome.continue') }}
</button>
</Button>
</div>
</WelcomeLayout>
</template>

View file

@ -133,7 +133,7 @@ const submit = (): void => {
:title="$t('welcome.goals_title')"
:description="$t('welcome.goals_description')"
:step="2"
wide
size="4xl"
>
<div class="flex flex-wrap justify-center gap-2.5">
<button

View file

@ -108,7 +108,7 @@ const submit = (): void => {
:title="$t('welcome.title')"
:description="$t('welcome.description')"
:step="1"
wide
size="4xl"
>
<div class="flex flex-wrap justify-center gap-2.5">
<button

View file

@ -30,7 +30,6 @@ import { store } from '@/routes/app/welcome/referral-source';
const props = defineProps<{
sources: string[];
selected?: string | null;
plan: { name: string; interval: string };
}>();
const form = useForm<{ referral_source: string }>({
@ -164,7 +163,7 @@ const submit = (): void => {
:title="$t('welcome.referral_source_title')"
:description="$t('welcome.referral_source_description')"
:step="3"
wide
size="4xl"
>
<div class="flex flex-wrap justify-center gap-2.5">
<button
@ -222,7 +221,8 @@ const submit = (): void => {
size="lg"
class="w-full rounded-full"
:disabled="form.referral_source === '' || form.processing"
data-testid="welcome-start-checkout"
data-testid="welcome-referral-continue"
dusk="welcome-referral-continue"
@click="submit"
>
{{ $t('welcome.continue') }}

View file

@ -0,0 +1,8 @@
export const SocialAccountStatus = {
Connected: 'connected',
Disconnected: 'disconnected',
TokenExpired: 'token_expired',
} as const;
export type SocialAccountStatusValue =
(typeof SocialAccountStatus)[keyof typeof SocialAccountStatus];

View file

@ -65,10 +65,14 @@
Route::get('welcome/goals', [WelcomeController::class, 'goals'])->name('app.welcome.goals');
Route::post('welcome/goals', [WelcomeController::class, 'storeGoals'])->name('app.welcome.goals.store');
Route::get('welcome/referral-source', [WelcomeController::class, 'referralSource'])->name('app.welcome.referral-source');
Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required');
Route::post('welcome/referral-source', [WelcomeController::class, 'storeReferralSource'])
->middleware('throttle:6,1')
->name('app.welcome.referral-source.store');
Route::get('welcome/connect', [WelcomeController::class, 'connect'])->name('app.welcome.connect');
Route::post('welcome/connect', [WelcomeController::class, 'storeConnect'])
->middleware('throttle:6,1')
->name('app.welcome.connect.store');
Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required');
Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing');
Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create');

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use App\Enums\User\Goal;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
/**
* Wait for a data-testid element to mount and lay out. Pest browser `@`
* selectors resolve to data-testid, and assertions do not auto-wait on SPA paint.
*/
function waitForWelcomeTestId(mixed $page, string $testId): void
{
$page->script(<<<JS
(async () => {
const sel = '[data-testid="{$testId}"]';
for (let i = 0; i < 100; i++) {
const el = document.querySelector(sel);
if (el && el.getBoundingClientRect().height > 0) return;
await new Promise((r) => setTimeout(r, 50));
}
})();
JS);
}
function welcomeOwnerOnConnectStep(): User
{
$user = User::factory()->create();
$user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
'referral_source' => ReferralSource::ProductHunt->value,
]);
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);
return $user->fresh();
}
test('connect step shows the grid and keeps continue disabled without a social account', function () {
config(['trypost.self_hosted' => false]);
$user = welcomeOwnerOnConnectStep();
$this->actingAs($user);
$page = visit(route('app.welcome.connect'));
waitForWelcomeTestId($page, 'welcome-start-checkout');
$page->assertRoute('app.welcome.connect')
->assertVisible('@welcome-connect-grid')
->assertVisible('@welcome-start-checkout')
->assertDisabled('@welcome-start-checkout')
->assertVisible('@welcome-step-4')
->assertNoJavaScriptErrors();
});
test('connect step enables continue when a social account is connected', function () {
config(['trypost.self_hosted' => false]);
$user = welcomeOwnerOnConnectStep();
SocialAccount::factory()->linkedin()->create([
'workspace_id' => $user->current_workspace_id,
]);
$this->actingAs($user->fresh());
$page = visit(route('app.welcome.connect'));
waitForWelcomeTestId($page, 'welcome-start-checkout');
$page->assertRoute('app.welcome.connect')
->assertVisible('@welcome-connect-grid')
->assertEnabled('@welcome-start-checkout')
->assertNoJavaScriptErrors();
});
test('connect step can go back to referral', function () {
config(['trypost.self_hosted' => false]);
$user = welcomeOwnerOnConnectStep();
$this->actingAs($user);
$page = visit(route('app.welcome.connect'));
waitForWelcomeTestId($page, 'welcome-step-3');
$page->click('@welcome-step-3');
waitForWelcomeTestId($page, 'welcome-referral-continue');
$page->assertRoute('app.welcome.referral-source')
->assertVisible('@welcome-referral-continue')
->assertNoJavaScriptErrors();
});
test('connect step redirects to persona when prior steps are missing', function () {
config(['trypost.self_hosted' => false]);
$user = User::factory()->create();
$this->actingAs($user);
$page = visit(route('app.welcome.connect'));
$page->assertRoute('app.welcome.persona')
->assertNoJavaScriptErrors();
});

View file

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\PostHog\IdentifyConnectedPlatforms;
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->account = Account::factory()->create();
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
});
test('job is queued on the posthog queue', function () {
$job = new IdentifyConnectedPlatforms((string) Str::uuid());
expect($job->queue)->toBe('posthog');
});
test('handle identifies the owner and groups with connected platforms', function () {
Queue::fake();
SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]);
(new IdentifyConnectedPlatforms((string) $this->workspace->id))->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'groupIdentify'
&& data_get($job->payload, 'groupType') === 'workspace'
&& data_get($job->payload, 'groupKey') === (string) $this->workspace->id
&& data_get($job->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value];
});
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'groupIdentify'
&& data_get($job->payload, 'groupType') === 'account'
&& data_get($job->payload, 'groupKey') === (string) $this->account->id
&& data_get($job->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value];
});
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'identify'
&& data_get($job->payload, 'distinctId') === $this->user->id
&& data_get($job->payload, 'properties.connected_platforms') === [Platform::LinkedIn->value];
});
});
test('handle keeps the account union when another workspace connects', function () {
Queue::fake();
SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]);
$otherWorkspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
SocialAccount::factory()->x()->create(['workspace_id' => $otherWorkspace->id]);
(new IdentifyConnectedPlatforms((string) $otherWorkspace->id))->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job) use ($otherWorkspace): bool {
return $job->method === 'groupIdentify'
&& data_get($job->payload, 'groupType') === 'workspace'
&& data_get($job->payload, 'groupKey') === (string) $otherWorkspace->id
&& data_get($job->payload, 'properties.connected_platforms') === [Platform::X->value];
});
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'groupIdentify'
&& data_get($job->payload, 'groupType') === 'account'
&& data_get($job->payload, 'groupKey') === (string) $this->account->id
&& data_get($job->payload, 'properties.connected_platforms') === [
Platform::LinkedIn->value,
Platform::X->value,
];
});
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'identify'
&& data_get($job->payload, 'distinctId') === $this->user->id
&& data_get($job->payload, 'properties.connected_platforms') === [
Platform::LinkedIn->value,
Platform::X->value,
];
});
});
test('handle identifies the owner without disconnected platforms', function () {
Queue::fake();
SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Disconnected,
]);
(new IdentifyConnectedPlatforms((string) $this->workspace->id))->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'identify'
&& data_get($job->payload, 'distinctId') === $this->user->id
&& data_get($job->payload, 'properties.connected_platforms') === [];
});
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'groupIdentify'
&& data_get($job->payload, 'groupType') === 'workspace'
&& data_get($job->payload, 'properties.connected_platforms') === [];
});
Queue::assertPushed(SendEvent::class, function (SendEvent $job): bool {
return $job->method === 'groupIdentify'
&& data_get($job->payload, 'groupType') === 'account'
&& data_get($job->payload, 'properties.connected_platforms') === [];
});
});
test('handle returns silently when the workspace does not exist', function () {
Queue::fake();
(new IdentifyConnectedPlatforms((string) Str::uuid()))->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle does not push when PostHog is disabled', function () {
config(['services.posthog.api_key' => null]);
Queue::fake();
SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]);
(new IdentifyConnectedPlatforms((string) $this->workspace->id))->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
});

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Enums\SocialAccount\Status;
use App\Jobs\PostHog\IdentifyConnectedPlatforms;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\SyncAccountUsage;
use App\Models\Account;
use App\Models\SocialAccount;
@ -22,6 +24,40 @@
]);
});
test('creating a social account dispatches IdentifyConnectedPlatforms', function () {
Bus::fake();
SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]);
Bus::assertDispatched(IdentifyConnectedPlatforms::class, function (IdentifyConnectedPlatforms $job): bool {
return $job->workspaceId === (string) $this->workspace->id;
});
});
test('deleting a social account dispatches IdentifyConnectedPlatforms', function () {
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]);
Bus::fake();
$socialAccount->delete();
Bus::assertDispatched(IdentifyConnectedPlatforms::class, function (IdentifyConnectedPlatforms $job): bool {
return $job->workspaceId === (string) $this->workspace->id;
});
});
test('disconnecting a social account dispatches IdentifyConnectedPlatforms', function () {
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]);
Bus::fake();
$socialAccount->update(['status' => Status::Disconnected]);
Bus::assertDispatched(IdentifyConnectedPlatforms::class, function (IdentifyConnectedPlatforms $job): bool {
return $job->workspaceId === (string) $this->workspace->id;
});
});
test('creating a social account dispatches SyncAccountUsage', function () {
Bus::fake();
@ -54,6 +90,8 @@
$socialAccount->update(['is_active' => false]);
Bus::assertNotDispatched(SyncAccountUsage::class);
Bus::assertNotDispatched(IdentifyConnectedPlatforms::class);
Bus::assertNotDispatched(SendEvent::class);
});
test('does not dispatch when PostHog is disabled', function () {
@ -64,6 +102,27 @@
SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
Bus::assertNotDispatched(SyncAccountUsage::class);
Bus::assertNotDispatched(IdentifyConnectedPlatforms::class);
Bus::assertNotDispatched(SendEvent::class);
});
test('does not identify connected platforms when self-hosted without PostHog', function () {
config([
'trypost.self_hosted' => true,
'services.posthog.enabled' => false,
'services.posthog.api_key' => null,
]);
Bus::fake();
$socialAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
]);
$this->assertModelExists($socialAccount);
Bus::assertNotDispatched(SyncAccountUsage::class);
Bus::assertNotDispatched(IdentifyConnectedPlatforms::class);
Bus::assertNotDispatched(SendEvent::class);
});
test('updating status on multiple batch-hydrated social accounts does not throw a lazy loading violation', function () {

View file

@ -6,14 +6,22 @@
use App\Enums\Plan\Slug;
use App\Enums\PostHog\CheckoutEvent;
use App\Enums\PostHog\WelcomeEvent;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Enums\User\Goal;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Enums\UserWorkspace\Role;
use App\Enums\Workspace\ContentLanguage;
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use App\Models\Plan;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Route;
beforeEach(function () {
@ -112,9 +120,11 @@
});
test('completed welcome steps remain reachable when going back', function () {
attachCurrentWorkspace($this->user);
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
'referral_source' => ReferralSource::Google->value,
]);
$this->actingAs($this->user->fresh())
@ -126,6 +136,16 @@
->get(route('app.welcome.goals'))
->assertOk()
->assertInertia(fn ($page) => $page->component('welcome/Goals', false));
$this->actingAs($this->user->fresh())
->get(route('app.welcome.referral-source'))
->assertOk()
->assertInertia(fn ($page) => $page->component('welcome/ReferralSource', false));
$this->actingAs($this->user->fresh())
->get(route('app.welcome.connect'))
->assertOk()
->assertInertia(fn ($page) => $page->component('welcome/Connect', false));
});
test('referral source redirects through incomplete prior steps', function (array $attributes, string $routeName) {
@ -163,7 +183,6 @@
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
$this->actingAs($this->user->fresh())
->get(route('app.welcome.referral-source'))
@ -176,8 +195,7 @@
&& collect($sources)->contains(ReferralSource::HackerNews->value)
&& collect($sources)->contains(ReferralSource::Directories->value)
&& collect($sources)->contains(ReferralSource::Founder->value))
->where('plan.name', $plan->name)
->where('plan.interval', 'monthly')
->missing('plan')
);
});
@ -197,7 +215,52 @@
'invalid' => [['referral_source' => 'not-a-source']],
]);
test('referral source store saves the source and starts Stripe checkout without a social account', function () {
test('welcome funnel captures connect between referral and checkout.started', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$workspace = attachCurrentWorkspace($this->user);
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->andReturn(redirect('https://checkout.stripe.test/session'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.persona.store'), ['persona' => Persona::Agency->value])
->assertRedirect(route('app.welcome.goals'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.goals.store'), ['goals' => [Goal::SaveTime->value]])
->assertRedirect(route('app.welcome.referral-source'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
])
->assertRedirect(route('app.welcome.connect'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.connect.store'))
->assertRedirect('https://checkout.stripe.test/session');
$funnel = WelcomeEvent::funnel();
$captured = collect(Bus::dispatched(SendEvent::class))
->filter(fn (SendEvent $event): bool => $event->method === 'capture')
->map(fn (SendEvent $event): string => (string) data_get($event->payload, 'event'))
->filter(fn (string $event): bool => in_array($event, $funnel, true))
->values()
->all();
expect($captured)->toBe($funnel);
});
test('referral source store saves the source mirrors it to PostHog and advances to connect', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update([
@ -205,6 +268,196 @@
'goals' => [Goal::SaveTime->value],
]);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
])
->assertRedirect(route('app.welcome.connect'));
expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt);
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'event') === WelcomeEvent::Referral->value
&& data_get($event->payload, 'properties.referral_source') === ReferralSource::ProductHunt->value);
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value,
);
});
test('connect redirects through incomplete prior steps', function (array $attributes, string $routeName, string $method, bool $withWorkspace) {
$this->user->update($attributes);
if ($withWorkspace) {
attachCurrentWorkspace($this->user);
}
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh());
$response = $method === 'get'
? $this->get(route('app.welcome.connect'))
: $this->post(route('app.welcome.connect.store'));
$response->assertRedirect(route($routeName));
})->with([
'get missing persona' => [[], 'app.welcome.persona', 'get'],
'get missing goals' => [['persona' => Persona::Agency->value], 'app.welcome.goals', 'get'],
'get missing referral' => [
[
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
],
'app.welcome.referral-source',
'get',
],
'post missing persona' => [[], 'app.welcome.persona', 'post'],
'post missing goals' => [['persona' => Persona::Agency->value], 'app.welcome.goals', 'post'],
'post missing referral' => [
[
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
],
'app.welcome.referral-source',
'post',
],
'get only removed goals' => [
[
'persona' => Persona::Agency->value,
'goals' => ['team_collaboration', 'automate_api', 'track_performance'],
'referral_source' => ReferralSource::Google->value,
],
'app.welcome.goals',
'get',
],
'post only removed goals' => [
[
'persona' => Persona::Agency->value,
'goals' => ['team_collaboration', 'automate_api', 'track_performance'],
'referral_source' => ReferralSource::Google->value,
],
'app.welcome.goals',
'post',
],
])->with([
'without workspace' => [false],
'with empty workspace' => [true],
]);
test('connect returns 404 when prior steps are complete but the user has no workspace', function () {
completeWelcomeThroughReferral($this->user);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->get(route('app.welcome.connect'))
->assertNotFound();
$this->actingAs($this->user->fresh())
->from(route('app.welcome.connect'))
->post(route('app.welcome.connect.store'))
->assertNotFound();
});
test('connect renders the network grid when the workspace has no accounts', function () {
completeWelcomeThroughReferral($this->user);
attachCurrentWorkspace($this->user);
$this->actingAs($this->user->fresh())
->get(route('app.welcome.connect'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('welcome/Connect', false)
->has('platforms', count(SocialPlatform::connectableOptions()))
->where('accounts', [])
);
});
test('connect renders connected accounts for the current workspace', function () {
completeWelcomeThroughReferral($this->user);
$workspace = attachCurrentWorkspace($this->user);
$account = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$this->actingAs($this->user->fresh())
->get(route('app.welcome.connect'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('welcome/Connect', false)
->has('platforms', count(SocialPlatform::connectableOptions()))
->has('accounts', 1)
->where('accounts.0.id', $account->id)
->where('accounts.0.platform', SocialPlatform::LinkedIn->value)
->where('accounts.0.status', Status::Connected->value)
);
});
test('connect copy exists in every locale', function (string $locale) {
expect(__('welcome.connect.title', [], $locale))->not->toBe('welcome.connect.title')
->and(__('welcome.connect.description', [], $locale))->not->toBe('welcome.connect.description')
->and(__('welcome.connect.required', [], $locale))->not->toBe('welcome.connect.required');
})->with(ContentLanguage::values());
test('connect store requires a connected social account', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
completeWelcomeThroughReferral($this->user);
attachCurrentWorkspace($this->user);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->post(route('app.welcome.connect.store'))
->assertSessionHasErrors('connect');
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === WelcomeEvent::Connect->value,
);
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value,
);
});
test('connect store rejects disconnected or expired social accounts', function () {
completeWelcomeThroughReferral($this->user);
$workspace = attachCurrentWorkspace($this->user);
SocialAccount::factory()->linkedin()->disconnected()->create(['workspace_id' => $workspace->id]);
SocialAccount::factory()->x()->tokenExpired()->create(['workspace_id' => $workspace->id]);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->post(route('app.welcome.connect.store'))
->assertSessionHasErrors('connect');
});
test('connect store ignores social accounts on another workspace', function () {
completeWelcomeThroughReferral($this->user);
attachCurrentWorkspace($this->user);
$otherWorkspace = Workspace::factory()->create([
'account_id' => $this->user->account_id,
'user_id' => $this->user->id,
]);
SocialAccount::factory()->linkedin()->create(['workspace_id' => $otherWorkspace->id]);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->post(route('app.welcome.connect.store'))
->assertSessionHasErrors('connect');
});
test('connect store starts Stripe checkout when a social account is connected', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
completeWelcomeThroughReferral($this->user);
$workspace = attachCurrentWorkspace($this->user);
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
]);
@ -214,28 +467,24 @@
->once()
->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $account->is($this->user->account)
&& $priceId === 'price_monthly_test'
&& $cancelUrl === route('app.welcome.referral-source'))
&& $cancelUrl === route('app.welcome.connect'))
->andReturn(redirect('https://checkout.stripe.test/session'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
])
->post(route('app.welcome.connect.store'))
->assertRedirect('https://checkout.stripe.test/session');
expect($this->user->fresh()->referral_source)->toBe(ReferralSource::ProductHunt);
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'event') === WelcomeEvent::Referral->value
&& data_get($event->payload, 'properties.referral_source') === ReferralSource::ProductHunt->value);
&& data_get($event->payload, 'event') === WelcomeEvent::Connect->value
&& data_get($event->payload, 'properties.platforms') === [SocialPlatform::LinkedIn->value]);
});
test('referral source store captures checkout.started with the plan name and interval', function () {
test('connect store captures checkout.started with the plan name and interval', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
completeWelcomeThroughReferral($this->user);
$workspace = attachCurrentWorkspace($this->user);
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
$plan->update(['stripe_monthly_price_id' => 'price_monthly_test']);
@ -246,9 +495,7 @@
->andReturn(redirect('https://checkout.stripe.test/session'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
]);
->post(route('app.welcome.connect.store'));
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'event') === CheckoutEvent::Started->value
@ -256,13 +503,12 @@
&& data_get($event->payload, 'properties.interval') === 'monthly');
});
test('referral source store does not capture checkout.started when Stripe checkout creation fails', function () {
test('connect store does not capture checkout.started when Stripe checkout creation fails', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
completeWelcomeThroughReferral($this->user);
$workspace = attachCurrentWorkspace($this->user);
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
@ -274,16 +520,44 @@
->andThrow(new RuntimeException('Stripe checkout could not be created.'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
]);
->post(route('app.welcome.connect.store'));
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === WelcomeEvent::Connect->value,
);
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value,
);
});
test('connect store still redirects to stripe when posthog capture fails', function () {
Exceptions::fake();
completeWelcomeThroughReferral($this->user);
$workspace = attachCurrentWorkspace($this->user);
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->andReturn(redirect('https://checkout.stripe.test/session'));
$this->mock(PostHogService::class)
->shouldReceive('capture')
->andThrow(new RuntimeException('PostHog is down.'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.connect.store'))
->assertRedirect('https://checkout.stripe.test/session');
Exceptions::assertReported(RuntimeException::class);
});
test('welcome steps redirect to calendar for subscribed accounts', function (string $routeName, string $method, array $payload = []) {
subscribeAccount($this->user->account);
@ -301,6 +575,8 @@
'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
'referral source' => ['app.welcome.referral-source', 'get'],
'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
'connect' => ['app.welcome.connect', 'get'],
'connect store' => ['app.welcome.connect.store', 'post'],
]);
test('welcome redirects generic-trial accounts with app access to calendar', function () {
@ -335,6 +611,8 @@
'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
'referral source' => ['app.welcome.referral-source', 'get'],
'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
'connect' => ['app.welcome.connect', 'get'],
'connect store' => ['app.welcome.connect.store', 'post'],
]);
test('old onboarding icp routes are not registered', function (string $routeName) {
@ -350,28 +628,38 @@
'checkout' => 'app.onboarding.checkout',
]);
test('members cannot start Stripe checkout from welcome', function () {
test('members cannot start Stripe checkout from welcome', function (bool $withWorkspace) {
$member = User::factory()->create(['account_id' => $this->user->account_id]);
$member->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
completeWelcomeThroughReferral($member);
if ($withWorkspace) {
attachCurrentWorkspace($member);
}
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
// Members never reach the referral step — they are held on the
// subscription-required screen before any checkout attempt.
$this->actingAs($member->fresh())
->get(route('app.welcome.referral-source'))
->get(route('app.welcome.connect'))
->assertRedirect(route('app.welcome.subscription-required'));
$this->actingAs($member->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
])
->post(route('app.welcome.connect.store'))
->assertRedirect(route('app.welcome.subscription-required'));
})->with([
'without workspace' => [false],
'with empty workspace' => [true],
]);
expect($member->fresh()->referral_source)->toBeNull();
test('subscribed owners skip connect validation and go to calendar', function () {
subscribeAccount($this->user->account);
completeWelcomeThroughReferral($this->user);
attachCurrentWorkspace($this->user);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->post(route('app.welcome.connect.store'))
->assertRedirect(route('app.calendar'));
});
test('members without app access are held on the subscription required screen', function (string $routeName, string $method, array $payload = []) {
@ -391,6 +679,8 @@
'goals store' => ['app.welcome.goals.store', 'post', ['goals' => [Goal::SaveTime->value]]],
'referral source' => ['app.welcome.referral-source', 'get'],
'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]],
'connect' => ['app.welcome.connect', 'get'],
'connect store' => ['app.welcome.connect.store', 'post'],
]);
test('subscription required screen renders for members without app access', function () {
@ -447,25 +737,47 @@
->assertRedirect(route('app.calendar'));
});
test('referral source store fails loudly when the monthly price is not configured', function () {
test('connect store fails loudly when the monthly price is not configured', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
completeWelcomeThroughReferral($this->user);
$workspace = attachCurrentWorkspace($this->user);
SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
Plan::where('slug', Slug::Workspace)->update(['stripe_monthly_price_id' => null]);
$this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect');
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::Google->value,
])
->post(route('app.welcome.connect.store'))
->assertServerError();
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === WelcomeEvent::Connect->value,
);
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value,
);
});
function completeWelcomeThroughReferral(User $user): void
{
$user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
'referral_source' => ReferralSource::ProductHunt->value,
]);
}
function attachCurrentWorkspace(User $user): Workspace
{
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);
return $workspace;
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use App\Enums\User\Goal;
test('containsCurrent is true only when at least one stored goal still exists', function (?array $goals, bool $expected) {
expect(Goal::containsCurrent($goals))->toBe($expected);
})->with([
'null' => [null, false],
'empty' => [[], false],
'current' => [[Goal::SaveTime->value], true],
'removed only' => [['team_collaboration', 'automate_api'], false],
'mixed' => [['team_collaboration', Goal::SaveTime->value], true],
]);

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use App\Enums\PostHog\CheckoutEvent;
use App\Enums\PostHog\WelcomeEvent;
test('welcome funnel puts connect between referral and checkout.started', function () {
expect(WelcomeEvent::funnel())->toBe([
WelcomeEvent::Persona->value,
WelcomeEvent::Goals->value,
WelcomeEvent::Referral->value,
WelcomeEvent::Connect->value,
CheckoutEvent::Started->value,
]);
});