iachat/docs/superpowers/plans/2026-04-14-reserva-1001-fase-4-multitenant-admin.md

49 KiB
Raw Permalink Blame History

Reserva Rede 1001 — Fase 4: Multi-tenant SaaS + Admin

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

Goal: Transformar o reserva-1001 num produto SaaS multi-tenant. Cada cliente (hotel/motel/rede) tem seu próprio subdomínio (prime.reserva.app, motelxyz.reserva.app), sua própria identidade visual (nome, cores, logo, fontes), seus próprios dados (marcas, unidades, preços, fotos, reservas) e seu próprio painel admin. Zero hardcode de "Rede 1001" — a página vira genérica e configurável.

Architecture:

  • Subdomínio → tenant slug → carrega app_config e todas as queries scoped por tenant_id
  • Supabase Auth pro admin, com tenant_id no user_metadata
  • RLS policies filtram automaticamente por tenant
  • Dev: localhost:5180 usa VITE_DEFAULT_TENANT_SLUG (fallback)
  • Prod: Vercel com wildcard domain *.reserva.fazer.ai

Tech Stack: Supabase (Postgres + Auth + Storage + RLS) · React 19 + Vite · TypeScript · Tailwind v4 CSS variables · Google Fonts dinâmico · Vercel

Spec: docs/superpowers/specs/2026-04-13-reserva-1001-design.md Fases anteriores: Fase 1-3.5 completas.


Escopo desta fase (o que entra)

  1. Multi-tenancy: tabelas novas tenants + app_config, coluna tenant_id em tudo, backfill, RLS
  2. Identidade visual editável: nome da rede, título, subtítulo, tagline, footer, logo, favicon, cor primária, cor secundária, cor de fundo, fonte display, fonte corpo
  3. Auth: Supabase Auth (email + senha), seed do primeiro admin
  4. Frontend dinâmico: resolve subdomínio, carrega config, injeta CSS variables + Google Fonts, substitui strings hardcoded
  5. Admin CRUD: login, layout com abas, todas as entidades editáveis (Aparência · Marcas · Unidades · Categorias · Preços · Fotos · Extras · Reservas read-only)
  6. Integração com Chatwoot: mantém — cada unidade ainda aponta pra um captain_unit_id, só que agora também pertence a um tenant

Fora de escopo (fase futura)

  • Onboarding self-service (criar tenant pela UI)
  • Billing / planos
  • Convite de novos admins
  • Roles granulares (por enquanto: owner do tenant = admin total)
  • White-label domain customizado (cliente.com em vez de cliente.reserva.app)

Dados conhecidos

  • Supabase project: acdvblhzzaneddlxqyst (InAudit Hotel), schema: reserva_hotel
  • Tabelas existentes com dados: marcas, unidades, suites, suites_unidades, precos, contas_pagamento, reservas, fotos_categoria, extras, reserva_extras
  • Tenant atual (implícito): Grupo Nova / Rede 1001
  • Chatwoot dev rodando em :3000, reserva-1001 em :5180
  • Cloudflared tunnel ativo pra testes mobile

File Structure

Supabase (novo):

reserva-1001/supabase/migrations/
├── 20260414000001_tenants_and_app_config.sql         # novo
├── 20260414000002_add_tenant_id_backfill.sql          # novo
└── 20260414000003_rls_tenant_scoping.sql              # novo

Frontend reserva-1001 (novo):

src/
├── lib/
│   ├── tenant.ts                      # resolver subdomínio → slug
│   ├── appConfig.ts                   # fetch + cache do app_config
│   └── supabase.ts                    # modify — tenant-aware
├── hooks/
│   ├── useAppConfig.ts                # hook do config ativo
│   └── useAuth.ts                     # NOVO — Supabase Auth
├── contexts/
│   └── TenantProvider.tsx             # NOVO — provê tenant + config pros filhos
├── pages/
│   ├── admin/
│   │   ├── AdminLayout.tsx            # shell com nav
│   │   ├── LoginPage.tsx              # login Supabase Auth
│   │   ├── AparenciaTab.tsx           # identidade editável
│   │   ├── MarcasTab.tsx              # CRUD
│   │   ├── UnidadesTab.tsx            # CRUD
│   │   ├── CategoriasTab.tsx          # CRUD
│   │   ├── PrecosTab.tsx              # grid editável
│   │   ├── FotosTab.tsx               # upload + reorder
│   │   ├── ExtrasTab.tsx              # CRUD
│   │   └── ReservasTab.tsx            # read-only + filtros
│   └── ReservationPage.tsx            # era App.tsx — renomeado
├── components/
│   ├── admin/
│   │   ├── AuthGate.tsx               # wrapper que exige login
│   │   ├── ColorPicker.tsx            # novo
│   │   ├── FontSelector.tsx           # novo
│   │   ├── ImageUpload.tsx            # novo (drag-and-drop)
│   │   └── DataTable.tsx              # novo (tabela genérica)
│   └── ... (existentes)
├── router.tsx                          # NOVO — React Router com rotas pública + /admin/*
└── main.tsx                            # modify — renderiza <RouterProvider>

Novos pacotes:

react-router-dom  @tanstack/react-query  react-colorful  react-hook-form  zod

PARTE A — Schema multi-tenant (Supabase)

Task A1: Tabela tenants e app_config

Files:

  • Create: reserva-1001/supabase/migrations/20260414000001_tenants_and_app_config.sql

  • Step 1: Criar migration SQL

-- Multi-tenancy foundation: tenants + app_config

create table if not exists reserva_hotel.tenants (
  id          bigserial primary key,
  slug        text not null unique,
  nome        text not null,
  ativo       boolean not null default true,
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now()
);

create index if not exists idx_tenants_slug on reserva_hotel.tenants(slug);

