iachat/docs/superpowers/plans/2026-04-13-reserva-1001-fase-2-3-fluxo-completo.md
Rodribm10 2216e082c1 docs: plano Fase 2+3 (backend Chatwoot + fluxo publico completo)
Plano combinado com 19 tasks bite-sized:
- Parte A: seed de dados de teste em reserva_hotel
- Parte B (Fase 2): controller publico Chatwoot com 2 endpoints,
  auth por token, 8 specs RSpec, smoke test via curl
- Parte C (Fase 3): client HTTP, formatadores, catalogoService,
  useReservationForm, StayDetailsStep, ImageGallery, PriceSummary,
  CustomerForm, PixCheckout com polling, SuccessScreen, ReservationFlow

Usa Captain::Unit id=4 (Hotel 1001 Aguas Lindas, inbox_id=2)
como unidade de teste (ja configurada com credenciais Inter).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:37:43 -03:00

62 KiB
Raw Permalink Blame History

Reserva Rede 1001 — Fase 2+3: Backend Chatwoot + Fluxo Público Completo

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

Goal: Fluxo completo onde o cliente abre a página reserva-1001, escolhe marca → unidade → permanência → categoria → data → vê fotos + preço, preenche seus dados, clica "Confirmar e Pagar" e recebe um QR Code PIX real gerado pela API do Chatwoot (que chama a Inter), paga, e a página detecta o pagamento via polling. No Chatwoot a atendente recebe uma mensagem automática " Pagamento confirmado!" na conversa.

Architecture (2 partes):

  1. Backend (Chatwoot): controller público novo Public::Api::V1::Captain::PublicReservationsController com 2 endpoints (POST cria reserva + PIX, GET retorna status). Reusa 100% do Captain::Inter::CobService, ConfirmationService e webhook Inter existentes.
  2. Frontend (reserva-1001): refatora App.tsx em fluxo multi-step, adiciona hook useReservationForm, services do Supabase (unidades/categorias/preços/fotos), componentes de form em cascata, tela de checkout com polling, tela de sucesso.

Tech Stack: Rails 7 (backend) · React 19 + TypeScript + Supabase (frontend) · Inter API (PIX, via Chatwoot) · RSpec · Vitest

Spec: docs/superpowers/specs/2026-04-13-reserva-1001-design.md Fase 1 (pré-requisito): docs/superpowers/plans/2026-04-13-reserva-1001-fase-1-fundacao.md — COMPLETO


Dados conhecidos (já investigados)

Chatwoot dev DB:

  • Captain::Unit id=4, name="Hotel 1001 Águas Lindas", account_id=1, inbox_id=2, Inter credentials completas (client_id/secret/cert/key/pix_key) → usar como unidade de teste.
  • Unit id=3 "Hotel Recanto" tem inbox_id=1 como segunda opção.

Modelos Chatwoot relevantes:

  • Captain::Reservation (enterprise/app/models/captain/reservation.rb) — campos obrigatórios: account, inbox, contact, contact_inbox, suite_identifier, check_in_at, check_out_at. Enum status: {scheduled:0, active:1, completed:2, cancelled:3, pending_payment:4, draft:5, confirmed:6}. payment_status é string ('pending'/'paid').
  • Captain::PixCharge (enterprise/app/models/captain/pix_charge.rb) — txid, pix_copia_e_cola, status enum {active, paid, expired, failed}, pertence a Reservation e Unit.
  • Captain::Inter::CobService#call — assinatura: initialize(reservation, amount: nil). Retorna Captain::PixCharge. Atualiza reservation.current_pix_charge_id.
  • Captain::Payments::ConfirmationService#perform — marca reservation paga, adiciona labels, cria mensagem automática na conversa.

Builders:

  • ContactInboxWithContactBuilder.new(source_id:, inbox:, contact_attributes:).perform — cria/dedupe contact + contact_inbox.
  • ConversationBuilder.new(params:, contact_inbox:).perform — cria conversation.
  • Messages::MessageBuilder.new(user, conversation, params).perform — cria mensagem.

Rotas existentes:

  • Webhook Inter: POST /api/v1/captain/webhooks/inter_pix (público, sem auth)
  • Short payment link: GET /r/:token

Supabase reserva_hotel (hoje):

  • marcas: 1 row ("Hotel 1001 Noites", categorias=["Standard","Superior","Luxo"], permanencias=["3hrs","6hrs","Pernoite"])
  • unidades, precos, fotos_categoria: 0 rows cada
  • Task A0 (seed) popula isso com dados de teste.

File Structure

Backend (dentro de chatwoot/):

enterprise/app/controllers/public/api/v1/captain/
└── public_reservations_controller.rb     # novo

config/routes.rb                           # adicionar 2 rotas

spec/enterprise/controllers/public/api/v1/captain/
└── public_reservations_controller_spec.rb # novo

Frontend (dentro de reserva-1001/src/):

lib/
├── chatwootApi.ts          # novo — client HTTP do endpoint novo
├── formatters.ts           # novo — utilidades BRL, CPF, tel
└── supabase.ts             # existente

services/                    # NOVO
├── unidadesService.ts      # lista unidades por marca
├── categoriasService.ts    # resolve categorias visíveis por unidade
├── precosService.ts        # resolve preço por categoria+permanência+day_range
└── fotosService.ts         # fotos por unidade+categoria

hooks/                       # NOVO
└── useReservationForm.ts   # estado consolidado do form

components/
├── FormField.tsx            # existente
├── SelectField.tsx          # existente
├── reservation/             # NOVO
│   ├── ReservationFlow.tsx  # orquestra as etapas
│   ├── StayDetailsStep.tsx  # marca→unidade→permanência→categoria→data
│   ├── ImageGallery.tsx     # grid de fotos
│   ├── PriceSummary.tsx     # preço total + 50% PIX
│   ├── CustomerForm.tsx     # nome/tel/CPF/email/obs
│   └── SubmitButton.tsx
├── checkout/                # NOVO
│   ├── PixCheckout.tsx      # QR + copia-cola + timer + polling
│   └── SuccessScreen.tsx    # confirmação
└── ui/
    └── button.tsx           # existente

App.tsx                      # reescrito pra montar ReservationFlow / PixCheckout / SuccessScreen

Pré-requisitos (informações que eu, controller, tenho antes de dispatchar qualquer subagente)

  1. Credenciais Supabase já em reserva-1001/.env.local (Fase 1 feita)
  2. Chatwoot dev rodando em http://localhost:3000 (para Fase 2 testar o endpoint via curl e para Fase 3 bater contra)
  3. Captain::Unit id=4 (Hotel 1001 Águas Lindas) é a unidade de teste conhecida, inbox_id=2, account_id=1
  4. Token de autenticação do endpoint público: vamos definir RESERVA_1001_API_TOKEN=dev-token-change-in-prod em chatwoot/.env (dev) e reserva-1001/.env.local (VITE_CHATWOOT_API_TOKEN)

PARTE A — Dados de teste (Supabase seed)

Task A0: Seed de dados de teste em reserva_hotel

Contexto: Sem unidade, preços e fotos no Supabase, o frontend não tem o que renderizar. Esta task popula o mínimo necessário pra testar todo o fluxo com a unidade Hotel 1001 Águas Lindas (Captain::Unit id=4 no Chatwoot).

Files:

  • Create: reserva-1001/supabase/migrations/20260413000002_seed_dados_teste.sql

  • Step 1: Aplicar seed via MCP (apply_migration)

Migration name: reserva_1001_seed_dados_teste

Query SQL:

