Tilbage til bloggen
Technical

Angular and Vue.js: Best Practices for CMP Integration

GetCookies-teametNovember 12, 202512 min læsning
AngularVueJavaScriptDevelopment

TLDR: Integrate consent management into Angular and Vue.js apps using reactive patterns and framework-specific best practices. The key principle: State First, Scripts Second—never fire a pixel before consent state propagates through your reactive system.

Read full summary

Step-by-step tutorial for building consent-aware Angular and Vue applications. Covers service injection, reactive state management with RxJS/Composition API, route guards, and conditional script loading based on user consent. Includes complete TypeScript implementations and testing strategies.

Summary by Claude AI


title: "Angular and Vue.js CMP Integration: Complete Framework Guide for 2025" slug: "angular-vue-cmp-integration-guide" excerpt: "Master consent management in Angular and Vue.js applications with reactive patterns, route guards, lazy loading strategies, and production-ready code examples for GDPR compliance." category: "Technical Implementation" tags: ["Angular", "Vue.js", "CMP Integration", "JavaScript Frameworks", "Reactive Consent", "GDPR", "TypeScript"] publishedAt: "2025-01-15" readTime: "18 min read"

The Race Condition That Costs €20 Million

Illustrative scenario: a composite example, not a specific reported case.

In January 2024, a European e-commerce company discovered their Angular SPA had been firing Facebook Pixel events for users who had explicitly rejected marketing cookies. The root cause? A race condition where the pixel loaded before the consent state propagated through their RxJS streams.

The DPA's investigation found this had been happening for 18 months. The fine: €1.2 million. The remediation costs: another €800,000. Total damage from a bug that would have taken two hours to fix—if anyone had thought to test consent state synchronization.

This isn't an isolated case. SPA frameworks handle state differently than traditional websites, and consent management that works perfectly on a server-rendered page can fail silently in Angular or Vue.

Angular and Vue.js require reactive consent management that integrates with their state management systems. Use Angular Services with RxJS for Angular applications, and Pinia stores with Vue Composables for Vue 3. The key principle is "State First, Scripts Second"—never load third-party scripts until consent state is confirmed and propagated through your reactive system.

Why SPAs Break Traditional Consent Patterns

Modern JavaScript frameworks like Angular and Vue.js have revolutionized how we build web applications. But they've also introduced new challenges for privacy compliance. Unlike traditional websites where you simply block scripts, SPAs (Single Page Applications) require consent management that integrates seamlessly with reactive state, routing, and component lifecycles.

The same mistakes appear repeatedly: developers treating consent as an afterthought, bolting on cookie banners without considering how they interact with the framework's reactivity system. The result? Race conditions where analytics fire before consent is confirmed, memory leaks from unmanaged subscriptions, and user experiences where the consent banner fights with the application for control.

This guide will show you how to build consent management that feels native to your framework. We'll cover everything from basic integration to advanced patterns like consent-aware lazy loading, route guards that respect privacy preferences, and server-side rendering considerations.

By the end, you'll have production-ready code that handles consent properly in even the most complex Angular and Vue.js applications. We've implemented these patterns across hundreds of SPA deployments, and we're confident they'll work for your use case too.

Understanding Reactive Consent Management

Why Traditional CMPs Fail in SPAs

Traditional consent management platforms were designed for server-rendered pages. They assume:

  1. The page loads once, and scripts are either present or absent
  2. Navigation triggers full page reloads
  3. State doesn't persist across "pages"

SPAs break all these assumptions. In an Angular or Vue.js application:

  • The application loads once and runs continuously
  • Navigation is handled client-side without page reloads
  • State persists and updates reactively
  • Components mount and unmount dynamically
  • Third-party scripts need to be loaded, unloaded, and managed throughout the session

This means your CMP needs to be reactive. When consent changes, every component that depends on consent needs to respond immediately. Analytics need to start or stop tracking. Marketing pixels need to be enabled or disabled. And all of this needs to happen without race conditions or memory leaks.

The State-First Architecture

The solution is what we call "State First, Scripts Second" architecture:

┌─────────────────────────────────────────────────────────────┐
│                    Consent State Store                       │
│  (Angular Service / Pinia Store)                            │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐  │
│  │   Analytics  │    │   Marketing  │    │  Functional  │  │
│  │   Consent    │    │   Consent    │    │   Consent    │  │
│  └──────┬───────┘    └──────┬───────┘    └──────┬───────┘  │
│         │                    │                    │          │
└─────────┼────────────────────┼────────────────────┼──────────┘
          │                    │                    │
          ▼                    ▼                    ▼
    ┌───────────┐        ┌───────────┐        ┌───────────┐
    │  GA4      │        │  Facebook │        │  Session  │
    │  Manager  │        │  Pixel    │        │  Replay   │
    └───────────┘        └───────────┘        └───────────┘

Every script manager subscribes to the consent store. When consent changes, they react accordingly. No script ever loads without first checking the store. No component ever assumes consent—it always asks the store.

Angular CMP Integration

Setting Up the Consent Service

Angular's dependency injection system makes it perfect for consent management. We'll create a singleton service that manages all consent state:

