trypost/app/Http/Controllers/App/OnboardingController.php
Paulo Castellano 287c27b792 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.
2026-04-16 11:00:16 -03:00

148 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
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;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class OnboardingController extends Controller
{
public function role(Request $request): Response|RedirectResponse
{
$redirect = $this->enforceStep($request->user(), Setup::Role);
if ($redirect) {
return $redirect;
}
return Inertia::render('onboarding/Role', [
'personas' => Persona::toSelectArray(),
]);
}
public function storeRole(Request $request): RedirectResponse
{
$validated = $request->validate([
'persona' => ['required', Rule::enum(Persona::class)],
]);
$request->user()->update([
'persona' => data_get($validated, 'persona'),
'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');
}
public function account(Request $request): Response|RedirectResponse
{
$redirect = $this->enforceStep($request->user(), Setup::Connections);
if ($redirect) {
return $redirect;
}
$user = $request->user();
$workspace = $user->currentWorkspace;
$platforms = collect();
if ($workspace) {
$connectedAccounts = $workspace->socialAccounts;
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'connected' => $connectedAccounts->firstWhere('platform', $platform) !== null,
'account' => $connectedAccounts->firstWhere('platform', $platform),
])->values();
}
return Inertia::render('onboarding/Account', [
'platforms' => $platforms,
'hasWorkspace' => $workspace !== null,
]);
}
public function storeAccount(Request $request): RedirectResponse
{
$request->user()->update(['setup' => Setup::Completed]);
if (config('trypost.self_hosted')) {
session()->flash('flash.banner', __('auth.flash.welcome'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.calendar');
}
return redirect()->route('app.subscribe');
}
private function enforceStep(User $user, Setup $expectedStep): ?RedirectResponse
{
if ($user->setup === $expectedStep) {
return null;
}
if ($user->setup === Setup::Completed) {
return redirect()->route('app.calendar');
}
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'),
};
}
}