-- Usa a marca já existente ("Hotel 1001 Noites") e cria:
--  • 1 unidade (Águas Lindas) amarrada ao Captain::Unit id=4
--  • 6 preços (2 categorias × 3 permanências, weekday)
--  • 4 fotos por categoria (URLs placeholders do Unsplash)
--  • 2 extras (adicional tira-gosto + decoração romântica)

do $$
declare
  v_marca_id  uuid;
  v_unidade_id uuid;
begin
  -- 1. Garante que a marca tem categorias e permanências coerentes com os preços
  select id into v_marca_id from reserva_hotel.marcas where nome = 'Hotel 1001 Noites' limit 1;

  if v_marca_id is null then
    insert into reserva_hotel.marcas (nome, categorias, permanencias, ativa)
    values ('Hotel 1001 Noites',
            array['Standard','Hidromassagem'],
            array['3hrs','4hrs','Pernoite'],
            true)
    returning id into v_marca_id;
  else
    update reserva_hotel.marcas
       set categorias = array['Standard','Hidromassagem'],
           permanencias = array['3hrs','4hrs','Pernoite']
     where id = v_marca_id;
  end if;

  -- 2. Unidade amarrada ao Captain::Unit id=4
  insert into reserva_hotel.unidades
    (nome, id_marca, id_conta_pagamento, categorias_visiveis,
     endereco, telefone, ativa, chatwoot_unit_id)
  select
    'Hotel 1001 Águas Lindas',
    v_marca_id,
    (select id from reserva_hotel.contas_pagamento limit 1),
    array['Standard','Hidromassagem'],
    'Águas Lindas, GO',
    '(61) 99999-0000',
    true,
    4
  on conflict do nothing
  returning id into v_unidade_id;

  if v_unidade_id is null then
    select id into v_unidade_id
      from reserva_hotel.unidades
     where chatwoot_unit_id = 4
     limit 1;
  end if;

  -- 3. Preços (6 rows: 2 categorias × 3 permanências) — weekday default
  insert into reserva_hotel.precos
    (id_marca, categoria, permanencia, periodo_semana, valor, ativo)
  values
    (v_marca_id, 'Standard',       '3hrs',     'default', 120.00, true),
    (v_marca_id, 'Standard',       '4hrs',     'default', 150.00, true),
    (v_marca_id, 'Standard',       'Pernoite', 'default', 220.00, true),
    (v_marca_id, 'Hidromassagem',  '3hrs',     'default', 170.00, true),
    (v_marca_id, 'Hidromassagem',  '4hrs',     'default', 210.00, true),
    (v_marca_id, 'Hidromassagem',  'Pernoite', 'default', 290.00, true)
  on conflict do nothing;

  -- 4. Fotos (URLs placeholder Unsplash, 2 por categoria)
  insert into reserva_hotel.fotos_categoria
    (id_unidade, categoria, url_foto, alt, ordem)
  values
    (v_unidade_id, 'Standard',
      'https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=800', 'Suíte Standard vista 1', 0),
    (v_unidade_id, 'Standard',
      'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800', 'Suíte Standard vista 2', 1),
    (v_unidade_id, 'Hidromassagem',
      'https://images.unsplash.com/photo-1582719508461-905c673771fd?w=800', 'Suíte Hidromassagem vista 1', 0),
    (v_unidade_id, 'Hidromassagem',
      'https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=800', 'Suíte Hidromassagem vista 2', 1)
  on conflict do nothing;

  -- 5. Extras simples
  insert into reserva_hotel.extras
    (id_marca, titulo, descricao, preco, ativo, ordem)
  values
    (v_marca_id, 'Tira-gosto', 'Porção de petiscos servida na suíte', 35.00, true, 0),
    (v_marca_id, 'Decoração romântica', 'Pétalas, velas e champanhe', 90.00, true, 1)
  on conflict do nothing;
end $$;

(Controller nota: aplicar via mcp__claude_ai_supabase__apply_migration com project_id acdvblhzzaneddlxqyst, name reserva_1001_seed_dados_teste.)

  • Step 2: Validar que os dados estão lá

Query via MCP execute_sql:

select
  (select count(*) from reserva_hotel.unidades where chatwoot_unit_id=4) as unidades,
  (select count(*) from reserva_hotel.precos) as precos,
  (select count(*) from reserva_hotel.fotos_categoria) as fotos,
  (select count(*) from reserva_hotel.extras) as extras;

Expected: unidades=1, precos=6, fotos=4, extras=2.

  • Step 3: Commit do arquivo de migration
cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
git add supabase/migrations/20260413000002_seed_dados_teste.sql
git commit -m "feat: seed de dados de teste (unidade aguas lindas, precos, fotos, extras)"

PARTE B — Backend Chatwoot (Fase 2)

Task B1: Variável de ambiente para o token

Files:

  • Modify: chatwoot/.env (não commitar — só local)

  • Modify: chatwoot/.env.example (commitar)

  • Step 1: Adicionar ao .env local

Append a chatwoot/.env:

# Reserva Rede 1001 — Public API token
RESERVA_1001_API_TOKEN=dev-token-change-in-prod
  • Step 2: Adicionar ao .env.example

Append a chatwoot/.env.example:

# Reserva Rede 1001 — public reservations API (Fase 2)
# Token usado para autenticar chamadas do app reserva-1001 ao endpoint publico
# de geracao de reservas/PIX. Gerar via `openssl rand -hex 32` em producao.
RESERVA_1001_API_TOKEN=
  • Step 3: Commit apenas o .env.example
cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/chatwoot
git add .env.example
git commit -m "chore: documenta RESERVA_1001_API_TOKEN no .env.example"

Task B2: Teste RSpec — autenticação por token

Files:

  • Create: chatwoot/spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb

  • Step 1: Criar o spec com o primeiro teste (TDD — falha porque controller ainda não existe)

Create spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb:

require 'rails_helper'

RSpec.describe 'Public Captain Reservations API', type: :request do
  let(:unit) { create(:captain_unit, account: create(:account)) }
  let(:valid_token) { 'test-token-abc' }

  before do
    allow(ENV).to receive(:fetch).and_call_original
    allow(ENV).to receive(:fetch).with('RESERVA_1001_API_TOKEN', any_args).and_return(valid_token)
  end

  describe 'POST /public/api/v1/captain/public_reservations' do
    context 'without token' do
      it 'returns 401' do
        post '/public/api/v1/captain/public_reservations',
             params: { chatwoot_unit_id: unit.id }.to_json,
             headers: { 'Content-Type' => 'application/json' }

        expect(response).to have_http_status(:unauthorized)
      end
    end

    context 'with invalid token' do
      it 'returns 401' do
        post '/public/api/v1/captain/public_reservations',
             params: { chatwoot_unit_id: unit.id }.to_json,
             headers: {
               'Content-Type' => 'application/json',
               'X-Reserva-Token' => 'wrong'
             }

        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
end
  • Step 2: Rodar o spec (deve falhar — rota/controller não existem)
cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/chatwoot
bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -20

Expected: FAILURE — rota não existe.

  • Step 3: Commit do spec falhando
git add spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb
git commit -m "test: spec para autenticacao do PublicReservationsController (red)"

Task B3: Rota + controller esqueleto com autenticação

Files:

  • Modify: chatwoot/config/routes.rb

  • Create: chatwoot/enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb

  • Step 1: Ler o config/routes.rb atual e encontrar o namespace público

cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/chatwoot
grep -n "public/api/v1/captain" config/routes.rb

Expected: mostra a linha do inter_webhooks. A nova rota vai logo abaixo.

  • Step 2: Adicionar rota

