feat: ReservationFlow orquestrando form + checkout + sucesso
This commit is contained in:
parent
ab2e64435e
commit
a4c8b04fed
71
src/App.tsx
71
src/App.tsx
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
}
|
||||
})
|
||||
|
||||
121
src/components/reservation/ReservationFlow.tsx
Normal file
121
src/components/reservation/ReservationFlow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user