feat(fase5): polish visual - hero stagger, carrossel+lightbox, skeletons, confetti, transicoes

- HeroSection com reveal escalonado (framer-motion)
- ImageGallery com embla-carousel + lightbox full-screen + navegacao por teclado
- PriceSummary com pulse anime.js quando preco muda
- PixCheckout com QR code glow pulsante infinito
- SuccessScreen com confetti + check SVG desenhado
- Skeleton component + shimmer keyframe
- Button com active:scale press feedback
- ReservationFlow com AnimatePresence entre phases
- StayDetailsStep com stagger container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-14 22:10:57 -03:00
parent a0ee24937c
commit 25abaa807c
13 changed files with 502 additions and 126 deletions

View File

@ -18,8 +18,10 @@
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@supabase/supabase-js": "^2.45.0", "@supabase/supabase-js": "^2.45.0",
"animejs": "^4.0.2", "animejs": "^4.0.2",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"motion": "^12.4.0", "motion": "^12.4.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
@ -34,6 +36,7 @@
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.5.0", "@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",

44
pnpm-lock.yaml generated
View File

@ -17,12 +17,18 @@ importers:
animejs: animejs:
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.3.6 version: 4.3.6
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.1 version: 0.7.1
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.5)
lucide-react: lucide-react:
specifier: ^0.454.0 specifier: ^0.454.0
version: 0.454.0(react@19.2.5) version: 0.454.0(react@19.2.5)
@ -60,6 +66,9 @@ importers:
'@testing-library/react': '@testing-library/react':
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/node': '@types/node':
specifier: ^22.9.0 specifier: ^22.9.0
version: 22.19.17 version: 22.19.17
@ -768,6 +777,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -976,6 +988,9 @@ packages:
caniuse-lite@1.0.30001787: caniuse-lite@1.0.30001787:
resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chai@5.3.3: chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1078,6 +1093,19 @@ packages:
electron-to-chromium@1.5.336: electron-to-chromium@1.5.336:
resolution: {integrity: sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==} resolution: {integrity: sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==}
embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
embla-carousel-reactive-utils@8.6.0:
resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==}
peerDependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
enhanced-resolve@5.20.1: enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -2514,6 +2542,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@types/canvas-confetti@1.9.0': {}
'@types/chai@5.2.3': '@types/chai@5.2.3':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
@ -2757,6 +2787,8 @@ snapshots:
caniuse-lite@1.0.30001787: {} caniuse-lite@1.0.30001787: {}
canvas-confetti@1.9.4: {}
chai@5.3.3: chai@5.3.3:
dependencies: dependencies:
assertion-error: 2.0.1 assertion-error: 2.0.1
@ -2842,6 +2874,18 @@ snapshots:
electron-to-chromium@1.5.336: {} electron-to-chromium@1.5.336: {}
embla-carousel-react@8.6.0(react@19.2.5):
dependencies:
embla-carousel: 8.6.0
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
react: 19.2.5
embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0):
dependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0: {}
enhanced-resolve@5.20.1: enhanced-resolve@5.20.1:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { motion } from 'motion/react'
import { chatwootApi, type CreateReservationResponse } from '@/lib/chatwootApi' import { chatwootApi, type CreateReservationResponse } from '@/lib/chatwootApi'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { formatBRL } from '@/lib/formatters' import { formatBRL } from '@/lib/formatters'
@ -56,9 +57,19 @@ export function PixCheckout({ reservation, depositCents, onPaid, onCancel }: Pro
<p className="mt-1 text-sm text-slate">{statusMsg}</p> <p className="mt-1 text-sm text-slate">{statusMsg}</p>
</header> </header>
<div className="mx-auto flex w-fit items-center justify-center rounded-xl border border-champagne/40 bg-ivory p-4 glow-champagne"> <motion.div
className="mx-auto flex w-fit items-center justify-center rounded-xl border border-champagne/40 bg-ivory p-4"
animate={{
boxShadow: [
'0 0 30px rgba(201, 169, 97, 0.4)',
'0 0 60px rgba(201, 169, 97, 0.7)',
'0 0 30px rgba(201, 169, 97, 0.4)',
],
}}
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
>
<QRCodeSVG value={reservation.pix.copia_e_cola} size={220} level="M" /> <QRCodeSVG value={reservation.pix.copia_e_cola} size={220} level="M" />
</div> </motion.div>
<div> <div>
<label className="text-xs uppercase tracking-widest text-champagne"> <label className="text-xs uppercase tracking-widest text-champagne">

View File

@ -1,3 +1,6 @@
import { useEffect } from 'react'
import confetti from 'canvas-confetti'
import { motion } from 'motion/react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
interface Props { interface Props {
@ -5,23 +8,70 @@ interface Props {
} }
export function SuccessScreen({ onRestart }: Props) { export function SuccessScreen({ onRestart }: Props) {
useEffect(() => {
const colors = ['#C9A961', '#E8B4A0', '#10B981', '#F5F1E8']
confetti({ particleCount: 100, spread: 90, origin: { y: 0.6 }, colors })
setTimeout(() => confetti({ particleCount: 60, spread: 120, origin: { y: 0.6 }, colors }), 250)
setTimeout(() => confetti({ particleCount: 80, spread: 100, origin: { y: 0.6 }, colors }), 500)
}, [])
return ( 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"> <motion.section
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald text-obsidian text-4xl font-bold"> initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
</div> transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }}
className="mx-auto max-w-md text-center space-y-6 rounded-2xl border border-emerald/40 bg-emerald/10 p-10 backdrop-blur"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 180, damping: 12 }}
className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald"
>
<svg viewBox="0 0 52 52" className="h-12 w-12">
<motion.path
d="M14 27 L23 36 L40 18"
fill="none"
stroke="#0b0d12"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ delay: 0.5, duration: 0.55, ease: 'easeOut' }}
/>
</svg>
</motion.div>
<h2 className="font-serif text-3xl text-ivory">Reserva confirmada!</h2> <motion.h2
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="font-serif text-3xl text-ivory"
>
Reserva confirmada!
</motion.h2>
<p className="text-slate"> <motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.75 }}
className="text-slate"
>
O pagamento caiu e sua reserva está registrada. O pagamento caiu e sua reserva está registrada.
<br /> <br />
Nossa atendente foi avisada e vai confirmar os próximos passos pelo WhatsApp. Nossa atendente foi avisada e vai confirmar os próximos passos pelo WhatsApp.
</p> </motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.9 }}
>
<Button variant="primary" size="md" onClick={onRestart} className="w-full"> <Button variant="primary" size="md" onClick={onRestart} className="w-full">
Fazer outra reserva Fazer outra reserva
</Button> </Button>
</section> </motion.div>
</motion.section>
) )
} }

