diff --git a/app/Enums/User/Setup.php b/app/Enums/User/Setup.php index f8bb1e42..c0b1451b 100644 --- a/app/Enums/User/Setup.php +++ b/app/Enums/User/Setup.php @@ -8,6 +8,7 @@ enum Setup: string { case Registering = 'registering'; case Role = 'role'; + case Brand = 'brand'; case Connections = 'connections'; case Subscription = 'subscription'; case Completed = 'completed'; @@ -17,6 +18,7 @@ public function label(): string return match ($this) { self::Registering => 'Registering', self::Role => 'Select Role', + self::Brand => 'Configure Brand', self::Connections => 'Connect Accounts', self::Subscription => 'Start Subscription', self::Completed => 'Completed', @@ -28,9 +30,10 @@ public function stepNumber(): int return match ($this) { self::Registering => 0, self::Role => 1, - self::Connections => 2, - self::Subscription => 3, - self::Completed => 4, + self::Brand => 2, + self::Connections => 3, + self::Subscription => 4, + self::Completed => 5, }; } } diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index 0653b2a9..18d6457e 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -7,6 +7,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\User\Persona; use App\Enums\User\Setup; +use App\Http\Requests\App\Onboarding\StoreBrandRequest; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -36,9 +37,50 @@ public function storeRole(Request $request): RedirectResponse $request->user()->update([ 'persona' => data_get($validated, 'persona'), - 'setup' => Setup::Connections, + 'setup' => Setup::Brand, ]); + return redirect()->route('app.onboarding.brand'); + } + + public function brand(Request $request): Response|RedirectResponse + { + $redirect = $this->enforceStep($request->user(), Setup::Brand); + if ($redirect) { + return $redirect; + } + + $workspace = $request->user()->currentWorkspace; + + return Inertia::render('onboarding/Brand', [ + 'workspace' => [ + 'name' => $workspace?->name ?? '', + 'brand_website' => $workspace?->brand_website ?? '', + 'brand_description' => $workspace?->brand_description ?? '', + 'brand_tone' => $workspace?->brand_tone ?? 'professional', + 'brand_voice_notes' => $workspace?->brand_voice_notes ?? '', + 'content_language' => $workspace?->content_language ?? 'en', + ], + ]); + } + + public function storeBrand(StoreBrandRequest $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if ($workspace) { + $workspace->update($request->validated()); + } + + $request->user()->update(['setup' => Setup::Connections]); + + return redirect()->route('app.onboarding.account'); + } + + public function skipBrand(Request $request): RedirectResponse + { + $request->user()->update(['setup' => Setup::Connections]); + return redirect()->route('app.onboarding.account'); } @@ -98,6 +140,7 @@ private function enforceStep(User $user, Setup $expectedStep): ?RedirectResponse return match ($user->setup) { Setup::Role => redirect()->route('app.onboarding.role'), + Setup::Brand => redirect()->route('app.onboarding.brand'), Setup::Connections => redirect()->route('app.onboarding.account'), default => redirect()->route('app.onboarding.role'), }; diff --git a/app/Http/Requests/App/Onboarding/StoreBrandRequest.php b/app/Http/Requests/App/Onboarding/StoreBrandRequest.php new file mode 100644 index 00000000..43c9113c --- /dev/null +++ b/app/Http/Requests/App/Onboarding/StoreBrandRequest.php @@ -0,0 +1,26 @@ + ['nullable', 'url', 'max:255'], + 'brand_description' => ['nullable', 'string', 'max:2000'], + 'brand_tone' => ['required', 'string', 'in:professional,casual,friendly,bold,inspirational,humorous,educational'], + 'brand_voice_notes' => ['nullable', 'string', 'max:2000'], + 'content_language' => ['required', 'string', 'in:en,pt-BR,es'], + ]; + } +} diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php index aa095997..33898d4a 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -37,6 +37,14 @@ ], ], + 'brand' => [ + 'page_title' => 'Tell us about your brand', + 'title' => 'Tell us about your brand', + 'description' => 'These defaults power every AI-generated post in this workspace. You can change them later in Settings.', + 'submit' => 'Continue', + 'skip' => 'Skip for now', + ], + 'connect' => [ 'page_title' => 'Connect your accounts', 'title' => 'Connect your accounts', diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php index a3795f59..cfb601e1 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -37,6 +37,14 @@ ], ], + 'brand' => [ + 'page_title' => 'Cuéntanos sobre tu marca', + 'title' => 'Cuéntanos sobre tu marca', + 'description' => 'Estos valores predeterminados se usarán en todos los posts generados por IA en este workspace. Puedes cambiarlos después en Configuración.', + 'submit' => 'Continuar', + 'skip' => 'Omitir por ahora', + ], + 'connect' => [ 'page_title' => 'Conecta tus cuentas', 'title' => 'Conecta tus cuentas', diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php index 388bbd64..07aab435 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -37,6 +37,14 @@ ], ], + 'brand' => [ + 'page_title' => 'Conte sobre sua marca', + 'title' => 'Conte sobre sua marca', + 'description' => 'Esses valores padrão serão usados em todos os posts gerados por AI neste workspace. Você pode alterar depois em Configurações.', + 'submit' => 'Continuar', + 'skip' => 'Pular por enquanto', + ], + 'connect' => [ 'page_title' => 'Conecte suas contas', 'title' => 'Conecte suas contas', diff --git a/resources/js/pages/onboarding/Brand.vue b/resources/js/pages/onboarding/Brand.vue new file mode 100644 index 00000000..4270bf6f --- /dev/null +++ b/resources/js/pages/onboarding/Brand.vue @@ -0,0 +1,154 @@ + + + + + + + + + {{ $t('settings.brand.website') }} + + + + + + {{ $t('settings.brand.brand_description') }} + + + + + + + {{ $t('settings.brand.tone') }} + + + + + + {{ $t('settings.brand.tone_professional') }} + {{ $t('settings.brand.tone_casual') }} + {{ $t('settings.brand.tone_friendly') }} + {{ $t('settings.brand.tone_bold') }} + {{ $t('settings.brand.tone_inspirational') }} + {{ $t('settings.brand.tone_humorous') }} + {{ $t('settings.brand.tone_educational') }} + + + + + + + {{ $t('settings.brand.content_language') }} + + + + + + English + Português (Brasil) + Español + + + + + + + + {{ $t('settings.brand.content_language_description') }} + + + + {{ $t('settings.brand.voice_notes') }} + + + + + + + {{ $t('onboarding.brand.submit') }} + + + {{ $t('onboarding.brand.skip') }} + + + + + diff --git a/routes/app.php b/routes/app.php index 3b91e136..1c2c20d4 100644 --- a/routes/app.php +++ b/routes/app.php @@ -55,6 +55,9 @@ Route::get('/', fn () => redirect()->route('app.onboarding.role')); Route::get('role', [OnboardingController::class, 'role'])->name('app.onboarding.role'); Route::post('role', [OnboardingController::class, 'storeRole'])->name('app.onboarding.role.store'); + Route::get('brand', [OnboardingController::class, 'brand'])->name('app.onboarding.brand'); + Route::post('brand', [OnboardingController::class, 'storeBrand'])->name('app.onboarding.brand.store'); + Route::post('brand/skip', [OnboardingController::class, 'skipBrand'])->name('app.onboarding.brand.skip'); Route::get('account', [OnboardingController::class, 'account'])->name('app.onboarding.account'); Route::post('account', [OnboardingController::class, 'storeAccount'])->name('app.onboarding.account.store'); }); diff --git a/tests/Feature/OnboardingControllerTest.php b/tests/Feature/OnboardingControllerTest.php index 61198980..24444a89 100644 --- a/tests/Feature/OnboardingControllerTest.php +++ b/tests/Feature/OnboardingControllerTest.php @@ -39,16 +39,16 @@ $response->assertRedirect(route('login')); }); -test('store step1 saves persona and redirects to step2', function () { +test('store step1 saves persona and redirects to brand step', function () { $response = $this->actingAs($this->user)->post(route('app.onboarding.role.store'), [ 'persona' => Persona::Founder->value, ]); - $response->assertRedirect(route('app.onboarding.account')); + $response->assertRedirect(route('app.onboarding.brand')); $this->user->refresh(); expect($this->user->persona)->toBe(Persona::Founder); - expect($this->user->setup)->toBe(Setup::Connections); + expect($this->user->setup)->toBe(Setup::Brand); }); test('store step1 validates persona is required', function () { @@ -67,6 +67,119 @@ $response->assertSessionHasErrors('persona'); }); +// Brand step tests +test('brand step requires authentication', function () { + $response = $this->get(route('app.onboarding.brand')); + + $response->assertRedirect(route('login')); +}); + +test('brand step redirects users not at the brand step', function () { + $this->user->update(['setup' => Setup::Role]); + + $response = $this->actingAs($this->user)->get(route('app.onboarding.brand')); + + $response->assertRedirect(route('app.onboarding.role')); +}); + +test('brand step shows the form with workspace defaults', function () { + $workspace = Workspace::factory()->create([ + 'user_id' => $this->user->id, + 'brand_tone' => 'casual', + 'content_language' => 'pt-BR', + ]); + $this->user->update([ + 'current_workspace_id' => $workspace->id, + 'setup' => Setup::Brand, + ]); + + $response = $this->actingAs($this->user)->get(route('app.onboarding.brand')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('onboarding/Brand', false) + ->where('workspace.brand_tone', 'casual') + ->where('workspace.content_language', 'pt-BR') + ); +}); + +test('store brand persists workspace settings and advances to connections', function () { + $workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update([ + 'current_workspace_id' => $workspace->id, + 'setup' => Setup::Brand, + ]); + + $response = $this->actingAs($this->user)->post(route('app.onboarding.brand.store'), [ + 'brand_website' => 'https://example.com', + 'brand_description' => 'We build social tools.', + 'brand_tone' => 'friendly', + 'brand_voice_notes' => 'Short and punchy.', + 'content_language' => 'pt-BR', + ]); + + $response->assertRedirect(route('app.onboarding.account')); + + $workspace->refresh(); + expect($workspace->brand_website)->toBe('https://example.com'); + expect($workspace->brand_tone)->toBe('friendly'); + expect($workspace->content_language)->toBe('pt-BR'); + + $this->user->refresh(); + expect($this->user->setup)->toBe(Setup::Connections); +}); + +test('store brand validates tone is in allowed list', function () { + $workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update([ + 'current_workspace_id' => $workspace->id, + 'setup' => Setup::Brand, + ]); + + $response = $this->actingAs($this->user)->post(route('app.onboarding.brand.store'), [ + 'brand_tone' => 'invalid-tone', + 'content_language' => 'en', + ]); + + $response->assertSessionHasErrors('brand_tone'); +}); + +test('store brand validates content_language is supported', function () { + $workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update([ + 'current_workspace_id' => $workspace->id, + 'setup' => Setup::Brand, + ]); + + $response = $this->actingAs($this->user)->post(route('app.onboarding.brand.store'), [ + 'brand_tone' => 'professional', + 'content_language' => 'fr', + ]); + + $response->assertSessionHasErrors('content_language'); +}); + +test('skip brand advances setup without saving workspace changes', function () { + $workspace = Workspace::factory()->create([ + 'user_id' => $this->user->id, + 'brand_tone' => 'casual', + ]); + $this->user->update([ + 'current_workspace_id' => $workspace->id, + 'setup' => Setup::Brand, + ]); + + $response = $this->actingAs($this->user)->post(route('app.onboarding.brand.skip')); + + $response->assertRedirect(route('app.onboarding.account')); + + $workspace->refresh(); + expect($workspace->brand_tone)->toBe('casual'); + + $this->user->refresh(); + expect($this->user->setup)->toBe(Setup::Connections); +}); + // Step 2 tests test('step2 requires authentication', function () { $response = $this->get(route('app.onboarding.account')); diff --git a/tests/Unit/Enums/UserSetupTest.php b/tests/Unit/Enums/UserSetupTest.php index 9328581e..8a9ab34e 100644 --- a/tests/Unit/Enums/UserSetupTest.php +++ b/tests/Unit/Enums/UserSetupTest.php @@ -7,6 +7,7 @@ test('user setup has correct values', function () { expect(Setup::Registering->value)->toBe('registering'); expect(Setup::Role->value)->toBe('role'); + expect(Setup::Brand->value)->toBe('brand'); expect(Setup::Connections->value)->toBe('connections'); expect(Setup::Subscription->value)->toBe('subscription'); expect(Setup::Completed->value)->toBe('completed'); @@ -15,6 +16,7 @@ test('user setup has labels', function () { expect(Setup::Registering->label())->toBe('Registering'); expect(Setup::Role->label())->toBe('Select Role'); + expect(Setup::Brand->label())->toBe('Configure Brand'); expect(Setup::Connections->label())->toBe('Connect Accounts'); expect(Setup::Subscription->label())->toBe('Start Subscription'); expect(Setup::Completed->label())->toBe('Completed'); @@ -23,7 +25,8 @@ test('user setup has step numbers', function () { expect(Setup::Registering->stepNumber())->toBe(0); expect(Setup::Role->stepNumber())->toBe(1); - expect(Setup::Connections->stepNumber())->toBe(2); - expect(Setup::Subscription->stepNumber())->toBe(3); - expect(Setup::Completed->stepNumber())->toBe(4); + expect(Setup::Brand->stepNumber())->toBe(2); + expect(Setup::Connections->stepNumber())->toBe(3); + expect(Setup::Subscription->stepNumber())->toBe(4); + expect(Setup::Completed->stepNumber())->toBe(5); });
+ {{ $t('settings.brand.content_language_description') }} +