// consent.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Observable, Subject, combineLatest } from 'rxjs';
import { map, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators';

export type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'functional';
export type ConsentStatus = 'pending' | 'granted' | 'denied';

export interface ConsentState {
  necessary: ConsentStatus;
  analytics: ConsentStatus;
  marketing: ConsentStatus;
  functional: ConsentStatus;
  timestamp: number;
  version: string;
}

export interface ConsentPreferences {
  analytics: boolean;
  marketing: boolean;
  functional: boolean;
}

const CONSENT_STORAGE_KEY = 'olc_consent_state';
const CONSENT_VERSION = '2.0';

@Injectable({
  providedIn: 'root'
})
export class ConsentService implements OnDestroy {
  private destroy$ = new Subject<void>();

  private consentState$ = new BehaviorSubject<ConsentState>({
    necessary: 'granted', // Always granted
    analytics: 'pending',
    marketing: 'pending',
    functional: 'pending',
    timestamp: 0,
    version: CONSENT_VERSION
  });

  private initialized$ = new BehaviorSubject<boolean>(false);
  private bannerVisible$ = new BehaviorSubject<boolean>(false);

  // Public observables
  readonly consent$: Observable<ConsentState> = this.consentState$.asObservable();
  readonly isInitialized$: Observable<boolean> = this.initialized$.asObservable();
  readonly showBanner$: Observable<boolean> = this.bannerVisible$.asObservable();

  // Convenience observables for specific categories
  readonly analyticsConsent$: Observable<ConsentStatus> = this.consent$.pipe(
    map(state => state.analytics),
    distinctUntilChanged()
  );

  readonly marketingConsent$: Observable<ConsentStatus> = this.consent$.pipe(
    map(state => state.marketing),
    distinctUntilChanged()
  );

  readonly functionalConsent$: Observable<ConsentStatus> = this.consent$.pipe(
    map(state => state.functional),
    distinctUntilChanged()
  );

  // Observable that emits true only when analytics is granted
  readonly analyticsGranted$: Observable<boolean> = this.analyticsConsent$.pipe(
    map(status => status === 'granted')
  );

  readonly marketingGranted$: Observable<boolean> = this.marketingConsent$.pipe(
    map(status => status === 'granted')
  );

  constructor() {
    this.initializeConsent();
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  private initializeConsent(): void {
    // Check for existing consent
    const stored = this.loadStoredConsent();

    if (stored && stored.version === CONSENT_VERSION) {
      // Valid stored consent found
      this.consentState$.next(stored);
      this.initialized$.next(true);
      this.bannerVisible$.next(false);

      // Apply consent to Google Consent Mode
      this.updateGoogleConsentMode(stored);
    } else {
      // No valid consent - show banner
      this.initialized$.next(true);
      this.bannerVisible$.next(true);

      // Set default denied state for Google Consent Mode
      this.setDefaultGoogleConsentMode();
    }
  }

  private loadStoredConsent(): ConsentState | null {
    try {
      const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
      if (!stored) return null;

      const parsed = JSON.parse(stored) as ConsentState;

      // Validate the stored consent
      if (!parsed.timestamp || !parsed.version) return null;

      // Check if consent is expired (re-consent after 1 year)
      const oneYearAgo = Date.now() - (365 * 24 * 60 * 60 * 1000);
      if (parsed.timestamp < oneYearAgo) return null;

      return parsed;
    } catch {
      return null;
    }
  }

  private saveConsent(state: ConsentState): void {
    try {
      localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state));
    } catch (e) {
      console.warn('Failed to save consent state:', e);
    }
  }

  /**
   * Accept all consent categories
   */
  acceptAll(): void {
    const newState: ConsentState = {
      necessary: 'granted',
      analytics: 'granted',
      marketing: 'granted',
      functional: 'granted',
      timestamp: Date.now(),
      version: CONSENT_VERSION
    };

    this.updateConsent(newState);
  }

  /**
   * Deny all optional consent categories
   */
  denyAll(): void {
    const newState: ConsentState = {
      necessary: 'granted',
      analytics: 'denied',
      marketing: 'denied',
      functional: 'denied',
      timestamp: Date.now(),
      version: CONSENT_VERSION
    };

    this.updateConsent(newState);
  }

  /**
   * Update consent with specific preferences
   */
  setPreferences(preferences: ConsentPreferences): void {
    const newState: ConsentState = {
      necessary: 'granted',
      analytics: preferences.analytics ? 'granted' : 'denied',
      marketing: preferences.marketing ? 'granted' : 'denied',
      functional: preferences.functional ? 'granted' : 'denied',
      timestamp: Date.now(),
      version: CONSENT_VERSION
    };

    this.updateConsent(newState);
  }

  private updateConsent(state: ConsentState): void {
    this.consentState$.next(state);
    this.saveConsent(state);
    this.bannerVisible$.next(false);
    this.updateGoogleConsentMode(state);
    this.pushToDataLayer(state);
  }

  /**
   * Show the consent banner (for preference management)
   */
  showBanner(): void {
    this.bannerVisible$.next(true);
  }

  /**
   * Hide the consent banner
   */
  hideBanner(): void {
    this.bannerVisible$.next(false);
  }

  /**
   * Get current consent state synchronously
   */
  getCurrentConsent(): ConsentState {
    return this.consentState$.getValue();
  }

  /**
   * Check if a specific category is granted
   */
  isGranted(category: ConsentCategory): boolean {
    return this.consentState$.getValue()[category] === 'granted';
  }

  /**
   * Wait for consent to be resolved (not pending)
   */
  waitForConsent(category: ConsentCategory): Observable<ConsentStatus> {
    return this.consent$.pipe(
      map(state => state[category]),
      filter(status => status !== 'pending'),
      distinctUntilChanged()
    );
  }

  private setDefaultGoogleConsentMode(): void {
    if (typeof window !== 'undefined' && (window as any).gtag) {
      (window as any).gtag('consent', 'default', {
        'analytics_storage': 'denied',
        'ad_storage': 'denied',
        'ad_user_data': 'denied',
        'ad_personalization': 'denied',
        'functionality_storage': 'denied',
        'personalization_storage': 'denied',
        'security_storage': 'granted'
      });
    }
  }

  private updateGoogleConsentMode(state: ConsentState): void {
    if (typeof window !== 'undefined' && (window as any).gtag) {
      (window as any).gtag('consent', 'update', {
        'analytics_storage': state.analytics === 'granted' ? 'granted' : 'denied',
        'ad_storage': state.marketing === 'granted' ? 'granted' : 'denied',
        'ad_user_data': state.marketing === 'granted' ? 'granted' : 'denied',
        'ad_personalization': state.marketing === 'granted' ? 'granted' : 'denied',
        'functionality_storage': state.functional === 'granted' ? 'granted' : 'denied',
        'personalization_storage': state.functional === 'granted' ? 'granted' : 'denied',
        'security_storage': 'granted'
      });
    }
  }

  private pushToDataLayer(state: ConsentState): void {
    if (typeof window !== 'undefined') {
      (window as any).dataLayer = (window as any).dataLayer || [];
      (window as any).dataLayer.push({
        event: 'consent_update',
        consent_analytics: state.analytics,
        consent_marketing: state.marketing,
        consent_functional: state.functional,
        consent_timestamp: state.timestamp
      });
    }
  }
}

Creating the Consent Banner Component

Now let's create a consent banner component that uses the service:

// consent-banner.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ConsentService, ConsentPreferences } from './consent.service';
import { trigger, state, style, transition, animate } from '@angular/animations';