Edit config/routes.rb — encontrar o bloco que registra inter_pix webhook (algo como post '/api/v1/captain/webhooks/inter_pix', to: 'public/api/v1/captain/inter_webhooks#create') e adicionar na sequência:

# Reserva Rede 1001 — public reservation API
namespace :public, defaults: { format: 'json' } do
  namespace :api do
    namespace :v1 do
      namespace :captain do
        resources :public_reservations, only: [:create, :show] do
          member do
            get :status
          end
        end
      end
    end
  end
end

Se já existir um namespace public/api/v1/captain aberto por outro motivo, colocar o resources :public_reservations dentro dele.

  • Step 3: Criar o controller com auth-only

Create enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb:

class Public::Api::V1::Captain::PublicReservationsController < ActionController::API
  include ActionController::Cookies

  before_action :authenticate_reserva_token!

  # POST /public/api/v1/captain/public_reservations
  def create
    render json: { error: 'not_implemented' }, status: :not_implemented
  end

  # GET /public/api/v1/captain/public_reservations/:id/status
  def status
    render json: { error: 'not_implemented' }, status: :not_implemented
  end

  private

  def authenticate_reserva_token!
    expected = ENV.fetch('RESERVA_1001_API_TOKEN', nil)
    provided = request.headers['X-Reserva-Token']

    if expected.blank?
      Rails.logger.error('[PublicReservations] RESERVA_1001_API_TOKEN not configured')
      render json: { error: 'service_unavailable' }, status: :service_unavailable and return
    end

    if provided.blank? || !ActiveSupport::SecurityUtils.secure_compare(provided, expected)
      render json: { error: 'unauthorized' }, status: :unauthorized and return
    end
  end
end
  • Step 4: Rodar o spec novamente (deve passar nos testes de auth)
bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -20

Expected: 2 passed (without token → 401, with invalid token → 401).

Se falhar por problema de carregamento do arquivo, garantir que enterprise/app/controllers/public/api/v1/captain/ é autoloaded (seguir o mesmo padrão do inter_webhooks_controller.rb).

  • Step 5: Commit
git add config/routes.rb enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb
git commit -m "feat: rota + controller esqueleto PublicReservations com token auth"

Task B4: Teste RSpec — criar reserva + PIX (happy path)

Files:

  • Modify: chatwoot/spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb

  • Step 1: Adicionar contexto e teste de criação bem-sucedida

Adicionar dentro do describe 'POST ...' bloco:

    context 'with valid token and valid payload' do
      let(:unit) do
        create(
          :captain_unit,
          account: account,
          inbox: inbox,
          inter_client_id: 'fake-id',
          inter_client_secret: 'fake-secret',
          inter_pix_key: 'fake-key@test.com',
          inter_account_number: '1234567',
          inter_cert_content: 'FAKE-CERT',
          inter_key_content: 'FAKE-KEY'
        )
      end
      let(:account) { create(:account) }
      let(:inbox) { create(:inbox, account: account) }

      let(:valid_payload) do
        {
          chatwoot_unit_id: unit.id,
          category: 'Standard',
          stay_type: '3hrs',
          checkin_at: 2.hours.from_now.iso8601,
          customer: {
            name: 'Maria Teste',
            phone: '+5561999990000',
            cpf: '12345678909',
            email: 'maria@teste.com'
          },
          total_cents: 12_000,
          deposit_cents: 6_000,
          notes: 'Chegaremos às 15h'
        }
      end

      let(:fake_pix_charge) do
        double('PixCharge',
               id: 99,
               txid: 'test-txid-123',
               pix_copia_e_cola: '00020126...',
               status: 'active')
      end

      before do
        allow_any_instance_of(Captain::Inter::CobService).to receive(:call).and_return(fake_pix_charge)
      end

      it 'returns 201 with reservation and PIX payload' do
        post '/public/api/v1/captain/public_reservations',
             params: valid_payload.to_json,
             headers: {
               'Content-Type' => 'application/json',
               'X-Reserva-Token' => valid_token
             }

        expect(response).to have_http_status(:created)
        body = JSON.parse(response.body)
        expect(body['reservation_id']).to be_present
        expect(body['conversation_id']).to be_present
        expect(body['pix']['txid']).to eq('test-txid-123')
        expect(body['pix']['copia_e_cola']).to start_with('00020126')
      end

      it 'creates Contact, ContactInbox, Conversation and Reservation' do
        expect {
          post '/public/api/v1/captain/public_reservations',
               params: valid_payload.to_json,
               headers: {
                 'Content-Type' => 'application/json',
                 'X-Reserva-Token' => valid_token
               }
        }.to change(Contact, :count).by(1)
         .and change(Conversation, :count).by(1)
         .and change(Captain::Reservation, :count).by(1)
      end
    end
  • Step 2: Rodar (deve falhar — lógica ainda não existe)
bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -25

Expected: 2 novos FAIL (501 not_implemented em vez de 201). Os 2 de auth continuam passando.

  • Step 3: Commit
git add spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb
git commit -m "test: specs do happy path do PublicReservationsController (red)"

Task B5: Implementar create — cria Contact, Conversation, Reservation, chama CobService

Files:

  • Modify: chatwoot/enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb

  • Step 1: Implementar o action create

Substituir o método create do controller por:

  def create
    unit = Captain::Unit.find_by(id: params[:chatwoot_unit_id])
    return render json: { error: 'unit_not_found' }, status: :not_found if unit.nil?
    return render json: { error: 'unit_has_no_inbox' }, status: :unprocessable_entity if unit.inbox_id.blank?
    return render json: { error: 'unit_missing_inter_credentials' }, status: :unprocessable_entity unless unit.inter_credentials_present?

    customer = params[:customer] || {}
    return render json: { error: 'customer_required' }, status: :unprocessable_entity if customer[:name].blank?

    account = unit.account
    inbox   = unit.inbox

    contact_inbox = ::ContactInboxWithContactBuilder.new(
      source_id: "reserva1001-#{SecureRandom.uuid}",
      inbox: inbox,
      contact_attributes: {
        name: customer[:name],
        phone_number: customer[:phone].presence,
        email: customer[:email].presence,
        additional_attributes: {
          cpf: customer[:cpf].presence,
          origem: 'reserva-1001'
        }.compact
      }
    ).perform

    conversation = ConversationBuilder.new(
      params: {
        additional_attributes: {
          source: 'reserva-1001',
          reserva_category: params[:category],
          reserva_stay_type: params[:stay_type],
          reserva_checkin_at: params[:checkin_at]
        }
      },
      contact_inbox: contact_inbox
    ).perform

    initial_note = <<~NOTE.strip
      🛎️ *Nova reserva via reserva.1001*
      Categoria: #{params[:category]}
      Permanência: #{params[:stay_type]}
      Check-in: #{params[:checkin_at]}
      Total: R$ #{format('%.2f', params[:total_cents].to_i / 100.0)}
      Entrada (PIX 50%): R$ #{format('%.2f', params[:deposit_cents].to_i / 100.0)}
      Observação: #{params[:notes].presence || '—'}
    NOTE

    Messages::MessageBuilder.new(
      nil,
      conversation,
      { content: initial_note, message_type: 'outgoing', private: true }
    ).perform

    reservation = Captain::Reservation.create!(
      account: account,
      inbox: inbox,
      contact: contact_inbox.contact,
      contact_inbox: contact_inbox,
      conversation: conversation,
      unit: unit,
      suite_identifier: "#{params[:category]} · #{params[:stay_type]}",
      check_in_at: params[:checkin_at],
      check_out_at: checkout_from(params[:checkin_at], params[:stay_type]),
      status: :draft,
      payment_status: 'pending',
      total_amount: (params[:total_cents].to_i / 100.0),
      metadata: {
        origem: 'reserva-1001',
        category: params[:category],
        stay_type: params[:stay_type],
        deposit_cents: params[:deposit_cents].to_i,
        notes: params[:notes]
      }
    )

    deposit_amount = (params[:deposit_cents].to_i / 100.0)
    charge = Captain::Inter::CobService.new(reservation, amount: deposit_amount).call
    reservation.update!(status: :pending_payment)

    render json: {
      reservation_id: reservation.id,
      conversation_id: conversation.id,
      pix: {
        txid: charge.txid,
        copia_e_cola: charge.pix_copia_e_cola,
        qrcode_base64: nil, # gerado no front via biblioteca qrcode (menos round-trip)
        expires_at: (Time.current + Captain::PixCharge::EXPIRATION_SECONDS.seconds).iso8601
      }
    }, status: :created
  rescue ActiveRecord::RecordInvalid => e
    Rails.logger.error("[PublicReservations] validation error: #{e.message}")
    render json: { error: 'validation_failed', details: e.record.errors.full_messages }, status: :unprocessable_entity
  rescue StandardError => e
    Rails.logger.error("[PublicReservations] unexpected error: #{e.class} - #{e.message}")
    render json: { error: 'internal_error', message: e.message }, status: :internal_server_error
  end

  private

  def checkout_from(checkin_iso, stay_type)
    checkin = Time.parse(checkin_iso.to_s)
    hours = case stay_type
            when '2hrs' then 2
            when '3hrs' then 3
            when '4hrs' then 4
            when 'Pernoite', 'pernoite' then 12
            when 'Diaria', 'diaria', 'Diária' then 24
            else 3
            end
    checkin + hours.hours
  end

