From 4ed39d26165b6e0450164717de91db7561b18b8f Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:25:44 +0200 Subject: [PATCH] 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) --- consent-sdk/src/angular/index.ts | 506 +----------------- consent-sdk/src/angular/interface.ts | 64 +++ consent-sdk/src/angular/module.ts | 79 +++ consent-sdk/src/angular/service.ts | 190 +++++++ consent-sdk/src/angular/templates.ts | 142 +++++ consent-sdk/src/core/ConsentManager.ts | 78 +-- .../src/core/consent-manager-config.ts | 58 ++ .../src/core/consent-manager-google.ts | 38 ++ consent-sdk/src/react/components.tsx | 190 +++++++ consent-sdk/src/react/context.ts | 44 ++ consent-sdk/src/react/hooks.ts | 43 ++ consent-sdk/src/react/index.tsx | 350 +----------- consent-sdk/src/vue/components.ts | 191 +++++++ consent-sdk/src/vue/composables.ts | 135 +++++ consent-sdk/src/vue/context.ts | 31 ++ consent-sdk/src/vue/index.ts | 503 +---------------- consent-sdk/src/vue/plugin.ts | 74 +++ 17 files changed, 1341 insertions(+), 1375 deletions(-) create mode 100644 consent-sdk/src/angular/interface.ts create mode 100644 consent-sdk/src/angular/module.ts create mode 100644 consent-sdk/src/angular/service.ts create mode 100644 consent-sdk/src/angular/templates.ts create mode 100644 consent-sdk/src/core/consent-manager-config.ts create mode 100644 consent-sdk/src/core/consent-manager-google.ts create mode 100644 consent-sdk/src/react/components.tsx create mode 100644 consent-sdk/src/react/context.ts create mode 100644 consent-sdk/src/react/hooks.ts create mode 100644 consent-sdk/src/vue/components.ts create mode 100644 consent-sdk/src/vue/composables.ts create mode 100644 consent-sdk/src/vue/context.ts create mode 100644 consent-sdk/src/vue/plugin.ts diff --git a/consent-sdk/src/angular/index.ts b/consent-sdk/src/angular/index.ts index 6c4ece4..69d00c2 100644 --- a/consent-sdk/src/angular/index.ts +++ b/consent-sdk/src/angular/index.ts @@ -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; - - /** Alle ablehnen */ - rejectAll(): Promise; - - /** Auswahl speichern */ - saveSelection(categories: Partial): Promise; - - /** 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 { - await this.manager.acceptAll(); - } - - async rejectAll(): Promise { - await this.manager.rejectAll(); - } - - async saveSelection(categories: Partial): Promise { - 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 { - 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 { - * 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 = ` - -`; - -/** - * ConsentGateDirective Template - * - * @example - * ```typescript - * @Directive({ - * selector: '[bpConsentGate]', - * }) - * export class ConsentGateDirective implements OnInit, OnDestroy { - * @Input('bpConsentGate') category!: ConsentCategory; - * - * private unsubscribe?: () => void; - * - * constructor( - * private templateRef: TemplateRef, - * 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 = ` - -
- -
- - - - - - -

Bitte akzeptieren Sie Marketing-Cookies.

-
-`; - -// ============================================================================= -// 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(null); - * private bannerVisibleSubject = new BehaviorSubject(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 }; diff --git a/consent-sdk/src/angular/interface.ts b/consent-sdk/src/angular/interface.ts new file mode 100644 index 0000000..ab56956 --- /dev/null +++ b/consent-sdk/src/angular/interface.ts @@ -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; + + /** Alle ablehnen */ + rejectAll(): Promise; + + /** Auswahl speichern */ + saveSelection(categories: Partial): Promise; + + /** Banner anzeigen */ + showBanner(): void; + + /** Banner ausblenden */ + hideBanner(): void; + + /** Einstellungen oeffnen */ + showSettings(): void; +} diff --git a/consent-sdk/src/angular/module.ts b/consent-sdk/src/angular/module.ts new file mode 100644 index 0000000..1f33af6 --- /dev/null +++ b/consent-sdk/src/angular/module.ts @@ -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 { + * 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, + }), +}; diff --git a/consent-sdk/src/angular/service.ts b/consent-sdk/src/angular/service.ts new file mode 100644 index 0000000..f908353 --- /dev/null +++ b/consent-sdk/src/angular/service.ts @@ -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 { + await this.manager.acceptAll(); + } + + async rejectAll(): Promise { + await this.manager.rejectAll(); + } + + async saveSelection(categories: Partial): Promise { + 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 { + 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; + } + } +} diff --git a/consent-sdk/src/angular/templates.ts b/consent-sdk/src/angular/templates.ts new file mode 100644 index 0000000..4125253 --- /dev/null +++ b/consent-sdk/src/angular/templates.ts @@ -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 = ` + +`; + +/** + * ConsentGateDirective Template + * + * @example + * ```typescript + * @Directive({ + * selector: '[bpConsentGate]', + * }) + * export class ConsentGateDirective implements OnInit, OnDestroy { + * @Input('bpConsentGate') category!: ConsentCategory; + * + * private unsubscribe?: () => void; + * + * constructor( + * private templateRef: TemplateRef, + * 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 = ` + +
+ +
+ + + + + + +

Bitte akzeptieren Sie Marketing-Cookies.

+
+`; + +/** + * 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(null); + * private bannerVisibleSubject = new BehaviorSubject(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)); + * } + * } + * ``` + */ diff --git a/consent-sdk/src/core/ConsentManager.ts b/consent-sdk/src/core/ConsentManager.ts index be203d9..c7fedd0 100644 --- a/consent-sdk/src/core/ConsentManager.ts +++ b/consent-sdk/src/core/ConsentManager.ts @@ -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 = { - 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'); } /** diff --git a/consent-sdk/src/core/consent-manager-config.ts b/consent-sdk/src/core/consent-manager-config.ts new file mode 100644 index 0000000..f976eab --- /dev/null +++ b/consent-sdk/src/core/consent-manager-config.ts @@ -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 = { + 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; +} diff --git a/consent-sdk/src/core/consent-manager-google.ts b/consent-sdk/src/core/consent-manager-google.ts new file mode 100644 index 0000000..6ebe50e --- /dev/null +++ b/consent-sdk/src/core/consent-manager-google.ts @@ -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; +} diff --git a/consent-sdk/src/react/components.tsx b/consent-sdk/src/react/components.tsx new file mode 100644 index 0000000..d0fb714 --- /dev/null +++ b/consent-sdk/src/react/components.tsx @@ -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 = ({ + 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 = ({ + category, + message, + buttonText, + className = '', +}) => { + const { showSettings } = useConsent(); + + const categoryNames: Record = { + essential: 'Essentielle Cookies', + functional: 'Funktionale Cookies', + analytics: 'Statistik-Cookies', + marketing: 'Marketing-Cookies', + social: 'Social Media-Cookies', + }; + + const defaultMessage = `Dieser Inhalt erfordert ${categoryNames[category]}.`; + + return ( +
+

{message || defaultMessage}

+ +
+ ); +}; + +// ============================================================================= +// ConsentBanner (headless) +// ============================================================================= + +export interface ConsentBannerRenderProps { + isVisible: boolean; + consent: ConsentState | null; + needsConsent: boolean; + onAcceptAll: () => void; + onRejectAll: () => void; + onSaveSelection: (categories: Partial) => 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 = ({ 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 ( +
+
+

