From e55f793ffc631320bb500570f146c6bd11f88ef8 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Tue, 14 Apr 2026 20:50:17 -0300 Subject: [PATCH] docs: plano Fase 4 - multi-tenant SaaS + admin CRUD --- ...4-reserva-1001-fase-4-multitenant-admin.md | 1401 +++++++++++++++++ 1 file changed, 1401 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-reserva-1001-fase-4-multitenant-admin.md diff --git a/docs/superpowers/plans/2026-04-14-reserva-1001-fase-4-multitenant-admin.md b/docs/superpowers/plans/2026-04-14-reserva-1001-fase-4-multitenant-admin.md new file mode 100644 index 000000000..46abb6a05 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-reserva-1001-fase-4-multitenant-admin.md @@ -0,0 +1,1401 @@ +# 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 +``` + +**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** + +```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: +```sql +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** + +```bash +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** + +```sql +-- 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: +```sql +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** + +```bash +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. + +```sql +-- 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** + +```bash +curl -s -o /tmp/t.json -w "HTTP %{http_code}\n" \ + -H "apikey: " \ + -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** + +```sql +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** + +```bash +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) + +```bash +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: " \ + -H "Authorization: Bearer " \ + -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** + +```sql +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: +```sql +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** + +```bash +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** + +```ts +// 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: .trycloudflare.com — usa default tb + if (host.endsWith('.trycloudflare.com')) { + return import.meta.env.VITE_DEFAULT_TENANT_SLUG || 'grupo-1001' + } + + // Padrão SaaS: .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** + +```bash +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** + +```bash +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`** + +```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 { + 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** + +```bash +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** + +```tsx +// 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 +} + +const TenantContext = createContext(null) + +export function TenantProvider({ children }: { children: ReactNode }) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [context, setContext] = useState(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 ( + + {children} + + ) +} + +export function useTenant() { + const ctx = useContext(TenantContext) + if (!ctx) throw new Error('useTenant must be used inside ') + 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 + 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("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** + +```ts +// 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** + +```bash +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** + +```tsx +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( + + + + + +) +``` + +- [ ] **Step 2: App.tsx usa config** + +Substituir strings hardcoded por `useAppConfig()`: + +```tsx +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
Carregando...
+ } + if (error || !config) { + return ( +
+ {error ?? 'Configuração não encontrada'} +
+ ) + } + + return ( +
+
+ {config.subtitulo_hero && ( +

+ {config.subtitulo_hero} +

+ )} +

+ {config.titulo_hero} +

+ {config.tagline && ( +

{config.tagline}

+ )} +
+ + + + {config.footer_text && ( +
+ {config.footer_text} +
+ )} +
+ ) +} +``` + +- [ ] **Step 3: `catalogoService.ts` filtra por tenant** + +Cada query do catálogo precisa passar `tenant_id`. Modificar todas as funções: + +```ts +async listMarcas(tenantId: number): Promise { + 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** + +```ts +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** + +```bash +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** + +```bash +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** + +```ts +// 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(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** + +```bash +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.tsx` → `reserva-1001/src/pages/ReservationPage.tsx` + +- [ ] **Step 1: Instalar React Router** + +```bash +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.tsx` → `src/pages/ReservationPage.tsx`. Ajustar imports. + +- [ ] **Step 3: Router** + +```tsx +// 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: }, + { path: '/admin/login', element: }, + { + path: '/admin', + element: , + children: [ + { index: true, element: }, + { path: 'aparencia', element: }, + // { path: 'marcas', element: }, + // etc + ], + }, +]) + +export function AppRouter() { + return +} +``` + +- [ ] **Step 4: AuthGate** + +```tsx +// 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
Carregando...
+ if (!session) return + return <>{children} +} +``` + +- [ ] **Step 5: LoginPage** + +```tsx +// 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(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 ( +
+
+

Painel Administrativo

+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + {error &&
{error}
} + + +
+ ) +} +``` + +- [ ] **Step 6: AdminLayout** + +```tsx +// 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 ( + +
+
+

Painel Administrativo

+
+ {user?.email} + +
+
+ +
+ +
+
+
+ ) +} +``` + +- [ ] **Step 7: main.tsx usa router** + +```tsx +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( + + + + + +) +``` + +- [ ] **Step 8: Validar + commit** + +```bash +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** + +```bash +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: + +```tsx +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(null) + + useEffect(() => { + setForm(context?.config ?? null) + }, [context]) + + if (!form || !tenantId) return

Carregando...

+ + const update = (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 ( +
+
+

Textos

+ update('nome_rede', e.target.value)} required /> + update('titulo_hero', e.target.value)} required /> + update('subtitulo_hero', e.target.value)} /> + update('tagline', e.target.value)} /> + update('footer_text', e.target.value)} /> +
+ +
+

Imagens

+ update('logo_url', e.target.value)} placeholder="https://..." /> + update('favicon_url', e.target.value)} placeholder="https://..." /> +
+ +
+

Cores

+
+ update('cor_primaria', v)} /> + update('cor_secundaria', v)} /> + update('cor_fundo', v)} /> + update('cor_superficie', v)} /> + update('cor_texto', v)} /> +
+
+ +
+

Tipografia

+ update('fonte_display', e.target.value)} options={FONT_OPTIONS} /> + update('fonte_corpo', e.target.value)} options={FONT_OPTIONS} /> +
+ + {error &&
{error}
} + +
+ +
+
+ ) +} + +function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { + const [open, setOpen] = useState(false) + return ( +
+ +
+
+ {open && ( +
+ +
+ )} +
+ ) +} +``` + +- [ ] **Step 3: Typecheck + commit** + +```bash +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 `` 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 `` + `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: +```tsx +// Tabela com colunas configuráveis + actions (editar, deletar) +interface Column { key: keyof T; label: string; render?: (row: T) => React.ReactNode } +interface Props { + rows: T[] + columns: Column[] + onEdit?: (row: T) => void + onDelete?: (row: T) => void + loading?: boolean +} +// ... implementação (~80 linhas, estilo premium) +``` + +- [ ] **Step 2: `useCrud` hook** + +```tsx +// Encapsula list/create/update/delete pra uma tabela do Supabase filtrada por tenant_id +export function useCrud(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