feat: add brand configuration step to onboarding

After users pick their persona (role), they now land on a new Brand
step that collects the same fields available in Settings → Workspace
→ Brand: website, description, tone, voice notes, and content
language. When they continue, every AI-generated post for this
workspace already has sensible defaults — before the user's first
post is ever drafted.

Flow:
Role (persona) → Brand (new) → Connections → Subscription → Completed.
A 'Skip for now' button on the brand step advances to Connections
without touching the workspace (defaults stay at their seed values).

Backend:
- Setup enum gets a new Brand case slotted between Role and
  Connections with matching stepNumber updates.
- OnboardingController::brand() renders the form pre-filled from the
  current workspace. storeBrand() validates via a new
  StoreBrandRequest form request and writes the fields onto the
  workspace, then advances setup. skipBrand() just advances.
- storeRole() redirects to brand instead of account. enforceStep()
  knows how to redirect users whose setup is Brand.
- Three new routes: GET /onboarding/brand, POST /onboarding/brand,
  POST /onboarding/brand/skip.

Frontend:
- New Brand.vue page mirrors the Settings brand form but inside the
  onboarding AuthLayout. Tone + language sit side by side, both
  selects take full width. Translations added to en, pt-BR, and es.
- Wayfinder regenerated so the page can import storeBrand / skipBrand.

Tests:
- Renamed 'redirects to step2' → 'redirects to brand step' and
  assert new setup.
- Added six new tests covering brand step auth, redirects, render,
  successful store, validation of tone and content_language, and
  skip.
- Updated UserSetupTest for the new enum case + reshuffled step
  numbers.
This commit is contained in:
Paulo Castellano 2026-04-16 11:00:16 -03:00
parent 6bd1e46f9a
commit 287c27b792
10 changed files with 379 additions and 10 deletions

View file

@ -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,
};
}
}

View file

@ -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'),
};

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Onboarding;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'brand_website' => ['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'],
];
}
}

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -0,0 +1,154 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import { skipBrand, storeBrand } from '@/actions/App/Http/Controllers/App/OnboardingController';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import AuthLayout from '@/layouts/AuthLayout.vue';
interface Workspace {
name: string;
brand_website: string | null;
brand_description: string | null;
brand_tone: string;
brand_voice_notes: string | null;
content_language: string;
}
interface Props {
workspace: Workspace;
}
const props = defineProps<Props>();
const form = useForm({
brand_website: props.workspace.brand_website ?? '',
brand_description: props.workspace.brand_description ?? '',
brand_tone: props.workspace.brand_tone ?? 'professional',
brand_voice_notes: props.workspace.brand_voice_notes ?? '',
content_language: props.workspace.content_language ?? 'en',
});
const skipForm = useForm({});
const submit = () => {
form.post(storeBrand.url());
};
const skip = () => {
skipForm.post(skipBrand.url());
};
const isSkipping = ref(false);
</script>
<template>
<Head :title="$t('onboarding.brand.page_title')" />
<AuthLayout
:title="$t('onboarding.brand.title')"
:description="$t('onboarding.brand.description')"
>
<form class="flex flex-col gap-5" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
<Input
id="brand_website"
v-model="form.brand_website"
type="url"
:placeholder="trans('settings.brand.website_placeholder')"
/>
<InputError :message="form.errors.brand_website" />
</div>
<div class="grid gap-2">
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
<Textarea
id="brand_description"
v-model="form.brand_description"
:placeholder="trans('settings.brand.brand_description_placeholder')"
rows="3"
/>
<InputError :message="form.errors.brand_description" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
<Select v-model="form.brand_tone">
<SelectTrigger id="brand_tone" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.brand_tone" />
</div>
<div class="grid gap-2">
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
<Select v-model="form.content_language">
<SelectTrigger id="content_language" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
<SelectItem value="es">Español</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.content_language" />
</div>
</div>
<p class="-mt-2 text-xs text-muted-foreground">
{{ $t('settings.brand.content_language_description') }}
</p>
<div class="grid gap-2">
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
<Textarea
id="brand_voice_notes"
v-model="form.brand_voice_notes"
:placeholder="trans('settings.brand.voice_notes_placeholder')"
rows="3"
/>
<InputError :message="form.errors.brand_voice_notes" />
</div>
<div class="mt-2 flex flex-col gap-2">
<Button type="submit" class="w-full" :disabled="form.processing">
{{ $t('onboarding.brand.submit') }}
</Button>
<Button
type="button"
variant="ghost"
class="w-full"
:disabled="skipForm.processing || form.processing"
@click="skip"
>
{{ $t('onboarding.brand.skip') }}
</Button>
</div>
</form>
</AuthLayout>
</template>

View file

@ -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');
});

View file

@ -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'));

View file

@ -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);
});