(manter authenticate_reserva_token! privado abaixo)

  • Step 2: Rodar os specs
bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -30

Expected: 4 passing.

Se falhar por algum motivo (ex: factory não aceita inbox, Captain::Unit factory diferente), adaptar os testes/código e iterar até verde.

  • Step 3: Commit
git add enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb
git commit -m "feat: implementa POST /public_reservations (contact + conversa + reserva + pix)"

Task B6: Teste + implementação do status (GET)

Files:

  • Modify: chatwoot/spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb

  • Modify: chatwoot/enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb

  • Step 1: Adicionar describe do GET /status

Adicionar ao spec:

  describe 'GET /public/api/v1/captain/public_reservations/:id/status' do
    let(:account) { create(:account) }
    let(:inbox)   { create(:inbox, account: account) }
    let(:contact) { create(:contact, account: account) }
    let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact) }
    let(:unit) { create(:captain_unit, account: account, inbox: inbox) }
    let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) }
    let(:reservation) do
      Captain::Reservation.create!(
        account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox,
        conversation: conversation, unit: unit, suite_identifier: 'Standard · 3hrs',
        check_in_at: 1.hour.from_now, check_out_at: 4.hours.from_now,
        status: :pending_payment, payment_status: 'pending', total_amount: 120.0
      )
    end

    it 'requires token' do
      get "/public/api/v1/captain/public_reservations/#{reservation.id}/status"
      expect(response).to have_http_status(:unauthorized)
    end

    it 'returns pending for unpaid reservation' do
      get "/public/api/v1/captain/public_reservations/#{reservation.id}/status",
          headers: { 'X-Reserva-Token' => valid_token }
      expect(response).to have_http_status(:ok)
      expect(JSON.parse(response.body)['status']).to eq('pending')
    end

    it 'returns paid after payment_status flips' do
      reservation.update!(payment_status: 'paid')
      get "/public/api/v1/captain/public_reservations/#{reservation.id}/status",
          headers: { 'X-Reserva-Token' => valid_token }
      expect(JSON.parse(response.body)['status']).to eq('paid')
    end

    it 'returns 404 for unknown reservation' do
      get '/public/api/v1/captain/public_reservations/999999/status',
          headers: { 'X-Reserva-Token' => valid_token }
      expect(response).to have_http_status(:not_found)
    end
  end
  • Step 2: Implementar status no controller

Substituir o método status do controller por:

  def status
    reservation = Captain::Reservation.find_by(id: params[:id])
    return render json: { error: 'not_found' }, status: :not_found if reservation.nil?

    render json: {
      reservation_id: reservation.id,
      status: reservation.payment_status
    }
  end
  • Step 3: Rodar os specs
bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -30

Expected: 8 passing (4 do POST + 4 do GET).

  • Step 4: Commit
git add spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb
git commit -m "feat: implementa GET /public_reservations/:id/status"

Task B7: Smoke test via curl contra o Chatwoot local

Files: nenhum arquivo novo.

  • Step 1: Garantir que o Chatwoot dev está rodando

Se não estiver: abra outro terminal e rode pnpm run dev dentro de chatwoot/. Espere até ver "Listening on 0.0.0.0:3000".

  • Step 2: Curl — tentativa sem token (espera 401)
curl -i -X POST http://localhost:3000/public/api/v1/captain/public_reservations \
  -H "Content-Type: application/json" \
  -d '{}'

Expected: HTTP/1.1 401 Unauthorized.

  • Step 3: Curl — tentativa com token válido e payload válido
curl -i -X POST http://localhost:3000/public/api/v1/captain/public_reservations \
  -H "Content-Type: application/json" \
  -H "X-Reserva-Token: dev-token-change-in-prod" \
  -d '{
    "chatwoot_unit_id": 4,
    "category": "Standard",
    "stay_type": "3hrs",
    "checkin_at": "2026-04-14T23:00:00-03:00",
    "customer": {
      "name": "Teste Curl",
      "phone": "+5561999998877",
      "cpf": "12345678909",
      "email": "teste@curl.com"
    },
    "total_cents": 12000,
    "deposit_cents": 6000,
    "notes": "smoke test"
  }'

Expected: HTTP/1.1 201 Created com body JSON contendo reservation_id, conversation_id, pix.txid, pix.copia_e_cola.

Se a Inter real responder com erro (cert inválido no dev, endpoint bloqueado, etc): aceitar como "falhou por motivo externo à nossa lógica" e documentar. O RSpec já validou nossa lógica com mock.

  • Step 4: Curl — GET /status
# Substituir <ID> pelo reservation_id retornado acima
curl -i http://localhost:3000/public/api/v1/captain/public_reservations/<ID>/status \
  -H "X-Reserva-Token: dev-token-change-in-prod"

Expected: 200 OK com {"reservation_id":...,"status":"pending"}.

  • Step 5: Commit (se houve ajustes)

Se os smoke tests exigiram correções no código, commit como fix:.


PARTE C — Frontend (Fase 3)

Task C1: Client HTTP do Chatwoot

Files:

  • Modify: reserva-1001/.env.local e .env.local.example

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

  • Step 1: Garantir variáveis de ambiente

Edit reserva-1001/.env.local, adicionar/completar:

VITE_CHATWOOT_API_URL=http://localhost:3000
VITE_CHATWOOT_API_TOKEN=dev-token-change-in-prod

Edit .env.local.example do mesmo jeito (com token em branco).

  • Step 2: Criar o client

Create reserva-1001/src/lib/chatwootApi.ts:

const API_URL = import.meta.env.VITE_CHATWOOT_API_URL
const API_TOKEN = import.meta.env.VITE_CHATWOOT_API_TOKEN

if (!API_URL || !API_TOKEN) {
  console.warn('VITE_CHATWOOT_API_URL / VITE_CHATWOOT_API_TOKEN nao definidos')
}

