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

Carregando...

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

Painel Administrativo

+
+ {user?.email} + +
+
+ +
+ +
+
+
+ ) +} 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 ( +
+
+
+

Painel Administrativo

+

Faça login pra gerenciar sua rede

+
+ + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + {error && ( +
+ {error} +
+ )} + + + +
+ ) +} 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 +}