create table if not exists reserva_hotel.app_config (
  id              bigserial primary key,
  tenant_id       bigint not null unique references reserva_hotel.tenants(id) on delete cascade,
  nome_rede       text not null,              -- "Rede 1001"
  titulo_hero     text not null,              -- "Reserva Rede 1001"
  subtitulo_hero  text,                       -- "Experiência exclusiva"
  tagline         text,                       -- "Escolha, confirme e receba seu PIX na hora."
  footer_text     text,                       -- "© 2026 Rede 1001 · Experiência Exclusiva"
  logo_url        text,                       -- URL (Supabase Storage)
  favicon_url     text,
  cor_primaria    text not null default '#C9A961',   -- champagne
  cor_secundaria  text not null default '#E8B4A0',   -- rose-gold
  cor_fundo       text not null default '#0B0D12',   -- obsidian
  cor_superficie  text not null default '#0F1A2E',   -- midnight
  cor_texto       text not null default '#F5F1E8',   -- ivory
  fonte_display   text not null default 'Fraunces',
  fonte_corpo     text not null default 'Inter',
  created_at      timestamptz not null default now(),
  updated_at      timestamptz not null default now()
);
  • Step 2: Aplicar via MCP apply_migration

Nome da migration: tenants_and_app_config. Validar:

select count(*) from reserva_hotel.tenants;  -- deve retornar 0
select count(*) from reserva_hotel.app_config; -- deve retornar 0
  • Step 3: Commit do arquivo de migration
cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
git add supabase/migrations/20260414000001_tenants_and_app_config.sql
git commit -m "feat: tabelas tenants e app_config (multi-tenant foundation)"

Task A2: Adicionar tenant_id em todas as tabelas + backfill

Files:

  • Create: reserva-1001/supabase/migrations/20260414000002_add_tenant_id_backfill.sql

  • Step 1: Criar migration

-- Adiciona tenant_id em todas as tabelas existentes + cria tenant default
-- + backfill de tudo pro tenant 1 ("Grupo Nova / Rede 1001")

-- 1. Cria o tenant default
insert into reserva_hotel.tenants (slug, nome, ativo)
values ('grupo-1001', 'Grupo Nova — Rede 1001', true)
on conflict (slug) do nothing;

-- 2. Cria o app_config default
insert into reserva_hotel.app_config
  (tenant_id, nome_rede, titulo_hero, subtitulo_hero, tagline, footer_text,
   cor_primaria, cor_secundaria, cor_fundo, cor_superficie, cor_texto,
   fonte_display, fonte_corpo)
select
  t.id,
  'Rede 1001',
  'Reserva Rede 1001',
  'Experiência exclusiva',
  'Escolha, confirme e receba seu PIX na hora.',
  '© 2026 Rede 1001 · Experiência Exclusiva',
  '#C9A961', '#E8B4A0', '#0B0D12', '#0F1A2E', '#F5F1E8',
  'Fraunces', 'Inter'
from reserva_hotel.tenants t
where t.slug = 'grupo-1001'
on conflict (tenant_id) do nothing;

-- 3. Adiciona tenant_id (nullable inicialmente pra backfill)
alter table reserva_hotel.marcas            add column if not exists tenant_id bigint references reserva_hotel.tenants(id);
alter table reserva_hotel.unidades          add column if not exists tenant_id bigint references reserva_hotel.tenants(id);
alter table reserva_hotel.suites            add column if not exists tenant_id bigint references reserva_hotel.tenants(id);
alter table reserva_hotel.precos            add column if not exists tenant_id bigint references reserva_hotel.tenants(id);
alter table reserva_hotel.contas_pagamento  add column if not exists tenant_id bigint references reserva_hotel.tenants(id);
alter table reserva_hotel.reservas          add column if not exists tenant_id bigint references reserva_hotel.tenants(id);
alter table reserva_hotel.fotos_categoria   add column if not exists tenant_id bigint references reserva_hotel.tenants(id);
alter table reserva_hotel.extras            add column if not exists tenant_id bigint references reserva_hotel.tenants(id);

-- 4. Backfill: atribui todas as rows existentes ao tenant grupo-1001
do $$
declare v_tenant_id bigint;
begin
  select id into v_tenant_id from reserva_hotel.tenants where slug = 'grupo-1001';

  update reserva_hotel.marcas           set tenant_id = v_tenant_id where tenant_id is null;
  update reserva_hotel.unidades         set tenant_id = v_tenant_id where tenant_id is null;
  update reserva_hotel.suites           set tenant_id = v_tenant_id where tenant_id is null;
  update reserva_hotel.precos           set tenant_id = v_tenant_id where tenant_id is null;
  update reserva_hotel.contas_pagamento set tenant_id = v_tenant_id where tenant_id is null;
  update reserva_hotel.reservas         set tenant_id = v_tenant_id where tenant_id is null;
  update reserva_hotel.fotos_categoria  set tenant_id = v_tenant_id where tenant_id is null;
  update reserva_hotel.extras           set tenant_id = v_tenant_id where tenant_id is null;
end $$;

-- 5. Torna tenant_id NOT NULL
alter table reserva_hotel.marcas            alter column tenant_id set not null;
alter table reserva_hotel.unidades          alter column tenant_id set not null;
alter table reserva_hotel.suites            alter column tenant_id set not null;
alter table reserva_hotel.precos            alter column tenant_id set not null;
alter table reserva_hotel.contas_pagamento  alter column tenant_id set not null;
alter table reserva_hotel.reservas          alter column tenant_id set not null;
alter table reserva_hotel.fotos_categoria   alter column tenant_id set not null;
alter table reserva_hotel.extras            alter column tenant_id set not null;

-- 6. Indexes
create index if not exists idx_marcas_tenant           on reserva_hotel.marcas(tenant_id);
create index if not exists idx_unidades_tenant         on reserva_hotel.unidades(tenant_id);
create index if not exists idx_precos_tenant           on reserva_hotel.precos(tenant_id);
create index if not exists idx_reservas_tenant         on reserva_hotel.reservas(tenant_id);
create index if not exists idx_fotos_categoria_tenant  on reserva_hotel.fotos_categoria(tenant_id);
create index if not exists idx_extras_tenant           on reserva_hotel.extras(tenant_id);
  • Step 2: Aplicar via MCP

Validar:

