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:
parent
f4980f026c
commit
d1ee2bdfa1
@ -24,7 +24,9 @@
|
|||||||
"motion": "^12.4.0",
|
"motion": "^12.4.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
"tailwind-merge": "^2.5.0"
|
"tailwind-merge": "^2.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
59
pnpm-lock.yaml
generated
59
pnpm-lock.yaml
generated
@ -35,9 +35,15 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.5
|
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:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.5(react@19.2.5)
|
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:
|
tailwind-merge:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.6.1
|
version: 2.6.1
|
||||||
@ -1006,6 +1012,10 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
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:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@ -1612,6 +1622,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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:
|
react-dom@19.2.5:
|
||||||
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
|
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1624,6 +1640,23 @@ packages:
|
|||||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
react@19.2.5:
|
||||||
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -1666,6 +1699,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2:
|
||||||
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2756,6 +2792,8 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@ -3319,6 +3357,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
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):
|
react-dom@19.2.5(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
@ -3328,6 +3371,20 @@ snapshots:
|
|||||||
|
|
||||||
react-refresh@0.18.0: {}
|
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: {}
|
react@19.2.5: {}
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
@ -3384,6 +3441,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
|
|||||||
@ -82,7 +82,7 @@ vi.mock('@/hooks/useReservationForm', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import App from '@/App'
|
import App from '@/pages/ReservationPage'
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('renderiza o titulo premium', () => {
|
it('renderiza o titulo premium', () => {
|
||||||
|
|||||||
20
src/components/admin/AuthGate.tsx
Normal file
20
src/components/admin/AuthGate.tsx
Normal 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
36
src/hooks/useAuth.ts
Normal 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
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
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 { AppRouter } from './router'
|
||||||
import { TenantProvider } from '@/contexts/TenantProvider'
|
import { TenantProvider } from '@/contexts/TenantProvider'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<TenantProvider>
|
<TenantProvider>
|
||||||
<App />
|
<AppRouter />
|
||||||
</TenantProvider>
|
</TenantProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { ReservationFlow } from '@/components/reservation/ReservationFlow'
|
|||||||
import { useTenant } from '@/contexts/TenantProvider'
|
import { useTenant } from '@/contexts/TenantProvider'
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||||
|
|
||||||
export default function App() {
|
export default function ReservationPage() {
|
||||||
const { loading, error } = useTenant()
|
const { loading, error } = useTenant()
|
||||||
const config = useAppConfig()
|
const config = useAppConfig()
|
||||||
|
|
||||||
46
src/pages/admin/AdminLayout.tsx
Normal file
46
src/pages/admin/AdminLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
src/pages/admin/AparenciaTab.tsx
Normal file
206
src/pages/admin/AparenciaTab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/pages/admin/LoginPage.tsx
Normal file
69
src/pages/admin/LoginPage.tsx
Normal 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
22
src/router.tsx
Normal 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} />
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user