export interface CreateReservationInput {
  chatwoot_unit_id: number
  category: string
  stay_type: string
  checkin_at: string // ISO
  customer: {
    name: string
    phone: string
    cpf: string
    email?: string
  }
  total_cents: number
  deposit_cents: number
  notes?: string
}

export interface CreateReservationResponse {
  reservation_id: number
  conversation_id: number
  pix: {
    txid: string
    copia_e_cola: string
    qrcode_base64: string | null
    expires_at: string
  }
}

export interface StatusResponse {
  reservation_id: number
  status: 'pending' | 'paid' | 'expired' | 'canceled'
}

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${API_URL}${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      'X-Reserva-Token': API_TOKEN,
      ...(init.headers ?? {}),
    },
  })

  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`Chatwoot API ${res.status}: ${body || res.statusText}`)
  }

  return (await res.json()) as T
}

export const chatwootApi = {
  createReservation(input: CreateReservationInput): Promise<CreateReservationResponse> {
    return request('/public/api/v1/captain/public_reservations', {
      method: 'POST',
      body: JSON.stringify(input),
    })
  },

  getStatus(id: number): Promise<StatusResponse> {
    return request(`/public/api/v1/captain/public_reservations/${id}/status`)
  },
}
  • Step 3: Typecheck + commit
cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
pnpm typecheck
git add .env.local.example src/lib/chatwootApi.ts
git commit -m "feat: client http do endpoint publico do chatwoot"

Task C2: Formatadores (BRL, CPF, telefone)

Files:

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

  • Step 1: Criar

export function formatBRL(cents: number): string {
  return (cents / 100).toLocaleString('pt-BR', {
    style: 'currency',
    currency: 'BRL',
  })
}

export function maskCPF(value: string): string {
  const digits = value.replace(/\D/g, '').slice(0, 11)
  return digits
    .replace(/(\d{3})(\d)/, '$1.$2')
    .replace(/(\d{3})(\d)/, '$1.$2')
    .replace(/(\d{3})(\d{1,2})$/, '$1-$2')
}

export function maskPhone(value: string): string {
  const digits = value.replace(/\D/g, '').slice(0, 11)
  if (digits.length <= 10) {
    return digits
      .replace(/(\d{2})(\d)/, '($1) $2')
      .replace(/(\d{4})(\d)/, '$1-$2')
  }
  return digits
    .replace(/(\d{2})(\d)/, '($1) $2')
    .replace(/(\d{5})(\d)/, '$1-$2')
}

export function onlyDigits(value: string): string {
  return value.replace(/\D/g, '')
}
  • Step 2: Commit
git add src/lib/formatters.ts
git commit -m "feat: formatadores BRL, CPF e telefone"

Task C3: Services do Supabase — unidades, preços, fotos, extras

Files:

  • Create: reserva-1001/src/services/catalogoService.ts

  • Step 1: Criar

import { supabase } from '@/lib/supabase'
import type { Database } from '@/types/database'

type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row']
type Preco = Database['reserva_hotel']['Tables']['precos']['Row']
type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']
type Extra = Database['reserva_hotel']['Tables']['extras']['Row']

export const catalogoService = {
  async listMarcas(): Promise<Marca[]> {
    const { data, error } = await supabase
      .from('marcas')
      .select('*')
      .eq('ativa', true)
      .order('nome')
    if (error) throw new Error(error.message)
    return data ?? []
  },

  async listUnidades(marcaId: string): Promise<Unidade[]> {
    const { data, error } = await supabase
      .from('unidades')
      .select('*')
      .eq('id_marca', marcaId)
      .eq('ativa', true)
      .order('nome')
    if (error) throw new Error(error.message)
    return data ?? []
  },

  async findPreco(
    marcaId: string,
    categoria: string,
    permanencia: string,
    periodo = 'default'
  ): Promise<Preco | null> {
    const { data, error } = await supabase
      .from('precos')
      .select('*')
      .eq('id_marca', marcaId)
      .eq('categoria', categoria)
      .eq('permanencia', permanencia)
      .eq('periodo_semana', periodo)
      .eq('ativo', true)
      .maybeSingle()
    if (error) throw new Error(error.message)
    return data
  },

  async listFotos(unidadeId: string, categoria: string): Promise<Foto[]> {
    const { data, error } = await supabase
      .from('fotos_categoria')
      .select('*')
      .eq('id_unidade', unidadeId)
      .eq('categoria', categoria)
      .eq('ativa', true)
      .order('ordem')
    if (error) throw new Error(error.message)
    return data ?? []
  },

  async listExtras(marcaId: string): Promise<Extra[]> {
    const { data, error } = await supabase
      .from('extras')
      .select('*')
      .eq('id_marca', marcaId)
      .eq('ativo', true)
      .order('ordem')
    if (error) throw new Error(error.message)
    return data ?? []
  },
}
  • Step 2: Typecheck
pnpm typecheck
  • Step 3: Commit
git add src/services/catalogoService.ts
git commit -m "feat: catalogoService com queries de marcas/unidades/precos/fotos/extras"

Task C4: Hook useReservationForm (estado consolidado)

Files:

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

  • Step 1: Criar

import { useEffect, useState, useCallback } from 'react'
import { catalogoService } from '@/services/catalogoService'
import type { Database } from '@/types/database'

type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row']
type Preco = Database['reserva_hotel']['Tables']['precos']['Row']
type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']

export interface ReservationFormState {
  marcaId: string
  unidadeId: string
  permanencia: string
  categoria: string
  checkinAt: string // datetime-local format
  nome: string
  telefone: string
  cpf: string
  email: string
  observacao: string
}

const empty: ReservationFormState = {
  marcaId: '',
  unidadeId: '',
  permanencia: '',
  categoria: '',
  checkinAt: '',
  nome: '',
  telefone: '',
  cpf: '',
  email: '',
  observacao: '',
}

