Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
da45562
feat: add social connect step to welcome before Stripe checkout
paulocastellano Aug 17, 2026
cac2d08
Nest welcome connect copy under a connect array.
paulocastellano Aug 17, 2026
a438192
Require a connected social account before welcome checkout.
paulocastellano Aug 17, 2026
bd09f6f
Harden the welcome connect step after review.
paulocastellano Aug 17, 2026
747b138
Refactor social account status handling across components
paulocastellano Aug 17, 2026
1917218
Refactor workspace resolution in WelcomeController and StoreWelcomeCo…
paulocastellano Aug 17, 2026
34c1f41
Inline welcome connect PostHog platforms from the current workspace.
paulocastellano Aug 17, 2026
0bca699
Inline Stripe checkout into the welcome connect store.
paulocastellano Aug 17, 2026
45fa3bc
Move welcome connect validation into the controller.
paulocastellano Aug 17, 2026
84dd3d3
Show welcome toasts and cover remaining connect cases.
paulocastellano Aug 17, 2026
fe2e967
Assume a welcome workspace, validate connect in the FormRequest, and …
paulocastellano Aug 17, 2026
60f905c
Rename WelcomeEvent::dashboardFunnel() to funnel().
paulocastellano Aug 17, 2026
84a007c
Identify connected platforms from the social account observer.
paulocastellano Aug 17, 2026
5427eb1
Queue connected-platform identify on the posthog queue.
paulocastellano Aug 17, 2026
1367040
Harden welcome connect: 404 without a workspace, and keep step redire…
paulocastellano Aug 17, 2026
155889b
Identify connected platforms on workspace and account groups, and kee…
paulocastellano Aug 17, 2026
ea58c23
Share hasCurrentGoals on User and keep Stripe checkout when PostHog c…
paulocastellano Aug 17, 2026
c5c3520
Skip welcome connect validation when the controller would redirect th…
paulocastellano Aug 17, 2026
f72664c
Move current-goal membership onto the Goal enum.
paulocastellano Aug 17, 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
17 changes: 17 additions & 0 deletions app/Enums/PostHog/WelcomeEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}
}
18 changes: 18 additions & 0 deletions app/Enums/User/Goal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) !== [];
}
}
104 changes: 66 additions & 38 deletions app/Http/Controllers/App/WelcomeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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]);
Expand All @@ -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;

Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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
Expand Down
70 changes: 70 additions & 0 deletions app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php
Original file line number Diff line number Diff line change
@@ -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'));
}
});
}
}
87 changes: 87 additions & 0 deletions app/Jobs/PostHog/IdentifyConnectedPlatforms.php
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading