Files
breakpilot-compliance/breakpilot-compliance-sdk/packages/vanilla/src/web-components/consent-banner.ts
T
Sharang Parnerkar 5cb91e88d2 refactor(compliance-sdk): split client/provider/embed/state under 500 LOC
Phase 4 continuation. All touched files now under the file-size cap, and
drive-by fixes unblock the types/core/react/vanilla builds which were broken
at baseline.

Splits
- packages/types/src/state 505 -> 31 LOC barrel + state-flow/-assessment/-core
- packages/core/src/client 521 -> 395 LOC + client-http 187 LOC (HTTP transport)
- packages/react/src/provider 539 -> 460 LOC + provider-context 101 LOC
- packages/vanilla/src/embed 611 -> 290 LOC + embed-banner 321 + embed-translations 78

Drive-by fixes (pre-existing typecheck/build failures)
- types/rag.ts: rename colliding LegalDocument export to RagLegalDocument
  (the `export *` chain in index.ts was ambiguous; two consumers updated
  - core/modules/rag.ts drops unused import, vue/composables/useRAG.ts
  switches to the renamed symbol).
- core/modules/rag.ts: wrap client searchRAG response to add the missing
  `query` field so the declared SearchResponse return type is satisfied.
- react/provider.tsx: re-export useCompliance so ComplianceDashboard /
  ConsentBanner / DSRPortal legacy `from '../provider'` imports resolve.
- vanilla/embed.ts + web-components/base.ts: default tenantId to ''
  so ComplianceClient construction typechecks.
- vanilla/web-components/consent-banner.ts: tighten categories literal to
  `as const` so t.categories indexing narrows correctly.

Verification: packages/types + core + react + vanilla all `pnpm build`
clean with DTS emission. consent-sdk unaffected (still green).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:39:47 +02:00

380 lines
9.5 KiB
TypeScript