Datenschutzeinstellungen

+

+ Wir nutzen Cookies und aehnliche Technologien, um Ihnen ein optimales + Nutzererlebnis zu bieten. +

+ +
+ + + +
+
+
+ ); +}; diff --git a/consent-sdk/src/react/context.ts b/consent-sdk/src/react/context.ts new file mode 100644 index 0000000..3251abf --- /dev/null +++ b/consent-sdk/src/react/context.ts @@ -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; + /** Alle ablehnen */ + rejectAll: () => Promise; + /** Auswahl speichern */ + saveSelection: (categories: Partial) => Promise; + /** Banner anzeigen */ + showBanner: () => void; + /** Banner verstecken */ + hideBanner: () => void; + /** Einstellungen oeffnen */ + showSettings: () => void; +} + +export const ConsentContext = createContext(null); diff --git a/consent-sdk/src/react/hooks.ts b/consent-sdk/src/react/hooks.ts new file mode 100644 index 0000000..27ee2de --- /dev/null +++ b/consent-sdk/src/react/hooks.ts @@ -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; +} diff --git a/consent-sdk/src/react/index.tsx b/consent-sdk/src/react/index.tsx index abaf0bb..cb4a728 100644 --- a/consent-sdk/src/react/index.tsx +++ b/consent-sdk/src/react/index.tsx @@ -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; - - /** Alle ablehnen */ - rejectAll: () => Promise; - - /** Auswahl speichern */ - saveSelection: (categories: Partial) => Promise; - - /** Banner anzeigen */ - showBanner: () => void; - - /** Banner verstecken */ - hideBanner: () => void; - - /** Einstellungen oeffnen */ - showSettings: () => void; -} - -const ConsentContext = createContext(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(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 = ({ config, @@ -228,284 +190,10 @@ export const ConsentProvider: FC = ({ }; // ============================================================================= -// 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 - * } - * > - * - * - * ``` - */ -export const ConsentGate: FC = ({ - 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 = ({ - category, - message, - buttonText, - className = '', -}) => { - const { showSettings } = useConsent(); - - const categoryNames: Record = { - essential: 'Essentielle Cookies', - functional: 'Funktionale Cookies', - analytics: 'Statistik-Cookies', - marketing: 'Marketing-Cookies', - social: 'Social Media-Cookies', - }; - - const defaultMessage = `Dieser Inhalt erfordert ${categoryNames[category]}.`; - - return ( -
-

{message || defaultMessage}

- -
- ); -}; - -// ============================================================================= -// 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) => 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 - * ( - * isVisible && ( - *
- * - * - *
- * ) - * )} - * /> - * - * // Mit Default-UI - * - * ``` - */ -export const ConsentBanner: FC = ({ 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 ( -
-
-