select (select count(*) from reserva_hotel.tenants) as tenants,
       (select count(*) from reserva_hotel.app_config) as configs,
       (select count(*) from reserva_hotel.marcas where tenant_id is null) as marcas_sem_tenant;

Expected: tenants=1, configs=1, marcas_sem_tenant=0

  • Step 3: Commit
git add supabase/migrations/20260414000002_add_tenant_id_backfill.sql
git commit -m "feat: adiciona tenant_id em todas tabelas + backfill grupo-1001"

Task A3: RLS tenant scoping

Files:

  • Create: reserva-1001/supabase/migrations/20260414000003_rls_tenant_scoping.sql

  • Step 1: Criar migration RLS

A ideia: RLS lê o tenant_id de um claim customizado no JWT. Pro anon (acesso público), a aplicação passa o tenant via query, não via JWT (porque anon não loga).

Estratégia simplificada:

  • Tabelas de catálogo (marcas, unidades, categorias, precos, fotos, extras) → RLS permite SELECT público quando tenant_id = current_setting('app.current_tenant_id', true)::bigint
  • app_config e tenants → SELECT público (são lidas pro tenant resolver)
  • Tabelas sensíveis (reservas, contas_pagamento) → apenas service_role

Para o anon setar o contexto, usa-se set_config('app.current_tenant_id', '1', false) via RPC. Ou mais simples: o frontend passa eq('tenant_id', X) em cada query. Vamos usar essa segunda abordagem porque é mais explícita e não depende de magic setting.

Migration apenas garante que as policies existentes continuam permitindo leitura pública das tabelas de catálogo. As queries do frontend passam a filtrar por tenant_id explicitamente.

-- Garante que as tabelas publicas tem RLS ON e policy de leitura.
alter table reserva_hotel.tenants enable row level security;
alter table reserva_hotel.app_config enable row level security;

drop policy if exists "public_read_tenants" on reserva_hotel.tenants;
create policy "public_read_tenants" on reserva_hotel.tenants
  for select using (ativo = true);

drop policy if exists "public_read_app_config" on reserva_hotel.app_config;
create policy "public_read_app_config" on reserva_hotel.app_config
  for select using (true);

-- As outras tabelas (marcas/unidades/etc) ja tem grants do anon feitos
-- numa migration anterior; nao precisa mexer.
  • Step 2: Aplicar via MCP + validar
curl -s -o /tmp/t.json -w "HTTP %{http_code}\n" \
  -H "apikey: <ANON_KEY>" \
  -H "Accept-Profile: reserva_hotel" \
  "https://acdvblhzzaneddlxqyst.supabase.co/rest/v1/tenants?select=id,slug,nome"
cat /tmp/t.json

Expected: [{"id":1,"slug":"grupo-1001","nome":"Grupo Nova — Rede 1001"}]

  • Step 3: Grant explícito pro anon ler tenants + app_config
grant select on reserva_hotel.tenants to anon, authenticated;
grant select on reserva_hotel.app_config to anon, authenticated;

Aplicar via MCP.

  • Step 4: Commit
git add supabase/migrations/20260414000003_rls_tenant_scoping.sql
git commit -m "feat: RLS policies + grants pra tenants/app_config publicas"

PARTE B — Supabase Auth + admin user seed

Task B1: Habilitar Supabase Auth + coluna tenant_id no user_metadata

Files: nenhum arquivo novo — config no Supabase dashboard.

  • Step 1: Via Supabase dashboard

No console do projeto acdvblhzzaneddlxqyst:

  1. Authentication → Providers → Email → enable (se ainda não estiver)
  2. Authentication → Settings → desmarcar "Enable email confirmations" (pra MVP — agiliza login no dev)
  • Step 2: Criar usuário admin de teste via Rails runner no Chatwoot (ou via supabase CLI)
cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
# Via API REST do Supabase (usa service_role key)
curl -X POST "https://acdvblhzzaneddlxqyst.supabase.co/auth/v1/admin/users" \
  -H "apikey: <SERVICE_ROLE_KEY>" \
  -H "Authorization: Bearer <SERVICE_ROLE_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@reserva.test",
    "password": "admin123",
    "email_confirm": true,
    "user_metadata": { "tenant_id": 1, "role": "tenant_admin" }
  }'

Nota: SERVICE_ROLE_KEY precisa ser obtido via MCP (mcp__claude_ai_supabase__get_publishable_keys não retorna service_role — preciso buscar via outra rota, ou pedir ao usuário).

Alternativa simpler pro MVP: criar o usuário via Supabase dashboard → Authentication → Add user manually, e setar tenant_id: 1 em "Raw User Meta Data".

  • Step 3: Documentar no README

Adicionar ao reserva-1001/README.md seção "Admin de teste" com email/senha.


Task B2: Tabela auxiliar tenant_members (quem pode admin do quê)

Files:

  • Create: reserva-1001/supabase/migrations/20260414000004_tenant_members.sql

  • Step 1: Migration

create table if not exists reserva_hotel.tenant_members (
  id          bigserial primary key,
  tenant_id   bigint not null references reserva_hotel.tenants(id) on delete cascade,
  user_id     uuid not null,                  -- auth.users.id
  role        text not null default 'admin',  -- 'admin' | 'viewer'
  created_at  timestamptz not null default now(),
  unique (tenant_id, user_id)
);

create index if not exists idx_tenant_members_user on reserva_hotel.tenant_members(user_id);
create index if not exists idx_tenant_members_tenant on reserva_hotel.tenant_members(tenant_id);

alter table reserva_hotel.tenant_members enable row level security;

-- Um user pode ler os tenants aos quais pertence
create policy "members_read_own" on reserva_hotel.tenant_members
  for select using (auth.uid() = user_id);

grant select on reserva_hotel.tenant_members to authenticated;

Aplicar + seed do primeiro admin:

insert into reserva_hotel.tenant_members (tenant_id, user_id, role)
select 1, id, 'admin' from auth.users where email = 'admin@reserva.test'
on conflict do nothing;
  • Step 2: Commit
