From afc4c7a80bc4b3d14836948669c0fbbbf5a9d39f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 25 Jun 2026 20:49:04 -0300 Subject: [PATCH] feat(onboarding): add goal step after persona After picking who they are, users now pick what they want to achieve with TryPost. A multi-select goal step (12 options + an exclusive "just exploring" and "something else") sits between the persona step and connect, mirroring the persona screen's style. The goals persist to a json column on users and are mirrored to PostHog on identify (onboarding_goals array plus a boolean per goal), so campaigns can be cross-tabbed against the intent they actually attracted. connect now requires both a persona and at least one goal; persona store advances to the goal step. Options are grounded in TryPost's real capabilities (publishing, AI content, brand voice, automation via API/MCP, collaboration, analytics) and copy is localized in en/es/pt-BR. --- app/Enums/User/Goal.php | 30 ++++ .../Controllers/App/OnboardingController.php | 71 +++++++++ .../StoreOnboardingGoalsRequest.php | 40 +++++ app/Models/User.php | 2 + ..._06_25_232554_add_goals_to_users_table.php | 28 ++++ lang/en/onboarding.php | 17 ++ lang/es/onboarding.php | 17 ++ lang/pt-BR/onboarding.php | 17 ++ resources/js/pages/onboarding/Goals.vue | 147 ++++++++++++++++++ routes/app.php | 2 + .../Onboarding/OnboardingControllerTest.php | 124 ++++++++++++++- 11 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 app/Enums/User/Goal.php create mode 100644 app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php create mode 100644 database/migrations/2026_06_25_232554_add_goals_to_users_table.php create mode 100644 resources/js/pages/onboarding/Goals.vue diff --git a/app/Enums/User/Goal.php b/app/Enums/User/Goal.php new file mode 100644 index 00000000..2211a2c0 --- /dev/null +++ b/app/Enums/User/Goal.php @@ -0,0 +1,30 @@ + $persona, ]); + return redirect()->route('app.onboarding.goals'); + } + + public function goals(Request $request): Response|RedirectResponse + { + if (config('trypost.self_hosted')) { + return redirect()->route('app.calendar'); + } + + $user = $request->user(); + + if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { + return redirect()->route('app.calendar'); + } + + if (! $user->persona) { + return redirect()->route('app.onboarding'); + } + + return Inertia::render('onboarding/Goals', [ + 'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()), + 'selected' => $user->goals ?? [], + ]); + } + + public function storeGoals(StoreOnboardingGoalsRequest $request, PostHogService $postHog): RedirectResponse + { + if (config('trypost.self_hosted')) { + return redirect()->route('app.calendar'); + } + + $user = $request->user(); + + if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) { + return redirect()->route('app.calendar'); + } + + if (! $user->persona) { + return redirect()->route('app.onboarding'); + } + + $goals = array_values($request->validated('goals')); + + $user->update(['goals' => $goals]); + + $postHog->identify($user->id, $this->goalProperties($goals)); + return redirect()->route('app.onboarding.connect'); } @@ -78,6 +127,10 @@ public function connect(Request $request): Response|RedirectResponse return redirect()->route('app.onboarding'); } + if (! $user->goals) { + return redirect()->route('app.onboarding.goals'); + } + $workspace = $user->currentWorkspace; if (! $workspace) { @@ -130,4 +183,22 @@ public function checkout(Request $request, StartSubscriptionCheckout $checkout): route('app.onboarding.connect'), ); } + + /** + * PostHog person properties for the selected goals: the full array plus a + * boolean per goal, so campaigns can be cross-tabbed against each intent. + * + * @param array $goals + * @return array + */ + private function goalProperties(array $goals): array + { + $properties = ['onboarding_goals' => $goals]; + + foreach (Goal::cases() as $goal) { + $properties["goal_{$goal->value}"] = in_array($goal->value, $goals, true); + } + + return $properties; + } } diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php new file mode 100644 index 00000000..fdda3bbf --- /dev/null +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php @@ -0,0 +1,40 @@ + + */ + public function rules(): array + { + return [ + 'goals' => ['required', 'array', 'min:1'], + 'goals.*' => [Rule::enum(Goal::class)], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $goals = (array) $this->input('goals', []); + + if (in_array(Goal::JustExploring->value, $goals, true) && count($goals) > 1) { + $validator->errors()->add('goals', __('onboarding.goals_exclusive')); + } + }); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index da109ae4..d8b11ebe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -44,6 +44,7 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable 'utm_content', 'registration_ip', 'persona', + 'goals', ]; /** @@ -78,6 +79,7 @@ protected function casts(): array 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', 'persona' => Persona::class, + 'goals' => 'array', ]; } diff --git a/database/migrations/2026_06_25_232554_add_goals_to_users_table.php b/database/migrations/2026_06_25_232554_add_goals_to_users_table.php new file mode 100644 index 00000000..677aa592 --- /dev/null +++ b/database/migrations/2026_06_25_232554_add_goals_to_users_table.php @@ -0,0 +1,28 @@ +json('goals')->nullable()->after('persona'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('goals'); + }); + } +}; diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php index c0808aae..a56d1c12 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -17,6 +17,23 @@ 'online_store' => 'Online store', 'other' => 'Other', ], + 'goals_title' => 'What\'s your goal with TryPost?', + 'goals_description' => 'Pick everything that fits and we\'ll set TryPost up for you.', + 'goals_exclusive' => 'If you\'re just exploring, leave the other goals unchecked.', + 'goals' => [ + 'save_time' => 'Save time by posting everywhere at once', + 'ai_content' => 'Create posts faster with AI', + 'plan_calendar' => 'Plan my posts on a calendar', + 'stay_on_brand' => 'Keep every post on brand', + 'grow_audience' => 'Grow my audience and engagement', + 'drive_sales' => 'Get more traffic and sales', + 'manage_clients' => 'Manage several brands or clients', + 'team_collaboration' => 'Work with my team', + 'automate_api' => 'Automate posting with the API, MCP or code', + 'track_performance' => 'See how my posts perform', + 'just_exploring' => 'Just exploring for now', + 'other' => 'Something else', + ], 'connect' => [ 'title' => 'Connect your first network', 'description' => 'Link at least one social account to start scheduling. You can add more anytime.', diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php index cb5ff7ba..74eac002 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -17,6 +17,23 @@ 'online_store' => 'Tienda online', 'other' => 'Otro', ], + 'goals_title' => '¿Cuál es tu objetivo con TryPost?', + 'goals_description' => 'Marca todo lo que encaje y adaptamos TryPost a ti.', + 'goals_exclusive' => 'Si solo estás explorando, deja los demás objetivos sin marcar.', + 'goals' => [ + 'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez', + 'ai_content' => 'Crear publicaciones más rápido con IA', + 'plan_calendar' => 'Planificar mis publicaciones en un calendario', + 'stay_on_brand' => 'Mantener la coherencia de mi marca', + 'grow_audience' => 'Hacer crecer mi audiencia y engagement', + 'drive_sales' => 'Conseguir más tráfico y ventas', + 'manage_clients' => 'Gestionar varias marcas o clientes', + 'team_collaboration' => 'Trabajar con mi equipo', + 'automate_api' => 'Automatizar publicaciones con la API, MCP o código', + 'track_performance' => 'Ver cómo rinden mis publicaciones', + 'just_exploring' => 'Solo estoy explorando por ahora', + 'other' => 'Otra cosa', + ], 'connect' => [ 'title' => 'Conecta tu primera red', 'description' => 'Vincula al menos una cuenta social para empezar a programar. Puedes añadir más cuando quieras.', diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php index 350ecf80..49455f30 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -17,6 +17,23 @@ 'online_store' => 'Loja online', 'other' => 'Outro', ], + 'goals_title' => 'Qual o seu objetivo com o TryPost?', + 'goals_description' => 'Marque tudo que faz sentido e a gente ajusta o TryPost pra você.', + 'goals_exclusive' => 'Se você só está dando uma olhada, deixe os outros desmarcados.', + 'goals' => [ + 'save_time' => 'Economizar tempo postando em todas as redes de uma vez', + 'ai_content' => 'Criar posts mais rápido com IA', + 'plan_calendar' => 'Planejar meus posts num calendário', + 'stay_on_brand' => 'Manter a consistência da minha marca', + 'grow_audience' => 'Crescer minha audiência e engajamento', + 'drive_sales' => 'Conseguir mais tráfego e vendas', + 'manage_clients' => 'Gerenciar várias marcas ou clientes', + 'team_collaboration' => 'Trabalhar com meu time', + 'automate_api' => 'Automatizar publicações com a API, MCP ou código', + 'track_performance' => 'Ver o desempenho dos meus posts', + 'just_exploring' => 'Só dando uma olhada por enquanto', + 'other' => 'Outra coisa', + ], 'connect' => [ 'title' => 'Conecte sua primeira rede', 'description' => 'Vincule pelo menos uma conta social para começar a agendar. Você pode adicionar mais quando quiser.', diff --git a/resources/js/pages/onboarding/Goals.vue b/resources/js/pages/onboarding/Goals.vue new file mode 100644 index 00000000..a5e7db0d --- /dev/null +++ b/resources/js/pages/onboarding/Goals.vue @@ -0,0 +1,147 @@ + + + diff --git a/routes/app.php b/routes/app.php index f503f8f4..9c64062e 100644 --- a/routes/app.php +++ b/routes/app.php @@ -58,6 +58,8 @@ Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe'); Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding'); Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store'); + Route::get('onboarding/goals', [OnboardingController::class, 'goals'])->name('app.onboarding.goals'); + Route::post('onboarding/goals', [OnboardingController::class, 'storeGoals'])->name('app.onboarding.goals.store'); Route::get('onboarding/connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect'); Route::post('onboarding/connect', [OnboardingController::class, 'checkout'])->name('app.onboarding.checkout'); Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing'); diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php index aac0c9bd..de911dd4 100644 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -5,6 +5,7 @@ use App\Actions\Billing\StartSubscriptionCheckout; use App\Enums\Plan\Slug; use App\Enums\SocialAccount\Platform; +use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Jobs\PostHog\SendEvent; use App\Models\Account; @@ -96,7 +97,7 @@ function subscribeOnboardingAccount(Account $account): void expect($this->user->fresh()->persona)->toBeNull(); }); -test('onboarding store saves the persona, mirrors to PostHog and advances to the connect step', function () { +test('onboarding store saves the persona, mirrors to PostHog and advances to the goals step', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); @@ -104,7 +105,7 @@ function subscribeOnboardingAccount(Account $account): void 'persona' => Persona::Agency->value, ]); - $response->assertRedirect(route('app.onboarding.connect')); + $response->assertRedirect(route('app.onboarding.goals')); expect($this->user->fresh()->persona)->toBe(Persona::Agency); Bus::assertDispatched(SendEvent::class); @@ -121,8 +122,117 @@ function subscribeOnboardingAccount(Account $account): void expect($this->user->fresh()->persona)->toBeNull(); }); -test('connect renders the network grid for an unsubscribed account that picked a persona', function () { +test('goals renders the goal selection for an account that picked a persona', function () { $this->user->update(['persona' => Persona::Agency->value]); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.goals')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('onboarding/Goals') + ->has('goals', count(Goal::cases())) + ); +}); + +test('goals redirects to the persona step when no persona was chosen', function () { + $response = $this->actingAs($this->user)->get(route('app.onboarding.goals')); + + $response->assertRedirect(route('app.onboarding')); +}); + +test('goals redirects to calendar in self-hosted mode', function () { + config(['trypost.self_hosted' => true]); + + $response = $this->actingAs($this->user)->get(route('app.onboarding.goals')); + + $response->assertRedirect(route('app.calendar')); +}); + +test('goals redirects to calendar when already subscribed', function () { + subscribeOnboardingAccount($this->user->account); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.goals')); + + $response->assertRedirect(route('app.calendar')); +}); + +test('goals store requires at least one goal', function () { + $this->user->update(['persona' => Persona::Agency->value]); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), ['goals' => []]); + + $response->assertSessionHasErrors('goals'); + expect($this->user->fresh()->goals)->toBeNull(); +}); + +test('goals store rejects an invalid goal', function () { + $this->user->update(['persona' => Persona::Agency->value]); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ + 'goals' => ['not-a-goal'], + ]); + + $response->assertSessionHasErrors('goals.0'); +}); + +test('goals store rejects just exploring combined with other goals', function () { + $this->user->update(['persona' => Persona::Agency->value]); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ + 'goals' => [Goal::JustExploring->value, Goal::SaveTime->value], + ]); + + $response->assertSessionHasErrors('goals'); +}); + +test('goals store saves the goals, mirrors to PostHog and advances to the connect step', function () { + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); + Bus::fake(); + + $this->user->update(['persona' => Persona::Agency->value]); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ + 'goals' => [Goal::SaveTime->value, Goal::AiContent->value], + ]); + + $response->assertRedirect(route('app.onboarding.connect')); + expect($this->user->fresh()->goals)->toBe([Goal::SaveTime->value, Goal::AiContent->value]); + + Bus::assertDispatched(SendEvent::class); +}); + +test('goals store redirects to the persona step when no persona was chosen', function () { + $response = $this->actingAs($this->user)->post(route('app.onboarding.goals.store'), [ + 'goals' => [Goal::SaveTime->value], + ]); + + $response->assertRedirect(route('app.onboarding')); + expect($this->user->fresh()->goals)->toBeNull(); +}); + +test('goals store does nothing in self-hosted mode', function () { + config(['trypost.self_hosted' => true]); + $this->user->update(['persona' => Persona::Agency->value]); + + $response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ + 'goals' => [Goal::SaveTime->value], + ]); + + $response->assertRedirect(route('app.calendar')); + expect($this->user->fresh()->goals)->toBeNull(); +}); + +test('connect redirects to the goals step when a persona was chosen but no goals', function () { + $this->user->update(['persona' => Persona::Agency->value]); + onboardingWorkspace($this->user); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertRedirect(route('app.onboarding.goals')); +}); + +test('connect renders the network grid for an unsubscribed account that picked a persona', function () { + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); onboardingWorkspace($this->user); $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); @@ -137,7 +247,7 @@ function subscribeOnboardingAccount(Account $account): void }); test('connect offers a single linkedin card and no standalone linkedin page card', function () { - $this->user->update(['persona' => Persona::Agency->value]); + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); onboardingWorkspace($this->user); $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); @@ -152,7 +262,7 @@ function subscribeOnboardingAccount(Account $account): void }); test('connect lists the workspace social accounts already connected', function () { - $this->user->update(['persona' => Persona::Agency->value]); + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); $workspace = onboardingWorkspace($this->user); SocialAccount::factory()->create(['workspace_id' => $workspace->id]); @@ -190,7 +300,7 @@ function subscribeOnboardingAccount(Account $account): void }); test('checkout blocks and redirects back when no network is connected', function () { - $this->user->update(['persona' => Persona::Agency->value]); + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); onboardingWorkspace($this->user); $this->mock(StartSubscriptionCheckout::class) @@ -203,7 +313,7 @@ function subscribeOnboardingAccount(Account $account): void }); test('checkout starts monthly checkout once at least one network is connected', function () { - $this->user->update(['persona' => Persona::Agency->value]); + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); $workspace = onboardingWorkspace($this->user); SocialAccount::factory()->create(['workspace_id' => $workspace->id]);