Datenschutzeinstellungen

-

- Wir nutzen Cookies und aehnliche Technologien, um Ihnen ein optimales - Nutzererlebnis zu bieten. -

- -
- - - -
-
-
- ); -}; - -// ============================================================================= -// Exports +// Re-exports for the public @breakpilot/consent-sdk/react entrypoint // ============================================================================= +export { useConsent, useConsentManager }; +export { ConsentBanner, ConsentGate, ConsentPlaceholder }; export { ConsentContext }; export type { ConsentContextValue, ConsentBannerRenderProps }; diff --git a/consent-sdk/src/vue/components.ts b/consent-sdk/src/vue/components.ts new file mode 100644 index 0000000..4d64fcd --- /dev/null +++ b/consent-sdk/src/vue/components.ts @@ -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, + 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, + 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, + required: true, + }, + message: { + type: String, + default: '', + }, + buttonText: { + type: String, + default: 'Cookie-Einstellungen öffnen', + }, + }, + setup(props) { + const { showSettings } = useConsent(); + + const categoryNames: Record = { + 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' + ), + ]), + ]), + ] + ); + }; + }, +}); diff --git a/consent-sdk/src/vue/composables.ts b/consent-sdk/src/vue/composables.ts new file mode 100644 index 0000000..1d1a34b --- /dev/null +++ b/consent-sdk/src/vue/composables.ts @@ -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(null); + const consent = ref(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 => { + await manager.value?.acceptAll(); + }; + + const rejectAll = async (): Promise => { + await manager.value?.rejectAll(); + }; + + const saveSelection = async ( + categories: Partial + ): Promise => { + 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, + consent: readonly(consent) as Ref, + isInitialized: readonly(isInitialized), + isLoading: readonly(isLoading), + isBannerVisible: readonly(isBannerVisible), + needsConsent, + hasConsent, + acceptAll, + rejectAll, + saveSelection, + showBanner, + hideBanner, + showSettings, + }; + + provide(CONSENT_KEY, context); + + return context; +} diff --git a/consent-sdk/src/vue/context.ts b/consent-sdk/src/vue/context.ts new file mode 100644 index 0000000..ce5a221 --- /dev/null +++ b/consent-sdk/src/vue/context.ts @@ -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; + consent: Ref; + isInitialized: Ref; + isLoading: Ref; + isBannerVisible: Ref; + needsConsent: Ref; + hasConsent: (category: ConsentCategory) => boolean; + acceptAll: () => Promise; + rejectAll: () => Promise; + saveSelection: (categories: Partial) => Promise; + showBanner: () => void; + hideBanner: () => void; + showSettings: () => void; +} + +export const CONSENT_KEY: InjectionKey = Symbol('consent'); diff --git a/consent-sdk/src/vue/index.ts b/consent-sdk/src/vue/index.ts index 9f157f7..1af08d6 100644 --- a/consent-sdk/src/vue/index.ts +++ b/consent-sdk/src/vue/index.ts @@ -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 * - * ``` - */ -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 - * - * ``` - */ -export function provideConsent(config: ConsentConfig): ConsentContext { - const manager = ref(null); - const consent = ref(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 => { - await manager.value?.acceptAll(); - }; - - const rejectAll = async (): Promise => { - await manager.value?.rejectAll(); - }; - - const saveSelection = async (categories: Partial): Promise => { - 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, - consent: readonly(consent) as Ref, - 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 - * - * - * - * ``` - */ -export const ConsentProvider = defineComponent({ - name: 'ConsentProvider', - props: { - config: { - type: Object as PropType, - required: true, - }, - }, - setup(props, { slots }) { - provideConsent(props.config); - return () => slots.default?.(); - }, -}); - -/** - * ConsentGate - Zeigt Inhalt nur bei Consent - * - * @example - * ```vue - * - * - * - * - * ``` - */ -export const ConsentGate = defineComponent({ - name: 'ConsentGate', - props: { - category: { - type: String as PropType, - 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 - * - * ``` - */ -export const ConsentPlaceholder = defineComponent({ - name: 'ConsentPlaceholder', - props: { - category: { - type: String as PropType, - required: true, - }, - message: { - type: String, - default: '', - }, - buttonText: { - type: String, - default: 'Cookie-Einstellungen öffnen', - }, - }, - setup(props) { - const { showSettings } = useConsent(); - - const categoryNames: Record = { - 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 - * - * - * - * ``` - */ -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(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, - consent: consent as Ref, - isInitialized, - isLoading, - isBannerVisible, - needsConsent: computed(() => manager.needsConsent()), - hasConsent: (category: ConsentCategory) => manager.hasConsent(category), - acceptAll: () => manager.acceptAll(), - rejectAll: () => manager.rejectAll(), - saveSelection: async (categories: Partial) => { - 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'; diff --git a/consent-sdk/src/vue/plugin.ts b/consent-sdk/src/vue/plugin.ts new file mode 100644 index 0000000..fac957f --- /dev/null +++ b/consent-sdk/src/vue/plugin.ts @@ -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(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, + consent: consent as Ref, + isInitialized, + isLoading, + isBannerVisible, + needsConsent: computed(() => manager.needsConsent()), + hasConsent: (category: ConsentCategory) => manager.hasConsent(category), + acceptAll: () => manager.acceptAll(), + rejectAll: () => manager.rejectAll(), + saveSelection: async (categories: Partial) => { + await manager.setConsent(categories); + manager.hideBanner(); + }, + showBanner: () => manager.showBanner(), + hideBanner: () => manager.hideBanner(), + showSettings: () => manager.showSettings(), + }; + + app.provide(CONSENT_KEY, context); + }, +};