git add supabase/migrations/20260414000004_tenant_members.sql
git commit -m "feat: tabela tenant_members pra escopar admins por tenant"

PARTE C — Frontend: tenant resolver + theming dinâmico

Task C1: src/lib/tenant.ts — resolver subdomínio

Files:

  • Create: reserva-1001/src/lib/tenant.ts

  • Modify: reserva-1001/.env.local.example — adicionar VITE_DEFAULT_TENANT_SLUG

  • Step 1: Criar parser

// Resolve o tenant slug a partir do hostname.
// Prod:   https://prime.reserva.fazer.ai  → slug = "prime"
// Dev:    http://localhost:5180           → fallback pro VITE_DEFAULT_TENANT_SLUG
// Custom: https://reserva.motelxyz.com    → host registrado = tenant slug (v2)

export function resolveTenantSlug(): string {
  if (typeof window === 'undefined') {
    return import.meta.env.VITE_DEFAULT_TENANT_SLUG || 'grupo-1001'
  }

  const host = window.location.hostname

  // dev / localhost
  if (host === 'localhost' || host.startsWith('127.') || host.endsWith('.local')) {
    return import.meta.env.VITE_DEFAULT_TENANT_SLUG || 'grupo-1001'
  }

  // cloudflared tunnel: <random>.trycloudflare.com — usa default tb
  if (host.endsWith('.trycloudflare.com')) {
    return import.meta.env.VITE_DEFAULT_TENANT_SLUG || 'grupo-1001'
  }

  // Padrão SaaS: <slug>.reserva.fazer.ai
  // Pega a primeira parte do host (antes do primeiro ponto)
  const parts = host.split('.')
  return parts[0] || 'grupo-1001'
}

Edit .env.local.example e .env.local:

VITE_DEFAULT_TENANT_SLUG=grupo-1001
  • Step 2: Commit
pnpm typecheck
git add src/lib/tenant.ts .env.local.example
git commit -m "feat: resolver de tenant slug por subdominio"

Task C2: src/lib/appConfig.ts + tipos do Supabase regenerados

Files:

  • Create: reserva-1001/src/lib/appConfig.ts

  • Modify: reserva-1001/src/types/database.ts (regenerar via supabase gen types)

  • Step 1: Regenerar types

cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
pnpm supabase:types

Isso pega os novos campos tenant_id, tabelas tenants, app_config, tenant_members.

  • Step 2: Criar appConfig.ts
import { supabase } from './supabase'
import type { Database } from '@/types/database'

export type Tenant = Database['reserva_hotel']['Tables']['tenants']['Row']
export type AppConfig = Database['reserva_hotel']['Tables']['app_config']['Row']

export interface LoadedTenantContext {
  tenant: Tenant
  config: AppConfig
}

export async function loadTenantContext(slug: string): Promise<LoadedTenantContext | null> {
  const { data: tenant, error: tenantError } = await supabase
    .from('tenants')
    .select('*')
    .eq('slug', slug)
    .eq('ativo', true)
    .maybeSingle()

  if (tenantError) throw new Error(tenantError.message)
  if (!tenant) return null

  const { data: config, error: configError } = await supabase
    .from('app_config')
    .select('*')
    .eq('tenant_id', tenant.id)
    .maybeSingle()

  if (configError) throw new Error(configError.message)
  if (!config) return null

  return { tenant, config }
}
  • Step 3: Typecheck + commit
pnpm typecheck
git add src/lib/appConfig.ts src/types/database.ts
git commit -m "feat: loadTenantContext carrega tenant + app_config por slug"

Task C3: TenantProvider + useAppConfig hook

Files:

  • Create: reserva-1001/src/contexts/TenantProvider.tsx

  • Create: reserva-1001/src/hooks/useAppConfig.ts

  • Step 1: Criar contexto

// src/contexts/TenantProvider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import type { LoadedTenantContext } from '@/lib/appConfig'
import { loadTenantContext } from '@/lib/appConfig'
import { resolveTenantSlug } from '@/lib/tenant'

interface TenantContextValue {
  loading: boolean
  error: string | null
  context: LoadedTenantContext | null
  refresh: () => Promise<void>
}

const TenantContext = createContext<TenantContextValue | null>(null)

