Area restrita - Questionario
This commit is contained in:
+28
-160
@@ -1,174 +1,42 @@
|
||||
/* App-specific styles */
|
||||
|
||||
/* Smooth scroll behavior */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: rgba(203, 244, 0, 0.3);
|
||||
color: inherit;
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #cbf400;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Image loading placeholder */
|
||||
img {
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
||||
}
|
||||
|
||||
/* Prevent text selection on interactive elements */
|
||||
button,
|
||||
a {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Line clamp utilities */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-4 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive video container */
|
||||
.video-container {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
GLOW BUTTON - Animacoes de brilho
|
||||
============================================================================ */
|
||||
|
||||
/* Pulso de brilho no botao */
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(203, 244, 0, 0.4),
|
||||
0 0 40px rgba(203, 244, 0, 0.2),
|
||||
0 0 60px rgba(203, 244, 0, 0.1);
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px rgba(203, 244, 0, 0.8),
|
||||
0 0 60px rgba(203, 244, 0, 0.5),
|
||||
0 0 90px rgba(203, 244, 0, 0.3),
|
||||
0 0 120px rgba(203, 244, 0, 0.1);
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Aura de brilho ao redor */
|
||||
@keyframes glow-aura {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.6;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
/* Brilho do icone */
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.2) rotate(10deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1) rotate(-5deg);
|
||||
}
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-glow-aura {
|
||||
animation: glow-aura 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-sparkle {
|
||||
animation: sparkle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.video-container iframe,
|
||||
.video-container video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.nav-sticky,
|
||||
.progress-bar,
|
||||
button[aria-label="Voltar ao topo"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
section {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.animate-float,
|
||||
.animate-pulse-glow,
|
||||
.animate-gradient,
|
||||
.animate-marquee {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.glass,
|
||||
.glass-dark {
|
||||
backdrop-filter: none;
|
||||
background: rgba(14, 14, 14, 0.95);
|
||||
}
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
+41
-46
@@ -1,18 +1,18 @@
|
||||
import { useEffect, useState, lazy, Suspense } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import Navigation from './sections/Navigation';
|
||||
import Footer from './sections/Footer';
|
||||
import WhatsAppWidget from './components/WhatsAppWidget';
|
||||
import CookieConsent from './components/CookieConsent';
|
||||
import HomologationBanner from './components/HomologationBanner';
|
||||
import Login from './pages/Login';
|
||||
|
||||
// Página inicial carrega direto (mais acessada)
|
||||
// Página inicial carrega direto
|
||||
import Home from './pages/Home';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
// Todas as outras páginas com lazy loading para reduzir bundle inicial
|
||||
// Todas as outras páginas com lazy loading
|
||||
const Sobre = lazy(() => import('./pages/Sobre'));
|
||||
const Blog = lazy(() => import('./pages/Blog'));
|
||||
const BlogPost = lazy(() => import('./pages/BlogPost'));
|
||||
@@ -21,7 +21,7 @@ const Contato = lazy(() => import('./pages/Contato'));
|
||||
const Privacidade = lazy(() => import('./pages/Privacidade'));
|
||||
const Termos = lazy(() => import('./pages/Termos'));
|
||||
|
||||
// Páginas de Serviços
|
||||
// Serviços
|
||||
const TiGerenciada = lazy(() => import('./pages/services/TiGerenciada'));
|
||||
const CloudComputing = lazy(() => import('./pages/services/CloudComputing'));
|
||||
const Seguranca = lazy(() => import('./pages/services/Seguranca'));
|
||||
@@ -33,7 +33,7 @@ const Servidores = lazy(() => import('./pages/services/Servidores'));
|
||||
const Consultoria = lazy(() => import('./pages/services/Consultoria'));
|
||||
const Noc = lazy(() => import('./pages/services/Noc'));
|
||||
|
||||
// Páginas de Tecnologias (SEO)
|
||||
// Tecnologias
|
||||
const Tecnologias = lazy(() => import('./pages/Tecnologias'));
|
||||
const WindowsServer = lazy(() => import('./pages/tech/WindowsServer'));
|
||||
const Linux = lazy(() => import('./pages/tech/Linux'));
|
||||
@@ -43,11 +43,11 @@ const Vmware = lazy(() => import('./pages/tech/Vmware'));
|
||||
const Sharepoint = lazy(() => import('./pages/tech/Sharepoint'));
|
||||
const Cisco = lazy(() => import('./pages/tech/Cisco'));
|
||||
|
||||
// Avaliador de Maturidade Tecnológica
|
||||
// Outras páginas
|
||||
const Avaliador = lazy(() => import('./pages/Avaliador'));
|
||||
const Condominios = lazy(() => import('./pages/Condominios'));
|
||||
|
||||
// Landing Pages por Nicho
|
||||
// Nichos
|
||||
const Contabilidade = lazy(() => import('./pages/nichos/Contabilidade'));
|
||||
const Advocacia = lazy(() => import('./pages/nichos/Advocacia'));
|
||||
const Monitoramento = lazy(() => import('./pages/nichos/Monitoramento'));
|
||||
@@ -55,7 +55,7 @@ const FirewallPfSense = lazy(() => import('./pages/nichos/FirewallPfSense'));
|
||||
const BackupCorporativo = lazy(() => import('./pages/nichos/BackupCorporativo'));
|
||||
const VoipEmpresarial = lazy(() => import('./pages/nichos/VoipEmpresarial'));
|
||||
|
||||
// SEO Local - Onde Atuamos
|
||||
// SEO Local
|
||||
const OndeAtuamos = lazy(() => import('./pages/OndeAtuamos'));
|
||||
const LocalGuarulhos = lazy(() => import('./pages/local-guarulhos'));
|
||||
const LocalMogi = lazy(() => import('./pages/local-mogi-das-cruzes'));
|
||||
@@ -73,7 +73,7 @@ const NichoClinicas = lazy(() => import('./pages/nicho-clinicas'));
|
||||
const NichoImobiliarias = lazy(() => import('./pages/nicho-imobiliarias'));
|
||||
const NichoRecrutamento = lazy(() => import('./pages/nicho-recrutamento-selecao'));
|
||||
|
||||
// Avanzato Tools
|
||||
// Ferramentas
|
||||
const Ferramentas = lazy(() => import('./pages/ferramentas'));
|
||||
const SpfChecker = lazy(() => import('./pages/ferramentas/SpfChecker'));
|
||||
const DkimChecker = lazy(() => import('./pages/ferramentas/DkimChecker'));
|
||||
@@ -83,48 +83,37 @@ const SslChecker = lazy(() => import('./pages/ferramentas/SslChecker'));
|
||||
const EmailSecurityScore = lazy(() => import('./pages/ferramentas/EmailSecurityScore'));
|
||||
const Microsoft365Analyzer = lazy(() => import('./pages/ferramentas/Microsoft365Analyzer'));
|
||||
|
||||
// Area do Cliente
|
||||
const AdminAreaCliente = lazy(() => import('./pages/area-cliente/Admin'));
|
||||
const FormularioPublico = lazy(() => import('./pages/area-cliente/FormularioPublico'));
|
||||
|
||||
import './App.css';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
// Componente para scroll ao topo ao mudar de página
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
// Refresh ScrollTrigger ao mudar de página
|
||||
ScrollTrigger.refresh();
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Componente de Progress Bar
|
||||
function ProgressBar() {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = (scrollTop / docHeight) * 100;
|
||||
setScrollProgress(progress);
|
||||
setScrollProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${scrollProgress}%` }}
|
||||
/>
|
||||
);
|
||||
return <div className="progress-bar" style={{ width: `${scrollProgress}%` }} />;
|
||||
}
|
||||
|
||||
// Loading simples para lazy load
|
||||
const PageLoader = () => (
|
||||
<div className="min-h-screen bg-[#0e0e0e] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[#cbf400] border-t-transparent rounded-full animate-spin" />
|
||||
@@ -133,21 +122,23 @@ const PageLoader = () => (
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<div className="relative bg-[#0e0e0e] min-h-screen">
|
||||
<ProgressBar />
|
||||
<Navigation />
|
||||
|
||||
<main>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Página Inicial - carrega direto */}
|
||||
{/* Home */}
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
{/* Sobre */}
|
||||
<Route path="/sobre" element={<Sobre />} />
|
||||
|
||||
{/* Login */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Serviços */}
|
||||
<Route path="/servicos/ti-gerenciada" element={<TiGerenciada />} />
|
||||
<Route path="/servicos/cloud-computing" element={<CloudComputing />} />
|
||||
@@ -167,7 +158,7 @@ function App() {
|
||||
{/* Cases */}
|
||||
<Route path="/cases" element={<Cases />} />
|
||||
|
||||
{/* Tecnologias (SEO) */}
|
||||
{/* Tecnologias */}
|
||||
<Route path="/tecnologias" element={<Tecnologias />} />
|
||||
<Route path="/suporte-windows-server" element={<WindowsServer />} />
|
||||
<Route path="/suporte-linux-empresas" element={<Linux />} />
|
||||
@@ -177,13 +168,13 @@ function App() {
|
||||
<Route path="/suporte-sharepoint" element={<Sharepoint />} />
|
||||
<Route path="/suporte-redes-cisco" element={<Cisco />} />
|
||||
|
||||
{/* Avaliador de Maturidade */}
|
||||
{/* Avaliador */}
|
||||
<Route path="/avaliacao" element={<Avaliador />} />
|
||||
|
||||
{/* Tecnologia para Condomínios */}
|
||||
{/* Condomínios */}
|
||||
<Route path="/tecnologia-para-condominios" element={<Condominios />} />
|
||||
|
||||
{/* Landing Pages por Nicho */}
|
||||
{/* Nichos */}
|
||||
<Route path="/ti-para-contabilidades" element={<Contabilidade />} />
|
||||
<Route path="/ti-para-advocacias" element={<Advocacia />} />
|
||||
<Route path="/monitoramento-empresarial" element={<Monitoramento />} />
|
||||
@@ -191,7 +182,7 @@ function App() {
|
||||
<Route path="/backup-corporativo" element={<BackupCorporativo />} />
|
||||
<Route path="/voip-empresarial" element={<VoipEmpresarial />} />
|
||||
|
||||
{/* SEO Local - Onde Atuamos */}
|
||||
{/* SEO Local */}
|
||||
<Route path="/onde-atuamos" element={<OndeAtuamos />} />
|
||||
<Route path="/suporte-ti-guarulhos" element={<LocalGuarulhos />} />
|
||||
<Route path="/suporte-ti-mogi-das-cruzes" element={<LocalMogi />} />
|
||||
@@ -209,14 +200,7 @@ function App() {
|
||||
<Route path="/suporte-ti-imobiliarias" element={<NichoImobiliarias />} />
|
||||
<Route path="/suporte-ti-recrutamento-selecao" element={<NichoRecrutamento />} />
|
||||
|
||||
{/* Contato */}
|
||||
<Route path="/contato" element={<Contato />} />
|
||||
|
||||
{/* Páginas Legais */}
|
||||
<Route path="/privacidade" element={<Privacidade />} />
|
||||
<Route path="/termos" element={<Termos />} />
|
||||
|
||||
{/* Avanzato Tools */}
|
||||
{/* Ferramentas */}
|
||||
<Route path="/ferramentas" element={<Ferramentas />} />
|
||||
<Route path="/ferramentas/spf-checker" element={<SpfChecker />} />
|
||||
<Route path="/ferramentas/dkim-checker" element={<DkimChecker />} />
|
||||
@@ -226,18 +210,29 @@ function App() {
|
||||
<Route path="/ferramentas/email-security-score" element={<EmailSecurityScore />} />
|
||||
<Route path="/ferramentas/microsoft-365-analyzer" element={<Microsoft365Analyzer />} />
|
||||
|
||||
{/* Area do Cliente */}
|
||||
<Route path="/area-do-cliente" element={<AdminAreaCliente />} />
|
||||
|
||||
{/* Formulario Publico */}
|
||||
<Route path="/formulario/:code" element={<FormularioPublico />} />
|
||||
|
||||
{/* Contato */}
|
||||
<Route path="/contato" element={<Contato />} />
|
||||
|
||||
{/* Legais */}
|
||||
<Route path="/privacidade" element={<Privacidade />} />
|
||||
<Route path="/termos" element={<Termos />} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<WhatsAppWidget />
|
||||
<CookieConsent />
|
||||
<HomologationBanner />
|
||||
</div>
|
||||
</Router>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { LOGIN_PATH } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
||||
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { AuthLayoutSkeleton } from "./AuthLayoutSkeleton";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
||||
];
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||
const DEFAULT_WIDTH = 280;
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 480;
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
||||
});
|
||||
const { isLoading, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
||||
}, [sidebarWidth]);
|
||||
|
||||
if (isLoading) {
|
||||
return <AuthLayoutSkeleton />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-center">
|
||||
Sign in to continue
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Access to this dashboard requires authentication. Continue to
|
||||
launch the login flow.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = LOGIN_PATH;
|
||||
}}
|
||||
size="lg"
|
||||
className="w-full shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": `${sidebarWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<AuthLayoutContent setSidebarWidth={setSidebarWidth}>
|
||||
{children}
|
||||
</AuthLayoutContent>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type AuthLayoutContentProps = {
|
||||
children: ReactNode;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
};
|
||||
|
||||
function AuthLayoutContent({
|
||||
children,
|
||||
setSidebarWidth,
|
||||
}: AuthLayoutContentProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { state, toggleSidebar } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const activeMenuItem = menuItems.find(item => item.path === location.pathname);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed) {
|
||||
setIsResizing(false);
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
||||
const newWidth = e.clientX - sidebarLeft;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [isResizing, setSidebarWidth]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative" ref={sidebarRef}>
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className="border-r-0"
|
||||
|
||||
>
|
||||
<SidebarHeader className="h-16 justify-center">
|
||||
<div className="flex items-center gap-3 px-2 transition-all w-full">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-semibold tracking-tight truncate">
|
||||
Navigation
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="gap-0">
|
||||
<SidebarMenu className="px-2 py-1">
|
||||
{menuItems.map(item => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => navigate(item.path)}
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon
|
||||
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Avatar className="h-9 w-9 border shrink-0">
|
||||
<AvatarFallback className="text-xs font-medium">
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
||||
<p className="text-sm font-medium truncate leading-none">
|
||||
{user?.name || "-"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
||||
{user?.email || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={logout}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
||||
onMouseDown={() => {
|
||||
if (isCollapsed) return;
|
||||
setIsResizing(true);
|
||||
}}
|
||||
style={{ zIndex: 50 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarInset>
|
||||
{isMobile && (
|
||||
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-backdrop-filter:backdrop-blur sticky top-0 z-40">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="tracking-tight text-foreground">
|
||||
{activeMenuItem?.label ?? "Menu"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main className="flex-1 p-4">{children}</main>
|
||||
</SidebarInset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
export function AuthLayoutSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* Sidebar skeleton */}
|
||||
<div className="w-70 border-r border-border bg-background p-4 space-y-6">
|
||||
{/* Logo area */}
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="space-y-2 px-2">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* User profile area at bottom */}
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-2 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content skeleton */}
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
{/* Content blocks */}
|
||||
<Skeleton className="h-12 w-48 rounded-lg" />
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,6 +135,7 @@ export const NAV_LINKS = [
|
||||
{ name: 'Cases', href: '/cases' },
|
||||
{ name: 'Blog', href: '/blog' },
|
||||
{ name: 'Contato', href: '/contato' },
|
||||
{ name: 'Area do Cliente', href: '/area-do-cliente' },
|
||||
];
|
||||
|
||||
// --- LINKS DO FOOTER --------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const LOGIN_PATH = "/login";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { trpc } from "@/providers/trpc";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { LOGIN_PATH } from "@/const";
|
||||
|
||||
type UseAuthOptions = {
|
||||
redirectOnUnauthenticated?: boolean;
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export function useAuth(options?: UseAuthOptions) {
|
||||
const { redirectOnUnauthenticated = false, redirectPath = LOGIN_PATH } =
|
||||
options ?? {};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = trpc.auth.me.useQuery(undefined, {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const logoutMutation = trpc.auth.logout.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.invalidate();
|
||||
navigate(redirectPath);
|
||||
},
|
||||
});
|
||||
|
||||
const logout = useCallback(() => logoutMutation.mutate(), [logoutMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectOnUnauthenticated && !isLoading && !user) {
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== redirectPath) {
|
||||
navigate(redirectPath);
|
||||
}
|
||||
}
|
||||
}, [redirectOnUnauthenticated, isLoading, user, navigate, redirectPath]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
user: user ?? null,
|
||||
isAuthenticated: !!user,
|
||||
isLoading: isLoading || logoutMutation.isPending,
|
||||
error,
|
||||
logout,
|
||||
refresh: refetch,
|
||||
}),
|
||||
[user, isLoading, logoutMutation.isPending, error, logout, refetch],
|
||||
);
|
||||
}
|
||||
+65
-356
@@ -1,366 +1,75 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Urbanist:wght@400;500;600;700;800;900&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--avanzato-green: #cbf400;
|
||||
--avanzato-black: #0e0e0e;
|
||||
--avanzato-white: #ffffff;
|
||||
--avanzato-gray: #1a1a1a;
|
||||
--avanzato-gray-light: #2a2a2a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Urbanist', sans-serif;
|
||||
background-color: var(--avanzato-black);
|
||||
color: var(--avanzato-white);
|
||||
overflow-x: hidden;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Urbanist', sans-serif;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--avanzato-black);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--avanzato-green);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #b8e000;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(203, 244, 0, 0.3);
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(203, 244, 0, 0.6);
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes draw-line {
|
||||
0% {
|
||||
stroke-dashoffset: 1000;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 8s ease infinite;
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* Glassmorphism */
|
||||
.glass {
|
||||
background: rgba(14, 14, 14, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(203, 244, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background: rgba(26, 26, 26, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Text gradient */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--avanzato-green), #d4ff1a);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
background: var(--avanzato-green);
|
||||
color: var(--avanzato-black);
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-family: 'Urbanist', sans-serif;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: 0 4px 15px rgba(203, 244, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(203, 244, 0, 0.5);
|
||||
background: #d4ff1a;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--avanzato-green);
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-family: 'Urbanist', sans-serif;
|
||||
border: 2px solid var(--avanzato-green);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--avanzato-green);
|
||||
color: var(--avanzato-black);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-3d {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.card-3d:hover {
|
||||
transform: perspective(1000px) rotateX(5deg) rotateY(-5deg) translateZ(20px);
|
||||
}
|
||||
|
||||
/* Input focus effect */
|
||||
.input-focus {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-focus::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--avanzato-green);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.input-focus:focus-within::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Noise texture overlay */
|
||||
.noise-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
opacity: 0.03;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Section padding */
|
||||
.section-padding {
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-padding {
|
||||
padding: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container-custom {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container-custom {
|
||||
padding: 0 48px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reveal animation */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.reveal.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Stagger children */
|
||||
.stagger-children > * {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.stagger-children.active > *:nth-child(1) { animation: fadeInUp 0.6s 0.1s forwards; }
|
||||
.stagger-children.active > *:nth-child(2) { animation: fadeInUp 0.6s 0.2s forwards; }
|
||||
.stagger-children.active > *:nth-child(3) { animation: fadeInUp 0.6s 0.3s forwards; }
|
||||
.stagger-children.active > *:nth-child(4) { animation: fadeInUp 0.6s 0.4s forwards; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Particle canvas */
|
||||
.particle-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: var(--avanzato-green);
|
||||
z-index: 9999;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.nav-sticky {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.nav-sticky.scrolled {
|
||||
background: rgba(14, 14, 14, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Service card border animation */
|
||||
.service-card-border {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.service-card-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, transparent, var(--avanzato-green), transparent);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.service-card-border:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Counter animation */
|
||||
.counter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Quote mark */
|
||||
.quote-mark {
|
||||
font-size: 120px;
|
||||
line-height: 1;
|
||||
color: var(--avanzato-green);
|
||||
opacity: 0.1;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
/* Responsive typography */
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2.5rem !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 2rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import './index.css'
|
||||
import { TRPCProvider } from "@/providers/trpc"
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<HelmetProvider>
|
||||
<App />
|
||||
</HelmetProvider>
|
||||
<BrowserRouter>
|
||||
<TRPCProvider>
|
||||
<App />
|
||||
</TRPCProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
+16
-17
@@ -1,21 +1,20 @@
|
||||
import Hero from '../sections/Hero';
|
||||
import Services from '../sections/Services';
|
||||
import CondominiosCTA from '../sections/CondominiosCTA';
|
||||
import About from '../sections/About';
|
||||
import Testimonials from '../sections/Testimonials';
|
||||
import Contact from '../sections/Contact';
|
||||
import { useState } from 'react'
|
||||
import '../App.css'
|
||||
|
||||
export default function Home() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const Home = () => {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Services />
|
||||
<CondominiosCTA />
|
||||
<About />
|
||||
<Testimonials />
|
||||
<Contact />
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function getOAuthUrl() {
|
||||
const kimiAuthUrl = import.meta.env.VITE_KIMI_AUTH_URL;
|
||||
const appID = import.meta.env.VITE_APP_ID;
|
||||
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
||||
const state = btoa(redirectUri);
|
||||
|
||||
const url = new URL(`${kimiAuthUrl}/api/oauth/authorize`);
|
||||
url.searchParams.set("client_id", appID);
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", "profile");
|
||||
url.searchParams.set("state", state);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Welcome</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
window.location.href = getOAuthUrl();
|
||||
}}
|
||||
>
|
||||
Sign in with Kimi
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/providers/trpc";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Plus, Copy, Check, Power, Trash2, RefreshCw,
|
||||
Eye, FileText, ChevronDown, ChevronUp
|
||||
} from "lucide-react";
|
||||
|
||||
export default function AdminAreaCliente() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: forms, isLoading } = trpc.form.list.useQuery();
|
||||
const createMutation = trpc.form.create.useMutation({
|
||||
onSuccess: () => { utils.form.list.invalidate(); setShowCreate(false); }
|
||||
});
|
||||
const statusMutation = trpc.form.updateStatus.useMutation({
|
||||
onSuccess: () => utils.form.list.invalidate()
|
||||
});
|
||||
const deleteMutation = trpc.form.delete.useMutation({
|
||||
onSuccess: () => utils.form.list.invalidate()
|
||||
});
|
||||
const regenerateMutation = trpc.form.regenerateCode.useMutation({
|
||||
onSuccess: () => utils.form.list.invalidate()
|
||||
});
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
const [expandedForm, setExpandedForm] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({ title: "", description: "", clientName: "", clientEmail: "" });
|
||||
|
||||
const copyCode = (code: string) => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/formulario/${code}`);
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
};
|
||||
|
||||
const handleCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.title || !formData.clientName) return;
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
active: "bg-green-500/20 text-green-400 border-green-500/30",
|
||||
inactive: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
archived: "bg-gray-500/20 text-gray-400 border-gray-500/30",
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
active: "Ativo",
|
||||
inactive: "Inativo",
|
||||
archived: "Arquivado",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0e0e0e] text-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
<span className="text-[#cbf400]">Area</span> do Cliente
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Painel administrativo - Gestao de formularios</p>
|
||||
</div>
|
||||
<Link to="/" className="text-gray-400 hover:text-[#cbf400] transition-colors text-sm">
|
||||
Voltar ao site
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-[#1a1a1a] rounded-xl p-4 border border-[#2a2a2a]">
|
||||
<div className="text-2xl font-bold text-[#cbf400]">{forms?.length || 0}</div>
|
||||
<div className="text-gray-400 text-sm">Formularios</div>
|
||||
</div>
|
||||
<div className="bg-[#1a1a1a] rounded-xl p-4 border border-[#2a2a2a]">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{forms?.filter(f => f.status === "active").length || 0}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">Ativos</div>
|
||||
</div>
|
||||
<div className="bg-[#1a1a1a] rounded-xl p-4 border border-[#2a2a2a]">
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
{forms?.filter(f => f.status === "inactive").length || 0}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">Inativos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criar Formulario */}
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="mb-6 bg-[#cbf400] text-[#0e0e0e] px-5 py-2.5 rounded-xl font-semibold hover:bg-[#b8e000] transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Novo Formulario
|
||||
</button>
|
||||
|
||||
{showCreate && (
|
||||
<form onSubmit={handleCreate} className="bg-[#1a1a1a] rounded-2xl p-6 border border-[#2a2a2a] mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4">Criar Novo Formulario</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titulo do formulario *"
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||
className="bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome do cliente *"
|
||||
required
|
||||
value={formData.clientName}
|
||||
onChange={e => setFormData({ ...formData, clientName: e.target.value })}
|
||||
className="bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email do cliente"
|
||||
value={formData.clientEmail}
|
||||
onChange={e => setFormData({ ...formData, clientEmail: e.target.value })}
|
||||
className="bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Descricao (opcional)"
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
className="bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button type="submit" disabled={createMutation.isPending}
|
||||
className="bg-[#cbf400] text-[#0e0e0e] px-6 py-2.5 rounded-xl font-semibold hover:bg-[#b8e000] transition-colors disabled:opacity-50">
|
||||
{createMutation.isPending ? "Criando..." : "Criar Formulario"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowCreate(false)}
|
||||
className="border border-[#2a2a2a] text-gray-400 px-6 py-2.5 rounded-xl hover:bg-[#2a2a2a] transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{createMutation.isSuccess && (
|
||||
<div className="mt-4 p-4 bg-green-500/10 border border-green-500/30 rounded-xl">
|
||||
<p className="text-green-400 font-medium">Formulario criado!</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Codigo: <span className="text-[#cbf400] font-mono">{createMutation.data?.code}</span>
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
URL: <span className="text-[#cbf400]">{window.location.origin}/formulario/{createMutation.data?.code}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Lista de Formularios */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-[#cbf400] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : !forms?.length ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
||||
<p>Nenhum formulario criado ainda.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{forms.map(form => (
|
||||
<div key={form.accessCode} className="bg-[#1a1a1a] rounded-2xl border border-[#2a2a2a] overflow-hidden">
|
||||
{/* Header do card */}
|
||||
<div className="p-5 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-semibold text-white">{form.title}</h3>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${statusColors[form.status]}`}>
|
||||
{statusLabels[form.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{form.clientName}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span>Codigo: <span className="text-[#cbf400] font-mono">{form.accessCode}</span></span>
|
||||
<span>Criado: {new Date(form.createdAt).toLocaleDateString('pt-BR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copiar URL */}
|
||||
<button
|
||||
onClick={() => copyCode(form.accessCode)}
|
||||
className="p-2 rounded-lg bg-[#2a2a2a] text-gray-400 hover:text-[#cbf400] hover:bg-[#333] transition-colors"
|
||||
title="Copiar URL do formulario"
|
||||
>
|
||||
{copiedCode === form.accessCode ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Ativar/Desativar */}
|
||||
<button
|
||||
onClick={() => statusMutation.mutate({
|
||||
accessCode: form.accessCode,
|
||||
status: form.status === "active" ? "inactive" : "active"
|
||||
})}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
form.status === "active"
|
||||
? "bg-green-500/10 text-green-400 hover:bg-green-500/20"
|
||||
: "bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20"
|
||||
}`}
|
||||
title={form.status === "active" ? "Desativar" : "Ativar"}
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Renovar codigo */}
|
||||
<button
|
||||
onClick={() => { if (confirm("Gerar novo codigo? O antigo deixara de funcionar.")) regenerateMutation.mutate({ accessCode: form.accessCode }); }}
|
||||
className="p-2 rounded-lg bg-[#2a2a2a] text-gray-400 hover:text-blue-400 hover:bg-[#333] transition-colors"
|
||||
title="Renovar codigo"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Excluir */}
|
||||
<button
|
||||
onClick={() => { if (confirm("Excluir permanentemente?")) deleteMutation.mutate({ accessCode: form.accessCode }); }}
|
||||
className="p-2 rounded-lg bg-[#2a2a2a] text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Expandir */}
|
||||
<button
|
||||
onClick={() => setExpandedForm(expandedForm === form.accessCode ? null : form.accessCode)}
|
||||
className="p-2 rounded-lg bg-[#2a2a2a] text-gray-400 hover:text-white hover:bg-[#333] transition-colors"
|
||||
>
|
||||
{expandedForm === form.accessCode ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detalhes expandidos */}
|
||||
{expandedForm === form.accessCode && (
|
||||
<div className="px-5 pb-5 border-t border-[#2a2a2a]">
|
||||
<div className="pt-4">
|
||||
<p className="text-gray-400 text-sm mb-3">{form.description}</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
to={`/formulario/${form.accessCode}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-sm text-[#cbf400] hover:underline"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Visualizar formulario
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { trpc } from "@/providers/trpc";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Send, CheckCircle, AlertTriangle, ArrowLeft,
|
||||
Shield, FileText, ChevronDown, ChevronUp
|
||||
} from "lucide-react";
|
||||
|
||||
export default function FormularioPublico() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const { data: form, isLoading, error } = trpc.form.getByCode.useQuery(
|
||||
{ code: code || "" },
|
||||
{ enabled: !!code }
|
||||
);
|
||||
const submitMutation = trpc.form.submit.useMutation();
|
||||
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submittedBy, setSubmittedBy] = useState("");
|
||||
const [submittedByEmail, setSubmittedByEmail] = useState("");
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
const handleAnswer = (questionId: string, value: string) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
const handleMultiSelect = (questionId: string, option: string) => {
|
||||
const current = answers[questionId] || "";
|
||||
const selected = current ? current.split(", ") : [];
|
||||
if (selected.includes(option)) {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: selected.filter(s => s !== option).join(", ") }));
|
||||
} else {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: [...selected, option].join(", ") }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form || !code) return;
|
||||
|
||||
const answersArray = Object.entries(answers)
|
||||
.filter(([, v]) => v.trim())
|
||||
.map(([questionId, answer]) => ({ questionId, answer }));
|
||||
|
||||
submitMutation.mutate({
|
||||
formId: form.id?.toString() || code,
|
||||
code,
|
||||
answers: answersArray,
|
||||
submittedBy,
|
||||
submittedByEmail,
|
||||
}, {
|
||||
onSuccess: () => setSubmitted(true),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections(prev => ({ ...prev, [sectionId]: !prev[sectionId] }));
|
||||
};
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0e0e0e] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[#cbf400] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Formulario nao encontrado
|
||||
if (!form) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0e0e0e] flex items-center justify-center text-center px-6">
|
||||
<div>
|
||||
<AlertTriangle className="w-16 h-16 text-yellow-400 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Formulario nao encontrado</h1>
|
||||
<p className="text-gray-400">O codigo informado nao corresponde a nenhum formulario ativo.</p>
|
||||
<Link to="/" className="text-[#cbf400] hover:underline mt-4 inline-block">
|
||||
Voltar ao site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bloqueado (inativo)
|
||||
if ("_blocked" in form && form._blocked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0e0e0e] flex items-center justify-center text-center px-6">
|
||||
<div>
|
||||
<AlertTriangle className="w-16 h-16 text-yellow-400 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Formulario indisponivel</h1>
|
||||
<p className="text-gray-400">Este formulario esta temporariamente desativado.</p>
|
||||
<p className="text-gray-500 text-sm mt-2">Entre em contato com a Avanzato para mais informacoes.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sucesso apos submissao
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0e0e0e] flex items-center justify-center text-center px-6">
|
||||
<div className="max-w-md">
|
||||
<CheckCircle className="w-20 h-20 text-[#cbf400] mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-bold text-white mb-4">Respostas enviadas!</h1>
|
||||
<p className="text-gray-400 mb-2">Obrigado por preencher o formulario.</p>
|
||||
<p className="text-gray-500 text-sm mb-8">
|
||||
A Avanzato analisara suas respostas e entrara em contato em breve.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 bg-[#cbf400] text-[#0e0e0e] px-6 py-3 rounded-xl font-semibold hover:bg-[#b8e000] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar ao site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dados do formulario
|
||||
const typedForm = form as {
|
||||
id?: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
clientName: string;
|
||||
questions?: Array<{
|
||||
id: number;
|
||||
question: string;
|
||||
fieldType: string;
|
||||
options: string | null;
|
||||
required: string;
|
||||
sectionId: string | null;
|
||||
}>;
|
||||
sections?: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const questions = typedForm.questions || [];
|
||||
const sections = typedForm.sections || [];
|
||||
|
||||
// Agrupar perguntas por secao
|
||||
const groupedBySection = sections.map(section => ({
|
||||
...section,
|
||||
questions: questions.filter(q => q.sectionId === `s${section.id}` || q.sectionId?.includes(`s${section.id}`)),
|
||||
}));
|
||||
|
||||
const renderField = (q: typeof questions[0]) => {
|
||||
const fieldId = q.id.toString();
|
||||
const options = q.options ? JSON.parse(q.options) as string[] : [];
|
||||
|
||||
switch (q.fieldType) {
|
||||
case "textarea":
|
||||
return (
|
||||
<textarea
|
||||
value={answers[fieldId] || ""}
|
||||
onChange={e => handleAnswer(fieldId, e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none resize-none"
|
||||
placeholder="Digite sua resposta..."
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={answers[fieldId] || ""}
|
||||
onChange={e => handleAnswer(fieldId, e.target.value)}
|
||||
className="w-full bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
placeholder="0"
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<select
|
||||
value={answers[fieldId] || ""}
|
||||
onChange={e => handleAnswer(fieldId, e.target.value)}
|
||||
className="w-full bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white focus:border-[#cbf400] focus:outline-none appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-[#1a1a1a]">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt} className="bg-[#1a1a1a]">{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case "multiselect":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{options.map(opt => {
|
||||
const selected = (answers[fieldId] || "").split(", ").filter(Boolean);
|
||||
const isSelected = selected.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => handleMultiSelect(fieldId, opt)}
|
||||
className={`w-full text-left px-4 py-3 rounded-xl border transition-colors ${
|
||||
isSelected
|
||||
? "bg-[#cbf400]/10 border-[#cbf400]/50 text-[#cbf400]"
|
||||
: "bg-[#0e0e0e] border-[#2a2a2a] text-gray-300 hover:border-[#3a3a3a]"
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block w-4 h-4 rounded border mr-3 align-middle ${
|
||||
isSelected ? "bg-[#cbf400] border-[#cbf400]" : "border-gray-500"
|
||||
}`}>
|
||||
{isSelected && <CheckCircle className="w-4 h-4 text-[#0e0e0e]" />}
|
||||
</span>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={answers[fieldId] || ""}
|
||||
onChange={e => handleAnswer(fieldId, e.target.value)}
|
||||
className="w-full bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
placeholder="Digite sua resposta..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0e0e0e] text-white">
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link to="/" className="inline-flex items-center gap-2 text-gray-400 hover:text-[#cbf400] transition-colors text-sm mb-6">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
avanzato.com.br
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-[#cbf400]/10 rounded-lg">
|
||||
<Shield className="w-6 h-6 text-[#cbf400]" />
|
||||
</div>
|
||||
<span className="text-[#cbf400] text-sm font-medium">Formulario Seguro - Avanzato</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-3">{typedForm.title}</h1>
|
||||
{typedForm.description && (
|
||||
<p className="text-gray-400 leading-relaxed">{typedForm.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
Cliente: {typedForm.clientName}
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>{questions.length} perguntas</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulario */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Dados do respondente */}
|
||||
<div className="bg-[#1a1a1a] rounded-2xl p-6 border border-[#2a2a2a] mb-6">
|
||||
<h3 className="font-semibold mb-4 text-[#cbf400]">Seus dados</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome completo *"
|
||||
required
|
||||
value={submittedBy}
|
||||
onChange={e => setSubmittedBy(e.target.value)}
|
||||
className="bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-mail *"
|
||||
required
|
||||
value={submittedByEmail}
|
||||
onChange={e => setSubmittedByEmail(e.target.value)}
|
||||
className="bg-[#0e0e0e] border border-[#2a2a2a] rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:border-[#cbf400] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secoes e perguntas */}
|
||||
{groupedBySection.map((section, idx) => (
|
||||
<div key={idx} className="bg-[#1a1a1a] rounded-2xl border border-[#2a2a2a] mb-6 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.title)}
|
||||
className="w-full p-5 flex items-center justify-between text-left hover:bg-[#1f1f1f] transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{section.title}</h3>
|
||||
{section.description && (
|
||||
<p className="text-gray-500 text-sm mt-0.5">{section.description}</p>
|
||||
)}
|
||||
<span className="text-gray-600 text-xs">{section.questions.length} perguntas</span>
|
||||
</div>
|
||||
{expandedSections[section.title] ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(expandedSections[section.title] !== false) && (
|
||||
<div className="px-5 pb-5 space-y-5 border-t border-[#2a2a2a] pt-5">
|
||||
{section.questions.map(q => (
|
||||
<div key={q.id}>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{q.question}
|
||||
{q.required === "yes" && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
{renderField(q)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Perguntas sem secao */}
|
||||
{questions.filter(q => !q.sectionId).length > 0 && (
|
||||
<div className="bg-[#1a1a1a] rounded-2xl p-6 border border-[#2a2a2a] mb-6 space-y-5">
|
||||
{questions.filter(q => !q.sectionId).map(q => (
|
||||
<div key={q.id}>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{q.question}
|
||||
{q.required === "yes" && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
{renderField(q)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitMutation.isPending}
|
||||
className="bg-[#cbf400] text-[#0e0e0e] px-8 py-4 rounded-xl font-semibold hover:bg-[#b8e000] transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-[#0e0e0e] border-t-transparent rounded-full animate-spin" />
|
||||
Enviando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Enviar Respostas
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Suas respostas serao enviadas diretamente para a Avanzato.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submitMutation.isError && (
|
||||
<div className="mt-4 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400">
|
||||
Erro ao enviar. Tente novamente.
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-16 pt-8 border-t border-[#2a2a2a] text-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
Formulario seguro - Avanzato Tecnologia
|
||||
</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
As informacoes sao confidenciais e protegidas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import { httpBatchLink } from "@trpc/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import superjson from "superjson";
|
||||
import type { AppRouter } from "../../api/router";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const trpcClient = trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: "/api/trpc",
|
||||
transformer: superjson,
|
||||
fetch(input, init) {
|
||||
return globalThis.fetch(input, {
|
||||
...(init ?? {}),
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export function TRPCProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user