Skip to content

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 — add display_username field
  • apps/communities/models/membership.py — add is_onboarded field
  • apps/users/migrations/00XX_add_display_username.py — auto-generated
  • apps/communities/migrations/00XX_add_is_onboarded.py — auto-generated
  • apps/headless/schemas.py — update SignupProfileUpdateRequestSerializer, CurrentMemberSerializer, ProfileSerializer
  • apps/headless/views.py — update CommunityMemberView, SignupProfileView

Frontend (new/modified)

  • frontend/src/routes/onboarding.tsx — NEW: onboarding page with 3-step wizard
  • frontend/src/components/onboarding/OnboardingWizard.tsx — NEW: wizard container
  • frontend/src/components/onboarding/StepPersonal.tsx — NEW: step 1
  • frontend/src/components/onboarding/StepAddress.tsx — NEW: step 2
  • frontend/src/components/onboarding/StepPreferences.tsx — NEW: step 3
  • frontend/src/routes/__root.tsx — add onboarding gate in beforeLoad
  • frontend/src/api/generated.ts — regenerated via make 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

python
# 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
bash
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

python
# 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
bash
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

python
# 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
python
# ProfileSerializer (line 242), add after timezone:
display_username = serializers.CharField(allow_blank=True)
is_onboarded = serializers.BooleanField()
  • [ ] Step 3: Extend SignupProfileUpdateRequestSerializer
python
# 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
bash
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

python
# 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
python
# 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
python
# 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
bash
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
bash
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.tsx

  • Create: frontend/src/components/onboarding/StepAddress.tsx

  • Create: frontend/src/components/onboarding/StepPreferences.tsx

  • Create: frontend/src/components/onboarding/OnboardingWizard.tsx

  • [ ] Step 1: Create StepPersonal component

tsx
// 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
tsx
// 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
tsx
// 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
tsx
// 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
bash
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

tsx
// 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
bash
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

typescript
// 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
typescript
// 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
tsx
// 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
bash
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

bash
git add -A
git commit -m "fix: onboarding implementation fixes"

Strum — Documentação.