diff --git a/src/hooks/useReservationForm.ts b/src/hooks/useReservationForm.ts index 4a2df89..468e2a5 100644 --- a/src/hooks/useReservationForm.ts +++ b/src/hooks/useReservationForm.ts @@ -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(( key: K, diff --git a/src/lib/chatwootApi.ts b/src/lib/chatwootApi.ts index 3d4021d..2bbbabf 100644 --- a/src/lib/chatwootApi.ts +++ b/src/lib/chatwootApi.ts @@ -68,4 +68,13 @@ export const chatwootApi = { getStatus(id: number): Promise { 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 }), + }) + }, } diff --git a/src/pages/RoletaPage.tsx b/src/pages/RoletaPage.tsx new file mode 100644 index 0000000..d386c3b --- /dev/null +++ b/src/pages/RoletaPage.tsx @@ -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('loading') + const [context, setContext] = useState(null) + const [result, setResult] = useState(null) + const [error, setError] = useState(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 ( +
+
+

Ops!

+

{error ?? 'Algo deu errado.'}

+
+
+ ) + } + + if (phase === 'loading' || !context) { + return ( +
+
Carregando...
+
+ ) + } + + 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 ( +
+ + +
+

+ {context.marca_nome ?? 'Grupo 1001'} +

+

+ Gira a Sorte +

+

+ {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 🫶'} +

+
+ + {/* Roda + ponteiro */} +
+ {/* Anel externo com glow pulsando quando ready */} +
+ + {/* Ponteiro */} +
+ + + + + + + + + + + + + + +
+ + {/* Círculo que gira */} +
+
+ + + {slices.map((s, i) => ( + + + + + ))} + + + + + + + + + + {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 ( + + + + + {nome} + + {label2 && ( + + {label2} + + )} + + + ) + })} + + {/* Hub central decorativo */} + + + + +
+
+
+ + {/* Botão */} + {phase === 'ready' && ( + + )} + + {/* Resultado */} + {phase === 'done' && result && ( +
+
+

{result.prize_nome}

+ {result.prize_tipo === 'desconto_percentual' && result.prize_valor != null && ( +

+ {Number(result.prize_valor)}% de + desconto no saldo do check-in +

+ )} + {result.prize_tipo === 'brinde_fisico' && ( +

Retire seu brinde na recepção 🎁

+ )} + {ganhou && result.code && ( +
+

+ Mostre esse código na recepção +

+

+ {result.code} +

+
+ )} + {!ganhou && ( +

+ Guarda a sua próxima estadia pra rodar de novo 😉 +

+ )} +
+
+ )} +
+ ) +} diff --git a/src/pages/admin/AdminLayout.tsx b/src/pages/admin/AdminLayout.tsx index ae997de..4874bf1 100644 --- a/src/pages/admin/AdminLayout.tsx +++ b/src/pages/admin/AdminLayout.tsx @@ -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() { diff --git a/src/pages/admin/RoletaPrizesTab.tsx b/src/pages/admin/RoletaPrizesTab.tsx new file mode 100644 index 0000000..7a7ebc8 --- /dev/null +++ b/src/pages/admin/RoletaPrizesTab.tsx @@ -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( + 'roulette_prizes', + { orderBy: 'created_at', ascending: true } + ) + + const [marcas, setMarcas] = useState([]) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [saving, setSaving] = useState(false) + const [formError, setFormError] = useState(null) + const [marcaFiltro, setMarcaFiltro] = useState('') + + 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[] = [ + { 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 {row.probabilidade} + } + const pct = (Number(row.probabilidade) / totalPesoAtivo) * 100 + return ( + + {row.probabilidade} ({pct.toFixed(1)}%) + + ) + }, + }, + { + 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) => ( + + {row.ativo ? 'Ativo' : 'Inativo'} + + ), + }, + ] + + return ( +
+
+
+

Roleta — Prêmios

+

+ 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. +

+
+ +
+ +
+
+ setMarcaFiltro(e.target.value)} + options={[ + { value: '', label: 'Todas as marcas' }, + ...marcas.map((m) => ({ value: m.id, label: m.nome })), + ]} + /> +
+
+ {validacaoRoleta.msg} +
+
+ + {error && ( +
{error}
+ )} + + + + setModalOpen(false)} + footer={ + <> + + + + } + > + setForm({ ...form, id_marca: e.target.value })} + options={[ + { value: '', label: 'Todas as marcas do tenant' }, + ...marcas.map((m) => ({ value: m.id, label: m.nome })), + ]} + /> + + setForm({ ...form, nome: e.target.value })} + placeholder='Ex: "10% de desconto", "Cerveja Heineken", "Tente de novo"' + /> + + + setForm({ + ...form, + tipo: e.target.value as RoulettePrize['tipo'], + valor: '', + estoque: '', + }) + } + options={TIPO_OPTIONS} + /> + + {form.tipo === 'desconto_percentual' && ( + setForm({ ...form, valor: e.target.value })} + placeholder="Ex: 5, 10, 15" + /> + )} + + {form.tipo === 'brinde_fisico' && ( + setForm({ ...form, estoque: e.target.value })} + placeholder="Ex: 20" + /> + )} + + setForm({ ...form, probabilidade: e.target.value })} + placeholder="Ex: 10" + /> +

+ Quanto maior o peso, maior a chance desse prêmio sair. Números relativos aos demais — não + precisa somar 100. +

+ + + + {formError && ( +
+ {formError} +
+ )} +
+
+ ) +} diff --git a/src/router.tsx b/src/router.tsx index 53211d0..08c5876 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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: }, + { path: '/roleta/:token', element: }, { path: '/admin/login', element: }, { path: '/admin', @@ -27,6 +30,7 @@ const router = createBrowserRouter([ { path: 'fotos', element: }, { path: 'extras', element: }, { path: 'reservas', element: }, + { path: 'roleta', element: }, ], }, ]) diff --git a/src/types/database.ts b/src/types/database.ts index 86a9cd0..47d055b 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -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