Onboarding Multi-Step Obrigatório — Implementation Plan
NOTE
This document may not reflect the current implementation. See the final report for up-to-date state: Final Report
For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Adicionar uma tela de onboarding multi-step obrigatória que coleta dados pessoais, endereço e preferências do usuário antes de permitir acesso à aplicação.
Architecture: Backend: novos campos display_username (CustomUser) e is_onboarded (Membership), serializers atualizados, view de signup estendida. Frontend: rota /onboarding com layout isolado (sem sidebar), gate no __root.tsx beforeLoad que redireciona usuários não onboardados.
Tech Stack: Django 6.0, DRF serializers, TanStack Router, TanStack Query, shadcn/ui, Tailwind CSS v4, Lucide icons.
File Structure
Backend (new/modified)
apps/users/models.py— adddisplay_usernamefieldapps/communities/models/membership.py— addis_onboardedfieldapps/users/migrations/00XX_add_display_username.py— auto-generatedapps/communities/migrations/00XX_add_is_onboarded.py— auto-generatedapps/headless/schemas.py— updateSignupProfileUpdateRequestSerializer,CurrentMemberSerializer,ProfileSerializerapps/headless/views.py— updateCommunityMemberView,SignupProfileView
Frontend (new/modified)
frontend/src/routes/onboarding.tsx— NEW: onboarding page with 3-step wizardfrontend/src/components/onboarding/OnboardingWizard.tsx— NEW: wizard containerfrontend/src/components/onboarding/StepPersonal.tsx— NEW: step 1frontend/src/components/onboarding/StepAddress.tsx— NEW: step 2frontend/src/components/onboarding/StepPreferences.tsx— NEW: step 3frontend/src/routes/__root.tsx— add onboarding gate in beforeLoadfrontend/src/api/generated.ts— regenerated viamake generate-api
Task 1: Add display_username field to CustomUser
Covers: Backend model changes
Files:
Modify:
apps/users/models.py:47-63[ ] Step 1: Add field to model
# In CustomUser class, after timezone field (line 63):
display_username = models.CharField(max_length=30, unique=True, blank=True, default="")- [ ] Step 2: Create migration
Run: make manage ARGS='makemigrations users --name add_display_username' Expected: Migration file created
- [ ] Step 3: Commit
git add apps/users/models.py apps/users/migrations/
git commit -m "feat: add display_username field to CustomUser"Task 2: Add is_onboarded field to Membership
Covers: Backend model changes
Files:
Modify:
apps/communities/models/membership.py:11-34[ ] Step 1: Add field to model
# In Membership class, after muted_until field (line 34):
is_onboarded = models.BooleanField(default=False)- [ ] Step 2: Create migration
Run: make manage ARGS='makemigrations communities --name add_is_onboarded' Expected: Migration file created
- [ ] Step 3: Commit
git add apps/communities/models/membership.py apps/communities/migrations/
git commit -m "feat: add is_onboarded field to Membership"Task 3: Update Backend Serializers
Covers: API contract for onboarding data
Files:
Modify:
apps/headless/schemas.py:226-265,1133-1137[ ] Step 1: Add display_username and is_onboarded to CurrentMemberSerializer
# CurrentMemberSerializer (line 226), add after community_name:
is_onboarded = serializers.BooleanField()
display_username = serializers.CharField(allow_blank=True)- [ ] Step 2: Add display_username and is_onboarded to ProfileSerializer
# ProfileSerializer (line 242), add after timezone:
display_username = serializers.CharField(allow_blank=True)
is_onboarded = serializers.BooleanField()- [ ] Step 3: Extend SignupProfileUpdateRequestSerializer
# SignupProfileUpdateRequestSerializer (line 1133), add fields:
class SignupProfileUpdateRequestSerializer(serializers.Serializer):
first_name = serializers.CharField(required=False, allow_blank=True)
last_name = serializers.CharField(required=False, allow_blank=True)
display_username = serializers.CharField(required=False, allow_blank=True)
headline = serializers.CharField(required=False, allow_blank=True)
address = serializers.CharField(required=False, allow_blank=True)
city = serializers.CharField(required=False, allow_blank=True)
state = serializers.CharField(required=False, allow_blank=True)
country = serializers.ChoiceField(
choices=["BR", "US", "PT", "ES", "AR"], required=False
)
theme_preference = serializers.ChoiceField(choices=["light", "dark"], required=False)
language = serializers.ChoiceField(choices=["pt-BR", "en", "es"], required=False)
currency = serializers.ChoiceField(choices=["BRL", "USD", "EUR"], required=False)
timezone = serializers.CharField(required=False)
community_member_profile_fields_attributes = ProfileFieldValueRequestSerializer(many=True, required=False)- [ ] Step 4: Commit
git add apps/headless/schemas.py
git commit -m "feat: extend serializers for onboarding fields"Task 4: Update Backend Views
Covers: API endpoints return is_onboarded, signup sets it
Files:
Modify:
apps/headless/views.py:153-178,3004-3064[ ] Step 1: Add is_onboarded and display_username to CommunityMemberView
# In CommunityMemberView.get (line 162), add to the Response dict:
"is_onboarded": membership.is_onboarded,
"display_username": user.display_username,- [ ] Step 2: Extend SignupProfileView to handle new fields
# In SignupProfileView.put (after line 3023), add:
display_username = request.data.get("display_username", "").strip()
if display_username and display_username != user.display_username:
if CustomUser.objects.filter(display_username=display_username).exclude(id=user.id).exists():
return Response(
{"message": "Este nome de usuário já está em uso."},
status=status.HTTP_400_BAD_REQUEST,
)
user.display_username = display_username
# Add preference fields after display_username handling:
for field in ["address", "city", "state"]:
val = request.data.get(field)
if val is not None:
setattr(user, field, val.strip() if isinstance(val, str) else val)
country = request.data.get("country")
if country:
user.country = country
theme = request.data.get("theme_preference")
if theme:
user.theme_preference = theme
lang = request.data.get("language")
if lang:
user.language = lang
curr = request.data.get("currency")
if curr:
user.currency = curr
tz = request.data.get("timezone")
if tz:
user.timezone = tz
user.save(update_fields=[
"first_name", "last_name", "display_username",
"address", "city", "state", "country",
"theme_preference", "language", "currency", "timezone",
])- [ ] Step 3: Set is_onboarded=True on completion
# After the user.save() call, before the profile fields handling:
membership.is_onboarded = True
membership.save(update_fields=["is_onboarded", "updated_at"])Note: Remove the duplicate user.save() that already exists at line 3023 — merge into the single save above.
- [ ] Step 4: Commit
git add apps/headless/views.py
git commit -m "feat: handle onboarding fields in SignupProfileView"Task 5: Run Migrations and Regenerate API Client
Covers: Apply DB changes and sync frontend types
- [ ] Step 1: Run migrations
Run: make migrate Expected: Both migrations applied successfully
- [ ] Step 2: Start Django dev server
Run: make django (background) Expected: Server running on port 8000
- [ ] Step 3: Regenerate API client
Run: make generate-api Expected: frontend/src/api/generated.ts updated with new fields
- [ ] Step 4: Commit generated files
git add frontend/src/api/generated.ts frontend/openapi.json
git commit -m "chore: regenerate API client for onboarding fields"Task 6: Create Onboarding Components
Covers: Frontend onboarding UI (3 steps)
Files:
Create:
frontend/src/components/onboarding/StepPersonal.tsxCreate:
frontend/src/components/onboarding/StepAddress.tsxCreate:
frontend/src/components/onboarding/StepPreferences.tsxCreate:
frontend/src/components/onboarding/OnboardingWizard.tsx[ ] Step 1: Create StepPersonal component
// frontend/src/components/onboarding/StepPersonal.tsx
import { User } from "lucide-react";
interface StepPersonalProps {
firstName: string;
lastName: string;
displayUsername: string;
onFirstNameChange: (val: string) => void;
onLastNameChange: (val: string) => void;
onDisplayUsernameChange: (val: string) => void;
}
export function StepPersonal({
firstName,
lastName,
displayUsername,
onFirstNameChange,
onLastNameChange,
onDisplayUsernameChange,
}: StepPersonalProps) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3 text-center justify-center">
<div className="rounded-full bg-primary/10 p-3">
<User className="h-6 w-6 text-primary" aria-hidden="true" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium mb-1.5">
Nome <span className="text-destructive">*</span>
</label>
<input
id="firstName"
type="text"
value={firstName}
onChange={(e) => onFirstNameChange(e.target.value)}
required
autoComplete="given-name"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-1.5">
Sobrenome <span className="text-destructive">*</span>
</label>
<input
id="lastName"
type="text"
value={lastName}
onChange={(e) => onLastNameChange(e.target.value)}
required
autoComplete="family-name"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
/>
</div>
</div>
<div>
<label htmlFor="displayUsername" className="block text-sm font-medium mb-1.5">
Nome de usuário <span className="text-destructive">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">@</span>
<input
id="displayUsername"
type="text"
value={displayUsername}
onChange={(e) => onDisplayUsernameChange(e.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, ""))}
placeholder="seu_usuario"
required
autoComplete="username"
maxLength={30}
className="w-full rounded-lg border border-border bg-background pl-7 pr-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Letras minúsculas, números, pontos, hífens e underscores.</p>
</div>
</div>
);
}- [ ] Step 2: Create StepAddress component
// frontend/src/components/onboarding/StepAddress.tsx
import { MapPin } from "lucide-react";
const COUNTRY_OPTIONS = [
{ value: "BR", label: "Brasil" },
{ value: "US", label: "United States" },
{ value: "PT", label: "Portugal" },
{ value: "ES", label: "España" },
{ value: "AR", label: "Argentina" },
];
interface StepAddressProps {
address: string;
city: string;
state: string;
country: string;
onAddressChange: (val: string) => void;
onCityChange: (val: string) => void;
onStateChange: (val: string) => void;
onCountryChange: (val: string) => void;
}
export function StepAddress({
address,
city,
state,
country,
onAddressChange,
onCityChange,
onStateChange,
onCountryChange,
}: StepAddressProps) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3 text-center justify-center">
<div className="rounded-full bg-primary/10 p-3">
<MapPin className="h-6 w-6 text-primary" aria-hidden="true" />
</div>
</div>
<div>
<label htmlFor="address" className="block text-sm font-medium mb-1.5">Endereço</label>
<input
id="address"
type="text"
value={address}
onChange={(e) => onAddressChange(e.target.value)}
placeholder="Rua, número, complemento"
autoComplete="street-address"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium mb-1.5">Cidade</label>
<input
id="city"
type="text"
value={city}
onChange={(e) => onCityChange(e.target.value)}
autoComplete="address-level2"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium mb-1.5">Estado</label>
<input
id="state"
type="text"
value={state}
onChange={(e) => onStateChange(e.target.value)}
autoComplete="address-level1"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
/>
</div>
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium mb-1.5">País</label>
<select
id="country"
value={country}
onChange={(e) => onCountryChange(e.target.value)}
autoComplete="country"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
>
{COUNTRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
);
}- [ ] Step 3: Create StepPreferences component
// frontend/src/components/onboarding/StepPreferences.tsx
import { Settings, Sun, Moon, Globe, DollarSign, Clock } from "lucide-react";
const THEME_OPTIONS = [
{ value: "light", label: "Claro", icon: Sun },
{ value: "dark", label: "Escuro", icon: Moon },
];
const LANGUAGE_OPTIONS = [
{ value: "pt-BR", label: "Português (Brasil)" },
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
];
const CURRENCY_OPTIONS = [
{ value: "BRL", label: "R$ (BRL)" },
{ value: "USD", label: "$ (USD)" },
{ value: "EUR", label: "€ (EUR)" },
];
const TIMEZONE_OPTIONS = [
{ value: "America/Sao_Paulo", label: "São Paulo (GMT-3)" },
{ value: "America/New_York", label: "New York (GMT-5)" },
{ value: "Europe/Lisbon", label: "Lisboa (GMT+0)" },
{ value: "Europe/Madrid", label: "Madrid (GMT+1)" },
{ value: "America/Argentina/Buenos_Aires", label: "Buenos Aires (GMT-3)" },
];
interface StepPreferencesProps {
themePreference: string;
language: string;
currency: string;
timezone: string;
onThemeChange: (val: string) => void;
onLanguageChange: (val: string) => void;
onCurrencyChange: (val: string) => void;
onTimezoneChange: (val: string) => void;
}
export function StepPreferences({
themePreference,
language,
currency,
timezone,
onThemeChange,
onLanguageChange,
onCurrencyChange,
onTimezoneChange,
}: StepPreferencesProps) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3 text-center justify-center">
<div className="rounded-full bg-primary/10 p-3">
<Settings className="h-6 w-6 text-primary" aria-hidden="true" />
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Tema</label>
<div className="grid grid-cols-2 gap-3">
{THEME_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
onClick={() => onThemeChange(opt.value)}
className={`flex items-center justify-center gap-2 rounded-lg border px-4 py-3 text-sm font-medium transition-all ${
themePreference === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-muted-foreground hover:border-primary/30"
}`}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{opt.label}
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="language" className="flex items-center gap-1.5 text-sm font-medium mb-1.5">
<Globe className="h-3.5 w-3.5" aria-hidden="true" />
Idioma
</label>
<select
id="language"
value={language}
onChange={(e) => onLanguageChange(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
>
{LANGUAGE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label htmlFor="currency" className="flex items-center gap-1.5 text-sm font-medium mb-1.5">
<DollarSign className="h-3.5 w-3.5" aria-hidden="true" />
Moeda
</label>
<select
id="currency"
value={currency}
onChange={(e) => onCurrencyChange(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
>
{CURRENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div>
<label htmlFor="timezone" className="flex items-center gap-1.5 text-sm font-medium mb-1.5">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
Fuso horário
</label>
<select
id="timezone"
value={timezone}
onChange={(e) => onTimezoneChange(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
>
{TIMEZONE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
);
}- [ ] Step 4: Create OnboardingWizard container
// frontend/src/components/onboarding/OnboardingWizard.tsx
import { useState } from "react";
import { ArrowLeft, ArrowRight, Check, Loader2 } from "lucide-react";
import { StepPersonal } from "./StepPersonal";
import { StepAddress } from "./StepAddress";
import { StepPreferences } from "./StepPreferences";
const STEPS = [
{ id: 1, title: "Pessoal", description: "Seus dados básicos" },
{ id: 2, title: "Endereço", description: "Onde você está" },
{ id: 3, title: "Preferências", description: "Personalize sua experiência" },
];
interface OnboardingData {
firstName: string;
lastName: string;
displayUsername: string;
address: string;
city: string;
state: string;
country: string;
themePreference: string;
language: string;
currency: string;
timezone: string;
}
interface OnboardingWizardProps {
initialData: OnboardingData;
onSubmit: (data: OnboardingData) => void;
isSubmitting: boolean;
error: string;
}
export function OnboardingWizard({ initialData, onSubmit, isSubmitting, error }: OnboardingWizardProps) {
const [currentStep, setCurrentStep] = useState(1);
const [data, setData] = useState<OnboardingData>(initialData);
const update = <K extends keyof OnboardingData>(key: K, value: OnboardingData[K]) => {
setData((prev) => ({ ...prev, [key]: value }));
};
const canNext = () => {
if (currentStep === 1) return data.firstName.trim().length > 0 && data.lastName.trim().length > 0 && data.displayUsername.trim().length >= 3;
return true;
};
const handleNext = () => {
if (currentStep < 3) setCurrentStep(currentStep + 1);
else onSubmit(data);
};
return (
<div className="w-full max-w-lg space-y-8">
{/* Progress */}
<div className="flex items-center justify-between" role="progressbar" aria-valuenow={currentStep} aria-valuemin={1} aria-valuemax={3}>
{STEPS.map((step, i) => (
<div key={step.id} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-all ${
currentStep > step.id
? "bg-primary text-primary-foreground"
: currentStep === step.id
? "bg-primary/20 text-primary ring-2 ring-primary"
: "bg-muted text-muted-foreground"
}`}
>
{currentStep > step.id ? <Check className="h-5 w-5" aria-hidden="true" /> : step.id}
</div>
<span className={`mt-1.5 text-xs font-medium ${currentStep === step.id ? "text-foreground" : "text-muted-foreground"}`}>
{step.title}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={`mx-3 h-0.5 w-12 sm:w-20 ${currentStep > step.id ? "bg-primary" : "bg-muted"}`} />
)}
</div>
))}
</div>
{/* Error */}
{error && (
<div className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive" role="alert">
{error}
</div>
)}
{/* Step content */}
<div className="min-h-[300px]">
{currentStep === 1 && (
<StepPersonal
firstName={data.firstName}
lastName={data.lastName}
displayUsername={data.displayUsername}
onFirstNameChange={(v) => update("firstName", v)}
onLastNameChange={(v) => update("lastName", v)}
onDisplayUsernameChange={(v) => update("displayUsername", v)}
/>
)}
{currentStep === 2 && (
<StepAddress
address={data.address}
city={data.city}
state={data.state}
country={data.country}
onAddressChange={(v) => update("address", v)}
onCityChange={(v) => update("city", v)}
onStateChange={(v) => update("state", v)}
onCountryChange={(v) => update("country", v)}
/>
)}
{currentStep === 3 && (
<StepPreferences
themePreference={data.themePreference}
language={data.language}
currency={data.currency}
timezone={data.timezone}
onThemeChange={(v) => update("themePreference", v)}
onLanguageChange={(v) => update("language", v)}
onCurrencyChange={(v) => update("currency", v)}
onTimezoneChange={(v) => update("timezone", v)}
/>
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCurrentStep(currentStep - 1)}
disabled={currentStep === 1 || isSubmitting}
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm font-medium text-foreground transition-all hover:bg-accent disabled:opacity-50"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Voltar
</button>
<button
type="button"
onClick={handleNext}
disabled={!canNext() || isSubmitting}
className="flex items-center gap-2 rounded-lg bg-primary px-6 py-2.5 text-sm font-medium text-primary-foreground transition-all hover:bg-primary/90 hover:shadow-lg hover:shadow-primary/25 active:scale-[0.98] disabled:opacity-50"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : currentStep === 3 ? (
<>
Finalizar
<Check className="h-4 w-4" aria-hidden="true" />
</>
) : (
<>
Próximo
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</>
)}
</button>
</div>
</div>
);
}- [ ] Step 5: Commit
git add frontend/src/components/onboarding/
git commit -m "feat: add onboarding wizard components (3 steps)"Task 7: Create Onboarding Route
Covers: Frontend route for onboarding page
Files:
Create:
frontend/src/routes/onboarding.tsx[ ] Step 1: Create the onboarding route
// frontend/src/routes/onboarding.tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useApiHeadlessV1SignupProfileUpdate } from "@/api/generated";
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
export const Route = createFileRoute("/onboarding")({
beforeLoad: () => {
document.documentElement.classList.add("dark");
document.documentElement.setAttribute("data-theme", "dark");
},
component: OnboardingPage,
});
function OnboardingPage() {
const { user, isAuthenticated, refetchUser } = useAuth();
const navigate = useNavigate();
const signupProfile = useApiHeadlessV1SignupProfileUpdate();
const [error, setError] = useState("");
if (!isAuthenticated) {
navigate({ to: "/login", search: { redirect: "/onboarding" } });
return null;
}
const initialData = {
firstName: user?.name?.split(" ")[0] ?? "",
lastName: user?.name?.split(" ").slice(1).join(" ") ?? "",
displayUsername: "",
address: "",
city: "",
state: "",
country: "BR",
themePreference: "dark",
language: "pt-BR",
currency: "BRL",
timezone: "America/Sao_Paulo",
};
const handleSubmit = (data: typeof initialData) => {
setError("");
signupProfile.mutate(
{
data: {
first_name: data.firstName,
last_name: data.lastName,
display_username: data.displayUsername,
address: data.address,
city: data.city,
state: data.state,
country: data.country,
theme_preference: data.themePreference,
language: data.language,
currency: data.currency,
timezone: data.timezone,
},
},
{
onSuccess: async () => {
await refetchUser();
navigate({ to: "/" });
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Erro ao salvar perfil");
},
},
);
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 px-4">
<div className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8 shadow-2xl backdrop-blur-sm">
<div className="mb-8 text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-gradient-to-br from-fuchsia-400 to-cyan-400" aria-hidden="true" />
<h1 className="mt-4 text-2xl font-bold text-white">Bem-vindo!</h1>
<p className="mt-1 text-sm text-gray-400">
Vamos personalizar sua experiência. São apenas 3 passos rápidos.
</p>
</div>
<OnboardingWizard
initialData={initialData}
onSubmit={handleSubmit}
isSubmitting={signupProfile.isPending}
error={error}
/>
</div>
</div>
);
}- [ ] Step 2: Commit
git add frontend/src/routes/onboarding.tsx
git commit -m "feat: add /onboarding route with dark gradient layout"Task 8: Add Onboarding Gate in Root Route
Covers: Mandatory redirect to /onboarding for non-onboarded users
Files:
Modify:
frontend/src/routes/__root.tsx:27-38,127-134[ ] Step 1: Add ONBOARDING_EXEMPT routes
// After PUBLIC_ROUTES definition (line 34), add:
const ONBOARDING_EXEMPT = ["/onboarding", "/login", "/register", "/forgot-password", "/confirm-email", "/reset-password", "/invite", "/community-not-found"];- [ ] Step 2: Add onboarding check in beforeLoad
// In beforeLoad (after line 133, before the closing brace), add:
// Onboarding gate: redirect to /onboarding if not completed
if (!ONBOARDING_EXEMPT.includes(location.pathname)) {
try {
const memberRes = await fetch("/api/headless/v1/community_member/", {
headers: { "X-Community-Slug": window.location.hostname.split(".")[0] },
});
if (memberRes.ok) {
const memberData = await memberRes.json();
if (memberData && !memberData.is_onboarded) {
throw redirect({ to: "/onboarding" });
}
}
} catch (e) {
if (e && typeof e === "object" && "to" in e) throw e;
// Ignore fetch errors — let the route render
}
}Note: The beforeLoad runs server-side during SSR and client-side during navigation. The fetch call needs the community slug header. An alternative is to use the existing getApiHeadlessV1CommunityMemberRetrieveQueryOptions query, but that requires the queryClient context. The simpler approach is to use the existing getSession flow and check via the session data. However, since getSession doesn't return is_onboarded, we need to make a separate call.
A cleaner approach: modify getSession to also return is_onboarded from the backend session data. But that's a larger change. For now, the fetch approach works.
Actually, looking at the code more carefully, the beforeLoad doesn't have access to window during SSR. Let me reconsider. The better approach is to check this in the loader which runs with context, or to check in the RootComponent using client-side hooks.
Let me revise this to use a client-side approach in the RootComponent instead, which is simpler and more reliable:
- [ ] Step 2 (revised): Add onboarding redirect in RootComponent
// In RootComponent (after the existing useEffect for checkTenant, around line 231), add:
useEffect(() => {
const checkOnboarding = async () => {
const pathname = window.location.pathname;
const exempt = ["/onboarding", "/login", "/register", "/forgot-password", "/confirm-email", "/reset-password", "/invite", "/community-not-found"];
if (exempt.some((r) => pathname.startsWith(r))) return;
try {
const res = await fetch("/api/headless/v1/community_member/", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data && !data.is_onboarded) {
router.navigate({ to: "/onboarding" });
}
}
} catch {
// ignore
}
};
checkOnboarding();
}, [router]);- [ ] Step 3: Commit
git add frontend/src/routes/__root.tsx
git commit -m "feat: add onboarding gate redirect in root component"Task 9: Verify End-to-End
Covers: All spec sections — final verification
- [ ] Step 1: Run Python linter
Run: make ruff Expected: No errors
- [ ] Step 2: Run TypeScript type check
Run: make npm-type-check Expected: No errors
- [ ] Step 3: Run Django tests
Run: make test Expected: All tests pass
[ ] Step 4: Manual verification checklist
Register new user → redirected to /onboarding
Complete step 1 (name + username) → Next button enabled
Complete step 2 (address) → Next button works
Complete step 3 (preferences) → Finalizar button works
After completion → redirected to /
Login again → no redirect to /onboarding (is_onboarded=true)
Direct navigation to / on a non-onboarded user → redirected to /onboarding
/onboarding page has dark gradient background, no sidebar
[ ] Step 5: Final commit if any fixes needed
git add -A
git commit -m "fix: onboarding implementation fixes"