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:
parent
6bd1e46f9a
commit
287c27b792
10 changed files with 379 additions and 10 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
26
app/Http/Requests/App/Onboarding/StoreBrandRequest.php
Normal file
26
app/Http/Requests/App/Onboarding/StoreBrandRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
154
resources/js/pages/onboarding/Brand.vue
Normal file
154
resources/js/pages/onboarding/Brand.vue
Normal 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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue