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:
parent
a0ee24937c
commit
25abaa807c
@ -18,8 +18,10 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"animejs": "^4.0.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"motion": "^12.4.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@ -34,6 +36,7 @@
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@ -17,12 +17,18 @@ importers:
|
||||
animejs:
|
||||
specifier: ^4.0.2
|
||||
version: 4.3.6
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
embla-carousel-react:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(react@19.2.5)
|
||||
lucide-react:
|
||||
specifier: ^0.454.0
|
||||
version: 0.454.0(react@19.2.5)
|
||||
@ -60,6 +66,9 @@ importers:
|
||||
'@testing-library/react':
|
||||
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)
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@types/node':
|
||||
specifier: ^22.9.0
|
||||
version: 22.19.17
|
||||
@ -768,6 +777,9 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/canvas-confetti@1.9.0':
|
||||
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
@ -976,6 +988,9 @@ packages:
|
||||
caniuse-lite@1.0.30001787:
|
||||
resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
|
||||
|
||||
canvas-confetti@1.9.4:
|
||||
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@ -1078,6 +1093,19 @@ packages:
|
||||
electron-to-chromium@1.5.336:
|
||||
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:
|
||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@ -2514,6 +2542,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/canvas-confetti@1.9.0': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
@ -2757,6 +2787,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001787: {}
|
||||
|
||||
canvas-confetti@1.9.4: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@ -2842,6 +2874,18 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { motion } from 'motion/react'
|
||||
import { chatwootApi, type CreateReservationResponse } from '@/lib/chatwootApi'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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>
|
||||
</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" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs uppercase tracking-widest text-champagne">
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import confetti from 'canvas-confetti'
|
||||
import { motion } from 'motion/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
@ -5,23 +8,70 @@ interface 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 (
|
||||
<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>
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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 já está registrada.
|
||||
<br />
|
||||
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">
|
||||
Fazer outra reserva
|
||||
</Button>
|
||||
</section>
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
|
||||
52
src/components/reservation/HeroSection.tsx
Normal file
52
src/components/reservation/HeroSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']
|
||||
@ -7,22 +10,134 @@ interface 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
|
||||
|
||||
return (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{fotos.map((foto) => (
|
||||
<div
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<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}
|
||||
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
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { animate } from 'animejs'
|
||||
import { formatBRL } from '@/lib/formatters'
|
||||
|
||||
interface Props {
|
||||
@ -6,11 +8,21 @@ interface 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
|
||||
const restante = totalCents - depositCents
|
||||
|
||||
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">
|
||||
<span className="text-slate text-sm uppercase tracking-widest">Preço estimado</span>
|
||||
<span className="font-serif text-3xl text-champagne">{formatBRL(totalCents)}</span>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useReservationForm } from '@/hooks/useReservationForm'
|
||||
import { parsePrefillFromURL } from '@/lib/prefill'
|
||||
|
||||
@ -16,6 +17,12 @@ import { Button } from '@/components/ui/button'
|
||||
|
||||
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() {
|
||||
const {
|
||||
form,
|
||||
@ -75,23 +82,49 @@ export function ReservationFlow() {
|
||||
setPhase('form')
|
||||
}
|
||||
|
||||
if (phase === 'success') {
|
||||
return <SuccessScreen onRestart={restart} />
|
||||
}
|
||||
|
||||
if (phase === 'checkout' && reservation) {
|
||||
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
|
||||
reservation={reservation}
|
||||
depositCents={depositCents}
|
||||
onPaid={() => setPhase('success')}
|
||||
onCancel={restart}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6">
|
||||
{phase === 'form' && (
|
||||
<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
|
||||
form={form}
|
||||
marcas={marcas}
|
||||
@ -120,6 +153,8 @@ export function ReservationFlow() {
|
||||
>
|
||||
{submitting ? 'Gerando PIX...' : 'Confirmar e pagar reserva'}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { SelectField } from '@/components/SelectField'
|
||||
import { FormField } from '@/components/FormField'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { ReservationFormState } from '@/hooks/useReservationForm'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
@ -13,7 +15,35 @@ interface Props {
|
||||
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) {
|
||||
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 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 })) ?? []
|
||||
|
||||
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>
|
||||
<motion.section
|
||||
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
|
||||
label="Marca"
|
||||
required
|
||||
@ -36,7 +74,10 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
|
||||
onChange={(e) => onChange('marcaId', e.target.value)}
|
||||
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
|
||||
label="Unidade do Hotel"
|
||||
required
|
||||
@ -45,7 +86,9 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
|
||||
onChange={(e) => onChange('unidadeId', e.target.value)}
|
||||
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">
|
||||
<SelectField
|
||||
label="Permanência"
|
||||
@ -65,7 +108,9 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
|
||||
options={categoriaOptions}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants} className="mt-6">
|
||||
<FormField
|
||||
label="Data e horário do check-in"
|
||||
required
|
||||
@ -73,6 +118,8 @@ export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) {
|
||||
value={form.checkinAt}
|
||||
onChange={(e) => onChange('checkinAt', e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
||||
14
src/components/ui/skeleton.tsx
Normal file
14
src/components/ui/skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -44,6 +44,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-gradient-gold {
|
||||
background: linear-gradient(135deg, var(--color-champagne), var(--color-rose-gold));
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ReservationFlow } from '@/components/reservation/ReservationFlow'
|
||||
import { HeroSection } from '@/components/reservation/HeroSection'
|
||||
import { useTenant } from '@/contexts/TenantProvider'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
|
||||
@ -26,19 +27,7 @@ export default function ReservationPage() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col items-center px-6 py-12">
|
||||
<header className="text-center mb-10">
|
||||
{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>
|
||||
<HeroSection config={config} />
|
||||
|
||||
<ReservationFlow />
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user