export function useReservationForm() {
  const [form, setForm] = useState<ReservationFormState>(empty)
  const [marcas, setMarcas] = useState<Marca[]>([])
  const [unidades, setUnidades] = useState<Unidade[]>([])
  const [preco, setPreco] = useState<Preco | null>(null)
  const [fotos, setFotos] = useState<Foto[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  // Carregar marcas uma vez
  useEffect(() => {
    catalogoService
      .listMarcas()
      .then(setMarcas)
      .catch((err: Error) => setError(err.message))
  }, [])

  // Quando muda marca, buscar unidades
  useEffect(() => {
    if (!form.marcaId) {
      setUnidades([])
      return
    }
    catalogoService
      .listUnidades(form.marcaId)
      .then(setUnidades)
      .catch((err: Error) => setError(err.message))
  }, [form.marcaId])

  // Quando tem marca + categoria + permanência, buscar preço
  useEffect(() => {
    if (!form.marcaId || !form.categoria || !form.permanencia) {
      setPreco(null)
      return
    }
    catalogoService
      .findPreco(form.marcaId, form.categoria, form.permanencia)
      .then(setPreco)
      .catch((err: Error) => setError(err.message))
  }, [form.marcaId, form.categoria, form.permanencia])

  // Quando tem unidade + categoria, buscar fotos
  useEffect(() => {
    if (!form.unidadeId || !form.categoria) {
      setFotos([])
      return
    }
    catalogoService
      .listFotos(form.unidadeId, form.categoria)
      .then(setFotos)
      .catch((err: Error) => setError(err.message))
  }, [form.unidadeId, form.categoria])

  const update = useCallback(<K extends keyof ReservationFormState>(
    key: K,
    value: ReservationFormState[K]
  ) => {
    setForm((prev) => {
      const next = { ...prev, [key]: value }
      // Reset em cascata
      if (key === 'marcaId') {
        next.unidadeId = ''
        next.categoria = ''
        next.permanencia = ''
      }
      if (key === 'unidadeId') {
        next.categoria = ''
      }
      return next
    })
  }, [])

  const marcaSelecionada = marcas.find((m) => m.id === form.marcaId) ?? null
  const unidadeSelecionada = unidades.find((u) => u.id === form.unidadeId) ?? null

  const precoTotalCents = preco ? Math.round(Number(preco.valor) * 100) : 0
  const depositCents = Math.round(precoTotalCents / 2)

  const canSubmit =
    form.marcaId !== '' &&
    form.unidadeId !== '' &&
    form.categoria !== '' &&
    form.permanencia !== '' &&
    form.checkinAt !== '' &&
    form.nome.trim() !== '' &&
    form.telefone.replace(/\D/g, '').length >= 10 &&
    form.cpf.replace(/\D/g, '').length === 11 &&
    preco !== null

  return {
    form,
    update,
    marcas,
    unidades,
    marcaSelecionada,
    unidadeSelecionada,
    preco,
    precoTotalCents,
    depositCents,
    fotos,
    loading,
    setLoading,
    error,
    setError,
    canSubmit,
    reset: () => setForm(empty),
  }
}
  • Step 2: Typecheck e commit
pnpm typecheck
git add src/hooks/useReservationForm.ts
git commit -m "feat: hook useReservationForm com estado em cascata"

Task C5: Componente StayDetailsStep (form em cascata)

Files:

  • Create: reserva-1001/src/components/reservation/StayDetailsStep.tsx

  • Step 1: Criar

import { SelectField } from '@/components/SelectField'
import { FormField } from '@/components/FormField'
import type { ReservationFormState } from '@/hooks/useReservationForm'
import type { Database } from '@/types/database'

type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row']

interface Props {
  form: ReservationFormState
  marcas: Marca[]
  unidades: Unidade[]
  onChange: <K extends keyof ReservationFormState>(k: K, v: ReservationFormState[K]) => void
}

export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
  const marca = marcas.find((m) => m.id === form.marcaId) ?? null
  const unidade = unidades.find((u) => u.id === form.unidadeId) ?? null

  const categoriaOptions =
    unidade?.categorias_visiveis?.map((c) => ({ value: c, label: c })) ??
    marca?.categorias?.map((c) => ({ value: c, label: c })) ??
    []

  const permanenciaOptions =
    marca?.permanencias?.map((p) => ({ value: p, label: p })) ?? []

  return (
    <section className="space-y-6 rounded-2xl border border-champagne/20 bg-midnight/50 p-6 backdrop-blur">
      <h2 className="font-serif text-2xl text-champagne">Detalhes da estadia</h2>

      <SelectField
        label="Marca"
        required
        value={form.marcaId}
        onChange={(e) => onChange('marcaId', e.target.value)}
        options={marcas.map((m) => ({ value: m.id, label: m.nome }))}
      />

      <SelectField
        label="Unidade do Hotel"
        required
        disabled={!form.marcaId}
        value={form.unidadeId}
        onChange={(e) => onChange('unidadeId', e.target.value)}
        options={unidades.map((u) => ({ value: u.id, label: u.nome }))}
      />

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <SelectField
          label="Permanência"
          required
          disabled={!form.marcaId}
          value={form.permanencia}
          onChange={(e) => onChange('permanencia', e.target.value)}
          options={permanenciaOptions}
        />

        <SelectField
          label="Categoria da suíte"
          required
          disabled={!form.unidadeId}
          value={form.categoria}
          onChange={(e) => onChange('categoria', e.target.value)}
          options={categoriaOptions}
        />
      </div>

      <FormField
        label="Data e horário do check-in"
        required
        type="datetime-local"
        value={form.checkinAt}
        onChange={(e) => onChange('checkinAt', e.target.value)}
      />
    </section>
  )
}
  • Step 2: Typecheck + commit
pnpm typecheck
git add src/components/reservation/StayDetailsStep.tsx
git commit -m "feat: StayDetailsStep com selects em cascata"

Task C6: ImageGallery e PriceSummary

Files:

  • Create: reserva-1001/src/components/reservation/ImageGallery.tsx

  • Create: reserva-1001/src/components/reservation/PriceSummary.tsx

  • Step 1: ImageGallery

Create src/components/reservation/ImageGallery.tsx:

import type { Database } from '@/types/database'

type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']

interface Props {
  fotos: Foto[]
}

export function ImageGallery({ fotos }: Props) {
  if (fotos.length === 0) return null

  return (
    <section className="grid grid-cols-1 md:grid-cols-2 gap-3">
      {fotos.map((foto) => (
        <div
          key={foto.id}
          className="aspect-video overflow-hidden rounded-xl border border-champagne/20"
        >
          <img
            src={foto.url_foto}
            alt={foto.alt ?? 'Foto da suíte'}
            className="h-full w-full object-cover transition duration-500 hover:scale-105"
          />
        </div>
      ))}
    </section>
  )
}
  • Step 2: PriceSummary

Create src/components/reservation/PriceSummary.tsx:

import { formatBRL } from '@/lib/formatters'

interface Props {
  totalCents: number
  depositCents: number
}

export function PriceSummary({ totalCents, depositCents }: Props) {
  if (totalCents === 0) return null
  const restante = totalCents - depositCents

  return (
    <section className="rounded-2xl border border-champagne/30 bg-midnight/60 p-6 backdrop-blur">
      <div className="flex items-baseline justify-between mb-3">
        <span className="text-slate text-sm uppercase tracking-widest">Preço estimado</span>
        <span className="font-serif text-3xl text-champagne">{formatBRL(totalCents)}</span>
      </div>
      <div className="flex items-baseline justify-between text-slate text-sm mb-2">
        <span>Pagar no check-in</span>
        <span>{formatBRL(restante)}</span>
      </div>
      <div className="mt-4 flex items-baseline justify-between rounded-xl border border-champagne/40 bg-champagne/10 px-4 py-3">
        <div>
          <div className="text-xs uppercase tracking-widest text-champagne">Entrada via PIX (50%)</div>
          <div className="text-slate text-xs">Necessário para confirmar</div>
        </div>
        <div className="font-serif text-3xl text-gradient-gold">{formatBRL(depositCents)}</div>
      </div>
    </section>
  )
}
  • Step 3: Typecheck + commit
pnpm typecheck
git add src/components/reservation/ImageGallery.tsx src/components/reservation/PriceSummary.tsx
git commit -m "feat: ImageGallery + PriceSummary"

Task C7: CustomerForm

Files:

  • Create: reserva-1001/src/components/reservation/CustomerForm.tsx

  • Step 1: Criar

import { FormField } from '@/components/FormField'
import { maskCPF, maskPhone } from '@/lib/formatters'
import type { ReservationFormState } from '@/hooks/useReservationForm'

interface Props {
  form: ReservationFormState
  onChange: <K extends keyof ReservationFormState>(k: K, v: ReservationFormState[K]) => void
}

