feat: ReservationFlow orquestrando form + checkout + sucesso

This commit is contained in:
Rodribm10 2026-04-14 00:00:21 -03:00
parent ab2e64435e
commit a4c8b04fed
4 changed files with 169 additions and 113 deletions

View File

@ -1,77 +1,24 @@
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import type { Database } from '@/types/database'
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
import { ReservationFlow } from '@/components/reservation/ReservationFlow'
export default function App() {
const [marcas, setMarcas] = useState<Marca[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function loadMarcas() {
const { data, error } = await supabase
.from('marcas')
.select('*')
.eq('ativa', true)
.order('nome', { ascending: true })
if (error) {
setError(error.message)
} else {
setMarcas(data ?? [])
}
setLoading(false)
}
loadMarcas()
}, [])
return (
<main className="min-h-screen flex flex-col items-center justify-center px-6 py-12">
<header className="text-center mb-12">
<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-6xl md:text-7xl text-gradient-gold mb-4">
<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 uma das nossas marcas para começar</p>
<p className="font-sans text-slate text-lg">
Escolha, confirme e receba seu PIX na hora.
</p>
</header>
{loading && <p className="text-slate">Carregando marcas...</p>}
{error && (
<div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory">
Erro ao carregar: {error}
</div>
)}
{!loading && !error && marcas.length === 0 && (
<p className="text-slate">Nenhuma marca cadastrada ainda.</p>
)}
{!loading && !error && marcas.length > 0 && (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-2xl">
{marcas.map((marca) => (
<li
key={marca.id}
className="rounded-xl border border-champagne/20 bg-midnight/60 backdrop-blur p-6 text-center transition hover:border-champagne hover:glow-champagne"
>
<h2 className="font-serif text-2xl text-champagne">{marca.nome}</h2>
{marca.descricao && <p className="text-slate text-sm mt-1">{marca.descricao}</p>}
{marca.categorias && marca.categorias.length > 0 && (
<p className="text-rose-gold text-xs mt-2 uppercase tracking-widest">
{marca.categorias.join(' · ')}
</p>
)}
</li>
))}
</ul>
)}
<ReservationFlow />
<footer className="mt-16 text-slate text-xs uppercase tracking-widest">
© 2026 Reserva Rede 1001 · Fase 1 · Fundação
© 2026 Reserva Rede 1001 · Experiência Exclusiva
</footer>
</main>
)

View File

@ -1,25 +1,48 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
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(),
}),
}))
import App from '@/App'
describe('App', () => {
it('renderiza título premium', () => {
it('renderiza o titulo premium', () => {
render(<App />)
expect(screen.getByRole('heading', { name: /reserva rede 1001/i })).toBeInTheDocument()
})
it('exibe marcas retornadas do supabase após carregar', async () => {
it('renderiza o botao de confirmar', () => {
render(<App />)
await waitFor(() => {
expect(screen.getByText('Hotel 1001 Noites')).toBeInTheDocument()
expect(screen.getByText('Dolce Amore')).toBeInTheDocument()
})
})
it('não mostra estado de loading após fetch completar', async () => {
render(<App />)
await waitFor(() => {
expect(screen.queryByText(/carregando marcas/i)).not.toBeInTheDocument()
})
expect(screen.getByRole('button', { name: /confirmar e pagar reserva/i })).toBeInTheDocument()
})
})

View File

@ -1,42 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
// Mock do Supabase client — imita from('marcas').select('*').eq('ativa', true).order('nome')
vi.mock('@/lib/supabase', () => {
const mockMarcas = [
{
id: '3fac5ed4-100f-4c0a-82ce-06110758b9c9',
nome: 'Hotel 1001 Noites',
categorias: ['Standard', 'Superior', 'Luxo'],
permanencias: ['3hrs', '6hrs', 'Pernoite'],
descricao: null,
ativa: true,
created_at: '2026-04-13T00:00:00Z',
updated_at: '2026-04-13T00:00:00Z',
},
{
id: '11111111-1111-1111-1111-111111111111',
nome: 'Dolce Amore',
categorias: ['Suite Master', 'Apartamento'],
permanencias: ['3hrs', '4hrs'],
descricao: null,
ativa: true,
created_at: '2026-04-13T00:00:00Z',
updated_at: '2026-04-13T00:00:00Z',
},
]
const orderFn = vi.fn(() => Promise.resolve({ data: mockMarcas, error: null }))
const eqFn = vi.fn(() => ({ order: orderFn }))
const selectFn = vi.fn(() => ({ eq: eqFn, order: orderFn }))
const fromFn = vi.fn(() => ({ select: selectFn }))
return {
supabase: { from: fromFn },
}
})

View File

@ -0,0 +1,121 @@
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>
)
}