View File

@ -0,0 +1,52 @@
import { motion } from 'motion/react'
import type { Database } from '@/types/database'
type AppConfig = Database['reserva_hotel']['Tables']['app_config']['Row']
interface Props {
config: AppConfig
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.18, delayChildren: 0.1 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] } },
}
export function HeroSection({ config }: Props) {
return (
<motion.header
className="text-center mb-10"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{config.subtitulo_hero && (
<motion.p
variants={itemVariants}
className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4"
>
{config.subtitulo_hero}
</motion.p>
)}
<motion.h1
variants={itemVariants}
className="font-serif text-5xl md:text-6xl text-gradient-gold mb-3"
>
{config.titulo_hero}
</motion.h1>
{config.tagline && (
<motion.p variants={itemVariants} className="font-sans text-slate text-lg">
{config.tagline}
</motion.p>
)}
</motion.header>
)
}

View File

@ -1,3 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import { motion, AnimatePresence } from 'motion/react'
import type { Database } from '@/types/database' import type { Database } from '@/types/database'
type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row'] type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']
@ -7,22 +10,134 @@ interface Props {
} }
export function ImageGallery({ fotos }: Props) { export function ImageGallery({ fotos }: Props) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: 'center' })
const [selectedIdx, setSelectedIdx] = useState(0)
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null)
const onSelect = useCallback(() => {
if (!emblaApi) return
setSelectedIdx(emblaApi.selectedScrollSnap())
}, [emblaApi])
useEffect(() => {
if (!emblaApi) return
emblaApi.on('select', onSelect)
onSelect()
return () => {
emblaApi.off('select', onSelect)
}
}, [emblaApi, onSelect])
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (lightboxIdx === null) return
if (e.key === 'Escape') setLightboxIdx(null)
if (e.key === 'ArrowLeft')
setLightboxIdx((idx) => (idx !== null && idx > 0 ? idx - 1 : idx))
if (e.key === 'ArrowRight')
setLightboxIdx((idx) => (idx !== null && idx < fotos.length - 1 ? idx + 1 : idx))
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [lightboxIdx, fotos.length])
if (fotos.length === 0) return null if (fotos.length === 0) return null
return ( return (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3"> <>
{fotos.map((foto) => ( <section className="space-y-3">
<div <div className="overflow-hidden rounded-2xl border border-champagne/20" ref={emblaRef}>
<div className="flex">
{fotos.map((foto, idx) => (
<button
type="button"
key={foto.id} key={foto.id}
className="aspect-video overflow-hidden rounded-xl border border-champagne/20" className="relative flex-[0_0_100%] aspect-video overflow-hidden"
onClick={() => setLightboxIdx(idx)}
aria-label={`Abrir foto ${idx + 1}`}
> >
<img <img
src={foto.url_foto} src={foto.url_foto}
alt={foto.alt ?? 'Foto da suíte'} alt={foto.alt ?? `Foto ${idx + 1}`}
className="h-full w-full object-cover transition duration-500 hover:scale-105" className="h-full w-full object-cover transition duration-500 hover:scale-105"
/> />
</div> </button>
))} ))}
</div>
</div>
{fotos.length > 1 && (
<div className="flex justify-center gap-2">
{fotos.map((_, idx) => (
<button
key={idx}
type="button"
aria-label={`Ir para foto ${idx + 1}`}
className={`h-1.5 w-8 rounded-full transition ${
idx === selectedIdx ? 'bg-champagne' : 'bg-champagne/20'
}`}
onClick={() => emblaApi?.scrollTo(idx)}
/>
))}
</div>
)}
</section> </section>
<AnimatePresence>
{lightboxIdx !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-obsidian/95 backdrop-blur p-4"
onClick={() => setLightboxIdx(null)}
>
<button
type="button"
className="absolute top-6 right-6 text-ivory text-3xl hover:text-champagne"
onClick={() => setLightboxIdx(null)}
aria-label="Fechar"
>
×
</button>
{lightboxIdx > 0 && (
<button
type="button"
className="absolute left-6 text-ivory text-4xl hover:text-champagne"
onClick={(e) => {
e.stopPropagation()
setLightboxIdx(lightboxIdx - 1)
}}
aria-label="Anterior"
>
</button>
)}
{lightboxIdx < fotos.length - 1 && (
<button
type="button"
className="absolute right-6 text-ivory text-4xl hover:text-champagne"
onClick={(e) => {
e.stopPropagation()
setLightboxIdx(lightboxIdx + 1)
}}
aria-label="Próxima"
>
</button>
)}
<motion.img
key={lightboxIdx}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
src={fotos[lightboxIdx].url_foto}
alt={fotos[lightboxIdx].alt ?? ''}
className="max-h-[90vh] max-w-[90vw] object-contain rounded-xl"
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
) )
} }

