Pular para conteúdo

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>&copy; 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 useComplyr implementado
  • Context ComplyrProvider criado
  • Tipos TypeScript definidos
  • Componente CookieSettings criado
  • 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

  1. WordPress - Integração em WordPress
  2. E-commerce - GTM + FB Pixel
  3. API JavaScript - Métodos disponíveis
  4. 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