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

@@ -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 };