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>
322 lines
8.5 KiB
TypeScript
322 lines
8.5 KiB
TypeScript
/**
|
|
* Cookie banner DOM rendering for the vanilla embed SDK.
|
|
*
|
|
* Phase 4: extracted from embed.ts. The banner module is pure rendering —
|
|
* caller passes in a BannerContext describing current consent state plus
|
|
* callbacks for user actions.
|
|
*/
|
|
|
|
import type {
|
|
ConsentPurpose,
|
|
CookieBannerPosition,
|
|
CookieBannerTheme,
|
|
} from '@breakpilot/compliance-sdk-types'
|
|
import {
|
|
TRANSLATIONS,
|
|
createElement,
|
|
type BannerLanguage,
|
|
} from './embed-translations'
|
|
|
|
export interface BannerConfig {
|
|
position?: CookieBannerPosition
|
|
theme?: CookieBannerTheme
|
|
language?: BannerLanguage
|
|
privacyPolicyUrl?: string
|
|
imprintUrl?: string
|
|
texts?: {
|
|
title?: string
|
|
description?: string
|
|
acceptAll?: string
|
|
rejectAll?: string
|
|
settings?: string
|
|
save?: string
|
|
}
|
|
customColors?: {
|
|
background?: string
|
|
text?: string
|
|
primary?: string
|
|
secondary?: string
|
|
}
|
|
}
|
|
|
|
export interface BannerCallbacks {
|
|
onAcceptAll: () => void
|
|
onRejectAll: () => void
|
|
onShowSettings: () => void
|
|
onToggleCategory: (category: ConsentPurpose, granted: boolean) => void
|
|
onSaveSettings: () => void
|
|
onBack: () => void
|
|
}
|
|
|
|
export interface BannerContext {
|
|
config: BannerConfig
|
|
consents: Record<ConsentPurpose, boolean>
|
|
callbacks: BannerCallbacks
|
|
}
|
|
|
|
interface ResolvedColors {
|
|
bgColor: string
|
|
textColor: string
|
|
primaryColor: string
|
|
secondaryColor: string
|
|
isDark: boolean
|
|
}
|
|
|
|
function resolveColors(config: BannerConfig): ResolvedColors {
|
|
const theme = config.theme || 'LIGHT'
|
|
const isDark = theme === 'DARK'
|
|
return {
|
|
isDark,
|
|
bgColor: config.customColors?.background || (isDark ? '#1a1a1a' : '#ffffff'),
|
|
textColor: config.customColors?.text || (isDark ? '#ffffff' : '#1a1a1a'),
|
|
primaryColor: config.customColors?.primary || (isDark ? '#ffffff' : '#1a1a1a'),
|
|
secondaryColor: config.customColors?.secondary || (isDark ? '#ffffff' : '#1a1a1a'),
|
|
}
|
|
}
|
|
|
|
export function createBannerElement(ctx: BannerContext): HTMLElement {
|
|
const { config, callbacks } = ctx
|
|
const lang: BannerLanguage = config.language || 'de'
|
|
const t = TRANSLATIONS[lang]
|
|
const position = config.position || 'BOTTOM'
|
|
const colors = resolveColors(config)
|
|
const { bgColor, textColor, primaryColor, secondaryColor, isDark } = colors
|
|
|
|
const container = createElement(
|
|
'div',
|
|
{
|
|
position: 'fixed',
|
|
zIndex: '99999',
|
|
left: position === 'CENTER' ? '50%' : '0',
|
|
right: position === 'CENTER' ? 'auto' : '0',
|
|
top: position === 'TOP' ? '0' : position === 'CENTER' ? '50%' : 'auto',
|
|
bottom: position === 'BOTTOM' ? '0' : 'auto',
|
|
transform: position === 'CENTER' ? 'translate(-50%, -50%)' : 'none',
|
|
maxWidth: position === 'CENTER' ? '500px' : 'none',
|
|
backgroundColor: bgColor,
|
|
color: textColor,
|
|
padding: '20px',
|
|
boxShadow: '0 -2px 10px rgba(0, 0, 0, 0.1)',
|
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
fontSize: '14px',
|
|
lineHeight: '1.5',
|
|
},
|
|
{ id: 'breakpilot-consent-banner', role: 'dialog', 'aria-label': t.title }
|
|
)
|
|
|
|
const title = createElement('h3', {
|
|
margin: '0 0 10px',
|
|
fontSize: '18px',
|
|
fontWeight: '600',
|
|
})
|
|
title.textContent = config.texts?.title || t.title
|
|
|
|
const description = createElement('p', {
|
|
margin: '0 0 15px',
|
|
opacity: '0.8',
|
|
})
|
|
description.textContent = config.texts?.description || t.description
|
|
|
|
const buttonsContainer = createElement('div', {
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '10px',
|
|
alignItems: 'center',
|
|
})
|
|
|
|
const acceptBtn = createElement(
|
|
'button',
|
|
{
|
|
padding: '10px 20px',
|
|
borderRadius: '4px',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
fontWeight: '500',
|
|
backgroundColor: primaryColor,
|
|
color: isDark ? '#1a1a1a' : '#ffffff',
|
|
},
|
|
{ type: 'button' }
|
|
)
|
|
acceptBtn.textContent = config.texts?.acceptAll || t.acceptAll
|
|
acceptBtn.onclick = () => callbacks.onAcceptAll()
|
|
|
|
const rejectBtn = createElement(
|
|
'button',
|
|
{
|
|
padding: '10px 20px',
|
|
borderRadius: '4px',
|
|
backgroundColor: 'transparent',
|
|
border: `1px solid ${secondaryColor}`,
|
|
color: textColor,
|
|
cursor: 'pointer',
|
|
fontWeight: '500',
|
|
},
|
|
{ type: 'button' }
|
|
)
|
|
rejectBtn.textContent = config.texts?.rejectAll || t.rejectAll
|
|
rejectBtn.onclick = () => callbacks.onRejectAll()
|
|
|
|
const settingsBtn = createElement(
|
|
'button',
|
|
{
|
|
padding: '10px 20px',
|
|
borderRadius: '4px',
|
|
backgroundColor: 'transparent',
|
|
border: `1px solid ${secondaryColor}`,
|
|
color: textColor,
|
|
cursor: 'pointer',
|
|
fontWeight: '500',
|
|
},
|
|
{ type: 'button' }
|
|
)
|
|
settingsBtn.textContent = config.texts?.settings || t.settings
|
|
settingsBtn.onclick = () => callbacks.onShowSettings()
|
|
|
|
const linksContainer = createElement('div', {
|
|
marginLeft: 'auto',
|
|
fontSize: '12px',
|
|
})
|
|
|
|
const privacyLink = createElement('a', {
|
|
marginRight: '15px',
|
|
color: textColor,
|
|
textDecoration: 'none',
|
|
})
|
|
privacyLink.href = config.privacyPolicyUrl || '/privacy'
|
|
privacyLink.textContent = t.privacy
|
|
|
|
const imprintLink = createElement('a', {
|
|
color: textColor,
|
|
textDecoration: 'none',
|
|
})
|
|
imprintLink.href = config.imprintUrl || '/imprint'
|
|
imprintLink.textContent = t.imprint
|
|
|
|
linksContainer.appendChild(privacyLink)
|
|
linksContainer.appendChild(imprintLink)
|
|
|
|
buttonsContainer.appendChild(acceptBtn)
|
|
buttonsContainer.appendChild(rejectBtn)
|
|
buttonsContainer.appendChild(settingsBtn)
|
|
buttonsContainer.appendChild(linksContainer)
|
|
|
|
container.appendChild(title)
|
|
container.appendChild(description)
|
|
container.appendChild(buttonsContainer)
|
|
|
|
return container
|
|
}
|
|
|
|
export function renderSettingsPanel(element: HTMLElement, ctx: BannerContext): void {
|
|
const { config, consents, callbacks } = ctx
|
|
const lang: BannerLanguage = config.language || 'de'
|
|
const t = TRANSLATIONS[lang]
|
|
const colors = resolveColors(config)
|
|
const { textColor, primaryColor, secondaryColor, isDark } = colors
|
|
|
|
element.innerHTML = ''
|
|
|
|
const title = createElement('h3', {
|
|
margin: '0 0 15px',
|
|
fontSize: '18px',
|
|
fontWeight: '600',
|
|
})
|
|
title.textContent = t.settings
|
|
|
|
element.appendChild(title)
|
|
|
|
const categories: ConsentPurpose[] = [
|
|
'ESSENTIAL',
|
|
'FUNCTIONAL',
|
|
'ANALYTICS',
|
|
'MARKETING',
|
|
]
|
|
|
|
categories.forEach(category => {
|
|
const catInfo = t.categories[category]
|
|
const row = createElement('div', {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '10px 0',
|
|
borderBottom: `1px solid ${isDark ? '#333' : '#eee'}`,
|
|
})
|
|
|
|
const labelContainer = createElement('div', {})
|
|
const labelName = createElement('div', { fontWeight: '500' })
|
|
labelName.textContent = catInfo.name
|
|
const labelDesc = createElement('div', { fontSize: '12px', opacity: '0.7' })
|
|
labelDesc.textContent = catInfo.description
|
|
|
|
labelContainer.appendChild(labelName)
|
|
labelContainer.appendChild(labelDesc)
|
|
|
|
const checkbox = createElement(
|
|
'input',
|
|
{
|
|
width: '20px',
|
|
height: '20px',
|
|
cursor: category === 'ESSENTIAL' ? 'not-allowed' : 'pointer',
|
|
},
|
|
{
|
|
type: 'checkbox',
|
|
'data-category': category,
|
|
}
|
|
)
|
|
checkbox.checked = consents[category]
|
|
checkbox.disabled = category === 'ESSENTIAL'
|
|
checkbox.onchange = () => {
|
|
if (category !== 'ESSENTIAL') {
|
|
callbacks.onToggleCategory(category, checkbox.checked)
|
|
}
|
|
}
|
|
|
|
row.appendChild(labelContainer)
|
|
row.appendChild(checkbox)
|
|
element.appendChild(row)
|
|
})
|
|
|
|
const buttonsContainer = createElement('div', {
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '10px',
|
|
marginTop: '15px',
|
|
})
|
|
|
|
const saveBtn = createElement(
|
|
'button',
|
|
{
|
|
padding: '10px 20px',
|
|
borderRadius: '4px',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
fontWeight: '500',
|
|
backgroundColor: primaryColor,
|
|
color: isDark ? '#1a1a1a' : '#ffffff',
|
|
},
|
|
{ type: 'button' }
|
|
)
|
|
saveBtn.textContent = config.texts?.save || t.save
|
|
saveBtn.onclick = () => callbacks.onSaveSettings()
|
|
|
|
const backBtn = createElement(
|
|
'button',
|
|
{
|
|
padding: '10px 20px',
|
|
borderRadius: '4px',
|
|
backgroundColor: 'transparent',
|
|
border: `1px solid ${secondaryColor}`,
|
|
color: textColor,
|
|
cursor: 'pointer',
|
|
fontWeight: '500',
|
|
},
|
|
{ type: 'button' }
|
|
)
|
|
backBtn.textContent = 'Zurück'
|
|
backBtn.onclick = () => callbacks.onBack()
|
|
|
|
buttonsContainer.appendChild(saveBtn)
|
|
buttonsContainer.appendChild(backBtn)
|
|
element.appendChild(buttonsContainer)
|
|
}
|