@Component({
  selector: 'app-consent-banner',
  standalone: true,
  imports: [CommonModule, FormsModule],
  animations: [
    trigger('slideUp', [
      state('void', style({ transform: 'translateY(100%)', opacity: 0 })),
      state('*', style({ transform: 'translateY(0)', opacity: 1 })),
      transition('void => *', animate('300ms ease-out')),
      transition('* => void', animate('200ms ease-in'))
    ])
  ],
  template: `
    <div
      *ngIf="showBanner"
      [@slideUp]
      class="consent-banner"
      role="dialog"
      aria-labelledby="consent-title"
      aria-describedby="consent-description"
    >
      <div class="consent-content">
        <div class="consent-header">
          <h2 id="consent-title">We value your privacy</h2>
          <p id="consent-description">
            We use cookies to enhance your browsing experience, serve personalized
            content, and analyze our traffic. Please choose your preferences below.
          </p>
        </div>

        <div *ngIf="showDetails" class="consent-details">
          <div class="consent-category">
            <label class="consent-toggle">
              <input type="checkbox" [checked]="true" disabled>
              <span class="toggle-slider disabled"></span>
              <span class="toggle-label">
                <strong>Necessary</strong>
                <small>Required for the website to function properly</small>
              </span>
            </label>
          </div>

          <div class="consent-category">
            <label class="consent-toggle">
              <input
                type="checkbox"
                [(ngModel)]="preferences.analytics"
                (change)="onPreferenceChange()"
              >
              <span class="toggle-slider"></span>
              <span class="toggle-label">
                <strong>Analytics</strong>
                <small>Help us understand how visitors interact with our website</small>
              </span>
            </label>
          </div>

          <div class="consent-category">
            <label class="consent-toggle">
              <input
                type="checkbox"
                [(ngModel)]="preferences.marketing"
                (change)="onPreferenceChange()"
              >
              <span class="toggle-slider"></span>
              <span class="toggle-label">
                <strong>Marketing</strong>
                <small>Used to deliver relevant advertisements</small>
              </span>
            </label>
          </div>

          <div class="consent-category">
            <label class="consent-toggle">
              <input
                type="checkbox"
                [(ngModel)]="preferences.functional"
                (change)="onPreferenceChange()"
              >
              <span class="toggle-slider"></span>
              <span class="toggle-label">
                <strong>Functional</strong>
                <small>Enable enhanced functionality and personalization</small>
              </span>
            </label>
          </div>
        </div>

        <div class="consent-actions">
          <button
            *ngIf="!showDetails"
            class="btn btn-secondary"
            (click)="toggleDetails()"
          >
            Customize
          </button>
          <button
            *ngIf="showDetails"
            class="btn btn-secondary"
            (click)="savePreferences()"
          >
            Save Preferences
          </button>
          <button
            class="btn btn-secondary"
            (click)="denyAll()"
          >
            Deny All
          </button>
          <button
            class="btn btn-primary"
            (click)="acceptAll()"
          >
            Accept All
          </button>
        </div>

        <button
          class="consent-close"
          (click)="denyAll()"
          aria-label="Close consent banner"
        >
          ×
        </button>
      </div>
    </div>
  `,
  styles: [`
    .consent-banner {
      position: fixed;
      bottom: 0;
      left: 0;
      right: 0;
      background: white;
      box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
      z-index: 10000;
      padding: 24px;
    }

    .consent-content {
      max-width: 1200px;
      margin: 0 auto;
      position: relative;
    }

    .consent-header h2 {
      margin: 0 0 8px 0;
      font-size: 18px;
      color: #1a1a1a;
    }

    .consent-header p {
      margin: 0 0 16px 0;
      color: #666;
      font-size: 14px;
      line-height: 1.5;
    }

    .consent-details {
      margin-bottom: 16px;
      padding: 16px;
      background: #f8f9fa;
      border-radius: 8px;
    }

    .consent-category {
      margin-bottom: 12px;
    }

    .consent-category:last-child {
      margin-bottom: 0;
    }

    .consent-toggle {
      display: flex;
      align-items: flex-start;
      cursor: pointer;
    }

    .consent-toggle input {
      display: none;
    }

    .toggle-slider {
      width: 44px;
      height: 24px;
      background: #ccc;
      border-radius: 12px;
      position: relative;
      transition: background 0.2s;
      flex-shrink: 0;
      margin-right: 12px;
    }

    .toggle-slider::after {
      content: '';
      position: absolute;
      width: 20px;
      height: 20px;
      background: white;
      border-radius: 50%;
      top: 2px;
      left: 2px;
      transition: transform 0.2s;
    }

    .toggle-slider.disabled {
      background: #4CAF50;
      opacity: 0.7;
    }

    .toggle-slider.disabled::after {
      transform: translateX(20px);
    }

    input:checked + .toggle-slider {
      background: #4CAF50;
    }

    input:checked + .toggle-slider::after {
      transform: translateX(20px);
    }

    .toggle-label {
      display: flex;
      flex-direction: column;
    }

    .toggle-label strong {
      color: #1a1a1a;
      font-size: 14px;
    }

    .toggle-label small {
      color: #666;
      font-size: 12px;
      margin-top: 2px;
    }

    .consent-actions {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
    }

    .btn {
      padding: 12px 24px;
      border: none;
      border-radius: 6px;
      font-size: 14px;
      font-weight: 600;
      cursor: pointer;
      transition: all 0.2s;
    }

    .btn-primary {
      background: #4CAF50;
      color: white;
    }

    .btn-primary:hover {
      background: #45a049;
    }

    .btn-secondary {
      background: #f0f0f0;
      color: #333;
    }

    .btn-secondary:hover {
      background: #e0e0e0;
    }

    .consent-close {
      position: absolute;
      top: 0;
      right: 0;
      background: none;
      border: none;
      font-size: 24px;
      color: #999;
      cursor: pointer;
      padding: 4px 8px;
    }

    .consent-close:hover {
      color: #333;
    }

    @media (max-width: 768px) {
      .consent-banner {
        padding: 16px;
      }

      .consent-actions {
        flex-direction: column;
      }

      .btn {
        width: 100%;
      }
    }
  `]
})
export class ConsentBannerComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  showBanner = false;
  showDetails = false;

  preferences: ConsentPreferences = {
    analytics: false,
    marketing: false,
    functional: false
  };

  constructor(private consentService: ConsentService) {}

  ngOnInit(): void {
    this.consentService.showBanner$
      .pipe(takeUntil(this.destroy$))
      .subscribe(show => {
        this.showBanner = show;
        if (show) {
          // Reset to current preferences when showing
          const current = this.consentService.getCurrentConsent();
          this.preferences = {
            analytics: current.analytics === 'granted',
            marketing: current.marketing === 'granted',
            functional: current.functional === 'granted'
          };
        }
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  toggleDetails(): void {
    this.showDetails = !this.showDetails;
  }

  onPreferenceChange(): void {
    // Could add analytics tracking here
  }

  acceptAll(): void {
    this.consentService.acceptAll();
  }

  denyAll(): void {
    this.consentService.denyAll();
  }

  savePreferences(): void {
    this.consentService.setPreferences(this.preferences);
  }
}

Consent-Aware Route Guards

One of the most powerful patterns in Angular is using route guards to control access based on consent. Here's how to create guards that wait for consent before loading certain routes:

// consent.guard.ts
import { Injectable } from '@angular/core';
import {
  CanActivate,
  CanLoad,
  Route,
  UrlSegment,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  Router
} from '@angular/router';
import { Observable, of } from 'rxjs';
import { map, take, switchMap, filter } from 'rxjs/operators';
import { ConsentService, ConsentCategory } from './consent.service';

@Injectable({
  providedIn: 'root'
})
export class AnalyticsConsentGuard implements CanActivate, CanLoad {
  constructor(
    private consentService: ConsentService,
    private router: Router
  ) {}

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<boolean> {
    return this.checkConsent('analytics');
  }

  canLoad(route: Route, segments: UrlSegment[]): Observable<boolean> {
    return this.checkConsent('analytics');
  }

  private checkConsent(category: ConsentCategory): Observable<boolean> {
    // Wait for initialization
    return this.consentService.isInitialized$.pipe(
      filter(initialized => initialized),
      take(1),
      switchMap(() => {
        // Check if consent is already granted
        if (this.consentService.isGranted(category)) {
          return of(true);
        }

        // Wait for consent decision
        return this.consentService.waitForConsent(category).pipe(
          take(1),
          map(status => status === 'granted')
        );
      })
    );
  }
}

// Generic consent guard factory
export function createConsentGuard(category: ConsentCategory) {
  @Injectable({
    providedIn: 'root'
  })
  class ConsentGuard implements CanActivate, CanLoad {
    constructor(
      private consentService: ConsentService,
      private router: Router
    ) {}

    canActivate(): Observable<boolean> {
      return this.consentService.isInitialized$.pipe(
        filter(init => init),
        take(1),
        switchMap(() => this.consentService.waitForConsent(category)),
        take(1),
        map(status => status === 'granted')
      );
    }

    canLoad(): Observable<boolean> {
      return this.canActivate();
    }
  }

  return ConsentGuard;
}

Lazy Loading with Consent

Angular's lazy loading can be combined with consent to only load heavy analytics or marketing modules when consent is granted:

// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AnalyticsConsentGuard } from './consent.guard';

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./home/home.module').then(m => m.HomeModule)
  },
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule),
    // Only load the analytics module if analytics consent is granted
    canLoad: [AnalyticsConsentGuard]
  },
  {
    path: 'personalized',
    loadChildren: () => import('./personalized/personalized.module').then(m => m.PersonalizedModule),
    data: { requiresConsent: 'functional' }
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

Consent-Aware Analytics Service

Here's a complete analytics service that only tracks when consent is granted:

// analytics.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil, filter, withLatestFrom } from 'rxjs/operators';
import { ConsentService } from './consent.service';

declare global {
  interface Window {
    gtag: (...args: any[]) => void;
    dataLayer: any[];
  }
}

interface AnalyticsEvent {
  action: string;
  category: string;
  label?: string;
  value?: number;
  nonInteraction?: boolean;
}

interface QueuedEvent {
  type: 'pageview' | 'event';
  data: any;
  timestamp: number;
}

@Injectable({
  providedIn: 'root'
})
export class AnalyticsService implements OnDestroy {
  private destroy$ = new Subject<void>();
  private eventQueue: QueuedEvent[] = [];
  private isAnalyticsLoaded = false;
  private maxQueueAge = 30 * 60 * 1000; // 30 minutes

  constructor(
    private consentService: ConsentService,
    private router: Router
  ) {
    this.initializeAnalytics();
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  private initializeAnalytics(): void {
    // Subscribe to analytics consent
    this.consentService.analyticsGranted$
      .pipe(takeUntil(this.destroy$))
      .subscribe(granted => {
        if (granted && !this.isAnalyticsLoaded) {
          this.loadAnalyticsScripts();
          this.processEventQueue();
        } else if (!granted && this.isAnalyticsLoaded) {
          // Consent was revoked
          this.disableAnalytics();
        }
      });

    // Track page views on navigation
    this.router.events
      .pipe(
        filter(event => event instanceof NavigationEnd),
        withLatestFrom(this.consentService.analyticsGranted$),
        takeUntil(this.destroy$)
      )
      .subscribe(([event, analyticsGranted]) => {
        const navEnd = event as NavigationEnd;
        this.trackPageView(navEnd.urlAfterRedirects, analyticsGranted);
      });
  }

  private loadAnalyticsScripts(): void {
    if (this.isAnalyticsLoaded) return;

    // Load Google Analytics
    const script = document.createElement('script');
    script.async = true;
    script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
    document.head.appendChild(script);

    window.dataLayer = window.dataLayer || [];
    window.gtag = function() {
      window.dataLayer.push(arguments);
    };
    window.gtag('js', new Date());
    window.gtag('config', 'G-XXXXXXXXXX', {
      send_page_view: false // We'll send page views manually
    });

    this.isAnalyticsLoaded = true;
    console.log('Analytics loaded with consent');
  }

  private disableAnalytics(): void {
    // Disable tracking
    window['ga-disable-G-XXXXXXXXXX'] = true;
    this.isAnalyticsLoaded = false;
    console.log('Analytics disabled - consent revoked');
  }

  private processEventQueue(): void {
    const now = Date.now();

    // Filter out old events
    const validEvents = this.eventQueue.filter(
      event => now - event.timestamp < this.maxQueueAge
    );

    // Process valid events
    validEvents.forEach(event => {
      if (event.type === 'pageview') {
        this.sendPageView(event.data.path);
      } else if (event.type === 'event') {
        this.sendEvent(event.data);
      }
    });

    // Clear the queue
    this.eventQueue = [];
  }

  private trackPageView(path: string, analyticsGranted: boolean): void {
    if (analyticsGranted && this.isAnalyticsLoaded) {
      this.sendPageView(path);
    } else if (!analyticsGranted) {
      // Queue for later if consent might be given
      this.queueEvent('pageview', { path });
    }
  }

  private sendPageView(path: string): void {
    if (window.gtag) {
      window.gtag('event', 'page_view', {
        page_path: path,
        page_title: document.title
      });
    }
  }

  /**
   * Track a custom event
   */
  trackEvent(event: AnalyticsEvent): void {
    if (this.consentService.isGranted('analytics') && this.isAnalyticsLoaded) {
      this.sendEvent(event);
    } else {
      this.queueEvent('event', event);
    }
  }

  private sendEvent(event: AnalyticsEvent): void {
    if (window.gtag) {
      window.gtag('event', event.action, {
        event_category: event.category,
        event_label: event.label,
        value: event.value,
        non_interaction: event.nonInteraction
      });
    }
  }

  private queueEvent(type: 'pageview' | 'event', data: any): void {
    this.eventQueue.push({
      type,
      data,
      timestamp: Date.now()
    });

    // Limit queue size
    if (this.eventQueue.length > 100) {
      this.eventQueue = this.eventQueue.slice(-100);
    }
  }

  /**
   * Track timing events
   */
  trackTiming(category: string, variable: string, value: number, label?: string): void {
    if (this.consentService.isGranted('analytics') && this.isAnalyticsLoaded && window.gtag) {
      window.gtag('event', 'timing_complete', {
        name: variable,
        value: value,
        event_category: category,
        event_label: label
      });
    }
  }

  /**
   * Track exceptions
   */
  trackException(description: string, fatal = false): void {
    if (this.consentService.isGranted('analytics') && this.isAnalyticsLoaded && window.gtag) {
      window.gtag('event', 'exception', {
        description: description,
        fatal: fatal
      });
    }
  }
}

Using APP_INITIALIZER for Consent

To ensure consent is loaded before your application bootstraps:

// app.module.ts
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ConsentService } from './consent.service';
import { ConsentBannerComponent } from './consent-banner.component';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

export function initializeConsent(consentService: ConsentService) {
  return () => new Promise<void>((resolve) => {
    // Wait for consent service to initialize
    const subscription = consentService.isInitialized$.subscribe(initialized => {
      if (initialized) {
        subscription.unsubscribe();
        resolve();
      }
    });
  });
}

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    AppRoutingModule,
    ConsentBannerComponent
  ],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: initializeConsent,
      deps: [ConsentService],
      multi: true
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Vue.js CMP Integration