/**
* <breakpilot-consent-banner> Web Component
*
* Usage:
* <breakpilot-consent-banner
* api-key="pk_live_xxx"
* position="bottom"
* theme="light"
* language="de"
* privacy-url="/privacy"
* imprint-url="/imprint">
* </breakpilot-consent-banner>
*/
import type {
ConsentPurpose,
CookieBannerPosition,
CookieBannerTheme,
} from '@breakpilot/compliance-sdk-types'
import { BreakPilotElement, COMMON_STYLES } from './base'
const TRANSLATIONS = {
de: {
title: 'Cookie-Einwilligung',
description:
'Wir verwenden Cookies, um Ihre Erfahrung zu verbessern. Weitere Informationen finden Sie in unserer Datenschutzerklärung.',
acceptAll: 'Alle akzeptieren',
rejectAll: 'Nur notwendige',
settings: 'Einstellungen',
save: 'Einstellungen speichern',
back: 'Zurück',
privacy: 'Datenschutz',
imprint: 'Impressum',
categories: {
ESSENTIAL: { name: 'Notwendig', description: 'Erforderlich für die Grundfunktionen' },
FUNCTIONAL: { name: 'Funktional', description: 'Verbesserte Funktionen' },
ANALYTICS: { name: 'Analyse', description: 'Nutzungsstatistiken' },
MARKETING: { name: 'Marketing', description: 'Personalisierte Werbung' },
},
},
en: {
title: 'Cookie Consent',
description:
'We use cookies to improve your experience. For more information, please see our privacy policy.',
acceptAll: 'Accept All',
rejectAll: 'Reject Non-Essential',
settings: 'Settings',
save: 'Save Settings',
back: 'Back',
privacy: 'Privacy Policy',
imprint: 'Imprint',
categories: {
ESSENTIAL: { name: 'Essential', description: 'Required for basic functionality' },
FUNCTIONAL: { name: 'Functional', description: 'Enhanced features' },
ANALYTICS: { name: 'Analytics', description: 'Usage statistics' },
MARKETING: { name: 'Marketing', description: 'Personalized advertising' },
},
},
}
export class ConsentBannerElement extends BreakPilotElement {
static get observedAttributes(): string[] {
return [
'api-key',
'api-endpoint',
'position',
'theme',
'language',
'privacy-url',
'imprint-url',
]
}
private consents: Record<ConsentPurpose, boolean> = {
ESSENTIAL: true,
FUNCTIONAL: false,
ANALYTICS: false,
MARKETING: false,
PERSONALIZATION: false,
THIRD_PARTY: false,
}
private showSettings = false
connectedCallback(): void {
super.connectedCallback()
this.loadStoredConsents()
}
private get position(): CookieBannerPosition {
return (this.getAttribute('position')?.toUpperCase() as CookieBannerPosition) || 'BOTTOM'
}
private get theme(): CookieBannerTheme {
return (this.getAttribute('theme')?.toUpperCase() as CookieBannerTheme) || 'LIGHT'
}
private get language(): 'de' | 'en' {
return (this.getAttribute('language') as 'de' | 'en') || 'de'
}
private get privacyUrl(): string {
return this.getAttribute('privacy-url') || '/privacy'
}
private get imprintUrl(): string {
return this.getAttribute('imprint-url') || '/imprint'
}
private get t() {
return TRANSLATIONS[this.language]
}
private loadStoredConsents(): void {
try {
const stored = localStorage.getItem('breakpilot_consents')
if (stored) {
this.consents = JSON.parse(stored)
this.hide()
}
} catch {
// Ignore errors
}
}
private storeConsents(): void {
try {
localStorage.setItem('breakpilot_consents', JSON.stringify(this.consents))
localStorage.setItem('breakpilot_consents_timestamp', Date.now().toString())
} catch {
// Ignore errors
}
}
private handleAcceptAll = (): void => {
this.consents = {
ESSENTIAL: true,
FUNCTIONAL: true,
ANALYTICS: true,
MARKETING: true,
PERSONALIZATION: true,
THIRD_PARTY: true,
}
this.applyConsents()
}
private handleRejectAll = (): void => {
this.consents = {
ESSENTIAL: true,
FUNCTIONAL: false,
ANALYTICS: false,
MARKETING: false,
PERSONALIZATION: false,
THIRD_PARTY: false,
}
this.applyConsents()
}
private handleSave = (): void => {
this.applyConsents()
}
private applyConsents(): void {
this.storeConsents()
this.emit('consent-change', { consents: this.consents })
this.hide()
}
private toggleSettings = (): void => {
this.showSettings = !this.showSettings
this.render()
}
public show(): void {
this.style.display = 'block'
this.render()
}
public hide(): void {
this.style.display = 'none'
}
public getConsents(): Record<ConsentPurpose, boolean> {
return { ...this.consents }
}
protected render(): void {
const isDark = this.theme === 'DARK'
const bgColor = isDark ? '#1a1a1a' : '#ffffff'
const textColor = isDark ? '#ffffff' : '#1a1a1a'
const borderColor = isDark ? '#333' : '#eee'
const positionStyles = {
TOP: 'top: 0; left: 0; right: 0;',
BOTTOM: 'bottom: 0; left: 0; right: 0;',
CENTER: 'top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: 500px;',
}
const styles = `
${COMMON_STYLES}
:host {
position: fixed;
z-index: 99999;
${positionStyles[this.position]}
}
.banner {
background-color: ${bgColor};
color: ${textColor};
padding: 20px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.title {
margin: 0 0 10px;
font-size: 18px;
font-weight: 600;
}
.description {
margin: 0 0 15px;
opacity: 0.8;
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.btn {
padding: 10px 20px;
border-radius: 4px;
border: none;
font-weight: 500;
}
.btn-primary {
background-color: ${isDark ? '#ffffff' : '#1a1a1a'};
color: ${isDark ? '#1a1a1a' : '#ffffff'};
}
.btn-secondary {
background-color: transparent;
border: 1px solid ${textColor};
color: ${textColor};
}
.links {
margin-left: auto;
font-size: 12px;
}
.links a {
color: ${textColor};
text-decoration: none;
margin-right: 15px;
}
.links a:last-child {
margin-right: 0;
}
.category {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid ${borderColor};
}
.category-name {
font-weight: 500;
}
.category-description {
font-size: 12px;
opacity: 0.7;
}
.checkbox {
width: 20px;
height: 20px;
}
.checkbox:disabled {
cursor: not-allowed;
}
`
if (this.showSettings) {
this.renderSettings(styles)
} else {
this.renderMain(styles)
}
}
private renderMain(styles: string): void {
const t = this.t
this.shadow.innerHTML = `
<style>${styles}</style>
<div class="banner">
<h3 class="title">${t.title}</h3>
<p class="description">${t.description}</p>
<div class="buttons">
<button class="btn btn-primary" id="accept-all">${t.acceptAll}</button>
<button class="btn btn-secondary" id="reject-all">${t.rejectAll}</button>
<button class="btn btn-secondary" id="settings">${t.settings}</button>
<div class="links">
<a href="${this.privacyUrl}">${t.privacy}</a>
<a href="${this.imprintUrl}">${t.imprint}</a>
</div>
</div>
</div>
`
this.shadow.getElementById('accept-all')!.onclick = this.handleAcceptAll
this.shadow.getElementById('reject-all')!.onclick = this.handleRejectAll
this.shadow.getElementById('settings')!.onclick = this.toggleSettings
}
private renderSettings(styles: string): void {
const t = this.t
const categories = ['ESSENTIAL', 'FUNCTIONAL', 'ANALYTICS', 'MARKETING'] as const
const categoriesHtml = categories
.map(
cat => `
<div class="category">
<div>
<div class="category-name">${t.categories[cat].name}</div>
<div class="category-description">${t.categories[cat].description}</div>
</div>
<input
type="checkbox"
class="checkbox"
data-category="${cat}"
${this.consents[cat] ? 'checked' : ''}
${cat === 'ESSENTIAL' ? 'disabled' : ''}
/>
</div>
`
)
.join('')
this.shadow.innerHTML = `
<style>${styles}</style>
<div class="banner">
<h3 class="title">${t.settings}</h3>
${categoriesHtml}
<div class="buttons" style="margin-top: 15px;">
<button class="btn btn-primary" id="save">${t.save}</button>
<button class="btn btn-secondary" id="back">${t.back}</button>
</div>
</div>
`
this.shadow.getElementById('save')!.onclick = this.handleSave
this.shadow.getElementById('back')!.onclick = this.toggleSettings
// Handle checkbox changes
this.shadow.querySelectorAll<HTMLInputElement>('.checkbox').forEach(checkbox => {
checkbox.onchange = () => {
const category = checkbox.dataset.category as ConsentPurpose
if (category !== 'ESSENTIAL') {
this.consents[category] = checkbox.checked
}
}
})
}
}
// Register the custom element
if (typeof customElements !== 'undefined') {
customElements.define('breakpilot-consent-banner', ConsentBannerElement)
}