diff --git a/.env.local.example b/.env.local.example
index 5fcb7dc..6a4ecda 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -6,3 +6,6 @@ VITE_SUPABASE_SCHEMA=reserva_hotel
# Chatwoot — token de integração (Fase 2)
VITE_CHATWOOT_API_URL=https://chatwoot.fazer.ai
VITE_CHATWOOT_API_TOKEN=
+
+# Multi-tenant: slug default quando rodando em localhost/tunnel (sem subdomínio)
+VITE_DEFAULT_TENANT_SLUG=grupo-1001
diff --git a/src/App.tsx b/src/App.tsx
index 30dad37..2aed13a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,25 +1,52 @@
import { ReservationFlow } from '@/components/reservation/ReservationFlow'
+import { useTenant } from '@/contexts/TenantProvider'
+import { useAppConfig } from '@/hooks/useAppConfig'
export default function App() {
+ const { loading, error } = useTenant()
+ const config = useAppConfig()
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error || !config) {
+ return (
+
+
+ {error ?? 'Configuração não encontrada'}
+
+
+ )
+ }
+
return (
-
- Experiência exclusiva
-
+ {config.subtitulo_hero && (
+
+ {config.subtitulo_hero}
+
+ )}
- Reserva Rede 1001
+ {config.titulo_hero}
-
- Escolha, confirme e receba seu PIX na hora.
-
+ {config.tagline && (
+ {config.tagline}
+ )}
-
+ {config.footer_text && (
+
+ )}
)
}
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
index 2653424..4528fba 100644
--- a/src/__tests__/App.test.tsx
+++ b/src/__tests__/App.test.tsx
@@ -1,6 +1,55 @@
import { render, screen } from '@testing-library/react'
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', () => ({
useReservationForm: () => ({
form: {
diff --git a/src/contexts/TenantProvider.tsx b/src/contexts/TenantProvider.tsx
new file mode 100644
index 0000000..110019e
--- /dev/null
+++ b/src/contexts/TenantProvider.tsx
@@ -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
+}
+
+const TenantContext = createContext(null)
+
+export function TenantProvider({ children }: { children: ReactNode }) {
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [context, setContext] = useState(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 (
+
+ {children}
+
+ )
+}
+
+// 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 ')
+ 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("link[rel='icon']")
+ if (!link) {
+ link = document.createElement('link')
+ link.rel = 'icon'
+ document.head.appendChild(link)
+ }
+ link.href = config.favicon_url
+ }
+}
diff --git a/src/hooks/useAppConfig.ts b/src/hooks/useAppConfig.ts
new file mode 100644
index 0000000..8a3cd3f
--- /dev/null
+++ b/src/hooks/useAppConfig.ts
@@ -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
+}
diff --git a/src/hooks/useReservationForm.ts b/src/hooks/useReservationForm.ts
index 046d08d..0f2b758 100644
--- a/src/hooks/useReservationForm.ts
+++ b/src/hooks/useReservationForm.ts
@@ -3,6 +3,7 @@ import { catalogoService } from '@/services/catalogoService'
import type { Database } from '@/types/database'
import type { PrefillData } from '@/lib/prefill'
import { prefillSimpleFields } from '@/lib/prefill'
+import { useTenantId } from '@/hooks/useAppConfig'
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row']
@@ -36,6 +37,8 @@ const empty: ReservationFormState = {
}
export function useReservationForm(initialPrefill?: PrefillData) {
+ const tenantId = useTenantId()
+
const [form, setForm] = useState(() => ({
...empty,
...prefillSimpleFields(initialPrefill ?? {}),
@@ -51,44 +54,48 @@ export function useReservationForm(initialPrefill?: PrefillData) {
const unidadePrefillAppliedRef = useRef(false)
useEffect(() => {
+ if (!tenantId) return
catalogoService
- .listMarcas()
+ .listMarcas(tenantId)
.then(setMarcas)
.catch((err: Error) => setError(err.message))
- }, [])
+ }, [tenantId])
useEffect(() => {
+ if (!tenantId) return
if (!form.marcaId) {
setUnidades([])
return
}
catalogoService
- .listUnidades(form.marcaId)
+ .listUnidades(tenantId, form.marcaId)
.then(setUnidades)
.catch((err: Error) => setError(err.message))
- }, [form.marcaId])
+ }, [tenantId, form.marcaId])
useEffect(() => {
+ if (!tenantId) return
if (!form.marcaId || !form.categoria || !form.permanencia) {
setPreco(null)
return
}
catalogoService
- .findPreco(form.marcaId, form.categoria, form.permanencia)
+ .findPreco(tenantId, form.marcaId, form.categoria, form.permanencia)
.then(setPreco)
.catch((err: Error) => setError(err.message))
- }, [form.marcaId, form.categoria, form.permanencia])
+ }, [tenantId, form.marcaId, form.categoria, form.permanencia])
useEffect(() => {
+ if (!tenantId) return
if (!form.unidadeId || !form.categoria) {
setFotos([])
return
}
catalogoService
- .listFotos(form.unidadeId, form.categoria)
+ .listFotos(tenantId, form.unidadeId, form.categoria)
.then(setFotos)
.catch((err: Error) => setError(err.message))
- }, [form.unidadeId, form.categoria])
+ }, [tenantId, form.unidadeId, form.categoria])
// Resolve prefill: marcaNome -> marcaId quando marcas carregam
useEffect(() => {
diff --git a/src/lib/appConfig.ts b/src/lib/appConfig.ts
new file mode 100644
index 0000000..09e75b2
--- /dev/null
+++ b/src/lib/appConfig.ts
@@ -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 {
+ 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 }
+}
diff --git a/src/lib/chatwootApi.ts b/src/lib/chatwootApi.ts
index 4522829..3d4021d 100644
--- a/src/lib/chatwootApi.ts
+++ b/src/lib/chatwootApi.ts
@@ -43,6 +43,8 @@ async function request(path: string, init: RequestInit = {}): Promise {
headers: {
'Content-Type': 'application/json',
'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 ?? {}),
},
})
diff --git a/src/lib/tenant.ts b/src/lib/tenant.ts
new file mode 100644
index 0000000..99fc447
--- /dev/null
+++ b/src/lib/tenant.ts
@@ -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
+}
diff --git a/src/main.tsx b/src/main.tsx
index 520b520..b5991b2 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
+import { TenantProvider } from '@/contexts/TenantProvider'
createRoot(document.getElementById('root')!).render(
-
+
+
+
)
diff --git a/src/services/catalogoService.ts b/src/services/catalogoService.ts
index de784fd..625d437 100644
--- a/src/services/catalogoService.ts
+++ b/src/services/catalogoService.ts
@@ -8,20 +8,22 @@ type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']
type Extra = Database['reserva_hotel']['Tables']['extras']['Row']
export const catalogoService = {
- async listMarcas(): Promise {
+ async listMarcas(tenantId: number): Promise {
const { data, error } = await supabase
.from('marcas')
.select('*')
+ .eq('tenant_id', tenantId)
.eq('ativa', true)
.order('nome')
if (error) throw new Error(error.message)
return data ?? []
},
- async listUnidades(marcaId: string): Promise {
+ async listUnidades(tenantId: number, marcaId: string): Promise {
const { data, error } = await supabase
.from('unidades')
.select('*')
+ .eq('tenant_id', tenantId)
.eq('id_marca', marcaId)
.eq('ativa', true)
.order('nome')
@@ -30,6 +32,7 @@ export const catalogoService = {
},
async findPreco(
+ tenantId: number,
marcaId: string,
categoria: string,
permanencia: string,
@@ -38,6 +41,7 @@ export const catalogoService = {
const { data, error } = await supabase
.from('precos')
.select('*')
+ .eq('tenant_id', tenantId)
.eq('id_marca', marcaId)
.eq('categoria', categoria)
.eq('permanencia', permanencia)
@@ -48,10 +52,11 @@ export const catalogoService = {
return data
},
- async listFotos(unidadeId: string, categoria: string): Promise {
+ async listFotos(tenantId: number, unidadeId: string, categoria: string): Promise {
const { data, error } = await supabase
.from('fotos_categoria')
.select('*')
+ .eq('tenant_id', tenantId)
.eq('id_unidade', unidadeId)
.eq('categoria', categoria)
.eq('ativa', true)
@@ -60,10 +65,11 @@ export const catalogoService = {
return data ?? []
},
- async listExtras(marcaId: string): Promise {
+ async listExtras(tenantId: number, marcaId: string): Promise {
const { data, error } = await supabase
.from('extras')
.select('*')
+ .eq('tenant_id', tenantId)
.eq('id_marca', marcaId)
.eq('ativo', true)
.order('ordem')
diff --git a/src/types/database.ts b/src/types/database.ts
index b932d16..86a9cd0 100644
--- a/src/types/database.ts
+++ b/src/types/database.ts
@@ -901,5 +901,3 @@ export const Constants = {
Enums: {},
},
} 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
diff --git a/vite.config.ts b/vite.config.ts
index 6b595b4..57378ff 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,10 +13,15 @@ export default defineConfig({
server: {
port: 5180,
strictPort: true,
+ host: true,
+ // Aceita qualquer host — usado quando rodamos atrás de um tunnel (loca.lt, ngrok, cloudflared)
+ allowedHosts: true,
},
preview: {
port: 5180,
strictPort: true,
+ host: true,
+ allowedHosts: true,
},
test: {
environment: 'jsdom',