@el7ven/cookie-kit 0.3.1 → 0.3.2

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,234 @@
1
+ /**
2
+ * Plugin Manager
3
+ * @module PluginManager
4
+ *
5
+ * Manages CMP plugins lifecycle and events
6
+ */
7
+
8
+ export class PluginManager {
9
+ constructor(cmpInstance) {
10
+ this.cmp = cmpInstance
11
+ this.plugins = new Map() // name -> plugin instance
12
+ this.hooks = {
13
+ beforeInit: [],
14
+ afterInit: [],
15
+ beforeBannerShow: [],
16
+ afterBannerShow: [],
17
+ beforeConsentSave: [],
18
+ afterConsentSave: []
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Register plugin
24
+ * @param {CMPPlugin} plugin - Plugin instance
25
+ * @returns {boolean}
26
+ */
27
+ register(plugin) {
28
+ if (!plugin || !plugin.name) return false
29
+ if (this.plugins.has(plugin.name)) return false
30
+
31
+ // Initialize plugin
32
+ plugin.init(this.cmp)
33
+
34
+ // Store plugin
35
+ this.plugins.set(plugin.name, plugin)
36
+
37
+ // Register lifecycle hooks
38
+ this._registerHooks(plugin)
39
+
40
+ // Setup event listeners
41
+ this._setupEventListeners(plugin)
42
+
43
+ this._debug(`Plugin "${plugin.name}" registered`)
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * Unregister plugin
49
+ * @param {string} pluginName
50
+ * @returns {boolean}
51
+ */
52
+ unregister(pluginName) {
53
+ const plugin = this.plugins.get(pluginName)
54
+ if (!plugin) {
55
+ return false
56
+ }
57
+
58
+ // Cleanup plugin
59
+ plugin.destroy()
60
+
61
+ // Remove from hooks
62
+ this._unregisterHooks(plugin)
63
+
64
+ // Remove plugin
65
+ this.plugins.delete(pluginName)
66
+
67
+ this._debug(`Plugin "${pluginName}" unregistered`)
68
+ return true
69
+ }
70
+
71
+ /**
72
+ * Get plugin by name
73
+ * @param {string} pluginName
74
+ * @returns {CMPPlugin|null}
75
+ */
76
+ get(pluginName) {
77
+ return this.plugins.get(pluginName) || null
78
+ }
79
+
80
+ /**
81
+ * Get all plugins
82
+ * @returns {Array}
83
+ */
84
+ getAll() {
85
+ return Array.from(this.plugins.values())
86
+ }
87
+
88
+ /**
89
+ * Check if plugin is registered
90
+ * @param {string} pluginName
91
+ * @returns {boolean}
92
+ */
93
+ has(pluginName) {
94
+ return this.plugins.has(pluginName)
95
+ }
96
+
97
+ /**
98
+ * Execute lifecycle hook
99
+ * @param {string} hookName
100
+ * @param {*} data
101
+ * @returns {*}
102
+ */
103
+ async executeHook(hookName, data = null) {
104
+ const hooks = this.hooks[hookName] || []
105
+
106
+ let result = data
107
+
108
+ for (const hook of hooks) {
109
+ try {
110
+ const hookResult = await hook(result)
111
+ // For beforeConsentSave, allow modification
112
+ if (hookName === 'beforeConsentSave' && hookResult !== undefined) {
113
+ result = hookResult
114
+ }
115
+ } catch (error) {
116
+ // Silent error
117
+ }
118
+ }
119
+
120
+ return result
121
+ }
122
+
123
+ /**
124
+ * Register plugin hooks
125
+ * @private
126
+ */
127
+ _registerHooks(plugin) {
128
+ // beforeInit
129
+ if (typeof plugin.beforeInit === 'function') {
130
+ this.hooks.beforeInit.push(() => plugin.beforeInit())
131
+ }
132
+
133
+ // afterInit
134
+ if (typeof plugin.afterInit === 'function') {
135
+ this.hooks.afterInit.push(() => plugin.afterInit())
136
+ }
137
+
138
+ // beforeBannerShow
139
+ if (typeof plugin.beforeBannerShow === 'function') {
140
+ this.hooks.beforeBannerShow.push(() => plugin.beforeBannerShow())
141
+ }
142
+
143
+ // afterBannerShow
144
+ if (typeof plugin.afterBannerShow === 'function') {
145
+ this.hooks.afterBannerShow.push(() => plugin.afterBannerShow())
146
+ }
147
+
148
+ // beforeConsentSave
149
+ if (typeof plugin.beforeConsentSave === 'function') {
150
+ this.hooks.beforeConsentSave.push((consent) => plugin.beforeConsentSave(consent))
151
+ }
152
+
153
+ // afterConsentSave
154
+ if (typeof plugin.afterConsentSave === 'function') {
155
+ this.hooks.afterConsentSave.push((consent) => plugin.afterConsentSave(consent))
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Unregister plugin hooks
161
+ * @private
162
+ */
163
+ _unregisterHooks(plugin) {
164
+ Object.keys(this.hooks).forEach(hookName => {
165
+ this.hooks[hookName] = this.hooks[hookName].filter(hook => {
166
+ // Remove hooks that belong to this plugin
167
+ return hook.toString().indexOf(plugin.name) === -1
168
+ })
169
+ })
170
+ }
171
+
172
+ /**
173
+ * Setup event listeners for plugin
174
+ * @private
175
+ */
176
+ _setupEventListeners(plugin) {
177
+ // Consent changed
178
+ if (typeof plugin.onConsentChange === 'function') {
179
+ this.cmp.on('consentChanged', (data) => plugin.onConsentChange(data))
180
+ }
181
+
182
+ // Consent accepted
183
+ if (typeof plugin.onConsentAccepted === 'function') {
184
+ this.cmp.on('consentAccepted', (data) => plugin.onConsentAccepted(data))
185
+ }
186
+
187
+ // Consent rejected
188
+ if (typeof plugin.onConsentRejected === 'function') {
189
+ this.cmp.on('consentRejected', (data) => plugin.onConsentRejected(data))
190
+ }
191
+
192
+ // Consent expired
193
+ if (typeof plugin.onConsentExpired === 'function') {
194
+ this.cmp.on('consentExpired', () => plugin.onConsentExpired())
195
+ }
196
+
197
+ // Script loaded
198
+ if (typeof plugin.onScriptLoaded === 'function') {
199
+ if (typeof window !== 'undefined') {
200
+ window.addEventListener('cookie-consent:script-loaded', (e) => {
201
+ plugin.onScriptLoaded(e.detail)
202
+ })
203
+ }
204
+ }
205
+
206
+ // Tracker detected
207
+ if (typeof plugin.onTrackerDetected === 'function') {
208
+ if (typeof window !== 'undefined') {
209
+ window.addEventListener('cookie-consent:tracker-detected', (e) => {
210
+ plugin.onTrackerDetected(e.detail)
211
+ })
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Get plugin stats
218
+ * @returns {Object}
219
+ */
220
+ getStats() {
221
+ return {
222
+ total: this.plugins.size,
223
+ plugins: this.getAll().map(p => ({
224
+ name: p.name,
225
+ version: p.version,
226
+ initialized: p.isInitialized
227
+ }))
228
+ }
229
+ }
230
+
231
+ _debug(...args) {
232
+ // Debug disabled
233
+ }
234
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Plugin System
3
+ * @module plugins
4
+ */
5
+
6
+ export { CMPPlugin } from './CMPPlugin.js'
7
+ export { PluginManager } from './PluginManager.js'
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Google Consent Mode v2 Provider
3
+ * Required for Google Ads, GA4, and GTM compliance
4
+ * @module GoogleConsentModeProvider
5
+ * @see https://developers.google.com/tag-platform/security/guides/consent
6
+ */
7
+
8
+ export class GoogleConsentModeProvider {
9
+ constructor(consentManager, config = {}) {
10
+ this.consentManager = consentManager
11
+ this.config = {
12
+ // Category mapping: CMP category → Google consent type
13
+ categoryMapping: {
14
+ analytics: ['analytics_storage'],
15
+ marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
16
+ preferences: ['functionality_storage', 'personalization_storage'],
17
+ ...config.categoryMapping
18
+ },
19
+ // Wait for GTM/GA to load
20
+ waitForGtag: true,
21
+ // Region-specific settings
22
+ regionSettings: {
23
+ EU: { wait_for_update: 500 },
24
+ UK: { wait_for_update: 500 },
25
+ US: { wait_for_update: 0 },
26
+ ...config.regionSettings
27
+ },
28
+ // Enable URL passthrough for conversion tracking
29
+ urlPassthrough: true,
30
+ // Enable ads data redaction
31
+ adsDataRedaction: true,
32
+ ...config
33
+ }
34
+
35
+ this.isInitialized = false
36
+ this.lastConsentHash = null // Prevent duplicate updates
37
+ this._debug('GoogleConsentModeProvider created')
38
+ }
39
+
40
+ /**
41
+ * Initialize Google Consent Mode
42
+ * MUST be called before GA/GTM loads
43
+ */
44
+ initialize() {
45
+ if (this.isInitialized) {
46
+ this._debug('Already initialized')
47
+ return
48
+ }
49
+
50
+ // Bootstrap gtag (CRITICAL: before GA/GTM loads)
51
+ this._bootstrapGtag()
52
+
53
+ // Set default consent state (DENIED for all)
54
+ this.setDefaultConsent()
55
+
56
+ // Listen for consent changes
57
+ this.consentManager.on('consentChanged', ({ consent, categories }) => {
58
+ this.updateConsent(categories, consent.region)
59
+ })
60
+
61
+ // Apply existing consent if available
62
+ const existingConsent = this.consentManager.getConsent()
63
+ if (existingConsent) {
64
+ this.updateConsent(existingConsent.categories, existingConsent.region)
65
+ }
66
+
67
+ this.isInitialized = true
68
+ this._debug('Google Consent Mode v2 initialized')
69
+ this._emitEvent('gcm:initialized', {})
70
+ }
71
+
72
+ /**
73
+ * Set default consent state (all DENIED)
74
+ * Called before GA/GTM loads
75
+ */
76
+ setDefaultConsent() {
77
+ const region = this.consentManager.getConsent()?.region || 'ROW'
78
+ const regionConfig = this.config.regionSettings[region] || {}
79
+
80
+ const defaultConsent = {
81
+ ad_storage: 'denied',
82
+ analytics_storage: 'denied',
83
+ ad_user_data: 'denied',
84
+ ad_personalization: 'denied',
85
+ functionality_storage: 'denied',
86
+ personalization_storage: 'denied',
87
+ security_storage: 'granted', // Always granted for security
88
+
89
+ // CRITICAL: wait_for_update gives CMP time to update consent
90
+ // Prevents GA from firing before consent update
91
+ wait_for_update: regionConfig.wait_for_update !== undefined
92
+ ? regionConfig.wait_for_update
93
+ : 500, // Default 500ms for all regions
94
+
95
+ ...regionConfig
96
+ }
97
+
98
+ // URL passthrough for conversion tracking without cookies
99
+ if (this.config.urlPassthrough) {
100
+ defaultConsent.url_passthrough = true
101
+ }
102
+
103
+ // Redact ads data until consent
104
+ if (this.config.adsDataRedaction) {
105
+ defaultConsent.ads_data_redaction = true
106
+ }
107
+
108
+ this._debug('Setting default consent:', defaultConsent)
109
+
110
+ window.gtag('consent', 'default', defaultConsent)
111
+
112
+ this._emitEvent('gcm:default-set', { consent: defaultConsent })
113
+ }
114
+
115
+ /**
116
+ * Update consent based on user choice
117
+ * @param {Object} categories - Consent categories
118
+ * @param {string} region - User region
119
+ */
120
+ updateConsent(categories, region = 'ROW') {
121
+ const consentUpdate = this._mapCategoriesToGoogleConsent(categories)
122
+
123
+ // Deduplication: prevent duplicate updates
124
+ const consentHash = this._hashConsent(consentUpdate)
125
+ if (this.lastConsentHash === consentHash) {
126
+ this._debug('Consent unchanged, skipping update')
127
+ return
128
+ }
129
+ this.lastConsentHash = consentHash
130
+
131
+ this._debug('Updating consent:', consentUpdate)
132
+
133
+ window.gtag('consent', 'update', consentUpdate)
134
+
135
+ this._emitEvent('gcm:updated', {
136
+ consent: consentUpdate,
137
+ categories,
138
+ region
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Map CMP categories to Google consent types
144
+ * @param {Object} categories - CMP categories
145
+ * @returns {Object} Google consent object
146
+ * @private
147
+ */
148
+ _mapCategoriesToGoogleConsent(categories) {
149
+ const googleConsent = {}
150
+
151
+ // Map each CMP category to Google consent types
152
+ Object.entries(this.config.categoryMapping).forEach(([cmpCategory, googleTypes]) => {
153
+ const isGranted = categories[cmpCategory] === true
154
+ const consentValue = isGranted ? 'granted' : 'denied'
155
+
156
+ googleTypes.forEach(googleType => {
157
+ googleConsent[googleType] = consentValue
158
+ })
159
+ })
160
+
161
+ // Security storage always granted
162
+ googleConsent.security_storage = 'granted'
163
+
164
+ return googleConsent
165
+ }
166
+
167
+ /**
168
+ * Get current consent state
169
+ * @returns {Object}
170
+ */
171
+ getConsentState() {
172
+ const consent = this.consentManager.getConsent()
173
+ if (!consent) {
174
+ return {
175
+ ad_storage: 'denied',
176
+ analytics_storage: 'denied',
177
+ ad_user_data: 'denied',
178
+ ad_personalization: 'denied'
179
+ }
180
+ }
181
+
182
+ return this._mapCategoriesToGoogleConsent(consent.categories)
183
+ }
184
+
185
+ /**
186
+ * Check if specific Google consent type is granted
187
+ * @param {string} consentType - Google consent type
188
+ * @returns {boolean}
189
+ */
190
+ isGranted(consentType) {
191
+ const state = this.getConsentState()
192
+ return state[consentType] === 'granted'
193
+ }
194
+
195
+ /**
196
+ * Manually trigger consent update
197
+ * Useful for testing or manual control
198
+ */
199
+ refresh() {
200
+ const consent = this.consentManager.getConsent()
201
+ if (consent) {
202
+ this.updateConsent(consent.categories, consent.region)
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Get category mapping configuration
208
+ * @returns {Object}
209
+ */
210
+ getCategoryMapping() {
211
+ return { ...this.config.categoryMapping }
212
+ }
213
+
214
+ /**
215
+ * Update category mapping
216
+ * @param {Object} mapping - New mapping
217
+ */
218
+ setCategoryMapping(mapping) {
219
+ this.config.categoryMapping = { ...this.config.categoryMapping, ...mapping }
220
+ this.refresh()
221
+ }
222
+
223
+ // --- Internal helpers ---
224
+
225
+ _emitEvent(eventName, detail) {
226
+ if (typeof window !== 'undefined') {
227
+ window.dispatchEvent(new CustomEvent(`cookie-consent:${eventName}`, { detail }))
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Bootstrap gtag function
233
+ * MUST run before GA/GTM loads
234
+ * @private
235
+ */
236
+ _bootstrapGtag() {
237
+ // Initialize dataLayer
238
+ window.dataLayer = window.dataLayer || []
239
+
240
+ // Create gtag function if not exists
241
+ if (!window.gtag) {
242
+ window.gtag = function() {
243
+ window.dataLayer.push(arguments)
244
+ }
245
+
246
+ // Mark gtag as bootstrapped by CMP
247
+ window.gtag.l = Date.now()
248
+ }
249
+
250
+ this._debug('gtag bootstrapped')
251
+ }
252
+
253
+ /**
254
+ * Hash consent object for deduplication
255
+ * @private
256
+ */
257
+ _hashConsent(consent) {
258
+ return JSON.stringify(consent)
259
+ }
260
+
261
+ _debug(...args) {
262
+ if (this.consentManager.config.debug) {
263
+ console.log('[GoogleConsentMode]', ...args)
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Helper function to initialize Google Consent Mode
270
+ * @param {ConsentManager} consentManager - Consent manager instance
271
+ * @param {Object} config - Configuration
272
+ * @returns {GoogleConsentModeProvider}
273
+ */
274
+ export function initGoogleConsentMode(consentManager, config = {}) {
275
+ const provider = new GoogleConsentModeProvider(consentManager, config)
276
+ provider.initialize()
277
+ return provider
278
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Consent Providers — integrations with external systems
3
+ * @module providers
4
+ */
5
+
6
+ export { GoogleConsentModeProvider, initGoogleConsentMode } from './GoogleConsentModeProvider.js'