feat(fase4-c): tenant resolver + theming dinamico

- src/lib/tenant.ts: resolve slug por subdominio (fallback env)
- src/lib/appConfig.ts: carrega tenant + app_config do Supabase
- TenantProvider: aplica CSS vars, Google Fonts dinamico, title/favicon
- useAppConfig + useTenantId hooks
- App.tsx renderiza titulo/subtitulo/tagline/footer do config
- catalogoService filtra todas queries por tenant_id
- useReservationForm aguarda tenantId antes de buscar
- Testes mockam TenantProvider + useAppConfig
- vite.config: host: true + allowedHosts (tunnel-friendly)
- chatwootApi: header ngrok-skip-browser-warning
This commit is contained in:
Rodribm10 2026-04-14 21:06:16 -03:00
parent 3cb5ecf47d
commit f4980f026c
13 changed files with 291 additions and 25 deletions

View File

@ -6,3 +6,6 @@ VITE_SUPABASE_SCHEMA=reserva_hotel
# Chatwoot — token de integração (Fase 2) # Chatwoot — token de integração (Fase 2)
VITE_CHATWOOT_API_URL=https://chatwoot.fazer.ai VITE_CHATWOOT_API_URL=https://chatwoot.fazer.ai
VITE_CHATWOOT_API_TOKEN= VITE_CHATWOOT_API_TOKEN=
# Multi-tenant: slug default quando rodando em localhost/tunnel (sem subdomínio)
VITE_DEFAULT_TENANT_SLUG=grupo-1001

View File

@ -1,25 +1,52 @@
import { ReservationFlow } from '@/components/reservation/ReservationFlow' import { ReservationFlow } from '@/components/reservation/ReservationFlow'
import { useTenant } from '@/contexts/TenantProvider'
import { useAppConfig } from '@/hooks/useAppConfig'
export default function App() { export default function App() {
const { loading, error } = useTenant()
const config = useAppConfig()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-slate font-sans">Carregando...</p>
</div>
)
}
if (error || !config) {
return (
<div className="min-h-screen flex items-center justify-center px-6">
<div className="rounded-xl border border-ruby/40 bg-ruby/10 p-6 text-ivory text-center max-w-md">
{error ?? 'Configuração não encontrada'}
</div>
</div>
)
}
return ( return (
<main className="min-h-screen flex flex-col items-center px-6 py-12"> <main className="min-h-screen flex flex-col items-center px-6 py-12">
<header className="text-center mb-10"> <header className="text-center mb-10">
<p className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4"> {config.subtitulo_hero && (
Experiência exclusiva <p className="font-sans text-sm uppercase tracking-[0.3em] text-rose-gold mb-4">
</p> {config.subtitulo_hero}
</p>
)}
<h1 className="font-serif text-5xl md:text-6xl text-gradient-gold mb-3"> <h1 className="font-serif text-5xl md:text-6xl text-gradient-gold mb-3">
Reserva Rede 1001 {config.titulo_hero}
</h1> </h1>
<p className="font-sans text-slate text-lg"> {config.tagline && (
Escolha, confirme e receba seu PIX na hora. <p className="font-sans text-slate text-lg">{config.tagline}</p>
</p> )}
</header> </header>
<ReservationFlow /> <ReservationFlow />
<footer className="mt-16 text-slate text-xs uppercase tracking-widest"> {config.footer_text && (
© 2026 Reserva Rede 1001 · Experiência Exclusiva <footer className="mt-16 text-slate text-xs uppercase tracking-widest">
</footer> {config.footer_text}
</footer>
)}
</main> </main>
) )
} }

View File

