consently 0.2.0 → 1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/MIT-LICENSE +1 -1
- data/README.md +62 -17
- data/app/assets/stylesheets/consently.css +11 -5
- data/app/helpers/consently/tags_helper.rb +70 -19
- data/app/javascript/consently/banner_controller.js +3 -0
- data/app/javascript/consently/events.js +78 -0
- data/config/importmap.rb +1 -0
- data/config/locales/cs.yml +5 -1
- data/config/locales/de.yml +3 -1
- data/config/locales/en.yml +3 -1
- data/config/locales/es.yml +3 -1
- data/config/locales/fr.yml +3 -1
- data/config/locales/hu.yml +3 -1
- data/config/locales/it.yml +3 -1
- data/config/locales/nl.yml +3 -1
- data/config/locales/pl.yml +5 -1
- data/config/locales/sk.yml +5 -1
- data/lib/consently/configuration.rb +16 -0
- data/lib/consently/engine.rb +1 -1
- data/lib/consently/event.rb +54 -0
- data/lib/consently/version.rb +1 -1
- data/lib/consently.rb +15 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dab77a672e8b422500eceb9e90f0481fc4f7abb180e22ca7c12fadf98c4ef9ef
|
|
4
|
+
data.tar.gz: 802a3a6d8c5915acce9d6e843bb5fd8b10a28a7eafb4dd68d0af8bc8617ba43b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3097f21d9037f63e8d27a697e73c2fae5e54b246e1de83e6722a40b2915005400c45ede835a7d8b77aaf1c3da55b877ba6318e23617aad7b3d00de9d0b001e59
|
|
7
|
+
data.tar.gz: 4247ab6d468b280bc7b784c52401d1f4ee53d98e627f168206159bea2daebc08ff3c359124afd71849b7cf535de37f9b50b49795e020099c988db74dfd12fc67
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0 (2026-09-04)
|
|
4
|
+
|
|
5
|
+
- Events speak the language of the page's tags: `gtag('event', ...)` under
|
|
6
|
+
gtag.js, a `dataLayer` push under Google Tag Manager, decided per request
|
|
7
|
+
from the tags in scope or forced with `event_transport`. Before, every event
|
|
8
|
+
was a dataLayer push - which gtag.js ignores, so a shop without a container
|
|
9
|
+
lost its whole ecommerce funnel.
|
|
10
|
+
- An event rendered without consent is held back as an inert script and
|
|
11
|
+
released with the tags when its category is granted, instead of being
|
|
12
|
+
dropped. The page a visitor accepts on now counts its `view_item`.
|
|
13
|
+
- `consently_stream_event` sends an event from a Turbo Stream response - the
|
|
14
|
+
add-to-cart that never renders a page - through a stream action that ships
|
|
15
|
+
with the banner controller; it is sent at once with consent and queued
|
|
16
|
+
until then otherwise.
|
|
17
|
+
- `Consently.track(event, payload, { category })` and `Consently.granted()`
|
|
18
|
+
in JavaScript, on the same queue.
|
|
19
|
+
- `consently_event` is the new name of `consently_data_layer_push`; the old
|
|
20
|
+
one still works.
|
|
21
|
+
|
|
22
|
+
## 0.2.1 (2026-08-13)
|
|
23
|
+
|
|
24
|
+
- The banner's custom properties moved from `.consently` to `:root`, so the
|
|
25
|
+
button on a blocked embed - which lives elsewhere on the page and wears the
|
|
26
|
+
same classes - is styled like the one in the banner instead of losing its
|
|
27
|
+
background. Overriding them on `.consently` still themes the banner.
|
|
28
|
+
- The cookie policy pluralises durations, so a one-day cookie no longer reads
|
|
29
|
+
"1 days"; the Slavic locales carry the full set of forms.
|
|
30
|
+
- A page anybody can open: <https://xeross99.github.io/consently/> runs four
|
|
31
|
+
tags blocked in the browser, and shows what happens to them - and to the
|
|
32
|
+
requests, the cookies and the dataLayer - the moment consent is given.
|
|
33
|
+
|
|
3
34
|
## 0.2.0 (2026-08-13)
|
|
4
35
|
|
|
5
36
|
- `consently_embed` for videos and maps: a YouTube or Vimeo iframe, or a
|
data/MIT-LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -13,6 +13,9 @@ an inert `<script type="text/plain">` the browser will not even fetch, and
|
|
|
13
13
|
becomes a live script the instant the visitor agrees - no page reload, no lost
|
|
14
14
|
pageview.
|
|
15
15
|
|
|
16
|
+
**[Try it in your browser](https://xeross99.github.io/consently/)** - four tags
|
|
17
|
+
blocked on a live page, and what happens to them the moment you click.
|
|
18
|
+
|
|
16
19
|
| | |
|
|
17
20
|
| --- | --- |
|
|
18
21
|
| **Blocks** | GA4, Google Tag Manager, Google Ads, Microsoft Clarity, Meta Pixel, Hotjar, Plausible, anything custom |
|
|
@@ -198,8 +201,9 @@ status panel - ask for a reload instead:
|
|
|
198
201
|
c.reload_after_choice = true
|
|
199
202
|
```
|
|
200
203
|
|
|
201
|
-
Either way
|
|
202
|
-
|
|
204
|
+
Either way the events waiting on that consent go out too (see below), and a
|
|
205
|
+
`consently:change` event fires on `document`, carrying the granted categories,
|
|
206
|
+
so you can react to it yourself:
|
|
203
207
|
|
|
204
208
|
```js
|
|
205
209
|
document.addEventListener("consently:change", ({ detail }) => {
|
|
@@ -234,7 +238,7 @@ button carries `aria-expanded` and `aria-controls`, reopening the panel moves
|
|
|
234
238
|
focus into it, and the animation gives way to `prefers-reduced-motion`. Nobody
|
|
235
239
|
is trapped in a focus cycle they did not ask for.
|
|
236
240
|
|
|
237
|
-
##
|
|
241
|
+
## Events
|
|
238
242
|
|
|
239
243
|
GA4 wants a particular shape, and your models are not it. Hand the helper
|
|
240
244
|
whatever you have:
|
|
@@ -246,17 +250,50 @@ whatever you have:
|
|
|
246
250
|
|
|
247
251
|
Items may be hashes already in GA4 shape, or any object answering to
|
|
248
252
|
`sku`/`id`, `name`, `price`, `quantity`, `category`, `brand`, `variant` - a
|
|
249
|
-
line item or a product usually does.
|
|
250
|
-
|
|
251
|
-
|
|
253
|
+
line item or a product usually does. Anything else:
|
|
254
|
+
|
|
255
|
+
```erb
|
|
256
|
+
<%= consently_event "newsletter_signup", source: "footer" %>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Sent the way your tags listen.** A page running gtag.js gets
|
|
260
|
+
`gtag('event', ...)` calls; a page running Google Tag Manager gets `dataLayer`
|
|
261
|
+
pushes, the previous `ecommerce` object cleared first as Google asks. The gem
|
|
262
|
+
decides per request from the tags in scope - a container means the dataLayer,
|
|
263
|
+
any other Google tag means gtag - because a push meant for a container is
|
|
264
|
+
something gtag.js silently ignores, and a whole checkout funnel can go missing
|
|
265
|
+
that way. Force it if you must:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
c.event_transport = :data_layer # or :gtag; :auto is the default
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Held back, not dropped.** Without consent an event is rendered the way a tag
|
|
272
|
+
is - an inert `<script type="text/plain">` - and released together with the
|
|
273
|
+
tags the moment its category is granted. The product page a visitor accepts
|
|
274
|
+
on still counts its `view_item`.
|
|
252
275
|
|
|
253
|
-
|
|
276
|
+
**From a Turbo Stream.** An add-to-cart that answers with a stream renders no
|
|
277
|
+
page to put a script on. The stream carries the event instead, and the
|
|
278
|
+
JavaScript side sends it at once if the category is granted, or keeps it until
|
|
279
|
+
it is:
|
|
254
280
|
|
|
255
281
|
```erb
|
|
256
|
-
|
|
282
|
+
<%# line_items/create.turbo_stream.erb %>
|
|
283
|
+
<%= turbo_stream.replace "cart", partial: "cart" %>
|
|
284
|
+
<%= consently_stream_event "add_to_cart", items: [@line_item],
|
|
285
|
+
currency: "EUR", value: @line_item.price %>
|
|
257
286
|
```
|
|
258
287
|
|
|
259
|
-
|
|
288
|
+
Nothing to register: the action arrives with the banner controller.
|
|
289
|
+
|
|
290
|
+
**From your own JavaScript.** The same queue, the same rule:
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
Consently.track("newsletter_signup", { source: "footer" })
|
|
294
|
+
Consently.track("lead", { value: 1 }, { category: "marketing", ecommerce: false })
|
|
295
|
+
Consently.granted("analytics") // true or false, right now
|
|
296
|
+
```
|
|
260
297
|
|
|
261
298
|
## Embedded videos and maps
|
|
262
299
|
|
|
@@ -319,11 +356,11 @@ moment the visitor chooses.
|
|
|
319
356
|
|
|
320
357
|
## Styling
|
|
321
358
|
|
|
322
|
-
The banner ships as plain CSS
|
|
323
|
-
|
|
359
|
+
The banner ships as plain CSS, driven by custom properties. Most restyling is a
|
|
360
|
+
few variables in your own stylesheet:
|
|
324
361
|
|
|
325
362
|
```css
|
|
326
|
-
|
|
363
|
+
:root {
|
|
327
364
|
--consently-accent: #4f46e5;
|
|
328
365
|
--consently-accent-text: #ffffff;
|
|
329
366
|
--consently-radius: 0;
|
|
@@ -331,6 +368,11 @@ properties. Most restyling is a few variables in your own stylesheet:
|
|
|
331
368
|
}
|
|
332
369
|
```
|
|
333
370
|
|
|
371
|
+
The defaults live on `:root`, not on `.consently`: a blocked embed sits
|
|
372
|
+
somewhere else on the page and its button wears the same classes, so variables
|
|
373
|
+
scoped to the banner would never reach it. Scope them tighter if you mean to,
|
|
374
|
+
`.consently` included - it is your stylesheet.
|
|
375
|
+
|
|
334
376
|
Want the markup instead? Take the partial over, and turn the gem's stylesheet
|
|
335
377
|
off so it stops loading:
|
|
336
378
|
|
|
@@ -368,13 +410,14 @@ Helpers:
|
|
|
368
410
|
|
|
369
411
|
| | |
|
|
370
412
|
| --- | --- |
|
|
371
|
-
| `consently_tags` | `<head>`: the stylesheet, consent mode defaults,
|
|
413
|
+
| `consently_tags` | `<head>`: the stylesheet, consent mode defaults, every tag (blocked or live), and what the JavaScript side needs to know |
|
|
372
414
|
| `consently_noscript_tags` | after `<body>`: GTM and Meta fallbacks, for granted categories only |
|
|
373
415
|
| `consently_banner` | the banner, the panel, and the JavaScript that releases blocked tags |
|
|
374
416
|
| `consently_policy` | the generated cookie policy: categories, vendors, cookies, durations |
|
|
375
417
|
| `consently_preferences_link` | "Cookie settings" link; anything with `data-consently-open` reopens the panel |
|
|
376
|
-
| `consently_ecommerce(event, items:, **params)` | a GA4 ecommerce event, items mapped from your own objects |
|
|
377
|
-
| `
|
|
418
|
+
| `consently_ecommerce(event, items:, **params)` | a GA4 ecommerce event, items mapped from your own objects; held back until consent |
|
|
419
|
+
| `consently_event(event, **payload)` | any other event, gtag or dataLayer as the page's tags expect; held back until consent |
|
|
420
|
+
| `consently_stream_event(event, items:, **payload)` | the same from a Turbo Stream response, sent or queued in the browser |
|
|
378
421
|
| `consently_embed(kind, id, category:, ratio:)` | a video or map that waits for consent |
|
|
379
422
|
| `consently_consent` | the current `Consently::Consent`; `granted?(:analytics)` in your own views |
|
|
380
423
|
|
|
@@ -389,6 +432,7 @@ Configuration:
|
|
|
389
432
|
| `c.enabled` | `true`, `false`, or a callable taking the request |
|
|
390
433
|
| `c.reload_after_choice` | reload once a choice is made; off by default |
|
|
391
434
|
| `c.google_consent_mode` | `:basic` (default), `:advanced`, or `false` |
|
|
435
|
+
| `c.event_transport` | `:auto` (default: gtag under gtag.js, the dataLayer under a container), `:gtag`, `:data_layer` |
|
|
392
436
|
| `c.log_consents`, `c.consent_subject` | store proof of each decision, optionally naming who |
|
|
393
437
|
| `c.stylesheet` | link the banner's CSS; off if you style it yourself |
|
|
394
438
|
| `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path`, `c.cookie_domain` | where the choice is kept |
|
|
@@ -396,8 +440,9 @@ Configuration:
|
|
|
396
440
|
| `c.respect_do_not_track`, `c.respect_global_privacy_control` | treat an opt-out signal as a rejection |
|
|
397
441
|
| `c.consent_required` | who has to be asked at all; false means no banner and everything granted |
|
|
398
442
|
|
|
399
|
-
JavaScript:
|
|
400
|
-
|
|
443
|
+
JavaScript: `Consently.track(event, payload, { category })` sends or queues
|
|
444
|
+
an event, `Consently.granted(category)` says where things stand, and a
|
|
445
|
+
`consently:change` event fires on `document` with the granted categories.
|
|
401
446
|
|
|
402
447
|
## Licence
|
|
403
448
|
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
* The banner's own styles. Plain CSS on purpose: a gem that only works if the
|
|
3
3
|
* host application runs Tailwind is half a gem.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* restyling means overriding a handful of
|
|
7
|
-
* markup:
|
|
5
|
+
* Every rule is scoped under .consently, .consently-embed or .consently-policy
|
|
6
|
+
* and driven by custom properties, so restyling means overriding a handful of
|
|
7
|
+
* variables rather than forking the markup:
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* :root { --consently-accent: #4f46e5; --consently-radius: 0; }
|
|
10
|
+
*
|
|
11
|
+
* The defaults live on :root rather than on .consently because a blocked embed
|
|
12
|
+
* is somewhere else on the page entirely, and its button wears the same
|
|
13
|
+
* classes: scoped to the banner, those variables would not reach it.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
:root {
|
|
13
17
|
--consently-background: #ffffff;
|
|
14
18
|
--consently-text: #111827;
|
|
15
19
|
--consently-muted: #4b5563;
|
|
@@ -19,7 +23,9 @@
|
|
|
19
23
|
--consently-radius: 0.75rem;
|
|
20
24
|
--consently-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
|
21
25
|
--consently-max-width: 36rem;
|
|
26
|
+
}
|
|
22
27
|
|
|
28
|
+
.consently {
|
|
23
29
|
position: fixed;
|
|
24
30
|
inset-inline: 0;
|
|
25
31
|
bottom: 0;
|
|
@@ -9,7 +9,7 @@ module Consently
|
|
|
9
9
|
def consently_tags
|
|
10
10
|
return "".html_safe unless consently_enabled?
|
|
11
11
|
|
|
12
|
-
parts = []
|
|
12
|
+
parts = [ consently_state_tags ]
|
|
13
13
|
parts << consently_stylesheet_tag if Consently.config.stylesheet
|
|
14
14
|
parts << consently_consent_mode_tag if Consently.config.google_consent_mode
|
|
15
15
|
Consently.tags_for(request).each do |provider|
|
|
@@ -75,17 +75,21 @@ module Consently
|
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
#
|
|
79
|
-
#
|
|
78
|
+
# An analytics event from a view, sent the way this page's tags expect -
|
|
79
|
+
# gtag('event', ...) under gtag.js, a dataLayer push under Google Tag
|
|
80
|
+
# Manager (see Consently.event_transport_for) - and only with consent.
|
|
81
|
+
# Without it the event is held back the way a tag is: an inert script
|
|
82
|
+
# the banner releases the moment the category is granted, so the page a
|
|
83
|
+
# visitor accepts on still counts.
|
|
80
84
|
#
|
|
81
|
-
# <%=
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
payload = payload.merge(event: event)
|
|
86
|
-
consently_inline_script "window.dataLayer = window.dataLayer || []; window.dataLayer.push(#{payload.to_json});"
|
|
85
|
+
# <%= consently_event("newsletter_signup", source: "footer") %>
|
|
86
|
+
def consently_event(event, category: :analytics, **payload)
|
|
87
|
+
consently_event_tag(Event.new(event, payload), category)
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
# The name this helper had before it learnt to speak gtag.
|
|
91
|
+
alias_method :consently_data_layer_push, :consently_event
|
|
92
|
+
|
|
89
93
|
# A GA4 ecommerce event in the shape Google expects, from whatever your
|
|
90
94
|
# models happen to look like:
|
|
91
95
|
#
|
|
@@ -96,19 +100,32 @@ module Consently
|
|
|
96
100
|
# few obvious names (id/sku, name, price, quantity, category, brand,
|
|
97
101
|
# variant) - a LineItem or a Product usually does.
|
|
98
102
|
#
|
|
99
|
-
#
|
|
100
|
-
# events on one page cannot bleed into
|
|
103
|
+
# Under Google Tag Manager the previous ecommerce object is cleared
|
|
104
|
+
# first, as Google asks, so two events on one page cannot bleed into
|
|
105
|
+
# each other. Held back without consent, like consently_event.
|
|
101
106
|
def consently_ecommerce(event, items: [], category: :analytics, **params)
|
|
102
|
-
|
|
107
|
+
consently_event_tag(Event.new(event, params.merge(items: consently_ecommerce_items(items)), ecommerce: true), category)
|
|
108
|
+
end
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
# The same event from a Turbo Stream response - an add-to-cart that
|
|
111
|
+
# never renders a page. Items make it an ecommerce event. The stream
|
|
112
|
+
# carries the event, not a script: the JavaScript side sends it at once
|
|
113
|
+
# if the category is granted and keeps it until then otherwise, so a
|
|
114
|
+
# cart filled before the click is counted after it.
|
|
115
|
+
#
|
|
116
|
+
# <%= consently_stream_event("add_to_cart", items: [ @line_item ], currency: "EUR", value: 12.5) %>
|
|
117
|
+
def consently_stream_event(event, items: nil, category: :analytics, **payload)
|
|
118
|
+
return "".html_safe unless consently_enabled?
|
|
106
119
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
payload = payload.merge(items: consently_ecommerce_items(items)) unless items.nil?
|
|
121
|
+
event = Event.new(event, payload, ecommerce: !items.nil?)
|
|
122
|
+
|
|
123
|
+
content_tag("turbo-stream", "",
|
|
124
|
+
action: "consently_event",
|
|
125
|
+
event: event.name,
|
|
126
|
+
category: category,
|
|
127
|
+
ecommerce: event.ecommerce?,
|
|
128
|
+
payload: event.payload.to_json)
|
|
112
129
|
end
|
|
113
130
|
|
|
114
131
|
# A complete cookie policy for the tags this request would load: every
|
|
@@ -167,6 +184,40 @@ module Consently
|
|
|
167
184
|
Consently.enabled?(request)
|
|
168
185
|
end
|
|
169
186
|
|
|
187
|
+
# Two <meta> tags the JavaScript side reads: which categories this page
|
|
188
|
+
# was rendered with, and how events are to be sent. Provisional head
|
|
189
|
+
# elements, so Turbo Drive swaps them on every visit.
|
|
190
|
+
def consently_state_tags
|
|
191
|
+
granted = Consently.config.categories.select { |category| consently_consent.granted?(category) }
|
|
192
|
+
|
|
193
|
+
safe_join([
|
|
194
|
+
tag.meta(name: "consently-granted", content: granted.join(" ")),
|
|
195
|
+
tag.meta(name: "consently-transport", content: consently_event_transport)
|
|
196
|
+
], "\n")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def consently_event_transport
|
|
200
|
+
@consently_event_transport ||= Consently.event_transport_for(request)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Live when the category is granted; otherwise inert, and released by the
|
|
204
|
+
# banner together with the tags the moment it is.
|
|
205
|
+
def consently_event_tag(event, category)
|
|
206
|
+
return "".html_safe unless consently_enabled?
|
|
207
|
+
|
|
208
|
+
javascript = event.to_js(consently_event_transport)
|
|
209
|
+
return consently_inline_script(javascript) if consently_consent.granted?(category)
|
|
210
|
+
|
|
211
|
+
attributes = { type: "text/plain", data: { "consently-category" => category } }
|
|
212
|
+
attributes[:nonce] = content_security_policy_nonce if content_security_policy_nonce.present?
|
|
213
|
+
|
|
214
|
+
content_tag(:script, javascript.html_safe, attributes)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def consently_ecommerce_items(items)
|
|
218
|
+
Array(items).map { |item| consently_ecommerce_item(item) }
|
|
219
|
+
end
|
|
220
|
+
|
|
170
221
|
# Whether this tag may run now. Normally that means consent; under
|
|
171
222
|
# advanced consent mode Google's own tags also load before it, having
|
|
172
223
|
# been told to store nothing until it arrives.
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
// Loaded for its side effects: the event queue, the Turbo Stream action and
|
|
3
|
+
// window.Consently.track come along with the banner.
|
|
4
|
+
import "consently/events"
|
|
2
5
|
|
|
3
6
|
// The banner, the preferences panel, and the part that actually matters:
|
|
4
7
|
// turning the blocked <script type="text/plain"> tags into live ones the
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Analytics events that wait for consent.
|
|
2
|
+
//
|
|
3
|
+
// Three roads lead here. An event rendered into a page without consent is an
|
|
4
|
+
// inert <script> the banner releases together with the tags - nothing to do
|
|
5
|
+
// on this side. A <turbo-stream action="consently_event"> comes from a
|
|
6
|
+
// response that renders no page, and window.Consently.track() from your own
|
|
7
|
+
// code; those two are sent at once when their category is granted and kept
|
|
8
|
+
// until it is otherwise, so an add-to-cart that happened before the click is
|
|
9
|
+
// counted after it.
|
|
10
|
+
//
|
|
11
|
+
// The page says what is granted and how to send in two <meta> tags written by
|
|
12
|
+
// consently_tags. The banner's consently:change event overrides the first the
|
|
13
|
+
// moment a choice is made, and the next Turbo visit brings a fresh page whose
|
|
14
|
+
// tags already know.
|
|
15
|
+
|
|
16
|
+
const NECESSARY = "necessary"
|
|
17
|
+
const waiting = []
|
|
18
|
+
let decided = null
|
|
19
|
+
|
|
20
|
+
export function track(name, payload = {}, { category = "analytics", ecommerce = false } = {}) {
|
|
21
|
+
const event = { name, payload, category, ecommerce }
|
|
22
|
+
|
|
23
|
+
if (granted(category)) send(event)
|
|
24
|
+
else waiting.push(event)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function granted(category) {
|
|
28
|
+
if (category === NECESSARY) return true
|
|
29
|
+
|
|
30
|
+
return (decided ?? meta("consently-granted").split(" ")).includes(category)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function meta(name) {
|
|
34
|
+
return document.querySelector(`meta[name="${name}"]`)?.content ?? ""
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function send({ name, payload, ecommerce }) {
|
|
38
|
+
window.dataLayer = window.dataLayer || []
|
|
39
|
+
|
|
40
|
+
if (meta("consently-transport") === "gtag") {
|
|
41
|
+
// gtag.js reads the queue, so this is safe before the tag has loaded.
|
|
42
|
+
window.gtag = window.gtag || function () { window.dataLayer.push(arguments) }
|
|
43
|
+
window.gtag("event", name, payload)
|
|
44
|
+
} else if (ecommerce) {
|
|
45
|
+
window.dataLayer.push({ ecommerce: null })
|
|
46
|
+
window.dataLayer.push({ event: name, ecommerce: payload })
|
|
47
|
+
} else {
|
|
48
|
+
window.dataLayer.push({ event: name, ...payload })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function release(categories) {
|
|
53
|
+
decided = [...categories, NECESSARY]
|
|
54
|
+
|
|
55
|
+
waiting.splice(0).forEach((event) => (granted(event.category) ? send(event) : waiting.push(event)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
document.addEventListener("consently:change", (event) => release(event.detail.categories))
|
|
59
|
+
|
|
60
|
+
// A new page carries the server's reading of the cookie, which also knows
|
|
61
|
+
// about policy versions and expiry; from here on it is the one to trust.
|
|
62
|
+
document.addEventListener("turbo:load", () => { decided = null })
|
|
63
|
+
|
|
64
|
+
// The Turbo Stream action. A listener rather than an entry in
|
|
65
|
+
// Turbo.StreamActions, so the gem imports nothing from Turbo and the order
|
|
66
|
+
// the modules load in does not matter.
|
|
67
|
+
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
68
|
+
const stream = event.target
|
|
69
|
+
if (stream.getAttribute("action") !== "consently_event") return
|
|
70
|
+
|
|
71
|
+
event.preventDefault()
|
|
72
|
+
track(stream.getAttribute("event"), JSON.parse(stream.getAttribute("payload") || "{}"), {
|
|
73
|
+
category: stream.getAttribute("category") || "analytics",
|
|
74
|
+
ecommerce: stream.getAttribute("ecommerce") === "true"
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
window.Consently = Object.assign(window.Consently || {}, { track, granted })
|
data/config/importmap.rb
CHANGED
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
# resolves without anyone editing config/importmap.rb.
|
|
3
3
|
pin "consently/banner_controller", to: "consently/banner_controller.js", preload: true
|
|
4
4
|
pin "consently/embed_controller", to: "consently/embed_controller.js", preload: true
|
|
5
|
+
pin "consently/events", to: "consently/events.js", preload: true
|
data/config/locales/cs.yml
CHANGED
|
@@ -23,7 +23,11 @@ cs:
|
|
|
23
23
|
no_cookies: "Nenastavuje žádné cookies."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Doba uložení"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} den"
|
|
28
|
+
few: "%{count} dny"
|
|
29
|
+
many: "%{count} dne"
|
|
30
|
+
other: "%{count} dní"
|
|
27
31
|
duration_session: "do zavření prohlížeče"
|
|
28
32
|
own_heading: "Samotná cookie se souhlasem"
|
|
29
33
|
own_body: "Vaše volba je uložená v jedné cookie ve vašem prohlížeči: kategorie, se kterými jste souhlasili, verze těchto zásad a čas. Nic o vás."
|
data/config/locales/de.yml
CHANGED
|
@@ -23,7 +23,9 @@ de:
|
|
|
23
23
|
no_cookies: "Setzt keine Cookies."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Speicherdauer"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} Tag"
|
|
28
|
+
other: "%{count} Tage"
|
|
27
29
|
duration_session: "bis zum Schließen des Browsers"
|
|
28
30
|
own_heading: "Das Einwilligungs-Cookie selbst"
|
|
29
31
|
own_body: "Ihre Wahl liegt in einem Cookie in Ihrem Browser: die Kategorien, denen Sie zugestimmt haben, die Version dieser Richtlinie und die Uhrzeit. Nichts über Sie."
|
data/config/locales/en.yml
CHANGED
|
@@ -23,7 +23,9 @@ en:
|
|
|
23
23
|
no_cookies: "Sets no cookies."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Kept for"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} day"
|
|
28
|
+
other: "%{count} days"
|
|
27
29
|
duration_session: "until you close the browser"
|
|
28
30
|
own_heading: "The consent cookie itself"
|
|
29
31
|
own_body: "Your choice is kept in one cookie in your browser: the categories you agreed to, the version of this policy and the time. Nothing about you."
|
data/config/locales/es.yml
CHANGED
|
@@ -23,7 +23,9 @@ es:
|
|
|
23
23
|
no_cookies: "No instala cookies."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Se conserva"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} día"
|
|
28
|
+
other: "%{count} días"
|
|
27
29
|
duration_session: "hasta que cierres el navegador"
|
|
28
30
|
own_heading: "La propia cookie de consentimiento"
|
|
29
31
|
own_body: "Tu elección se guarda en una cookie de tu navegador: las categorías que aceptaste, la versión de esta política y la hora. Nada sobre ti."
|
data/config/locales/fr.yml
CHANGED
|
@@ -23,7 +23,9 @@ fr:
|
|
|
23
23
|
no_cookies: "Ne dépose aucun cookie."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Conservé"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} jour"
|
|
28
|
+
other: "%{count} jours"
|
|
27
29
|
duration_session: "jusqu'à la fermeture du navigateur"
|
|
28
30
|
own_heading: "Le cookie de consentement lui-même"
|
|
29
31
|
own_body: "Votre choix tient dans un cookie de votre navigateur : les catégories acceptées, la version de cette politique et l'heure. Rien sur vous."
|
data/config/locales/hu.yml
CHANGED
|
@@ -23,7 +23,9 @@ hu:
|
|
|
23
23
|
no_cookies: "Nem helyez el sütit."
|
|
24
24
|
cookie_name: "Süti"
|
|
25
25
|
cookie_duration: "Megőrzés"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} nap"
|
|
28
|
+
other: "%{count} nap"
|
|
27
29
|
duration_session: "a böngésző bezárásáig"
|
|
28
30
|
own_heading: "Maga a hozzájárulási süti"
|
|
29
31
|
own_body: "A választásod egyetlen sütiben van a böngésződben: az elfogadott kategóriák, e szabályzat verziója és az idő. Rólad semmi."
|
data/config/locales/it.yml
CHANGED
|
@@ -23,7 +23,9 @@ it:
|
|
|
23
23
|
no_cookies: "Non imposta cookie."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Conservato"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} giorno"
|
|
28
|
+
other: "%{count} giorni"
|
|
27
29
|
duration_session: "fino alla chiusura del browser"
|
|
28
30
|
own_heading: "Il cookie di consenso"
|
|
29
31
|
own_body: "La tua scelta sta in un cookie nel tuo browser: le categorie accettate, la versione di questa policy e l'ora. Nulla su di te."
|
data/config/locales/nl.yml
CHANGED
|
@@ -23,7 +23,9 @@ nl:
|
|
|
23
23
|
no_cookies: "Plaatst geen cookies."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Bewaard"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} dag"
|
|
28
|
+
other: "%{count} dagen"
|
|
27
29
|
duration_session: "tot je de browser sluit"
|
|
28
30
|
own_heading: "De toestemmingscookie zelf"
|
|
29
31
|
own_body: "Je keuze staat in één cookie in je browser: de categorieën die je toestond, de versie van dit beleid en het tijdstip. Niets over jou."
|
data/config/locales/pl.yml
CHANGED
|
@@ -23,7 +23,11 @@ pl:
|
|
|
23
23
|
no_cookies: "Nie ustawia ciasteczek."
|
|
24
24
|
cookie_name: "Ciasteczko"
|
|
25
25
|
cookie_duration: "Przechowywane"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} dzień"
|
|
28
|
+
few: "%{count} dni"
|
|
29
|
+
many: "%{count} dni"
|
|
30
|
+
other: "%{count} dni"
|
|
27
31
|
duration_session: "do zamknięcia przeglądarki"
|
|
28
32
|
own_heading: "Samo ciasteczko zgody"
|
|
29
33
|
own_body: "Twój wybór trzymamy w jednym ciasteczku w Twojej przeglądarce: kategorie, na które się zgodziłeś, wersja tej polityki i czas. Nic o Tobie."
|
data/config/locales/sk.yml
CHANGED
|
@@ -23,7 +23,11 @@ sk:
|
|
|
23
23
|
no_cookies: "Nenastavuje žiadne cookies."
|
|
24
24
|
cookie_name: "Cookie"
|
|
25
25
|
cookie_duration: "Doba uloženia"
|
|
26
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} deň"
|
|
28
|
+
few: "%{count} dni"
|
|
29
|
+
many: "%{count} dňa"
|
|
30
|
+
other: "%{count} dní"
|
|
27
31
|
duration_session: "do zatvorenia prehliadača"
|
|
28
32
|
own_heading: "Samotná cookie so súhlasom"
|
|
29
33
|
own_body: "Vaša voľba je uložená v jednej cookie vo vašom prehliadači: kategórie, s ktorými ste súhlasili, verzia týchto zásad a čas. Nič o vás."
|
|
@@ -50,6 +50,21 @@ module Consently
|
|
|
50
50
|
google_consent_mode == :advanced
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# How events are sent: :gtag for a page running gtag.js, :data_layer for
|
|
54
|
+
# one running Google Tag Manager. The default, :auto, decides per request
|
|
55
|
+
# from the tags in its scope (see Consently.event_transport_for) and is
|
|
56
|
+
# right unless you push to a container the gem does not know about.
|
|
57
|
+
attr_reader :event_transport
|
|
58
|
+
|
|
59
|
+
def event_transport=(transport)
|
|
60
|
+
transport = transport&.to_sym
|
|
61
|
+
unless [ :auto, *Event::TRANSPORTS ].include?(transport)
|
|
62
|
+
raise ArgumentError, "event_transport must be :auto, :gtag or :data_layer"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@event_transport = transport
|
|
66
|
+
end
|
|
67
|
+
|
|
53
68
|
# Store a row per decision, as proof of consent. Needs the engine mounted
|
|
54
69
|
# and the migration from `rails g consently:consent_log`.
|
|
55
70
|
attr_accessor :log_consents
|
|
@@ -109,6 +124,7 @@ module Consently
|
|
|
109
124
|
@consent_version = 1
|
|
110
125
|
@enabled = true
|
|
111
126
|
@google_consent_mode = :basic
|
|
127
|
+
@event_transport = :auto
|
|
112
128
|
@stylesheet = true
|
|
113
129
|
@log_consents = false
|
|
114
130
|
@respect_do_not_track = false
|
data/lib/consently/engine.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Consently
|
|
|
14
14
|
if app.config.respond_to?(:assets)
|
|
15
15
|
app.config.assets.paths << root.join("app/javascript")
|
|
16
16
|
if app.config.assets.respond_to?(:precompile)
|
|
17
|
-
app.config.assets.precompile += %w[consently.css consently/banner_controller.js consently/embed_controller.js]
|
|
17
|
+
app.config.assets.precompile += %w[consently.css consently/banner_controller.js consently/embed_controller.js consently/events.js]
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "active_support/core_ext/object/json"
|
|
2
|
+
|
|
3
|
+
module Consently
|
|
4
|
+
# One analytics event on its way to the page: a name, a payload, and whether
|
|
5
|
+
# it is a GA4 ecommerce event. It is written as JavaScript for whichever
|
|
6
|
+
# transport the page runs on - gtag('event', ...) for gtag.js, a dataLayer
|
|
7
|
+
# push for Google Tag Manager - so the helpers never have to know which.
|
|
8
|
+
class Event
|
|
9
|
+
TRANSPORTS = %i[gtag data_layer].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name, :payload
|
|
12
|
+
|
|
13
|
+
def initialize(name, payload = {}, ecommerce: false)
|
|
14
|
+
@name = name.to_s
|
|
15
|
+
@payload = payload.to_h.compact
|
|
16
|
+
@ecommerce = ecommerce
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ecommerce?
|
|
20
|
+
@ecommerce
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_js(transport)
|
|
24
|
+
case transport.to_sym
|
|
25
|
+
when :gtag then gtag_js
|
|
26
|
+
when :data_layer then data_layer_js
|
|
27
|
+
else raise ArgumentError, "Consently: unknown event transport #{transport.inspect}, expected one of #{TRANSPORTS.join(", ")}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# gtag.js reads the dataLayer as a queue, so an event pushed before the
|
|
34
|
+
# tag has loaded is not lost. That is what lets a released event fire the
|
|
35
|
+
# moment consent arrives, while gtag.js is still on its way.
|
|
36
|
+
def gtag_js
|
|
37
|
+
"window.dataLayer = window.dataLayer || []; " \
|
|
38
|
+
"window.gtag = window.gtag || function(){dataLayer.push(arguments);}; " \
|
|
39
|
+
"gtag('event', #{name.to_json}, #{payload.to_json});"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def data_layer_js
|
|
43
|
+
if ecommerce?
|
|
44
|
+
# Cleared first, as Google asks, so two events on one page cannot
|
|
45
|
+
# bleed into each other.
|
|
46
|
+
"window.dataLayer = window.dataLayer || []; " \
|
|
47
|
+
"window.dataLayer.push({ ecommerce: null }); " \
|
|
48
|
+
"window.dataLayer.push(#{{ event: name, ecommerce: payload }.to_json});"
|
|
49
|
+
else
|
|
50
|
+
"window.dataLayer = window.dataLayer || []; window.dataLayer.push(#{payload.merge(event: name).to_json});"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/consently/version.rb
CHANGED
data/lib/consently.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "consently/version"
|
|
|
7
7
|
require "consently/script"
|
|
8
8
|
require "consently/cookie"
|
|
9
9
|
require "consently/consent"
|
|
10
|
+
require "consently/event"
|
|
10
11
|
require "consently/providers/base"
|
|
11
12
|
require "consently/providers"
|
|
12
13
|
require "consently/configuration"
|
|
@@ -60,6 +61,20 @@ module Consently
|
|
|
60
61
|
resolve(config.enabled, request)
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
# How events reach Google for this request. Google Tag Manager reads
|
|
65
|
+
# dataLayer pushes; gtag.js only acts on gtag('event', ...) calls, and a
|
|
66
|
+
# push meant for a container it silently ignores. :auto looks at the tags
|
|
67
|
+
# in the request's scope: a container present means the dataLayer,
|
|
68
|
+
# otherwise any Google tag means gtag.
|
|
69
|
+
def event_transport_for(request = nil)
|
|
70
|
+
return config.event_transport unless config.event_transport == :auto
|
|
71
|
+
|
|
72
|
+
providers = tags_for(request)
|
|
73
|
+
return :data_layer if providers.any? { |provider| provider.is_a?(Providers::GoogleTagManager) }
|
|
74
|
+
|
|
75
|
+
providers.any?(&:google?) ? :gtag : :data_layer
|
|
76
|
+
end
|
|
77
|
+
|
|
63
78
|
# Whether this visitor has to be asked. When they do not, every category
|
|
64
79
|
# counts as granted and no banner is rendered - see config.consent_required.
|
|
65
80
|
def consent_required?(request = nil)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: consently
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michał Krzysteczko
|
|
@@ -58,6 +58,7 @@ files:
|
|
|
58
58
|
- app/helpers/consently/tags_helper.rb
|
|
59
59
|
- app/javascript/consently/banner_controller.js
|
|
60
60
|
- app/javascript/consently/embed_controller.js
|
|
61
|
+
- app/javascript/consently/events.js
|
|
61
62
|
- app/models/consently/application_record.rb
|
|
62
63
|
- app/models/consently/consent_record.rb
|
|
63
64
|
- app/views/consently/_banner.html.erb
|
|
@@ -80,6 +81,7 @@ files:
|
|
|
80
81
|
- lib/consently/consent.rb
|
|
81
82
|
- lib/consently/cookie.rb
|
|
82
83
|
- lib/consently/engine.rb
|
|
84
|
+
- lib/consently/event.rb
|
|
83
85
|
- lib/consently/providers.rb
|
|
84
86
|
- lib/consently/providers/base.rb
|
|
85
87
|
- lib/consently/providers/clarity.rb
|
|
@@ -103,6 +105,7 @@ licenses:
|
|
|
103
105
|
- MIT
|
|
104
106
|
metadata:
|
|
105
107
|
homepage_uri: https://github.com/Xeross99/consently
|
|
108
|
+
documentation_uri: https://xeross99.github.io/consently/
|
|
106
109
|
bug_tracker_uri: https://github.com/Xeross99/consently/issues
|
|
107
110
|
changelog_uri: https://github.com/Xeross99/consently/blob/main/CHANGELOG.md
|
|
108
111
|
rubygems_mfa_required: 'true'
|
|
@@ -120,7 +123,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
120
123
|
- !ruby/object:Gem::Version
|
|
121
124
|
version: '0'
|
|
122
125
|
requirements: []
|
|
123
|
-
rubygems_version: 4.0.
|
|
126
|
+
rubygems_version: 4.0.18
|
|
124
127
|
specification_version: 4
|
|
125
128
|
summary: GDPR cookie consent for Rails that actually blocks your analytics tags until
|
|
126
129
|
the visitor agrees
|