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

Carregando...

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

+ )}
-
- © 2026 Reserva Rede 1001 · Experiência Exclusiva -
+ {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',