View File

@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react'
import { animate } from 'animejs'
import { formatBRL } from '@/lib/formatters' import { formatBRL } from '@/lib/formatters'
interface Props { interface Props {
@ -6,11 +8,21 @@ interface Props {
} }
export function PriceSummary({ totalCents, depositCents }: Props) { export function PriceSummary({ totalCents, depositCents }: Props) {
const ref = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!ref.current || totalCents === 0) return
animate(ref.current, { scale: [1, 1.06, 1], duration: 400, easing: 'easeOutQuad' })
}, [totalCents])
if (totalCents === 0) return null if (totalCents === 0) return null
const restante = totalCents - depositCents const restante = totalCents - depositCents
return ( return (
<section className="rounded-2xl border border-champagne/30 bg-midnight/60 p-6 backdrop-blur"> <section
ref={ref}
className="rounded-2xl border border-champagne/30 bg-midnight/60 p-6 backdrop-blur"
>
<div className="flex items-baseline justify-between mb-3"> <div className="flex items-baseline justify-between mb-3">
<span className="text-slate text-sm uppercase tracking-widest">Preço estimado</span> <span className="text-slate text-sm uppercase tracking-widest">Preço estimado</span>
<span className="font-serif text-3xl text-champagne">{formatBRL(totalCents)}</span> <span className="font-serif text-3xl text-champagne">{formatBRL(totalCents)}</span>

View File

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
import { useReservationForm } from '@/hooks/useReservationForm' import { useReservationForm } from '@/hooks/useReservationForm'
import { parsePrefillFromURL } from '@/lib/prefill' import { parsePrefillFromURL } from '@/lib/prefill'
@ -16,6 +17,12 @@ import { Button } from '@/components/ui/button'
type Phase = 'form' | 'checkout' | 'success' type Phase = 'form' | 'checkout' | 'success'
const phaseVariants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
}
export function ReservationFlow() { export function ReservationFlow() {
const { const {
form, form,
@ -75,23 +82,49 @@ export function ReservationFlow() {
setPhase('form') setPhase('form')
} }
if (phase === 'success') {
return <SuccessScreen onRestart={restart} />
}
if (phase === 'checkout' && reservation) {
return ( return (
<AnimatePresence mode="wait">
{phase === 'success' && (
<motion.div
key="success"
variants={phaseVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<SuccessScreen onRestart={restart} />
</motion.div>
)}
{phase === 'checkout' && reservation && (
<motion.div
key="checkout"
variants={phaseVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<PixCheckout <PixCheckout
reservation={reservation} reservation={reservation}
depositCents={depositCents} depositCents={depositCents}
onPaid={() => setPhase('success')} onPaid={() => setPhase('success')}
onCancel={restart} onCancel={restart}
/> />
) </motion.div>
} )}
return ( {phase === 'form' && (
<div className="mx-auto w-full max-w-2xl space-y-6"> <motion.div
key="form"
variants={phaseVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
className="mx-auto w-full max-w-2xl space-y-6"
>
<StayDetailsStep <StayDetailsStep
form={form} form={form}
marcas={marcas} marcas={marcas}
@ -120,6 +153,8 @@ export function ReservationFlow() {
> >
{submitting ? 'Gerando PIX...' : 'Confirmar e pagar reserva'} {submitting ? 'Gerando PIX...' : 'Confirmar e pagar reserva'}
</Button> </Button>
</div> </motion.div>
)}
</AnimatePresence>
) )
} }

View File

@ -1,5 +1,7 @@
import { motion } from 'motion/react'
import { SelectField } from '@/components/SelectField' import { SelectField } from '@/components/SelectField'
import { FormField } from '@/components/FormField' import { FormField } from '@/components/FormField'
import { Skeleton } from '@/components/ui/skeleton'
import type { ReservationFormState } from '@/hooks/useReservationForm' import type { ReservationFormState } from '@/hooks/useReservationForm'
import type { Database } from '@/types/database' import type { Database } from '@/types/database'
@ -13,7 +15,35 @@ interface Props {
onChange: <K extends keyof ReservationFormState>(k: K, v: ReservationFormState[K]) => void onChange: <K extends keyof ReservationFormState>(k: K, v: ReservationFormState[K]) => void
} }
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1, delayChildren: 0.05 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] } },
}
export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) { export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
if (marcas.length === 0) {
return (
<section className="space-y-6 rounded-2xl border border-champagne/20 bg-midnight/50 p-6 backdrop-blur">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
<Skeleton className="h-12 w-full" />
</section>
)
}
const marca = marcas.find((m) => m.id === form.marcaId) ?? null const marca = marcas.find((m) => m.id === form.marcaId) ?? null
const unidade = unidades.find((u) => u.id === form.unidadeId) ?? null const unidade = unidades.find((u) => u.id === form.unidadeId) ?? null
@ -26,9 +56,17 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
marca?.permanencias?.map((p) => ({ value: p, label: p })) ?? [] marca?.permanencias?.map((p) => ({ value: p, label: p })) ?? []
return ( return (
<section className="space-y-6 rounded-2xl border border-champagne/20 bg-midnight/50 p-6 backdrop-blur"> <motion.section
<h2 className="font-serif text-2xl text-champagne">Detalhes da estadia</h2> className="space-y-6 rounded-2xl border border-champagne/20 bg-midnight/50 p-6 backdrop-blur"
variants={containerVariants}
initial="hidden"
animate="visible"
>
<motion.h2 variants={itemVariants} className="font-serif text-2xl text-champagne">
Detalhes da estadia
</motion.h2>
<motion.div variants={itemVariants}>
<SelectField <SelectField
label="Marca" label="Marca"
required required
@ -36,7 +74,10 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
onChange={(e) => onChange('marcaId', e.target.value)} onChange={(e) => onChange('marcaId', e.target.value)}
options={marcas.map((m) => ({ value: m.id, label: m.nome }))} options={marcas.map((m) => ({ value: m.id, label: m.nome }))}
/> />
</motion.div>
<motion.div key={form.marcaId || 'no-marca'} variants={containerVariants} initial="hidden" animate="visible">
<motion.div variants={itemVariants}>
<SelectField <SelectField
label="Unidade do Hotel" label="Unidade do Hotel"
required required
@ -45,7 +86,9 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
onChange={(e) => onChange('unidadeId', e.target.value)} onChange={(e) => onChange('unidadeId', e.target.value)}
options={unidades.map((u) => ({ value: u.id, label: u.nome }))} options={unidades.map((u) => ({ value: u.id, label: u.nome }))}
/> />
</motion.div>
<motion.div variants={itemVariants} className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField <SelectField
label="Permanência" label="Permanência"
@ -65,7 +108,9 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
options={categoriaOptions} options={categoriaOptions}
/> />
</div> </div>
</motion.div>
<motion.div variants={itemVariants} className="mt-6">
<FormField <FormField
label="Data e horário do check-in" label="Data e horário do check-in"
required required
@ -73,6 +118,8 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
value={form.checkinAt} value={form.checkinAt}
onChange={(e) => onChange('checkinAt', e.target.value)} onChange={(e) => onChange('checkinAt', e.target.value)}
/> />
</section> </motion.div>
</motion.div>
</motion.section>
) )
} }