export function CustomerForm({ form, onChange }: Props) {
  return (
    <section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/50 p-6 backdrop-blur">
      <h2 className="font-serif text-2xl text-champagne">Seus dados</h2>

      <FormField
        label="Nome completo"
        required
        placeholder="Como no documento"
        value={form.nome}
        onChange={(e) => onChange('nome', e.target.value)}
      />

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <FormField
          label="Telefone / WhatsApp"
          required
          placeholder="(99) 99999-9999"
          value={form.telefone}
          onChange={(e) => onChange('telefone', maskPhone(e.target.value))}
        />
        <FormField
          label="CPF"
          required
          placeholder="000.000.000-00"
          value={form.cpf}
          onChange={(e) => onChange('cpf', maskCPF(e.target.value))}
        />
      </div>

      <FormField
        label="E-mail"
        type="email"
        placeholder="seu@email.com"
        value={form.email}
        onChange={(e) => onChange('email', e.target.value)}
      />

      <div>
        <label className="font-sans text-xs uppercase tracking-widest text-champagne">
          Observação (opcional)
        </label>
        <textarea
          className="mt-2 w-full rounded-lg border border-champagne/30 bg-midnight/60 px-4 py-3 font-sans text-ivory placeholder:text-slate focus:border-champagne focus:outline-none"
          rows={3}
          placeholder="Alguma preferência especial?"
          value={form.observacao}
          onChange={(e) => onChange('observacao', e.target.value)}
        />
      </div>
    </section>
  )
}
  • Step 2: Typecheck + commit
pnpm typecheck
git add src/components/reservation/CustomerForm.tsx
git commit -m "feat: CustomerForm com mascaras CPF e telefone"

Task C8: PixCheckout com polling

Files:

  • Create: reserva-1001/src/components/checkout/PixCheckout.tsx

Primeiro, instalar uma lib de QR code leve:

  • Step 1: Instalar qrcode.react
cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
pnpm add qrcode.react
  • Step 2: Criar o componente
import { useEffect, useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { chatwootApi, type CreateReservationResponse } from '@/lib/chatwootApi'
import { Button } from '@/components/ui/button'
import { formatBRL } from '@/lib/formatters'

interface Props {
  reservation: CreateReservationResponse
  depositCents: number
  onPaid: () => void
  onCancel: () => void
}

export function PixCheckout({ reservation, depositCents, onPaid, onCancel }: Props) {
  const [copied, setCopied] = useState(false)
  const [statusMsg, setStatusMsg] = useState<string>('Aguardando pagamento...')

  useEffect(() => {
    let canceled = false
    const interval = setInterval(async () => {
      try {
        const s = await chatwootApi.getStatus(reservation.reservation_id)
        if (canceled) return
        if (s.status === 'paid') {
          setStatusMsg('Pagamento confirmado!')
          clearInterval(interval)
          onPaid()
        } else if (s.status === 'expired' || s.status === 'canceled') {
          setStatusMsg(`Reserva ${s.status}`)
          clearInterval(interval)
        }
      } catch (err) {
        console.error('Erro no polling:', err)
      }
    }, 3000)

    return () => {
      canceled = true
      clearInterval(interval)
    }
  }, [reservation.reservation_id, onPaid])

  const handleCopy = async () => {
    await navigator.clipboard.writeText(reservation.pix.copia_e_cola)
    setCopied(true)
    setTimeout(() => setCopied(false), 2000)
  }

  return (
    <section className="mx-auto max-w-md space-y-6 rounded-2xl border border-champagne/30 bg-midnight/60 p-8 backdrop-blur">
      <header className="text-center">
        <p className="text-xs uppercase tracking-[0.3em] text-rose-gold">Pagamento</p>
        <h2 className="mt-2 font-serif text-3xl text-gradient-gold">
          {formatBRL(depositCents)}
        </h2>
        <p className="mt-1 text-sm text-slate">{statusMsg}</p>
      </header>

      <div className="mx-auto flex w-fit items-center justify-center rounded-xl border border-champagne/40 bg-ivory p-4 glow-champagne">
        <QRCodeSVG value={reservation.pix.copia_e_cola} size={220} level="M" />
      </div>

      <div>
        <label className="text-xs uppercase tracking-widest text-champagne">
          Pix copia-e-cola
        </label>
        <div className="mt-2 flex items-stretch gap-2">
          <input
            readOnly
            value={reservation.pix.copia_e_cola}
            className="flex-1 truncate rounded-lg border border-champagne/30 bg-obsidian/60 px-3 py-2 text-xs text-ivory"
          />
          <Button variant="secondary" size="sm" onClick={handleCopy}>
            {copied ? 'Copiado!' : 'Copiar'}
          </Button>
        </div>
      </div>

      <p className="text-center text-xs text-slate">
        Expira em 1h. Após o pagamento, esta tela atualiza automaticamente.
      </p>

      <Button variant="ghost" size="sm" onClick={onCancel} className="w-full">
        Cancelar e voltar
      </Button>
    </section>
  )
}
  • Step 3: Typecheck + commit
pnpm typecheck
git add src/components/checkout/PixCheckout.tsx package.json pnpm-lock.yaml
git commit -m "feat: PixCheckout com QR code e polling de status"

Task C9: SuccessScreen

Files:

  • Create: reserva-1001/src/components/checkout/SuccessScreen.tsx

  • Step 1: Criar

import { Button } from '@/components/ui/button'

interface Props {
  onRestart: () => void
}

export function SuccessScreen({ onRestart }: Props) {
  return (
    <section className="mx-auto max-w-md text-center space-y-6 rounded-2xl border border-emerald/40 bg-emerald/10 p-10 backdrop-blur">
      <div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald text-obsidian text-4xl font-bold">
        
      </div>

      <h2 className="font-serif text-3xl text-ivory">Reserva confirmada!</h2>

      <p className="text-slate">
        O pagamento caiu e sua reserva  está registrada.
        <br />
        Nossa atendente foi avisada e vai confirmar os próximos passos pelo WhatsApp.
      </p>

      <Button variant="primary" size="md" onClick={onRestart} className="w-full">
        Fazer outra reserva
      </Button>
    </section>
  )
}
  • Step 2: Commit
git add src/components/checkout/SuccessScreen.tsx
git commit -m "feat: SuccessScreen apos pagamento confirmado"

Task C10: ReservationFlow + novo App.tsx

Files:

  • Create: reserva-1001/src/components/reservation/ReservationFlow.tsx

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

  • Step 1: ReservationFlow

Create src/components/reservation/ReservationFlow.tsx:

import { useState } from 'react'
import { useReservationForm } from '@/hooks/useReservationForm'
import { chatwootApi, type CreateReservationResponse } from '@/lib/chatwootApi'
import { onlyDigits } from '@/lib/formatters'
import { StayDetailsStep } from './StayDetailsStep'
import { ImageGallery } from './ImageGallery'
import { PriceSummary } from './PriceSummary'
import { CustomerForm } from './CustomerForm'
import { PixCheckout } from '@/components/checkout/PixCheckout'
import { SuccessScreen } from '@/components/checkout/SuccessScreen'
import { Button } from '@/components/ui/button'

type Phase = 'form' | 'checkout' | 'success'

export function ReservationFlow() {
  const {
    form,
    update,
    marcas,
    unidades,
    unidadeSelecionada,
    precoTotalCents,
    depositCents,
    fotos,
    canSubmit,
    reset,
  } = useReservationForm()

  const [phase, setPhase] = useState<Phase>('form')
  const [submitting, setSubmitting] = useState(false)
  const [submitError, setSubmitError] = useState<string | null>(null)
  const [reservation, setReservation] = useState<CreateReservationResponse | null>(null)

  async function handleSubmit() {
    setSubmitting(true)
    setSubmitError(null)
    try {
      if (unidadeSelecionada?.chatwoot_unit_id == null) {
        throw new Error('Unidade não está vinculada ao Chatwoot')
      }

      const res = await chatwootApi.createReservation({
        chatwoot_unit_id: Number(unidadeSelecionada.chatwoot_unit_id),
        category: form.categoria,
        stay_type: form.permanencia,
        checkin_at: new Date(form.checkinAt).toISOString(),
        customer: {
          name: form.nome,
          phone: onlyDigits(form.telefone),
          cpf: onlyDigits(form.cpf),
          email: form.email || undefined,
        },
        total_cents: precoTotalCents,
        deposit_cents: depositCents,
        notes: form.observacao || undefined,
      })

      setReservation(res)
      setPhase('checkout')
    } catch (err) {
      setSubmitError(err instanceof Error ? err.message : 'Erro desconhecido')
    } finally {
      setSubmitting(false)
    }
  }

  function restart() {
    reset()
    setReservation(null)
    setSubmitError(null)
    setPhase('form')
  }

  if (phase === 'success') {
    return <SuccessScreen onRestart={restart} />
  }

  if (phase === 'checkout' && reservation) {
    return (
      <PixCheckout
        reservation={reservation}
        depositCents={depositCents}
        onPaid={() => setPhase('success')}
        onCancel={restart}
      />
    )
  }

  return (
    <div className="mx-auto w-full max-w-2xl space-y-6">
      <StayDetailsStep
        form={form}
        marcas={marcas}
        unidades={unidades}
        onChange={update}
      />

      <ImageGallery fotos={fotos} />

      <PriceSummary totalCents={precoTotalCents} depositCents={depositCents} />

      <CustomerForm form={form} onChange={update} />

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

      <Button
        variant="primary"
        size="lg"
        className="w-full"
        disabled={!canSubmit || submitting}
        onClick={handleSubmit}
      >
        {submitting ? 'Gerando PIX...' : 'Confirmar e pagar reserva'}
      </Button>
    </div>
  )
}
  • Step 2: Reescrever App.tsx

Replace src/App.tsx com:

import { ReservationFlow } from '@/components/reservation/ReservationFlow'

export default function App() {
  return (
    <main className="min-h-screen flex flex-col items-center px-6 py-12">
      <header className="text-center mb-10">
        <p className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4">
          Experiência exclusiva
        </p>
        <h1 className="font-serif text-5xl md:text-6xl text-gradient-gold mb-3">
          Reserva Rede 1001
        </h1>
        <p className="font-sans text-slate text-lg">
          Escolha, confirme e receba seu PIX na hora.
        </p>
      </header>

      <ReservationFlow />

      <footer className="mt-16 text-slate text-xs uppercase tracking-widest">
        © 2026 Reserva Rede 1001 · Experiência Exclusiva
      </footer>
    </main>
  )
}
  • Step 3: Ajustar o smoke test (que testava as marcas no App) pra simplesmente verificar o título

Edit src/__tests__/App.test.tsx — deixar apenas o primeiro teste (de título) e remover os outros 2 que dependiam de App ler marcas diretamente (agora o ReservationFlow é quem lê):

import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import App from '@/App'

// Mock do hook para não precisar de Supabase nos testes
vi.mock('@/hooks/useReservationForm', () => ({
  useReservationForm: () => ({
    form: {
      marcaId: '',
      unidadeId: '',
      permanencia: '',
      categoria: '',
      checkinAt: '',
      nome: '',
      telefone: '',
      cpf: '',
      email: '',
      observacao: '',
    },
    update: vi.fn(),
    marcas: [],
    unidades: [],
    marcaSelecionada: null,
    unidadeSelecionada: null,
    preco: null,
    precoTotalCents: 0,
    depositCents: 0,
    fotos: [],
    loading: false,
    setLoading: vi.fn(),
    error: null,
    setError: vi.fn(),
    canSubmit: false,
    reset: vi.fn(),
  }),
}))

describe('App', () => {
  it('renderiza o titulo premium', () => {
    render(<App />)
    expect(screen.getByRole('heading', { name: /reserva rede 1001/i })).toBeInTheDocument()
  })

  it('renderiza o botao de confirmar', () => {
    render(<App />)
    expect(screen.getByRole('button', { name: /confirmar e pagar reserva/i })).toBeInTheDocument()
  })
})

Adicione também no topo do arquivo de setup vi import se não houver: import { vi } from 'vitest'. E remova o antigo mock de @/lib/supabase do src/__tests__/setup.ts — agora não precisa mais porque mockamos o hook:

Edit src/__tests__/setup.ts:

import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup()
})
  • Step 4: Rodar tudo
