@eturnity/eturnity_reusable_components 9.19.1 → 9.19.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.
- package/README.md +97 -0
- package/dist/eturnity-consent-bridge.md +207 -0
- package/dist/eturnity-consent-bridge.min.js +1 -0
- package/package.json +10 -3
- package/src/components/modals/cookieConsent/CookieConsent.vue +3 -15
- package/src/helpers/cookieHelper.js +205 -18
- package/src/utils/eturnity-consent-bridge.js +541 -0
package/README.md
CHANGED
|
@@ -115,3 +115,100 @@ https://docs.npmjs.com/about-semantic-versioning
|
|
|
115
115
|
|
|
116
116
|
npm publish
|
|
117
117
|
```
|
|
118
|
+
|
|
119
|
+
## Provided assets release flow (per-asset version tags)
|
|
120
|
+
|
|
121
|
+
Customers load provided files from jsDelivr using stable tag-based URLs:
|
|
122
|
+
|
|
123
|
+
```html
|
|
124
|
+
<script src="https://cdn.jsdelivr.net/npm/@eturnity/eturnity_reusable_components@latest-consent-bridge/dist/eturnity-consent-bridge.min.js"></script>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Consent bridge integration documentation is maintained in `docs/eturnity-consent-bridge.md` and published to:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
https://cdn.jsdelivr.net/npm/@eturnity/eturnity_reusable_components@latest-consent-bridge/dist/eturnity-consent-bridge.md
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Provided files and docs are configured in `provided-assets.config.js`. Script and doc entries can both define their own `versionTag` (for example `latest-consent-bridge`). Because customers keep these URLs unchanged, we update what they receive by moving those npm dist-tags to a published package version.
|
|
134
|
+
|
|
135
|
+
### Build and publish behavior
|
|
136
|
+
|
|
137
|
+
- `npm run build` runs `vite build` (library), `npm run build:provided-files` (JS CDN bundles), and `npm run copy:provided-docs` (published docs).
|
|
138
|
+
- `npm run build:provided-files` builds files listed in `provided-assets.config.js` into `dist/*.min.js`.
|
|
139
|
+
- `npm run copy:provided-docs` copies docs listed in `provided-assets.config.js` into `dist/*.md`.
|
|
140
|
+
- `npm run verify:provided-files` checks expected provided files exist in `dist/` and are non-empty.
|
|
141
|
+
- `npm run verify:provided-docs` checks expected provided docs exist in `dist/` and are non-empty.
|
|
142
|
+
- `prepack` and `prepublishOnly` run both verification steps to reduce broken publish risk.
|
|
143
|
+
|
|
144
|
+
### Standard release steps
|
|
145
|
+
|
|
146
|
+
1. Publish a new immutable npm version:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm publish
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
2. Optional: promote provided-asset tag(s) so customer script/doc URLs point to that version.
|
|
153
|
+
|
|
154
|
+
Use this only if you want to update what customers receive from stable CDN tag URLs:
|
|
155
|
+
|
|
156
|
+
Script options (`--dry-run`, `--version=…`, `--name=…`, and combinations)
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Preview planned commands only (no npm dist-tag, no registry wait, no cache purge, no CDN verification).
|
|
160
|
+
npm run promote:provided-files-tag -- --dry-run
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Promote all configured promotable assets to package.json version.
|
|
165
|
+
npm run promote:provided-files-tag
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Promote all configured promotable assets to a specific published version.
|
|
170
|
+
npm run promote:provided-files-tag -- --version=9.16.0
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Promote one specific promotable asset by name (uses package.json version).
|
|
175
|
+
npm run promote:provided-files-tag -- --name=eturnity-consent-bridge
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# Promote one specific promotable asset by name to a specific version.
|
|
180
|
+
npm run promote:provided-files-tag -- --name=eturnity-consent-bridge --version=9.16.0
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
When promotion is used, the script:
|
|
184
|
+
|
|
185
|
+
1. updates npm dist-tag(s) for selected promotable asset(s)
|
|
186
|
+
2. waits until npm registry serves updated dist-tag value(s)
|
|
187
|
+
3. purges jsDelivr cache for selected promotable asset(s)
|
|
188
|
+
4. verifies CDN serves promoted version bytes
|
|
189
|
+
|
|
190
|
+
Optional tuning via environment variables:
|
|
191
|
+
|
|
192
|
+
- `PROMOTE_TAG_REGISTRY_RETRIES` (default `24`)
|
|
193
|
+
- `PROMOTE_TAG_REGISTRY_INTERVAL_MS` (default `5000`)
|
|
194
|
+
- `PROMOTE_TAG_VERIFY_RETRIES` (default `12`)
|
|
195
|
+
- `PROMOTE_TAG_VERIFY_INTERVAL_MS` (default `5000`)
|
|
196
|
+
|
|
197
|
+
### Optional rollback (re-point customer URL to older stable package)
|
|
198
|
+
|
|
199
|
+
If a promoted version is broken, you can optionally promote a previously published version:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
npm run promote:provided-files-tag -- --version=<older-published-version>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
# Roll back only one promotable asset tag.
|
|
207
|
+
npm run promote:provided-files-tag -- --name=eturnity-consent-bridge --version=<older-published-version>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npm run promote:provided-files-tag -- --version=9.15.2
|
|
214
|
+
```
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# eturnityConsentBridge
|
|
2
|
+
|
|
3
|
+
Lightweight browser bridge for synchronizing consent-related cookie payloads between a host page and an embedded Eturnity iframe via `postMessage`.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`eturnityConsentBridge` is a public browser/CDN script that exposes a global function:
|
|
8
|
+
|
|
9
|
+
- `window.eturnityConsentBridge(...)`
|
|
10
|
+
- `eturnityConsentBridge(...)`
|
|
11
|
+
|
|
12
|
+
It is designed for third-party embedding on customer websites and supports multiple bridge instances (multiple iframes) on the same page.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Global API for plain HTML and JavaScript-based script injection flows
|
|
17
|
+
- Safe message routing per iframe instance using `event.source` and origin checks
|
|
18
|
+
- Idempotent initialization for repeated calls with the same iframe ID
|
|
19
|
+
- Non-throwing input validation (returns `null` on invalid init input)
|
|
20
|
+
- Cookie fallback persistence and replay on iframe sync-ready handshake
|
|
21
|
+
- Optional debug logging with `logs: true`
|
|
22
|
+
- Lifecycle helpers: per-instance and global cleanup
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
### 1) CDN script tag
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<script src="https://ABSOLUTE_CDN_LINK/eturnity-consent-bridge.min.js"></script>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2) Dynamic script injection
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const script = document.createElement('script')
|
|
36
|
+
script.src = 'https://ABSOLUTE_CDN_LINK/eturnity-consent-bridge.min.js'
|
|
37
|
+
script.async = true
|
|
38
|
+
script.addEventListener('load', () => {
|
|
39
|
+
window.eturnityConsentBridge('my-iframe-id', { type: 'solar_calculator' })
|
|
40
|
+
}, { once: true })
|
|
41
|
+
document.body.appendChild(script)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
After the script is loaded, initialize per iframe:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
eturnityConsentBridge('my-iframe-id', {
|
|
50
|
+
type: 'solar_calculator',
|
|
51
|
+
logs: false,
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Initialization returns:
|
|
56
|
+
|
|
57
|
+
- instance object on success
|
|
58
|
+
- `null` when validation fails
|
|
59
|
+
|
|
60
|
+
The iframe can be missing at init time; the bridge will keep trying to resolve it during message handling.
|
|
61
|
+
|
|
62
|
+
## Message Contract
|
|
63
|
+
|
|
64
|
+
All message names are namespaced by selected `type`:
|
|
65
|
+
|
|
66
|
+
- `eturnity_<type>_cookie_sync_ready` (iframe -> host)
|
|
67
|
+
- `eturnity_<type>_cookie_fallback` (iframe -> host)
|
|
68
|
+
- `eturnity_<type>_cookie_delete` (iframe -> host)
|
|
69
|
+
- `eturnity_<type>_cookie_sync` (host -> iframe)
|
|
70
|
+
|
|
71
|
+
### Payloads
|
|
72
|
+
|
|
73
|
+
- `cookie_fallback` payload:
|
|
74
|
+
- `{ cookieName: string, cookieValue: string }`
|
|
75
|
+
- `cookie_delete` payload:
|
|
76
|
+
- `{ cookieName: string }`
|
|
77
|
+
- `cookie_sync` payload for set/update replay:
|
|
78
|
+
- `{ cookieName: string, cookieValue: string }`
|
|
79
|
+
- `cookie_sync` payload for delete replay:
|
|
80
|
+
- `{ cookieName: string, isDeleted: true }`
|
|
81
|
+
|
|
82
|
+
`isDeleted: true` is the delete marker for iframe listeners.
|
|
83
|
+
|
|
84
|
+
### Replay lifecycle (important)
|
|
85
|
+
|
|
86
|
+
Stored cookie replay can be triggered in two safe ways:
|
|
87
|
+
|
|
88
|
+
- **on init**: right after bridge initialization (`eturnityConsentBridge(...)`)
|
|
89
|
+
- **on handshake**: when iframe sends `eturnity_<type>_cookie_sync_ready`
|
|
90
|
+
|
|
91
|
+
The init replay exists to avoid reload race conditions where the iframe handshake might be sent before the host bridge is fully attached.
|
|
92
|
+
|
|
93
|
+
Delete events are also synchronized: when iframe sends `eturnity_<type>_cookie_delete`, host removes that entry from its cache and then emits `eturnity_<type>_cookie_sync` with `{ cookieName, isDeleted: true }`.
|
|
94
|
+
|
|
95
|
+
## API
|
|
96
|
+
|
|
97
|
+
### `eturnityConsentBridge(iframeId, options)`
|
|
98
|
+
|
|
99
|
+
Creates or updates a bridge instance for a target iframe.
|
|
100
|
+
|
|
101
|
+
- **`iframeId`**: `string` (required)
|
|
102
|
+
- Accepts values with or without leading `#`
|
|
103
|
+
- **`options`**: `object` (required)
|
|
104
|
+
- See [Options](#options)
|
|
105
|
+
|
|
106
|
+
**Returns**
|
|
107
|
+
|
|
108
|
+
- `BridgeInstance` on success
|
|
109
|
+
- `null` on invalid input
|
|
110
|
+
|
|
111
|
+
### `eturnityConsentBridge.destroy(iframeId)`
|
|
112
|
+
|
|
113
|
+
Destroys one bridge instance.
|
|
114
|
+
|
|
115
|
+
- Returns `true` if an instance was removed, otherwise `false`.
|
|
116
|
+
|
|
117
|
+
### `eturnityConsentBridge.destroyAll()`
|
|
118
|
+
|
|
119
|
+
Destroys all bridge instances and removes the shared `message` listener when no instances remain.
|
|
120
|
+
|
|
121
|
+
### `BridgeInstance.destroy()`
|
|
122
|
+
|
|
123
|
+
Returned instance includes `destroy()` to remove itself.
|
|
124
|
+
|
|
125
|
+
## Options
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
type BridgeOptions = {
|
|
129
|
+
type: 'solar_calculator' | 'heating_calculator' | 'e_mobility_configurator'
|
|
130
|
+
logs?: boolean
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **`type`** (required): selects internal message/cookie namespace
|
|
135
|
+
- **`logs`** (optional, default `false`): enables verbose console logging for this instance
|
|
136
|
+
|
|
137
|
+
Unknown option keys are ignored.
|
|
138
|
+
|
|
139
|
+
## Examples
|
|
140
|
+
|
|
141
|
+
### Basic
|
|
142
|
+
|
|
143
|
+
```html
|
|
144
|
+
<iframe id="eturnity-solar" src="https://example-iframe-host/path"></iframe>
|
|
145
|
+
<script src="https://ABSOLUTE_CDN_LINK/eturnity-consent-bridge.min.js"></script>
|
|
146
|
+
<script>
|
|
147
|
+
eturnityConsentBridge('eturnity-solar', { type: 'solar_calculator' })
|
|
148
|
+
</script>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Multiple iframes on one page
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
eturnityConsentBridge('solar-iframe', { type: 'solar_calculator' })
|
|
155
|
+
eturnityConsentBridge('heating-iframe', { type: 'heating_calculator', logs: true })
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Re-initialize same iframe (idempotent update)
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
eturnityConsentBridge('solar-iframe', { type: 'solar_calculator', logs: false })
|
|
162
|
+
eturnityConsentBridge('solar-iframe', { type: 'solar_calculator', logs: true }) // updates existing instance
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Cleanup
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
const instance = eturnityConsentBridge('solar-iframe', { type: 'solar_calculator' })
|
|
169
|
+
instance && instance.destroy()
|
|
170
|
+
|
|
171
|
+
eturnityConsentBridge.destroy('heating-iframe')
|
|
172
|
+
eturnityConsentBridge.destroyAll()
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Troubleshooting
|
|
176
|
+
|
|
177
|
+
### `Missing iframe id` or `Invalid options.type`
|
|
178
|
+
|
|
179
|
+
- Ensure `iframeId` is a non-empty string.
|
|
180
|
+
- Ensure `options.type` is one of:
|
|
181
|
+
- `solar_calculator`
|
|
182
|
+
- `heating_calculator`
|
|
183
|
+
- `e_mobility_configurator`
|
|
184
|
+
|
|
185
|
+
### `Iframe ... was not found during initialization`
|
|
186
|
+
|
|
187
|
+
- Verify iframe ID matches exactly.
|
|
188
|
+
- If iframe is rendered later, you can initialize early; bridge resolves context again during runtime.
|
|
189
|
+
|
|
190
|
+
### `Could not resolve iframe origin`
|
|
191
|
+
|
|
192
|
+
- Ensure iframe has a valid `src` URL.
|
|
193
|
+
|
|
194
|
+
### `Ignoring ... due to origin mismatch`
|
|
195
|
+
|
|
196
|
+
- Host and iframe messaging origins must match the iframe `src` origin resolved by the bridge.
|
|
197
|
+
|
|
198
|
+
### Why replay may happen before `cookie_sync_ready`
|
|
199
|
+
|
|
200
|
+
- This is expected behavior: replay on init is intentional and does not depend on handshake timing.
|
|
201
|
+
- In some page-load orders, the iframe can emit `cookie_sync_ready` before the host bridge listener is attached; in that case you may not see a handshake log.
|
|
202
|
+
- This does not break integration: the iframe consumes `eturnity_<type>_cookie_sync` messages directly, and `cookie_sync_ready` is only a replay signal from iframe to host.
|
|
203
|
+
|
|
204
|
+
### Cookie warning on non-HTTPS host
|
|
205
|
+
|
|
206
|
+
- The bridge writes cookies with `Secure`; on non-HTTPS pages browsers may reject persistence.
|
|
207
|
+
- Use HTTPS in production embedding environments.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){"use strict";(()=>{const u="eturnityConsentBridge",k="__eturnityConsentBridgeState__",C=["solar_calculator","heating_calculator","e_mobility_configurator"];if(typeof window>"u"||typeof document>"u")return;const i=globalThis[k]||{instancesByIframeId:new Map,messageListenerAttached:!1,api:null};if(globalThis[k]=i,typeof i.api=="function"){window[u]=i.api,globalThis[u]=i.api;return}const $=e=>({consentCookieName:`eturnity_${e}_consent`,cookieSyncReadyMessage:`eturnity_${e}_cookie_sync_ready`,cookieFallbackMessage:`eturnity_${e}_cookie_fallback`,cookieDeleteMessage:`eturnity_${e}_cookie_delete`,cookieSyncMessage:`eturnity_${e}_cookie_sync`}),d=e=>typeof e!="string"?"":e.trim().replace(/^#/,""),l=e=>Object.prototype.toString.call(e)==="[object Object]",O=e=>!l(e)||typeof e.type!="string"?"":e.type,a=(e,o,r)=>{if(!e||!e.logs)return;const t=`[eturnityConsentBridge:${e.iframeId}]`;if(typeof r>"u"){console.log(`${t} ${o}`);return}console.log(`${t} ${o}`,r)},h=()=>{let e="path=/; SameSite=Lax";return window.location.protocol==="https:"&&(e+="; Secure"),e},I=(e,o,r)=>{try{const t=new Date;return t.setMonth(t.getMonth()+r),document.cookie=`${e}=${encodeURIComponent(o)}; expires=${t.toUTCString()}; ${h()}`,!0}catch(t){return console.warn(`[eturnityConsentBridge] Failed to write cookie "${e}".`,t),!1}},M=e=>{try{return document.cookie=`${e}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; ${h()}`,!0}catch(o){return console.warn(`[eturnityConsentBridge] Failed to delete cookie "${e}".`,o),!1}},N=e=>{try{const o=`${e}=`,r=document.cookie.split(";");for(let t=0;t<r.length;t+=1){const s=r[t].trim();if(s.indexOf(o)===0)return decodeURIComponent(s.substring(o.length))}return null}catch(o){return console.warn(`[eturnityConsentBridge] Failed to read cookie "${e}".`,o),null}},g=e=>{const o=N(e);if(!o)return{};try{const r=JSON.parse(o);return!r||typeof r!="object"?{}:r}catch{return console.warn(`[eturnityConsentBridge] Invalid JSON in "${e}" cookie. Ignoring stored value.`),{}}},v=(e,o,r)=>{const t=g(e.config.consentCookieName);t[o]={cookieValue:r,updatedAt:new Date().toISOString()};let s="";try{s=JSON.stringify(t)}catch(c){return console.warn(`[eturnityConsentBridge] Failed to serialize cookie cache for iframe "${e.iframeId}".`,c),!1}s.length>3500&&console.warn(`[eturnityConsentBridge] Cookie cache for iframe "${e.iframeId}" is approaching cookie size limits and may be truncated by browsers.`);const n=I(e.config.consentCookieName,s,6);return n&&a(e,`Stored cookie fallback "${o}" in host cookie cache.`),n},T=(e,o)=>{const r=g(e.config.consentCookieName);if(!Object.prototype.hasOwnProperty.call(r,o))return!0;if(delete r[o],Object.keys(r).length===0){const n=M(e.config.consentCookieName);return n&&a(e,`Removed "${o}" from host cookie cache and cleared empty cache cookie.`),n}let t="";try{t=JSON.stringify(r)}catch(n){return console.warn(`[eturnityConsentBridge] Failed to serialize cookie cache for iframe "${e.iframeId}" after deleting "${o}".`,n),!1}const s=I(e.config.consentCookieName,t,6);return s&&a(e,`Removed cookie fallback "${o}" from host cookie cache.`),s},x=e=>!l(e)||typeof e.cookieName!="string"||typeof e.cookieValue!="string"?null:{cookieName:e.cookieName,cookieValue:e.cookieValue},E=e=>!l(e)||typeof e.cookieName!="string"?null:{cookieName:e.cookieName},w=e=>{const o=document.getElementById(e);if(!o||o.tagName!=="IFRAME")return null;const r=o.getAttribute("src")||"";if(!r)return{iframe:o,origin:null};try{return{iframe:o,origin:new URL(r,window.location.origin).origin}}catch{return{iframe:o,origin:null}}},y=e=>{const o=w(e.iframeId);if(!o){e.iframe=null,e.origin=null;return}e.iframe=o.iframe,e.origin=o.origin},m=(e,o)=>{if(y(e),!e.iframe||!e.iframe.contentWindow||!e.origin){console.warn(`[eturnityConsentBridge] Cannot post "${e.config.cookieSyncMessage}" for iframe "${e.iframeId}" because iframe window or origin is not ready.`);return}e.iframe.contentWindow.postMessage({type:e.config.cookieSyncMessage,payload:o},e.origin),a(e,`Posted "${e.config.cookieSyncMessage}" to iframe origin "${e.origin}".`,o)},p=(e,o)=>{const r=o==="sync_ready"?"sync_ready":"init",t=g(e.config.consentCookieName),s=Object.keys(t);if(!s.length){a(e,`No stored cookies to replay (${r}).`);return}a(e,`Replaying ${s.length} stored cookie(s) to iframe (${r}).`),s.forEach(n=>{const c=t[n];!c||typeof c.cookieValue!="string"||m(e,{cookieName:n,cookieValue:c.cookieValue})})},z=e=>{const o=Array.from(i.instancesByIframeId.values());for(let r=0;r<o.length;r+=1){const t=o[r];if(y(t),t.iframe&&t.iframe.contentWindow&&t.iframe.contentWindow===e)return t}return null},_=e=>{if(!e||!l(e.data))return;const o=O(e.data),r=z(e.source);if(!r){o.indexOf("eturnity_")===0&&console.warn(`[eturnityConsentBridge] Received "${o}" but could not match event.source to a registered iframe instance.`);return}if(!r.origin){o.indexOf("eturnity_")===0&&console.warn(`[eturnityConsentBridge] Ignoring "${o}" for iframe "${r.iframeId}" because iframe origin could not be resolved from src.`);return}if(e.origin!==r.origin){console.warn(`[eturnityConsentBridge] Ignoring "${o}" due to origin mismatch for iframe "${r.iframeId}". Expected "${r.origin}", got "${e.origin}".`);return}if(o){if(o===r.config.cookieSyncReadyMessage){a(r,`Iframe reported "${r.config.cookieSyncReadyMessage}".`),p(r,"sync_ready");return}if(o===r.config.cookieFallbackMessage){a(r,`Received message "${o}" from origin "${e.origin}".`);const t=x(e.data.payload);if(!t){console.warn(`[eturnityConsentBridge] Invalid payload for "${o}" from iframe "${r.iframeId}".`);return}v(r,t.cookieName,t.cookieValue),m(r,t);return}if(o===r.config.cookieDeleteMessage){a(r,`Received message "${o}" from origin "${e.origin}".`);const t=E(e.data.payload);if(!t){console.warn(`[eturnityConsentBridge] Invalid payload for "${o}" from iframe "${r.iframeId}".`);return}T(r,t.cookieName),m(r,{cookieName:t.cookieName,isDeleted:!0});return}o.indexOf("eturnity_")===0&&console.warn(`[eturnityConsentBridge] Ignoring unsupported message "${o}" for iframe "${r.iframeId}".`)}},A=()=>{i.messageListenerAttached||(window.addEventListener("message",_),i.messageListenerAttached=!0)},B=()=>{!i.messageListenerAttached||i.instancesByIframeId.size>0||(window.removeEventListener("message",_),i.messageListenerAttached=!1)},S=e=>{const o=d(e);if(!o)return!1;const r=i.instancesByIframeId.delete(o);return B(),r},R=e=>l(e)?typeof e.logs<"u"&&typeof e.logs!="boolean"?{valid:!1,reason:"[eturnityConsentBridge] Invalid options.logs value. Expected boolean when provided."}:typeof e.type!="string"||!C.includes(e.type)?{valid:!1,reason:`[eturnityConsentBridge] Invalid options.type. Supported values: ${C.join(", ")}.`}:{valid:!0,value:{type:e.type,logs:e.logs===!0}}:{valid:!1,reason:'[eturnityConsentBridge] Missing or invalid options object. Use eturnityConsentBridge("iframe-id", { type: "solar_calculator | heating_calculator | e_mobility_configurator", logs: false }).'},L=(e,o)=>{const r=i.instancesByIframeId.get(e);if(r){const b=r.type!==o.type;return r.logs=o.logs,b&&(r.type=o.type,r.config=$(o.type)),y(r),a(r,b?`Bridge updated to type "${o.type}".`:"Bridge re-initialized with existing configuration."),p(r,"init"),r}const t=o.type,s=$(t),n=w(e),c={iframeId:e,iframe:n?n.iframe:null,origin:n?n.origin:null,type:t,config:s,logs:o.logs,destroy:()=>S(e)};return n?n.origin||console.warn(`[eturnityConsentBridge] Could not resolve iframe origin for "${e}". Please make sure iframe src is set to a valid URL.`):console.warn(`[eturnityConsentBridge] Iframe with id "${e}" was not found during initialization. The bridge will start working once the iframe is available.`),i.instancesByIframeId.set(e,c),A(),a(c,`Bridge initialized with type "${t}".`),p(c,"init"),c},f=(e,o)=>{const r=d(e);if(!r)return console.warn('[eturnityConsentBridge] Missing iframe id. Use eturnityConsentBridge("iframe-id", { type: "solar_calculator | heating_calculator | e_mobility_configurator" }).'),null;const t=R(o);return t.valid?L(r,t.value):(console.warn(t.reason),null)};f.destroy=e=>{const o=d(e);return o?S(o):(console.warn('[eturnityConsentBridge] Missing iframe id for destroy(). Use eturnityConsentBridge.destroy("iframe-id").'),!1)},f.destroyAll=()=>{i.instancesByIframeId.clear(),B()},i.api=f,window[u]=f,globalThis[u]=f})()})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eturnity/eturnity_reusable_components",
|
|
3
|
-
"version": "9.19.
|
|
3
|
+
"version": "9.19.2",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"src"
|
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
"private": false,
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "vite",
|
|
13
|
-
"build": "vite build",
|
|
13
|
+
"build": "vite build && npm run build:provided-files && npm run copy:provided-docs",
|
|
14
|
+
"build:provided-files": "node scripts/build-provided-files.js",
|
|
15
|
+
"verify:provided-files": "node scripts/verify-provided-files.js",
|
|
16
|
+
"copy:provided-docs": "node scripts/copy-provided-docs.js",
|
|
17
|
+
"verify:provided-docs": "node scripts/verify-provided-docs.js",
|
|
18
|
+
"purge:provided-files-cache": "node scripts/purge-provided-files-cache.js",
|
|
19
|
+
"promote:provided-files-tag": "node scripts/promote-provided-files-tag.js",
|
|
14
20
|
"lint": "vue-cli-service lint",
|
|
15
21
|
"storybook": "storybook dev -p 6006",
|
|
16
22
|
"build-storybook": "storybook build",
|
|
@@ -18,7 +24,8 @@
|
|
|
18
24
|
"test": "jest",
|
|
19
25
|
"test-coverage": "jest --coverage",
|
|
20
26
|
"merge-remote-master": "node scripts/merge-remote-master.js",
|
|
21
|
-
"
|
|
27
|
+
"prepack": "npm run verify:provided-files && npm run verify:provided-docs",
|
|
28
|
+
"prepublishOnly": "npm run test && npm run build && npm run verify:provided-files && npm run verify:provided-docs"
|
|
22
29
|
},
|
|
23
30
|
"peerDependencies": {
|
|
24
31
|
"vue": "^3.0.0",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
import IconComponent from '../../icon'
|
|
41
41
|
import ToggleComponent from '../../inputs/toggle'
|
|
42
42
|
import MainButton from '../../buttons/mainButton'
|
|
43
|
-
import { saveCookieConsent } from '../../../helpers/cookieHelper'
|
|
43
|
+
import { getCookieValue, saveCookieConsent } from '../../../helpers/cookieHelper'
|
|
44
44
|
|
|
45
45
|
const ModalContainer = styled.div`
|
|
46
46
|
box-sizing: border-box;
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
},
|
|
156
156
|
methods: {
|
|
157
157
|
loadCookieState() {
|
|
158
|
-
const cookieValue =
|
|
158
|
+
const cookieValue = getCookieValue('cookieConsent')
|
|
159
159
|
if (cookieValue) {
|
|
160
160
|
try {
|
|
161
161
|
const cookieData = JSON.parse(cookieValue)
|
|
@@ -186,18 +186,6 @@
|
|
|
186
186
|
})
|
|
187
187
|
return filtered
|
|
188
188
|
},
|
|
189
|
-
getCookieValue(cookieName) {
|
|
190
|
-
const name = cookieName + '='
|
|
191
|
-
const decodedCookie = decodeURIComponent(document.cookie)
|
|
192
|
-
const cookieArray = decodedCookie.split(';')
|
|
193
|
-
for (let i = 0; i < cookieArray.length; i++) {
|
|
194
|
-
let cookie = cookieArray[i].trim()
|
|
195
|
-
if (cookie.indexOf(name) === 0) {
|
|
196
|
-
return cookie.substring(name.length, cookie.length)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return null
|
|
200
|
-
},
|
|
201
189
|
isToggleChecked(choice) {
|
|
202
190
|
// Check if the option is required - if so, always return true
|
|
203
191
|
const option = this.toggleOptions.find((opt) => opt.choice === choice)
|
|
@@ -220,7 +208,7 @@
|
|
|
220
208
|
return
|
|
221
209
|
}
|
|
222
210
|
|
|
223
|
-
const cookieValue =
|
|
211
|
+
const cookieValue = getCookieValue('cookieConsent')
|
|
224
212
|
|
|
225
213
|
if (cookieValue) {
|
|
226
214
|
try {
|
|
@@ -1,29 +1,216 @@
|
|
|
1
|
-
export function
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
export function isRunningInIframe() {
|
|
2
|
+
return typeof window !== 'undefined' && window.self !== window.top
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Iframe cookie sync (parent page ↔ iframe)
|
|
7
|
+
//
|
|
8
|
+
// In an iframe, cookies may not be readable right after normal document.cookie = STRING
|
|
9
|
+
// (ITP / third-party restrictions). We mirror the intended value in
|
|
10
|
+
// syncedCookieFallback so getCookieValue() returns it immediately;
|
|
11
|
+
// the host receives `eturnity_<consentBridgeAppType>_cookie_fallback` via postMessage
|
|
12
|
+
// to persist and echo `eturnity_<consentBridgeAppType>_cookie_sync`.
|
|
13
|
+
// On load, the parent's replay also fills syncedCookieFallback via that message.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const syncedCookieFallback = {}
|
|
17
|
+
let cookieSyncListenerAttachedToParent = false
|
|
18
|
+
let consentBridgeAppType = null
|
|
19
|
+
let parentCookieSyncOrigin = null
|
|
20
|
+
|
|
21
|
+
function hasKnownParentCookieSyncOrigin() {
|
|
22
|
+
const hasKnownOrigin = typeof parentCookieSyncOrigin === 'string' && parentCookieSyncOrigin.trim() !== ''
|
|
23
|
+
if (!hasKnownOrigin) {
|
|
24
|
+
console.warn('[cookieHelper] Cannot sync cookie to parent: origin unknown. Skipping.')
|
|
25
|
+
}
|
|
26
|
+
return hasKnownOrigin
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readSyncedCookieFallback(cookieName) {
|
|
30
|
+
if (!cookieName || typeof cookieName !== 'string') return null
|
|
31
|
+
return Object.prototype.hasOwnProperty.call(syncedCookieFallback, cookieName)
|
|
32
|
+
? syncedCookieFallback[cookieName]
|
|
33
|
+
: null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clearSyncedCookieFallback(cookieName) {
|
|
37
|
+
if (!cookieName || typeof cookieName !== 'string') return
|
|
38
|
+
if (Object.prototype.hasOwnProperty.call(syncedCookieFallback, cookieName)) {
|
|
39
|
+
delete syncedCookieFallback[cookieName]
|
|
7
40
|
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function saveSyncedCookieFallback(cookieName, cookieValue) {
|
|
44
|
+
if (!cookieName || typeof cookieName !== 'string') return
|
|
45
|
+
syncedCookieFallback[cookieName] = cookieValue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getParentOriginFromReferrer() {
|
|
49
|
+
if (typeof document === 'undefined' || !document.referrer) return null
|
|
50
|
+
try {
|
|
51
|
+
return new URL(document.referrer).origin
|
|
52
|
+
} catch (_) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getCookieAttributes() {
|
|
58
|
+
const sameSite = isRunningInIframe() ? 'None' : 'Lax'
|
|
59
|
+
let attributes = `path=/; sameSite=${sameSite}`
|
|
60
|
+
if (window.location.protocol === 'https:') {
|
|
61
|
+
attributes += '; Secure'
|
|
62
|
+
}
|
|
63
|
+
return attributes
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setupParentCookieSyncListener(options = {}) {
|
|
67
|
+
if (typeof window === 'undefined' || !isRunningInIframe()) return
|
|
68
|
+
|
|
69
|
+
consentBridgeAppType = typeof options?.consentBridgeAppType === 'string' ? options.consentBridgeAppType : null
|
|
70
|
+
const onCookieSync = typeof options?.onCookieSync === 'function' ? options.onCookieSync : null
|
|
71
|
+
parentCookieSyncOrigin =
|
|
72
|
+
typeof options?.parentOrigin === 'string' && options.parentOrigin.trim()
|
|
73
|
+
? options.parentOrigin.trim()
|
|
74
|
+
: getParentOriginFromReferrer()
|
|
75
|
+
|
|
76
|
+
if (consentBridgeAppType && !cookieSyncListenerAttachedToParent) {
|
|
77
|
+
window.addEventListener('message', (event) => {
|
|
78
|
+
if (
|
|
79
|
+
event.source !== window.parent ||
|
|
80
|
+
!hasKnownParentCookieSyncOrigin() ||
|
|
81
|
+
event.origin !== parentCookieSyncOrigin ||
|
|
82
|
+
!event.data ||
|
|
83
|
+
event.data.type !== `eturnity_${consentBridgeAppType}_cookie_sync`
|
|
84
|
+
) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const payload = event.data.payload || {}
|
|
89
|
+
if (!payload.cookieName || typeof payload.cookieName !== 'string') return
|
|
90
|
+
|
|
91
|
+
if (payload.isDeleted === true) {
|
|
92
|
+
clearSyncedCookieFallback(payload.cookieName)
|
|
93
|
+
} else if (typeof payload.cookieValue === 'string') {
|
|
94
|
+
saveSyncedCookieFallback(payload.cookieName, payload.cookieValue)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof onCookieSync === 'function') {
|
|
98
|
+
onCookieSync(payload)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
cookieSyncListenerAttachedToParent = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Tell the parent it can start sending sync messages (optional handshake for embed integrations).
|
|
105
|
+
if (
|
|
106
|
+
hasKnownParentCookieSyncOrigin() &&
|
|
107
|
+
window.parent &&
|
|
108
|
+
typeof window.parent.postMessage === 'function'
|
|
109
|
+
) {
|
|
110
|
+
window.parent.postMessage(
|
|
111
|
+
{ type: `eturnity_${consentBridgeAppType}_cookie_sync_ready` },
|
|
112
|
+
parentCookieSyncOrigin
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getRawCookieValue(cookieName) {
|
|
118
|
+
const name = `${cookieName}=`
|
|
119
|
+
const decodedCookie = decodeURIComponent(document.cookie)
|
|
120
|
+
const cookieArray = decodedCookie.split(';')
|
|
121
|
+
for (let i = 0; i < cookieArray.length; i++) {
|
|
122
|
+
const cookie = cookieArray[i].trim()
|
|
123
|
+
if (cookie.indexOf(name) === 0) {
|
|
124
|
+
return cookie.substring(name.length, cookie.length)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getCookieValue(cookieName) {
|
|
131
|
+
const rawCookieValue = getRawCookieValue(cookieName)
|
|
132
|
+
if (rawCookieValue !== null) {
|
|
133
|
+
return rawCookieValue
|
|
134
|
+
}
|
|
135
|
+
return readSyncedCookieFallback(cookieName)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function setCookieWithThirdPartyFallback(cookieName, cookieValue) {
|
|
139
|
+
// If the cookie value is not immediately readable after
|
|
140
|
+
// document.cookie = cookieStr (common in third-party iframes), we keep the intended
|
|
141
|
+
// value in syncedCookieFallback and notify the parent to persist it.
|
|
142
|
+
if (!cookieName || typeof cookieName !== 'string' || typeof cookieValue !== 'string') return
|
|
8
143
|
|
|
9
|
-
const cookieString = JSON.stringify(cookieData)
|
|
10
144
|
const expiryDate = new Date()
|
|
11
145
|
expiryDate.setMonth(expiryDate.getMonth() + 6)
|
|
146
|
+
let cookieStr = `${cookieName}=${encodeURIComponent(
|
|
147
|
+
cookieValue
|
|
148
|
+
)}; expires=${expiryDate.toUTCString()}; ${getCookieAttributes()}`
|
|
12
149
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
)}; expires=${expiryDate.toUTCString()}; path=/`
|
|
150
|
+
document.cookie = cookieStr
|
|
151
|
+
let storedCookie = getRawCookieValue(cookieName)
|
|
16
152
|
|
|
17
|
-
|
|
18
|
-
|
|
153
|
+
if (
|
|
154
|
+
storedCookie !== cookieValue &&
|
|
155
|
+
isRunningInIframe() &&
|
|
156
|
+
hasKnownParentCookieSyncOrigin() &&
|
|
157
|
+
window.parent &&
|
|
158
|
+
typeof window.parent.postMessage === 'function'
|
|
159
|
+
) {
|
|
160
|
+
saveSyncedCookieFallback(cookieName, cookieValue)
|
|
161
|
+
if (consentBridgeAppType) {
|
|
162
|
+
window.parent.postMessage(
|
|
163
|
+
{
|
|
164
|
+
type: `eturnity_${consentBridgeAppType}_cookie_fallback`,
|
|
165
|
+
payload: {
|
|
166
|
+
cookieName,
|
|
167
|
+
cookieValue
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
parentCookieSyncOrigin
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
storedCookie = getCookieValue(cookieName)
|
|
174
|
+
}
|
|
19
175
|
|
|
20
|
-
|
|
21
|
-
|
|
176
|
+
return storedCookie === cookieValue
|
|
177
|
+
}
|
|
22
178
|
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
cookieStr += '; Secure'
|
|
26
|
-
}
|
|
179
|
+
export function deleteCookieWithThirdPartyFallback(cookieName) {
|
|
180
|
+
if (!cookieName || typeof cookieName !== 'string') return false
|
|
27
181
|
|
|
182
|
+
const cookieStr = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; ${getCookieAttributes()}`
|
|
28
183
|
document.cookie = cookieStr
|
|
184
|
+
|
|
185
|
+
clearSyncedCookieFallback(cookieName)
|
|
186
|
+
if (
|
|
187
|
+
isRunningInIframe() &&
|
|
188
|
+
hasKnownParentCookieSyncOrigin() &&
|
|
189
|
+
window.parent &&
|
|
190
|
+
typeof window.parent.postMessage === 'function' &&
|
|
191
|
+
consentBridgeAppType
|
|
192
|
+
) {
|
|
193
|
+
window.parent.postMessage(
|
|
194
|
+
{
|
|
195
|
+
type: `eturnity_${consentBridgeAppType}_cookie_delete`,
|
|
196
|
+
payload: { cookieName }
|
|
197
|
+
},
|
|
198
|
+
parentCookieSyncOrigin
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const storedCookie = getCookieValue(cookieName)
|
|
203
|
+
return storedCookie === null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function saveCookieConsent(categories) {
|
|
207
|
+
const cookieData = {
|
|
208
|
+
categories,
|
|
209
|
+
timestamp: new Date().toISOString(),
|
|
210
|
+
consent_given: true
|
|
211
|
+
}
|
|
212
|
+
setCookieWithThirdPartyFallback(
|
|
213
|
+
'cookieConsent',
|
|
214
|
+
JSON.stringify(cookieData)
|
|
215
|
+
)
|
|
29
216
|
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
;(() => {
|
|
2
|
+
const API_NAME = 'eturnityConsentBridge'
|
|
3
|
+
const STATE_KEY = '__eturnityConsentBridgeState__'
|
|
4
|
+
const CONSENT_COOKIE_MONTHS = 6
|
|
5
|
+
const SUPPORTED_TYPES = [
|
|
6
|
+
'solar_calculator',
|
|
7
|
+
'heating_calculator',
|
|
8
|
+
'e_mobility_configurator',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
12
|
+
|
|
13
|
+
const sharedState = globalThis[STATE_KEY] || {
|
|
14
|
+
instancesByIframeId: new Map(),
|
|
15
|
+
messageListenerAttached: false,
|
|
16
|
+
api: null,
|
|
17
|
+
}
|
|
18
|
+
globalThis[STATE_KEY] = sharedState
|
|
19
|
+
|
|
20
|
+
if (typeof sharedState.api === 'function') {
|
|
21
|
+
window[API_NAME] = sharedState.api
|
|
22
|
+
globalThis[API_NAME] = sharedState.api
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const getTypeConfig = (type) => {
|
|
27
|
+
return {
|
|
28
|
+
consentCookieName: `eturnity_${type}_consent`,
|
|
29
|
+
cookieSyncReadyMessage: `eturnity_${type}_cookie_sync_ready`,
|
|
30
|
+
cookieFallbackMessage: `eturnity_${type}_cookie_fallback`,
|
|
31
|
+
cookieDeleteMessage: `eturnity_${type}_cookie_delete`,
|
|
32
|
+
cookieSyncMessage: `eturnity_${type}_cookie_sync`,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const normalizeIframeId = (iframeId) => {
|
|
37
|
+
if (typeof iframeId !== 'string') return ''
|
|
38
|
+
return iframeId.trim().replace(/^#/, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isPlainObject = (value) => {
|
|
42
|
+
return Object.prototype.toString.call(value) === '[object Object]'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const readMessageType = (data) => {
|
|
46
|
+
if (!isPlainObject(data) || typeof data.type !== 'string') return ''
|
|
47
|
+
return data.type
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const log = (instance, message, details) => {
|
|
51
|
+
if (!instance || !instance.logs) return
|
|
52
|
+
const prefix = `[eturnityConsentBridge:${instance.iframeId}]`
|
|
53
|
+
if (typeof details === 'undefined') {
|
|
54
|
+
console.log(`${prefix} ${message}`)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
console.log(`${prefix} ${message}`, details)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const getHostCookieAttributes = () => {
|
|
61
|
+
let attributes = 'path=/; SameSite=Lax'
|
|
62
|
+
if (window.location.protocol === 'https:') {
|
|
63
|
+
attributes += '; Secure'
|
|
64
|
+
}
|
|
65
|
+
return attributes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const setCookie = (name, value, months) => {
|
|
69
|
+
// Use calendar months so host and iframe expiry stay aligned.
|
|
70
|
+
try {
|
|
71
|
+
const expiresAt = new Date()
|
|
72
|
+
expiresAt.setMonth(expiresAt.getMonth() + months)
|
|
73
|
+
document.cookie = `${name}=${encodeURIComponent(
|
|
74
|
+
value
|
|
75
|
+
)}; expires=${expiresAt.toUTCString()}; ${getHostCookieAttributes()}`
|
|
76
|
+
return true
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.warn(
|
|
79
|
+
`[eturnityConsentBridge] Failed to write cookie "${name}".`,
|
|
80
|
+
error
|
|
81
|
+
)
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const deleteCookie = (name) => {
|
|
87
|
+
try {
|
|
88
|
+
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; ${getHostCookieAttributes()}`
|
|
89
|
+
return true
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn(
|
|
92
|
+
`[eturnityConsentBridge] Failed to delete cookie "${name}".`,
|
|
93
|
+
error
|
|
94
|
+
)
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const getCookie = (name) => {
|
|
100
|
+
try {
|
|
101
|
+
const prefix = `${name}=`
|
|
102
|
+
const parts = document.cookie.split(';')
|
|
103
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
104
|
+
const part = parts[i].trim()
|
|
105
|
+
if (part.indexOf(prefix) === 0) {
|
|
106
|
+
return decodeURIComponent(part.substring(prefix.length))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.warn(`[eturnityConsentBridge] Failed to read cookie "${name}".`, error)
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const readStoredSyncedCookies = (consentCookieName) => {
|
|
117
|
+
// Stored cookie format: { [cookieName]: { cookieValue, updatedAt } }
|
|
118
|
+
const raw = getCookie(consentCookieName)
|
|
119
|
+
if (!raw) return {}
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(raw)
|
|
122
|
+
if (!parsed || typeof parsed !== 'object') return {}
|
|
123
|
+
return parsed
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[eturnityConsentBridge] Invalid JSON in "${consentCookieName}" cookie. Ignoring stored value.`
|
|
127
|
+
)
|
|
128
|
+
return {}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const persistSyncedCookie = (instance, cookieName, cookieValue) => {
|
|
133
|
+
// Keep latest fallback payload in host cookie for page reload replay.
|
|
134
|
+
const currentData = readStoredSyncedCookies(instance.config.consentCookieName)
|
|
135
|
+
currentData[cookieName] = {
|
|
136
|
+
cookieValue,
|
|
137
|
+
updatedAt: new Date().toISOString(),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let serialized = ''
|
|
141
|
+
try {
|
|
142
|
+
serialized = JSON.stringify(currentData)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`[eturnityConsentBridge] Failed to serialize cookie cache for iframe "${instance.iframeId}".`,
|
|
146
|
+
error
|
|
147
|
+
)
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (serialized.length > 3500) {
|
|
152
|
+
console.warn(
|
|
153
|
+
`[eturnityConsentBridge] Cookie cache for iframe "${instance.iframeId}" is approaching cookie size limits and may be truncated by browsers.`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const persisted = setCookie(
|
|
158
|
+
instance.config.consentCookieName,
|
|
159
|
+
serialized,
|
|
160
|
+
CONSENT_COOKIE_MONTHS
|
|
161
|
+
)
|
|
162
|
+
if (persisted) {
|
|
163
|
+
log(instance, `Stored cookie fallback "${cookieName}" in host cookie cache.`)
|
|
164
|
+
}
|
|
165
|
+
return persisted
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const removeSyncedCookie = (instance, cookieName) => {
|
|
169
|
+
const currentData = readStoredSyncedCookies(instance.config.consentCookieName)
|
|
170
|
+
if (!Object.prototype.hasOwnProperty.call(currentData, cookieName)) return true
|
|
171
|
+
|
|
172
|
+
delete currentData[cookieName]
|
|
173
|
+
|
|
174
|
+
if (Object.keys(currentData).length === 0) {
|
|
175
|
+
const deleted = deleteCookie(instance.config.consentCookieName)
|
|
176
|
+
if (deleted) {
|
|
177
|
+
log(instance, `Removed "${cookieName}" from host cookie cache and cleared empty cache cookie.`)
|
|
178
|
+
}
|
|
179
|
+
return deleted
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let serialized = ''
|
|
183
|
+
try {
|
|
184
|
+
serialized = JSON.stringify(currentData)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.warn(
|
|
187
|
+
`[eturnityConsentBridge] Failed to serialize cookie cache for iframe "${instance.iframeId}" after deleting "${cookieName}".`,
|
|
188
|
+
error
|
|
189
|
+
)
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const persisted = setCookie(
|
|
194
|
+
instance.config.consentCookieName,
|
|
195
|
+
serialized,
|
|
196
|
+
CONSENT_COOKIE_MONTHS
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if (persisted) {
|
|
200
|
+
log(instance, `Removed cookie fallback "${cookieName}" from host cookie cache.`)
|
|
201
|
+
}
|
|
202
|
+
return persisted
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const parseCookiePayload = (payload) => {
|
|
206
|
+
if (!isPlainObject(payload)) return null
|
|
207
|
+
if (typeof payload.cookieName !== 'string') return null
|
|
208
|
+
if (typeof payload.cookieValue !== 'string') return null
|
|
209
|
+
return {
|
|
210
|
+
cookieName: payload.cookieName,
|
|
211
|
+
cookieValue: payload.cookieValue,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const parseCookieDeletePayload = (payload) => {
|
|
216
|
+
if (!isPlainObject(payload)) return null
|
|
217
|
+
if (typeof payload.cookieName !== 'string') return null
|
|
218
|
+
return {
|
|
219
|
+
cookieName: payload.cookieName,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const getIframeContext = (iframeId) => {
|
|
224
|
+
const iframe = document.getElementById(iframeId)
|
|
225
|
+
if (!iframe || iframe.tagName !== 'IFRAME') return null
|
|
226
|
+
|
|
227
|
+
const src = iframe.getAttribute('src') || ''
|
|
228
|
+
if (!src) return { iframe, origin: null }
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
return {
|
|
232
|
+
iframe,
|
|
233
|
+
origin: new URL(src, window.location.origin).origin,
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { iframe, origin: null }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const refreshInstanceContext = (instance) => {
|
|
241
|
+
// Re-resolve iframe/origin because src can change after initialization.
|
|
242
|
+
const context = getIframeContext(instance.iframeId)
|
|
243
|
+
if (!context) {
|
|
244
|
+
instance.iframe = null
|
|
245
|
+
instance.origin = null
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
instance.iframe = context.iframe
|
|
249
|
+
instance.origin = context.origin
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const postCookieToIframe = (instance, cookiePayload) => {
|
|
253
|
+
refreshInstanceContext(instance)
|
|
254
|
+
if (!instance.iframe || !instance.iframe.contentWindow || !instance.origin) {
|
|
255
|
+
console.warn(
|
|
256
|
+
`[eturnityConsentBridge] Cannot post "${instance.config.cookieSyncMessage}" for iframe "${instance.iframeId}" because iframe window or origin is not ready.`
|
|
257
|
+
)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
// Post only to the known iframe origin for this instance.
|
|
261
|
+
instance.iframe.contentWindow.postMessage(
|
|
262
|
+
{
|
|
263
|
+
type: instance.config.cookieSyncMessage,
|
|
264
|
+
payload: cookiePayload,
|
|
265
|
+
},
|
|
266
|
+
instance.origin
|
|
267
|
+
)
|
|
268
|
+
log(
|
|
269
|
+
instance,
|
|
270
|
+
`Posted "${instance.config.cookieSyncMessage}" to iframe origin "${instance.origin}".`,
|
|
271
|
+
cookiePayload
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const postStoredCookiesToIframe = (instance, source) => {
|
|
276
|
+
// Replay host-stored values when requested by init or handshake.
|
|
277
|
+
const replaySource = source === 'sync_ready' ? 'sync_ready' : 'init'
|
|
278
|
+
const stored = readStoredSyncedCookies(instance.config.consentCookieName)
|
|
279
|
+
const cookieNames = Object.keys(stored)
|
|
280
|
+
if (!cookieNames.length) {
|
|
281
|
+
log(instance, `No stored cookies to replay (${replaySource}).`)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
log(
|
|
285
|
+
instance,
|
|
286
|
+
`Replaying ${cookieNames.length} stored cookie(s) to iframe (${replaySource}).`
|
|
287
|
+
)
|
|
288
|
+
cookieNames.forEach((cookieName) => {
|
|
289
|
+
const entry = stored[cookieName]
|
|
290
|
+
if (!entry || typeof entry.cookieValue !== 'string') return
|
|
291
|
+
postCookieToIframe(instance, {
|
|
292
|
+
cookieName,
|
|
293
|
+
cookieValue: entry.cookieValue,
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const findMatchingInstance = (eventSource) => {
|
|
299
|
+
// Route by source window so multiple iframes on a page are isolated.
|
|
300
|
+
const instances = Array.from(sharedState.instancesByIframeId.values())
|
|
301
|
+
for (let i = 0; i < instances.length; i += 1) {
|
|
302
|
+
const instance = instances[i]
|
|
303
|
+
refreshInstanceContext(instance)
|
|
304
|
+
if (
|
|
305
|
+
instance.iframe &&
|
|
306
|
+
instance.iframe.contentWindow &&
|
|
307
|
+
instance.iframe.contentWindow === eventSource
|
|
308
|
+
) {
|
|
309
|
+
return instance
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const onMessage = (event) => {
|
|
316
|
+
if (!event || !isPlainObject(event.data)) return
|
|
317
|
+
|
|
318
|
+
const messageType = readMessageType(event.data)
|
|
319
|
+
const instance = findMatchingInstance(event.source)
|
|
320
|
+
if (!instance) {
|
|
321
|
+
if (messageType.indexOf('eturnity_') === 0) {
|
|
322
|
+
console.warn(
|
|
323
|
+
`[eturnityConsentBridge] Received "${messageType}" but could not match event.source to a registered iframe instance.`
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!instance.origin) {
|
|
330
|
+
if (messageType.indexOf('eturnity_') === 0) {
|
|
331
|
+
console.warn(
|
|
332
|
+
`[eturnityConsentBridge] Ignoring "${messageType}" for iframe "${instance.iframeId}" because iframe origin could not be resolved from src.`
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Reject messages not coming from the expected origin of that iframe.
|
|
339
|
+
if (event.origin !== instance.origin) {
|
|
340
|
+
console.warn(
|
|
341
|
+
`[eturnityConsentBridge] Ignoring "${messageType}" due to origin mismatch for iframe "${instance.iframeId}". Expected "${instance.origin}", got "${event.origin}".`
|
|
342
|
+
)
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!messageType) return
|
|
347
|
+
|
|
348
|
+
// Handshake event: iframe requests replay of previously synced cookies.
|
|
349
|
+
if (messageType === instance.config.cookieSyncReadyMessage) {
|
|
350
|
+
log(instance, `Iframe reported "${instance.config.cookieSyncReadyMessage}".`)
|
|
351
|
+
postStoredCookiesToIframe(instance, 'sync_ready')
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (messageType === instance.config.cookieFallbackMessage) {
|
|
356
|
+
log(instance, `Received message "${messageType}" from origin "${event.origin}".`)
|
|
357
|
+
|
|
358
|
+
const payload = parseCookiePayload(event.data.payload)
|
|
359
|
+
if (!payload) {
|
|
360
|
+
console.warn(
|
|
361
|
+
`[eturnityConsentBridge] Invalid payload for "${messageType}" from iframe "${instance.iframeId}".`
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
persistSyncedCookie(instance, payload.cookieName, payload.cookieValue)
|
|
367
|
+
postCookieToIframe(instance, payload)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (messageType === instance.config.cookieDeleteMessage) {
|
|
372
|
+
log(instance, `Received message "${messageType}" from origin "${event.origin}".`)
|
|
373
|
+
|
|
374
|
+
const payload = parseCookieDeletePayload(event.data.payload)
|
|
375
|
+
if (!payload) {
|
|
376
|
+
console.warn(
|
|
377
|
+
`[eturnityConsentBridge] Invalid payload for "${messageType}" from iframe "${instance.iframeId}".`
|
|
378
|
+
)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
removeSyncedCookie(instance, payload.cookieName)
|
|
383
|
+
postCookieToIframe(instance, {
|
|
384
|
+
cookieName: payload.cookieName,
|
|
385
|
+
isDeleted: true,
|
|
386
|
+
})
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (messageType.indexOf('eturnity_') === 0) {
|
|
391
|
+
console.warn(
|
|
392
|
+
`[eturnityConsentBridge] Ignoring unsupported message "${messageType}" for iframe "${instance.iframeId}".`
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const ensureMessageListener = () => {
|
|
398
|
+
// One shared listener handles all registered iframe instances.
|
|
399
|
+
if (sharedState.messageListenerAttached) return
|
|
400
|
+
window.addEventListener('message', onMessage)
|
|
401
|
+
sharedState.messageListenerAttached = true
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const cleanupMessageListenerIfIdle = () => {
|
|
405
|
+
if (
|
|
406
|
+
!sharedState.messageListenerAttached ||
|
|
407
|
+
sharedState.instancesByIframeId.size > 0
|
|
408
|
+
) {
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
window.removeEventListener('message', onMessage)
|
|
412
|
+
sharedState.messageListenerAttached = false
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const destroyInstance = (iframeId) => {
|
|
416
|
+
const normalizedIframeId = normalizeIframeId(iframeId)
|
|
417
|
+
if (!normalizedIframeId) return false
|
|
418
|
+
const removed = sharedState.instancesByIframeId.delete(normalizedIframeId)
|
|
419
|
+
cleanupMessageListenerIfIdle()
|
|
420
|
+
return removed
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const normalizeOptions = (options) => {
|
|
424
|
+
if (!isPlainObject(options)) {
|
|
425
|
+
return {
|
|
426
|
+
valid: false,
|
|
427
|
+
reason:
|
|
428
|
+
'[eturnityConsentBridge] Missing or invalid options object. Use eturnityConsentBridge("iframe-id", { type: "solar_calculator | heating_calculator | e_mobility_configurator", logs: false }).',
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (typeof options.logs !== 'undefined' && typeof options.logs !== 'boolean') {
|
|
433
|
+
return {
|
|
434
|
+
valid: false,
|
|
435
|
+
reason:
|
|
436
|
+
'[eturnityConsentBridge] Invalid options.logs value. Expected boolean when provided.',
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (typeof options.type !== 'string' || !SUPPORTED_TYPES.includes(options.type)) {
|
|
441
|
+
return {
|
|
442
|
+
valid: false,
|
|
443
|
+
reason: `[eturnityConsentBridge] Invalid options.type. Supported values: ${SUPPORTED_TYPES.join(
|
|
444
|
+
', '
|
|
445
|
+
)}.`,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
valid: true,
|
|
451
|
+
value: {
|
|
452
|
+
type: options.type,
|
|
453
|
+
logs: options.logs === true,
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const initInstance = (iframeId, options) => {
|
|
459
|
+
const existing = sharedState.instancesByIframeId.get(iframeId)
|
|
460
|
+
if (existing) {
|
|
461
|
+
const typeChanged = existing.type !== options.type
|
|
462
|
+
existing.logs = options.logs
|
|
463
|
+
if (typeChanged) {
|
|
464
|
+
existing.type = options.type
|
|
465
|
+
existing.config = getTypeConfig(options.type)
|
|
466
|
+
}
|
|
467
|
+
refreshInstanceContext(existing)
|
|
468
|
+
log(existing, typeChanged ? `Bridge updated to type "${options.type}".` : 'Bridge re-initialized with existing configuration.')
|
|
469
|
+
postStoredCookiesToIframe(existing, 'init')
|
|
470
|
+
return existing
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const type = options.type
|
|
474
|
+
const config = getTypeConfig(type)
|
|
475
|
+
const context = getIframeContext(iframeId)
|
|
476
|
+
const instance = {
|
|
477
|
+
iframeId,
|
|
478
|
+
iframe: context ? context.iframe : null,
|
|
479
|
+
origin: context ? context.origin : null,
|
|
480
|
+
type,
|
|
481
|
+
config,
|
|
482
|
+
logs: options.logs,
|
|
483
|
+
destroy: () => destroyInstance(iframeId),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (!context) {
|
|
487
|
+
console.warn(
|
|
488
|
+
`[eturnityConsentBridge] Iframe with id "${iframeId}" was not found during initialization. The bridge will start working once the iframe is available.`
|
|
489
|
+
)
|
|
490
|
+
} else if (!context.origin) {
|
|
491
|
+
console.warn(
|
|
492
|
+
`[eturnityConsentBridge] Could not resolve iframe origin for "${iframeId}". Please make sure iframe src is set to a valid URL.`
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
sharedState.instancesByIframeId.set(iframeId, instance)
|
|
497
|
+
ensureMessageListener()
|
|
498
|
+
log(instance, `Bridge initialized with type "${type}".`)
|
|
499
|
+
postStoredCookiesToIframe(instance, 'init')
|
|
500
|
+
return instance
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const eturnityConsentBridge = (iframeId, options) => {
|
|
504
|
+
const normalizedIframeId = normalizeIframeId(iframeId)
|
|
505
|
+
if (!normalizedIframeId) {
|
|
506
|
+
console.warn(
|
|
507
|
+
'[eturnityConsentBridge] Missing iframe id. Use eturnityConsentBridge("iframe-id", { type: "solar_calculator | heating_calculator | e_mobility_configurator" }).'
|
|
508
|
+
)
|
|
509
|
+
return null
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const normalizedOptions = normalizeOptions(options)
|
|
513
|
+
if (!normalizedOptions.valid) {
|
|
514
|
+
console.warn(normalizedOptions.reason)
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return initInstance(normalizedIframeId, normalizedOptions.value)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
eturnityConsentBridge.destroy = (iframeId) => {
|
|
522
|
+
const normalizedIframeId = normalizeIframeId(iframeId)
|
|
523
|
+
if (!normalizedIframeId) {
|
|
524
|
+
console.warn(
|
|
525
|
+
'[eturnityConsentBridge] Missing iframe id for destroy(). Use eturnityConsentBridge.destroy("iframe-id").'
|
|
526
|
+
)
|
|
527
|
+
return false
|
|
528
|
+
}
|
|
529
|
+
return destroyInstance(normalizedIframeId)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
eturnityConsentBridge.destroyAll = () => {
|
|
533
|
+
sharedState.instancesByIframeId.clear()
|
|
534
|
+
cleanupMessageListenerIfIdle()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Expose API as both window.eturnityConsentBridge(...) and eturnityConsentBridge(...).
|
|
538
|
+
sharedState.api = eturnityConsentBridge
|
|
539
|
+
window[API_NAME] = eturnityConsentBridge
|
|
540
|
+
globalThis[API_NAME] = eturnityConsentBridge
|
|
541
|
+
})()
|