feat: Roleta da Sorte + prefill case-insensitive
- Nova rota pública /roleta/:token com animação 7s (cubic-bezier desaceleração contínua) + landing aleatório dentro da slice vencedora. - Aba admin /admin/roleta pra CRUD de prêmios (peso como probabilidade relativa, % calculado em tempo real, validação "não pode tudo ser nada"). - Integração chatwootApi.notifyRouletteResult() — avisa backend quando o prêmio é revelado. - Fix: useReservationForm com matchCanonical() case-insensitive resolve mismatch entre "pernoite"/"Pernoite" vindo do URL do Chatwoot vs permanências canônicas da marca no Supabase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
337229ce22
commit
f13ba7e0bd
@ -36,6 +36,12 @@ const empty: ReservationFormState = {
|
||||
observacao: '',
|
||||
}
|
||||
|
||||
function matchCanonical(candidates: string[] | null | undefined, value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
if (!candidates || candidates.length === 0) return value
|
||||
return candidates.find((c) => c.toLowerCase() === value.toLowerCase()) ?? value
|
||||
}
|
||||
|
||||
export function useReservationForm(initialPrefill?: PrefillData) {
|
||||
const tenantId = useTenantId()
|
||||
|
||||
@ -142,15 +148,20 @@ export function useReservationForm(initialPrefill?: PrefillData) {
|
||||
(u) => u.nome.toLowerCase() === initialPrefill.unidadeNome!.toLowerCase()
|
||||
)
|
||||
if (unidade) {
|
||||
// Case-insensitive match contra valores canônicos da marca — o Chatwoot envia
|
||||
// "pernoite"/"alexa" minúsculo e o DB armazena "Pernoite"/"Alexa".
|
||||
const marca = marcas.find((m) => m.id === unidade.id_marca)
|
||||
const canonicalPermanencia = matchCanonical(marca?.permanencias, initialPrefill.permanencia)
|
||||
const canonicalCategoria = matchCanonical(marca?.categorias, initialPrefill.categoria)
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
unidadeId: unidade.id,
|
||||
permanencia: initialPrefill.permanencia ?? prev.permanencia,
|
||||
categoria: initialPrefill.categoria ?? prev.categoria,
|
||||
permanencia: canonicalPermanencia ?? prev.permanencia,
|
||||
categoria: canonicalCategoria ?? prev.categoria,
|
||||
}))
|
||||
}
|
||||
unidadePrefillAppliedRef.current = true
|
||||
}, [unidades, initialPrefill])
|
||||
}, [unidades, marcas, initialPrefill])
|
||||
|
||||
const update = useCallback(<K extends keyof ReservationFormState>(
|
||||
key: K,
|
||||
|
||||
@ -68,4 +68,13 @@ export const chatwootApi = {
|
||||
getStatus(id: number): Promise<StatusResponse> {
|
||||
return request(`/public/api/v1/captain/public_reservations/${id}/status`)
|
||||
},
|
||||
|
||||
// Avisa o Chatwoot que o cliente revelou o prêmio da roleta.
|
||||
// Idempotente no backend (claim atômico). Fire-and-forget no front.
|
||||
notifyRouletteResult(token: string): Promise<{ enqueued: boolean }> {
|
||||
return request('/api/v1/captain/roleta/notify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
442
src/pages/RoletaPage.tsx
Normal file
442
src/pages/RoletaPage.tsx
Normal file
@ -0,0 +1,442 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import confetti from 'canvas-confetti'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { chatwootApi } from '@/lib/chatwootApi'
|
||||
|
||||
interface RevealResult {
|
||||
status: 'revealed' | 'expired'
|
||||
prize_nome: string
|
||||
prize_tipo: 'desconto_percentual' | 'brinde_fisico' | 'nada' | ''
|
||||
prize_valor: number | null
|
||||
code: string | null
|
||||
revealed_at: string | null
|
||||
total_blocos: number
|
||||
bloco_sorteado: number
|
||||
}
|
||||
|
||||
interface DrawPrize {
|
||||
position: number
|
||||
nome: string
|
||||
tipo: 'desconto_percentual' | 'brinde_fisico' | 'nada'
|
||||
valor: number | null
|
||||
}
|
||||
|
||||
interface DrawContext {
|
||||
marca_nome: string | null
|
||||
prizes: DrawPrize[]
|
||||
}
|
||||
|
||||
// Paleta rica (tons joia) — gradientes dark→bright por slice
|
||||
const PALETTE = [
|
||||
{ from: '#1a4d3e', to: '#3f9e78' }, // esmeralda
|
||||
{ from: '#6b1d3f', to: '#c4416c' }, // rubi
|
||||
{ from: '#1e3d6b', to: '#4687c2' }, // safira
|
||||
{ from: '#4a2a6b', to: '#8a5dc4' }, // ametista
|
||||
{ from: '#6b4a1a', to: '#d4a140' }, // topázio
|
||||
{ from: '#1a5a5a', to: '#3fa0a0' }, // turquesa
|
||||
{ from: '#6b2a1a', to: '#c46548' }, // coral
|
||||
{ from: '#3d2a6b', to: '#6b5dc4' }, // índigo
|
||||
{ from: '#5a4a1a', to: '#c4a040' }, // âmbar
|
||||
{ from: '#2a5a2a', to: '#5ab05a' }, // jade
|
||||
]
|
||||
|
||||
type Phase = 'loading' | 'ready' | 'spinning' | 'done' | 'error'
|
||||
|
||||
function iconFor(tipo: DrawPrize['tipo']): string {
|
||||
if (tipo === 'desconto_percentual') return '%'
|
||||
if (tipo === 'brinde_fisico') return '🎁'
|
||||
return '🎲'
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number) {
|
||||
return s.length > n ? s.slice(0, n - 1) + '…' : s
|
||||
}
|
||||
|
||||
export default function RoletaPage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const [phase, setPhase] = useState<Phase>('loading')
|
||||
const [context, setContext] = useState<DrawContext | null>(null)
|
||||
const [result, setResult] = useState<RevealResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
const startedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || startedRef.current) return
|
||||
startedRef.current = true
|
||||
void (async () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error: rpcErr } = await (supabase as any).rpc('get_draw_context', {
|
||||
p_token: token,
|
||||
})
|
||||
if (rpcErr) throw new Error(rpcErr.message)
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error('Nenhum prêmio configurado pra essa marca')
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const marca = (data[0] as any).marca_nome ?? null
|
||||
const prizes: DrawPrize[] = data.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(r: any): DrawPrize => ({
|
||||
position: r.prize_position,
|
||||
nome: r.prize_nome,
|
||||
tipo: r.prize_tipo,
|
||||
valor: r.prize_valor,
|
||||
})
|
||||
)
|
||||
setContext({ marca_nome: marca, prizes })
|
||||
setPhase('ready')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Link inválido')
|
||||
setPhase('error')
|
||||
}
|
||||
})()
|
||||
}, [token])
|
||||
|
||||
const startSpin = async () => {
|
||||
if (!context || phase !== 'ready') return
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error: rpcErr } = await (supabase as any).rpc('reveal_draw', {
|
||||
p_token: token,
|
||||
})
|
||||
if (rpcErr) throw new Error(rpcErr.message)
|
||||
const row = Array.isArray(data) && data.length > 0 ? (data[0] as RevealResult) : null
|
||||
if (!row) throw new Error('Resposta vazia')
|
||||
if (row.status === 'expired') throw new Error('Essa roleta expirou.')
|
||||
setResult(row)
|
||||
|
||||
// Drama pela desaceleração contínua (sem overshoot nem reversão):
|
||||
// Roda gira forte, vai perdendo velocidade GRADUALMENTE e termina crawlando MUITO devagar.
|
||||
// Nos últimos 1-1.5s cruza cada fronteira de slice bem lento — o cliente fica na dúvida
|
||||
// se vai passar pra próxima ou ficar na atual. Efeito "quase não passou".
|
||||
const N = row.total_blocos
|
||||
const K = row.bloco_sorteado
|
||||
const blockAngle = 360 / N
|
||||
// Posição aleatória DENTRO da slice vencedora (não sempre no centro).
|
||||
// Range [0.12, 0.88]: às vezes cai perto da borda (suspense real no crawl final),
|
||||
// às vezes no meio. Nunca nos ~12% da extremidade pra não ficar ambíguo.
|
||||
const offsetFactor = 0.12 + Math.random() * 0.76
|
||||
const targetAngle = 360 - (K * blockAngle + blockAngle * offsetFactor)
|
||||
const totalSpins = 8
|
||||
const finalRotation = totalSpins * 360 + targetAngle
|
||||
|
||||
setPhase('spinning')
|
||||
setRotation(finalRotation)
|
||||
|
||||
setTimeout(() => {
|
||||
setPhase('done')
|
||||
if (row.prize_tipo !== 'nada' && row.prize_tipo !== '') {
|
||||
confetti({ particleCount: 220, spread: 110, origin: { y: 0.55 } })
|
||||
setTimeout(() => confetti({ particleCount: 120, spread: 160, origin: { y: 0.6 } }), 400)
|
||||
}
|
||||
// Avisa o Chatwoot — Jasmine manda a msg automática.
|
||||
// Fire-and-forget: se falhar, não trava o cliente (cron de fallback cobre).
|
||||
if (token) {
|
||||
chatwootApi.notifyRouletteResult(token).catch((e) => {
|
||||
console.warn('Falha ao notificar Chatwoot:', e)
|
||||
})
|
||||
}
|
||||
}, 7500)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erro no sorteio')
|
||||
setPhase('error')
|
||||
}
|
||||
}
|
||||
|
||||
const slices = useMemo(() => {
|
||||
if (!context) return []
|
||||
const N = context.prizes.length
|
||||
const angle = 360 / N
|
||||
return context.prizes.map((p, i) => ({
|
||||
...p,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
rotation: i * angle,
|
||||
angle,
|
||||
}))
|
||||
}, [context])
|
||||
|
||||
// Transição única com desaceleração agressiva no final.
|
||||
// cubic-bezier(0.04, 0.85, 0.08, 1): sai com velocidade MUITO alta (derivada inicial ~21),
|
||||
// e a curva achata dramaticamente perto do fim — nas últimas ~1.5s a roda está crawlando,
|
||||
// cruzando cada fronteira de slice bem devagar. Isso gera a dúvida natural "vai passar ou não?".
|
||||
const transitionStyle: React.CSSProperties = useMemo(() => {
|
||||
if (phase === 'spinning') {
|
||||
return {
|
||||
transitionDuration: '7000ms',
|
||||
transitionTimingFunction: 'cubic-bezier(0.04, 0.85, 0.08, 1)',
|
||||
}
|
||||
}
|
||||
return { transitionDuration: '0ms' }
|
||||
}, [phase])
|
||||
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="font-serif text-3xl text-champagne">Ops!</h1>
|
||||
<p className="text-ivory">{error ?? 'Algo deu errado.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === 'loading' || !context) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-ivory animate-pulse">Carregando...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showPrize = phase === 'done'
|
||||
const ganhou = showPrize && result && result.prize_tipo !== 'nada' && result.prize_tipo !== ''
|
||||
const N = slices.length
|
||||
// Font size scala com número de blocos (menos blocos = texto maior)
|
||||
const textFontSize = N <= 4 ? 7.5 : N <= 6 ? 6 : N <= 9 ? 5 : 4.2
|
||||
const maxLen = N <= 4 ? 16 : N <= 6 ? 12 : N <= 9 ? 10 : 8
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-10 bg-gradient-to-b from-obsidian via-midnight to-obsidian">
|
||||
<style>{`
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { box-shadow: 0 0 40px 4px rgba(212,165,116,0.35), 0 0 80px 12px rgba(212,165,116,0.15); }
|
||||
50% { box-shadow: 0 0 60px 8px rgba(212,165,116,0.55), 0 0 120px 20px rgba(212,165,116,0.25); }
|
||||
}
|
||||
@keyframes shakeWheel {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-1px, 1px); }
|
||||
50% { transform: translate(1px, -1px); }
|
||||
75% { transform: translate(-1px, -1px); }
|
||||
}
|
||||
@keyframes pointerBounce {
|
||||
0% { transform: translateX(-50%) rotate(0deg) scale(1); }
|
||||
30% { transform: translateX(-50%) rotate(-12deg) scale(1.1); }
|
||||
60% { transform: translateX(-50%) rotate(8deg) scale(1.05); }
|
||||
100% { transform: translateX(-50%) rotate(0deg) scale(1); }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<header className="text-center mb-8 space-y-2 max-w-xl">
|
||||
<p className="text-champagne/70 text-sm font-sans tracking-[0.3em] uppercase">
|
||||
{context.marca_nome ?? 'Grupo 1001'}
|
||||
</p>
|
||||
<h1 className="font-serif text-4xl md:text-5xl text-champagne drop-shadow-[0_2px_12px_rgba(212,165,116,0.3)]">
|
||||
Gira a Sorte
|
||||
</h1>
|
||||
<p className="text-ivory/80 min-h-[1.5rem]">
|
||||
{phase === 'ready' && 'Toca o botão e descobre seu prêmio.'}
|
||||
{phase === 'spinning' && 'Rodando...'}
|
||||
{phase === 'done' && ganhou && 'Parabéns! Você ganhou:'}
|
||||
{phase === 'done' && !ganhou && 'Dessa vez não rolou, mas a gente te espera 🫶'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Roda + ponteiro */}
|
||||
<div className="relative w-[340px] h-[340px] md:w-[440px] md:h-[440px]">
|
||||
{/* Anel externo com glow pulsando quando ready */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
animation: phase === 'ready' ? 'pulseGlow 2.4s ease-in-out infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Ponteiro */}
|
||||
<div
|
||||
className="absolute left-1/2 -top-6 z-20"
|
||||
style={{
|
||||
animation: phase === 'done' ? 'pointerBounce 600ms ease-out' : undefined,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<svg width="44" height="56" viewBox="0 0 44 56">
|
||||
<defs>
|
||||
<linearGradient id="pointerGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f5d99a" />
|
||||
<stop offset="60%" stopColor="#d4a574" />
|
||||
<stop offset="100%" stopColor="#8a6a3c" />
|
||||
</linearGradient>
|
||||
<filter id="pointerShadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#000" floodOpacity="0.55" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 22 52 L 4 4 L 40 4 Z"
|
||||
fill="url(#pointerGrad)"
|
||||
stroke="#2a1e10"
|
||||
strokeWidth="1.5"
|
||||
filter="url(#pointerShadow)"
|
||||
/>
|
||||
<circle cx="22" cy="12" r="3" fill="#f5d99a" opacity="0.8" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Círculo que gira */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full overflow-hidden"
|
||||
style={{
|
||||
// shake removido — o overshoot+reversão já dá drama suficiente sem precisar de tremida
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full transition-transform"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
...transitionStyle,
|
||||
}}
|
||||
>
|
||||
<svg viewBox="-100 -100 200 200" className="w-full h-full">
|
||||
<defs>
|
||||
{slices.map((s, i) => (
|
||||
<radialGradient key={`grad-${i}`} id={`slice-${i}`} cx="0.5" cy="0.5" r="0.9">
|
||||
<stop offset="0%" stopColor={s.color.from} />
|
||||
<stop offset="100%" stopColor={s.color.to} />
|
||||
</radialGradient>
|
||||
))}
|
||||
<filter id="innerGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{slices.map((s, i) => {
|
||||
const startAngle = (s.rotation - 90) * (Math.PI / 180)
|
||||
const endAngle = (s.rotation + s.angle - 90) * (Math.PI / 180)
|
||||
const x1 = 100 * Math.cos(startAngle)
|
||||
const y1 = 100 * Math.sin(startAngle)
|
||||
const x2 = 100 * Math.cos(endAngle)
|
||||
const y2 = 100 * Math.sin(endAngle)
|
||||
const largeArc = s.angle > 180 ? 1 : 0
|
||||
const midAngle = s.rotation + s.angle / 2 - 90
|
||||
const midRad = midAngle * (Math.PI / 180)
|
||||
const tx = 62 * Math.cos(midRad)
|
||||
const ty = 62 * Math.sin(midRad)
|
||||
|
||||
// Texto do prêmio: nome (curto) na linha 1, valor/ícone na linha 2
|
||||
const nome = truncate(s.nome, maxLen)
|
||||
const label2 =
|
||||
s.tipo === 'desconto_percentual' && s.valor != null
|
||||
? `${Number(s.valor)}%`
|
||||
: s.tipo === 'brinde_fisico'
|
||||
? iconFor(s.tipo)
|
||||
: ''
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<path
|
||||
d={`M 0 0 L ${x1} ${y1} A 100 100 0 ${largeArc} 1 ${x2} ${y2} Z`}
|
||||
fill={`url(#slice-${i})`}
|
||||
stroke="#f5d99a"
|
||||
strokeWidth="0.6"
|
||||
opacity="0.95"
|
||||
/>
|
||||
<g transform={`rotate(${midAngle + 90} ${tx} ${ty})`}>
|
||||
<text
|
||||
x={tx}
|
||||
y={ty - (label2 ? 3 : 0)}
|
||||
fill="#ffffff"
|
||||
fontSize={textFontSize}
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.9)',
|
||||
}}
|
||||
>
|
||||
{nome}
|
||||
</text>
|
||||
{label2 && (
|
||||
<text
|
||||
x={tx}
|
||||
y={ty + textFontSize}
|
||||
fill="#f5d99a"
|
||||
fontSize={textFontSize * 1.15}
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.9)',
|
||||
}}
|
||||
>
|
||||
{label2}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Hub central decorativo */}
|
||||
<circle cx="0" cy="0" r="16" fill="#1a1a2e" stroke="#d4a574" strokeWidth="2.5" />
|
||||
<circle cx="0" cy="0" r="10" fill="#2a2a44" stroke="#f5d99a" strokeWidth="1" />
|
||||
<circle cx="0" cy="0" r="4" fill="#f5d99a" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botão */}
|
||||
{phase === 'ready' && (
|
||||
<button
|
||||
onClick={startSpin}
|
||||
className="mt-12 px-12 py-4 rounded-full bg-gradient-to-b from-[#f5d99a] via-[#d4a574] to-[#a07a48] text-obsidian font-sans font-bold text-lg tracking-wider shadow-2xl hover:scale-105 active:scale-95 transition-transform border-2 border-[#f5d99a]/60"
|
||||
style={{ boxShadow: '0 10px 30px rgba(212,165,116,0.4), inset 0 1px 0 rgba(255,255,255,0.3)' }}
|
||||
>
|
||||
GIRAR
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Resultado */}
|
||||
{phase === 'done' && result && (
|
||||
<div
|
||||
className="mt-12 max-w-md w-full text-center space-y-4"
|
||||
style={{ animation: 'fadeInUp 500ms ease-out' }}
|
||||
>
|
||||
<div
|
||||
className={`rounded-2xl p-7 border-2 backdrop-blur ${
|
||||
ganhou
|
||||
? 'border-champagne/60 bg-gradient-to-b from-champagne/10 to-champagne/5'
|
||||
: 'border-slate-600/40 bg-slate-600/10'
|
||||
}`}
|
||||
>
|
||||
<p className="text-3xl md:text-4xl font-serif text-champagne mb-2">{result.prize_nome}</p>
|
||||
{result.prize_tipo === 'desconto_percentual' && result.prize_valor != null && (
|
||||
<p className="text-ivory text-lg">
|
||||
<span className="text-champagne font-bold">{Number(result.prize_valor)}%</span> de
|
||||
desconto no saldo do check-in
|
||||
</p>
|
||||
)}
|
||||
{result.prize_tipo === 'brinde_fisico' && (
|
||||
<p className="text-ivory text-lg">Retire seu brinde na recepção 🎁</p>
|
||||
)}
|
||||
{ganhou && result.code && (
|
||||
<div className="mt-5 pt-5 border-t border-champagne/20">
|
||||
<p className="text-xs text-ivory/70 mb-2 tracking-widest uppercase">
|
||||
Mostre esse código na recepção
|
||||
</p>
|
||||
<p className="text-4xl md:text-5xl font-mono tracking-[0.4em] text-champagne drop-shadow-[0_0_20px_rgba(212,165,116,0.5)]">
|
||||
{result.code}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!ganhou && (
|
||||
<p className="text-ivory/80 text-sm mt-3">
|
||||
Guarda a sua próxima estadia pra rodar de novo 😉
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -12,6 +12,7 @@ const TABS = [
|
||||
{ to: 'fotos', label: 'Fotos' },
|
||||
{ to: 'extras', label: 'Extras' },
|
||||
{ to: 'reservas', label: 'Reservas' },
|
||||
{ to: 'roleta', label: 'Roleta' },
|
||||
]
|
||||
|
||||
export function AdminLayout() {
|
||||
|
||||
375
src/pages/admin/RoletaPrizesTab.tsx
Normal file
375
src/pages/admin/RoletaPrizesTab.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useTenantId } from '@/hooks/useAppConfig'
|
||||
import type { Database } from '@/types/database'
|
||||
import { useCrud } from '@/hooks/useCrud'
|
||||
import { DataTable, type Column } from '@/components/admin/DataTable'
|
||||
import { Modal } from '@/components/admin/Modal'
|
||||
import { FormField } from '@/components/FormField'
|
||||
import { SelectField } from '@/components/SelectField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
|
||||
|
||||
interface RoulettePrize {
|
||||
id: string
|
||||
tenant_id: number
|
||||
id_marca: string | null
|
||||
nome: string
|
||||
tipo: 'desconto_percentual' | 'brinde_fisico' | 'nada'
|
||||
valor: number | null
|
||||
probabilidade: number
|
||||
estoque: number | null
|
||||
ativo: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const TIPO_OPTIONS = [
|
||||
{ value: 'desconto_percentual', label: 'Desconto %' },
|
||||
{ value: 'brinde_fisico', label: 'Brinde físico' },
|
||||
{ value: 'nada', label: 'Sem sorte (nada)' },
|
||||
]
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id_marca: '',
|
||||
nome: '',
|
||||
tipo: 'desconto_percentual' as RoulettePrize['tipo'],
|
||||
valor: '',
|
||||
probabilidade: '10',
|
||||
estoque: '',
|
||||
ativo: true,
|
||||
}
|
||||
|
||||
export function RoletaPrizesTab() {
|
||||
const tenantId = useTenantId()
|
||||
const { rows, loading, error, create, update, remove } = useCrud<RoulettePrize>(
|
||||
'roulette_prizes',
|
||||
{ orderBy: 'created_at', ascending: true }
|
||||
)
|
||||
|
||||
const [marcas, setMarcas] = useState<Marca[]>([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<RoulettePrize | null>(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [marcaFiltro, setMarcaFiltro] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantId) return
|
||||
void supabase
|
||||
.from('marcas')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('nome')
|
||||
.then(({ data }) => setMarcas(data ?? []))
|
||||
}, [tenantId])
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => (marcaFiltro ? rows.filter((r) => r.id_marca === marcaFiltro) : rows),
|
||||
[rows, marcaFiltro]
|
||||
)
|
||||
|
||||
const totalPesoAtivo = useMemo(
|
||||
() =>
|
||||
filteredRows
|
||||
.filter((r) => r.ativo)
|
||||
.reduce((sum, r) => sum + Number(r.probabilidade || 0), 0),
|
||||
[filteredRows]
|
||||
)
|
||||
|
||||
const validacaoRoleta = useMemo(() => {
|
||||
const ativos = filteredRows.filter((r) => r.ativo)
|
||||
if (ativos.length === 0) return { ok: false, msg: 'Nenhum prêmio ativo — a roleta não vai funcionar.' }
|
||||
const temGanho = ativos.some((r) => r.tipo !== 'nada')
|
||||
if (!temGanho) {
|
||||
return {
|
||||
ok: false,
|
||||
msg: 'Todos os prêmios ativos são do tipo "nada". Adicione ao menos 1 desconto ou brinde.',
|
||||
}
|
||||
}
|
||||
return { ok: true, msg: `Roleta terá ${ativos.length} bloco(s) visualmente iguais.` }
|
||||
}, [filteredRows])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setForm({ ...EMPTY_FORM, id_marca: marcaFiltro || '' })
|
||||
setFormError(null)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (prize: RoulettePrize) => {
|
||||
setEditing(prize)
|
||||
setForm({
|
||||
id_marca: prize.id_marca ?? '',
|
||||
nome: prize.nome,
|
||||
tipo: prize.tipo,
|
||||
valor: prize.valor != null ? String(prize.valor) : '',
|
||||
probabilidade: String(prize.probabilidade),
|
||||
estoque: prize.estoque != null ? String(prize.estoque) : '',
|
||||
ativo: prize.ativo,
|
||||
})
|
||||
setFormError(null)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (prize: RoulettePrize) => {
|
||||
if (!confirm(`Excluir o prêmio "${prize.nome}"?`)) return
|
||||
try {
|
||||
await remove(prize.id)
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Erro ao excluir')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.nome.trim()) {
|
||||
setFormError('Nome é obrigatório')
|
||||
return
|
||||
}
|
||||
const prob = Number(form.probabilidade.replace(',', '.'))
|
||||
if (isNaN(prob) || prob < 0) {
|
||||
setFormError('Probabilidade (peso) inválida')
|
||||
return
|
||||
}
|
||||
let valor: number | null = null
|
||||
if (form.tipo === 'desconto_percentual') {
|
||||
valor = Number(form.valor.replace(',', '.'))
|
||||
if (isNaN(valor) || valor <= 0 || valor > 100) {
|
||||
setFormError('Desconto % deve ser entre 0 e 100')
|
||||
return
|
||||
}
|
||||
}
|
||||
let estoque: number | null = null
|
||||
if (form.tipo === 'brinde_fisico' && form.estoque.trim()) {
|
||||
estoque = Number(form.estoque)
|
||||
if (isNaN(estoque) || estoque < 0) {
|
||||
setFormError('Estoque inválido')
|
||||
return
|
||||
}
|
||||
}
|
||||
setSaving(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
const payload = {
|
||||
id_marca: form.id_marca || null,
|
||||
nome: form.nome.trim(),
|
||||
tipo: form.tipo,
|
||||
valor,
|
||||
probabilidade: prob,
|
||||
estoque,
|
||||
ativo: form.ativo,
|
||||
}
|
||||
if (editing) {
|
||||
await update(editing.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
setModalOpen(false)
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : 'Erro ao salvar')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<RoulettePrize>[] = [
|
||||
{ key: 'nome', label: 'Nome' },
|
||||
{
|
||||
key: 'tipo',
|
||||
label: 'Tipo',
|
||||
render: (row) => TIPO_OPTIONS.find((t) => t.value === row.tipo)?.label ?? row.tipo,
|
||||
},
|
||||
{
|
||||
key: 'valor',
|
||||
label: 'Valor',
|
||||
render: (row) => (row.tipo === 'desconto_percentual' ? `${row.valor}%` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'probabilidade',
|
||||
label: 'Peso',
|
||||
render: (row) => {
|
||||
if (!row.ativo || totalPesoAtivo === 0) {
|
||||
return <span className="text-slate-400">{row.probabilidade}</span>
|
||||
}
|
||||
const pct = (Number(row.probabilidade) / totalPesoAtivo) * 100
|
||||
return (
|
||||
<span>
|
||||
{row.probabilidade} <span className="text-slate-400">({pct.toFixed(1)}%)</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'estoque',
|
||||
label: 'Estoque',
|
||||
render: (row) => (row.estoque == null ? 'Ilimitado' : String(row.estoque)),
|
||||
},
|
||||
{
|
||||
key: 'id_marca',
|
||||
label: 'Marca',
|
||||
render: (row) =>
|
||||
row.id_marca ? marcas.find((m) => m.id === row.id_marca)?.nome ?? '—' : 'Todas',
|
||||
},
|
||||
{
|
||||
key: 'ativo',
|
||||
label: 'Status',
|
||||
render: (row) => (
|
||||
<span className={row.ativo ? 'text-emerald-400' : 'text-slate-400'}>
|
||||
{row.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl text-champagne mb-2">Roleta — Prêmios</h1>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Cada prêmio ativo vira um bloco da roleta (visualmente todos iguais). O peso controla a
|
||||
chance de cair em cada um — o % de chance é calculado automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={openCreate}>+ Novo prêmio</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="min-w-[240px]">
|
||||
<SelectField
|
||||
label="Filtrar por marca"
|
||||
value={marcaFiltro}
|
||||
onChange={(e) => setMarcaFiltro(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Todas as marcas' },
|
||||
...marcas.map((m) => ({ value: m.id, label: m.nome })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 rounded-xl border p-3 text-sm ${
|
||||
validacaoRoleta.ok
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-amber-500/40 bg-amber-500/10 text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{validacaoRoleta.msg}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 p-4 text-ivory">{error}</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
rows={filteredRows}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
emptyMessage='Nenhum prêmio cadastrado. Clique em "+ Novo prêmio" para começar.'
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? 'Editar prêmio' : 'Novo prêmio'}
|
||||
onClose={() => setModalOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Salvando...' : 'Salvar'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
label="Marca"
|
||||
value={form.id_marca}
|
||||
onChange={(e) => setForm({ ...form, id_marca: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: 'Todas as marcas do tenant' },
|
||||
...marcas.map((m) => ({ value: m.id, label: m.nome })),
|
||||
]}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Nome do prêmio"
|
||||
required
|
||||
value={form.nome}
|
||||
onChange={(e) => setForm({ ...form, nome: e.target.value })}
|
||||
placeholder='Ex: "10% de desconto", "Cerveja Heineken", "Tente de novo"'
|
||||
/>
|
||||
|
||||
<SelectField
|
||||
label="Tipo"
|
||||
required
|
||||
value={form.tipo}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
tipo: e.target.value as RoulettePrize['tipo'],
|
||||
valor: '',
|
||||
estoque: '',
|
||||
})
|
||||
}
|
||||
options={TIPO_OPTIONS}
|
||||
/>
|
||||
|
||||
{form.tipo === 'desconto_percentual' && (
|
||||
<FormField
|
||||
label="% de desconto"
|
||||
required
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={form.valor}
|
||||
onChange={(e) => setForm({ ...form, valor: e.target.value })}
|
||||
placeholder="Ex: 5, 10, 15"
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.tipo === 'brinde_fisico' && (
|
||||
<FormField
|
||||
label="Estoque (vazio = ilimitado)"
|
||||
type="number"
|
||||
value={form.estoque}
|
||||
onChange={(e) => setForm({ ...form, estoque: e.target.value })}
|
||||
placeholder="Ex: 20"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Peso (probabilidade relativa)"
|
||||
required
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={form.probabilidade}
|
||||
onChange={(e) => setForm({ ...form, probabilidade: e.target.value })}
|
||||
placeholder="Ex: 10"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 -mt-3">
|
||||
Quanto maior o peso, maior a chance desse prêmio sair. Números relativos aos demais — não
|
||||
precisa somar 100.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 text-ivory font-sans text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.ativo}
|
||||
onChange={(e) => setForm({ ...form, ativo: e.target.checked })}
|
||||
className="h-4 w-4 accent-champagne"
|
||||
/>
|
||||
Ativo (entra na roleta)
|
||||
</label>
|
||||
|
||||
{formError && (
|
||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 p-3 text-ivory text-sm">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
|
||||
import ReservationPage from '@/pages/ReservationPage'
|
||||
import RoletaPage from '@/pages/RoletaPage'
|
||||
import { LoginPage } from '@/pages/admin/LoginPage'
|
||||
import { AdminLayout } from '@/pages/admin/AdminLayout'
|
||||
import { AparenciaTab } from '@/pages/admin/AparenciaTab'
|
||||
@ -10,9 +11,11 @@ import { PrecosTab } from '@/pages/admin/PrecosTab'
|
||||
import { FotosTab } from '@/pages/admin/FotosTab'
|
||||
import { ExtrasTab } from '@/pages/admin/ExtrasTab'
|
||||
import { ReservasTab } from '@/pages/admin/ReservasTab'
|
||||
import { RoletaPrizesTab } from '@/pages/admin/RoletaPrizesTab'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/', element: <ReservationPage /> },
|
||||
{ path: '/roleta/:token', element: <RoletaPage /> },
|
||||
{ path: '/admin/login', element: <LoginPage /> },
|
||||
{
|
||||
path: '/admin',
|
||||
@ -27,6 +30,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'fotos', element: <FotosTab /> },
|
||||
{ path: 'extras', element: <ExtrasTab /> },
|
||||
{ path: 'reservas', element: <ReservasTab /> },
|
||||
{ path: 'roleta', element: <RoletaPrizesTab /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@ -249,6 +249,60 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
marca_periodos: {
|
||||
Row: {
|
||||
ativo: boolean
|
||||
created_at: string
|
||||
dias: number[]
|
||||
id: string
|
||||
id_marca: string
|
||||
nome: string
|
||||
ordem: number
|
||||
slug: string
|
||||
tenant_id: number
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
ativo?: boolean
|
||||
created_at?: string
|
||||
dias?: number[]
|
||||
id?: string
|
||||
id_marca: string
|
||||
nome: string
|
||||
ordem?: number
|
||||
slug: string
|
||||
tenant_id: number
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
ativo?: boolean
|
||||
created_at?: string
|
||||
dias?: number[]
|
||||
id?: string
|
||||
id_marca?: string
|
||||
nome?: string
|
||||
ordem?: number
|
||||
slug?: string
|
||||
tenant_id?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "marca_periodos_id_marca_fkey"
|
||||
columns: ["id_marca"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "marcas"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "marca_periodos_tenant_id_fkey"
|
||||
columns: ["tenant_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tenants"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
marcas: {
|
||||
Row: {
|
||||
ativa: boolean | null
|
||||
@ -588,6 +642,38 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
tenant_members: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: number
|
||||
role: string
|
||||
tenant_id: number
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
role?: string
|
||||
tenant_id: number
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
role?: string
|
||||
tenant_id?: number
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "tenant_members_tenant_id_fkey"
|
||||
columns: ["tenant_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tenants"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tenants: {
|
||||
Row: {
|
||||
ativo: boolean
|
||||
@ -769,6 +855,7 @@ export type Database = {
|
||||
}
|
||||
Returns: number
|
||||
}
|
||||
is_tenant_member: { Args: { check_tenant_id: number }; Returns: boolean }
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
|
||||
Loading…
Reference in New Issue
Block a user