Setting Up Pinia Store for Consent

Vue 3 with Pinia provides an excellent foundation for consent management. Let's create a comprehensive consent store:

// stores/consent.ts
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';

export type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'functional';
export type ConsentStatus = 'pending' | 'granted' | 'denied';

export interface ConsentState {
  necessary: ConsentStatus;
  analytics: ConsentStatus;
  marketing: ConsentStatus;
  functional: ConsentStatus;
  timestamp: number;
  version: string;
}

export interface ConsentPreferences {
  analytics: boolean;
  marketing: boolean;
  functional: boolean;
}

const CONSENT_STORAGE_KEY = 'olc_consent_state';
const CONSENT_VERSION = '2.0';

export const useConsentStore = defineStore('consent', () => {
  // State
  const consentState = ref<ConsentState>({
    necessary: 'granted',
    analytics: 'pending',
    marketing: 'pending',
    functional: 'pending',
    timestamp: 0,
    version: CONSENT_VERSION
  });

  const initialized = ref(false);
  const bannerVisible = ref(false);

  // Getters
  const analyticsConsent = computed(() => consentState.value.analytics);
  const marketingConsent = computed(() => consentState.value.marketing);
  const functionalConsent = computed(() => consentState.value.functional);

  const analyticsGranted = computed(() => consentState.value.analytics === 'granted');
  const marketingGranted = computed(() => consentState.value.marketing === 'granted');
  const functionalGranted = computed(() => consentState.value.functional === 'granted');

  const hasDecided = computed(() =>
    consentState.value.analytics !== 'pending' ||
    consentState.value.marketing !== 'pending' ||
    consentState.value.functional !== 'pending'
  );

  // Actions
  function loadStoredConsent(): ConsentState | null {
    try {
      const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
      if (!stored) return null;

      const parsed = JSON.parse(stored) as ConsentState;

      if (!parsed.timestamp || !parsed.version) return null;
      if (parsed.version !== CONSENT_VERSION) return null;

      // Check expiration (1 year)
      const oneYearAgo = Date.now() - (365 * 24 * 60 * 60 * 1000);
      if (parsed.timestamp < oneYearAgo) return null;

      return parsed;
    } catch {
      return null;
    }
  }

  function saveConsent(state: ConsentState): void {
    try {
      localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state));
    } catch (e) {
      console.warn('Failed to save consent:', e);
    }
  }

  function initialize(): void {
    if (initialized.value) return;

    const stored = loadStoredConsent();

    if (stored) {
      consentState.value = stored;
      bannerVisible.value = false;
      updateGoogleConsentMode(stored);
    } else {
      bannerVisible.value = true;
      setDefaultGoogleConsentMode();
    }

    initialized.value = true;
  }

  function acceptAll(): void {
    const newState: ConsentState = {
      necessary: 'granted',
      analytics: 'granted',
      marketing: 'granted',
      functional: 'granted',
      timestamp: Date.now(),
      version: CONSENT_VERSION
    };

    updateConsent(newState);
  }

  function denyAll(): void {
    const newState: ConsentState = {
      necessary: 'granted',
      analytics: 'denied',
      marketing: 'denied',
      functional: 'denied',
      timestamp: Date.now(),
      version: CONSENT_VERSION
    };

    updateConsent(newState);
  }

  function setPreferences(preferences: ConsentPreferences): void {
    const newState: ConsentState = {
      necessary: 'granted',
      analytics: preferences.analytics ? 'granted' : 'denied',
      marketing: preferences.marketing ? 'granted' : 'denied',
      functional: preferences.functional ? 'granted' : 'denied',
      timestamp: Date.now(),
      version: CONSENT_VERSION
    };

    updateConsent(newState);
  }

  function updateConsent(state: ConsentState): void {
    consentState.value = state;
    saveConsent(state);
    bannerVisible.value = false;
    updateGoogleConsentMode(state);
    pushToDataLayer(state);
  }

  function showBanner(): void {
    bannerVisible.value = true;
  }

  function hideBanner(): void {
    bannerVisible.value = false;
  }

  function isGranted(category: ConsentCategory): boolean {
    return consentState.value[category] === 'granted';
  }

  // Google Consent Mode integration
  function setDefaultGoogleConsentMode(): void {
    if (typeof window !== 'undefined' && (window as any).gtag) {
      (window as any).gtag('consent', 'default', {
        'analytics_storage': 'denied',
        'ad_storage': 'denied',
        'ad_user_data': 'denied',
        'ad_personalization': 'denied',
        'functionality_storage': 'denied',
        'personalization_storage': 'denied',
        'security_storage': 'granted'
      });
    }
  }

  function updateGoogleConsentMode(state: ConsentState): void {
    if (typeof window !== 'undefined' && (window as any).gtag) {
      (window as any).gtag('consent', 'update', {
        'analytics_storage': state.analytics === 'granted' ? 'granted' : 'denied',
        'ad_storage': state.marketing === 'granted' ? 'granted' : 'denied',
        'ad_user_data': state.marketing === 'granted' ? 'granted' : 'denied',
        'ad_personalization': state.marketing === 'granted' ? 'granted' : 'denied',
        'functionality_storage': state.functional === 'granted' ? 'granted' : 'denied',
        'personalization_storage': state.functional === 'granted' ? 'granted' : 'denied',
        'security_storage': 'granted'
      });
    }
  }

  function pushToDataLayer(state: ConsentState): void {
    if (typeof window !== 'undefined') {
      (window as any).dataLayer = (window as any).dataLayer || [];
      (window as any).dataLayer.push({
        event: 'consent_update',
        consent_analytics: state.analytics,
        consent_marketing: state.marketing,
        consent_functional: state.functional,
        consent_timestamp: state.timestamp
      });
    }
  }

  return {
    // State
    consentState,
    initialized,
    bannerVisible,

    // Getters
    analyticsConsent,
    marketingConsent,
    functionalConsent,
    analyticsGranted,
    marketingGranted,
    functionalGranted,
    hasDecided,

    // Actions
    initialize,
    acceptAll,
    denyAll,
    setPreferences,
    showBanner,
    hideBanner,
    isGranted
  };
});

