trypost/app/Http/Controllers/App/WelcomeController.php

241 lines
7.4 KiB
PHP
Raw Normal View History

Welcome: pre-subscription funnel and member subscription-required screen (#243) * Rename pre-subscription onboarding funnel to Welcome. Move the ICP steps to /welcome, drop the social-connect checkout gate, hold unpaid members on a subscription-required screen, and keep legacy /onboarding URLs working until the post-subscription checklist lands. Closes #237 Co-authored-by: Cursor <cursoragent@cursor.com> * Drop legacy /onboarding ICP URL aliases. Unfinished users re-enter Welcome via EnsureAccountReady on next login; /onboarding stays free for the post-subscription checklist. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify Welcome PostHog event names. Use welcome.persona/goals/referral and drop the unused checkout case — begin checkout stays on the frontend as checkout.started / begin_checkout. Co-authored-by: Cursor <cursoragent@cursor.com> * Slim welcome goal options to match the #204 set. Drop team_collaboration, automate_api, and track_performance so the goals step stays at nine choices. Co-authored-by: Cursor <cursoragent@cursor.com> * Split Welcome AI goals into TryPost AI and MCP assistants. Rewrite ai_content for in-app generation and add use_mcp so Claude/ChatGPT/Cursor intent is captured separately across locales. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden Welcome goals gate and drop dead checkout UI. Treat removed goal values as incomplete so mid-funnel users re-select, remove the unused canCheckout branch, and fix the pt-BR welcome progress label. Co-authored-by: Cursor <cursoragent@cursor.com> * Add password visibility toggle to the login form. Match the register eye control so users can reveal their password while signing in. Co-authored-by: Cursor <cursoragent@cursor.com> * Point the sidebar community link to Discord. Replace the X stay-updated entry with Join Discord and the trypost.it/discord invite. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename the sidebar Discord link to Discord community. Softer label that matches the other support nav items. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix broken Turkish Discord community translation. An unescaped apostrophe left a parse error in lang/tr/sidebar.php. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:34:50 +00:00
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
use App\Enums\PostHog\WelcomeEvent;
use App\Enums\User\Goal;
use App\Enums\User\Persona;
use App\Enums\User\ReferralSource;
use App\Http\Requests\App\Welcome\StoreWelcomeGoalsRequest;
use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest;
use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest;
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;
class WelcomeController extends Controller
{
public function persona(Request $request): InertiaResponse|RedirectResponse
{
if ($redirect = $this->redirectIfUnavailable($request)) {
return $redirect;
}
return Inertia::render('welcome/Persona', [
'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()),
'selected' => $request->user()->persona?->value,
]);
}
public function storePersona(StoreWelcomePersonaRequest $request, PostHogService $postHog): RedirectResponse
{
if ($redirect = $this->redirectIfUnavailable($request)) {
return $redirect;
}
$user = $request->user();
$persona = (string) $request->validated('persona');
$user->update(['persona' => $persona]);
$postHog->identify($user->id, [
'persona' => $persona,
]);
$postHog->capture(
$user->id,
WelcomeEvent::Persona->value,
['persona' => $persona],
$user->account,
);
return redirect()->route('app.welcome.goals');
}
public function goals(Request $request): InertiaResponse|RedirectResponse
{
if ($redirect = $this->redirectIfStepIncomplete($request)) {
return $redirect;
}
$user = $request->user();
return Inertia::render('welcome/Goals', [
'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()),
'selected' => $user->goals ?? [],
]);
}
public function storeGoals(StoreWelcomeGoalsRequest $request, PostHogService $postHog): RedirectResponse
{
if ($redirect = $this->redirectIfStepIncomplete($request)) {
return $redirect;
}
$user = $request->user();
$goals = array_values($request->validated('goals'));
$user->update(['goals' => $goals]);
$postHog->identify($user->id, [
'goals' => $goals,
]);
$postHog->capture(
$user->id,
WelcomeEvent::Goals->value,
['goals' => $goals],
$user->account,
);
return redirect()->route('app.welcome.referral-source');
}
public function referralSource(Request $request): InertiaResponse|RedirectResponse
{
if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) {
return $redirect;
}
$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 {
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]);
$postHog->identify($user->id, [
'referral_source' => $referralSource,
]);
$postHog->capture(
$user->id,
WelcomeEvent::Referral->value,
['referral_source' => $referralSource],
$user->account,
);
return $this->startCheckout($request, $checkout);
}
private function startCheckout(
Request $request,
StartSubscriptionCheckout $checkout,
): Response|RedirectResponse {
$user = $request->user();
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
$priceId = $plan->stripe_monthly_price_id;
abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Monthly price is not configured.');
return $checkout->redirect(
$user->account,
$priceId,
route('app.welcome.referral-source'),
);
}
public function subscriptionRequired(Request $request): InertiaResponse|RedirectResponse
{
$user = $request->user();
if ($user->account?->hasAppAccess()) {
return redirect()->route('app.calendar');
}
if ($user->isAccountOwner()) {
return redirect()->route('app.welcome.persona');
}
return Inertia::render('welcome/SubscriptionRequired', [
'ownerName' => $user->account?->owner?->name,
]);
}
private function redirectIfStepIncomplete(Request $request, bool $requireGoals = false): ?RedirectResponse
{
if ($redirect = $this->redirectIfUnavailable($request)) {
return $redirect;
}
$user = $request->user();
if (! $user->persona) {
return redirect()->route('app.welcome.persona');
}
if ($requireGoals && ! $this->hasCurrentGoals($user)) {
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;
}
$allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases());
return array_intersect($goals, $allowed) !== [];
}
private function redirectIfUnavailable(Request $request): ?RedirectResponse
{
$user = $request->user();
// Match EnsureAccountReady — generic-trial (no-card) users already have
// app access and must not be sent through Stripe checkout again.
// Self-hosted always has app access, so welcome/checkout is skipped too.
if ($user->account?->hasAppAccess()) {
return redirect()->route('app.calendar');
}
// Members can't check out — hold them on a dedicated screen instead of
// walking an ICP flow they can never finish.
if (! $user->isAccountOwner()) {
return redirect()->route('app.welcome.subscription-required');
}
return null;
}
}