React SPA (Single Page Application)¶
Visão Geral¶
Este exemplo demonstra a implementação otimizada do Complyr em uma React SPA moderna, focando em TypeScript, hooks customizados, Context API e testes unitários.
Ideal para SPAs React, Next.js, Create React App e Vite + React que necessitam de uma solução robusta e testável.
Características: - ✅ TypeScript com tipos fortemente tipados - ✅ Hook customizado useComplyr() - ✅ Context Provider para estado global - ✅ Integração com React Router v6 - ✅ Testes unitários com Jest + React Testing Library - ✅ SSR support (Next.js) - ✅ Performance otimizada
Tecnologias: - React 18+ - TypeScript 5+ - React Router v6 - Jest + React Testing Library - Vite ou Create React App
Tempo estimado: 25 minutos
Nível: Intermediário 🟡
Instalação¶
# Create React App com TypeScript
npx create-react-app my-app --template typescript
# OU Vite com TypeScript (recomendado)
npm create vite@latest my-app -- --template react-ts
# OU Next.js (para SSR)
npx create-next-app@latest my-app --typescript
cd my-app
npm install
Passo 1: Adicionar Script Complyr¶
Option A: HTML Estático (CRA/Vite)¶
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Minha SPA React</title>
</head>
<body>
<div id="root"></div>
<!-- Complyr Script -->
<script
src="https://app.complyr.com.br/tag/js"
data-workspace-id="SEU_WORKSPACE_ID"
data-complyr-script
async
defer>
</script>
</body>
</html>
Option B: Next.js (SSR)¶
// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="pt-BR">
<Head />
<body>
<Main />
<NextScript />
{/* Complyr Script */}
<script
src="https://app.complyr.com.br/tag/js"
data-workspace-id={process.env.NEXT_PUBLIC_COMPLYR_WORKSPACE_ID}
data-complyr-script
async
defer
/>
</body>
</Html>
);
}
Passo 2: Criar Tipos TypeScript¶
// src/types/complyr.ts
export interface ConsentPurposes {
essential: boolean;
analytics: boolean;
marketing: boolean;
personalization: boolean;
third_party: boolean;
}
export type ConsentStatus =
| 'NONE'
| 'GRANTED'
| 'PARTIAL'
| 'DENIED'
| 'REVOKED'
| 'EXPIRED';
export interface PurposeDetails {
granted: boolean;
grantedAt: string | null;
revokedAt?: string | null;
}
export interface ConsentData {
workspaceId: string;
consentId: string;
status: ConsentStatus;
purposes: Record<string, PurposeDetails>;
createdAt: string;
expiresAt: string;
userEmail?: string;
}
export interface ComplyrAPI {
identify: (type: 'email' | 'cpf' | 'phone', value: string) => void;
openPreferences: () => void;
revokeConsent: (reason: string) => void;
loadPolicy: (type: 'privacy_policy' | 'cookie_policy' | 'terms_of_service', policyId: string | null) => void;
acceptPolicy: (type: 'consent', policyId: string | null) => void;
}
declare global {
interface Window {
complyr?: ComplyrAPI;
dataLayer?: Array<Record<string, any>>;
}
}
export {};
Passo 3: Hook Customizado useComplyr¶
// src/hooks/useComplyr.ts
import { useEffect, useState, useCallback } from 'react';
import type { ConsentData, ConsentPurposes } from '../types/complyr';
export function useComplyr() {
const [isLoaded, setIsLoaded] = useState(false);
const [consent, setConsent] = useState<ConsentData | null>(null);
const [isLoadingConsent, setIsLoadingConsent] = useState(true);
useEffect(() => {
let isMounted = true;
const handleComplyrLoaded = () => {
if (isMounted) {
setIsLoaded(true);
loadConsent();
}
};
const handleConsentUpdated = (event: Event) => {
const customEvent = event as CustomEvent<ConsentPurposes>;
console.log('[Complyr] Consent updated:', customEvent.detail);
if (isMounted) {
loadConsent();
}
};
const handleBannerDisplayed = () => {
console.log('[Complyr] Banner displayed');
};
const handlePreferencesOpened = () => {
console.log('[Complyr] Preferences modal opened');
};
// Register event listeners
document.addEventListener('complyr:loaded', handleComplyrLoaded);
document.addEventListener('consent-updated', handleConsentUpdated);
document.addEventListener('banner-displayed', handleBannerDisplayed);
document.addEventListener('preferences-opened', handlePreferencesOpened);
// Check if already loaded
if (window.complyr) {
setIsLoaded(true);
loadConsent();
}
// Cleanup
return () => {
isMounted = false;
document.removeEventListener('complyr:loaded', handleComplyrLoaded);
document.removeEventListener('consent-updated', handleConsentUpdated);
document.removeEventListener('banner-displayed', handleBannerDisplayed);
document.removeEventListener('preferences-opened', handlePreferencesOpened);
};
}, []);
const loadConsent = useCallback(() => {
try {
const stored = localStorage.getItem('complyr_consent');
if (stored) {
const parsed: ConsentData = JSON.parse(stored);
setConsent(parsed);
} else {
setConsent(null);
}
} catch (error) {
console.error('[Complyr] Error loading consent:', error);
setConsent(null);
} finally {
setIsLoadingConsent(false);
}
}, []);
const identify = useCallback((type: 'email' | 'cpf' | 'phone', value: string) => {
if (!window.complyr) {
console.warn('[Complyr] API not loaded yet');
return;
}
try {
window.complyr.identify(type, value);
console.log(`[Complyr] User identified with ${type}`);
} catch (error) {
console.error('[Complyr] Error identifying user:', error);
}
}, []);
const openPreferences = useCallback(() => {
if (!window.complyr) {
console.warn('[Complyr] API not loaded yet');
return;
}
try {
window.complyr.openPreferences();
} catch (error) {
console.error('[Complyr] Error opening preferences:', error);
}
}, []);
const revokeConsent = useCallback((reason: string) => {
if (!window.complyr) {
console.warn('[Complyr] API not loaded yet');
return;
}
try {
window.complyr.revokeConsent(reason);
console.log(`[Complyr] Consent revoked: ${reason}`);
} catch (error) {
console.error('[Complyr] Error revoking consent:', error);
}
}, []);
const hasConsent = useCallback((purpose: keyof ConsentPurposes): boolean => {
if (!consent || !consent.purposes) {
return false;
}
const purposeData = consent.purposes[purpose];
return purposeData?.granted === true;
}, [consent]);
return {
isLoaded,
consent,
isLoadingConsent,
identify,
openPreferences,
revokeConsent,
hasConsent,
};
}
Passo 4: Context Provider¶
// src/contexts/ComplyrContext.tsx
import React, { createContext, useContext, ReactNode } from 'react';
import { useComplyr } from '../hooks/useComplyr';
import type { ConsentData } from '../types/complyr';
interface ComplyrContextData {
isLoaded: boolean;
consent: ConsentData | null;
isLoadingConsent: boolean;
identify: (type: 'email' | 'cpf' | 'phone', value: string) => void;
openPreferences: () => void;
revokeConsent: (reason: string) => void;
hasConsent: (purpose: 'essential' | 'analytics' | 'marketing' | 'personalization' | 'third_party') => boolean;
}
const ComplyrContext = createContext<ComplyrContextData | undefined>(undefined);
export function ComplyrProvider({ children }: { children: ReactNode }) {
const complyr = useComplyr();
return (
<ComplyrContext.Provider value={complyr}>
{children}
</ComplyrContext.Provider>
);
}
export function useComplyrContext() {
const context = useContext(ComplyrContext);
if (context === undefined) {
throw new Error('useComplyrContext must be used within ComplyrProvider');
}
return context;
}
Passo 5: Componente CookieSettings¶
// src/components/CookieSettings.tsx
import React, { useState } from 'react';
import { useComplyrContext } from '../contexts/ComplyrContext';
import './CookieSettings.css';
export function CookieSettings() {
const { consent, openPreferences, revokeConsent, hasConsent, isLoaded } = useComplyrContext();
const [isRevoking, setIsRevoking] = useState(false);
const handleOpenPreferences = () => {
openPreferences();
};
const handleRevokeConsent = async () => {
if (window.confirm('Deseja revogar todos os seus consentimentos?')) {
setIsRevoking(true);
try {
revokeConsent('User requested consent revocation via settings page');
alert('Consentimentos revogados com sucesso!');
} catch (error) {
alert('Erro ao revogar consentimentos. Tente novamente.');
} finally {
setIsRevoking(false);
}
}
};
if (!consent) {
return (
<div className="cookie-settings">
<div className="empty-state">
<p>Você ainda não definiu suas preferências de cookies.</p>
<button
className="btn btn-primary"
onClick={handleOpenPreferences}
disabled={!isLoaded}
>
Definir Preferências
</button>
</div>
</div>
);
}
return (
<div className="cookie-settings">
<h2>Configurações de Cookies</h2>
<p>Gerencie suas preferências de privacidade e cookies.</p>
<div className="consent-status">
<h3>Status Atual</h3>
<div className={`status-badge status-${consent.status.toLowerCase()}`}>
{consent.status}
</div>
<p className="consent-date">
Consentimento criado em: {new Date(consent.createdAt).toLocaleDateString('pt-BR')}
</p>
</div>
<div className="purposes-list">
<h3>Suas Preferências</h3>
<div className="purpose-item">
<span className="purpose-icon">🔒</span>
<div className="purpose-info">
<strong>Cookies Essenciais</strong>
<p>Necessários para o funcionamento do site</p>
</div>
<span className="status-indicator active">Sempre Ativo</span>
</div>
<div className="purpose-item">
<span className="purpose-icon">📊</span>
<div className="purpose-info">
<strong>Analytics</strong>
<p>Análise de uso e performance</p>
</div>
<span className={`status-indicator ${hasConsent('analytics') ? 'active' : 'inactive'}`}>
{hasConsent('analytics') ? 'Ativo' : 'Inativo'}
</span>
</div>
<div className="purpose-item">
<span className="purpose-icon">📢</span>
<div className="purpose-info">
<strong>Marketing</strong>
<p>Publicidade personalizada</p>
</div>
<span className={`status-indicator ${hasConsent('marketing') ? 'active' : 'inactive'}`}>
{hasConsent('marketing') ? 'Ativo' : 'Inativo'}
</span>
</div>
<div className="purpose-item">
<span className="purpose-icon">🎨</span>
<div className="purpose-info">
<strong>Personalização</strong>
<p>Conteúdo personalizado</p>
</div>
<span className={`status-indicator ${hasConsent('personalization') ? 'active' : 'inactive'}`}>
{hasConsent('personalization') ? 'Ativo' : 'Inativo'}
</span>
</div>
<div className="purpose-item">
<span className="purpose-icon">🔗</span>
<div className="purpose-info">
<strong>Terceiros</strong>
<p>Serviços de terceiros</p>
</div>
<span className={`status-indicator ${hasConsent('third_party') ? 'active' : 'inactive'}`}>
{hasConsent('third_party') ? 'Ativo' : 'Inativo'}
</span>
</div>
</div>
<div className="actions">
<button
className="btn btn-primary"
onClick={handleOpenPreferences}
disabled={!isLoaded}
>
Alterar Preferências
</button>
<button
className="btn btn-danger"
onClick={handleRevokeConsent}
disabled={isRevoking || !isLoaded}
>
{isRevoking ? 'Revogando...' : 'Revogar Todos os Consentimentos'}
</button>
</div>
<div className="info-box">
<h4>Seus Direitos (LGPD)</h4>
<ul>
<li>Confirmação de processamento de dados</li>
<li>Acesso aos seus dados</li>
<li>Correção de dados incompletos ou desatualizados</li>
<li>Anonimização, bloqueio ou eliminação</li>
<li>Portabilidade dos dados</li>
<li>Revogação de consentimento a qualquer momento</li>
</ul>
</div>
</div>
);
}
Passo 6: Integração com React Router¶
// src/App.tsx
import React from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { ComplyrProvider } from './contexts/ComplyrContext';
import { Home } from './pages/Home';
import { About } from './pages/About';
import { CookieSettings } from './components/CookieSettings';
function App() {
return (
<ComplyrProvider>
<BrowserRouter>
<div className="app">
<nav className="navbar">
<div className="container">
<Link to="/" className="logo">Minha SPA</Link>
<ul className="nav-links">
<Link to="/">Home</Link>
<Link to="/about">Sobre</Link>
<Link to="/settings/cookies">Cookies</Link>
</ul>
</div>
</nav>
<main className="main-content">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/settings/cookies" element={<CookieSettings />} />
</Routes>
</main>
<footer className="footer">
<p>© 2025 Minha SPA. Todos os direitos reservados.</p>
</footer>
</div>
</BrowserRouter>
</ComplyrProvider>
);
}
export default App;
Passo 7: Testes Unitários¶
Testar Hook useComplyr¶
// src/hooks/__tests__/useComplyr.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { useComplyr } from '../useComplyr';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('useComplyr', () => {
beforeEach(() => {
localStorageMock.clear();
delete (window as any).complyr;
});
it('should initialize with correct default values', () => {
const { result } = renderHook(() => useComplyr());
expect(result.current.isLoaded).toBe(false);
expect(result.current.consent).toBeNull();
expect(result.current.isLoadingConsent).toBe(false);
});
it('should load consent from localStorage', () => {
const mockConsent = {
workspaceId: 'test-workspace',
consentId: 'test-consent',
status: 'GRANTED',
purposes: {
analytics: { granted: true, grantedAt: '2025-01-01' },
},
createdAt: '2025-01-01',
expiresAt: '2026-01-01',
};
localStorageMock.setItem('complyr_consent', JSON.stringify(mockConsent));
const { result } = renderHook(() => useComplyr());
expect(result.current.consent).toEqual(mockConsent);
});
it('should detect when Complyr API is loaded', async () => {
const { result } = renderHook(() => useComplyr());
// Simulate Complyr API loading
(window as any).complyr = {
identify: jest.fn(),
openPreferences: jest.fn(),
revokeConsent: jest.fn(),
};
act(() => {
document.dispatchEvent(new Event('complyr:loaded'));
});
await waitFor(() => {
expect(result.current.isLoaded).toBe(true);
});
});
it('should call identify method correctly', () => {
(window as any).complyr = {
identify: jest.fn(),
};
const { result } = renderHook(() => useComplyr());
act(() => {
result.current.identify('email', 'user@example.com');
});
expect(window.complyr?.identify).toHaveBeenCalledWith('email', 'user@example.com');
});
it('should return correct hasConsent value', () => {
const mockConsent = {
workspaceId: 'test',
consentId: 'test',
status: 'PARTIAL',
purposes: {
analytics: { granted: true, grantedAt: '2025-01-01' },
marketing: { granted: false, grantedAt: null },
},
createdAt: '2025-01-01',
expiresAt: '2026-01-01',
};
localStorageMock.setItem('complyr_consent', JSON.stringify(mockConsent));
const { result } = renderHook(() => useComplyr());
expect(result.current.hasConsent('analytics')).toBe(true);
expect(result.current.hasConsent('marketing')).toBe(false);
});
});
Testar Componente CookieSettings¶
// src/components/__tests__/CookieSettings.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { CookieSettings } from '../CookieSettings';
import { ComplyrProvider } from '../../contexts/ComplyrContext';
const mockConsent = {
workspaceId: 'test-workspace',
consentId: 'test-consent',
status: 'GRANTED' as const,
purposes: {
analytics: { granted: true, grantedAt: '2025-01-01' },
marketing: { granted: false, grantedAt: null },
},
createdAt: '2025-01-01T00:00:00.000Z',
expiresAt: '2026-01-01T00:00:00.000Z',
};
jest.mock('../../hooks/useComplyr', () => ({
useComplyr: () => ({
isLoaded: true,
consent: mockConsent,
isLoadingConsent: false,
identify: jest.fn(),
openPreferences: jest.fn(),
revokeConsent: jest.fn(),
hasConsent: (purpose: string) => mockConsent.purposes[purpose]?.granted === true,
}),
}));
describe('CookieSettings', () => {
it('should render consent status correctly', () => {
render(
<ComplyrProvider>
<CookieSettings />
</ComplyrProvider>
);
expect(screen.getByText('GRANTED')).toBeInTheDocument();
expect(screen.getByText(/Consentimento criado em:/)).toBeInTheDocument();
});
it('should display purposes with correct status', () => {
render(
<ComplyrProvider>
<CookieSettings />
</ComplyrProvider>
);
// Analytics should be active
const analyticsStatus = screen.getByText('Analytics').closest('.purpose-item')?.querySelector('.status-indicator');
expect(analyticsStatus).toHaveTextContent('Ativo');
// Marketing should be inactive
const marketingStatus = screen.getByText('Marketing').closest('.purpose-item')?.querySelector('.status-indicator');
expect(marketingStatus).toHaveTextContent('Inativo');
});
it('should call openPreferences when button is clicked', () => {
const mockOpenPreferences = jest.fn();
jest.spyOn(require('../../hooks/useComplyr'), 'useComplyr').mockReturnValue({
...require('../../hooks/useComplyr').useComplyr(),
openPreferences: mockOpenPreferences,
});
render(
<ComplyrProvider>
<CookieSettings />
</ComplyrProvider>
);
const button = screen.getByText('Alterar Preferências');
fireEvent.click(button);
expect(mockOpenPreferences).toHaveBeenCalled();
});
});
Passo 8: Next.js Support (SSR)¶
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { useEffect } from 'react';
import { ComplyrProvider } from '../contexts/ComplyrContext';
import '../styles/globals.css';
function MyApp({ Component, pageProps }: AppProps) {
useEffect(() => {
// Garantir que o script Complyr carregue apenas no client-side
if (typeof window !== 'undefined') {
console.log('[Complyr] Client-side ready');
}
}, []);
return (
<ComplyrProvider>
<Component {...pageProps} />
</ComplyrProvider>
);
}
export default MyApp;
// hooks/useComplyrSSR.ts (versão SSR-safe)
import { useEffect, useState, useCallback } from 'react';
import type { ConsentData } from '../types/complyr';
export function useComplyrSSR() {
const [isClient, setIsClient] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const [consent, setConsent] = useState<ConsentData | null>(null);
useEffect(() => {
// Executar apenas no client-side
setIsClient(true);
if (typeof window !== 'undefined') {
const handleComplyrLoaded = () => {
setIsLoaded(true);
loadConsent();
};
document.addEventListener('complyr:loaded', handleComplyrLoaded);
if (window.complyr) {
setIsLoaded(true);
loadConsent();
}
return () => {
document.removeEventListener('complyr:loaded', handleComplyrLoaded);
};
}
}, []);
const loadConsent = useCallback(() => {
if (typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('complyr_consent');
if (stored) {
setConsent(JSON.parse(stored));
}
} catch (error) {
console.error('[Complyr] Error loading consent:', error);
}
}
}, []);
const identify = useCallback((type: 'email', value: string) => {
if (typeof window !== 'undefined' && window.complyr) {
window.complyr.identify(type, value);
}
}, []);
const openPreferences = useCallback(() => {
if (typeof window !== 'undefined' && window.complyr) {
window.complyr.openPreferences();
}
}, []);
return {
isClient,
isLoaded,
consent,
identify,
openPreferences,
};
}
Performance Optimization¶
Lazy Loading do Modal¶
// src/components/CookieSettingsLazy.tsx
import React, { lazy, Suspense } from 'react';
const CookieSettings = lazy(() => import('./CookieSettings').then(mod => ({ default: mod.CookieSettings })));
export function CookieSettingsLazy() {
return (
<Suspense fallback={<div>Carregando configurações...</div>}>
<CookieSettings />
</Suspense>
);
}
Memoização¶
// src/hooks/useComplyr.ts (otimizado)
import { useMemo } from 'react';
export function useComplyr() {
// ...código anterior
const consentPurposes = useMemo(() => {
if (!consent || !consent.purposes) {
return {
analytics: false,
marketing: false,
personalization: false,
third_party: false,
};
}
return {
analytics: consent.purposes.analytics?.granted === true,
marketing: consent.purposes.marketing?.granted === true,
personalization: consent.purposes.personalization?.granted === true,
third_party: consent.purposes.third_party?.granted === true,
};
}, [consent]);
return {
// ...outros retornos
consentPurposes,
};
}
Checklist de Validação¶
- TypeScript configurado corretamente
- Hook
useComplyrimplementado - Context
ComplyrProvidercriado - Tipos TypeScript definidos
- Componente
CookieSettingscriado - Integração com React Router funcionando
- Testes unitários passando (>80% cobertura)
- SSR support (se usar Next.js)
- Performance otimizada (lazy loading)
- Sem erros no Console
- Banner aparece na primeira visita
- Preferências persistem corretamente
- Métodos funcionam sem erros
Próximos Passos¶
- WordPress - Integração em WordPress
- E-commerce - GTM + FB Pixel
- API JavaScript - Métodos disponíveis
- LGPD Compliance - Conformidade
Conclusão¶
Parabéns! 🎉 Você implementou uma solução robusta de consentimento para React SPA com:
- ✅ TypeScript fortemente tipado
- ✅ Hooks customizados reutilizáveis
- ✅ Context API para estado global
- ✅ Testes unitários completos
- ✅ SSR support (Next.js)
- ✅ Performance otimizada
Suporte: contato@complyr.com.br