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: '',
|
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,
|
||||||
|
|||||||
@ -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
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: '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() {
|
||||||
|
|||||||
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 { 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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user