@ -1,6 +1,55 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
vi.mock('@/contexts/TenantProvider', () => ({
TenantProvider: ({ children }: { children: React.ReactNode }) => children,
useTenant: () => ({
loading: false,
error: null,
context: {
tenant: {
id: 1,
slug: 'test',
nome: 'Test',
ativo: true,
created_at: '',
updated_at: '',
},
config: {
id: 1,
tenant_id: 1,
nome_rede: 'Test Rede',
titulo_hero: 'Reserva Rede 1001',
subtitulo_hero: 'Teste',
tagline: null,
footer_text: null,
logo_url: null,
favicon_url: null,
cor_primaria: '#C9A961',
cor_secundaria: '#E8B4A0',
cor_fundo: '#0B0D12',
cor_superficie: '#0F1A2E',
cor_texto: '#F5F1E8',
fonte_display: 'Fraunces',
fonte_corpo: 'Inter',
created_at: '',
updated_at: '',
},
},
refresh: vi.fn(),
}),
}))
vi.mock('@/hooks/useAppConfig', () => ({
useAppConfig: () => ({
titulo_hero: 'Reserva Rede 1001',
subtitulo_hero: 'Teste',
tagline: null,
footer_text: null,
}),
useTenantId: () => 1,
}))
vi.mock('@/hooks/useReservationForm', () => ({ vi.mock('@/hooks/useReservationForm', () => ({
useReservationForm: () => ({ useReservationForm: () => ({
form: { form: {

View File

@ -0,0 +1,97 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import type { LoadedTenantContext } from '@/lib/appConfig'
import { loadTenantContext } from '@/lib/appConfig'
import { resolveTenantSlug } from '@/lib/tenant'
interface TenantContextValue {
loading: boolean
error: string | null
context: LoadedTenantContext | null
refresh: () => Promise<void>
}
const TenantContext = createContext<TenantContextValue | null>(null)
export function TenantProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [context, setContext] = useState<LoadedTenantContext | null>(null)
const load = async () => {
setLoading(true)
setError(null)
try {
const slug = resolveTenantSlug()
const ctx = await loadTenantContext(slug)
if (!ctx) {
setError(`Tenant "${slug}" não encontrado ou inativo.`)
} else {
setContext(ctx)
applyTheme(ctx)
applyMetadata(ctx)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Erro ao carregar tenant')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<TenantContext.Provider value={{ loading, error, context, refresh: load }}>
{children}
</TenantContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function useTenant() {
const ctx = useContext(TenantContext)
if (!ctx) throw new Error('useTenant must be used inside <TenantProvider>')
return ctx
}
function applyTheme(ctx: LoadedTenantContext) {
const { config } = ctx
const root = document.documentElement
root.style.setProperty('--color-champagne', config.cor_primaria)
root.style.setProperty('--color-rose-gold', config.cor_secundaria)
root.style.setProperty('--color-obsidian', config.cor_fundo)
root.style.setProperty('--color-midnight', config.cor_superficie)
root.style.setProperty('--color-ivory', config.cor_texto)
loadGoogleFont(config.fonte_display)
loadGoogleFont(config.fonte_corpo)
root.style.setProperty('--font-serif', `'${config.fonte_display}', Georgia, serif`)
root.style.setProperty('--font-sans', `'${config.fonte_corpo}', system-ui, sans-serif`)
}
function loadGoogleFont(family: string) {
if (!family) return
const id = `gfont-${family.replace(/\s+/g, '-').toLowerCase()}`
if (document.getElementById(id)) return
const link = document.createElement('link')
link.id = id
link.rel = 'stylesheet'
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;500;600;700&display=swap`
document.head.appendChild(link)
}
function applyMetadata(ctx: LoadedTenantContext) {
const { config } = ctx
document.title = config.titulo_hero
if (config.favicon_url) {
let link = document.querySelector<HTMLLinkElement>("link[rel='icon']")
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = config.favicon_url
}
}

11
src/hooks/useAppConfig.ts Normal file
View File

@ -0,0 +1,11 @@
import { useTenant } from '@/contexts/TenantProvider'
export function useAppConfig() {
const { context } = useTenant()
return context?.config ?? null
}
export function useTenantId() {
const { context } = useTenant()
return context?.tenant.id ?? null
}

View File

@ -3,6 +3,7 @@ import { catalogoService } from '@/services/catalogoService'
import type { Database } from '@/types/database' import type { Database } from '@/types/database'
import type { PrefillData } from '@/lib/prefill' import type { PrefillData } from '@/lib/prefill'
import { prefillSimpleFields } from '@/lib/prefill' import { prefillSimpleFields } from '@/lib/prefill'
import { useTenantId } from '@/hooks/useAppConfig'
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row'] type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row'] type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row']
@ -36,6 +37,8 @@ const empty: ReservationFormState = {
} }
export function useReservationForm(initialPrefill?: PrefillData) { export function useReservationForm(initialPrefill?: PrefillData) {
const tenantId = useTenantId()
const [form, setForm] = useState<ReservationFormState>(() => ({ const [form, setForm] = useState<ReservationFormState>(() => ({
...empty, ...empty,
...prefillSimpleFields(initialPrefill ?? {}), ...prefillSimpleFields(initialPrefill ?? {}),
@ -51,44 +54,48 @@ export function useReservationForm(initialPrefill?: PrefillData) {
const unidadePrefillAppliedRef = useRef(false) const unidadePrefillAppliedRef = useRef(false)
useEffect(() => { useEffect(() => {
if (!tenantId) return
catalogoService catalogoService
.listMarcas() .listMarcas(tenantId)
.then(setMarcas) .then(setMarcas)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
}, []) }, [tenantId])
useEffect(() => { useEffect(() => {
if (!tenantId) return
if (!form.marcaId) { if (!form.marcaId) {
setUnidades([]) setUnidades([])
return return
} }
catalogoService catalogoService
.listUnidades(form.marcaId) .listUnidades(tenantId, form.marcaId)
.then(setUnidades) .then(setUnidades)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
}, [form.marcaId]) }, [tenantId, form.marcaId])
useEffect(() => { useEffect(() => {
if (!tenantId) return
if (!form.marcaId || !form.categoria || !form.permanencia) { if (!form.marcaId || !form.categoria || !form.permanencia) {
setPreco(null) setPreco(null)
return return
} }
catalogoService catalogoService
.findPreco(form.marcaId, form.categoria, form.permanencia) .findPreco(tenantId, form.marcaId, form.categoria, form.permanencia)
.then(setPreco) .then(setPreco)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
}, [form.marcaId, form.categoria, form.permanencia]) }, [tenantId, form.marcaId, form.categoria, form.permanencia])
useEffect(() => { useEffect(() => {
if (!tenantId) return
if (!form.unidadeId || !form.categoria) { if (!form.unidadeId || !form.categoria) {
setFotos([]) setFotos([])
return return
} }
catalogoService catalogoService
.listFotos(form.unidadeId, form.categoria) .listFotos(tenantId, form.unidadeId, form.categoria)
.then(setFotos) .then(setFotos)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
}, [form.unidadeId, form.categoria]) }, [tenantId, form.unidadeId, form.categoria])
// Resolve prefill: marcaNome -> marcaId quando marcas carregam // Resolve prefill: marcaNome -> marcaId quando marcas carregam
useEffect(() => { useEffect(() => {

33
src/lib/appConfig.ts Normal file
View File

@ -0,0 +1,33 @@
import { supabase } from './supabase'
import type { Database } from '@/types/database'
export type Tenant = Database['reserva_hotel']['Tables']['tenants']['Row']
export type AppConfig = Database['reserva_hotel']['Tables']['app_config']['Row']
export interface LoadedTenantContext {
tenant: Tenant
config: AppConfig
}
export async function loadTenantContext(slug: string): Promise<LoadedTenantContext | null> {
const { data: tenant, error: tenantError } = await supabase
.from('tenants')
.select('*')
.eq('slug', slug)
.eq('ativo', true)
.maybeSingle()
if (tenantError) throw new Error(tenantError.message)
if (!tenant) return null
const { data: config, error: configError } = await supabase
.from('app_config')
.select('*')
.eq('tenant_id', tenant.id)
.maybeSingle()
if (configError) throw new Error(configError.message)
if (!config) return null
return { tenant, config }
}

View File

@ -43,6 +43,8 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Reserva-Token': API_TOKEN, 'X-Reserva-Token': API_TOKEN,
// Pula a pagina de aviso do ngrok quando o chatwoot esta atras do tunnel
'ngrok-skip-browser-warning': 'true',
...(init.headers ?? {}), ...(init.headers ?? {}),
}, },
}) })

25
src/lib/tenant.ts Normal file
View File

@ -0,0 +1,25 @@
// Resolve o tenant slug a partir do hostname.
// Prod: https://prime.reserva.fazer.ai → slug = "prime"
// Dev: http://localhost:5180 → fallback VITE_DEFAULT_TENANT_SLUG
// Cloudflared tunnel: *.trycloudflare.com → fallback VITE_DEFAULT_TENANT_SLUG
export function resolveTenantSlug(): string {
const defaultSlug = import.meta.env.VITE_DEFAULT_TENANT_SLUG || 'grupo-1001'
if (typeof window === 'undefined') {
return defaultSlug
}
const host = window.location.hostname
if (host === 'localhost' || host.startsWith('127.') || host.endsWith('.local')) {
return defaultSlug
}
if (host.endsWith('.trycloudflare.com') || host.endsWith('.loca.lt') || host.endsWith('.ngrok-free.dev')) {
return defaultSlug
}
const parts = host.split('.')
return parts[0] || defaultSlug
}

View File

@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App' import App from './App'
import { TenantProvider } from '@/contexts/TenantProvider'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <TenantProvider>
<App />
</TenantProvider>
</StrictMode> </StrictMode>
) )

View File

@ -8,20 +8,22 @@ type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']
type Extra = Database['reserva_hotel']['Tables']['extras']['Row'] type Extra = Database['reserva_hotel']['Tables']['extras']['Row']
export const catalogoService = { export const catalogoService = {
async listMarcas(): Promise<Marca[]> { async listMarcas(tenantId: number): Promise<Marca[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from('marcas') .from('marcas')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('ativa', true) .eq('ativa', true)
.order('nome') .order('nome')
if (error) throw new Error(error.message) if (error) throw new Error(error.message)
return data ?? [] return data ?? []
}, },
async listUnidades(marcaId: string): Promise<Unidade[]> { async listUnidades(tenantId: number, marcaId: string): Promise<Unidade[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from('unidades') .from('unidades')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('id_marca', marcaId) .eq('id_marca', marcaId)
.eq('ativa', true) .eq('ativa', true)
.order('nome') .order('nome')
@ -30,6 +32,7 @@ export const catalogoService = {
}, },
async findPreco( async findPreco(
tenantId: number,
marcaId: string, marcaId: string,
categoria: string, categoria: string,
permanencia: string, permanencia: string,
@ -38,6 +41,7 @@ export const catalogoService = {
const { data, error } = await supabase const { data, error } = await supabase
.from('precos') .from('precos')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('id_marca', marcaId) .eq('id_marca', marcaId)
.eq('categoria', categoria) .eq('categoria', categoria)
.eq('permanencia', permanencia) .eq('permanencia', permanencia)
@ -48,10 +52,11 @@ export const catalogoService = {
return data return data
}, },
async listFotos(unidadeId: string, categoria: string): Promise<Foto[]> { async listFotos(tenantId: number, unidadeId: string, categoria: string): Promise<Foto[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from('fotos_categoria') .from('fotos_categoria')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('id_unidade', unidadeId) .eq('id_unidade', unidadeId)
.eq('categoria', categoria) .eq('categoria', categoria)
.eq('ativa', true) .eq('ativa', true)
@ -60,10 +65,11 @@ export const catalogoService = {
return data ?? [] return data ?? []
}, },
async listExtras(marcaId: string): Promise<Extra[]> { async listExtras(tenantId: number, marcaId: string): Promise<Extra[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from('extras') .from('extras')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('id_marca', marcaId) .eq('id_marca', marcaId)
.eq('ativo', true) .eq('ativo', true)
.order('ordem') .order('ordem')

View File

@ -901,5 +901,3 @@ export const Constants = {
Enums: {}, Enums: {},
}, },
} as const } as const
A new version of Supabase CLI is available: v2.90.0 (currently installed v2.48.3)
We recommend updating regularly for new features and bug fixes: https://supabase.com/docs/guides/cli/getting-started#updating-the-supabase-cli

View File

@ -13,10 +13,15 @@ export default defineConfig({
server: { server: {
port: 5180, port: 5180,
strictPort: true, strictPort: true,
host: true,
// Aceita qualquer host — usado quando rodamos atrás de um tunnel (loca.lt, ngrok, cloudflared)
allowedHosts: true,
}, },
preview: { preview: {
port: 5180, port: 5180,
strictPort: true, strictPort: true,
host: true,
allowedHosts: true,
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',