@everymatrix/player-consents 1.38.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,617 @@
1
+ <svelte:options tag={null} />
2
+ <script lang="ts">
3
+ import { _, addNewMessages, setLocale } from './i18n';
4
+ import { TRANSLATIONS } from './translations.js';
5
+ import { onMount } from 'svelte';
6
+ import type { Consent, ConsentCategory, PlayerConsent } from '../types/types';
7
+
8
+ import '@everymatrix/general-animation-loading';
9
+ import './images/fa-circle-exclamation.svg.svelte';
10
+
11
+ export let session: string = '';
12
+ export let userid: string = '';
13
+ export let endpoint: string = '';
14
+ export let clientstyling: string = '';
15
+ export let clientstylingurl:string = '';
16
+ export let lang: string = 'en';
17
+ export let displayconsentdescription: string = '';
18
+ export let translationurl:string = '';
19
+
20
+ let displayNone: boolean = false;
21
+ let customStylingContainer: HTMLElement;
22
+ let isMounted: boolean = false;
23
+ let isLoggedIn: boolean = false;
24
+ let sessionID: string = '';
25
+ let playerID: string = '';
26
+ let errorMessage: string = '';
27
+ let fatalError: string = '';
28
+ let consentsCategories: Array<ConsentCategory> = [];
29
+ let consentsList: Array<Consent> = [];
30
+ let playerConsents: Array<PlayerConsent> = [];
31
+ let isLoading: boolean = true;
32
+ let canSaveData: boolean = true;
33
+
34
+ let categoryToggle: { [key: string]: boolean } = {};
35
+ let prevCategoryToggleState: { [key: string]: boolean } = {};
36
+ let initialConsentsState: { [key: string]: boolean } = {};
37
+ let consentsState: { [key: string]: boolean } = {};
38
+
39
+ type TranslationKeys = keyof typeof TRANSLATIONS;
40
+ Object.keys(TRANSLATIONS).forEach((item: TranslationKeys): void => {
41
+ addNewMessages(item, TRANSLATIONS[item]);
42
+ });
43
+
44
+ /**
45
+ * Sets the active language by updating the locale.
46
+ */
47
+ const setActiveLanguage = (): void => {
48
+ setLocale(lang);
49
+ };
50
+
51
+ /**
52
+ * Sets translation URL by fetching translation data and updating messages.
53
+ */
54
+ const setTranslationUrl = ():void => {
55
+ let url = new URL(translationurl);
56
+
57
+ fetch(url.href).then((res:any) => res.json())
58
+ .then((res) => {
59
+ Object.keys(res).forEach((item:any):void => {
60
+ addNewMessages(item, res[item]);
61
+ });
62
+ }).catch((err:any) => {
63
+ console.log(err);
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Sets client styling by appending a style element to the custom styling container.
69
+ */
70
+ const setClientStyling = (): void => {
71
+ let sheet = document.createElement('style');
72
+ sheet.innerHTML = clientstyling;
73
+ customStylingContainer.appendChild(sheet);
74
+ };
75
+
76
+ /**
77
+ * Sets client styling URL by fetching the CSS file and appending a style element to the custom styling container.
78
+ */
79
+ const setClientStylingURL = ():void => {
80
+ try {
81
+ displayNone = true;
82
+
83
+ let url = new URL(clientstylingurl);
84
+ let cssFile = document.createElement('style');
85
+
86
+ fetch(url.href)
87
+ .then((res:any) => res.text())
88
+ .then((data:any) => {
89
+ cssFile.innerHTML = data;
90
+
91
+ setTimeout(() => { customStylingContainer.appendChild(cssFile) }, 1);
92
+ setTimeout(() => { displayNone = false; }, 500);
93
+ });
94
+ } catch ( err ) {
95
+ const errorMessage = err instanceof TypeError ? $_('invalidUrl') : err.message;
96
+ handleError(errorMessage);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Sets the session information upon login.
102
+ */
103
+ const setSession = (): void => {
104
+ if( session ){
105
+ sessionID = session;
106
+ isLoggedIn = true;
107
+ }
108
+ if( userid ){
109
+ playerID = userid;
110
+ }
111
+ };
112
+
113
+ /**
114
+ * Handles errors by updating the error message.
115
+ *
116
+ * @param error - The error message to be handled.
117
+ * @param isFatal - A flag to indicate if the error is fatal.
118
+ */
119
+ const handleError = (error: string, isFatal: boolean = false): void => {
120
+ if (isFatal) {
121
+ fatalError = error;
122
+ } else {
123
+ cancelChanges();
124
+ errorMessage = error;
125
+ }
126
+ };
127
+
128
+ /**
129
+ * Fetches data from a given URL and returns the JSON response.
130
+ *
131
+ * @param url - The URL to fetch data from.
132
+ * @param errorMsg - The error message to be used if the request fails.
133
+ * @param options - The options for the fetch request.
134
+ * @returns The JSON response from the fetch request.
135
+ */
136
+ const fetchData = async (url: string, errorMsg: string, options: RequestInit, isFatal:boolean = false): Promise<any> => {
137
+ try {
138
+ const response = await fetch(url, options);
139
+
140
+ if (!response.ok) {
141
+ throw new Error($_(errorMsg));
142
+ }
143
+
144
+ // return await response.json();
145
+ const data = await response.json();
146
+ if (isLoggedIn) {
147
+ return data;
148
+ }
149
+
150
+ // If user is not logged in, filter the consents by ShowOnRegister flag
151
+ return data.filter((consent: Consent) => consent.showOnRegister === true);
152
+ } catch (error) {
153
+ handleError(error instanceof TypeError ? $_(errorMsg) : error.message, isFatal);
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Retrieves consents data including consents, consent categories, and player consents.
160
+ * If any request fails, an error is thrown.
161
+ */
162
+ const getConsentsData = async (): Promise<void> => {
163
+ try {
164
+
165
+ let consents: Consent[] = [];
166
+ let playerConsentsData: PlayerConsent[] = [];
167
+
168
+ // Consider the case where the widget is used in a non-logged in state
169
+ if (!isLoggedIn) {
170
+ consents = await fetchAllConsentsData();
171
+ } else {
172
+ [consents, playerConsentsData] = await fetchAllConsentsData();
173
+ }
174
+
175
+ isLoading = false;
176
+ consentsList = [ ...consents ];
177
+ consentsCategories = getCategoriesFromConsents(consentsList).sort((a, b) => a.categoryTagCode.localeCompare(b.categoryTagCode));
178
+ categoryToggle = getCategoryToggle(consentsCategories);
179
+ prevCategoryToggleState = { ...categoryToggle };
180
+ playerConsents = [ ...playerConsentsData ];
181
+ initializeConsentStates();
182
+ } catch (err) {
183
+ isLoading = false;
184
+ handleError(err instanceof TypeError ? $_('invalidUrl') : err.message, true);
185
+ throw err;
186
+ }
187
+ };
188
+
189
+ /**
190
+ * Fetches all consents data in parallel.
191
+ */
192
+ const fetchAllConsentsData = async () => {
193
+ const consentsUrl = new URL(`${endpoint}/api/v1/gm/consents`);
194
+ consentsUrl.searchParams.append('Status', 'Active');
195
+
196
+ // The widget can be used at register where the session id does not exists. If session is not set, fetch only the active consents
197
+ if (!isLoggedIn) {
198
+ return await fetchData(consentsUrl.href, 'fetchConsentsError', {
199
+ method: 'GET',
200
+ }, true);
201
+ }
202
+
203
+ // Get all data if the session id exists
204
+ const playerConsentsUrl = new URL(`${endpoint}/api/v1/gm/user-consents/${playerID}`);
205
+
206
+ return await Promise.all([
207
+ fetchData(consentsUrl.href, 'fetchConsentsError', {
208
+ method: 'GET',
209
+ }, true),
210
+ fetchData(playerConsentsUrl.href, 'fetchPlayerConsentsError', {
211
+ method: 'GET',
212
+ headers: {
213
+ 'X-SessionId': sessionID,
214
+ 'Content-Type': 'application/json'
215
+ },
216
+ }),
217
+ ]);
218
+ };
219
+
220
+ /**
221
+ * Get consents categories from playerConsentsData
222
+ *
223
+ * @param playerConsentsData - Data from player consents
224
+ */
225
+ const getCategoriesFromConsents = (playerConsentsData: Array<any>) => {
226
+ const categoriesMap = new Map<string, ConsentCategory>();
227
+
228
+ playerConsentsData.forEach(consent => {
229
+ if (!categoriesMap.has(consent.category.categoryTagCode)) {
230
+ categoriesMap.set(consent.category.categoryTagCode, consent.category);
231
+ }
232
+ });
233
+
234
+ return Array.from(categoriesMap.values());
235
+ };
236
+
237
+ /**
238
+ * Initializes the category toggle state.
239
+ */
240
+ const getCategoryToggle = (categories: Array<ConsentCategory>) => {
241
+ const categoryToggleValueCached = localStorage.getItem('categoryToggle' + playerID);
242
+ if (categoryToggleValueCached === null) {
243
+ const newCategoryToggle = categories.reduce((acc: { [x: string]: boolean; }, category: ConsentCategory) => {
244
+ acc[category.categoryTagCode] = false;
245
+ return acc;
246
+ }, {});
247
+ localStorage.setItem('categoryToggle' + playerID, JSON.stringify(newCategoryToggle));
248
+ return newCategoryToggle;
249
+ } else {
250
+ return JSON.parse(categoryToggleValueCached);
251
+ }
252
+ };
253
+
254
+ /**
255
+ * Initializes consent states for the current user.
256
+ */
257
+ const initializeConsentStates = () => {
258
+ consentsList.forEach(consent => {
259
+ const playerConsent = playerConsents.find(pc => pc.tagCode === consent.tagCode);
260
+ initialConsentsState[consent.tagCode] = playerConsent && playerConsent.status === 'Accepted';
261
+ });
262
+
263
+ consentsState = { ...initialConsentsState };
264
+ };
265
+
266
+ /**
267
+ * Event handler to revert changes and cancel editing.
268
+ */
269
+ const cancelChanges = () => {
270
+ consentsState = { ...initialConsentsState };
271
+ categoryToggle = { ...prevCategoryToggleState };
272
+ };
273
+
274
+ /**
275
+ * Event handler to save current state and log it.
276
+ */
277
+ const saveChanges = async () => {
278
+ if ( !canSaveData ) {
279
+ return;
280
+ }
281
+ canSaveData = false;
282
+ const updateConsents: Array<{ tagCode: string, status: string }> = [];
283
+ const newConsentsList: Array<{ tagCode: string, status: string }> = [];
284
+
285
+ // Check if there are any consents in consentsState that have MustAccept parameter set to true and are set to false
286
+ const mustAcceptConsents = consentsList.filter(consent => consent.mustAccept === true && (!consentsState[consent.tagCode] || consentsState[consent.tagCode] === false));
287
+ window.postMessage({ type: 'HasMandatoryConsents', data: mustAcceptConsents.length > 0 });
288
+
289
+ Object.keys(consentsState).forEach(tagCode => {
290
+ const consent = playerConsents.find(c => c.tagCode === tagCode);
291
+ if (consentsState[tagCode] !== initialConsentsState[tagCode]) {
292
+ if (consent) {
293
+ updateConsents.push({ tagCode, status: consentsState[tagCode] ? 'Accepted' : 'Denied' });
294
+ } else {
295
+ newConsentsList.push({ tagCode, status: consentsState[tagCode] ? 'Accepted' : 'Denied' });
296
+ }
297
+ }
298
+ });
299
+
300
+ // If the user is not logged in, just emit an event with the new consents and return
301
+ if (!isLoggedIn) {
302
+ localStorage.setItem('categoryToggle' + playerID, JSON.stringify(categoryToggle) )
303
+ prevCategoryToggleState = { ...categoryToggle };
304
+ window.postMessage({ type: 'NewPlayerConsentData', data: JSON.stringify(newConsentsList) }, window.location.href);
305
+ canSaveData = true;
306
+ return;
307
+ }
308
+
309
+ const consentsApiUrl = new URL(`${endpoint}/api/v1/gm/user-consents/${playerID}`);
310
+
311
+ try {
312
+ const results = await Promise.allSettled([
313
+ newConsentsList.length > 0 && fetchData(consentsApiUrl.href, 'updateConsentsError', {
314
+ method: 'POST',
315
+ headers: {
316
+ 'X-SessionId': sessionID,
317
+ 'Content-Type': 'application/json'
318
+ },
319
+ body: JSON.stringify({ userConsents: newConsentsList }),
320
+ }),
321
+ updateConsents.length > 0 && fetchData(consentsApiUrl.href, 'updateConsentsError', {
322
+ method: 'PATCH',
323
+ headers: {
324
+ 'X-SessionId': sessionID,
325
+ 'Content-Type': 'application/json'
326
+ },
327
+ body: JSON.stringify({ userConsents: updateConsents }),
328
+ })
329
+ ]);
330
+
331
+ results.forEach((result, index) => {
332
+ if (result.status === 'rejected' || result.value.ok === false) {
333
+ const consent = index < newConsentsList.length ? newConsentsList[index] : updateConsents[index - newConsentsList.length];
334
+ consentsState[consent.tagCode] = initialConsentsState[consent.tagCode];
335
+ }
336
+ });
337
+
338
+ if (results.every(result => result.status === 'fulfilled')) {
339
+ localStorage.setItem('categoryToggle' + playerID, JSON.stringify(categoryToggle) )
340
+ prevCategoryToggleState = { ...categoryToggle };
341
+ window.postMessage({ type: 'PlayerConsentUpdated', success: true }, window.location.href);
342
+ initialConsentsState = { ...consentsState };
343
+ }
344
+ } catch (err) {
345
+ handleError(err instanceof TypeError ? $_('saveChangesError') : err.message);
346
+ window.postMessage({ type: 'PlayerConsentUpdated', success: false }, window.location.href);
347
+ } finally {
348
+ canSaveData = true;
349
+ };
350
+ };
351
+
352
+ /**
353
+ * Toggles all consents in a category.
354
+ *
355
+ * @param categoryTagCode - The category tag code to identify the category.
356
+ */
357
+ const toggleCategoryConsents = (categoryTagCode: string) => {
358
+ consentsList
359
+ .filter(consent => consent.category.categoryTagCode === categoryTagCode)
360
+ .forEach(consent => {
361
+ if (consent.mustAccept && initialConsentsState[consent.tagCode] === true) {
362
+ return;
363
+ }
364
+ consentsState[consent.tagCode] = categoryToggle[categoryTagCode] || false;
365
+ });
366
+ saveChanges();
367
+ };
368
+
369
+ /**
370
+ * Toggles a consent.
371
+ *
372
+ * @param consentCategory - The category of the category.
373
+ */
374
+ const toggleConsent = (consentCategory: ConsentCategory) => {
375
+ categoryToggle[consentCategory.categoryTagCode] = false;
376
+ saveChanges();
377
+ }
378
+
379
+ /**
380
+ * Sets the isMounted flag after a short delay when the component is first rendered.
381
+ */
382
+ onMount(() => {
383
+ setTimeout(() => {
384
+ isMounted = true;
385
+ }, 50);
386
+ });
387
+
388
+ // Reactive statements
389
+ $: if (isMounted) {
390
+ setSession();
391
+ getConsentsData();
392
+ }
393
+ $: if (customStylingContainer) {
394
+ if (clientstyling) setClientStyling();
395
+ if (clientstylingurl) setClientStylingURL();
396
+ }
397
+ $: if (lang) setActiveLanguage();
398
+ $: if (translationurl) setTranslationUrl();
399
+ </script>
400
+
401
+ <div class={displayNone ? 'DisplayNone' : ''}>
402
+ <div class="ConsentsContainer" bind:this={customStylingContainer}>
403
+
404
+ {#if isLoading}
405
+ <general-animation-loading clientstyling={clientstyling} clientstylingurl={clientstylingurl} />
406
+ {:else if fatalError}
407
+ <div class="ContainerCenter">
408
+ <strong class="ErrorMessage">{fatalError}</strong>
409
+ </div>
410
+ {:else}
411
+ {#if TRANSLATIONS[lang]['title'] || TRANSLATIONS[lang]['description']}
412
+ <div class="PlayerConsentsHeader">
413
+ {#if TRANSLATIONS[lang]['title']}
414
+ <h2 class="PlayerConsentsTitle">{TRANSLATIONS[lang]['title']}</h2>
415
+ {/if}
416
+ {#if TRANSLATIONS[lang]['description']}
417
+ <p class="PlayerConsentsDescription">{TRANSLATIONS[lang]['description']}</p>
418
+ {/if}
419
+ </div>
420
+ {/if}
421
+ {#each consentsCategories as category}
422
+ <div class="AccordionItem">
423
+ <div class="AccordionHeader">
424
+ <h3>{category.friendlyName}</h3>
425
+ <label class="ToggleSwitch Big">
426
+ <input type="checkbox" bind:checked={categoryToggle[category.categoryTagCode]} on:change={() => toggleCategoryConsents(category.categoryTagCode)} />
427
+ <span class="Slider Round"></span>
428
+ </label>
429
+ </div>
430
+ <div class="AccordionContent">
431
+ {#each consentsList.filter(consent => consent.category.categoryTagCode === category.categoryTagCode) as consent}
432
+ <div class="ConsentItem">
433
+ <div class="ConsentContent">
434
+ <h4 class="ConsentName">
435
+ {consent.friendlyName}
436
+ {#if consent.mustAccept === true}
437
+ <sup class="ConsentRequired">*</sup>
438
+ {/if}
439
+ </h4>
440
+ {#if displayconsentdescription === 'true'}
441
+ <p class="ConsentDescription">{consent.description}</p>
442
+ {/if}
443
+ </div>
444
+ <label class="ToggleSwitch">
445
+ <input type="checkbox" bind:checked={consentsState[consent.tagCode]} disabled={ consent.mustAccept === true && initialConsentsState[consent.tagCode] === true} on:change={() => toggleConsent(consent.category)} />
446
+ <span class="Slider Round"></span>
447
+ </label>
448
+ </div>
449
+ {/each}
450
+ </div>
451
+ </div>
452
+ {/each}
453
+ { #if errorMessage }
454
+ <div class="ConsentErrorContainer">
455
+ <circle-exclamation-icon/>
456
+ <strong class="ErrorMessage">{errorMessage}</strong>
457
+ </div>
458
+ {/if}
459
+ {/if}
460
+ </div>
461
+ </div>
462
+
463
+ <style lang="scss">
464
+ $primary-color: var(--emw--color-primary, #307fe2);
465
+ $danger-color: var(--emw--color-error, #ed0909);
466
+ $category-border-color: var(--emw--color-gray-50, #cccccc);
467
+ $toggle-bg-color: var(--emw--color-gray-150, #a1a1a1);
468
+ $toggle-button-bg: var(--emw--color-white, #fff);
469
+
470
+ .DisplayNone {
471
+ display: none;
472
+ }
473
+
474
+ .ContainerCenter {
475
+ width: 100%;
476
+ display: flex;
477
+ flex-direction: column;
478
+ justify-content: center;
479
+ align-items: center;
480
+ min-height: 219px;
481
+ }
482
+
483
+ .ErrorMessage {
484
+ font-size: 12px;
485
+ color: $danger-color;
486
+ }
487
+
488
+ .PlayerConsentsHeader {
489
+ margin-bottom: 30px;
490
+ }
491
+
492
+ .AccordionHeader {
493
+ font-weight: bold;
494
+ cursor: pointer;
495
+ border-bottom: 1px solid $category-border-color;
496
+ display: flex;
497
+ align-items: center;
498
+ justify-content: space-between;
499
+ }
500
+
501
+ .AccordionItem {
502
+ margin-bottom: 10px;
503
+ }
504
+
505
+ .AccordionContent {
506
+ display: block;
507
+ padding: 10px 0;
508
+ &:last-of-type {
509
+ padding-bottom: 0;
510
+ }
511
+ }
512
+
513
+ .ConsentItem {
514
+ display: flex;
515
+ width: 100%;
516
+ justify-content: space-between;
517
+ align-items: center;
518
+ margin-bottom: 20px;
519
+ &:last-of-type {
520
+ margin-bottom: 0;
521
+ }
522
+ .ConsentName {
523
+ margin: 0;
524
+ }
525
+ .ConsentDescription {
526
+ font-size: 0.8rem;
527
+ }
528
+ }
529
+
530
+ .ToggleSwitch {
531
+ position: relative;
532
+ display: inline-block;
533
+ width: 40px;
534
+ height: 24px;
535
+
536
+ &.Big{
537
+ width: 53px;
538
+ height: 30px;
539
+ .Slider:before {
540
+ width: 22px;
541
+ height: 22px;
542
+ }
543
+
544
+ input:checked + .Slider:before {
545
+ -webkit-transform: translateX(22px);
546
+ -ms-transform: translateX(22px);
547
+ transform: translateX(22px);
548
+ }
549
+ }
550
+
551
+ input {
552
+ opacity: 0;
553
+ width: 0;
554
+ height: 0;
555
+ &:checked + .Slider {
556
+ background-color: $primary-color;
557
+ }
558
+ &:disabled + .Slider{
559
+ opacity: 0.1;
560
+ }
561
+ &:checked + .Slider:before {
562
+ -webkit-transform: translateX(16px);
563
+ -ms-transform: translateX(16px);
564
+ transform: translateX(16px);
565
+ }
566
+ &:focus + .Slider {
567
+ box-shadow: 0 0 1px $primary-color;
568
+ }
569
+
570
+ }
571
+ .Slider {
572
+ position: absolute;
573
+ cursor: pointer;
574
+ top: 0;
575
+ left: 0;
576
+ right: 0;
577
+ bottom: 0;
578
+ background-color: $toggle-bg-color;
579
+ -webkit-transition: .4s;
580
+ transition: .4s;
581
+ &:before {
582
+ position: absolute;
583
+ content: "";
584
+ height: 16px;
585
+ width: 16px;
586
+ left: 4px;
587
+ bottom: 4px;
588
+ background-color: $toggle-button-bg;
589
+ -webkit-transition: .4s;
590
+ transition: .4s;
591
+ }
592
+ &.Round {
593
+ border-radius: 34px;
594
+ &:before {
595
+ border-radius: 50%;
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ .ConsentErrorContainer {
602
+ display: flex;
603
+ gap: 10px;
604
+ align-items: center;
605
+ border: 1px dashed $danger-color;
606
+ padding: 10px;
607
+ margin-bottom: 10px;
608
+ circle-exclamation-icon {
609
+ width: 15px;
610
+ fill: $danger-color;
611
+ }
612
+ }
613
+
614
+ .ConsentRequired {
615
+ color: $danger-color;
616
+ }
617
+ </style>
package/src/i18n.js ADDED
@@ -0,0 +1,28 @@
1
+ import {
2
+ dictionary,
3
+ locale,
4
+ addMessages,
5
+ _
6
+ } from 'svelte-i18n';
7
+
8
+ function setupI18n({ withLocale: _locale, translations }) {
9
+ locale.subscribe((data) => {
10
+ if (data == null) {
11
+ dictionary.set(translations);
12
+ locale.set(_locale);
13
+ }
14
+ }); // maybe we will need this to make sure that the i18n is set up only once
15
+ /*dictionary.set(translations);
16
+ locale.set(_locale);*/
17
+ }
18
+
19
+ function addNewMessages(lang, dict) {
20
+ addMessages(lang, dict);
21
+ }
22
+
23
+ function setLocale(_locale) {
24
+ locale.set(_locale);
25
+ }
26
+
27
+ export { _, setupI18n, addNewMessages, setLocale };
28
+
@@ -0,0 +1,2 @@
1
+ <svelte:options tag={'circle-exclamation-icon'} />
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ import PlayerConsents from './PlayerConsents.svelte';
2
+
3
+ !customElements.get('player-consents') && customElements.define('player-consents', PlayerConsents);
4
+ export default PlayerConsents;