diff --git a/app/Enums/User/Goal.php b/app/Enums/User/Goal.php new file mode 100644 index 00000000..ced8c382 --- /dev/null +++ b/app/Enums/User/Goal.php @@ -0,0 +1,21 @@ + $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, [ + 'goals' => $goals, + ]); + return redirect()->route('app.onboarding.connect'); } @@ -78,6 +129,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) { diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php new file mode 100644 index 00000000..e127eab3 --- /dev/null +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'goals' => ['required', 'array', 'min:1'], + 'goals.*' => [Rule::enum(Goal::class)], + ]; + } +} 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..75768dd0 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -17,6 +17,22 @@ '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' => [ + '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..f612aa85 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -17,6 +17,22 @@ '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' => [ + '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..ae9ed497 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -17,6 +17,22 @@ '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' => [ + '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..5f25e6aa 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,134 @@ 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 accepts any combination of valid 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->assertRedirect(route('app.onboarding.connect')); + expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->value, Goal::SaveTime->value]); +}); + +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 saves just exploring on its own as a real signal', 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::JustExploring->value], + ]); + + $response->assertRedirect(route('app.onboarding.connect')); + expect($this->user->fresh()->goals)->toBe([Goal::JustExploring->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 +264,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 +279,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 +317,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 +330,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]); @@ -246,3 +373,63 @@ function subscribeOnboardingAccount(Account $account): void $response->assertRedirect(route('app.calendar')); }); + +test('connect redirects to workspace creation when no workspace exists', function () { + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertRedirect(route('app.workspaces.create')); +}); + +test('a user walks the full onboarding flow from the account gate to stripe checkout', function () { + Plan::where('slug', Slug::Workspace)->firstOrFail()->update(['stripe_monthly_price_id' => 'price_monthly_test']); + $workspace = onboardingWorkspace($this->user); + + // The account gate sends an unsubscribed user into onboarding. + $this->actingAs($this->user->fresh())->get(route('app.calendar')) + ->assertRedirect(route('app.onboarding')); + + // Persona advances to goals. + $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), ['persona' => Persona::Creator->value]) + ->assertRedirect(route('app.onboarding.goals')); + + // Goals advances to connect. + $this->actingAs($this->user->fresh())->post(route('app.onboarding.goals.store'), [ + 'goals' => [Goal::AiContent->value, Goal::SaveTime->value], + ])->assertRedirect(route('app.onboarding.connect')); + + // Connect renders now that persona, goals and a workspace are present. + $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'))->assertOk(); + + // Checkout blocks until a network is connected. + $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout')) + ->assertRedirect(route('app.onboarding.connect')); + + // Once a network is connected, checkout hands off to Stripe. + SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->once() + ->andReturn(redirect('https://checkout.stripe.test/session')); + + $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout')) + ->assertRedirect('https://checkout.stripe.test/session'); + + expect($this->user->fresh()->persona)->toBe(Persona::Creator); + expect($this->user->fresh()->goals)->toBe([Goal::AiContent->value, Goal::SaveTime->value]); +}); + +test('a self-hosted user never enters the onboarding flow', function () { + config(['trypost.self_hosted' => true]); + onboardingWorkspace($this->user); + + // The app is reachable directly, with no subscription. + $this->actingAs($this->user->fresh())->get(route('app.calendar'))->assertOk(); + + // Every onboarding step just bounces to the calendar. + foreach (['app.onboarding', 'app.onboarding.goals', 'app.onboarding.connect'] as $routeName) { + $this->actingAs($this->user->fresh())->get(route($routeName))->assertRedirect(route('app.calendar')); + } +});