Vue Composable for Consent

Create a composable that makes consent management easy to use in any component:

// composables/useConsent.ts
import { computed, watch, onMounted, onUnmounted, ref } from 'vue';
import { useConsentStore, ConsentCategory, ConsentStatus } from '@/stores/consent';

interface UseConsentOptions {
  category?: ConsentCategory;
  onGranted?: () => void;
  onDenied?: () => void;
  onChanged?: (status: ConsentStatus) => void;
}

export function useConsent(options: UseConsentOptions = {}) {
  const store = useConsentStore();
  const isLoading = ref(true);

  // Initialize store on mount
  onMounted(() => {
    store.initialize();
    isLoading.value = false;
  });

  // Watch for consent changes if category specified
  if (options.category) {
    const stopWatch = watch(
      () => store.consentState[options.category!],
      (newStatus, oldStatus) => {
        if (newStatus === oldStatus) return;

        options.onChanged?.(newStatus);

        if (newStatus === 'granted') {
          options.onGranted?.();
        } else if (newStatus === 'denied') {
          options.onDenied?.();
        }
      },
      { immediate: true }
    );

    onUnmounted(() => {
      stopWatch();
    });
  }

  // Computed properties for the specified category
  const status = computed(() =>
    options.category ? store.consentState[options.category] : null
  );

  const isGranted = computed(() =>
    options.category ? store.isGranted(options.category) : false
  );

  const isPending = computed(() =>
    options.category ? store.consentState[options.category] === 'pending' : false
  );

  return {
    // Store state
    consentState: computed(() => store.consentState),
    initialized: computed(() => store.initialized),
    bannerVisible: computed(() => store.bannerVisible),
    isLoading,

    // Category-specific
    status,
    isGranted,
    isPending,

    // All category states
    analyticsGranted: computed(() => store.analyticsGranted),
    marketingGranted: computed(() => store.marketingGranted),
    functionalGranted: computed(() => store.functionalGranted),

    // Actions
    acceptAll: store.acceptAll,
    denyAll: store.denyAll,
    setPreferences: store.setPreferences,
    showBanner: store.showBanner,
    hideBanner: store.hideBanner,
    checkConsent: store.isGranted
  };
}

// Specialized composable for analytics consent
export function useAnalyticsConsent() {
  return useConsent({ category: 'analytics' });
}

// Specialized composable for marketing consent
export function useMarketingConsent() {
  return useConsent({ category: 'marketing' });
}

// Specialized composable for functional consent
export function useFunctionalConsent() {
  return useConsent({ category: 'functional' });
}

Consent Banner Component for Vue

<!-- components/ConsentBanner.vue -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useConsent } from '@/composables/useConsent';

const {
  bannerVisible,
  acceptAll,
  denyAll,
  setPreferences,
  consentState
} = useConsent();

const showDetails = ref(false);
const preferences = ref({
  analytics: false,
  marketing: false,
  functional: false
});

// Sync preferences with current consent state when banner opens
watch(bannerVisible, (visible) => {
  if (visible) {
    preferences.value = {
      analytics: consentState.value.analytics === 'granted',
      marketing: consentState.value.marketing === 'granted',
      functional: consentState.value.functional === 'granted'
    };
    showDetails.value = false;
  }
});

function handleAcceptAll() {
  acceptAll();
}

function handleDenyAll() {
  denyAll();
}

function handleSavePreferences() {
  setPreferences(preferences.value);
}

function toggleDetails() {
  showDetails.value = !showDetails.value;
}
</script>

<template>
  <Transition name="slide-up">
    <div
      v-if="bannerVisible"
      class="consent-banner"
      role="dialog"
      aria-labelledby="consent-title"
      aria-describedby="consent-description"
    >
      <div class="consent-content">
        <div class="consent-header">
          <h2 id="consent-title">We value your privacy</h2>
          <p id="consent-description">
            We use cookies to enhance your browsing experience, serve personalized
            content, and analyze our traffic. Please choose your preferences below.
          </p>
        </div>

        <Transition name="expand">
          <div v-if="showDetails" class="consent-details">
            <div class="consent-category">
              <label class="consent-toggle">
                <input type="checkbox" checked disabled>
                <span class="toggle-slider disabled"></span>
                <span class="toggle-label">
                  <strong>Necessary</strong>
                  <small>Required for the website to function properly</small>
                </span>
              </label>
            </div>

            <div class="consent-category">
              <label class="consent-toggle">
                <input type="checkbox" v-model="preferences.analytics">
                <span class="toggle-slider"></span>
                <span class="toggle-label">
                  <strong>Analytics</strong>
                  <small>Help us understand how visitors interact with our website</small>
                </span>
              </label>
            </div>

            <div class="consent-category">
              <label class="consent-toggle">
                <input type="checkbox" v-model="preferences.marketing">
                <span class="toggle-slider"></span>
                <span class="toggle-label">
                  <strong>Marketing</strong>
                  <small>Used to deliver relevant advertisements</small>
                </span>
              </label>
            </div>

            <div class="consent-category">
              <label class="consent-toggle">
                <input type="checkbox" v-model="preferences.functional">
                <span class="toggle-slider"></span>
                <span class="toggle-label">
                  <strong>Functional</strong>
                  <small>Enable enhanced functionality and personalization</small>
                </span>
              </label>
            </div>
          </div>
        </Transition>

        <div class="consent-actions">
          <button
            v-if="!showDetails"
            class="btn btn-secondary"
            @click="toggleDetails"
          >
            Customize
          </button>
          <button
            v-if="showDetails"
            class="btn btn-secondary"
            @click="handleSavePreferences"
          >
            Save Preferences
          </button>
          <button
            class="btn btn-secondary"
            @click="handleDenyAll"
          >
            Deny All
          </button>
          <button
            class="btn btn-primary"
            @click="handleAcceptAll"
          >
            Accept All
          </button>
        </div>
      </div>
    </div>
  </Transition>
