@dpgradio/creative 7.6.0-beta.1 → 8.0.0

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.
package/README.md CHANGED
@@ -30,9 +30,9 @@ Example:
30
30
  @import "@dpgradio/creative/styles/fonts/qmusic";
31
31
 
32
32
  body, html {
33
- background-color: rgb(var(--q-teal));
33
+ background-color: rgb(var(--q-green));
34
34
  color: rgb(var(--q-grey) / 0.8);
35
- font-family: 'QMarkMyWords';
35
+ font-family: 'BlackBones';
36
36
  }
37
37
  ```
38
38
 
@@ -61,11 +61,11 @@ await configuration.retrieveConfigForDetectedStation(appId) // Default, by hostn
61
61
  // Or, if you want to retrieve the config by hostnames only.
62
62
  await configuration.retrieveConfigByHostname(appId)
63
63
  // Or, if you want to retrieve the config for a specific station:
64
- await configuration.retrieveConfigByStation(stationId, appId)
64
+ await configuration.retrieveConfigForStation(stationId, appId)
65
65
 
66
66
  // By default the first station ID (stationIdA) is used as the current station of the configuration.
67
67
  // If you want to use a different station, you can do so by calling setStation:
68
- await configuration.retrieveConfigByStations([stationIdA, stationIdB, stationIdC], appId)
68
+ await configuration.retrieveConfigForStations([stationIdA, stationIdB, stationIdC], appId)
69
69
  configuration.setStation(stationIdC)
70
70
  ```
71
71
  You can change the station later on at any time without having to retrieve the config again.
@@ -152,6 +152,18 @@ dataLayer.pushVirtualPageView(brand) // brand is not required when the config is
152
152
  dataLayer.pushEvent(event, data)
153
153
  ```
154
154
 
155
+ With Mixpanel
156
+ ```js
157
+ import { mixpanel } from '@dpgradio/creative'
158
+ import Mixpanel from 'mixpanel-browser'
159
+
160
+ mixpanel.initialize(Mixpanel, {
161
+ mixpanelId: 'MIXPANEL ID',
162
+ })
163
+
164
+ mixpanel.trackEvent(event, data)
165
+ ```
166
+
155
167
  ## API
156
168
 
157
169
  With an initialized [configuration](#config):
@@ -354,6 +366,25 @@ const image = new ImageGeneratorProperties('https://static.qmusic.be/acties/joe-
354
366
  (await shareable.generateUsingImage(image)).openWhatsappUrl()
355
367
  ```
356
368
 
369
+ ## CSP
370
+
371
+ Sometimes it's necessary to set strict Content Security Policy (CSP) headers. When that's the case a nicety of some of the methods in this package is that they support a random nonce to be passed in so extra sources (GTM, Privacy gate) don't need to be explicitly whitelisted (since they might be unknown at the time of development). Our datalayer and privacy gate initializers have an optional `nonce` parameter that can be passed in.
372
+
373
+ ```js
374
+ import { privacy, dataLayer } from '@dpgradio/creative'
375
+
376
+ privacy.initialize(privacyManagerId, websiteUrl, cmpCname, { nonce: "abc1234" })
377
+ dataLayer.initialize({ nonce: "abc1234" })
378
+ ```
379
+
380
+ Which then allows you to whitelist that nonce in your CSP header
381
+
382
+ ```
383
+ Content-Security-Policy: script-src 'nonce-abc1234'
384
+ ```
385
+
386
+ **!! Take note that this nonce need to be random on every request, as else this security is useless !!**
387
+
357
388
  ## Utilities
358
389
 
359
390
  This package provides a number of utility functions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpgradio/creative",
3
- "version": "7.6.0-beta.1",
3
+ "version": "8.0.0",
4
4
  "description": "Support package for standalone Javascript applications",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
13
- "url": "https://github.com/dpgradio/creative.git"
13
+ "url": "git+https://github.com/dpgradio/creative.git"
14
14
  },
