@dpgradio/creative 5.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.
@@ -0,0 +1,37 @@
1
+ // eslint-disable-next-line no-unused-vars -- used in JSDoc
2
+ import { Api } from '../api.js'
3
+ // eslint-disable-next-line no-unused-vars -- used in JSDoc
4
+ import Request from '../request.js'
5
+
6
+ export default class Endpoint {
7
+ /**
8
+ * @param {Api} api
9
+ */
10
+ constructor(api) {
11
+ this.api = api
12
+ }
13
+
14
+ /**
15
+ * Callback for requesting data.
16
+ *
17
+ * @callback requestCallback
18
+ * @param {Request} request
19
+ * @returns {object}
20
+ */
21
+
22
+ /**
23
+ * @param {requestCallback} callback
24
+ * @returns {object}
25
+ */
26
+ async requestData(callback, key = 'data') {
27
+ const response = await callback(this.api.request())
28
+
29
+ if (response === null) {
30
+ throw new Error(`Endpoint returned invalid JSON.`)
31
+ }
32
+ if (response[key] === undefined) {
33
+ throw new Error(`Key '${key}' not found in response: ${JSON.stringify(response)}`)
34
+ }
35
+ return response[key]
36
+ }
37
+ }
@@ -0,0 +1,7 @@
1
+ import Endpoint from './Endpoint.js'
2
+
3
+ export default class Members extends Endpoint {
4
+ async me() {
5
+ return await this.api.request().get('/members/me')
6
+ }
7
+ }
@@ -0,0 +1,100 @@
1
+ import tap from '../utils/tap.js'
2
+
3
+ export default class Request {
4
+ constructor(baseUrl, version) {
5
+ this.baseUrl = baseUrl
6
+ this.version = version
7
+ this.queryParameters = {}
8
+ this.data = null
9
+ this.headers = {}
10
+ this.fetchOptions = {}
11
+ }
12
+
13
+ withQueryParameters(queryParameters) {
14
+ this.queryParameters = { ...this.queryParameters, ...queryParameters }
15
+
16
+ return this
17
+ }
18
+
19
+ withData(data) {
20
+ this.data = { ...(this.data || {}), ...data }
21
+
22
+ return this
23
+ }
24
+
25
+ withFetchOptions(fetchOptions) {
26
+ this.fetchOptions = { ...this.fetchOptions, ...fetchOptions }
27
+
28
+ return this
29
+ }
30
+
31
+ withHeader(key, value) {
32
+ return this.withHeaders({ [key]: value })
33
+ }
34
+
35
+ withHeaders(headers) {
36
+ this.headers = { ...this.headers, ...headers }
37
+
38
+ return this
39
+ }
40
+
41
+ async get(endpoint, parameters = {}) {
42
+ return await this.withQueryParameters(parameters).fetchJson(endpoint)
43
+ }
44
+
45
+ async post(endpoint, data = {}, parameters = {}) {
46
+ return await this.withData(data).withQueryParameters(parameters).fetchJson(endpoint, 'POST')
47
+ }
48
+
49
+ async put(endpoint, data = {}, parameters = {}) {
50
+ return await this.withData(data).withQueryParameters(parameters).fetchJson(endpoint, 'PUT')
51
+ }
52
+
53
+ async patch(endpoint, data = {}, parameters = {}) {
54
+ return await this.withData(data).withQueryParameters(parameters).fetchJson(endpoint, 'PATCH')
55
+ }
56
+
57
+ async delete(endpoint, data = {}, parameters = {}) {
58
+ return await this.withData(data).withQueryParameters(parameters).fetchJson(endpoint, 'DELETE')
59
+ }
60
+
61
+ async fetchJson(endpoint, method = 'GET') {
62
+ const url = this.constructUrl(endpoint)
63
+
64
+ this.withHeader('Accept', 'application/json')
65
+
66
+ if (this.data !== null) {
67
+ this.withHeader('Content-Type', 'application/json')
68
+ this.withFetchOptions({ body: JSON.stringify(this.data) })
69
+ }
70
+
71
+ const response = await fetch(url, {
72
+ method,
73
+ headers: this.headers,
74
+ ...this.fetchOptions,
75
+ })
76
+
77
+ if (!response.ok) {
78
+ throw new Error(
79
+ `API request (${method} ${url}) failed: [${response.status}] ${response.statusText}\n\n` +
80
+ `RESPONSE\n--------\n${await response.text()}`
81
+ )
82
+ }
83
+
84
+ try {
85
+ return await response.json()
86
+ } catch (error) {
87
+ return null
88
+ }
89
+ }
90
+
91
+ constructUrl(endpoint) {
92
+ const baseUrlWithProtocol = this.baseUrl.startsWith('http') ? this.baseUrl : `https://${this.baseUrl}`
93
+
94
+ const endpointWithoutSlash = endpoint.replace(/^\/+/, '')
95
+
96
+ return tap(new URL(`${baseUrlWithProtocol}/${this.version}/${endpointWithoutSlash}`), (url) => {
97
+ url.search = new URLSearchParams(this.queryParameters).toString()
98
+ })
99
+ }
100
+ }
@@ -0,0 +1,107 @@
1
+ import jwtDecode from 'jwt-decode'
2
+
3
+ // Modern versions of the radio apps set up specific User-Agents:
4
+ // Android User-Agent: Qmusic/7.6.1 (nl.qmusic.app; build:21726; Android 11; Sdk:30; Manufacturer:OnePlus; Model: IN2013) OkHttp/ 4.9.1
5
+ // iOS User-Agent: Joe/265 (be.vmma.joe.app; build:1; iOS 14.8.1) Alamofire/265
6
+ const androidRegexp =
7
+ /^(?<brand>.+)\/(?<storeVersion>[0-9.]+) \((?<buildName>.+); build:(?<buildVersion>\d+); (?<platform>Android) (?<osVersion>\d+); Sdk:(?<sdkVersion>\d+); Manufacturer:(?<manufacturer>.+); Model: (?<model>.+)\)/
8
+ const iOSRegexp =
9
+ /^(?<brand>.+)\/(?<buildVersion>[0-9.]+) \((?<buildName>.+); build:(?<internalBuildVersion>\d+); (?<platform>iOS) (?<osVersion>\d+\.\d+\.\d+)\)/
10
+
11
+ class Hybrid {
12
+ constructor() {
13
+ // Hook this on window so it can be required in multiple packs
14
+ window._hybridEventSubscriptions = window._hybridEventSubscriptions || {}
15
+
16
+ this.appInfo = this.detectApp(window.appVersion || navigator.userAgent)
17
+
18
+ this._cachedRadioTokenOnLoad = undefined
19
+ this.on('appLoad', (context) => {
20
+ this._cachedRadioTokenOnLoad = context?.radioToken ?? null
21
+ })
22
+ }
23
+
24
+ isNativeApp() {
25
+ return this.appInfo.platform !== 'browser'
26
+ }
27
+
28
+ isVersion({ android, ios }) {
29
+ const { platform, buildName, buildVersion } = this.appInfo
30
+
31
+ return (
32
+ (platform === 'Android' && android[buildName] && android[buildName] <= parseInt(buildVersion, 10)) ||
33
+ (platform === 'iOS' && ios <= parseInt(buildVersion, 10))
34
+ )
35
+ }
36
+
37
+ call(method, options = {}) {
38
+ if (window.webkit && window.webkit.messageHandlers) {
39
+ const handler = window.webkit.messageHandlers[method]
40
+ handler && handler.postMessage(options)
41
+ } else if (window.Android) {
42
+ // Note: It's important to either directly call window.Android[method], or bind it with
43
+ // const ref = window.android[method].bind(Android)
44
+ // ref()
45
+ // ref: https://bugs.chromium.org/p/chromium/issues/detail?id=514628
46
+ window.Android[method](JSON.stringify(options))
47
+ } else {
48
+ console.error('Call on unsupported device', method, options)
49
+ }
50
+ }
51
+
52
+ on(method, fn, once = false) {
53
+ if (!window._hybridEventSubscriptions[method]) {
54
+ window._hybridEventSubscriptions[method] = []
55
+ }
56
+ window._hybridEventSubscriptions[method].push({ fn, once })
57
+ this.ensureTriggerExists(method)
58
+ }
59
+
60
+ one(method, fn) {
61
+ this.one(method, fn, true)
62
+ }
63
+
64
+ appLoaded() {
65
+ return new Promise((resolve, reject) => {
66
+ if (this._cachedRadioTokenOnLoad !== undefined) {
67
+ return resolve(this._cachedRadioTokenOnLoad)
68
+ }
69
+ this.on('appLoad', (context) => {
70
+ context?.radioToken ? resolve(context.radioToken) : reject('No radio token provided')
71
+ })
72
+ })
73
+ }
74
+
75
+ ensureTriggerExists(method) {
76
+ if (window[method]) {
77
+ return
78
+ }
79
+
80
+ window[method] = (args) => {
81
+ const callbacks = window._hybridEventSubscriptions[method] || []
82
+ for (const callback of callbacks) {
83
+ callback.fn(args)
84
+ if (callback.once) {
85
+ callback.delete = true // Mark for deletion
86
+ }
87
+ }
88
+ window._hybridEventSubscriptions[method] = callbacks.filter((callback) => !callback.delete) // Delete all marked
89
+ }
90
+ }
91
+
92
+ detectApp(userAgent) {
93
+ for (const regexp of [androidRegexp, iOSRegexp]) {
94
+ const match = userAgent.match(regexp)
95
+ if (match) {
96
+ return match.groups
97
+ }
98
+ }
99
+ return { platform: 'browser' }
100
+ }
101
+
102
+ decodeRadioToken(token) {
103
+ return jwtDecode(token)
104
+ }
105
+ }
106
+
107
+ export default new Hybrid()
@@ -0,0 +1,60 @@
1
+ import api from '../api/api.js'
2
+
3
+ const dotSyntaxAccess = (object, path) => path.split('.').reduce((o, p) => o[p], object)
4
+
5
+ class Configuration {
6
+ constructor() {
7
+ this.stationId = null
8
+ this.appId = null
9
+ this.rawConfig = null
10
+ }
11
+
12
+ setStation(stationId) {
13
+ this.stationId = stationId
14
+
15
+ return this
16
+ }
17
+
18
+ async retrieveConfig(appId, ...stationIds) {
19
+ this.appId = appId
20
+ this.stationId = stationIds[0]
21
+
22
+ this.rawConfig = await api.global().config.app(appId, stationIds)
23
+
24
+ return this
25
+ }
26
+
27
+ config(property = null) {
28
+ if (!this.rawConfig) {
29
+ throw new Error('No config retrieved. First use [config.retrieveConfig] to retrieve the configuration.')
30
+ }
31
+ if (!this.stationId) {
32
+ throw new Error('No station set. First use [config.setStation] to set the station.')
33
+ }
34
+ if (!this.appId) {
35
+ throw new Error('No app identifier set. First use [config.retrieveConfig] to retrieve the configuration.')
36
+ }
37
+ if (!this.rawConfig[this.stationId]?.[this.appId]) {
38
+ throw new Error(
39
+ `No configuration found for station [${this.stationId}] and app [${this.appId}]. Make sure the app is enabled for this station.`
40
+ )
41
+ }
42
+
43
+ const stationConfig = {
44
+ ...this.rawConfig[this.stationId],
45
+ app: this.rawConfig[this.stationId][this.appId],
46
+ }
47
+
48
+ return property ? dotSyntaxAccess(stationConfig, property) : stationConfig
49
+ }
50
+
51
+ async replaceSchema(appId, schema) {
52
+ await api.global().config.updateSchema(appId, schema)
53
+ }
54
+ }
55
+
56
+ const configuration = new Configuration()
57
+
58
+ export default configuration
59
+
60
+ export const config = configuration.config.bind(configuration)
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ export { default as hybrid } from './app/hybrid'
2
+
3
+ export { default as privacy } from './privacy/privacy'
4
+ export { default as dataLayer } from './privacy/dataLayer'
5
+
6
+ export { default as socket } from './socket/socket'
7
+
8
+ export { default as configuration, config } from './config/config'
9
+
10
+ export { default as api } from './api/api'
11
+
12
+ export { default as loadScript } from './utils/loadScript'
13
+ export { default as openLink } from './utils/openLink'
14
+ export { default as tap } from './utils/tap'
15
+ export { cdnImageUrl, cdnUrl } from './utils/cdnUrl'
@@ -0,0 +1,73 @@
1
+ import hybrid from '../app/hybrid'
2
+ import { config } from '../config/config'
3
+ import loadScript from '../utils/loadScript'
4
+
5
+ class DataLayer {
6
+ constructor() {
7
+ window.dataLayer = window.dataLayer || []
8
+ }
9
+
10
+ initialize(gtmId = 'GTM-TW99VZN') {
11
+ loadScript(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`)
12
+
13
+ this.pushGtmStart()
14
+ this.pushUserWhenAuthenticated()
15
+ }
16
+
17
+ push(data) {
18
+ window.dataLayer.push(data)
19
+ }
20
+
21
+ pushGtmStart() {
22
+ this.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' })
23
+ }
24
+
25
+ pushEvent(event, data) {
26
+ this.push({ event, ...data })
27
+ }
28
+
29
+ pushUserWhenAuthenticated() {
30
+ hybrid.on('authenticated', ({ radioToken }) => {
31
+ if (radioToken) {
32
+ this.pushEvent('account_id', this._formatUserInformation(radioToken))
33
+ }
34
+ })
35
+ }
36
+
37
+ async pushVirtualPageView(brand = config('gtm_brand')) {
38
+ const user = await this._getUserInformationOnLoad()
39
+
40
+ this.pushEvent('VirtualPageView', {
41
+ virtualPageURL: {
42
+ ...window.location,
43
+ platform: 'browser',
44
+ brand,
45
+ },
46
+ ...user,
47
+ })
48
+ }
49
+
50
+ async _getUserInformationOnLoad() {
51
+ if (!hybrid.isNativeApp()) {
52
+ return {}
53
+ }
54
+ try {
55
+ const radioToken = await hybrid.appLoaded()
56
+ return this._formatUserInformation(radioToken)
57
+ } catch (error) {
58
+ console.error('User information could not be loaded:', error)
59
+ return {}
60
+ }
61
+ }
62
+
63
+ _formatUserInformation(radioToken) {
64
+ return {
65
+ user: {
66
+ account_id: hybrid.decodeRadioToken(radioToken).uid,
67
+ loggedIn: true,
68
+ },
69
+ }
70
+ }
71
+ }
72
+
73
+ export default new DataLayer()
@@ -0,0 +1,100 @@
1
+ import { config } from '../config/config'
2
+ import loadScript from '../utils/loadScript'
3
+
4
+ class Privacy {
5
+ constructor() {
6
+ window._privacy = window._privacy || []
7
+
8
+ this.consent = undefined
9
+ this.consentSubscribers = []
10
+ }
11
+
12
+ /**
13
+ * Load the CMP script asynchronously.
14
+ *
15
+ * @param {string} privacyManagerId e.g. '148844'
16
+ * @param {string} websiteUrl e.g. 'https://qmusic.nl'
17
+ * @param {string} cmpCname e.g. 'https://cmp.qmusic.nl'
18
+ */
19
+ initialize(
20
+ privacyManagerId = config('privacy_manager_id'),
21
+ websiteUrl = config('website_url'),
22
+ cmpCname = config('cmp_cname')
23
+ ) {
24
+ window.cmpProperties = {
25
+ privacyManagerId,
26
+ cmpCname,
27
+ baseUrl: websiteUrl,
28
+ language: 'nl',
29
+ }
30
+
31
+ loadScript('https://myprivacy-static.dpgmedia.net/consent.js')
32
+
33
+ this.requestConsentInformation()
34
+ }
35
+
36
+ /**
37
+ * Request consent information from the CMP.
38
+ *
39
+ * This method should be called immediately after loading the page.
40
+ * The timeout is set to 10 seconds, after which we assume the CMP could not be loaded (potentially blocked by the browser).
41
+ * In that case, we set the consent to null to indicate that we don't know the consent status.
42
+ * When {@link waitForConsent} is called, it will immediately resolve with null instead of waiting for its own timeout.
43
+ *
44
+ * @param {number} timeoutTime
45
+ */
46
+ requestConsentInformation(timeoutTime = 10000) {
47
+ const timeout = setTimeout(() => {
48
+ this.consent = null
49
+ this.consentSubscribers.forEach((subscriber) => subscriber(null))
50
+ }, timeoutTime)
51
+
52
+ this.pushFunctional(() => {
53
+ clearTimeout(timeout)
54
+ this.consent = new Consent(window._privacy.consentString, window._privacy.purposesProvider.purposes)
55
+ this.consentSubscribers.forEach((subscriber) => subscriber(this.consent))
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Wait for {@param timeoutTime} milliseconds for consent information to be available.
61
+ *
62
+ * @param {number} timeoutTime
63
+ * @returns {Promise<Consent>}
64
+ */
65
+ waitForConsent(timeoutTime = 1000) {
66
+ return new Promise((resolve, reject) => {
67
+ if (this.consent) {
68
+ return resolve(this.consent)
69
+ }
70
+ const timeout = setTimeout(() => reject(new Error('Retrieving consent information timed out.')), timeoutTime)
71
+ this.consentSubscribers.push((consent) => {
72
+ clearTimeout(timeout)
73
+ resolve(consent)
74
+ })
75
+ })
76
+ }
77
+
78
+ push(type, callback) {
79
+ window._privacy.push([type, callback])
80
+ }
81
+
82
+ pushFunctional(callback) {
83
+ this.push('functional', callback)
84
+ }
85
+ }
86
+
87
+ const targetedAdvertisingPurposes = [3, 4]
88
+
89
+ class Consent {
90
+ constructor(consentString, purposes) {
91
+ this.consentString = consentString
92
+ this.purposes = purposes
93
+ }
94
+
95
+ allowsTargetedAdvertising() {
96
+ return targetedAdvertisingPurposes.every((purpose) => this.purposes.has(purpose.toString()))
97
+ }
98
+ }
99
+
100
+ export default new Privacy()
@@ -0,0 +1,28 @@
1
+ export default class ImageGeneratorProperties {
2
+ constructor(url) {
3
+ this.url = url
4
+ this.width = 800
5
+ this.height = 800
6
+ this.payload = {}
7
+ }
8
+
9
+ withDimensions(width, height) {
10
+ this.width = width
11
+ this.height = height
12
+ return this
13
+ }
14
+
15
+ withPayload(payload) {
16
+ this.payload = payload
17
+ return this
18
+ }
19
+
20
+ toJson() {
21
+ return {
22
+ url: this.url,
23
+ width: this.width,
24
+ height: this.height,
25
+ payload: this.payload,
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,33 @@
1
+ import openLink from '../utils/openLink'
2
+
3
+ export default class ShareResult {
4
+ constructor(shareable, { url, image }) {
5
+ this.shareable = shareable
6
+ this.url = url
7
+ this.image = image
8
+ }
9
+
10
+ facebookUrl() {
11
+ return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.url)}`
12
+ }
13
+
14
+ whatsappUrl() {
15
+ return `https://api.whatsapp.com/send?text=${encodeURIComponent(this.shareable.messageText)}%20${this.url}`
16
+ }
17
+
18
+ instagramUrl() {
19
+ return this.image
20
+ }
21
+
22
+ openFacebookUrl() {
23
+ openLink(this.facebookUrl())
24
+ }
25
+
26
+ openWhatsappUrl() {
27
+ openLink(this.whatsappUrl())
28
+ }
29
+
30
+ openInstagramUrl() {
31
+ openLink(this.instagramUrl())
32
+ }
33
+ }
@@ -0,0 +1,55 @@
1
+ import axios from 'axios'
2
+ import ShareResult from './ShareResult'
3
+
4
+ export default class Shareable {
5
+ constructor() {
6
+ this.title = ''
7
+ this.description = ''
8
+ this.messageText = ''
9
+ this.redirectUrl = ''
10
+ this.domain = ''
11
+ }
12
+
13
+ withTitle(title) {
14
+ this.title = title
15
+ return this
16
+ }
17
+
18
+ withDescription(description) {
19
+ this.description = description
20
+ return this
21
+ }
22
+
23
+ withMessageText(messageText) {
24
+ this.messageText = messageText
25
+ return this
26
+ }
27
+
28
+ redirectTo(redirectUrl) {
29
+ this.redirectUrl = redirectUrl
30
+ return this
31
+ }
32
+
33
+ fromDomain(domain) {
34
+ this.domain = domain
35
+ return this
36
+ }
37
+
38
+ generateUsingImage(imageGeneratorProperties) {
39
+ return new Promise((resolve, reject) => {
40
+ axios({
41
+ url: 'https://dba1du5ckc.execute-api.eu-west-3.amazonaws.com/prod/',
42
+ method: 'post',
43
+ data: {
44
+ title: this.title,
45
+ description: this.description,
46
+ image_generator: imageGeneratorProperties.toJson(),
47
+ redirect_url: this.redirectUrl,
48
+ domain: this.domain,
49
+ },
50
+ })
51
+ .then((response) => resolve(new ShareResult(this, response.data)))
52
+ .catch(reject)
53
+ })
54
+ }
55
+ }
package/src/share.js ADDED
@@ -0,0 +1,5 @@
1
+ import Shareable from './share/Shareable'
2
+ import ImageGeneratorProperties from './share/ImageGeneratorProperties'
3
+ import ShareResult from './share/ShareResult'
4
+
5
+ export { ShareResult, Shareable, ImageGeneratorProperties }