Area restrita - Questionario

This commit is contained in:
2026-06-08 23:50:59 -03:00
parent f2e2400637
commit ef968f10ae
6972 changed files with 23454 additions and 2267883 deletions
+28 -160
View File
@@ -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
View File
@@ -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>
</>
);
}
+266
View File
@@ -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>
</>
);
}
+46
View File
@@ -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>
);
}
+1
View File
@@ -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 --------------------------------------------------------
+1
View File
@@ -0,0 +1 @@
export const LOGIN_PATH = "/login";
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
)
}
+41
View File
@@ -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>
);
}
+266
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}