15
15
  "keywords": [
16
16
  "qmusic"
@@ -5,13 +5,37 @@ import loadScript from '../utils/loadScript.js'
5
5
 
6
6
  class DataLayer {
7
7
  constructor() {
8
+ if (typeof window === 'undefined') {
9
+ return
10
+ }
11
+
8
12
  window.dataLayer = window.dataLayer || []
9
13
  this.campaignDetails = {}
10
14
  this.userInformation = {}
11
15
  }
12
16
 
13
- initialize(gtmId = 'GTM-TW99VZN') {
14
- loadScript(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`)
17
+ /**
18
+ * @param {Object} parameters The parameters object.
19
+ * @param {string} [parameters.gtmId] The GTM ID - Defaults to our default GTM tag
20
+ * @param {string} [parameters.nonce] The nonce value for the script tag (Used for CSP).
21
+ */
22
+ initialize(parameters) {
23
+ // Backwards compatability
24
+ if (typeof parameters === 'string') {
25
+ parameters = { gtmId: parameters }
26
+
27
+ // Deprecation warning in development
28
+ if (process.env.NODE_ENV === 'development') {
29
+ console.warn(
30
+ '@dpgmedia/creative',
31
+ 'DEPRECATION WARNING: new Datalayer(gtmId) is deprecated, please use new Datalayer({ gtmId }) instead.'
32
+ )
33
+ }
34
+ }
35
+
36
+ const { gtmId = 'GTM-TW99VZN', nonce } = parameters || {}
37
+
38
+ loadScript(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`, { nonce })
15
39
 
16
40
  this.pushGtmStart()
17
41
  this.pushUserWhenAuthenticated()
@@ -1,4 +1,4 @@
1
- import { privacy } from '../privacy/privacy'
1
+ import privacy from '../privacy/privacy.js'
2
2
 
3
3
  class Mixpanel {
4
4
  constructor() {
@@ -7,11 +7,11 @@ class Mixpanel {
7
7
  this.mixpanel = null
8
8
  }
9
9
 
10
- initialize(Mixpanel, { mixpanelId, track_pageview = true }) {
10
+ initialize(Mixpanel, { mixpanelId, trackPageview = true }) {
11
11
  this.mixpanel = Mixpanel
12
12
  privacy.push('analytics', () => {
13
13
  this.mixpanel.init(mixpanelId, {
14
- track_pageview,
14
+ track_pageview: trackPageview,
15
15
  persistence: 'localStorage',
16
16
  api_host: 'https://api-eu.mixpanel.com',
17
17
  })
@@ -20,10 +20,12 @@ class Mixpanel {
20
20
  const { eventName, properties } = this.trackBacklog.shift()
21
21
  this.mixpanel.track(eventName, properties)
22
22
  }
23
+
24
+ this.trackingEvents = true
23
25
  })
24
26
  }
25
27
 
26
- trackEvent(eventName, properties) {
28
+ pushEvent(eventName, properties) {
27
29
  if (this.trackingEvents) {
28
30
  this.mixpanel.track(eventName, properties)
29
31
  } else {
package/src/app/hybrid.js CHANGED
@@ -17,6 +17,10 @@ class Hybrid {
17
17
  }
18
18
 
19
19
  constructor() {
20
+ if (typeof window === 'undefined') {
21
+ return
22
+ }
23
+
20
24
  // Hook this on window so it can be required in multiple packs
21
25
  window._hybridEventSubscriptions = window._hybridEventSubscriptions || {}
22
26
 
@@ -18,10 +18,10 @@ class Configuration {
18
18
  /**
19
19
  * Retrieve the configuration for the current hostname or present query parameter and the given app (optional).
20
20
  */
21
- async retrieveConfigForDetectedStation(appId = null) {
21
+ async retrieveConfigForDetectedStation(appId = null, { parameterName = 'stationId' } = {}) {
22
22
  const parameters = new URLSearchParams(window.location.search)
23
- if (parameters.has('stationId')) {
24
- return this.retrieveConfigForStation(parameters.get('stationId'), appId)
23
+ if (parameters.has(parameterName)) {
24
+ return this.retrieveConfigForStation(parameters.get(parameterName), appId)
25
25
  }
26
26
  return this.retrieveConfigByHostname(appId)
27
27
  }
@@ -3,6 +3,10 @@ import loadScript from '../utils/loadScript.js'
3
3
 
4
4
  class Privacy {
5
5
  constructor() {
6
+ if (typeof window === 'undefined') {
7
+ return
8
+ }
9
+
6
10
  window._privacy = window._privacy || []
7
11
 
8
12
  this.consent = undefined
@@ -15,11 +19,14 @@ class Privacy {
15
19
  * @param {string} privacyManagerId e.g. '148844'
16
20
  * @param {string} websiteUrl e.g. 'https://qmusic.nl'
17
21
  * @param {string} cmpCname e.g. 'https://cmp.qmusic.nl'
22
+ * @param {Object} options Additional options.
23
+ * @param {string} [options.nonce] The nonce value for the script tag (Used for CSP).
18
24
  */
19
25
  initialize(
20
26
  privacyManagerId = config('privacy_manager_id'),
21
27
  websiteUrl = config('website_url'),
22
- cmpCname = config('cmp_cname')
28
+ cmpCname = config('cmp_cname'),
29
+ { nonce = undefined } = {}
23
30
  ) {
24
31
  window.cmpProperties = {
25
32
  privacyManagerId,
@@ -28,7 +35,7 @@ class Privacy {
28
35
  language: 'nl',
29
36
  }
30
37
 
31
- loadScript('https://myprivacy-static.dpgmedia.net/consent.js')
38
+ loadScript('https://myprivacy-static.dpgmedia.net/consent.js', { nonce })
32
39
 
33
40
  this.requestConsentInformation()
34
41
  }
@@ -49,10 +56,39 @@ class Privacy {
49
56
  this.consentSubscribers.forEach((subscriber) => subscriber(null))
50
57
  }, timeoutTime)
51
58
 
52
- this.pushFunctional(() => {
53
- clearTimeout(timeout)
54
- this.consent = new Consent(window._privacy.consentString, window._privacy.purposesProvider.enabledPurposes)
55
- this.consentSubscribers.forEach((subscriber) => subscriber(this.consent))
59
+ this.pushConsentGiven((consentData) => {
60
+ let updatedConsentData = {
61
+ consentString: consentData.tcString,
62
+ purposes: new Set(consentData.dpgConsentString.split('|')),
63
+ }
64
+ const iabPurposePromises = []
65
+
66
+ // We have 10 IAB purposes, so we create 10 promises that resolve with the purpose number or null if the purpose is not given.
67
+ for (let i = 1; i < 11; i++) {
68
+ iabPurposePromises.push(
69
+ new Promise((resolve) => {
70
+ this.push(
71
+ i,
72
+ () => resolve(i), // Resolve with the purpose number
73
+ () => resolve(null) // Resolve without a value, indicating that the purpose is not given
74
+ )
75
+ })
76
+ )
77
+ }
78
+
79
+ Promise.all(iabPurposePromises).then((purposeConsents) => {
80
+ updatedConsentData = purposeConsents.reduce((acc, purpose) => {
81
+ if (purpose) {
82
+ acc.purposes.add(purpose)
83
+ }
84
+ return acc
85
+ }, updatedConsentData)
86
+
87
+ this.consent = new Consent(updatedConsentData)
88
+ this.consentSubscribers.forEach((subscriber) => subscriber(this.consent))
89
+
90
+ clearTimeout(timeout)
91
+ })
56
92
  })
57
93
  }
58
94
 
@@ -75,8 +111,12 @@ class Privacy {
75
111
  })
76
112
  }
77
113
 
78
- push(type, callback) {
79
- window._privacy.push([type, callback])
114
+ push(type, successCallback, failCallback = () => {}) {
115
+ window._privacy.push([type, successCallback, failCallback])
116
+ }
117
+
118
+ pushConsentGiven(callback) {
119
+ this.push('consentgiven', callback)
80
120
  }
81
121
 
82
122
  pushFunctional(callback) {
@@ -84,16 +124,14 @@ class Privacy {
84
124
  }
85
125
  }
86
126
 
87
- const targetedAdvertisingPurposes = [3, 4]
88
-
89
127
  class Consent {
90
- constructor(consentString, purposes) {
91
- this.consentString = consentString
92
- this.purposes = purposes
128
+ constructor(consentData) {
129
+ this.consentString = consentData.consentString
130
+ this.purposes = consentData.purposes
93
131
  }
94
132
 
95
133
  allowsTargetedAdvertising() {
96
- return targetedAdvertisingPurposes.every((purpose) => this.purposes.has(purpose.toString()))
134
+ return this.purposes.has('targeted_advertising')
97
135
  }
98
136
  }
99
137
 
@@ -1,4 +1,4 @@
1
- import openLink from '../utils/openLink.js'
1
+ import openExternalUrl from '../utils/openExternalUrl.js'
2
2
 
3
3
  export default class ShareResult {
4
4
  constructor(shareable, { url, image }) {
@@ -20,14 +20,14 @@ export default class ShareResult {
20
20
  }
21
21
 
22
22
  openFacebookUrl() {
23
- openLink(this.facebookUrl())
23
+ openExternalUrl(this.facebookUrl())
24
24
  }
25
25
 
26
26
  openWhatsappUrl() {
27
- openLink(this.whatsappUrl())
27
+ openExternalUrl(this.whatsappUrl())
28
28
  }
29
29
 
30
30
  openInstagramUrl() {
31
- openLink(this.instagramUrl())
31
+ openExternalUrl(this.instagramUrl())
32
32
  }
33
33
  }
@@ -1,5 +1,3 @@
1
- // eslint-disable-next-line import/no-unresolved -- TODO: Needs a rewrite to fetch
2
- import axios from 'axios'
3
1
  import ShareResult from './ShareResult.js'
4
2
 
5
3
  export default class Shareable {
@@ -36,21 +34,21 @@ export default class Shareable {
36
34
  return this
37
35
  }
38
36
 
39
- generateUsingImage(imageGeneratorProperties) {
40
- return new Promise((resolve, reject) => {
41
- axios({
42
- url: 'https://dba1du5ckc.execute-api.eu-west-3.amazonaws.com/prod/',
43
- method: 'post',
44
- data: {
45
- title: this.title,
46
- description: this.description,
47
- image_generator: imageGeneratorProperties.toJson(),
48
- redirect_url: this.redirectUrl,
49
- domain: this.domain,
50
- },
51
- })
52
- .then((response) => resolve(new ShareResult(this, response.data)))
53
- .catch(reject)
37
+ async generateUsingImage(imageGeneratorProperties) {
38
+ const response = await fetch('https://dba1du5ckc.execute-api.eu-west-3.amazonaws.com/prod/', {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ },
43
+ body: JSON.stringify({
44
+ title: this.title,
45
+ description: this.description,
46
+ image_generator: imageGeneratorProperties.toJson(),
47
+ redirect_url: this.redirectUrl,
48
+ domain: this.domain,
49
+ }),
54
50
  })
51
+ const data = await response.json()
52
+ return new ShareResult(this, data)
55
53
  }
56
54
  }
@@ -1,12 +1,15 @@
1
1
  // From: https://github.com/medialaan/radio-radioplayer-frontend/blob/develop/src/general/utils/loadScript.js
2
2
  // TODO: Refactor
3
3
 
4
- export default function loadScript(url, { timeout = undefined } = {}) {
4
+ export default function loadScript(url, { timeout = undefined, nonce = undefined } = {}) {
5
5
  return new Promise((resolve, reject) => {
6
6
  let script = document.createElement('script')
7
7
  const firstScript = document.getElementsByTagName('script')[0]
8
8
  script.async = true
9
9
  script.defer = true
10
+ if (nonce) {
11
+ script.nonce = nonce
12
+ }
10
13
 
11
14
  const loadScriptTimeout = timeout ? setTimeout(() => reject(`Loading script [${url}] blocked.`), timeout) : null
12
15
 
@@ -1,17 +1,13 @@
1
1
  :root {
2
2
  --q-red: 237 54 36;
3
- --q-grey: 61 61 61;
4
- --q-teal: 0 191 179;
5
- --q-yellow: 249 212 35;
6
- --q-purple: 210 110 230;
7
- --q-green: 151 215 0;
8
- --q-blue: 2 134 255;
3
+ --q-red-dark: 217 46 32;
9
4
 
10
- --q-red-dark: 205 34 17;
11
- --q-blue-dark: 9 0 134;
5
+ --q-grey: 61 61 61;
6
+ --q-grey-light: 230 230 230;
7
+ --q-grey-dark: 43 43 43;
12
8
 
13
- --q-blue-light: 91 194 231;
14
- --q-teal-light: 184 229 209;
15
- --q-grey-light: 249 249 250;
16
- --q-purple-light: 244 224 249;
9
+ --q-green: 168 216 50;
10
+ --q-yellow: 249 243 197;
11
+ --q-purple: 252 104 182;
12
+ --q-blue: 124 186 247;
17
13
  }
@@ -41,8 +41,11 @@
41
41
  }
42
42
 
43
43
  @font-face {
44
- font-family: QMarkMyWords;
45
- src: url(https://fonts.qmusic.be/qmarkmywords-webfont.ttf), url(https://fonts.qmusic.be/qmarkmywords-webfont.eot), url(https://fonts.qmusic.be/qmarkmywords-webfont.woff), url(https://fonts.qmusic.be/qmarkmywords-webfont.woff2), url(https://fonts.qmusic.be/qmarkmywords-webfont.svg);
46
- font-weight: 400;
47
- font-style: normal
44
+ font-family: BlackBones;
45
+ src: url(https://fonts.qmusic.be/black-bones.ttf), url(https://fonts.qmusic.be/black-bones.eot), url(https://fonts.qmusic.be/black-bones.woff), url(https://fonts.qmusic.be/black-bones.woff2), url(https://fonts.qmusic.be/black-bones.svg);
46
+ font-weight: normal;
47
+ font-style: normal;
48
+ font-display: fallback;
48
49
  }
50
+
51
+