export function TenantProvider({ children }: { children: ReactNode }) {
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [context, setContext] = useState<LoadedTenantContext | null>(null)

  const load = async () => {
    setLoading(true)
    setError(null)
    try {
      const slug = resolveTenantSlug()
      const ctx = await loadTenantContext(slug)
      if (!ctx) {
        setError(`Tenant "${slug}" não encontrado ou inativo.`)
      } else {
        setContext(ctx)
        applyTheme(ctx)
        applyMetadata(ctx)
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Erro ao carregar tenant')
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    void load()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  return (
    <TenantContext.Provider value={{ loading, error, context, refresh: load }}>
      {children}
    </TenantContext.Provider>
  )
}

export function useTenant() {
  const ctx = useContext(TenantContext)
  if (!ctx) throw new Error('useTenant must be used inside <TenantProvider>')
  return ctx
}

function applyTheme(ctx: LoadedTenantContext) {
  const { config } = ctx
  const root = document.documentElement
  root.style.setProperty('--color-champagne', config.cor_primaria)
  root.style.setProperty('--color-rose-gold', config.cor_secundaria)
  root.style.setProperty('--color-obsidian', config.cor_fundo)
  root.style.setProperty('--color-midnight', config.cor_superficie)
  root.style.setProperty('--color-ivory', config.cor_texto)

  // Fontes via Google Fonts dynamic <link>
  loadGoogleFont(config.fonte_display)
  loadGoogleFont(config.fonte_corpo)
  root.style.setProperty('--font-serif', `'${config.fonte_display}', Georgia, serif`)
  root.style.setProperty('--font-sans', `'${config.fonte_corpo}', system-ui, sans-serif`)
}

function loadGoogleFont(family: string) {
  if (!family) return
  const id = `gfont-${family.replace(/\s+/g, '-').toLowerCase()}`
  if (document.getElementById(id)) return
  const link = document.createElement('link')
  link.id = id
  link.rel = 'stylesheet'
  link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;500;600;700&display=swap`
  document.head.appendChild(link)
}

function applyMetadata(ctx: LoadedTenantContext) {
  const { config } = ctx
  document.title = config.titulo_hero
  if (config.favicon_url) {
    let link = document.querySelector<HTMLLinkElement>("link[rel='icon']")
    if (!link) {
      link = document.createElement('link')
      link.rel = 'icon'
      document.head.appendChild(link)
    }
    link.href = config.favicon_url
  }
}
  • Step 2: Hook de conveniência
// src/hooks/useAppConfig.ts
import { useTenant } from '@/contexts/TenantProvider'

export function useAppConfig() {
  const { context } = useTenant()
  return context?.config ?? null
}

export function useTenantId() {
  const { context } = useTenant()
  return context?.tenant.id ?? null
}
  • Step 3: Commit
pnpm typecheck
git add src/contexts/TenantProvider.tsx src/hooks/useAppConfig.ts
git commit -m "feat: TenantProvider aplica tema e metadados dinamicos"

Task C4: Envolver o app no TenantProvider + usar config na página pública

Files:

  • Modify: reserva-1001/src/main.tsx

  • Modify: reserva-1001/src/App.tsx

  • Modify: reserva-1001/src/services/catalogoService.ts — filtrar por tenant_id

  • Step 1: main.tsx envolve tudo

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
import { TenantProvider } from '@/contexts/TenantProvider'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TenantProvider>
      <App />
    </TenantProvider>
  </StrictMode>
)
  • Step 2: App.tsx usa config

Substituir strings hardcoded por useAppConfig():

import { useAppConfig } from '@/hooks/useAppConfig'
import { useTenant } from '@/contexts/TenantProvider'
import { ReservationFlow } from '@/components/reservation/ReservationFlow'

export default function App() {
  const { loading, error, context } = useTenant()
  const config = useAppConfig()

  if (loading) {
    return <div className="min-h-screen flex items-center justify-center text-ivory">Carregando...</div>
  }
  if (error || !config) {
    return (
      <div className="min-h-screen flex items-center justify-center text-ruby p-6 text-center">
        {error ?? 'Configuração não encontrada'}
      </div>
    )
  }

  return (
    <main className="min-h-screen flex flex-col items-center px-6 py-12">
      <header className="text-center mb-10">
        {config.subtitulo_hero && (
          <p className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4">
            {config.subtitulo_hero}
          </p>
        )}
        <h1 className="font-serif text-5xl md:text-6xl text-gradient-gold mb-3">
          {config.titulo_hero}
        </h1>
        {config.tagline && (
          <p className="font-sans text-slate text-lg">{config.tagline}</p>
        )}
      </header>

      <ReservationFlow />

      {config.footer_text && (
        <footer className="mt-16 text-slate text-xs uppercase tracking-widest">
          {config.footer_text}
        </footer>
      )}
    </main>
  )
}
  • Step 3: catalogoService.ts filtra por tenant

Cada query do catálogo precisa passar tenant_id. Modificar todas as funções:

async listMarcas(tenantId: number): Promise<Marca[]> {
  const { data, error } = await supabase
    .from('marcas')
    .select('*')
    .eq('tenant_id', tenantId)
    .eq('ativa', true)
    .order('nome')
  if (error) throw new Error(error.message)
  return data ?? []
}
// idem pras outras: listUnidades, findPreco, listFotos, listExtras
// todas recebem tenantId e filtram por ele
  • Step 4: useReservationForm passa tenantId pras queries
import { useTenantId } from '@/hooks/useAppConfig'

export function useReservationForm(initialPrefill?: PrefillData) {
  const tenantId = useTenantId()
  // ...
  useEffect(() => {
    if (!tenantId) return
    catalogoService.listMarcas(tenantId).then(setMarcas).catch(/* ... */)
  }, [tenantId])
  // idem pros outros useEffects — todos dependem de tenantId
}
  • Step 5: Typecheck + test + commit
pnpm typecheck
pnpm test
pnpm build
git add src/main.tsx src/App.tsx src/services/catalogoService.ts src/hooks/useReservationForm.ts
git commit -m "feat: app usa config dinamico + queries filtradas por tenant"
  • Step 6: Teste manual
pnpm dev

Abre http://localhost:5180/. Deve continuar funcionando igual (lê tenant grupo-1001 como fallback). Título, subtítulo, tagline, cores — tudo vem do app_config agora.


PARTE D — Auth + admin shell

Task D1: Hook useAuth + integração Supabase

Files:

  • Create: reserva-1001/src/hooks/useAuth.ts

  • Step 1: Criar hook

// src/hooks/useAuth.ts
import { useEffect, useState } from 'react'
import type { Session, User } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'

export function useAuth() {
  const [session, setSession] = useState<Session | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    supabase.auth.getSession().then(({ data }) => {
      setSession(data.session)
      setLoading(false)
    })
    const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
      setSession(newSession)
    })
    return () => sub.subscription.unsubscribe()
  }, [])

  const signIn = async (email: string, password: string) => {
    const { error } = await supabase.auth.signInWithPassword({ email, password })
    if (error) throw new Error(error.message)
  }

  const signOut = async () => {
    await supabase.auth.signOut()
  }

  return { session, user: session?.user ?? null, loading, signIn, signOut }
}

export function useAuthenticatedTenantId(user: User | null): number | null {
  const rawTid = user?.user_metadata?.tenant_id
  const parsed = typeof rawTid === 'number' ? rawTid : Number(rawTid)
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
  • Step 2: Commit
pnpm typecheck
git add src/hooks/useAuth.ts
git commit -m "feat: useAuth hook integrado ao Supabase Auth"

Task D2: Rotas + AdminLayout + LoginPage

Files:

  • Install: pnpm add react-router-dom

  • Create: reserva-1001/src/router.tsx

  • Create: reserva-1001/src/pages/admin/LoginPage.tsx

  • Create: reserva-1001/src/pages/admin/AdminLayout.tsx

  • Create: reserva-1001/src/components/admin/AuthGate.tsx

  • Modify: reserva-1001/src/main.tsx

  • Rename: reserva-1001/src/App.tsxreserva-1001/src/pages/ReservationPage.tsx

  • Step 1: Instalar React Router

cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
pnpm add react-router-dom
  • Step 2: Renomear App.tsx → ReservationPage.tsx

Mover src/App.tsxsrc/pages/ReservationPage.tsx. Ajustar imports.

  • Step 3: Router
// src/router.tsx
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'
import { ReservationPage } from '@/pages/ReservationPage'
import { LoginPage } from '@/pages/admin/LoginPage'
import { AdminLayout } from '@/pages/admin/AdminLayout'
import { AparenciaTab } from '@/pages/admin/AparenciaTab'
// ... imports das outras abas

const router = createBrowserRouter([
  { path: '/', element: <ReservationPage /> },
  { path: '/admin/login', element: <LoginPage /> },
  {
    path: '/admin',
    element: <AdminLayout />,
    children: [
      { index: true, element: <Navigate to="aparencia" replace /> },
      { path: 'aparencia', element: <AparenciaTab /> },
      // { path: 'marcas', element: <MarcasTab /> },
      // etc
    ],
  },
])

export function AppRouter() {
  return <RouterProvider router={router} />
}
  • Step 4: AuthGate
// src/components/admin/AuthGate.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@/hooks/useAuth'

export function AuthGate({ children }: { children: React.ReactNode }) {
  const { session, loading } = useAuth()
  const location = useLocation()

  if (loading) return <div className="p-12 text-ivory">Carregando...</div>
  if (!session) return <Navigate to="/admin/login" state={{ from: location }} replace />
  return <>{children}</>
}
  • Step 5: LoginPage
// src/pages/admin/LoginPage.tsx
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/hooks/useAuth'
import { FormField } from '@/components/FormField'
import { Button } from '@/components/ui/button'

export function LoginPage() {
  const { signIn } = useAuth()
  const navigate = useNavigate()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [submitting, setSubmitting] = useState(false)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setSubmitting(true)
    setError(null)
    try {
      await signIn(email, password)
      navigate('/admin')
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Erro ao entrar')
    } finally {
      setSubmitting(false)
    }
  }

  return (
    <main className="min-h-screen flex items-center justify-center px-6">
      <form onSubmit={handleSubmit} className="w-full max-w-md space-y-6 rounded-2xl border border-champagne/20 bg-midnight/60 p-8 backdrop-blur">
        <h1 className="font-serif text-3xl text-gradient-gold text-center">Painel Administrativo</h1>
        <FormField
          label="E-mail"
          required
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        <FormField
          label="Senha"
          required
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        {error && <div className="rounded-xl border border-ruby/40 bg-ruby/10 p-3 text-ivory text-sm">{error}</div>}
        <Button type="submit" variant="primary" size="lg" className="w-full" disabled={submitting}>
          {submitting ? 'Entrando...' : 'Entrar'}
        </Button>
      </form>
    </main>
  )
}
  • Step 6: AdminLayout
// src/pages/admin/AdminLayout.tsx
import { NavLink, Outlet } from 'react-router-dom'
import { useAuth } from '@/hooks/useAuth'
import { AuthGate } from '@/components/admin/AuthGate'
import { Button } from '@/components/ui/button'

const TABS = [
  { to: 'aparencia', label: 'Aparência' },
  { to: 'marcas', label: 'Marcas' },
  { to: 'unidades', label: 'Unidades' },
  { to: 'categorias', label: 'Categorias' },
  { to: 'precos', label: 'Preços' },
  { to: 'fotos', label: 'Fotos' },
  { to: 'extras', label: 'Extras' },
  { to: 'reservas', label: 'Reservas' },
]

export function AdminLayout() {
  const { signOut, user } = useAuth()

  return (
    <AuthGate>
      <div className="min-h-screen flex flex-col">
        <header className="border-b border-champagne/20 bg-midnight/60 backdrop-blur px-6 py-4 flex items-center justify-between">
          <h1 className="font-serif text-2xl text-champagne">Painel Administrativo</h1>
          <div className="flex items-center gap-3 text-sm text-slate">
            <span>{user?.email}</span>
            <Button variant="ghost" size="sm" onClick={() => signOut()}>Sair</Button>
          </div>
        </header>
        <nav className="border-b border-champagne/10 bg-obsidian/60 px-6 py-3 flex gap-1 overflow-x-auto">
          {TABS.map((tab) => (
            <NavLink
              key={tab.to}
              to={tab.to}
              className={({ isActive }) =>
                `px-4 py-2 rounded-lg text-sm font-sans transition ${
                  isActive
                    ? 'bg-champagne text-obsidian font-semibold'
                    : 'text-ivory hover:bg-champagne/10'
                }`
              }
            >
              {tab.label}
            </NavLink>
          ))}
        </nav>
        <div className="flex-1 p-6 md:p-10">
          <Outlet />
        </div>
      </div>
    </AuthGate>
  )
}
  • Step 7: main.tsx usa router
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { AppRouter } from './router'
import { TenantProvider } from '@/contexts/TenantProvider'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TenantProvider>
      <AppRouter />
    </TenantProvider>
  </StrictMode>
)
  • Step 8: Validar + commit
pnpm typecheck
pnpm build
git add src/router.tsx src/pages/admin/ src/components/admin/AuthGate.tsx src/main.tsx src/pages/ReservationPage.tsx package.json pnpm-lock.yaml
git rm src/App.tsx 2>/dev/null || true
git commit -m "feat: rotas + admin shell + login Supabase Auth"

Task D3: Aba Aparência (identidade editável)

Files:

  • Create: reserva-1001/src/pages/admin/AparenciaTab.tsx

  • Install: pnpm add react-colorful react-hook-form zod @hookform/resolvers

  • Step 1: Install deps

pnpm add react-colorful react-hook-form zod @hookform/resolvers
  • Step 2: AparenciaTab

Form com:

  • Nome da rede, título hero, subtítulo, tagline, footer text (campos texto)
  • Logo URL, favicon URL (upload no Task D8 — por enquanto só URL text input)
  • Cor primária, secundária, fundo, superfície, texto (via react-colorful HexColorPicker)
  • Fonte display, corpo (dropdown com opções: Fraunces, Playfair Display, Cormorant Garamond, Lora, Inter, Poppins, Manrope, DM Sans)
  • Botão "Salvar" que faz supabase.from('app_config').update(...).eq('tenant_id', tenantId)
  • Botão "Preview" que aplica as mudanças temporariamente via CSS variables sem salvar
  • Após salvar, refresh do TenantProvider pra aplicar no app inteiro

Código completo:

import { useEffect, useState } from 'react'
import { HexColorPicker } from 'react-colorful'
import { supabase } from '@/lib/supabase'
import { useTenant } from '@/contexts/TenantProvider'
import { FormField } from '@/components/FormField'
import { SelectField } from '@/components/SelectField'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/hooks/useAuth'
import { useAuthenticatedTenantId } from '@/hooks/useAuth'

const FONT_OPTIONS = [
  { value: 'Fraunces', label: 'Fraunces' },
  { value: 'Playfair Display', label: 'Playfair Display' },
  { value: 'Cormorant Garamond', label: 'Cormorant Garamond' },
  { value: 'Lora', label: 'Lora' },
  { value: 'Inter', label: 'Inter' },
  { value: 'Poppins', label: 'Poppins' },
  { value: 'Manrope', label: 'Manrope' },
  { value: 'DM Sans', label: 'DM Sans' },
]

export function AparenciaTab() {
  const { context, refresh } = useTenant()
  const { user } = useAuth()
  const tenantId = useAuthenticatedTenantId(user)
  const [form, setForm] = useState(context?.config ?? null)
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    setForm(context?.config ?? null)
  }, [context])

  if (!form || !tenantId) return <p className="text-slate">Carregando...</p>

  const update = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) => {
    setForm({ ...form, [key]: value })
  }

  const handleSave = async () => {
    if (!form || !tenantId) return
    setSaving(true)
    setError(null)
    try {
      const { error: err } = await supabase
        .from('app_config')
        .update({
          nome_rede: form.nome_rede,
          titulo_hero: form.titulo_hero,
          subtitulo_hero: form.subtitulo_hero,
          tagline: form.tagline,
          footer_text: form.footer_text,
          logo_url: form.logo_url,
          favicon_url: form.favicon_url,
          cor_primaria: form.cor_primaria,
          cor_secundaria: form.cor_secundaria,
          cor_fundo: form.cor_fundo,
          cor_superficie: form.cor_superficie,
          cor_texto: form.cor_texto,
          fonte_display: form.fonte_display,
          fonte_corpo: form.fonte_corpo,
        })
        .eq('tenant_id', tenantId)

      if (err) throw new Error(err.message)
      await refresh()
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Erro ao salvar')
    } finally {
      setSaving(false)
    }
  }

  return (
    <div className="max-w-3xl space-y-8">
      <section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
        <h2 className="font-serif text-2xl text-champagne">Textos</h2>
        <FormField label="Nome da rede" value={form.nome_rede} onChange={(e) => update('nome_rede', e.target.value)} required />
        <FormField label="Título da página" value={form.titulo_hero} onChange={(e) => update('titulo_hero', e.target.value)} required />
        <FormField label="Subtítulo" value={form.subtitulo_hero ?? ''} onChange={(e) => update('subtitulo_hero', e.target.value)} />
        <FormField label="Tagline" value={form.tagline ?? ''} onChange={(e) => update('tagline', e.target.value)} />
        <FormField label="Footer" value={form.footer_text ?? ''} onChange={(e) => update('footer_text', e.target.value)} />
      </section>

      <section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
        <h2 className="font-serif text-2xl text-champagne">Imagens</h2>
        <FormField label="URL do logo" value={form.logo_url ?? ''} onChange={(e) => update('logo_url', e.target.value)} placeholder="https://..." />
        <FormField label="URL do favicon" value={form.favicon_url ?? ''} onChange={(e) => update('favicon_url', e.target.value)} placeholder="https://..." />
      </section>

      <section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
        <h2 className="font-serif text-2xl text-champagne">Cores</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          <ColorField label="Primária" value={form.cor_primaria} onChange={(v) => update('cor_primaria', v)} />
          <ColorField label="Secundária" value={form.cor_secundaria} onChange={(v) => update('cor_secundaria', v)} />
          <ColorField label="Fundo" value={form.cor_fundo} onChange={(v) => update('cor_fundo', v)} />
          <ColorField label="Superfície" value={form.cor_superficie} onChange={(v) => update('cor_superficie', v)} />
          <ColorField label="Texto" value={form.cor_texto} onChange={(v) => update('cor_texto', v)} />
        </div>
      </section>

      <section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
        <h2 className="font-serif text-2xl text-champagne">Tipografia</h2>
        <SelectField label="Fonte dos títulos (display)" value={form.fonte_display} onChange={(e) => update('fonte_display', e.target.value)} options={FONT_OPTIONS} />
        <SelectField label="Fonte do corpo" value={form.fonte_corpo} onChange={(e) => update('fonte_corpo', e.target.value)} options={FONT_OPTIONS} />
      </section>

      {error && <div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory">{error}</div>}

      <div className="flex gap-3">
        <Button variant="primary" size="lg" onClick={handleSave} disabled={saving}>
          {saving ? 'Salvando...' : 'Salvar aparência'}
        </Button>
      </div>
    </div>
  )
}

function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <label className="font-sans text-xs uppercase tracking-widest text-champagne">{label}</label>
      <div className="mt-2 flex items-center gap-3">
        <button
          type="button"
          className="h-10 w-10 rounded-lg border border-champagne/30"
          style={{ backgroundColor: value }}
          onClick={() => setOpen(!open)}
        />
        <FormField label="" value={value} onChange={(e) => onChange(e.target.value)} className="flex-1" />
      </div>
      {open && (
        <div className="mt-2">
          <HexColorPicker color={value} onChange={onChange} />
        </div>
      )}
    </div>
  )
}
  • Step 3: Typecheck + commit
pnpm typecheck
git add src/pages/admin/AparenciaTab.tsx package.json pnpm-lock.yaml
git commit -m "feat: aba Aparencia edita identidade visual do tenant"

PARTE E — Abas de CRUD

As tasks D4 a D11 do escopo original ficam aqui. Cada uma segue o mesmo padrão:

  • Query do Supabase com eq('tenant_id', tenantId)
  • Lista em tabela com <DataTable> genérico
  • Botão "+ Novo" abre modal de form
  • Edit inline ou modal
  • Delete com confirmação

Devido ao tamanho, vou definir um template reutilizável e cada task referencia ele.

Task E1: Componente genérico <DataTable> + useCrud hook

Files:

  • Create: reserva-1001/src/components/admin/DataTable.tsx

  • Create: reserva-1001/src/hooks/useCrud.ts

  • Step 1: DataTable (tabela simples com actions)

Template:

// Tabela com colunas configuráveis + actions (editar, deletar)
interface Column<T> { key: keyof T; label: string; render?: (row: T) => React.ReactNode }
interface Props<T> {
  rows: T[]
  columns: Column<T>[]
  onEdit?: (row: T) => void
  onDelete?: (row: T) => void
  loading?: boolean
}
// ... implementação (~80 linhas, estilo premium)
  • Step 2: useCrud hook
// Encapsula list/create/update/delete pra uma tabela do Supabase filtrada por tenant_id
export function useCrud<T extends { id: number | string }>(tableName: string) {
  const tenantId = useAuthenticatedTenantId(user)
  // retorna { rows, loading, error, refresh, create, update, remove }
}
  • Step 3: Commit

Tasks E2-E8: Cada aba de CRUD

Cada uma segue o mesmo template:

Task E2: MarcasTab

Form: nome, descricao, categorias (array text), permanencias (array text), ativa.

Task E3: UnidadesTab

Form: nome, id_marca (dropdown), chatwoot_unit_id (number — vinculação ao Chatwoot), endereco, telefone, email, categorias_visiveis (array), ativa.

Task E4: CategoriasTab

Nota: hoje as categorias moram no array marcas.categorias. Vou manter assim — a aba Categorias é na verdade uma view agrupada que edita o array da marca selecionada.

Task E5: PrecosTab

Grid editável por categoria × permanência. Input numérico inline. Salva em batch.

Task E6: FotosTab

Upload drag-and-drop pro Supabase Storage (reserva-1001-fotos bucket) + thumbnail + reorder + delete.

Task E7: ExtrasTab

Form: titulo, descricao, preco, imagem_url, ativo, ordem.

Task E8: ReservasTab (read-only)

Lista com filtros (status, data, unidade). Click numa reserva mostra detalhes + link pra conversa no Chatwoot ({CHATWOOT_URL}/app/accounts/1/conversations/{id}).


PARTE F — Deploy + testes

Task F1: Configurar Vercel wildcard domain

  • Criar projeto Vercel
  • Apontar domínio reserva.fazer.ai (ou subdomínio que você escolher) via DNS CNAME
  • Configurar wildcard *.reserva.fazer.ai no Vercel Project Settings → Domains
  • Env vars de produção: VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_SUPABASE_SCHEMA=reserva_hotel, VITE_CHATWOOT_API_URL, VITE_CHATWOOT_API_TOKEN, VITE_DEFAULT_TENANT_SLUG=grupo-1001

Task F2: Teste ponta-a-ponta multi-tenant

  • Criar um segundo tenant manualmente (INSERT INTO tenants (slug, nome) VALUES ('motel-teste', 'Motel Teste'))
  • Criar app_config pra ele com nome/cores diferentes
  • Abrir motel-teste.reserva.fazer.ai e grupo-1001.reserva.fazer.ai — cada um mostra sua identidade

Task F3: README atualizado

Documentar:

  • Como criar um novo tenant
  • Como subir env vars
  • Como acessar o admin
  • Limitações conhecidas (não tem onboarding self-service)

Critérios de conclusão

Multi-tenancy:

  • Todas as tabelas têm tenant_id
  • Dados existentes backfilled pro tenant grupo-1001
  • Frontend resolve subdomínio → slug → tenant
  • TenantProvider aplica tema (CSS vars) e metadados (title, favicon) dinamicamente

Identidade editável:

  • Aba "Aparência" permite editar: nome, título, subtítulo, tagline, footer, logo URL, favicon URL, 5 cores, 2 fontes
  • Mudanças refletem no app após salvar (via refresh do TenantProvider)

Admin:

  • Login via Supabase Auth funciona
  • /admin/login → após login → redirect pra /admin/aparencia
  • Layout com nav tabs funcional
  • Logout limpa sessão
  • Rotas /admin/* bloqueadas por AuthGate

CRUD:

  • Marcas, Unidades, Preços, Fotos, Extras têm CRUD funcional
  • Reservas read-only com filtros
  • Todas as queries filtradas por tenant

Deploy:

  • Vercel publicado
  • Wildcard domain funcionando
  • 2 tenants de teste subindo com identidades diferentes

Ordem de execução sugerida

  1. Parte A (schema) — base de tudo, roda primeiro
  2. Parte B (auth) — pré-requisito pro admin
  3. Parte C (tenant resolver + theming) — já faz a página pública usar config dinâmico, entrega valor imediato
  4. Parte D (admin shell + Aparência) — você já consegue editar identidade
  5. Parte E (demais abas CRUD) — uma por uma, testáveis independentemente
  6. Parte F (deploy) — só no final, depois que tudo funcionar local

Se quiser fazer só parte dela agora (ex: Partes A + C pra ver a página pública virar genérica), é totalmente possível — cada parte é auto-contida.

Próximas fases

  • Fase 5 — Polish visual (hero, animações framer-motion, carrossel, skeletons, success screen confetti)
  • Fase 6 — QA ponta-a-ponta + produção final