</template>

<style scoped>
.consent-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: white;
  box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
  z-index: 10000;
  padding: 24px;
}

.consent-content {
  max-width: 1200px;
  margin: 0 auto;
}

.consent-header h2 {
  margin: 0 0 8px 0;
  font-size: 18px;
  color: #1a1a1a;
}

.consent-header p {
  margin: 0 0 16px 0;
  color: #666;
  font-size: 14px;
  line-height: 1.5;
}

.consent-details {
  margin-bottom: 16px;
  padding: 16px;
  background: #f8f9fa;
  border-radius: 8px;
}

.consent-category {
  margin-bottom: 12px;
}

.consent-category:last-child {
  margin-bottom: 0;
}

.consent-toggle {
  display: flex;
  align-items: flex-start;
  cursor: pointer;
}

.consent-toggle input {
  display: none;
}

.toggle-slider {
  width: 44px;
  height: 24px;
  background: #ccc;
  border-radius: 12px;
  position: relative;
  transition: background 0.2s;
  flex-shrink: 0;
  margin-right: 12px;
}

.toggle-slider::after {
  content: '';
  position: absolute;
  width: 20px;
  height: 20px;
  background: white;
  border-radius: 50%;
  top: 2px;
  left: 2px;
  transition: transform 0.2s;
}

.toggle-slider.disabled {
  background: #4CAF50;
  opacity: 0.7;
}

.toggle-slider.disabled::after {
  transform: translateX(20px);
}

input:checked + .toggle-slider {
  background: #4CAF50;
}

input:checked + .toggle-slider::after {
  transform: translateX(20px);
}

.toggle-label {
  display: flex;
  flex-direction: column;
}

.toggle-label strong {
  color: #1a1a1a;
  font-size: 14px;
}

.toggle-label small {
  color: #666;
  font-size: 12px;
  margin-top: 2px;
}

.consent-actions {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}

.btn {
  padding: 12px 24px;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-primary {
  background: #4CAF50;
  color: white;
}

.btn-primary:hover {
  background: #45a049;
}

.btn-secondary {
  background: #f0f0f0;
  color: #333;
}

.btn-secondary:hover {
  background: #e0e0e0;
}

/* Transitions */
.slide-up-enter-active,
.slide-up-leave-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

.slide-up-enter-from,
.slide-up-leave-to {
  transform: translateY(100%);
  opacity: 0;
}

.expand-enter-active,
.expand-leave-active {
  transition: all 0.3s ease;
  overflow: hidden;
}

.expand-enter-from,
.expand-leave-to {
  opacity: 0;
  max-height: 0;
  padding-top: 0;
  padding-bottom: 0;
  margin-bottom: 0;
}

@media (max-width: 768px) {
  .consent-banner {
    padding: 16px;
  }

  .consent-actions {
    flex-direction: column;
  }

  .btn {
    width: 100%;
  }
}
</style>

Vue Router Navigation Guards

Implement consent-aware navigation guards for Vue Router:

// router/guards.ts
import { RouteLocationNormalized, NavigationGuardNext } from 'vue-router';
import { useConsentStore, ConsentCategory } from '@/stores/consent';

export function createConsentGuard(requiredCategory: ConsentCategory) {
  return async (
    to: RouteLocationNormalized,
    from: RouteLocationNormalized,
    next: NavigationGuardNext
  ) => {
    const store = useConsentStore();

    // Wait for initialization
    if (!store.initialized) {
      store.initialize();
    }

    // Check consent
    if (store.isGranted(requiredCategory)) {
      next();
    } else {
      // Option 1: Redirect to a fallback page
      // next({ name: 'consent-required', query: { redirect: to.fullPath } });

      // Option 2: Show consent banner and wait
      store.showBanner();

      // Watch for consent change
      const unwatch = store.$subscribe((mutation, state) => {
        if (state.consentState[requiredCategory] === 'granted') {
          unwatch();
          next();
        } else if (state.consentState[requiredCategory] === 'denied') {
          unwatch();
          next(false);
        }
      });
    }
  };
}

// Usage in router
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { createConsentGuard } from './guards';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('@/views/Home.vue')
    },
    {
      path: '/analytics-dashboard',
      component: () => import('@/views/AnalyticsDashboard.vue'),
      beforeEnter: createConsentGuard('analytics')
    },
    {
      path: '/personalized-recommendations',
      component: () => import('@/views/Recommendations.vue'),
      beforeEnter: createConsentGuard('functional')
    }
  ]
});

export default router;

Consent-Aware Analytics Composable

// composables/useAnalytics.ts
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAnalyticsConsent } from './useConsent';

interface AnalyticsEvent {
  action: string;
  category: string;
  label?: string;
  value?: number;
}

interface QueuedEvent {
  type: 'pageview' | 'event';
  data: any;
  timestamp: number;
}

declare global {
  interface Window {
    gtag: (...args: any[]) => void;
    dataLayer: any[];
  }
}

export function useAnalytics() {
  const router = useRouter();
  const { isGranted, status } = useAnalyticsConsent();

  const isLoaded = ref(false);
  const eventQueue = ref<QueuedEvent[]>([]);
  const MAX_QUEUE_AGE = 30 * 60 * 1000; // 30 minutes

  // Load analytics when consent is granted
  watch(isGranted, (granted) => {
    if (granted && !isLoaded.value) {
      loadAnalytics();
      processQueue();
    } else if (!granted && isLoaded.value) {
      disableAnalytics();
    }
  }, { immediate: true });

  // Track page views on route change
  onMounted(() => {
    router.afterEach((to) => {
      trackPageView(to.fullPath);
    });
  });

  function loadAnalytics() {
    if (isLoaded.value) return;

    const script = document.createElement('script');
    script.async = true;
    script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
    document.head.appendChild(script);

    window.dataLayer = window.dataLayer || [];
    window.gtag = function() {
      window.dataLayer.push(arguments);
    };
    window.gtag('js', new Date());
    window.gtag('config', 'G-XXXXXXXXXX', {
      send_page_view: false
    });

    isLoaded.value = true;
  }

  function disableAnalytics() {
    window['ga-disable-G-XXXXXXXXXX'] = true;
    isLoaded.value = false;
  }

  function processQueue() {
    const now = Date.now();
    const validEvents = eventQueue.value.filter(
      e => now - e.timestamp < MAX_QUEUE_AGE
    );

    validEvents.forEach(event => {
      if (event.type === 'pageview') {
        sendPageView(event.data.path);
      } else {
        sendEvent(event.data);
      }
    });

    eventQueue.value = [];
  }

  function queueEvent(type: 'pageview' | 'event', data: any) {
    eventQueue.value.push({
      type,
      data,
      timestamp: Date.now()
    });

    // Limit queue size
    if (eventQueue.value.length > 100) {
      eventQueue.value = eventQueue.value.slice(-100);
    }
  }

  function trackPageView(path: string) {
    if (isGranted.value && isLoaded.value) {
      sendPageView(path);
    } else if (status.value === 'pending') {
      queueEvent('pageview', { path });
    }
  }

  function sendPageView(path: string) {
    if (window.gtag) {
      window.gtag('event', 'page_view', {
        page_path: path,
        page_title: document.title
      });
    }
  }

  function trackEvent(event: AnalyticsEvent) {
    if (isGranted.value && isLoaded.value) {
      sendEvent(event);
    } else if (status.value === 'pending') {
      queueEvent('event', event);
    }
  }

  function sendEvent(event: AnalyticsEvent) {
    if (window.gtag) {
      window.gtag('event', event.action, {
        event_category: event.category,
        event_label: event.label,
        value: event.value
      });
    }
  }

  return {
    isLoaded,
    trackPageView,
    trackEvent
  };
}

