feat(fase4-d): router + admin shell + login + aba Aparencia

- react-router-dom + rotas / publica e /admin/*
- src/hooks/useAuth.ts com Supabase Auth
- AuthGate, LoginPage, AdminLayout com nav tabs e logout
- AparenciaTab edita identidade visual: textos, logo, 5 cores via
  react-colorful, 2 fontes via dropdown curado (Fraunces/Playfair/etc)
- Apos salvar, TenantProvider.refresh() re-aplica tema
- App.tsx renomeado pra pages/ReservationPage.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-14 21:10:27 -03:00
parent f4980f026c
commit d1ee2bdfa1
11 changed files with 464 additions and 4 deletions

View File

@ -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": {

59
pnpm-lock.yaml generated
View File

@ -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

View File

@ -82,7 +82,7 @@ vi.mock('@/hooks/useReservationForm', () => ({
}),
}))
import App from '@/App'
import App from '@/pages/ReservationPage'
describe('App', () => {
it('renderiza o titulo premium', () => {

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center">
<p className="text-slate font-sans">Carregando...</p>
</div>
)
}
if (!session) {
return <Navigate to="/admin/login" state={{ from: location }} replace />
}
return <>{children}</>
}

36
src/hooks/useAuth.ts Normal file
View File

@ -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<Session | null>(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
}

View File

@ -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(
<StrictMode>
<TenantProvider>
<App />
<AppRouter />
</TenantProvider>
</StrictMode>
)

View File

@ -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()

View File

@ -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 (
<AuthGate>
<div className="min-h-screen flex flex-col">
<header className="border-b border-champagne/20 bg-midnight/60 backdrop-blur px-6 py-4 flex items-center justify-between">
<h1 className="font-serif text-2xl text-champagne">Painel Administrativo</h1>
<div className="flex items-center gap-3 text-sm text-slate">
<span>{user?.email}</span>
<Button variant="ghost" size="sm" onClick={() => signOut()}>
Sair
</Button>
</div>
</header>
<nav className="border-b border-champagne/10 bg-obsidian/60 px-6 py-3 flex gap-1 overflow-x-auto">
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
`px-4 py-2 rounded-lg text-sm font-sans transition ${
isActive
? 'bg-champagne text-obsidian font-semibold'
: 'text-ivory hover:bg-champagne/10'
}`
}
>
{tab.label}
</NavLink>
))}
</nav>
<div className="flex-1 p-6 md:p-10">
<Outlet />
</div>
</div>
</AuthGate>
)
}

View File

@ -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<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
useEffect(() => {
setForm(context?.config ?? null)
}, [context])
if (!form) return <p className="text-slate">Carregando configuração</p>
const update = <K extends keyof typeof form>(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 (
<div className="max-w-3xl space-y-6">
<header>
<h1 className="font-serif text-3xl text-gradient-gold mb-2">Aparência</h1>
<p className="text-slate text-sm">
Personalize o nome da sua rede, textos, cores e fontes. As mudanças aplicam no app assim que você salvar.
</p>
</header>
<section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
<h2 className="font-serif text-2xl text-champagne">Textos</h2>
<FormField
label="Nome da rede"
required
value={form.nome_rede}
onChange={(e) => update('nome_rede', e.target.value)}
/>
<FormField
label="Título da página"
required
value={form.titulo_hero}
onChange={(e) => update('titulo_hero', e.target.value)}
/>
<FormField
label="Subtítulo (acima do título)"
value={form.subtitulo_hero ?? ''}
onChange={(e) => update('subtitulo_hero', e.target.value || null)}
/>
<FormField
label="Tagline (abaixo do título)"
value={form.tagline ?? ''}
onChange={(e) => update('tagline', e.target.value || null)}
/>
<FormField
label="Footer"
value={form.footer_text ?? ''}
onChange={(e) => update('footer_text', e.target.value || null)}
/>
</section>
<section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
<h2 className="font-serif text-2xl text-champagne">Imagens</h2>
<FormField
label="URL do logo"
value={form.logo_url ?? ''}
onChange={(e) => update('logo_url', e.target.value || null)}
placeholder="https://..."
/>
<FormField
label="URL do favicon"
value={form.favicon_url ?? ''}
onChange={(e) => update('favicon_url', e.target.value || null)}
placeholder="https://..."
/>
</section>
<section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
<h2 className="font-serif text-2xl text-champagne">Cores</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ColorField label="Primária (acento)" value={form.cor_primaria} onChange={(v) => update('cor_primaria', v)} />
<ColorField label="Secundária (acento 2)" value={form.cor_secundaria} onChange={(v) => update('cor_secundaria', v)} />
<ColorField label="Fundo" value={form.cor_fundo} onChange={(v) => update('cor_fundo', v)} />
<ColorField label="Superfície (cards)" value={form.cor_superficie} onChange={(v) => update('cor_superficie', v)} />
<ColorField label="Texto" value={form.cor_texto} onChange={(v) => update('cor_texto', v)} />
</div>
</section>
<section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
<h2 className="font-serif text-2xl text-champagne">Tipografia</h2>
<SelectField
label="Fonte dos títulos"
value={form.fonte_display}
onChange={(e) => update('fonte_display', e.target.value)}
options={FONT_OPTIONS}
/>
<SelectField
label="Fonte do corpo"
value={form.fonte_corpo}
onChange={(e) => update('fonte_corpo', e.target.value)}
options={FONT_OPTIONS}
/>
</section>
{error && (
<div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory">{error}</div>
)}
{successMsg && (
<div className="rounded-xl border border-emerald/40 bg-emerald/10 p-4 text-ivory">{successMsg}</div>
)}
<div className="flex gap-3 sticky bottom-6 z-10">
<Button variant="primary" size="lg" onClick={handleSave} disabled={saving}>
{saving ? 'Salvando...' : 'Salvar aparência'}
</Button>
</div>
</div>
)
}
interface ColorFieldProps {
label: string
value: string
onChange: (v: string) => void
}
function ColorField({ label, value, onChange }: ColorFieldProps) {
const [open, setOpen] = useState(false)
return (
<div>
<label className="font-sans text-xs uppercase tracking-widest text-champagne">{label}</label>
<div className="mt-2 flex items-center gap-3">
<button
type="button"
className="h-10 w-10 rounded-lg border border-champagne/30 shrink-0"
style={{ backgroundColor: value }}
onClick={() => setOpen(!open)}
aria-label={`Abrir color picker ${label}`}
/>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded-lg border border-champagne/30 bg-midnight/60 px-4 py-3 font-mono text-sm text-ivory focus:border-champagne focus:outline-none"
/>
</div>
{open && (
<div className="mt-3">
<HexColorPicker color={value} onChange={onChange} />
</div>
)}
</div>
)
}

View File

@ -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<string | null>(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 (
<main className="min-h-screen flex items-center justify-center px-6 py-12">
<form
onSubmit={handleSubmit}
className="w-full max-w-md space-y-6 rounded-2xl border border-champagne/20 bg-midnight/60 p-8 backdrop-blur"
>
<header className="text-center space-y-2">
<h1 className="font-serif text-3xl text-gradient-gold">Painel Administrativo</h1>
<p className="text-slate text-sm">Faça login pra gerenciar sua rede</p>
</header>
<FormField
label="E-mail"
required
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<FormField
label="Senha"
required
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && (
<div className="rounded-xl border border-ruby/40 bg-ruby/10 p-3 text-ivory text-sm">
{error}
</div>
)}
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={submitting}>
{submitting ? 'Entrando...' : 'Entrar'}
</Button>
</form>
</main>
)
}

22
src/router.tsx Normal file
View File

@ -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: <ReservationPage /> },
{ path: '/admin/login', element: <LoginPage /> },
{
path: '/admin',
element: <AdminLayout />,
children: [
{ index: true, element: <Navigate to="aparencia" replace /> },
{ path: 'aparencia', element: <AparenciaTab /> },
],
},
])
export function AppRouter() {
return <RouterProvider router={router} />
}