View File

@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-lg font-sans font-semibold transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-champagne/60', 'inline-flex items-center justify-center gap-2 rounded-lg font-sans font-semibold transition-all duration-200 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-champagne/60',
{ {
variants: { variants: {
variant: { variant: {

View File

@ -0,0 +1,14 @@
import { cn } from '@/lib/utils'
export function Skeleton({ className }: { className?: string }) {
return (
<div
className={cn(
'relative overflow-hidden rounded-lg bg-midnight/50 border border-champagne/10',
className
)}
>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-champagne/10 to-transparent" />
</div>
)
}

View File

@ -44,6 +44,10 @@
} }
} }
@keyframes shimmer {
100% { transform: translateX(100%); }
}
@layer utilities { @layer utilities {
.text-gradient-gold { .text-gradient-gold {
background: linear-gradient(135deg, var(--color-champagne), var(--color-rose-gold)); background: linear-gradient(135deg, var(--color-champagne), var(--color-rose-gold));

View File

@ -1,4 +1,5 @@
import { ReservationFlow } from '@/components/reservation/ReservationFlow' import { ReservationFlow } from '@/components/reservation/ReservationFlow'
import { HeroSection } from '@/components/reservation/HeroSection'
import { useTenant } from '@/contexts/TenantProvider' import { useTenant } from '@/contexts/TenantProvider'
import { useAppConfig } from '@/hooks/useAppConfig' import { useAppConfig } from '@/hooks/useAppConfig'
@ -26,19 +27,7 @@ export default function ReservationPage() {
return ( return (
<main className="min-h-screen flex flex-col items-center px-6 py-12"> <main className="min-h-screen flex flex-col items-center px-6 py-12">
<header className="text-center mb-10"> <HeroSection config={config} />
{config.subtitulo_hero && (
<p className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4">
{config.subtitulo_hero}
</p>
)}
<h1 className="font-serif text-5xl md:text-6xl text-gradient-gold mb-3">
{config.titulo_hero}
</h1>
{config.tagline && (
<p className="font-sans text-slate text-lg">{config.tagline}</p>
)}
</header>
<ReservationFlow /> <ReservationFlow />