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 { ReservationFlow } from '@/components/reservation/ReservationFlow'
|
||||||
import { supabase } from '@/lib/supabase'
|
|
||||||
import type { Database } from '@/types/database'
|
|
||||||
|
|
||||||
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
|
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen flex flex-col items-center justify-center px-6 py-12">
|
<main className="min-h-screen flex flex-col items-center px-6 py-12">
|
||||||
<header className="text-center mb-12">
|
<header className="text-center mb-10">
|
||||||
<p className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4">
|
<p className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4">
|
||||||
Experiência exclusiva
|
Experiência exclusiva
|
||||||
</p>
|
</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
|
Reserva Rede 1001
|
||||||
</h1>
|
</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>
|
</header>
|
||||||
|
|
||||||
{loading && <p className="text-slate">Carregando marcas...</p>}
|
<ReservationFlow />
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="mt-16 text-slate text-xs uppercase tracking-widest">
|
<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>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,25 +1,48 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect } from 'vitest'
|
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'
|
import App from '@/App'
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('renderiza título premium', () => {
|
it('renderiza o titulo premium', () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
expect(screen.getByRole('heading', { name: /reserva rede 1001/i })).toBeInTheDocument()
|
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 />)
|
render(<App />)
|
||||||
await waitFor(() => {
|
expect(screen.getByRole('button', { name: /confirmar e pagar reserva/i })).toBeInTheDocument()
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,42 +1,7 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react'
|
||||||
import { afterEach, vi } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
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