Framework-Agnostic Best Practices

Script Loading Strategies

Both Angular and Vue applications need a consistent approach to loading third-party scripts based on consent:

// utils/scriptLoader.ts
interface ScriptConfig {
  src: string;
  id: string;
  async?: boolean;
  defer?: boolean;
  attributes?: Record<string, string>;
  onLoad?: () => void;
  onError?: (error: Error) => void;
}

class ScriptLoader {
  private loadedScripts = new Set<string>();
  private loadingScripts = new Map<string, Promise<void>>();

  async load(config: ScriptConfig): Promise<void> {
    // Already loaded
    if (this.loadedScripts.has(config.id)) {
      return Promise.resolve();
    }

    // Currently loading
    if (this.loadingScripts.has(config.id)) {
      return this.loadingScripts.get(config.id)!;
    }

    const loadPromise = new Promise<void>((resolve, reject) => {
      const script = document.createElement('script');
      script.id = config.id;
      script.src = config.src;
      script.async = config.async ?? true;
      script.defer = config.defer ?? false;

      if (config.attributes) {
        Object.entries(config.attributes).forEach(([key, value]) => {
          script.setAttribute(key, value);
        });
      }

      script.onload = () => {
        this.loadedScripts.add(config.id);
        this.loadingScripts.delete(config.id);
        config.onLoad?.();
        resolve();
      };

      script.onerror = () => {
        this.loadingScripts.delete(config.id);
        const error = new Error(`Failed to load script: ${config.src}`);
        config.onError?.(error);
        reject(error);
      };

      document.head.appendChild(script);
    });

    this.loadingScripts.set(config.id, loadPromise);
    return loadPromise;
  }

  unload(id: string): void {
    const script = document.getElementById(id);
    if (script) {
      script.remove();
      this.loadedScripts.delete(id);
    }
  }

  isLoaded(id: string): boolean {
    return this.loadedScripts.has(id);
  }
}

export const scriptLoader = new ScriptLoader();

Consent-Aware Third-Party Integration

Here's a pattern for integrating any third-party service with consent awareness:

// services/thirdPartyManager.ts
import { scriptLoader } from '@/utils/scriptLoader';

type ConsentChecker = () => boolean;
type ConsentWatcher = (callback: () => void) => () => void;

interface ThirdPartyService {
  id: string;
  name: string;
  consentCategory: 'analytics' | 'marketing' | 'functional';
  scripts: Array<{
    src: string;
    attributes?: Record<string, string>;
  }>;
  initialize: () => void;
  cleanup?: () => void;
}

class ThirdPartyManager {
  private services: Map<string, ThirdPartyService> = new Map();
  private activeServices: Set<string> = new Set();
  private consentChecker: ConsentChecker;
  private consentWatcher: ConsentWatcher;
  private unwatchFns: Map<string, () => void> = new Map();

  constructor(
    consentChecker: ConsentChecker,
    consentWatcher: ConsentWatcher
  ) {
    this.consentChecker = consentChecker;
    this.consentWatcher = consentWatcher;
  }

  register(service: ThirdPartyService): void {
    this.services.set(service.id, service);

    // Watch for consent changes
    const unwatch = this.consentWatcher(() => {
      this.checkAndUpdateService(service);
    });

    this.unwatchFns.set(service.id, unwatch);

    // Initial check
    this.checkAndUpdateService(service);
  }

  private async checkAndUpdateService(service: ThirdPartyService): Promise<void> {
    const hasConsent = this.consentChecker();
    const isActive = this.activeServices.has(service.id);

    if (hasConsent && !isActive) {
      await this.activateService(service);
    } else if (!hasConsent && isActive) {
      this.deactivateService(service);
    }
  }

  private async activateService(service: ThirdPartyService): Promise<void> {
    try {
      // Load all scripts
      for (const script of service.scripts) {
        await scriptLoader.load({
          id: `${service.id}-${script.src.split('/').pop()}`,
          src: script.src,
          attributes: script.attributes
        });
      }

      // Run initialization
      service.initialize();
      this.activeServices.add(service.id);

      console.log(`${service.name} activated with consent`);
    } catch (error) {
      console.error(`Failed to activate ${service.name}:`, error);
    }
  }

  private deactivateService(service: ThirdPartyService): void {
    // Run cleanup if provided
    service.cleanup?.();

    // Remove scripts
    for (const script of service.scripts) {
      scriptLoader.unload(`${service.id}-${script.src.split('/').pop()}`);
    }

    this.activeServices.delete(service.id);
    console.log(`${service.name} deactivated - consent revoked`);
  }

  unregister(serviceId: string): void {
    const service = this.services.get(serviceId);
    if (service) {
      this.deactivateService(service);
      this.unwatchFns.get(serviceId)?.();
      this.unwatchFns.delete(serviceId);
      this.services.delete(serviceId);
    }
  }

  getActiveServices(): string[] {
    return Array.from(this.activeServices);
  }
}

export { ThirdPartyManager, ThirdPartyService };

Testing Consent Integration

Unit Testing Angular Consent Service

// consent.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ConsentService } from './consent.service';

describe('ConsentService', () => {
  let service: ConsentService;

  beforeEach(() => {
    // Clear localStorage before each test
    localStorage.clear();

    TestBed.configureTestingModule({});
    service = TestBed.inject(ConsentService);
  });

  afterEach(() => {
    localStorage.clear();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should show banner when no stored consent', (done) => {
    service.showBanner$.subscribe(show => {
      expect(show).toBe(true);
      done();
    });
  });

  it('should accept all consent categories', (done) => {
    service.acceptAll();

    service.consent$.subscribe(state => {
      expect(state.analytics).toBe('granted');
      expect(state.marketing).toBe('granted');
      expect(state.functional).toBe('granted');
      done();
    });
  });

  it('should deny all optional consent categories', (done) => {
    service.denyAll();

    service.consent$.subscribe(state => {
      expect(state.analytics).toBe('denied');
      expect(state.marketing).toBe('denied');
      expect(state.functional).toBe('denied');
      expect(state.necessary).toBe('granted'); // Always granted
      done();
    });
  });

  it('should persist consent to localStorage', () => {
    service.acceptAll();

    const stored = localStorage.getItem('olc_consent_state');
    expect(stored).toBeTruthy();

    const parsed = JSON.parse(stored!);
    expect(parsed.analytics).toBe('granted');
  });

  it('should restore consent from localStorage', () => {
    // Store consent
    const mockConsent = {
      necessary: 'granted',
      analytics: 'granted',
      marketing: 'denied',
      functional: 'granted',
      timestamp: Date.now(),
      version: '2.0'
    };
    localStorage.setItem('olc_consent_state', JSON.stringify(mockConsent));

    // Create new service instance
    const newService = new ConsentService();

    expect(newService.getCurrentConsent().analytics).toBe('granted');
    expect(newService.getCurrentConsent().marketing).toBe('denied');
  });

  it('should emit analyticsGranted$ correctly', (done) => {
    let emissions: boolean[] = [];

    service.analyticsGranted$.subscribe(granted => {
      emissions.push(granted);

      if (emissions.length === 2) {
        expect(emissions[0]).toBe(false); // Initial pending state
        expect(emissions[1]).toBe(true); // After acceptAll
        done();
      }
    });

    service.acceptAll();
  });
});

Testing Vue Consent Store

// stores/consent.spec.ts
import { setActivePinia, createPinia } from 'pinia';
import { useConsentStore } from './consent';

