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:
Rodribm10 2026-04-21 16:59:12 -03:00
parent 337229ce22
commit f13ba7e0bd
7 changed files with 932 additions and 3 deletions

View File

@ -36,6 +36,12 @@ const empty: ReservationFormState = {
observacao: '', 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) { export function useReservationForm(initialPrefill?: PrefillData) {
const tenantId = useTenantId() const tenantId = useTenantId()
@ -142,15 +148,20 @@ export function useReservationForm(initialPrefill?: PrefillData) {
(u) => u.nome.toLowerCase() === initialPrefill.unidadeNome!.toLowerCase() (u) => u.nome.toLowerCase() === initialPrefill.unidadeNome!.toLowerCase()
) )
if (unidade) { 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) => ({ setForm((prev) => ({
...prev, ...prev,
unidadeId: unidade.id, unidadeId: unidade.id,
permanencia: initialPrefill.permanencia ?? prev.permanencia, permanencia: canonicalPermanencia ?? prev.permanencia,
categoria: initialPrefill.categoria ?? prev.categoria, categoria: canonicalCategoria ?? prev.categoria,
})) }))
} }
unidadePrefillAppliedRef.current = true unidadePrefillAppliedRef.current = true
}, [unidades, initialPrefill]) }, [unidades, marcas, initialPrefill])
const update = useCallback(<K extends keyof ReservationFormState>( const update = useCallback(<K extends keyof ReservationFormState>(
key: K, key: K,

View File

@ -68,4 +68,13 @@ export const chatwootApi = {
getStatus(id: number): Promise<StatusResponse> { getStatus(id: number): Promise<StatusResponse> {
return request(`/public/api/v1/captain/public_reservations/${id}/status`) 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
View 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>
)
}

View File

@ -12,6 +12,7 @@ const TABS = [
{ to: 'fotos', label: 'Fotos' }, { to: 'fotos', label: 'Fotos' },
{ to: 'extras', label: 'Extras' }, { to: 'extras', label: 'Extras' },
{ to: 'reservas', label: 'Reservas' }, { to: 'reservas', label: 'Reservas' },
{ to: 'roleta', label: 'Roleta' },
] ]
export function AdminLayout() { export function AdminLayout() {

View 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>
)
}

View File

@ -1,5 +1,6 @@
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom' import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
import ReservationPage from '@/pages/ReservationPage' import ReservationPage from '@/pages/ReservationPage'
import RoletaPage from '@/pages/RoletaPage'
import { LoginPage } from '@/pages/admin/LoginPage' import { LoginPage } from '@/pages/admin/LoginPage'
import { AdminLayout } from '@/pages/admin/AdminLayout' import { AdminLayout } from '@/pages/admin/AdminLayout'
import { AparenciaTab } from '@/pages/admin/AparenciaTab' import { AparenciaTab } from '@/pages/admin/AparenciaTab'
@ -10,9 +11,11 @@ import { PrecosTab } from '@/pages/admin/PrecosTab'
import { FotosTab } from '@/pages/admin/FotosTab' import { FotosTab } from '@/pages/admin/FotosTab'
import { ExtrasTab } from '@/pages/admin/ExtrasTab' import { ExtrasTab } from '@/pages/admin/ExtrasTab'
import { ReservasTab } from '@/pages/admin/ReservasTab' import { ReservasTab } from '@/pages/admin/ReservasTab'
import { RoletaPrizesTab } from '@/pages/admin/RoletaPrizesTab'
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: '/', element: <ReservationPage /> }, { path: '/', element: <ReservationPage /> },
{ path: '/roleta/:token', element: <RoletaPage /> },
{ path: '/admin/login', element: <LoginPage /> }, { path: '/admin/login', element: <LoginPage /> },
{ {
path: '/admin', path: '/admin',
@ -27,6 +30,7 @@ const router = createBrowserRouter([
{ path: 'fotos', element: <FotosTab /> }, { path: 'fotos', element: <FotosTab /> },
{ path: 'extras', element: <ExtrasTab /> }, { path: 'extras', element: <ExtrasTab /> },
{ path: 'reservas', element: <ReservasTab /> }, { path: 'reservas', element: <ReservasTab /> },
{ path: 'roleta', element: <RoletaPrizesTab /> },
], ],
}, },
]) ])

View File

@ -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: { marcas: {
Row: { Row: {
ativa: boolean | null 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: { tenants: {
Row: { Row: {
ativo: boolean ativo: boolean
@ -769,6 +855,7 @@ export type Database = {
} }
Returns: number Returns: number
} }
is_tenant_member: { Args: { check_tenant_id: number }; Returns: boolean }
} }
Enums: { Enums: {
[_ in never]: never [_ in never]: never