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>
62 KiB
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):
- Backend (Chatwoot): controller público novo
Public::Api::V1::Captain::PublicReservationsControllercom 2 endpoints (POST cria reserva + PIX, GET retorna status). Reusa 100% doCaptain::Inter::CobService,ConfirmationServicee webhook Inter existentes. - Frontend (reserva-1001): refatora
App.tsxem fluxo multi-step, adiciona hookuseReservationForm, 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::Unitid=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,statusenum{active, paid, expired, failed}, pertence aReservationeUnit.Captain::Inter::CobService#call— assinatura:initialize(reservation, amount: nil). RetornaCaptain::PixCharge. Atualizareservation.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)
- Credenciais Supabase já em
reserva-1001/.env.local(Fase 1 feita) - Chatwoot dev rodando em
http://localhost:3000(para Fase 2 testar o endpoint via curl e para Fase 3 bater contra) - Captain::Unit id=4 (Hotel 1001 Águas Lindas) é a unidade de teste conhecida, inbox_id=2, account_id=1
- Token de autenticação do endpoint público: vamos definir
RESERVA_1001_API_TOKEN=dev-token-change-in-prodemchatwoot/.env(dev) ereserva-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
.envlocal
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.rbatual 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
statusno 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.locale.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 já 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:3000com 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
- Selecionar Marca: "Hotel 1001 Noites"
- Selecionar Unidade: "Hotel 1001 Águas Lindas"
- Selecionar Permanência: "3hrs"
- Selecionar Categoria: "Standard"
- Escolher data/hora futura
- Ver fotos + preço total R$ 120 + PIX 50% R$ 60
- Preencher nome, telefone, CPF, email
- Clicar "Confirmar e pagar reserva"
- Esperado: aparece a tela de checkout com QR code e copia-e-cola
- 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
- 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_TOKENdocumentado 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 buildtodos 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