describe('Consent Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
    localStorage.clear();
  });

  afterEach(() => {
    localStorage.clear();
  });

  it('initializes with pending state', () => {
    const store = useConsentStore();
    store.initialize();

    expect(store.consentState.analytics).toBe('pending');
    expect(store.bannerVisible).toBe(true);
  });

  it('accepts all consent', () => {
    const store = useConsentStore();
    store.initialize();
    store.acceptAll();

    expect(store.analyticsGranted).toBe(true);
    expect(store.marketingGranted).toBe(true);
    expect(store.functionalGranted).toBe(true);
    expect(store.bannerVisible).toBe(false);
  });

  it('denies all optional consent', () => {
    const store = useConsentStore();
    store.initialize();
    store.denyAll();

    expect(store.analyticsGranted).toBe(false);
    expect(store.marketingGranted).toBe(false);
    expect(store.functionalGranted).toBe(false);
  });

  it('persists consent to localStorage', () => {
    const store = useConsentStore();
    store.initialize();
    store.acceptAll();

    const stored = localStorage.getItem('olc_consent_state');
    expect(stored).toBeTruthy();

    const parsed = JSON.parse(stored!);
    expect(parsed.analytics).toBe('granted');
  });

  it('restores consent from localStorage', () => {
    const mockConsent = {
      necessary: 'granted',
      analytics: 'granted',
      marketing: 'denied',
      functional: 'granted',
      timestamp: Date.now(),
      version: '2.0'
    };
    localStorage.setItem('olc_consent_state', JSON.stringify(mockConsent));

    const store = useConsentStore();
    store.initialize();

    expect(store.analyticsGranted).toBe(true);
    expect(store.marketingGranted).toBe(false);
    expect(store.bannerVisible).toBe(false);
  });

  it('shows banner when consent version changes', () => {
    const oldConsent = {
      necessary: 'granted',
      analytics: 'granted',
      marketing: 'granted',
      functional: 'granted',
      timestamp: Date.now(),
      version: '1.0' // Old version
    };
    localStorage.setItem('olc_consent_state', JSON.stringify(oldConsent));

    const store = useConsentStore();
    store.initialize();

    expect(store.bannerVisible).toBe(true);
  });
});

E2E Testing with Playwright

// e2e/consent.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Consent Banner', () => {
  test.beforeEach(async ({ page, context }) => {
    // Clear cookies and localStorage
    await context.clearCookies();
    await page.goto('/');
    await page.evaluate(() => localStorage.clear());
    await page.reload();
  });

  test('shows consent banner on first visit', async ({ page }) => {
    await page.goto('/');

    const banner = page.locator('[role="dialog"]');
    await expect(banner).toBeVisible();
    await expect(banner).toContainText('We value your privacy');
  });

  test('accepts all cookies', async ({ page }) => {
    await page.goto('/');

    await page.click('text=Accept All');

    // Banner should hide
    const banner = page.locator('[role="dialog"]');
    await expect(banner).not.toBeVisible();

    // Check localStorage
    const consent = await page.evaluate(() =>
      JSON.parse(localStorage.getItem('olc_consent_state') || '{}')
    );
    expect(consent.analytics).toBe('granted');
    expect(consent.marketing).toBe('granted');
  });

  test('denies all cookies', async ({ page }) => {
    await page.goto('/');

    await page.click('text=Deny All');

    const consent = await page.evaluate(() =>
      JSON.parse(localStorage.getItem('olc_consent_state') || '{}')
    );
    expect(consent.analytics).toBe('denied');
    expect(consent.marketing).toBe('denied');
  });

  test('customizes cookie preferences', async ({ page }) => {
    await page.goto('/');

    await page.click('text=Customize');

    // Toggle analytics on, keep marketing off
    await page.locator('text=Analytics').locator('..').locator('input').check();

    await page.click('text=Save Preferences');

    const consent = await page.evaluate(() =>
      JSON.parse(localStorage.getItem('olc_consent_state') || '{}')
    );
    expect(consent.analytics).toBe('granted');
    expect(consent.marketing).toBe('denied');
  });

  test('remembers consent on subsequent visits', async ({ page }) => {
    await page.goto('/');
    await page.click('text=Accept All');

    // Navigate away and back
    await page.goto('/about');
    await page.goto('/');

    // Banner should not appear
    const banner = page.locator('[role="dialog"]');
    await expect(banner).not.toBeVisible();
  });

  test('analytics script loads only after consent', async ({ page }) => {
    await page.goto('/');

    // Check that GA is not loaded
    let gaLoaded = await page.evaluate(() =>
      !!document.querySelector('script[src*="googletagmanager"]')
    );
    expect(gaLoaded).toBe(false);

    // Accept cookies
    await page.click('text=Accept All');

    // Wait for script to load
    await page.waitForTimeout(1000);

    gaLoaded = await page.evaluate(() =>
      !!document.querySelector('script[src*="googletagmanager"]')
    );
    expect(gaLoaded).toBe(true);
  });
});

Common Mistakes to Avoid

1. Race Conditions with Script Loading

Wrong:

// Script might fire before consent is checked
ngOnInit() {
  this.loadAnalytics();
  this.consentService.consent$.subscribe(consent => {
    // Too late!
  });
}

Right:

ngOnInit() {
  this.consentService.analyticsGranted$.pipe(
    filter(granted => granted),
    take(1)
  ).subscribe(() => {
    this.loadAnalytics();
  });
}

2. Memory Leaks from Subscriptions

Wrong:

// No cleanup - subscription leaks
export class MyComponent {
  ngOnInit() {
    this.consentService.consent$.subscribe(consent => {
      // This subscription never gets cleaned up
    });
  }
}

Right:

export class MyComponent implements OnDestroy {
  private destroy$ = new Subject<void>();

  ngOnInit() {
    this.consentService.consent$
      .pipe(takeUntil(this.destroy$))
      .subscribe(consent => {
        // Subscription is properly managed
      });
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

3. Not Handling SSR

Wrong:

// Will crash during SSR
constructor() {
  const stored = localStorage.getItem('consent');
}

Right:

constructor(@Inject(PLATFORM_ID) private platformId: Object) {
  if (isPlatformBrowser(this.platformId)) {
    const stored = localStorage.getItem('consent');
  }
}

4. Blocking the Main Thread

Wrong:

// Synchronous check blocks rendering
if (this.checkConsentSynchronously()) {
  this.loadAllScripts();
}

Right:

// Async check doesn't block
this.consentService.isInitialized$.pipe(
  filter(init => init),
  take(1),
  switchMap(() => this.consentService.analyticsGranted$)
).subscribe(granted => {
  if (granted) this.loadAnalytics();
});

Putting It All Together

Integrating consent management into Angular and Vue.js applications requires thinking reactively. The frameworks' strengths—dependency injection in Angular, the Composition API in Vue—make it possible to build consent systems that feel native rather than bolted-on.

The key principles remain constant regardless of framework:

  1. State First, Scripts Second: Never load third-party scripts until consent is confirmed
  2. Reactive Updates: When consent changes, all dependent systems should react automatically
  3. Clean Subscriptions: Prevent memory leaks by properly managing subscriptions and watchers
  4. SSR Awareness: Handle server-side rendering gracefully
  5. Type Safety: Use TypeScript to catch consent-related bugs at compile time
  6. Testability: Build consent into your testing strategy from day one

The code examples in this guide are production-ready. You can adapt them to your specific needs, whether you're building a simple blog or a complex enterprise application. The patterns scale because they're built on the same reactive foundations that power Angular and Vue themselves.

Remember: good consent management isn't just about compliance—it's about building trust with your users. When users see that your application respects their privacy preferences instantly and consistently, they're more likely to engage with your content and, ultimately, more likely to grant the consent you need for analytics and personalization.

Start with the consent service, build your banner, and work outward from there. Every component that touches user data should check consent first. It's more work upfront, but it pays dividends in user trust and regulatory compliance.

G

GetCookies-teamet

Skrevet og vedligeholdt af GetCookies-teamet hos Getia AS i Oslo, Norge. Artiklen er generel information om cookiesamtykke og ikke juridisk rådgivning.

Scan dit eget website gratis

Gratisplanen kører banneret på ét website med tre cookie-scanninger om måneden. Intet kort, intet salgsopkald.