@el7ven/cookie-kit 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Google Consent Mode v2 Integration
3
+ * Automatically syncs cookie consent with Google's Consent API
4
+ * @module ConsentMode
5
+ */
6
+
7
+ const CONSENT_MODE_DEFAULTS = {
8
+ analytics_storage: 'denied',
9
+ ad_storage: 'denied',
10
+ ad_user_data: 'denied',
11
+ ad_personalization: 'denied',
12
+ functionality_storage: 'denied',
13
+ personalization_storage: 'denied',
14
+ security_storage: 'granted'
15
+ }
16
+
17
+ // Mapping: cookie category → Google consent types
18
+ const CATEGORY_MAPPING = {
19
+ necessary: ['security_storage'],
20
+ analytics: ['analytics_storage'],
21
+ marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
22
+ preferences: ['functionality_storage', 'personalization_storage']
23
+ }
24
+
25
+ export class ConsentMode {
26
+ constructor(consentManager, options = {}) {
27
+ this.manager = consentManager
28
+ this.enabled = options.enabled !== false
29
+ this.categoryMapping = { ...CATEGORY_MAPPING, ...options.categoryMapping }
30
+ this.waitForUpdate = options.waitForUpdate || 500
31
+ this._initialized = false
32
+
33
+ if (this.enabled) {
34
+ this._init()
35
+ }
36
+ }
37
+
38
+ _init() {
39
+ if (typeof window === 'undefined') return
40
+ if (this._initialized) return
41
+
42
+ // Ensure dataLayer exists
43
+ window.dataLayer = window.dataLayer || []
44
+
45
+ // Set default consent state (denied for everything except security)
46
+ this._gtag('consent', 'default', {
47
+ ...CONSENT_MODE_DEFAULTS,
48
+ wait_for_update: this.waitForUpdate
49
+ })
50
+
51
+ this.manager._debug('ConsentMode: defaults set', CONSENT_MODE_DEFAULTS)
52
+
53
+ // Check if user already has consent
54
+ const existing = this.manager.getConsent()
55
+ if (existing?.hasConsented) {
56
+ this._updateFromCategories(existing.categories)
57
+ }
58
+
59
+ // Listen for consent changes
60
+ this.manager.on('consentChanged', ({ categories }) => {
61
+ this._updateFromCategories(categories)
62
+ })
63
+
64
+ this._initialized = true
65
+ }
66
+
67
+ _updateFromCategories(categories) {
68
+ const consentUpdate = {}
69
+
70
+ Object.entries(this.categoryMapping).forEach(([category, gtagKeys]) => {
71
+ const granted = categories[category] === true
72
+ gtagKeys.forEach(key => {
73
+ consentUpdate[key] = granted ? 'granted' : 'denied'
74
+ })
75
+ })
76
+
77
+ this._gtag('consent', 'update', consentUpdate)
78
+ this.manager._debug('ConsentMode: updated', consentUpdate)
79
+ }
80
+
81
+ _gtag() {
82
+ if (typeof window === 'undefined') return
83
+ window.dataLayer = window.dataLayer || []
84
+ window.dataLayer.push(arguments)
85
+ }
86
+
87
+ // Get current consent mode state
88
+ getState() {
89
+ const consent = this.manager.getConsent()
90
+ if (!consent?.hasConsented) {
91
+ return { ...CONSENT_MODE_DEFAULTS }
92
+ }
93
+
94
+ const state = { ...CONSENT_MODE_DEFAULTS }
95
+ Object.entries(this.categoryMapping).forEach(([category, gtagKeys]) => {
96
+ const granted = consent.categories[category] === true
97
+ gtagKeys.forEach(key => {
98
+ state[key] = granted ? 'granted' : 'denied'
99
+ })
100
+ })
101
+ return state
102
+ }
103
+
104
+ destroy() {
105
+ this._initialized = false
106
+ }
107
+ }
108
+
109
+ export { CONSENT_MODE_DEFAULTS, CATEGORY_MAPPING }
@@ -0,0 +1,130 @@
1
+ const FOCUSABLE_SELECTORS = [
2
+ 'a[href]',
3
+ 'button:not([disabled])',
4
+ 'input:not([disabled])',
5
+ 'select:not([disabled])',
6
+ 'textarea:not([disabled])',
7
+ '[tabindex]:not([tabindex="-1"])',
8
+ '[contenteditable]'
9
+ ].join(', ')
10
+
11
+ export class FocusTrap {
12
+ constructor(options = {}) {
13
+ this.active = false
14
+ this.element = null
15
+ this.previousFocus = null
16
+ this.onEscape = options.onEscape || null
17
+ this._handleKeyDown = this._handleKeyDown.bind(this)
18
+ }
19
+
20
+ activate(element) {
21
+ if (!element || typeof document === 'undefined') return
22
+
23
+ this.element = element
24
+ this.previousFocus = document.activeElement
25
+ this.active = true
26
+
27
+ element.setAttribute('role', 'dialog')
28
+ element.setAttribute('aria-modal', 'true')
29
+
30
+ document.addEventListener('keydown', this._handleKeyDown, { capture: true })
31
+ element.addEventListener('keydown', this._handleKeyDown, { capture: true })
32
+
33
+ requestAnimationFrame(() => {
34
+ const first = this._getFirstFocusable()
35
+ if (first) {
36
+ first.focus()
37
+ } else {
38
+ element.setAttribute('tabindex', '-1')
39
+ element.focus()
40
+ }
41
+ })
42
+ }
43
+
44
+ deactivate() {
45
+ if (!this.active) return
46
+
47
+ this.active = false
48
+
49
+ // Remove both listeners
50
+ document.removeEventListener('keydown', this._handleKeyDown, { capture: true })
51
+ if (this.element) {
52
+ this.element.removeEventListener('keydown', this._handleKeyDown, { capture: true })
53
+ }
54
+
55
+ // Restore previous focus
56
+ if (this.previousFocus && typeof this.previousFocus.focus === 'function') {
57
+ this.previousFocus.focus()
58
+ }
59
+
60
+ this.element = null
61
+ this.previousFocus = null
62
+ }
63
+
64
+ _handleKeyDown(event) {
65
+ if (!this.active || !this.element) return
66
+
67
+ if (event.key === 'Escape') {
68
+ event.preventDefault()
69
+ if (this.onEscape) {
70
+ this.onEscape()
71
+ }
72
+ return
73
+ }
74
+
75
+ // Tab key — trap focus
76
+ if (event.key === 'Tab') {
77
+ const focusables = this._getFocusableElements()
78
+
79
+ if (focusables.length === 0) {
80
+ event.preventDefault()
81
+ return
82
+ }
83
+
84
+ const first = focusables[0]
85
+ const last = focusables[focusables.length - 1]
86
+ const current = document.activeElement
87
+
88
+ if (event.shiftKey) {
89
+ if (current === first || !focusables.includes(current)) {
90
+ event.preventDefault()
91
+ last.focus()
92
+ }
93
+ } else {
94
+ if (current === last || !focusables.includes(current)) {
95
+ event.preventDefault()
96
+ first.focus()
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ _getFocusableElements() {
103
+ if (!this.element) return []
104
+
105
+ return Array.from(this.element.querySelectorAll(FOCUSABLE_SELECTORS))
106
+ .filter(el => {
107
+ const style = window.getComputedStyle(el)
108
+ const isToggleInput = el.classList.contains('cookie-drawer__toggle-input')
109
+
110
+ let isVisible = style.display !== 'none' && style.visibility !== 'hidden'
111
+
112
+ if (!isToggleInput) {
113
+ isVisible = isVisible && el.offsetWidth > 0 && el.offsetHeight > 0
114
+ }
115
+
116
+ const isEnabled = !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
117
+
118
+ return isVisible && isEnabled
119
+ })
120
+ }
121
+
122
+ _getFirstFocusable() {
123
+ const elements = this._getFocusableElements()
124
+ return elements[0] || null
125
+ }
126
+
127
+ destroy() {
128
+ this.deactivate()
129
+ }
130
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Geo GDPR Detection — auto-detect user region for consent rules
3
+ * EU/UK → show banner, US → optional, other → configurable
4
+ * @module GeoDetector
5
+ */
6
+
7
+ const EU_COUNTRIES = [
8
+ 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
9
+ 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
10
+ 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO'
11
+ ]
12
+
13
+ const CCPA_STATES = ['CA']
14
+
15
+ const REGION_RULES = {
16
+ gdpr: { showBanner: true, requireExplicit: true, rejectButton: true },
17
+ ccpa: { showBanner: true, requireExplicit: false, rejectButton: true },
18
+ none: { showBanner: false, requireExplicit: false, rejectButton: false }
19
+ }
20
+
21
+ export class GeoDetector {
22
+ constructor(consentManager, options = {}) {
23
+ this.manager = consentManager
24
+ this.defaultRegion = options.defaultRegion || 'unknown'
25
+ this.apiUrl = options.apiUrl || null
26
+ this.apiKey = options.apiKey || null
27
+ this.provider = options.provider || 'auto' // 'auto' | 'ipapi' | 'ipstack' | 'manual'
28
+ this._region = null
29
+ this._country = null
30
+ this._detected = false
31
+ }
32
+
33
+ /**
34
+ * Detect user's region
35
+ * @returns {Promise<{country: string, region: string, rules: Object}>}
36
+ */
37
+ async detect() {
38
+ if (this._detected) {
39
+ return this.getResult()
40
+ }
41
+
42
+ try {
43
+ if (this.provider === 'manual') {
44
+ this._region = this.defaultRegion
45
+ this._detected = true
46
+ return this.getResult()
47
+ }
48
+
49
+ const data = await this._fetchGeoData()
50
+ this._country = data.country
51
+ this._region = this._classifyRegion(data.country, data.state)
52
+ this._detected = true
53
+
54
+ this.manager._debug('GeoDetector: detected', {
55
+ country: this._country,
56
+ region: this._region
57
+ })
58
+
59
+ return this.getResult()
60
+ } catch (e) {
61
+ this.manager._debug('GeoDetector: detection failed, using default', e.message)
62
+ this._region = this.defaultRegion === 'gdpr' ? 'gdpr' : 'none'
63
+ this._detected = true
64
+ return this.getResult()
65
+ }
66
+ }
67
+
68
+ getResult() {
69
+ const region = this._region || 'gdpr' // default to gdpr for safety
70
+ return {
71
+ country: this._country,
72
+ region,
73
+ rules: REGION_RULES[region] || REGION_RULES.gdpr,
74
+ detected: this._detected
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Check if banner should be shown based on region
80
+ */
81
+ shouldShowBanner() {
82
+ const result = this.getResult()
83
+ return result.rules.showBanner
84
+ }
85
+
86
+ /**
87
+ * Check if explicit consent is required
88
+ */
89
+ requiresExplicitConsent() {
90
+ const result = this.getResult()
91
+ return result.rules.requireExplicit
92
+ }
93
+
94
+ // --- Internal ---
95
+
96
+ _classifyRegion(country, state) {
97
+ if (EU_COUNTRIES.includes(country)) return 'gdpr'
98
+ if (country === 'US' && CCPA_STATES.includes(state)) return 'ccpa'
99
+ if (country === 'BR') return 'gdpr' // LGPD similar to GDPR
100
+ return 'none'
101
+ }
102
+
103
+ async _fetchGeoData() {
104
+ // Try multiple free providers
105
+ const providers = [
106
+ () => this._fetchIpApi(),
107
+ () => this._fetchCustom()
108
+ ]
109
+
110
+ for (const provider of providers) {
111
+ try {
112
+ return await provider()
113
+ } catch (e) {
114
+ continue
115
+ }
116
+ }
117
+
118
+ throw new Error('All geo providers failed')
119
+ }
120
+
121
+ async _fetchIpApi() {
122
+ const res = await fetch('https://ipapi.co/json/', {
123
+ signal: AbortSignal.timeout(3000)
124
+ })
125
+ if (!res.ok) throw new Error('ipapi failed')
126
+ const data = await res.json()
127
+ return {
128
+ country: data.country_code,
129
+ state: data.region_code
130
+ }
131
+ }
132
+
133
+ async _fetchCustom() {
134
+ if (!this.apiUrl) throw new Error('No custom API URL')
135
+ const url = this.apiKey ? `${this.apiUrl}?key=${this.apiKey}` : this.apiUrl
136
+ const res = await fetch(url, {
137
+ signal: AbortSignal.timeout(3000)
138
+ })
139
+ if (!res.ok) throw new Error('Custom API failed')
140
+ return await res.json()
141
+ }
142
+ }
143
+
144
+ export { EU_COUNTRIES, CCPA_STATES, REGION_RULES }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Script Loader System — blocks/unblocks scripts based on consent
3
+ * Supports inline and external scripts with data-cookiecategory attribute
4
+ * @module ScriptLoader
5
+ */
6
+
7
+ export class ScriptLoader {
8
+ constructor(consentManager) {
9
+ this.manager = consentManager
10
+ this.loadedScripts = new Map()
11
+ this.blockedScripts = []
12
+ this._observer = null
13
+
14
+ this._init()
15
+ }
16
+
17
+ _init() {
18
+ if (typeof window === 'undefined') return
19
+
20
+ // Scan existing scripts on page
21
+ this._scanBlockedScripts()
22
+
23
+ // Watch for new scripts added to DOM
24
+ this._observeDOM()
25
+
26
+ // Listen for consent changes
27
+ this.manager.on('consentChanged', ({ categories }) => {
28
+ this._onConsentChanged(categories)
29
+ })
30
+
31
+ // Check if consent already exists
32
+ const consent = this.manager.getConsent()
33
+ if (consent?.hasConsented) {
34
+ this._onConsentChanged(consent.categories)
35
+ }
36
+
37
+ this.manager._debug('ScriptLoader: initialized')
38
+ }
39
+
40
+ // --- Public API ---
41
+
42
+ /**
43
+ * Register a script to load when category consent is granted
44
+ * @param {string} category - Cookie category id
45
+ * @param {string} src - Script URL
46
+ * @param {Object} options - Script attributes
47
+ */
48
+ register(category, src, options = {}) {
49
+ const script = { category, src, options, loaded: false }
50
+ this.blockedScripts.push(script)
51
+
52
+ // Check if already consented
53
+ if (this.manager.hasCategoryConsent(category)) {
54
+ this._loadScript(script)
55
+ }
56
+
57
+ this.manager._debug('ScriptLoader: registered', { category, src })
58
+ return this
59
+ }
60
+
61
+ /**
62
+ * Load a script immediately (bypasses consent check)
63
+ */
64
+ loadImmediate(src, options = {}) {
65
+ return this._injectScript(src, options)
66
+ }
67
+
68
+ /**
69
+ * Remove all scripts for a category
70
+ */
71
+ removeCategory(category) {
72
+ this.loadedScripts.forEach((info, element) => {
73
+ if (info.category === category) {
74
+ element.remove()
75
+ this.loadedScripts.delete(element)
76
+ this.manager._debug('ScriptLoader: removed script', info.src)
77
+ }
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Get all registered scripts and their status
83
+ */
84
+ getStatus() {
85
+ return this.blockedScripts.map(s => ({
86
+ category: s.category,
87
+ src: s.src,
88
+ loaded: s.loaded
89
+ }))
90
+ }
91
+
92
+ // --- Internal ---
93
+
94
+ _scanBlockedScripts() {
95
+ if (typeof document === 'undefined') return
96
+
97
+ const scripts = document.querySelectorAll('script[data-cookiecategory]')
98
+ scripts.forEach(script => {
99
+ const category = script.getAttribute('data-cookiecategory')
100
+ const src = script.getAttribute('data-src') || script.src
101
+
102
+ if (script.type === 'text/plain') {
103
+ // Blocked script — store for later activation
104
+ this.blockedScripts.push({
105
+ category,
106
+ src,
107
+ options: { inline: !src, content: script.textContent },
108
+ element: script,
109
+ loaded: false
110
+ })
111
+ this.manager._debug('ScriptLoader: found blocked script', { category, src })
112
+ }
113
+ })
114
+ }
115
+
116
+ _observeDOM() {
117
+ if (typeof MutationObserver === 'undefined') return
118
+
119
+ this._observer = new MutationObserver((mutations) => {
120
+ mutations.forEach(mutation => {
121
+ mutation.addedNodes.forEach(node => {
122
+ if (node.tagName === 'SCRIPT' && node.getAttribute('data-cookiecategory')) {
123
+ const category = node.getAttribute('data-cookiecategory')
124
+ if (node.type === 'text/plain') {
125
+ this.blockedScripts.push({
126
+ category,
127
+ src: node.getAttribute('data-src') || node.src,
128
+ options: { inline: !node.src, content: node.textContent },
129
+ element: node,
130
+ loaded: false
131
+ })
132
+ }
133
+ }
134
+ })
135
+ })
136
+ })
137
+
138
+ this._observer.observe(document.documentElement, {
139
+ childList: true,
140
+ subtree: true
141
+ })
142
+ }
143
+
144
+ _onConsentChanged(categories) {
145
+ Object.entries(categories).forEach(([catId, granted]) => {
146
+ if (granted) {
147
+ // Load all scripts for this category
148
+ this.blockedScripts
149
+ .filter(s => s.category === catId && !s.loaded)
150
+ .forEach(s => this._loadScript(s))
151
+ } else {
152
+ // Remove scripts for rejected category
153
+ this.removeCategory(catId)
154
+ }
155
+ })
156
+ }
157
+
158
+ _loadScript(scriptInfo) {
159
+ if (scriptInfo.loaded) return
160
+
161
+ if (scriptInfo.options?.inline && scriptInfo.options?.content) {
162
+ // Inline script
163
+ this._injectInlineScript(scriptInfo)
164
+ } else if (scriptInfo.src) {
165
+ // External script
166
+ this._injectScript(scriptInfo.src, {
167
+ ...scriptInfo.options,
168
+ category: scriptInfo.category
169
+ })
170
+ }
171
+
172
+ scriptInfo.loaded = true
173
+ this.manager._debug('ScriptLoader: loaded', scriptInfo.src || 'inline')
174
+ }
175
+
176
+ _injectScript(src, options = {}) {
177
+ return new Promise((resolve, reject) => {
178
+ const script = document.createElement('script')
179
+ script.src = src
180
+ script.async = options.async !== false
181
+
182
+ if (options.id) script.id = options.id
183
+ if (options.defer) script.defer = true
184
+ if (options.crossOrigin) script.crossOrigin = options.crossOrigin
185
+
186
+ script.onload = () => {
187
+ this.manager._debug('ScriptLoader: script loaded', src)
188
+ resolve(script)
189
+ }
190
+ script.onerror = (err) => {
191
+ this.manager._debug('ScriptLoader: script error', src, err)
192
+ reject(err)
193
+ }
194
+
195
+ document.head.appendChild(script)
196
+
197
+ this.loadedScripts.set(script, {
198
+ src,
199
+ category: options.category,
200
+ loaded: true
201
+ })
202
+ })
203
+ }
204
+
205
+ _injectInlineScript(scriptInfo) {
206
+ const script = document.createElement('script')
207
+ script.textContent = scriptInfo.options.content
208
+ document.head.appendChild(script)
209
+
210
+ // Remove original blocked script
211
+ if (scriptInfo.element) {
212
+ scriptInfo.element.remove()
213
+ }
214
+
215
+ this.loadedScripts.set(script, {
216
+ src: 'inline',
217
+ category: scriptInfo.category,
218
+ loaded: true
219
+ })
220
+ }
221
+
222
+ destroy() {
223
+ if (this._observer) {
224
+ this._observer.disconnect()
225
+ }
226
+ this.loadedScripts.clear()
227
+ this.blockedScripts = []
228
+ }
229
+ }