pnpm typecheck
pnpm test
pnpm build

Todos devem passar. Se algum teste reclamar de vi is not defined, adicione import { vi } from 'vitest' no topo do arquivo.

  • Step 5: Commit
git add src/components/reservation/ReservationFlow.tsx src/App.tsx src/__tests__/App.test.tsx src/__tests__/setup.ts
git commit -m "feat: ReservationFlow orquestrando form + checkout + sucesso"

Task C11: Teste ponta-a-ponta local (manual)

  • Step 1: Chatwoot rodando em localhost:3000 com o token configurado

  • Step 2: Rodar o reserva-1001

cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001
pnpm dev

Abra http://localhost:5180/.

  • Step 3: Fluxo manual
  1. Selecionar Marca: "Hotel 1001 Noites"
  2. Selecionar Unidade: "Hotel 1001 Águas Lindas"
  3. Selecionar Permanência: "3hrs"
  4. Selecionar Categoria: "Standard"
  5. Escolher data/hora futura
  6. Ver fotos + preço total R$ 120 + PIX 50% R$ 60
  7. Preencher nome, telefone, CPF, email
  8. Clicar "Confirmar e pagar reserva"
  9. Esperado: aparece a tela de checkout com QR code e copia-e-cola
  10. Abrir o Chatwoot em outra aba: deve ter uma nova conversa no inbox 2 com uma nota interna descrevendo a reserva e a mensagem de aguardando pagamento
  11. Se tiver ambiente Inter real: pagar o PIX. A página deve detectar via polling (3s) e mostrar a tela de sucesso. No Chatwoot, a mensagem " Pagamento confirmado!" aparece na conversa.

Se o PIX real falhar (cert, endpoint): validar pelo menos que a tela de checkout renderizou com os dados mockados do que o backend devolveu. Flag pra o user: "Inter real precisa ser testada com credenciais de produção ou sandbox".

  • Step 4: Documentar o que funcionou e o que não

Se tudo OK: tag mental "Fase 2+3 done". Se algo falhou: criar tasks de correção e iterar.


Critérios de conclusão da Fase 2+3

Backend:

  • RESERVA_1001_API_TOKEN documentado no .env.example
  • 8 specs RSpec passando (auth + create + status)
  • Smoke test via curl retorna 201 com PIX

Frontend:

  • pnpm typecheck, pnpm lint, pnpm test, pnpm build todos passam
  • Form em cascata funcional (marca→unidade→permanência→categoria→data)
  • Preço resolvido a partir do Supabase
  • Fotos da categoria exibidas
  • Submit bate no Chatwoot e retorna PIX
  • Checkout mostra QR code + copia-e-cola + polling
  • Tela de sucesso quando status=paid

Integração:

  • Nova conversa criada no Chatwoot ao submeter
  • Nota interna com detalhes da reserva aparece na conversa
  • Após pagamento real (ou mock via SQL update), tela de sucesso aparece
  • Mensagem " Pagamento confirmado!" chega na conversa via ConfirmationService

Próximas fases

  • Fase 4 — Admin: CRUD no reserva-1001 pra cadastrar unidades/categorias/preços/fotos com Supabase Auth
  • Fase 5 — Polish visual: animações, carrossel de fotos, hero, skeletons, confetti no sucesso
  • Fase 6 — Deploy: Vercel + subdomínio + testes de produção