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.
This commit is contained in:
parent
93208d4bb2
commit
afc4c7a80b
11 changed files with 488 additions and 7 deletions
30
app/Enums/User/Goal.php
Normal file
30
app/Enums/User/Goal.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\User;
|
||||
|
||||
enum Goal: string
|
||||
{
|
||||
case SaveTime = 'save_time';
|
||||
case AiContent = 'ai_content';
|
||||
case PlanCalendar = 'plan_calendar';
|
||||
case StayOnBrand = 'stay_on_brand';
|
||||
case GrowAudience = 'grow_audience';
|
||||
case DriveSales = 'drive_sales';
|
||||
case ManageClients = 'manage_clients';
|
||||
case TeamCollaboration = 'team_collaboration';
|
||||
case AutomateApi = 'automate_api';
|
||||
case TrackPerformance = 'track_performance';
|
||||
case JustExploring = 'just_exploring';
|
||||
case Other = 'other';
|
||||
|
||||
/**
|
||||
* Selecting this goal means the user has no specific intent yet, so it is
|
||||
* mutually exclusive with every other goal.
|
||||
*/
|
||||
public function isExclusive(): bool
|
||||
{
|
||||
return $this === self::JustExploring;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@
|
|||
use App\Actions\Billing\StartSubscriptionCheckout;
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\User\Goal;
|
||||
use App\Enums\User\Persona;
|
||||
use App\Http\Requests\App\Onboarding\StoreOnboardingGoalsRequest;
|
||||
use App\Http\Requests\App\Onboarding\StoreOnboardingRequest;
|
||||
use App\Http\Resources\App\SocialAccountResource;
|
||||
use App\Models\Account;
|
||||
|
|
@ -59,6 +61,53 @@ public function store(StoreOnboardingRequest $request, PostHogService $postHog):
|
|||
'persona' => $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<int, string> $goals
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Onboarding;
|
||||
|
||||
use App\Enums\User\Goal;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreOnboardingGoalsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('goals')->nullable()->after('persona');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('goals');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
147
resources/js/pages/onboarding/Goals.vue
Normal file
147
resources/js/pages/onboarding/Goals.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCalendar,
|
||||
IconChartBar,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconCoin,
|
||||
IconCompass,
|
||||
IconDots,
|
||||
IconPalette,
|
||||
IconRobot,
|
||||
IconSparkles,
|
||||
IconTrendingUp,
|
||||
IconUsers,
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import type { FunctionalComponent } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { store } from '@/routes/app/onboarding/goals';
|
||||
|
||||
const props = defineProps<{
|
||||
goals: string[];
|
||||
selected?: string[] | null;
|
||||
}>();
|
||||
|
||||
const EXCLUSIVE_GOAL = 'just_exploring';
|
||||
|
||||
const form = useForm<{ goals: string[] }>({ goals: props.selected ?? [] });
|
||||
|
||||
const goalMeta: Record<string, { icon: FunctionalComponent; color: string }> = {
|
||||
save_time: { icon: IconClock, color: 'text-amber-600' },
|
||||
ai_content: { icon: IconSparkles, color: 'text-violet-700' },
|
||||
plan_calendar: { icon: IconCalendar, color: 'text-blue-700' },
|
||||
stay_on_brand: { icon: IconPalette, color: 'text-orange-600' },
|
||||
grow_audience: { icon: IconTrendingUp, color: 'text-rose-600' },
|
||||
drive_sales: { icon: IconCoin, color: 'text-emerald-600' },
|
||||
manage_clients: { icon: IconUsersGroup, color: 'text-cyan-600' },
|
||||
team_collaboration: { icon: IconUsers, color: 'text-fuchsia-600' },
|
||||
automate_api: { icon: IconRobot, color: 'text-teal-600' },
|
||||
track_performance: { icon: IconChartBar, color: 'text-indigo-600' },
|
||||
just_exploring: { icon: IconCompass, color: 'text-sky-600' },
|
||||
other: { icon: IconDots, color: 'text-foreground' },
|
||||
};
|
||||
|
||||
const goalIcon = (value: string): FunctionalComponent => goalMeta[value]?.icon ?? IconDots;
|
||||
|
||||
const goalColor = (value: string): string => goalMeta[value]?.color ?? 'text-foreground';
|
||||
|
||||
const goalLabel = (value: string): string => trans(`onboarding.goals.${value}`);
|
||||
|
||||
const isSelected = (value: string): boolean => form.goals.includes(value);
|
||||
|
||||
const toggle = (value: string): void => {
|
||||
if (value === EXCLUSIVE_GOAL) {
|
||||
form.goals = isSelected(value) ? [] : [value];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const withoutExclusive = form.goals.filter((goal) => goal !== EXCLUSIVE_GOAL);
|
||||
|
||||
form.goals = isSelected(value)
|
||||
? withoutExclusive.filter((goal) => goal !== value)
|
||||
: [...withoutExclusive, value];
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (form.goals.length === 0 || form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.post(store.url());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('onboarding.goals_title')" />
|
||||
|
||||
<section class="relative min-h-screen overflow-hidden bg-background">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||
style="background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px); background-size: 28px 28px;"
|
||||
/>
|
||||
<div class="pointer-events-none absolute -top-20 right-0 size-[560px] rounded-full bg-violet-200/50 blur-3xl" />
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col justify-center px-6 py-12">
|
||||
<div class="mx-auto mb-10 max-w-xl space-y-3 text-center">
|
||||
<h1
|
||||
class="text-balance text-3xl font-normal leading-[1.1] tracking-tight text-foreground sm:text-4xl"
|
||||
style="font-family: var(--font-display);"
|
||||
>
|
||||
{{ $t('onboarding.goals_title') }}
|
||||
</h1>
|
||||
<p class="text-balance text-base text-muted-foreground">
|
||||
{{ $t('onboarding.goals_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<button
|
||||
v-for="goal in goals"
|
||||
:key="goal"
|
||||
type="button"
|
||||
:class="[
|
||||
'relative flex cursor-pointer flex-col items-start gap-3 rounded-2xl border-2 border-foreground p-5 text-left shadow-2xs transition-shadow hover:shadow-md',
|
||||
isSelected(goal) ? 'bg-violet-100' : 'bg-card',
|
||||
]"
|
||||
@click="toggle(goal)"
|
||||
>
|
||||
<span class="inline-flex size-10 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
|
||||
<component
|
||||
:is="goalIcon(goal)"
|
||||
:class="[goalColor(goal), 'size-5']"
|
||||
stroke-width="2.25"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-base font-bold tracking-tight text-foreground">
|
||||
{{ goalLabel(goal) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isSelected(goal)"
|
||||
class="absolute right-4 top-4 inline-flex size-5 items-center justify-center rounded-full border-2 border-foreground bg-foreground"
|
||||
>
|
||||
<IconCheck class="size-3 text-background" stroke-width="3" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-10 flex w-full max-w-sm flex-col items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
class="w-full rounded-full"
|
||||
:disabled="form.goals.length === 0 || form.processing"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('onboarding.continue') }}
|
||||
<IconArrowRight class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue