refactor(consent-sdk): split ConsentManager + framework adapters under 500 LOC

Phase 4: extract config defaults, Google Consent Mode helper, and framework
adapter internals into sibling files so every source file is under the
hard cap. Public API surface preserved; all 135 tests green, tsup build +
tsc typecheck clean.

- core/ConsentManager 525 -> 467 LOC (extract config + google helpers)
- react/index 511 LOC -> 199 LOC barrel + components/hooks/context
- vue/index 511 LOC -> 32 LOC barrel + components/composables/context/plugin
- angular/index 509 LOC -> 45 LOC barrel + interface/service/module/templates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-11 22:25:44 +02:00
parent ef8284dff5
commit 4ed39d2616
17 changed files with 1341 additions and 1375 deletions

View File

@@ -1,6 +1,9 @@
/**
* Angular Integration fuer @breakpilot/consent-sdk
*
* Phase 4 refactor: thin barrel. Interface, service, module definition, and
* template snippets live in sibling files.
*
* @example
* ```typescript
* // app.module.ts
@@ -16,494 +19,27 @@
* })
* export class AppModule {}
* ```
*
* @remarks
* Angular hat ein komplexeres Build-System (ngc, ng-packagr). Diese Dateien
* definieren die Schnittstelle — fuer Production muss ein separates Angular
* Library Package erstellt werden (`ng generate library @breakpilot/consent-sdk-angular`).
*/
// =============================================================================
// NOTE: Angular SDK Structure
// =============================================================================
//
// Angular hat ein komplexeres Build-System (ngc, ng-packagr).
// Diese Datei definiert die Schnittstelle - fuer Production muss ein
// separates Angular Library Package erstellt werden:
//
// ng generate library @breakpilot/consent-sdk-angular
//
// Die folgende Implementation ist fuer direkten Import vorgesehen.
// =============================================================================
export type { IConsentService } from './interface';
export { ConsentServiceBase } from './service';
export {
CONSENT_CONFIG,
CONSENT_SERVICE,
ConsentModuleDefinition,
consentServiceFactory,
type ConsentModuleConfig,
} from './module';
export { CONSENT_BANNER_TEMPLATE, CONSENT_GATE_USAGE } from './templates';
import { ConsentManager } from '../core/ConsentManager';
import type {
export type {
ConsentCategories,
ConsentCategory,
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
} from '../types';
// =============================================================================
// Angular Service Interface
// =============================================================================
/**
* ConsentService Interface fuer Angular DI
*
* @example
* ```typescript
* @Component({...})
* export class MyComponent {
* constructor(private consent: ConsentService) {
* if (this.consent.hasConsent('analytics')) {
* // Analytics laden
* }
* }
* }
* ```
*/
export interface IConsentService {
/** Initialisiert? */
readonly isInitialized: boolean;
/** Laedt noch? */
readonly isLoading: boolean;
/** Banner sichtbar? */
readonly isBannerVisible: boolean;
/** Aktueller Consent-Zustand */
readonly consent: ConsentState | null;
/** Muss Consent eingeholt werden? */
readonly needsConsent: boolean;
/** Prueft Consent fuer Kategorie */
hasConsent(category: ConsentCategory): boolean;
/** Alle akzeptieren */
acceptAll(): Promise<void>;
/** Alle ablehnen */
rejectAll(): Promise<void>;
/** Auswahl speichern */
saveSelection(categories: Partial<ConsentCategories>): Promise<void>;
/** Banner anzeigen */
showBanner(): void;
/** Banner ausblenden */
hideBanner(): void;
/** Einstellungen oeffnen */
showSettings(): void;
}
// =============================================================================
// ConsentService Implementation
// =============================================================================
/**
* ConsentService - Angular Service Wrapper
*
* Diese Klasse kann als Angular Service registriert werden:
*
* @example
* ```typescript
* // consent.service.ts
* import { Injectable } from '@angular/core';
* import { ConsentServiceBase } from '@breakpilot/consent-sdk/angular';
*
* @Injectable({ providedIn: 'root' })
* export class ConsentService extends ConsentServiceBase {
* constructor() {
* super({
* apiEndpoint: environment.consentApiEndpoint,
* siteId: environment.siteId,
* });
* }
* }
* ```
*/
export class ConsentServiceBase implements IConsentService {
private manager: ConsentManager;
private _consent: ConsentState | null = null;
private _isInitialized = false;
private _isLoading = true;
private _isBannerVisible = false;
// Callbacks fuer Angular Change Detection
private changeCallbacks: Array<(consent: ConsentState) => void> = [];
private bannerShowCallbacks: Array<() => void> = [];
private bannerHideCallbacks: Array<() => void> = [];
constructor(config: ConsentConfig) {
this.manager = new ConsentManager(config);
this.setupEventListeners();
this.initialize();
}
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
get isInitialized(): boolean {
return this._isInitialized;
}
get isLoading(): boolean {
return this._isLoading;
}
get isBannerVisible(): boolean {
return this._isBannerVisible;
}
get consent(): ConsentState | null {
return this._consent;
}
get needsConsent(): boolean {
return this.manager.needsConsent();
}
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
hasConsent(category: ConsentCategory): boolean {
return this.manager.hasConsent(category);
}
async acceptAll(): Promise<void> {
await this.manager.acceptAll();
}
async rejectAll(): Promise<void> {
await this.manager.rejectAll();
}
async saveSelection(categories: Partial<ConsentCategories>): Promise<void> {
await this.manager.setConsent(categories);
this.manager.hideBanner();
}
showBanner(): void {
this.manager.showBanner();
}
hideBanner(): void {
this.manager.hideBanner();
}
showSettings(): void {
this.manager.showSettings();
}
// ---------------------------------------------------------------------------
// Change Detection Support
// ---------------------------------------------------------------------------
/**
* Registriert Callback fuer Consent-Aenderungen
* (fuer Angular Change Detection)
*/
onConsentChange(callback: (consent: ConsentState) => void): () => void {
this.changeCallbacks.push(callback);
return () => {
const index = this.changeCallbacks.indexOf(callback);
if (index > -1) {
this.changeCallbacks.splice(index, 1);
}
};
}
/**
* Registriert Callback wenn Banner angezeigt wird
*/
onBannerShow(callback: () => void): () => void {
this.bannerShowCallbacks.push(callback);
return () => {
const index = this.bannerShowCallbacks.indexOf(callback);
if (index > -1) {
this.bannerShowCallbacks.splice(index, 1);
}
};
}
/**
* Registriert Callback wenn Banner ausgeblendet wird
*/
onBannerHide(callback: () => void): () => void {
this.bannerHideCallbacks.push(callback);
return () => {
const index = this.bannerHideCallbacks.indexOf(callback);
if (index > -1) {
this.bannerHideCallbacks.splice(index, 1);
}
};
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private setupEventListeners(): void {
this.manager.on('change', (consent) => {
this._consent = consent;
this.changeCallbacks.forEach((cb) => cb(consent));
});
this.manager.on('banner_show', () => {
this._isBannerVisible = true;
this.bannerShowCallbacks.forEach((cb) => cb());
});
this.manager.on('banner_hide', () => {
this._isBannerVisible = false;
this.bannerHideCallbacks.forEach((cb) => cb());
});
}
private async initialize(): Promise<void> {
try {
await this.manager.init();
this._consent = this.manager.getConsent();
this._isInitialized = true;
this._isBannerVisible = this.manager.isBannerVisible();
} catch (error) {
console.error('Failed to initialize ConsentManager:', error);
} finally {
this._isLoading = false;
}
}
}
// =============================================================================
// Angular Module Configuration
// =============================================================================
/**
* Konfiguration fuer ConsentModule.forRoot()
*/
export interface ConsentModuleConfig extends ConsentConfig {}
/**
* Token fuer Dependency Injection
* Verwendung mit Angular @Inject():
*
* @example
* ```typescript
* constructor(@Inject(CONSENT_CONFIG) private config: ConsentConfig) {}
* ```
*/
export const CONSENT_CONFIG = 'CONSENT_CONFIG';
export const CONSENT_SERVICE = 'CONSENT_SERVICE';
// =============================================================================
// Factory Functions fuer Angular DI
// =============================================================================
/**
* Factory fuer ConsentService
*
* @example
* ```typescript
* // app.module.ts
* providers: [
* { provide: CONSENT_CONFIG, useValue: { apiEndpoint: '...', siteId: '...' } },
* { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] },
* ]
* ```
*/
export function consentServiceFactory(config: ConsentConfig): ConsentServiceBase {
return new ConsentServiceBase(config);
}
// =============================================================================
// Angular Module Definition (Template)
// =============================================================================
/**
* ConsentModule - Angular Module
*
* Dies ist eine Template-Definition. Fuer echte Angular-Nutzung
* muss ein separates Angular Library Package erstellt werden.
*
* @example
* ```typescript
* // In einem Angular Library Package:
* @NgModule({
* declarations: [ConsentBannerComponent, ConsentGateDirective],
* exports: [ConsentBannerComponent, ConsentGateDirective],
* })
* export class ConsentModule {
* static forRoot(config: ConsentModuleConfig): ModuleWithProviders<ConsentModule> {
* return {
* ngModule: ConsentModule,
* providers: [
* { provide: CONSENT_CONFIG, useValue: config },
* { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] },
* ],
* };
* }
* }
* ```
*/
export const ConsentModuleDefinition = {
/**
* Providers fuer Root-Module
*/
forRoot: (config: ConsentModuleConfig) => ({
provide: CONSENT_CONFIG,
useValue: config,
}),
};
// =============================================================================
// Component Templates (fuer Angular Library)
// =============================================================================
/**
* ConsentBannerComponent Template
*
* Fuer Angular Library Implementation:
*
* @example
* ```typescript
* @Component({
* selector: 'bp-consent-banner',
* template: CONSENT_BANNER_TEMPLATE,
* styles: [CONSENT_BANNER_STYLES],
* })
* export class ConsentBannerComponent {
* constructor(public consent: ConsentService) {}
* }
* ```
*/
export const CONSENT_BANNER_TEMPLATE = `
<div
*ngIf="consent.isBannerVisible"
class="bp-consent-banner"
role="dialog"
aria-modal="true"
aria-label="Cookie-Einstellungen"
>
<div class="bp-consent-banner-content">
<h2>Datenschutzeinstellungen</h2>
<p>
Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales
Nutzererlebnis zu bieten.
</p>
<div class="bp-consent-banner-actions">
<button
type="button"
class="bp-consent-btn bp-consent-btn-reject"
(click)="consent.rejectAll()"
>
Alle ablehnen
</button>
<button
type="button"
class="bp-consent-btn bp-consent-btn-settings"
(click)="consent.showSettings()"
>
Einstellungen
</button>
<button
type="button"
class="bp-consent-btn bp-consent-btn-accept"
(click)="consent.acceptAll()"
>
Alle akzeptieren
</button>
</div>
</div>
</div>
`;
/**
* ConsentGateDirective Template
*
* @example
* ```typescript
* @Directive({
* selector: '[bpConsentGate]',
* })
* export class ConsentGateDirective implements OnInit, OnDestroy {
* @Input('bpConsentGate') category!: ConsentCategory;
*
* private unsubscribe?: () => void;
*
* constructor(
* private templateRef: TemplateRef<any>,
* private viewContainer: ViewContainerRef,
* private consent: ConsentService
* ) {}
*
* ngOnInit() {
* this.updateView();
* this.unsubscribe = this.consent.onConsentChange(() => this.updateView());
* }
*
* ngOnDestroy() {
* this.unsubscribe?.();
* }
*
* private updateView() {
* if (this.consent.hasConsent(this.category)) {
* this.viewContainer.createEmbeddedView(this.templateRef);
* } else {
* this.viewContainer.clear();
* }
* }
* }
* ```
*/
export const CONSENT_GATE_USAGE = `
<!-- Verwendung in Templates -->
<div *bpConsentGate="'analytics'">
<analytics-component></analytics-component>
</div>
<!-- Mit else Template -->
<ng-container *bpConsentGate="'marketing'; else placeholder">
<marketing-component></marketing-component>
</ng-container>
<ng-template #placeholder>
<p>Bitte akzeptieren Sie Marketing-Cookies.</p>
</ng-template>
`;
// =============================================================================
// RxJS Observable Wrapper (Optional)
// =============================================================================
/**
* RxJS Observable Wrapper fuer ConsentService
*
* Fuer Projekte die RxJS bevorzugen:
*
* @example
* ```typescript
* import { BehaviorSubject, Observable } from 'rxjs';
*
* export class ConsentServiceRx extends ConsentServiceBase {
* private consentSubject = new BehaviorSubject<ConsentState | null>(null);
* private bannerVisibleSubject = new BehaviorSubject<boolean>(false);
*
* consent$ = this.consentSubject.asObservable();
* isBannerVisible$ = this.bannerVisibleSubject.asObservable();
*
* constructor(config: ConsentConfig) {
* super(config);
* this.onConsentChange((c) => this.consentSubject.next(c));
* this.onBannerShow(() => this.bannerVisibleSubject.next(true));
* this.onBannerHide(() => this.bannerVisibleSubject.next(false));
* }
* }
* ```
*/
// =============================================================================
// Exports
// =============================================================================
export type { ConsentConfig, ConsentState, ConsentCategory, ConsentCategories };

View File

@@ -0,0 +1,64 @@
/**
* Angular IConsentService — interface for DI.
*
* Phase 4: extracted from angular/index.ts.
*/
import type {
ConsentCategories,
ConsentCategory,
ConsentState,
} from '../types';
/**
* ConsentService Interface fuer Angular DI
*
* @example
* ```typescript
* @Component({...})
* export class MyComponent {
* constructor(private consent: ConsentService) {
* if (this.consent.hasConsent('analytics')) {
* // Analytics laden
* }
* }
* }
* ```
*/
export interface IConsentService {
/** Initialisiert? */
readonly isInitialized: boolean;
/** Laedt noch? */
readonly isLoading: boolean;
/** Banner sichtbar? */
readonly isBannerVisible: boolean;
/** Aktueller Consent-Zustand */
readonly consent: ConsentState | null;
/** Muss Consent eingeholt werden? */
readonly needsConsent: boolean;
/** Prueft Consent fuer Kategorie */
hasConsent(category: ConsentCategory): boolean;
/** Alle akzeptieren */
acceptAll(): Promise<void>;
/** Alle ablehnen */
rejectAll(): Promise<void>;
/** Auswahl speichern */
saveSelection(categories: Partial<ConsentCategories>): Promise<void>;
/** Banner anzeigen */
showBanner(): void;
/** Banner ausblenden */
hideBanner(): void;
/** Einstellungen oeffnen */
showSettings(): void;
}

View File

@@ -0,0 +1,79 @@
/**
* Angular Module configuration — DI tokens, factory, module definition.
*
* Phase 4: extracted from angular/index.ts.
*/
import type { ConsentConfig } from '../types';
import { ConsentServiceBase } from './service';
/**
* Konfiguration fuer ConsentModule.forRoot()
*/
export interface ConsentModuleConfig extends ConsentConfig {}
/**
* Token fuer Dependency Injection
* Verwendung mit Angular @Inject():
*
* @example
* ```typescript
* constructor(@Inject(CONSENT_CONFIG) private config: ConsentConfig) {}
* ```
*/
export const CONSENT_CONFIG = 'CONSENT_CONFIG';
export const CONSENT_SERVICE = 'CONSENT_SERVICE';
/**
* Factory fuer ConsentService
*
* @example
* ```typescript
* // app.module.ts
* providers: [
* { provide: CONSENT_CONFIG, useValue: { apiEndpoint: '...', siteId: '...' } },
* { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] },
* ]
* ```
*/
export function consentServiceFactory(
config: ConsentConfig
): ConsentServiceBase {
return new ConsentServiceBase(config);
}
/**
* ConsentModule - Angular Module
*
* Dies ist eine Template-Definition. Fuer echte Angular-Nutzung
* muss ein separates Angular Library Package erstellt werden.
*
* @example
* ```typescript
* // In einem Angular Library Package:
* @NgModule({
* declarations: [ConsentBannerComponent, ConsentGateDirective],
* exports: [ConsentBannerComponent, ConsentGateDirective],
* })
* export class ConsentModule {
* static forRoot(config: ConsentModuleConfig): ModuleWithProviders<ConsentModule> {
* return {
* ngModule: ConsentModule,
* providers: [
* { provide: CONSENT_CONFIG, useValue: config },
* { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] },
* ],
* };
* }
* }
* ```
*/
export const ConsentModuleDefinition = {
/**
* Providers fuer Root-Module
*/
forRoot: (config: ConsentModuleConfig) => ({
provide: CONSENT_CONFIG,
useValue: config,
}),
};

View File

@@ -0,0 +1,190 @@
/**
* ConsentServiceBase — Angular Service Wrapper.
*
* Phase 4: extracted from angular/index.ts.
*/
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentCategories,
ConsentCategory,
ConsentConfig,
ConsentState,
} from '../types';
import type { IConsentService } from './interface';
/**
* ConsentService - Angular Service Wrapper
*
* Diese Klasse kann als Angular Service registriert werden:
*
* @example
* ```typescript
* // consent.service.ts
* import { Injectable } from '@angular/core';
* import { ConsentServiceBase } from '@breakpilot/consent-sdk/angular';
*
* @Injectable({ providedIn: 'root' })
* export class ConsentService extends ConsentServiceBase {
* constructor() {
* super({
* apiEndpoint: environment.consentApiEndpoint,
* siteId: environment.siteId,
* });
* }
* }
* ```
*/
export class ConsentServiceBase implements IConsentService {
private manager: ConsentManager;
private _consent: ConsentState | null = null;
private _isInitialized = false;
private _isLoading = true;
private _isBannerVisible = false;
// Callbacks fuer Angular Change Detection
private changeCallbacks: Array<(consent: ConsentState) => void> = [];
private bannerShowCallbacks: Array<() => void> = [];
private bannerHideCallbacks: Array<() => void> = [];
constructor(config: ConsentConfig) {
this.manager = new ConsentManager(config);
this.setupEventListeners();
this.initialize();
}
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
get isInitialized(): boolean {
return this._isInitialized;
}
get isLoading(): boolean {
return this._isLoading;
}
get isBannerVisible(): boolean {
return this._isBannerVisible;
}
get consent(): ConsentState | null {
return this._consent;
}
get needsConsent(): boolean {
return this.manager.needsConsent();
}
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
hasConsent(category: ConsentCategory): boolean {
return this.manager.hasConsent(category);
}
async acceptAll(): Promise<void> {
await this.manager.acceptAll();
}
async rejectAll(): Promise<void> {
await this.manager.rejectAll();
}
async saveSelection(categories: Partial<ConsentCategories>): Promise<void> {
await this.manager.setConsent(categories);
this.manager.hideBanner();
}
showBanner(): void {
this.manager.showBanner();
}
hideBanner(): void {
this.manager.hideBanner();
}
showSettings(): void {
this.manager.showSettings();
}
// ---------------------------------------------------------------------------
// Change Detection Support
// ---------------------------------------------------------------------------
/**
* Registriert Callback fuer Consent-Aenderungen
* (fuer Angular Change Detection)
*/
onConsentChange(callback: (consent: ConsentState) => void): () => void {
this.changeCallbacks.push(callback);
return () => {
const index = this.changeCallbacks.indexOf(callback);
if (index > -1) {
this.changeCallbacks.splice(index, 1);
}
};
}
/**
* Registriert Callback wenn Banner angezeigt wird
*/
onBannerShow(callback: () => void): () => void {
this.bannerShowCallbacks.push(callback);
return () => {
const index = this.bannerShowCallbacks.indexOf(callback);
if (index > -1) {
this.bannerShowCallbacks.splice(index, 1);
}
};
}
/**
* Registriert Callback wenn Banner ausgeblendet wird
*/
onBannerHide(callback: () => void): () => void {
this.bannerHideCallbacks.push(callback);
return () => {
const index = this.bannerHideCallbacks.indexOf(callback);
if (index > -1) {
this.bannerHideCallbacks.splice(index, 1);
}
};
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private setupEventListeners(): void {
this.manager.on('change', (consent) => {
this._consent = consent;
this.changeCallbacks.forEach((cb) => cb(consent));
});
this.manager.on('banner_show', () => {
this._isBannerVisible = true;
this.bannerShowCallbacks.forEach((cb) => cb());
});
this.manager.on('banner_hide', () => {
this._isBannerVisible = false;
this.bannerHideCallbacks.forEach((cb) => cb());
});
}
private async initialize(): Promise<void> {
try {
await this.manager.init();
this._consent = this.manager.getConsent();
this._isInitialized = true;
this._isBannerVisible = this.manager.isBannerVisible();
} catch (error) {
console.error('Failed to initialize ConsentManager:', error);
} finally {
this._isLoading = false;
}
}
}

View File

@@ -0,0 +1,142 @@
/**
* Angular component templates — Banner + Gate directive reference snippets.
*
* Phase 4: extracted from angular/index.ts.
*/
/**
* ConsentBannerComponent Template
*
* Fuer Angular Library Implementation:
*
* @example
* ```typescript
* @Component({
* selector: 'bp-consent-banner',
* template: CONSENT_BANNER_TEMPLATE,
* styles: [CONSENT_BANNER_STYLES],
* })
* export class ConsentBannerComponent {
* constructor(public consent: ConsentService) {}
* }
* ```
*/
export const CONSENT_BANNER_TEMPLATE = `
<div
*ngIf="consent.isBannerVisible"
class="bp-consent-banner"
role="dialog"
aria-modal="true"
aria-label="Cookie-Einstellungen"
>
<div class="bp-consent-banner-content">
<h2>Datenschutzeinstellungen</h2>
<p>
Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales
Nutzererlebnis zu bieten.
</p>
<div class="bp-consent-banner-actions">
<button
type="button"
class="bp-consent-btn bp-consent-btn-reject"
(click)="consent.rejectAll()"
>
Alle ablehnen
</button>
<button
type="button"
class="bp-consent-btn bp-consent-btn-settings"
(click)="consent.showSettings()"
>
Einstellungen
</button>
<button
type="button"
class="bp-consent-btn bp-consent-btn-accept"
(click)="consent.acceptAll()"
>
Alle akzeptieren
</button>
</div>
</div>
</div>
`;
/**
* ConsentGateDirective Template
*
* @example
* ```typescript
* @Directive({
* selector: '[bpConsentGate]',
* })
* export class ConsentGateDirective implements OnInit, OnDestroy {
* @Input('bpConsentGate') category!: ConsentCategory;
*
* private unsubscribe?: () => void;
*
* constructor(
* private templateRef: TemplateRef<any>,
* private viewContainer: ViewContainerRef,
* private consent: ConsentService
* ) {}
*
* ngOnInit() {
* this.updateView();
* this.unsubscribe = this.consent.onConsentChange(() => this.updateView());
* }
*
* ngOnDestroy() {
* this.unsubscribe?.();
* }
*
* private updateView() {
* if (this.consent.hasConsent(this.category)) {
* this.viewContainer.createEmbeddedView(this.templateRef);
* } else {
* this.viewContainer.clear();
* }
* }
* }
* ```
*/
export const CONSENT_GATE_USAGE = `
<!-- Verwendung in Templates -->
<div *bpConsentGate="'analytics'">
<analytics-component></analytics-component>
</div>
<!-- Mit else Template -->
<ng-container *bpConsentGate="'marketing'; else placeholder">
<marketing-component></marketing-component>
</ng-container>
<ng-template #placeholder>
<p>Bitte akzeptieren Sie Marketing-Cookies.</p>
</ng-template>
`;
/**
* RxJS Observable Wrapper fuer ConsentService
*
* Fuer Projekte die RxJS bevorzugen:
*
* @example
* ```typescript
* import { BehaviorSubject, Observable } from 'rxjs';
*
* export class ConsentServiceRx extends ConsentServiceBase {
* private consentSubject = new BehaviorSubject<ConsentState | null>(null);
* private bannerVisibleSubject = new BehaviorSubject<boolean>(false);
*
* consent$ = this.consentSubject.asObservable();
* isBannerVisible$ = this.bannerVisibleSubject.asObservable();
*
* constructor(config: ConsentConfig) {
* super(config);
* this.onConsentChange((c) => this.consentSubject.next(c));
* this.onBannerShow(() => this.bannerVisibleSubject.next(true));
* this.onBannerHide(() => this.bannerVisibleSubject.next(false));
* }
* }
* ```
*/

View File

@@ -20,45 +20,11 @@ import { ConsentAPI } from './ConsentAPI';
import { EventEmitter } from '../utils/EventEmitter';
import { generateFingerprint } from '../utils/fingerprint';
import { SDK_VERSION } from '../version';
/**
* Default-Konfiguration
*/
const DEFAULT_CONFIG: Partial<ConsentConfig> = {
language: 'de',
fallbackLanguage: 'en',
ui: {
position: 'bottom',
layout: 'modal',
theme: 'auto',
zIndex: 999999,
blockScrollOnModal: true,
},
consent: {
required: true,
rejectAllVisible: true,
acceptAllVisible: true,
granularControl: true,
vendorControl: false,
rememberChoice: true,
rememberDays: 365,
geoTargeting: false,
recheckAfterDays: 180,
},
categories: ['essential', 'functional', 'analytics', 'marketing', 'social'],
debug: false,
};
/**
* Default Consent-State (nur Essential aktiv)
*/
const DEFAULT_CONSENT: ConsentCategories = {
essential: true,
functional: false,
analytics: false,
marketing: false,
social: false,
};
import {
DEFAULT_CONSENT,
mergeConsentConfig,
} from './consent-manager-config';
import { updateGoogleConsentMode as applyGoogleConsent } from './consent-manager-google';
/**
* ConsentManager - Zentrale Klasse fuer Consent-Verwaltung
@@ -389,15 +355,10 @@ export class ConsentManager {
// ===========================================================================
/**
* Konfiguration zusammenfuehren
* Konfiguration zusammenfuehren — delegates to the extracted helper.
*/
private mergeConfig(config: ConsentConfig): ConsentConfig {
return {
...DEFAULT_CONFIG,
...config,
ui: { ...DEFAULT_CONFIG.ui, ...config.ui },
consent: { ...DEFAULT_CONFIG.consent, ...config.consent },
} as ConsentConfig;
return mergeConsentConfig(config);
}
/**
@@ -434,31 +395,12 @@ export class ConsentManager {
}
/**
* Google Consent Mode v2 aktualisieren
* Google Consent Mode v2 aktualisieren — delegates to the extracted helper.
*/
private updateGoogleConsentMode(): void {
if (typeof window === 'undefined' || !this.currentConsent) {
return;
if (applyGoogleConsent(this.currentConsent)) {
this.log('Google Consent Mode updated');
}
const gtag = (window as unknown as { gtag?: (...args: unknown[]) => void }).gtag;
if (typeof gtag !== 'function') {
return;
}
const { categories } = this.currentConsent;
gtag('consent', 'update', {
ad_storage: categories.marketing ? 'granted' : 'denied',
ad_user_data: categories.marketing ? 'granted' : 'denied',
ad_personalization: categories.marketing ? 'granted' : 'denied',
analytics_storage: categories.analytics ? 'granted' : 'denied',
functionality_storage: categories.functional ? 'granted' : 'denied',
personalization_storage: categories.functional ? 'granted' : 'denied',
security_storage: 'granted',
});
this.log('Google Consent Mode updated');
}
/**

View File

@@ -0,0 +1,58 @@
/**
* ConsentManager default configuration + merge helpers.
*
* Phase 4: extracted from ConsentManager.ts to keep the main class under 500 LOC.
*/
import type { ConsentCategories, ConsentConfig } from '../types';
/**
* Default configuration applied when a consumer omits optional fields.
*/
export const DEFAULT_CONFIG: Partial<ConsentConfig> = {
language: 'de',
fallbackLanguage: 'en',
ui: {
position: 'bottom',
layout: 'modal',
theme: 'auto',
zIndex: 999999,
blockScrollOnModal: true,
},
consent: {
required: true,
rejectAllVisible: true,
acceptAllVisible: true,
granularControl: true,
vendorControl: false,
rememberChoice: true,
rememberDays: 365,
geoTargeting: false,
recheckAfterDays: 180,
},
categories: ['essential', 'functional', 'analytics', 'marketing', 'social'],
debug: false,
};
/**
* Default consent state — only essential category is active.
*/
export const DEFAULT_CONSENT: ConsentCategories = {
essential: true,
functional: false,
analytics: false,
marketing: false,
social: false,
};
/**
* Merge a user-supplied config onto DEFAULT_CONFIG, preserving nested objects.
*/
export function mergeConsentConfig(config: ConsentConfig): ConsentConfig {
return {
...DEFAULT_CONFIG,
...config,
ui: { ...DEFAULT_CONFIG.ui, ...config.ui },
consent: { ...DEFAULT_CONFIG.consent, ...config.consent },
} as ConsentConfig;
}

View File

@@ -0,0 +1,38 @@
/**
* Google Consent Mode v2 integration helper.
*
* Phase 4: extracted from ConsentManager.ts. Updates gtag() with the
* current consent category state whenever consent changes.
*/
import type { ConsentState } from '../types';
/**
* Update Google Consent Mode v2 based on the current consent categories.
* No-op when running outside the browser or when gtag is not loaded.
* Returns true if the gtag update was actually applied.
*/
export function updateGoogleConsentMode(consent: ConsentState | null): boolean {
if (typeof window === 'undefined' || !consent) {
return false;
}
const gtag = (window as unknown as { gtag?: (...args: unknown[]) => void }).gtag;
if (typeof gtag !== 'function') {
return false;
}
const { categories } = consent;
gtag('consent', 'update', {
ad_storage: categories.marketing ? 'granted' : 'denied',
ad_user_data: categories.marketing ? 'granted' : 'denied',
ad_personalization: categories.marketing ? 'granted' : 'denied',
analytics_storage: categories.analytics ? 'granted' : 'denied',
functionality_storage: categories.functional ? 'granted' : 'denied',
personalization_storage: categories.functional ? 'granted' : 'denied',
security_storage: 'granted',
});
return true;
}

View File

@@ -0,0 +1,190 @@
/**
* React UI components for the consent SDK.
*
* Phase 4: extracted from index.tsx to keep the main file under 500 LOC.
* Exports ConsentGate, ConsentPlaceholder, and ConsentBanner (all headless).
*/
import type { FC, ReactNode } from 'react';
import type {
ConsentCategories,
ConsentCategory,
ConsentState,
} from '../types';
import { useConsent } from './hooks';
// =============================================================================
// ConsentGate
// =============================================================================
interface ConsentGateProps {
/** Erforderliche Kategorie */
category: ConsentCategory;
/** Inhalt bei Consent */
children: ReactNode;
/** Inhalt ohne Consent */
placeholder?: ReactNode;
/** Fallback waehrend Laden */
fallback?: ReactNode;
}
/**
* ConsentGate - zeigt Inhalt nur bei Consent.
*/
export const ConsentGate: FC<ConsentGateProps> = ({
category,
children,
placeholder = null,
fallback = null,
}) => {
const { hasConsent, isLoading } = useConsent();
if (isLoading) {
return <>{fallback}</>;
}
if (!hasConsent(category)) {
return <>{placeholder}</>;
}
return <>{children}</>;
};
// =============================================================================
// ConsentPlaceholder
// =============================================================================
interface ConsentPlaceholderProps {
category: ConsentCategory;
message?: string;
buttonText?: string;
className?: string;
}
/**
* ConsentPlaceholder - Placeholder fuer blockierten Inhalt.
*/
export const ConsentPlaceholder: FC<ConsentPlaceholderProps> = ({
category,
message,
buttonText,
className = '',
}) => {
const { showSettings } = useConsent();
const categoryNames: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
const defaultMessage = `Dieser Inhalt erfordert ${categoryNames[category]}.`;
return (
<div className={`bp-consent-placeholder ${className}`}>
<p>{message || defaultMessage}</p>
<button type="button" onClick={showSettings}>
{buttonText || 'Cookie-Einstellungen oeffnen'}
</button>
</div>
);
};
// =============================================================================
// ConsentBanner (headless)
// =============================================================================
export interface ConsentBannerRenderProps {
isVisible: boolean;
consent: ConsentState | null;
needsConsent: boolean;
onAcceptAll: () => void;
onRejectAll: () => void;
onSaveSelection: (categories: Partial<ConsentCategories>) => void;
onShowSettings: () => void;
onClose: () => void;
}
interface ConsentBannerProps {
render?: (props: ConsentBannerRenderProps) => ReactNode;
className?: string;
}
/**
* ConsentBanner - Headless Banner-Komponente.
* Kann mit eigener UI gerendert werden oder nutzt Default-UI.
*/
export const ConsentBanner: FC<ConsentBannerProps> = ({ render, className }) => {
const {
consent,
isBannerVisible,
needsConsent,
acceptAll,
rejectAll,
saveSelection,
showSettings,
hideBanner,
} = useConsent();
const renderProps: ConsentBannerRenderProps = {
isVisible: isBannerVisible,
consent,
needsConsent,
onAcceptAll: acceptAll,
onRejectAll: rejectAll,
onSaveSelection: saveSelection,
onShowSettings: showSettings,
onClose: hideBanner,
};
if (render) {
return <>{render(renderProps)}</>;
}
if (!isBannerVisible) {
return null;
}
return (
<div
className={`bp-consent-banner ${className || ''}`}
role="dialog"
aria-modal="true"
aria-label="Cookie-Einstellungen"
>
<div className="bp-consent-banner-content">
<h2>Datenschutzeinstellungen</h2>
<p>
Wir nutzen Cookies und aehnliche Technologien, um Ihnen ein optimales
Nutzererlebnis zu bieten.
</p>
<div className="bp-consent-banner-actions">
<button
type="button"
className="bp-consent-btn bp-consent-btn-reject"
onClick={rejectAll}
>
Alle ablehnen
</button>
<button
type="button"
className="bp-consent-btn bp-consent-btn-settings"
onClick={showSettings}
>
Einstellungen
</button>
<button
type="button"
className="bp-consent-btn bp-consent-btn-accept"
onClick={acceptAll}
>
Alle akzeptieren
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
/**
* Consent context definition — shared by the provider and hooks.
*
* Phase 4: extracted from index.tsx.
*/
import { createContext } from 'react';
import type { ConsentManager } from '../core/ConsentManager';
import type {
ConsentCategories,
ConsentCategory,
ConsentState,
} from '../types';
export interface ConsentContextValue {
/** ConsentManager Instanz */
manager: ConsentManager | null;
/** Aktueller Consent-State */
consent: ConsentState | null;
/** Ist SDK initialisiert? */
isInitialized: boolean;
/** Wird geladen? */
isLoading: boolean;
/** Ist Banner sichtbar? */
isBannerVisible: boolean;
/** Wird Consent benoetigt? */
needsConsent: boolean;
/** Consent fuer Kategorie pruefen */
hasConsent: (category: ConsentCategory) => boolean;
/** Alle akzeptieren */
acceptAll: () => Promise<void>;
/** Alle ablehnen */
rejectAll: () => Promise<void>;
/** Auswahl speichern */
saveSelection: (categories: Partial<ConsentCategories>) => Promise<void>;
/** Banner anzeigen */
showBanner: () => void;
/** Banner verstecken */
hideBanner: () => void;
/** Einstellungen oeffnen */
showSettings: () => void;
}
export const ConsentContext = createContext<ConsentContextValue | null>(null);

View File

@@ -0,0 +1,43 @@
/**
* React hooks for the consent SDK.
*
* Phase 4: extracted from index.tsx to keep the main file under 500 LOC.
*/
import { useContext } from 'react';
import type { ConsentCategory } from '../types';
import type { ConsentManager } from '../core/ConsentManager';
import { ConsentContext, type ConsentContextValue } from './context';
/**
* useConsent - Consent-Hook.
* Overloads: call without args for the full context; pass a category to also get `allowed`.
*/
export function useConsent(): ConsentContextValue;
export function useConsent(
category: ConsentCategory
): ConsentContextValue & { allowed: boolean };
export function useConsent(category?: ConsentCategory) {
const context = useContext(ConsentContext);
if (!context) {
throw new Error('useConsent must be used within a ConsentProvider');
}
if (category) {
return {
...context,
allowed: context.hasConsent(category),
};
}
return context;
}
/**
* useConsentManager - Direkter Zugriff auf ConsentManager.
*/
export function useConsentManager(): ConsentManager | null {
const context = useContext(ConsentContext);
return context?.manager ?? null;
}

View File

@@ -14,72 +14,35 @@
* );
* }
* ```
*
* Phase 4 refactor: provider stays here; hooks + components live in sibling
* files. Context definition is in ./context so hooks and provider can share it
* without circular imports.
*/
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
useMemo,
type ReactNode,
type FC,
type ReactNode,
} from 'react';
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentCategories,
ConsentCategory,
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
} from '../types';
// =============================================================================
// Context
// =============================================================================
interface ConsentContextValue {
/** ConsentManager Instanz */
manager: ConsentManager | null;
/** Aktueller Consent-State */
consent: ConsentState | null;
/** Ist SDK initialisiert? */
isInitialized: boolean;
/** Wird geladen? */
isLoading: boolean;
/** Ist Banner sichtbar? */
isBannerVisible: boolean;
/** Wird Consent benoetigt? */
needsConsent: boolean;
/** Consent fuer Kategorie pruefen */
hasConsent: (category: ConsentCategory) => boolean;
/** Alle akzeptieren */
acceptAll: () => Promise<void>;
/** Alle ablehnen */
rejectAll: () => Promise<void>;
/** Auswahl speichern */
saveSelection: (categories: Partial<ConsentCategories>) => Promise<void>;
/** Banner anzeigen */
showBanner: () => void;
/** Banner verstecken */
hideBanner: () => void;
/** Einstellungen oeffnen */
showSettings: () => void;
}
const ConsentContext = createContext<ConsentContextValue | null>(null);
import { ConsentContext, type ConsentContextValue } from './context';
import { useConsent, useConsentManager } from './hooks';
import {
ConsentBanner,
ConsentGate,
ConsentPlaceholder,
type ConsentBannerRenderProps,
} from './components';
// =============================================================================
// Provider
@@ -88,13 +51,12 @@ const ConsentContext = createContext<ConsentContextValue | null>(null);
interface ConsentProviderProps {
/** SDK-Konfiguration */
config: ConsentConfig;
/** Kinder-Komponenten */
children: ReactNode;
}
/**
* ConsentProvider - Stellt Consent-Kontext bereit
* ConsentProvider - Stellt Consent-Kontext bereit.
*/
export const ConsentProvider: FC<ConsentProviderProps> = ({
config,
@@ -228,284 +190,10 @@ export const ConsentProvider: FC<ConsentProviderProps> = ({
};
// =============================================================================
// Hooks
// =============================================================================
/**
* useConsent - Hook fuer Consent-Zugriff
*
* @example
* ```tsx
* const { hasConsent, acceptAll, rejectAll } = useConsent();
*
* if (hasConsent('analytics')) {
* // Analytics laden
* }
* ```
*/
export function useConsent(): ConsentContextValue;
export function useConsent(
category: ConsentCategory
): ConsentContextValue & { allowed: boolean };
export function useConsent(category?: ConsentCategory) {
const context = useContext(ConsentContext);
if (!context) {
throw new Error('useConsent must be used within a ConsentProvider');
}
if (category) {
return {
...context,
allowed: context.hasConsent(category),
};
}
return context;
}
/**
* useConsentManager - Direkter Zugriff auf ConsentManager
*/
export function useConsentManager(): ConsentManager | null {
const context = useContext(ConsentContext);
return context?.manager ?? null;
}
// =============================================================================
// Components
// =============================================================================
interface ConsentGateProps {
/** Erforderliche Kategorie */
category: ConsentCategory;
/** Inhalt bei Consent */
children: ReactNode;
/** Inhalt ohne Consent */
placeholder?: ReactNode;
/** Fallback waehrend Laden */
fallback?: ReactNode;
}
/**
* ConsentGate - Zeigt Inhalt nur bei Consent
*
* @example
* ```tsx
* <ConsentGate
* category="analytics"
* placeholder={<ConsentPlaceholder category="analytics" />}
* >
* <GoogleAnalytics />
* </ConsentGate>
* ```
*/
export const ConsentGate: FC<ConsentGateProps> = ({
category,
children,
placeholder = null,
fallback = null,
}) => {
const { hasConsent, isLoading } = useConsent();
if (isLoading) {
return <>{fallback}</>;
}
if (!hasConsent(category)) {
return <>{placeholder}</>;
}
return <>{children}</>;
};
interface ConsentPlaceholderProps {
/** Kategorie */
category: ConsentCategory;
/** Custom Nachricht */
message?: string;
/** Custom Button-Text */
buttonText?: string;
/** Custom Styling */
className?: string;
}
/**
* ConsentPlaceholder - Placeholder fuer blockierten Inhalt
*/
export const ConsentPlaceholder: FC<ConsentPlaceholderProps> = ({
category,
message,
buttonText,
className = '',
}) => {
const { showSettings } = useConsent();
const categoryNames: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
const defaultMessage = `Dieser Inhalt erfordert ${categoryNames[category]}.`;
return (
<div className={`bp-consent-placeholder ${className}`}>
<p>{message || defaultMessage}</p>
<button type="button" onClick={showSettings}>
{buttonText || 'Cookie-Einstellungen oeffnen'}
</button>
</div>
);
};
// =============================================================================
// Banner Component (Headless)
// =============================================================================
interface ConsentBannerRenderProps {
/** Ist Banner sichtbar? */
isVisible: boolean;
/** Aktueller Consent */
consent: ConsentState | null;
/** Wird Consent benoetigt? */
needsConsent: boolean;
/** Alle akzeptieren */
onAcceptAll: () => void;
/** Alle ablehnen */
onRejectAll: () => void;
/** Auswahl speichern */
onSaveSelection: (categories: Partial<ConsentCategories>) => void;
/** Einstellungen oeffnen */
onShowSettings: () => void;
/** Banner schliessen */
onClose: () => void;
}
interface ConsentBannerProps {
/** Render-Funktion fuer Custom UI */
render?: (props: ConsentBannerRenderProps) => ReactNode;
/** Custom Styling */
className?: string;
}
/**
* ConsentBanner - Headless Banner-Komponente
*
* Kann mit eigener UI gerendert werden oder nutzt Default-UI.
*
* @example
* ```tsx
* // Mit eigener UI
* <ConsentBanner
* render={({ isVisible, onAcceptAll, onRejectAll }) => (
* isVisible && (
* <div className="my-banner">
* <button onClick={onAcceptAll}>Accept</button>
* <button onClick={onRejectAll}>Reject</button>
* </div>
* )
* )}
* />
*
* // Mit Default-UI
* <ConsentBanner />
* ```
*/
export const ConsentBanner: FC<ConsentBannerProps> = ({ render, className }) => {
const {
consent,
isBannerVisible,
needsConsent,
acceptAll,
rejectAll,
saveSelection,
showSettings,
hideBanner,
} = useConsent();
const renderProps: ConsentBannerRenderProps = {
isVisible: isBannerVisible,
consent,
needsConsent,
onAcceptAll: acceptAll,
onRejectAll: rejectAll,
onSaveSelection: saveSelection,
onShowSettings: showSettings,
onClose: hideBanner,
};
// Custom Render
if (render) {
return <>{render(renderProps)}</>;
}
// Default UI
if (!isBannerVisible) {
return null;
}
return (
<div
className={`bp-consent-banner ${className || ''}`}
role="dialog"
aria-modal="true"
aria-label="Cookie-Einstellungen"
>
<div className="bp-consent-banner-content">
<h2>Datenschutzeinstellungen</h2>
<p>
Wir nutzen Cookies und aehnliche Technologien, um Ihnen ein optimales
Nutzererlebnis zu bieten.
</p>
<div className="bp-consent-banner-actions">
<button
type="button"
className="bp-consent-btn bp-consent-btn-reject"
onClick={rejectAll}
>
Alle ablehnen
</button>
<button
type="button"
className="bp-consent-btn bp-consent-btn-settings"
onClick={showSettings}
>
Einstellungen
</button>
<button
type="button"
className="bp-consent-btn bp-consent-btn-accept"
onClick={acceptAll}
>
Alle akzeptieren
</button>
</div>
</div>
</div>
);
};
// =============================================================================
// Exports
// Re-exports for the public @breakpilot/consent-sdk/react entrypoint
// =============================================================================
export { useConsent, useConsentManager };
export { ConsentBanner, ConsentGate, ConsentPlaceholder };
export { ConsentContext };
export type { ConsentContextValue, ConsentBannerRenderProps };

View File

@@ -0,0 +1,191 @@
/**
* Vue consent components: ConsentProvider, ConsentGate, ConsentPlaceholder,
* ConsentBanner.
*
* Phase 4: extracted from vue/index.ts.
*/
import { computed, defineComponent, h, type PropType } from 'vue';
import type { ConsentCategory, ConsentConfig } from '../types';
import { provideConsent, useConsent } from './composables';
/**
* ConsentProvider - Wrapper-Komponente.
*/
export const ConsentProvider = defineComponent({
name: 'ConsentProvider',
props: {
config: {
type: Object as PropType<ConsentConfig>,
required: true,
},
},
setup(props, { slots }) {
provideConsent(props.config);
return () => slots.default?.();
},
});
/**
* ConsentGate - Zeigt Inhalt nur bei Consent.
*/
export const ConsentGate = defineComponent({
name: 'ConsentGate',
props: {
category: {
type: String as PropType<ConsentCategory>,
required: true,
},
},
setup(props, { slots }) {
const { hasConsent, isLoading } = useConsent();
return () => {
if (isLoading.value) {
return slots.fallback?.() ?? null;
}
if (!hasConsent(props.category)) {
return slots.placeholder?.() ?? null;
}
return slots.default?.();
};
},
});
/**
* ConsentPlaceholder - Placeholder fuer blockierten Inhalt.
*/
export const ConsentPlaceholder = defineComponent({
name: 'ConsentPlaceholder',
props: {
category: {
type: String as PropType<ConsentCategory>,
required: true,
},
message: {
type: String,
default: '',
},
buttonText: {
type: String,
default: 'Cookie-Einstellungen öffnen',
},
},
setup(props) {
const { showSettings } = useConsent();
const categoryNames: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
const displayMessage = computed(() => {
return (
props.message ||
`Dieser Inhalt erfordert ${categoryNames[props.category]}.`
);
});
return () =>
h('div', { class: 'bp-consent-placeholder' }, [
h('p', displayMessage.value),
h(
'button',
{
type: 'button',
onClick: showSettings,
},
props.buttonText
),
]);
},
});
/**
* ConsentBanner - Cookie-Banner Komponente (headless with default UI).
*/
export const ConsentBanner = defineComponent({
name: 'ConsentBanner',
setup(_, { slots }) {
const {
consent,
isBannerVisible,
needsConsent,
acceptAll,
rejectAll,
saveSelection,
showSettings,
hideBanner,
} = useConsent();
const slotProps = computed(() => ({
isVisible: isBannerVisible.value,
consent: consent.value,
needsConsent: needsConsent.value,
onAcceptAll: acceptAll,
onRejectAll: rejectAll,
onSaveSelection: saveSelection,
onShowSettings: showSettings,
onClose: hideBanner,
}));
return () => {
if (slots.default) {
return slots.default(slotProps.value);
}
if (!isBannerVisible.value) {
return null;
}
return h(
'div',
{
class: 'bp-consent-banner',
role: 'dialog',
'aria-modal': 'true',
'aria-label': 'Cookie-Einstellungen',
},
[
h('div', { class: 'bp-consent-banner-content' }, [
h('h2', 'Datenschutzeinstellungen'),
h(
'p',
'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.'
),
h('div', { class: 'bp-consent-banner-actions' }, [
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-reject',
onClick: rejectAll,
},
'Alle ablehnen'
),
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-settings',
onClick: showSettings,
},
'Einstellungen'
),
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-accept',
onClick: acceptAll,
},
'Alle akzeptieren'
),
]),
]),
]
);
};
},
});

View File

@@ -0,0 +1,135 @@
/**
* Vue composables: useConsent + provideConsent.
*
* Phase 4: extracted from vue/index.ts.
*/
import {
computed,
inject,
onMounted,
onUnmounted,
provide,
readonly,
ref,
type Ref,
} from 'vue';
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentCategories,
ConsentCategory,
ConsentConfig,
ConsentState,
} from '../types';
import { CONSENT_KEY, type ConsentContext } from './context';
/**
* Haupt-Composable fuer Consent-Zugriff.
*/
export function useConsent(): ConsentContext {
const context = inject(CONSENT_KEY);
if (!context) {
throw new Error(
'useConsent() must be used within a component that has called provideConsent() or is wrapped in ConsentProvider'
);
}
return context;
}
/**
* Consent-Provider einrichten (in App.vue aufrufen).
*/
export function provideConsent(config: ConsentConfig): ConsentContext {
const manager = ref<ConsentManager | null>(null);
const consent = ref<ConsentState | null>(null);
const isInitialized = ref(false);
const isLoading = ref(true);
const isBannerVisible = ref(false);
const needsConsent = computed(() => {
return manager.value?.needsConsent() ?? true;
});
onMounted(async () => {
const consentManager = new ConsentManager(config);
manager.value = consentManager;
const unsubChange = consentManager.on('change', (newConsent) => {
consent.value = newConsent;
});
const unsubBannerShow = consentManager.on('banner_show', () => {
isBannerVisible.value = true;
});
const unsubBannerHide = consentManager.on('banner_hide', () => {
isBannerVisible.value = false;
});
try {
await consentManager.init();
consent.value = consentManager.getConsent();
isInitialized.value = true;
isBannerVisible.value = consentManager.isBannerVisible();
} catch (error) {
console.error('Failed to initialize ConsentManager:', error);
} finally {
isLoading.value = false;
}
onUnmounted(() => {
unsubChange();
unsubBannerShow();
unsubBannerHide();
});
});
const hasConsent = (category: ConsentCategory): boolean => {
return manager.value?.hasConsent(category) ?? category === 'essential';
};
const acceptAll = async (): Promise<void> => {
await manager.value?.acceptAll();
};
const rejectAll = async (): Promise<void> => {
await manager.value?.rejectAll();
};
const saveSelection = async (
categories: Partial<ConsentCategories>
): Promise<void> => {
await manager.value?.setConsent(categories);
manager.value?.hideBanner();
};
const showBanner = (): void => {
manager.value?.showBanner();
};
const hideBanner = (): void => {
manager.value?.hideBanner();
};
const showSettings = (): void => {
manager.value?.showSettings();
};
const context: ConsentContext = {
manager: readonly(manager) as Ref<ConsentManager | null>,
consent: readonly(consent) as Ref<ConsentState | null>,
isInitialized: readonly(isInitialized),
isLoading: readonly(isLoading),
isBannerVisible: readonly(isBannerVisible),
needsConsent,
hasConsent,
acceptAll,
rejectAll,
saveSelection,
showBanner,
hideBanner,
showSettings,
};
provide(CONSENT_KEY, context);
return context;
}

View File

@@ -0,0 +1,31 @@
/**
* Vue consent context — injection key + shape.
*
* Phase 4: extracted from vue/index.ts.
*/
import type { InjectionKey, Ref } from 'vue';
import type { ConsentManager } from '../core/ConsentManager';
import type {
ConsentCategories,
ConsentCategory,
ConsentState,
} from '../types';
export interface ConsentContext {
manager: Ref<ConsentManager | null>;
consent: Ref<ConsentState | null>;
isInitialized: Ref<boolean>;
isLoading: Ref<boolean>;
isBannerVisible: Ref<boolean>;
needsConsent: Ref<boolean>;
hasConsent: (category: ConsentCategory) => boolean;
acceptAll: () => Promise<void>;
rejectAll: () => Promise<void>;
saveSelection: (categories: Partial<ConsentCategories>) => Promise<void>;
showBanner: () => void;
hideBanner: () => void;
showSettings: () => void;
}
export const CONSENT_KEY: InjectionKey<ConsentContext> = Symbol('consent');

View File

@@ -1,6 +1,9 @@
/**
* Vue 3 Integration fuer @breakpilot/consent-sdk
*
* Phase 4 refactor: thin barrel. Composables, components, plugin, and the
* injection key live in sibling files.
*
* @example
* ```vue
* <script setup>
@@ -18,494 +21,12 @@
* ```
*/
import {
ref,
computed,
readonly,
inject,
provide,
onMounted,
onUnmounted,
defineComponent,
h,
type Ref,
type InjectionKey,
type PropType,
} from 'vue';
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentConfig,
ConsentState,
ConsentCategory,
ConsentCategories,
} from '../types';
// =============================================================================
// Injection Key
// =============================================================================
const CONSENT_KEY: InjectionKey<ConsentContext> = Symbol('consent');
// =============================================================================
// Types
// =============================================================================
interface ConsentContext {
manager: Ref<ConsentManager | null>;
consent: Ref<ConsentState | null>;
isInitialized: Ref<boolean>;
isLoading: Ref<boolean>;
isBannerVisible: Ref<boolean>;
needsConsent: Ref<boolean>;
hasConsent: (category: ConsentCategory) => boolean;
acceptAll: () => Promise<void>;
rejectAll: () => Promise<void>;
saveSelection: (categories: Partial<ConsentCategories>) => Promise<void>;
showBanner: () => void;
hideBanner: () => void;
showSettings: () => void;
}
// =============================================================================
// Composable: useConsent
// =============================================================================
/**
* Haupt-Composable fuer Consent-Zugriff
*
* @example
* ```vue
* <script setup>
* const { hasConsent, acceptAll } = useConsent();
*
* if (hasConsent('analytics')) {
* // Analytics laden
* }
* </script>
* ```
*/
export function useConsent(): ConsentContext {
const context = inject(CONSENT_KEY);
if (!context) {
throw new Error(
'useConsent() must be used within a component that has called provideConsent() or is wrapped in ConsentProvider'
);
}
return context;
}
/**
* Consent-Provider einrichten (in App.vue aufrufen)
*
* @example
* ```vue
* <script setup>
* import { provideConsent } from '@breakpilot/consent-sdk/vue';
*
* provideConsent({
* apiEndpoint: 'https://consent.example.com/api/v1',
* siteId: 'site_abc123',
* });
* </script>
* ```
*/
export function provideConsent(config: ConsentConfig): ConsentContext {
const manager = ref<ConsentManager | null>(null);
const consent = ref<ConsentState | null>(null);
const isInitialized = ref(false);
const isLoading = ref(true);
const isBannerVisible = ref(false);
const needsConsent = computed(() => {
return manager.value?.needsConsent() ?? true;
});
// Initialisierung
onMounted(async () => {
const consentManager = new ConsentManager(config);
manager.value = consentManager;
// Events abonnieren
const unsubChange = consentManager.on('change', (newConsent) => {
consent.value = newConsent;
});
const unsubBannerShow = consentManager.on('banner_show', () => {
isBannerVisible.value = true;
});
const unsubBannerHide = consentManager.on('banner_hide', () => {
isBannerVisible.value = false;
});
try {
await consentManager.init();
consent.value = consentManager.getConsent();
isInitialized.value = true;
isBannerVisible.value = consentManager.isBannerVisible();
} catch (error) {
console.error('Failed to initialize ConsentManager:', error);
} finally {
isLoading.value = false;
}
// Cleanup bei Unmount
onUnmounted(() => {
unsubChange();
unsubBannerShow();
unsubBannerHide();
});
});
// Methoden
const hasConsent = (category: ConsentCategory): boolean => {
return manager.value?.hasConsent(category) ?? category === 'essential';
};
const acceptAll = async (): Promise<void> => {
await manager.value?.acceptAll();
};
const rejectAll = async (): Promise<void> => {
await manager.value?.rejectAll();
};
const saveSelection = async (categories: Partial<ConsentCategories>): Promise<void> => {
await manager.value?.setConsent(categories);
manager.value?.hideBanner();
};
const showBanner = (): void => {
manager.value?.showBanner();
};
const hideBanner = (): void => {
manager.value?.hideBanner();
};
const showSettings = (): void => {
manager.value?.showSettings();
};
const context: ConsentContext = {
manager: readonly(manager) as Ref<ConsentManager | null>,
consent: readonly(consent) as Ref<ConsentState | null>,
isInitialized: readonly(isInitialized),
isLoading: readonly(isLoading),
isBannerVisible: readonly(isBannerVisible),
needsConsent,
hasConsent,
acceptAll,
rejectAll,
saveSelection,
showBanner,
hideBanner,
showSettings,
};
provide(CONSENT_KEY, context);
return context;
}
// =============================================================================
// Components
// =============================================================================
/**
* ConsentProvider - Wrapper-Komponente
*
* @example
* ```vue
* <ConsentProvider :config="config">
* <App />
* </ConsentProvider>
* ```
*/
export const ConsentProvider = defineComponent({
name: 'ConsentProvider',
props: {
config: {
type: Object as PropType<ConsentConfig>,
required: true,
},
},
setup(props, { slots }) {
provideConsent(props.config);
return () => slots.default?.();
},
});
/**
* ConsentGate - Zeigt Inhalt nur bei Consent
*
* @example
* ```vue
* <ConsentGate category="analytics">
* <template #default>
* <AnalyticsComponent />
* </template>
* <template #placeholder>
* <p>Bitte akzeptieren Sie Statistik-Cookies.</p>
* </template>
* </ConsentGate>
* ```
*/
export const ConsentGate = defineComponent({
name: 'ConsentGate',
props: {
category: {
type: String as PropType<ConsentCategory>,
required: true,
},
},
setup(props, { slots }) {
const { hasConsent, isLoading } = useConsent();
return () => {
if (isLoading.value) {
return slots.fallback?.() ?? null;
}
if (!hasConsent(props.category)) {
return slots.placeholder?.() ?? null;
}
return slots.default?.();
};
},
});
/**
* ConsentPlaceholder - Placeholder fuer blockierten Inhalt
*
* @example
* ```vue
* <ConsentPlaceholder category="marketing" />
* ```
*/
export const ConsentPlaceholder = defineComponent({
name: 'ConsentPlaceholder',
props: {
category: {
type: String as PropType<ConsentCategory>,
required: true,
},
message: {
type: String,
default: '',
},
buttonText: {
type: String,
default: 'Cookie-Einstellungen öffnen',
},
},
setup(props) {
const { showSettings } = useConsent();
const categoryNames: Record<ConsentCategory, string> = {
essential: 'Essentielle Cookies',
functional: 'Funktionale Cookies',
analytics: 'Statistik-Cookies',
marketing: 'Marketing-Cookies',
social: 'Social Media-Cookies',
};
const displayMessage = computed(() => {
return props.message || `Dieser Inhalt erfordert ${categoryNames[props.category]}.`;
});
return () =>
h('div', { class: 'bp-consent-placeholder' }, [
h('p', displayMessage.value),
h(
'button',
{
type: 'button',
onClick: showSettings,
},
props.buttonText
),
]);
},
});
/**
* ConsentBanner - Cookie-Banner Komponente
*
* @example
* ```vue
* <ConsentBanner>
* <template #default="{ isVisible, onAcceptAll, onRejectAll, onShowSettings }">
* <div v-if="isVisible" class="my-banner">
* <button @click="onAcceptAll">Accept</button>
* <button @click="onRejectAll">Reject</button>
* </div>
* </template>
* </ConsentBanner>
* ```
*/
export const ConsentBanner = defineComponent({
name: 'ConsentBanner',
setup(_, { slots }) {
const {
consent,
isBannerVisible,
needsConsent,
acceptAll,
rejectAll,
saveSelection,
showSettings,
hideBanner,
} = useConsent();
const slotProps = computed(() => ({
isVisible: isBannerVisible.value,
consent: consent.value,
needsConsent: needsConsent.value,
onAcceptAll: acceptAll,
onRejectAll: rejectAll,
onSaveSelection: saveSelection,
onShowSettings: showSettings,
onClose: hideBanner,
}));
return () => {
// Custom Slot
if (slots.default) {
return slots.default(slotProps.value);
}
// Default UI
if (!isBannerVisible.value) {
return null;
}
return h(
'div',
{
class: 'bp-consent-banner',
role: 'dialog',
'aria-modal': 'true',
'aria-label': 'Cookie-Einstellungen',
},
[
h('div', { class: 'bp-consent-banner-content' }, [
h('h2', 'Datenschutzeinstellungen'),
h(
'p',
'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.'
),
h('div', { class: 'bp-consent-banner-actions' }, [
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-reject',
onClick: rejectAll,
},
'Alle ablehnen'
),
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-settings',
onClick: showSettings,
},
'Einstellungen'
),
h(
'button',
{
type: 'button',
class: 'bp-consent-btn bp-consent-btn-accept',
onClick: acceptAll,
},
'Alle akzeptieren'
),
]),
]),
]
);
};
},
});
// =============================================================================
// Plugin
// =============================================================================
/**
* Vue Plugin fuer globale Installation
*
* @example
* ```ts
* import { createApp } from 'vue';
* import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
*
* const app = createApp(App);
* app.use(ConsentPlugin, {
* apiEndpoint: 'https://consent.example.com/api/v1',
* siteId: 'site_abc123',
* });
* ```
*/
export const ConsentPlugin = {
install(app: { provide: (key: symbol | string, value: unknown) => void }, config: ConsentConfig) {
const manager = new ConsentManager(config);
const consent = ref<ConsentState | null>(null);
const isInitialized = ref(false);
const isLoading = ref(true);
const isBannerVisible = ref(false);
// Initialisieren
manager.init().then(() => {
consent.value = manager.getConsent();
isInitialized.value = true;
isLoading.value = false;
isBannerVisible.value = manager.isBannerVisible();
});
// Events
manager.on('change', (newConsent) => {
consent.value = newConsent;
});
manager.on('banner_show', () => {
isBannerVisible.value = true;
});
manager.on('banner_hide', () => {
isBannerVisible.value = false;
});
const context: ConsentContext = {
manager: ref(manager) as Ref<ConsentManager | null>,
consent: consent as Ref<ConsentState | null>,
isInitialized,
isLoading,
isBannerVisible,
needsConsent: computed(() => manager.needsConsent()),
hasConsent: (category: ConsentCategory) => manager.hasConsent(category),
acceptAll: () => manager.acceptAll(),
rejectAll: () => manager.rejectAll(),
saveSelection: async (categories: Partial<ConsentCategories>) => {
await manager.setConsent(categories);
manager.hideBanner();
},
showBanner: () => manager.showBanner(),
hideBanner: () => manager.hideBanner(),
showSettings: () => manager.showSettings(),
};
app.provide(CONSENT_KEY, context);
},
};
// =============================================================================
// Exports
// =============================================================================
export { CONSENT_KEY };
export type { ConsentContext };
export { CONSENT_KEY, type ConsentContext } from './context';
export { useConsent, provideConsent } from './composables';
export {
ConsentProvider,
ConsentGate,
ConsentPlaceholder,
ConsentBanner,
} from './components';
export { ConsentPlugin } from './plugin';

View File

@@ -0,0 +1,74 @@
/**
* Vue plugin for global installation.
*
* Phase 4: extracted from vue/index.ts.
*/
import { computed, ref, type Ref } from 'vue';
import { ConsentManager } from '../core/ConsentManager';
import type {
ConsentCategories,
ConsentCategory,
ConsentConfig,
ConsentState,
} from '../types';
import { CONSENT_KEY, type ConsentContext } from './context';
/**
* Vue Plugin fuer globale Installation.
*
* @example
* ```ts
* app.use(ConsentPlugin, { apiEndpoint: '...', siteId: '...' });
* ```
*/
export const ConsentPlugin = {
install(
app: { provide: (key: symbol | string, value: unknown) => void },
config: ConsentConfig
) {
const manager = new ConsentManager(config);
const consent = ref<ConsentState | null>(null);
const isInitialized = ref(false);
const isLoading = ref(true);
const isBannerVisible = ref(false);
manager.init().then(() => {
consent.value = manager.getConsent();
isInitialized.value = true;
isLoading.value = false;
isBannerVisible.value = manager.isBannerVisible();
});
manager.on('change', (newConsent) => {
consent.value = newConsent;
});
manager.on('banner_show', () => {
isBannerVisible.value = true;
});
manager.on('banner_hide', () => {
isBannerVisible.value = false;
});
const context: ConsentContext = {
manager: ref(manager) as Ref<ConsentManager | null>,
consent: consent as Ref<ConsentState | null>,
isInitialized,
isLoading,
isBannerVisible,
needsConsent: computed(() => manager.needsConsent()),
hasConsent: (category: ConsentCategory) => manager.hasConsent(category),
acceptAll: () => manager.acceptAll(),
rejectAll: () => manager.rejectAll(),
saveSelection: async (categories: Partial<ConsentCategories>) => {
await manager.setConsent(categories);
manager.hideBanner();
},
showBanner: () => manager.showBanner(),
hideBanner: () => manager.hideBanner(),
showSettings: () => manager.showSettings(),
};
app.provide(CONSENT_KEY, context);
},
};