diff --git a/package.json b/package.json
index 42ca0e3..a6cb514 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,9 @@
"motion": "^12.4.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
+ "react-colorful": "^5.6.1",
"react-dom": "^19.1.0",
+ "react-router-dom": "^7.14.1",
"tailwind-merge": "^2.5.0"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 79df99e..7d42831 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,9 +35,15 @@ importers:
react:
specifier: ^19.1.0
version: 19.2.5
+ react-colorful:
+ specifier: ^5.6.1
+ version: 5.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-dom:
specifier: ^19.1.0
version: 19.2.5(react@19.2.5)
+ react-router-dom:
+ specifier: ^7.14.1
+ version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
tailwind-merge:
specifier: ^2.5.0
version: 2.6.1
@@ -1006,6 +1012,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1612,6 +1622,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-colorful@5.6.1:
+ resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
@@ -1624,6 +1640,23 @@ packages:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
+ react-router-dom@7.14.1:
+ resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.14.1:
+ resolution: {integrity: sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
react@19.2.5:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
@@ -1666,6 +1699,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2756,6 +2792,8 @@ snapshots:
convert-source-map@2.0.0: {}
+ cookie@1.1.1: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -3319,6 +3357,11 @@ snapshots:
dependencies:
react: 19.2.5
+ react-colorful@5.6.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ dependencies:
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
@@ -3328,6 +3371,20 @@ snapshots:
react-refresh@0.18.0: {}
+ react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ dependencies:
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-router: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+
+ react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.5
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.5(react@19.2.5)
+
react@19.2.5: {}
redent@3.0.0:
@@ -3384,6 +3441,8 @@ snapshots:
semver@7.7.4: {}
+ set-cookie-parser@2.7.2: {}
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
index 4528fba..09e9ee7 100644
--- a/src/__tests__/App.test.tsx
+++ b/src/__tests__/App.test.tsx
@@ -82,7 +82,7 @@ vi.mock('@/hooks/useReservationForm', () => ({
}),
}))
-import App from '@/App'
+import App from '@/pages/ReservationPage'
describe('App', () => {
it('renderiza o titulo premium', () => {
diff --git a/src/components/admin/AuthGate.tsx b/src/components/admin/AuthGate.tsx
new file mode 100644
index 0000000..d133ebc
--- /dev/null
+++ b/src/components/admin/AuthGate.tsx
@@ -0,0 +1,20 @@
+import { Navigate, useLocation } from 'react-router-dom'
+import { useAuth } from '@/hooks/useAuth'
+import type { ReactNode } from 'react'
+
+export function AuthGate({ children }: { children: ReactNode }) {
+ const { session, loading } = useAuth()
+ const location = useLocation()
+
+ if (loading) {
+ return (
+
+ )
+ }
+ if (!session) {
+ return
+ }
+ return <>{children}>
+}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
new file mode 100644
index 0000000..10091c6
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -0,0 +1,36 @@
+import { useEffect, useState } from 'react'
+import type { Session, User } from '@supabase/supabase-js'
+import { supabase } from '@/lib/supabase'
+
+export function useAuth() {
+ const [session, setSession] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ supabase.auth.getSession().then(({ data }) => {
+ setSession(data.session)
+ setLoading(false)
+ })
+ const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
+ setSession(newSession)
+ })
+ return () => sub.subscription.unsubscribe()
+ }, [])
+
+ const signIn = async (email: string, password: string) => {
+ const { error } = await supabase.auth.signInWithPassword({ email, password })
+ if (error) throw new Error(error.message)
+ }
+
+ const signOut = async () => {
+ await supabase.auth.signOut()
+ }
+
+ return { session, user: session?.user ?? null, loading, signIn, signOut }
+}
+
+export function resolveUserTenantId(user: User | null): number | null {
+ const raw = user?.user_metadata?.tenant_id ?? user?.app_metadata?.tenant_id
+ const parsed = typeof raw === 'number' ? raw : Number(raw)
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
+}
diff --git a/src/main.tsx b/src/main.tsx
index b5991b2..67812be 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,13 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
-import App from './App'
+import { AppRouter } from './router'
import { TenantProvider } from '@/contexts/TenantProvider'
createRoot(document.getElementById('root')!).render(
-
+
)
diff --git a/src/App.tsx b/src/pages/ReservationPage.tsx
similarity index 97%
rename from src/App.tsx
rename to src/pages/ReservationPage.tsx
index 2aed13a..22ac7d0 100644
--- a/src/App.tsx
+++ b/src/pages/ReservationPage.tsx
@@ -2,7 +2,7 @@ import { ReservationFlow } from '@/components/reservation/ReservationFlow'
import { useTenant } from '@/contexts/TenantProvider'
import { useAppConfig } from '@/hooks/useAppConfig'
-export default function App() {
+export default function ReservationPage() {
const { loading, error } = useTenant()
const config = useAppConfig()
diff --git a/src/pages/admin/AdminLayout.tsx b/src/pages/admin/AdminLayout.tsx
new file mode 100644
index 0000000..c90d371
--- /dev/null
+++ b/src/pages/admin/AdminLayout.tsx
@@ -0,0 +1,46 @@
+import { NavLink, Outlet } from 'react-router-dom'
+import { AuthGate } from '@/components/admin/AuthGate'
+import { useAuth } from '@/hooks/useAuth'
+import { Button } from '@/components/ui/button'
+
+const TABS = [{ to: 'aparencia', label: 'Aparência' }]
+
+export function AdminLayout() {
+ const { user, signOut } = useAuth()
+
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/AparenciaTab.tsx b/src/pages/admin/AparenciaTab.tsx
new file mode 100644
index 0000000..dcabd48
--- /dev/null
+++ b/src/pages/admin/AparenciaTab.tsx
@@ -0,0 +1,206 @@
+import { useEffect, useState } from 'react'
+import { HexColorPicker } from 'react-colorful'
+import { supabase } from '@/lib/supabase'
+import { useTenant } from '@/contexts/TenantProvider'
+import { FormField } from '@/components/FormField'
+import { SelectField } from '@/components/SelectField'
+import { Button } from '@/components/ui/button'
+
+const FONT_OPTIONS = [
+ { value: 'Fraunces', label: 'Fraunces (serif moderna)' },
+ { value: 'Playfair Display', label: 'Playfair Display (serif clássica)' },
+ { value: 'Cormorant Garamond', label: 'Cormorant Garamond (serif elegante)' },
+ { value: 'Lora', label: 'Lora (serif legível)' },
+ { value: 'Inter', label: 'Inter (sans moderna)' },
+ { value: 'Poppins', label: 'Poppins (sans geométrica)' },
+ { value: 'Manrope', label: 'Manrope (sans minimalista)' },
+ { value: 'DM Sans', label: 'DM Sans (sans neutra)' },
+]
+
+export function AparenciaTab() {
+ const { context, refresh } = useTenant()
+ const [form, setForm] = useState(context?.config ?? null)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const [successMsg, setSuccessMsg] = useState(null)
+
+ useEffect(() => {
+ setForm(context?.config ?? null)
+ }, [context])
+
+ if (!form) return Carregando configuração…
+
+ const update = (key: K, value: (typeof form)[K]) => {
+ setForm({ ...form, [key]: value })
+ }
+
+ const handleSave = async () => {
+ if (!form) return
+ setSaving(true)
+ setError(null)
+ setSuccessMsg(null)
+ try {
+ const { error: err } = await supabase
+ .from('app_config')
+ .update({
+ nome_rede: form.nome_rede,
+ titulo_hero: form.titulo_hero,
+ subtitulo_hero: form.subtitulo_hero,
+ tagline: form.tagline,
+ footer_text: form.footer_text,
+ logo_url: form.logo_url,
+ favicon_url: form.favicon_url,
+ cor_primaria: form.cor_primaria,
+ cor_secundaria: form.cor_secundaria,
+ cor_fundo: form.cor_fundo,
+ cor_superficie: form.cor_superficie,
+ cor_texto: form.cor_texto,
+ fonte_display: form.fonte_display,
+ fonte_corpo: form.fonte_corpo,
+ })
+ .eq('tenant_id', form.tenant_id)
+
+ if (err) throw new Error(err.message)
+ await refresh()
+ setSuccessMsg('Aparência salva com sucesso!')
+ setTimeout(() => setSuccessMsg(null), 3000)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Erro ao salvar')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+ Aparência
+
+ Personalize o nome da sua rede, textos, cores e fontes. As mudanças aplicam no app assim que você salvar.
+
+
+
+
+ Textos
+ update('nome_rede', e.target.value)}
+ />
+ update('titulo_hero', e.target.value)}
+ />
+ update('subtitulo_hero', e.target.value || null)}
+ />
+ update('tagline', e.target.value || null)}
+ />
+ update('footer_text', e.target.value || null)}
+ />
+
+
+
+ Imagens
+ update('logo_url', e.target.value || null)}
+ placeholder="https://..."
+ />
+ update('favicon_url', e.target.value || null)}
+ placeholder="https://..."
+ />
+
+
+
+ Cores
+
+ update('cor_primaria', v)} />
+ update('cor_secundaria', v)} />
+ update('cor_fundo', v)} />
+ update('cor_superficie', v)} />
+ update('cor_texto', v)} />
+
+
+
+
+ Tipografia
+ update('fonte_display', e.target.value)}
+ options={FONT_OPTIONS}
+ />
+ update('fonte_corpo', e.target.value)}
+ options={FONT_OPTIONS}
+ />
+
+
+ {error && (
+
{error}
+ )}
+ {successMsg && (
+
{successMsg}
+ )}
+
+
+
+
+
+ )
+}
+
+interface ColorFieldProps {
+ label: string
+ value: string
+ onChange: (v: string) => void
+}
+
+function ColorField({ label, value, onChange }: ColorFieldProps) {
+ const [open, setOpen] = useState(false)
+ return (
+
+
+
+
+ {open && (
+
+
+
+ )}
+
+ )
+}
diff --git a/src/pages/admin/LoginPage.tsx b/src/pages/admin/LoginPage.tsx
new file mode 100644
index 0000000..877a27d
--- /dev/null
+++ b/src/pages/admin/LoginPage.tsx
@@ -0,0 +1,69 @@
+import { useState, type FormEvent } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuth } from '@/hooks/useAuth'
+import { FormField } from '@/components/FormField'
+import { Button } from '@/components/ui/button'
+
+export function LoginPage() {
+ const { signIn } = useAuth()
+ const navigate = useNavigate()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setSubmitting(true)
+ setError(null)
+ try {
+ await signIn(email, password)
+ navigate('/admin')
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Erro ao entrar')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/router.tsx b/src/router.tsx
new file mode 100644
index 0000000..cdf5b37
--- /dev/null
+++ b/src/router.tsx
@@ -0,0 +1,22 @@
+import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
+import ReservationPage from '@/pages/ReservationPage'
+import { LoginPage } from '@/pages/admin/LoginPage'
+import { AdminLayout } from '@/pages/admin/AdminLayout'
+import { AparenciaTab } from '@/pages/admin/AparenciaTab'
+
+const router = createBrowserRouter([
+ { path: '/', element: },
+ { path: '/admin/login', element: },
+ {
+ path: '/admin',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'aparencia', element: },
+ ],
+ },
+])
+
+export function AppRouter() {
+ return
+}