consently 0.1.0 → 0.2.1
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 +26 -0
- data/MIT-LICENSE +1 -1
- data/README.md +152 -19
- data/app/assets/stylesheets/consently.css +49 -5
- data/app/helpers/consently/tags_helper.rb +106 -4
- data/app/javascript/consently/banner_controller.js +4 -1
- data/app/javascript/consently/embed_controller.js +40 -0
- data/app/views/consently/_banner.html.erb +1 -0
- data/app/views/consently/_embed.html.erb +24 -0
- data/config/importmap.rb +2 -1
- data/config/locales/cs.yml +12 -1
- data/config/locales/de.yml +10 -1
- data/config/locales/en.yml +10 -1
- data/config/locales/es.yml +10 -1
- data/config/locales/fr.yml +10 -1
- data/config/locales/hu.yml +10 -1
- data/config/locales/it.yml +10 -1
- data/config/locales/nl.yml +10 -1
- data/config/locales/pl.yml +12 -1
- data/config/locales/sk.yml +12 -1
- data/lib/consently/configuration.rb +40 -8
- data/lib/consently/consent.rb +14 -1
- data/lib/consently/engine.rb +1 -1
- data/lib/consently/providers/base.rb +13 -0
- data/lib/consently/providers/google_ads.rb +1 -0
- data/lib/consently/providers/google_analytics.rb +1 -0
- data/lib/consently/providers/google_tag_manager.rb +1 -0
- data/lib/consently/version.rb +1 -1
- data/lib/consently.rb +1 -0
- data/lib/generators/consently/install/install_generator.rb +2 -0
- data/lib/generators/consently/install/templates/consently.rb +10 -3
- metadata +17 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ae532d0c3dd92af70f6bd6bfae150b2d61889fbb7ecae3ea1edae521e33605a
|
|
4
|
+
data.tar.gz: f0bccd48aba2d92a00e1a1359580b5f051206de246aa205bb3a2a3323df516c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8c2917ed994ffc2db028512616ef551d18471e2e68df786e150337fae51b14c8de902aabd209cf3d65a81b14fcd7aed13135e5b2a8a3f886a217a5e6b4abd348
|
|
7
|
+
data.tar.gz: bc74d85205720239b176d6caeab993e1bd08d9c49258f4fc6778dd17d763b808a4705155f32b8f7eec620102b1f4215469d3b523fe51b859c74a6961fafa763e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 (2026-08-13)
|
|
4
|
+
|
|
5
|
+
- The banner's custom properties moved from `.consently` to `:root`, so the
|
|
6
|
+
button on a blocked embed - which lives elsewhere on the page and wears the
|
|
7
|
+
same classes - is styled like the one in the banner instead of losing its
|
|
8
|
+
background. Overriding them on `.consently` still themes the banner.
|
|
9
|
+
- The cookie policy pluralises durations, so a one-day cookie no longer reads
|
|
10
|
+
"1 days"; the Slavic locales carry the full set of forms.
|
|
11
|
+
- A page anybody can open: <https://xeross99.github.io/consently/> runs four
|
|
12
|
+
tags blocked in the browser, and shows what happens to them - and to the
|
|
13
|
+
requests, the cookies and the dataLayer - the moment consent is given.
|
|
14
|
+
|
|
15
|
+
## 0.2.0 (2026-08-13)
|
|
16
|
+
|
|
17
|
+
- `consently_embed` for videos and maps: a YouTube or Vimeo iframe, or a
|
|
18
|
+
Google map, waits behind a placeholder until its category is granted and
|
|
19
|
+
then appears without a reload.
|
|
20
|
+
- `consently_ecommerce` builds GA4 ecommerce events from your own line items
|
|
21
|
+
or products, clearing the previous `ecommerce` object the way Google asks.
|
|
22
|
+
- `google_consent_mode` now takes `:basic` (the previous behaviour) or
|
|
23
|
+
`:advanced`, which lets Google's own tags load denied so Ads can model the
|
|
24
|
+
conversions of visitors who refused.
|
|
25
|
+
- `cookie_domain`, so one consent covers every subdomain.
|
|
26
|
+
- `consent_max_age`, to ask again after a year without bumping the policy
|
|
27
|
+
version.
|
|
28
|
+
|
|
3
29
|
## 0.1.0 (2026-08-12)
|
|
4
30
|
|
|
5
31
|
First release.
|
data/MIT-LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Consently
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/consently)
|
|
4
|
+
[](https://rubygems.org/gems/consently)
|
|
5
|
+
[](https://github.com/Xeross99/consently/actions/workflows/ci.yml)
|
|
6
|
+
[](MIT-LICENSE)
|
|
7
|
+
|
|
3
8
|
**GDPR cookie consent for Rails that actually blocks your tags.**
|
|
4
9
|
|
|
5
10
|
Most banners ask for consent and load Google Analytics anyway. Consently is
|
|
@@ -8,16 +13,31 @@ an inert `<script type="text/plain">` the browser will not even fetch, and
|
|
|
8
13
|
becomes a live script the instant the visitor agrees - no page reload, no lost
|
|
9
14
|
pageview.
|
|
10
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
|
+
|
|
11
19
|
| | |
|
|
12
20
|
| --- | --- |
|
|
13
21
|
| **Blocks** | GA4, Google Tag Manager, Google Ads, Microsoft Clarity, Meta Pixel, Hotjar, Plausible, anything custom |
|
|
14
22
|
| **Google Consent Mode v2** | denied by default before any Google tag, updated on the click |
|
|
15
|
-
| **Banner** | plain CSS, no Tailwind, no build step,
|
|
23
|
+
| **Banner** | plain CSS, no Tailwind, no build step, ten languages |
|
|
24
|
+
| **Embeds** | YouTube, Vimeo and Google Maps iframes wait behind a placeholder too |
|
|
25
|
+
| **Ecommerce** | GA4 `purchase`, `add_to_cart` and friends, built from your own line items |
|
|
16
26
|
| **Cookie policy** | generated from the same config - every vendor, every cookie, every duration |
|
|
17
27
|
| **Multi-tenant** | different tags per domain or shop from one initializer |
|
|
18
28
|
| **Proof of consent** | optional log in your own database; no third-party service, nothing leaves your servers |
|
|
19
29
|
| **Install** | one initializer, three helpers in your layout |
|
|
20
30
|
|
|
31
|
+
Before the click, and right after it - same page, no reload in between:
|
|
32
|
+
|
|
33
|
+
| Blocked | Running |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
|  |  |
|
|
36
|
+
|
|
37
|
+
The preferences panel, one category at a time:
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
21
41
|
```ruby
|
|
22
42
|
# config/initializers/consently.rb
|
|
23
43
|
Consently.configure do |c|
|
|
@@ -52,10 +72,12 @@ bundle install
|
|
|
52
72
|
rails g consently:install
|
|
53
73
|
```
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
On RubyGems: <https://rubygems.org/gems/consently>
|
|
76
|
+
|
|
77
|
+
The generator writes the initializer and registers the two Stimulus controllers
|
|
78
|
+
in `app/javascript/controllers/index.js`. Nothing else to set up: the banner
|
|
79
|
+
brings its own plain CSS, so there is no Tailwind, no build step and no config
|
|
80
|
+
file to keep in sync.
|
|
59
81
|
|
|
60
82
|
## What is a tag
|
|
61
83
|
|
|
@@ -91,7 +113,19 @@ c.category :personalization
|
|
|
91
113
|
visitor agrees, and the choice is kept in a `consently` cookie for six months.
|
|
92
114
|
|
|
93
115
|
Change your policy? Bump `c.consent_version` and every older consent stops
|
|
94
|
-
counting; the banner asks again.
|
|
116
|
+
counting; the banner asks again. To ask again on a schedule as well - the
|
|
117
|
+
guidance across the EU converges on about a year - set an age:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
c.consent_max_age = 12.months
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Spanning subdomains? Say so, or a consent given on `www` will not count on
|
|
124
|
+
`shop`:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
c.cookie_domain = ".example.com"
|
|
128
|
+
```
|
|
95
129
|
|
|
96
130
|
## Per-domain, per-tenant
|
|
97
131
|
|
|
@@ -100,12 +134,12 @@ One initializer, different tags per host or shop:
|
|
|
100
134
|
```ruby
|
|
101
135
|
c.scope_resolver = -> (request) { request.host }
|
|
102
136
|
|
|
103
|
-
c.scope "
|
|
104
|
-
s.tag :google_analytics, id: "G-
|
|
137
|
+
c.scope "shop.example.com" do |s|
|
|
138
|
+
s.tag :google_analytics, id: "G-SHOP00001"
|
|
105
139
|
end
|
|
106
140
|
|
|
107
|
-
c.scope "
|
|
108
|
-
s.tag :google_analytics, id: "G-
|
|
141
|
+
c.scope "blog.example.com" do |s|
|
|
142
|
+
s.tag :google_analytics, id: "G-BLOG00001"
|
|
109
143
|
end
|
|
110
144
|
```
|
|
111
145
|
|
|
@@ -113,6 +147,22 @@ Scopes inherit the tags declared outside them and override by declaring the
|
|
|
113
147
|
same provider again. The resolver can return anything - `Current.shop&.name`
|
|
114
148
|
works just as well as a host.
|
|
115
149
|
|
|
150
|
+
## The cookie policy writes itself
|
|
151
|
+
|
|
152
|
+
```erb
|
|
153
|
+
<h1>Cookie policy</h1>
|
|
154
|
+
<p>Your own legal text.</p>
|
|
155
|
+
|
|
156
|
+
<%= consently_policy %>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Every category, the vendors in it, the cookies each one sets and how long they
|
|
160
|
+
last - rendered from the configuration your tags come from, so it cannot drift
|
|
161
|
+
out of date. Add a tag to the initializer and it appears here, in the right
|
|
162
|
+
category, with its cookies.
|
|
163
|
+
|
|
164
|
+

|
|
165
|
+
|
|
116
166
|
## Proof of consent
|
|
117
167
|
|
|
118
168
|
```bash
|
|
@@ -187,21 +237,96 @@ button carries `aria-expanded` and `aria-controls`, reopening the panel moves
|
|
|
187
237
|
focus into it, and the animation gives way to `prefers-reduced-motion`. Nobody
|
|
188
238
|
is trapped in a focus cycle they did not ask for.
|
|
189
239
|
|
|
190
|
-
##
|
|
240
|
+
## Ecommerce events
|
|
241
|
+
|
|
242
|
+
GA4 wants a particular shape, and your models are not it. Hand the helper
|
|
243
|
+
whatever you have:
|
|
244
|
+
|
|
245
|
+
```erb
|
|
246
|
+
<%= consently_ecommerce "purchase", items: @order.line_items,
|
|
247
|
+
value: @order.total, currency: "EUR", transaction_id: @order.number %>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Items may be hashes already in GA4 shape, or any object answering to
|
|
251
|
+
`sku`/`id`, `name`, `price`, `quantity`, `category`, `brand`, `variant` - a
|
|
252
|
+
line item or a product usually does. The previous `ecommerce` object is
|
|
253
|
+
cleared first, as Google asks, so two events on one page cannot bleed into
|
|
254
|
+
each other.
|
|
255
|
+
|
|
256
|
+
Anything else:
|
|
257
|
+
|
|
258
|
+
```erb
|
|
259
|
+
<%= consently_data_layer_push "newsletter_signup", source: "footer" %>
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Both render nothing at all when analytics consent is missing.
|
|
263
|
+
|
|
264
|
+
## Embedded videos and maps
|
|
265
|
+
|
|
266
|
+
Blocking scripts is half the job: a YouTube iframe sets cookies on its own.
|
|
267
|
+
|
|
268
|
+
```erb
|
|
269
|
+
<%= consently_embed :youtube, "dQw4w9WgXcQ" %>
|
|
270
|
+
<%= consently_embed :vimeo, "76979871", category: :analytics %>
|
|
271
|
+
<%= consently_embed :google_maps, "Bahnhofstrasse 12, Berlin" %>
|
|
272
|
+
<%= consently_embed :custom, "https://example.com/widget", title: "Widget", ratio: "4 / 3" %>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Until the category is granted the visitor gets a placeholder the same size as
|
|
276
|
+
the embed - so nothing jumps - with a button that opens the preferences panel.
|
|
277
|
+
The iframe appears the moment they agree, without a reload.
|
|
278
|
+
|
|
279
|
+
| Waiting for consent | After the click |
|
|
280
|
+
| --- | --- |
|
|
281
|
+
|  |  |
|
|
282
|
+
|
|
283
|
+
What the page holds until then is only the address:
|
|
284
|
+
|
|
285
|
+
```html
|
|
286
|
+
<div class="consently-embed"
|
|
287
|
+
data-controller="consently-embed"
|
|
288
|
+
data-consently-embed-category-value="marketing"
|
|
289
|
+
data-consently-embed-src-value="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ">
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
No iframe, no request to YouTube, no cookie - and `youtube-nocookie.com` is
|
|
293
|
+
what gets embedded once there is consent. `ratio:` sets the box (`16 / 9` by
|
|
294
|
+
default), `category:` decides which consent releases it, and any other option
|
|
295
|
+
is passed straight to the iframe:
|
|
191
296
|
|
|
192
297
|
```erb
|
|
193
|
-
<%=
|
|
298
|
+
<%= consently_embed :youtube, "dQw4w9WgXcQ", ratio: "21 / 9", allow: "autoplay; picture-in-picture" %>
|
|
194
299
|
```
|
|
195
300
|
|
|
196
|
-
|
|
301
|
+
Your own markup can do the same thing without the helper: anything carrying
|
|
302
|
+
`data-consently-open` opens the preferences panel, and the `consently:change`
|
|
303
|
+
event tells you what was granted.
|
|
304
|
+
|
|
305
|
+
## Google consent mode: basic or advanced
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
c.google_consent_mode = :basic # default
|
|
309
|
+
c.google_consent_mode = :advanced
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Basic** keeps Google's tags off the page until consent: nothing about the
|
|
313
|
+
visitor reaches Google before they agree.
|
|
314
|
+
|
|
315
|
+
**Advanced** loads them right away with everything denied, so they send
|
|
316
|
+
cookieless pings and Google Ads can model the conversions of visitors who said
|
|
317
|
+
no. More data for you, a request to Google either way. Which one is defensible
|
|
318
|
+
is a legal call, not a technical one - the gem simply does what you set.
|
|
319
|
+
|
|
320
|
+
Either way the defaults are emitted before any Google tag and updated the
|
|
321
|
+
moment the visitor chooses.
|
|
197
322
|
|
|
198
323
|
## Styling
|
|
199
324
|
|
|
200
|
-
The banner ships as plain CSS
|
|
201
|
-
|
|
325
|
+
The banner ships as plain CSS, driven by custom properties. Most restyling is a
|
|
326
|
+
few variables in your own stylesheet:
|
|
202
327
|
|
|
203
328
|
```css
|
|
204
|
-
|
|
329
|
+
:root {
|
|
205
330
|
--consently-accent: #4f46e5;
|
|
206
331
|
--consently-accent-text: #ffffff;
|
|
207
332
|
--consently-radius: 0;
|
|
@@ -209,6 +334,11 @@ properties. Most restyling is a few variables in your own stylesheet:
|
|
|
209
334
|
}
|
|
210
335
|
```
|
|
211
336
|
|
|
337
|
+
The defaults live on `:root`, not on `.consently`: a blocked embed sits
|
|
338
|
+
somewhere else on the page and its button wears the same classes, so variables
|
|
339
|
+
scoped to the banner would never reach it. Scope them tighter if you mean to,
|
|
340
|
+
`.consently` included - it is your stylesheet.
|
|
341
|
+
|
|
212
342
|
Want the markup instead? Take the partial over, and turn the gem's stylesheet
|
|
213
343
|
off so it stops loading:
|
|
214
344
|
|
|
@@ -251,7 +381,9 @@ Helpers:
|
|
|
251
381
|
| `consently_banner` | the banner, the panel, and the JavaScript that releases blocked tags |
|
|
252
382
|
| `consently_policy` | the generated cookie policy: categories, vendors, cookies, durations |
|
|
253
383
|
| `consently_preferences_link` | "Cookie settings" link; anything with `data-consently-open` reopens the panel |
|
|
254
|
-
| `
|
|
384
|
+
| `consently_ecommerce(event, items:, **params)` | a GA4 ecommerce event, items mapped from your own objects |
|
|
385
|
+
| `consently_data_layer_push(event, **payload)` | any other dataLayer event, rendered only with analytics consent |
|
|
386
|
+
| `consently_embed(kind, id, category:, ratio:)` | a video or map that waits for consent |
|
|
255
387
|
| `consently_consent` | the current `Consently::Consent`; `granted?(:analytics)` in your own views |
|
|
256
388
|
|
|
257
389
|
Configuration:
|
|
@@ -264,10 +396,11 @@ Configuration:
|
|
|
264
396
|
| `c.consent_version` | bump it and every older consent stops counting |
|
|
265
397
|
| `c.enabled` | `true`, `false`, or a callable taking the request |
|
|
266
398
|
| `c.reload_after_choice` | reload once a choice is made; off by default |
|
|
267
|
-
| `c.google_consent_mode` |
|
|
399
|
+
| `c.google_consent_mode` | `:basic` (default), `:advanced`, or `false` |
|
|
268
400
|
| `c.log_consents`, `c.consent_subject` | store proof of each decision, optionally naming who |
|
|
269
401
|
| `c.stylesheet` | link the banner's CSS; off if you style it yourself |
|
|
270
|
-
| `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path` | where the choice is kept |
|
|
402
|
+
| `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path`, `c.cookie_domain` | where the choice is kept |
|
|
403
|
+
| `c.consent_max_age` | ask again after this long, whatever the cookie says |
|
|
271
404
|
| `c.respect_do_not_track`, `c.respect_global_privacy_control` | treat an opt-out signal as a rejection |
|
|
272
405
|
| `c.consent_required` | who has to be asked at all; false means no banner and everything granted |
|
|
273
406
|
|
|
@@ -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;
|
|
@@ -146,6 +152,44 @@
|
|
|
146
152
|
display: none !important;
|
|
147
153
|
}
|
|
148
154
|
|
|
155
|
+
/* Embedded videos and maps. The placeholder keeps the same box as the iframe
|
|
156
|
+
it replaces, so nothing on the page jumps when consent arrives. */
|
|
157
|
+
.consently-embed {
|
|
158
|
+
position: relative;
|
|
159
|
+
aspect-ratio: var(--consently-embed-ratio, 16 / 9);
|
|
160
|
+
width: 100%;
|
|
161
|
+
border-radius: 0.5rem;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
background: #f3f4f6;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.consently-embed__frame {
|
|
167
|
+
width: 100%;
|
|
168
|
+
height: 100%;
|
|
169
|
+
border: 0;
|
|
170
|
+
display: block;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.consently-embed__placeholder {
|
|
174
|
+
position: absolute;
|
|
175
|
+
inset: 0;
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
align-items: center;
|
|
179
|
+
justify-content: center;
|
|
180
|
+
gap: 0.75rem;
|
|
181
|
+
padding: 1.5rem;
|
|
182
|
+
text-align: center;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.consently-embed__text {
|
|
186
|
+
margin: 0;
|
|
187
|
+
max-width: 28rem;
|
|
188
|
+
font-size: 0.875rem;
|
|
189
|
+
line-height: 1.5rem;
|
|
190
|
+
color: #4b5563;
|
|
191
|
+
}
|
|
192
|
+
|
|
149
193
|
/* The generated cookie policy. Deliberately quiet: it lands inside someone
|
|
150
194
|
else's page and should inherit its typography. */
|
|
151
195
|
.consently-policy {
|
|
@@ -13,8 +13,7 @@ module Consently
|
|
|
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|
|
|
16
|
-
|
|
17
|
-
provider.scripts.each { |script| parts << consently_script_tag(script, provider, granted) }
|
|
16
|
+
provider.scripts.each { |script| parts << consently_script_tag(script, provider, consently_release?(provider)) }
|
|
18
17
|
end
|
|
19
18
|
|
|
20
19
|
safe_join(parts, "\n")
|
|
@@ -65,7 +64,11 @@ module Consently
|
|
|
65
64
|
|
|
66
65
|
def consently_consent
|
|
67
66
|
@consently_consent ||= if Consently.consent_required?(request)
|
|
68
|
-
Consent.from_cookie(
|
|
67
|
+
Consent.from_cookie(
|
|
68
|
+
cookies[Consently.config.cookie_name],
|
|
69
|
+
version: Consently.config.consent_version,
|
|
70
|
+
max_age: Consently.config.consent_max_age
|
|
71
|
+
)
|
|
69
72
|
else
|
|
70
73
|
# Nobody to ask, so nothing is held back.
|
|
71
74
|
Consent.new(categories: Consently.config.categories, version: Consently.config.consent_version)
|
|
@@ -75,7 +78,7 @@ module Consently
|
|
|
75
78
|
# Push an event onto the dataLayer from a view, respecting consent: with
|
|
76
79
|
# no analytics consent the event is simply not emitted.
|
|
77
80
|
#
|
|
78
|
-
# <%= consently_data_layer_push("
|
|
81
|
+
# <%= consently_data_layer_push("newsletter_signup", source: "footer") %>
|
|
79
82
|
def consently_data_layer_push(event, category: :analytics, **payload)
|
|
80
83
|
return "".html_safe unless consently_enabled? && consently_consent.granted?(category)
|
|
81
84
|
|
|
@@ -83,6 +86,31 @@ module Consently
|
|
|
83
86
|
consently_inline_script "window.dataLayer = window.dataLayer || []; window.dataLayer.push(#{payload.to_json});"
|
|
84
87
|
end
|
|
85
88
|
|
|
89
|
+
# A GA4 ecommerce event in the shape Google expects, from whatever your
|
|
90
|
+
# models happen to look like:
|
|
91
|
+
#
|
|
92
|
+
# <%= consently_ecommerce("purchase", items: @order.line_items,
|
|
93
|
+
# value: @order.total, currency: "EUR", transaction_id: @order.number) %>
|
|
94
|
+
#
|
|
95
|
+
# Items may be hashes already in GA4 shape, or any object answering to a
|
|
96
|
+
# few obvious names (id/sku, name, price, quantity, category, brand,
|
|
97
|
+
# variant) - a LineItem or a Product usually does.
|
|
98
|
+
#
|
|
99
|
+
# The previous ecommerce object is cleared first, as Google asks, so two
|
|
100
|
+
# events on one page cannot bleed into each other.
|
|
101
|
+
def consently_ecommerce(event, items: [], category: :analytics, **params)
|
|
102
|
+
return "".html_safe unless consently_enabled? && consently_consent.granted?(category)
|
|
103
|
+
|
|
104
|
+
ecommerce = params.merge(items: Array(items).map { |item| consently_ecommerce_item(item) })
|
|
105
|
+
payload = { event: event, ecommerce: ecommerce.compact }
|
|
106
|
+
|
|
107
|
+
consently_inline_script <<~JS.strip
|
|
108
|
+
window.dataLayer = window.dataLayer || [];
|
|
109
|
+
window.dataLayer.push({ ecommerce: null });
|
|
110
|
+
window.dataLayer.push(#{payload.to_json});
|
|
111
|
+
JS
|
|
112
|
+
end
|
|
113
|
+
|
|
86
114
|
# A complete cookie policy for the tags this request would load: every
|
|
87
115
|
# category, every vendor, every cookie it sets and for how long, plus
|
|
88
116
|
# whether the visitor has agreed to it right now.
|
|
@@ -92,6 +120,30 @@ module Consently
|
|
|
92
120
|
render "consently/policy", tags: Consently.tags_for(request), consent: consently_consent
|
|
93
121
|
end
|
|
94
122
|
|
|
123
|
+
# An embedded video or map that waits for consent. Blocking scripts is
|
|
124
|
+
# only half the job: a YouTube iframe sets cookies all by itself.
|
|
125
|
+
#
|
|
126
|
+
# <%= consently_embed :youtube, "dQw4w9WgXcQ" %>
|
|
127
|
+
# <%= consently_embed :vimeo, "76979871", category: :analytics %>
|
|
128
|
+
# <%= consently_embed :google_maps, "Bahnhofstrasse 12, Berlin" %>
|
|
129
|
+
# <%= consently_embed :custom, "https://example.com/widget", title: "Widget" %>
|
|
130
|
+
#
|
|
131
|
+
# Until the category is granted the visitor sees a placeholder with a
|
|
132
|
+
# button that opens the preferences panel; the iframe appears the moment
|
|
133
|
+
# they agree, without a reload.
|
|
134
|
+
def consently_embed(kind, identifier, category: :marketing, title: nil, ratio: "16 / 9", **iframe_options)
|
|
135
|
+
src = consently_embed_src(kind, identifier)
|
|
136
|
+
return "".html_safe if src.blank?
|
|
137
|
+
|
|
138
|
+
render "consently/embed",
|
|
139
|
+
src: src,
|
|
140
|
+
title: title || t("consently.embed.title_#{kind}", default: t("consently.embed.title_default")),
|
|
141
|
+
category: category.to_sym,
|
|
142
|
+
ratio: ratio,
|
|
143
|
+
granted: consently_consent.granted?(category),
|
|
144
|
+
iframe_options: iframe_options
|
|
145
|
+
end
|
|
146
|
+
|
|
95
147
|
# The banner brings its own plain CSS - no framework, no build step. The
|
|
96
148
|
# look is driven by custom properties, so overriding a few variables is
|
|
97
149
|
# usually enough; `rails g consently:views` is there for the rest.
|
|
@@ -115,6 +167,15 @@ module Consently
|
|
|
115
167
|
Consently.enabled?(request)
|
|
116
168
|
end
|
|
117
169
|
|
|
170
|
+
# Whether this tag may run now. Normally that means consent; under
|
|
171
|
+
# advanced consent mode Google's own tags also load before it, having
|
|
172
|
+
# been told to store nothing until it arrives.
|
|
173
|
+
def consently_release?(provider)
|
|
174
|
+
return true if consently_consent.granted?(provider.category)
|
|
175
|
+
|
|
176
|
+
Consently.config.advanced_google_consent_mode? && provider.google?
|
|
177
|
+
end
|
|
178
|
+
|
|
118
179
|
# Google's consent mode v2 defaults. This one is never blocked: its whole
|
|
119
180
|
# job is to tell Google's tags that they may not store anything yet, and
|
|
120
181
|
# it has to be on the page before them.
|
|
@@ -138,6 +199,47 @@ module Consently
|
|
|
138
199
|
JS
|
|
139
200
|
end
|
|
140
201
|
|
|
202
|
+
# A bare id is enough for the vendors people embed most; anything else
|
|
203
|
+
# takes a full URL.
|
|
204
|
+
def consently_embed_src(kind, identifier)
|
|
205
|
+
identifier = identifier.to_s
|
|
206
|
+
return identifier if identifier.start_with?("http")
|
|
207
|
+
|
|
208
|
+
case kind.to_sym
|
|
209
|
+
when :youtube then "https://www.youtube-nocookie.com/embed/#{u(identifier)}"
|
|
210
|
+
when :vimeo then "https://player.vimeo.com/video/#{u(identifier)}"
|
|
211
|
+
when :google_maps then "https://www.google.com/maps?q=#{u(identifier)}&output=embed"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Whatever the application handed us, in the shape GA4 reads. Hashes pass
|
|
216
|
+
# through (with symbol keys stringified by to_json anyway); objects are
|
|
217
|
+
# asked for the handful of names that carry the same meaning everywhere.
|
|
218
|
+
def consently_ecommerce_item(item)
|
|
219
|
+
return item.compact if item.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
{
|
|
222
|
+
item_id: consently_first_value(item, :item_id, :sku, :id),
|
|
223
|
+
item_name: consently_first_value(item, :item_name, :name, :title),
|
|
224
|
+
price: consently_first_value(item, :price, :unit_price, :price_eur),
|
|
225
|
+
quantity: consently_first_value(item, :quantity, :qty),
|
|
226
|
+
item_category: consently_first_value(item, :item_category, :category),
|
|
227
|
+
item_brand: consently_first_value(item, :item_brand, :brand),
|
|
228
|
+
item_variant: consently_first_value(item, :item_variant, :variant)
|
|
229
|
+
}.compact
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def consently_first_value(item, *names)
|
|
233
|
+
names.each do |name|
|
|
234
|
+
next unless item.respond_to?(name)
|
|
235
|
+
|
|
236
|
+
value = item.public_send(name)
|
|
237
|
+
return value.respond_to?(:name) ? value.name : value if value.present?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
141
243
|
# A plain <script> with the JS as written. javascript_tag would wrap it in
|
|
142
244
|
# a CDATA comment nobody has needed since XHTML.
|
|
143
245
|
def consently_inline_script(javascript)
|
|
@@ -16,6 +16,7 @@ export default class extends Controller {
|
|
|
16
16
|
version: { type: String, default: "1" },
|
|
17
17
|
maxAge: { type: Number, default: 60 * 60 * 24 * 180 },
|
|
18
18
|
path: { type: String, default: "/" },
|
|
19
|
+
domain: String,
|
|
19
20
|
categories: Array,
|
|
20
21
|
googleConsentMode: { type: Boolean, default: true },
|
|
21
22
|
respectDoNotTrack: { type: Boolean, default: false },
|
|
@@ -105,8 +106,10 @@ export default class extends Controller {
|
|
|
105
106
|
#writeCookie(categories) {
|
|
106
107
|
const value = JSON.stringify({ v: this.versionValue, c: categories, t: new Date().toISOString() })
|
|
107
108
|
const secure = window.location.protocol === "https:" ? "; Secure" : ""
|
|
109
|
+
// A domain of ".example.com" is what carries the choice across subdomains.
|
|
110
|
+
const domain = this.domainValue ? `; domain=${this.domainValue}` : ""
|
|
108
111
|
|
|
109
|
-
document.cookie = `${this.cookieValue}=${encodeURIComponent(value)}; path=${this.pathValue}; max-age=${this.maxAgeValue}; SameSite=Lax${secure}`
|
|
112
|
+
document.cookie = `${this.cookieValue}=${encodeURIComponent(value)}; path=${this.pathValue}${domain}; max-age=${this.maxAgeValue}; SameSite=Lax${secure}`
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
#activateScripts(categories) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// A video or map that appears the moment its category is granted. The banner
|
|
4
|
+
// announces every decision on `document`, so this controller only has to
|
|
5
|
+
// listen and build the iframe the server did not render.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static targets = ["placeholder"]
|
|
8
|
+
static classes = ["hidden"]
|
|
9
|
+
|
|
10
|
+
static values = {
|
|
11
|
+
category: String,
|
|
12
|
+
src: String,
|
|
13
|
+
title: String,
|
|
14
|
+
attributes: { type: Object, default: {} }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The event carries the categories, so no cookie parsing here.
|
|
18
|
+
granted({ detail }) {
|
|
19
|
+
if (!detail.categories.includes(this.categoryValue)) return
|
|
20
|
+
|
|
21
|
+
this.#insertFrame()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#insertFrame() {
|
|
25
|
+
if (this.element.querySelector("iframe")) return
|
|
26
|
+
|
|
27
|
+
const frame = document.createElement("iframe")
|
|
28
|
+
frame.src = this.srcValue
|
|
29
|
+
frame.title = this.titleValue
|
|
30
|
+
frame.loading = "lazy"
|
|
31
|
+
frame.allowFullscreen = true
|
|
32
|
+
frame.className = "consently-embed__frame"
|
|
33
|
+
for (const [name, value] of Object.entries(this.attributesValue)) {
|
|
34
|
+
frame.setAttribute(name, value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.element.prepend(frame)
|
|
38
|
+
this.placeholderTarget.classList.add(...this.hiddenClasses)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
data-consently-banner-version-value="<%= Consently.config.consent_version %>"
|
|
14
14
|
data-consently-banner-max-age-value="<%= Consently.config.cookie_max_age %>"
|
|
15
15
|
data-consently-banner-path-value="<%= Consently.config.cookie_path %>"
|
|
16
|
+
data-consently-banner-domain-value="<%= Consently.config.cookie_domain %>"
|
|
16
17
|
data-consently-banner-categories-value="<%= categories.map(&:to_s).to_json %>"
|
|
17
18
|
data-consently-banner-google-consent-mode-value="<%= Consently.config.google_consent_mode %>"
|
|
18
19
|
data-consently-banner-respect-do-not-track-value="<%= Consently.config.respect_do_not_track %>"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# An iframe that waits for consent. The src lives in a data attribute until
|
|
2
|
+
then, so nothing is requested from the vendor and no cookie is set. %>
|
|
3
|
+
<div class="consently-embed"
|
|
4
|
+
style="--consently-embed-ratio: <%= ratio %>"
|
|
5
|
+
data-controller="consently-embed"
|
|
6
|
+
data-consently-embed-category-value="<%= category %>"
|
|
7
|
+
data-consently-embed-src-value="<%= src %>"
|
|
8
|
+
data-consently-embed-title-value="<%= title %>"
|
|
9
|
+
data-consently-embed-attributes-value="<%= iframe_options.to_json %>"
|
|
10
|
+
data-consently-embed-hidden-class="consently-hidden"
|
|
11
|
+
data-action="consently:change@document->consently-embed#granted">
|
|
12
|
+
<% if granted %>
|
|
13
|
+
<%= tag.iframe src: src, title: title, loading: "lazy", allowfullscreen: true,
|
|
14
|
+
class: "consently-embed__frame", **iframe_options %>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<div class="consently-embed__placeholder <%= "consently-hidden" if granted %>"
|
|
18
|
+
data-consently-embed-target="placeholder">
|
|
19
|
+
<p class="consently-embed__text"><%= t("consently.embed.blocked", category: t("consently.categories.#{category}.name", default: category.to_s.humanize)) %></p>
|
|
20
|
+
<button type="button" data-consently-open class="consently__button consently__button--primary">
|
|
21
|
+
<%= t("consently.embed.allow") %>
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
data/config/importmap.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
# Pinned for the host application, so `import "consently/banner_controller"`
|
|
2
|
-
# resolves without anyone editing
|
|
2
|
+
# resolves without anyone editing config/importmap.rb.
|
|
3
3
|
pin "consently/banner_controller", to: "consently/banner_controller.js", preload: true
|
|
4
|
+
pin "consently/embed_controller", to: "consently/embed_controller.js", preload: true
|
data/config/locales/cs.yml
CHANGED
|
@@ -9,6 +9,13 @@ cs:
|
|
|
9
9
|
save: "Uložit volbu"
|
|
10
10
|
cancel: "Zrušit"
|
|
11
11
|
preferences_link: "Nastavení cookies"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Tento obsah se načítá z jiného webu a vyžaduje váš souhlas pro kategorii %{category}."
|
|
14
|
+
allow: "Povolit a zobrazit"
|
|
15
|
+
title_youtube: "Video"
|
|
16
|
+
title_vimeo: "Video"
|
|
17
|
+
title_google_maps: "Mapa"
|
|
18
|
+
title_default: "Vložený obsah"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "souhlas udělen"
|
|
14
21
|
denied: "bez souhlasu"
|
|
@@ -16,7 +23,11 @@ cs:
|
|
|
16
23
|
no_cookies: "Nenastavuje žádné cookies."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Doba uložení"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} den"
|
|
28
|
+
few: "%{count} dny"
|
|
29
|
+
many: "%{count} dne"
|
|
30
|
+
other: "%{count} dní"
|
|
20
31
|
duration_session: "do zavření prohlížeče"
|
|
21
32
|
own_heading: "Samotná cookie se souhlasem"
|
|
22
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
|
@@ -9,6 +9,13 @@ de:
|
|
|
9
9
|
save: "Auswahl speichern"
|
|
10
10
|
cancel: "Abbrechen"
|
|
11
11
|
preferences_link: "Cookie-Einstellungen"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Dieser Inhalt wird von einer anderen Website geladen und braucht Ihre Einwilligung für %{category}."
|
|
14
|
+
allow: "Erlauben und anzeigen"
|
|
15
|
+
title_youtube: "Video"
|
|
16
|
+
title_vimeo: "Video"
|
|
17
|
+
title_google_maps: "Karte"
|
|
18
|
+
title_default: "Eingebetteter Inhalt"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "eingewilligt"
|
|
14
21
|
denied: "nicht eingewilligt"
|
|
@@ -16,7 +23,9 @@ de:
|
|
|
16
23
|
no_cookies: "Setzt keine Cookies."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Speicherdauer"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} Tag"
|
|
28
|
+
other: "%{count} Tage"
|
|
20
29
|
duration_session: "bis zum Schließen des Browsers"
|
|
21
30
|
own_heading: "Das Einwilligungs-Cookie selbst"
|
|
22
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
|
@@ -9,6 +9,13 @@ en:
|
|
|
9
9
|
save: "Save choices"
|
|
10
10
|
cancel: "Cancel"
|
|
11
11
|
preferences_link: "Cookie settings"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "This content is loaded from another site and needs your %{category} consent."
|
|
14
|
+
allow: "Allow and show"
|
|
15
|
+
title_youtube: "Video"
|
|
16
|
+
title_vimeo: "Video"
|
|
17
|
+
title_google_maps: "Map"
|
|
18
|
+
title_default: "Embedded content"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "you agreed"
|
|
14
21
|
denied: "not agreed"
|
|
@@ -16,7 +23,9 @@ en:
|
|
|
16
23
|
no_cookies: "Sets no cookies."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Kept for"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} day"
|
|
28
|
+
other: "%{count} days"
|
|
20
29
|
duration_session: "until you close the browser"
|
|
21
30
|
own_heading: "The consent cookie itself"
|
|
22
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
|
@@ -9,6 +9,13 @@ es:
|
|
|
9
9
|
save: "Guardar selección"
|
|
10
10
|
cancel: "Cancelar"
|
|
11
11
|
preferences_link: "Ajustes de cookies"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Este contenido se carga desde otro sitio y necesita tu consentimiento de %{category}."
|
|
14
|
+
allow: "Permitir y mostrar"
|
|
15
|
+
title_youtube: "Vídeo"
|
|
16
|
+
title_vimeo: "Vídeo"
|
|
17
|
+
title_google_maps: "Mapa"
|
|
18
|
+
title_default: "Contenido incrustado"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "consentido"
|
|
14
21
|
denied: "sin consentimiento"
|
|
@@ -16,7 +23,9 @@ es:
|
|
|
16
23
|
no_cookies: "No instala cookies."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Se conserva"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} día"
|
|
28
|
+
other: "%{count} días"
|
|
20
29
|
duration_session: "hasta que cierres el navegador"
|
|
21
30
|
own_heading: "La propia cookie de consentimiento"
|
|
22
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
|
@@ -9,6 +9,13 @@ fr:
|
|
|
9
9
|
save: "Enregistrer mes choix"
|
|
10
10
|
cancel: "Annuler"
|
|
11
11
|
preferences_link: "Paramètres des cookies"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Ce contenu provient d'un autre site et nécessite votre accord pour %{category}."
|
|
14
|
+
allow: "Autoriser et afficher"
|
|
15
|
+
title_youtube: "Vidéo"
|
|
16
|
+
title_vimeo: "Vidéo"
|
|
17
|
+
title_google_maps: "Carte"
|
|
18
|
+
title_default: "Contenu intégré"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "accepté"
|
|
14
21
|
denied: "non accepté"
|
|
@@ -16,7 +23,9 @@ fr:
|
|
|
16
23
|
no_cookies: "Ne dépose aucun cookie."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Conservé"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} jour"
|
|
28
|
+
other: "%{count} jours"
|
|
20
29
|
duration_session: "jusqu'à la fermeture du navigateur"
|
|
21
30
|
own_heading: "Le cookie de consentement lui-même"
|
|
22
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
|
@@ -9,6 +9,13 @@ hu:
|
|
|
9
9
|
save: "Választás mentése"
|
|
10
10
|
cancel: "Mégse"
|
|
11
11
|
preferences_link: "Süti beállítások"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Ez a tartalom másik oldalról töltődik be, és a(z) %{category} kategóriához adott hozzájárulásod kell hozzá."
|
|
14
|
+
allow: "Engedélyezés és megjelenítés"
|
|
15
|
+
title_youtube: "Videó"
|
|
16
|
+
title_vimeo: "Videó"
|
|
17
|
+
title_google_maps: "Térkép"
|
|
18
|
+
title_default: "Beágyazott tartalom"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "hozzájárultál"
|
|
14
21
|
denied: "nincs hozzájárulás"
|
|
@@ -16,7 +23,9 @@ hu:
|
|
|
16
23
|
no_cookies: "Nem helyez el sütit."
|
|
17
24
|
cookie_name: "Süti"
|
|
18
25
|
cookie_duration: "Megőrzés"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} nap"
|
|
28
|
+
other: "%{count} nap"
|
|
20
29
|
duration_session: "a böngésző bezárásáig"
|
|
21
30
|
own_heading: "Maga a hozzájárulási süti"
|
|
22
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
|
@@ -9,6 +9,13 @@ it:
|
|
|
9
9
|
save: "Salva le scelte"
|
|
10
10
|
cancel: "Annulla"
|
|
11
11
|
preferences_link: "Impostazioni cookie"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Questo contenuto viene caricato da un altro sito e richiede il tuo consenso per %{category}."
|
|
14
|
+
allow: "Consenti e mostra"
|
|
15
|
+
title_youtube: "Video"
|
|
16
|
+
title_vimeo: "Video"
|
|
17
|
+
title_google_maps: "Mappa"
|
|
18
|
+
title_default: "Contenuto incorporato"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "consenso dato"
|
|
14
21
|
denied: "nessun consenso"
|
|
@@ -16,7 +23,9 @@ it:
|
|
|
16
23
|
no_cookies: "Non imposta cookie."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Conservato"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} giorno"
|
|
28
|
+
other: "%{count} giorni"
|
|
20
29
|
duration_session: "fino alla chiusura del browser"
|
|
21
30
|
own_heading: "Il cookie di consenso"
|
|
22
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
|
@@ -9,6 +9,13 @@ nl:
|
|
|
9
9
|
save: "Keuze opslaan"
|
|
10
10
|
cancel: "Annuleren"
|
|
11
11
|
preferences_link: "Cookie-instellingen"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Deze inhoud komt van een andere site en heeft je toestemming voor %{category} nodig."
|
|
14
|
+
allow: "Toestaan en tonen"
|
|
15
|
+
title_youtube: "Video"
|
|
16
|
+
title_vimeo: "Video"
|
|
17
|
+
title_google_maps: "Kaart"
|
|
18
|
+
title_default: "Ingesloten inhoud"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "toegestaan"
|
|
14
21
|
denied: "niet toegestaan"
|
|
@@ -16,7 +23,9 @@ nl:
|
|
|
16
23
|
no_cookies: "Plaatst geen cookies."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Bewaard"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} dag"
|
|
28
|
+
other: "%{count} dagen"
|
|
20
29
|
duration_session: "tot je de browser sluit"
|
|
21
30
|
own_heading: "De toestemmingscookie zelf"
|
|
22
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
|
@@ -9,6 +9,13 @@ pl:
|
|
|
9
9
|
save: "Zapisz wybór"
|
|
10
10
|
cancel: "Anuluj"
|
|
11
11
|
preferences_link: "Ustawienia cookies"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Ta treść ładuje się z innej strony i wymaga Twojej zgody na kategorię %{category}."
|
|
14
|
+
allow: "Zezwól i pokaż"
|
|
15
|
+
title_youtube: "Wideo"
|
|
16
|
+
title_vimeo: "Wideo"
|
|
17
|
+
title_google_maps: "Mapa"
|
|
18
|
+
title_default: "Osadzona treść"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "zgoda udzielona"
|
|
14
21
|
denied: "brak zgody"
|
|
@@ -16,7 +23,11 @@ pl:
|
|
|
16
23
|
no_cookies: "Nie ustawia ciasteczek."
|
|
17
24
|
cookie_name: "Ciasteczko"
|
|
18
25
|
cookie_duration: "Przechowywane"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} dzień"
|
|
28
|
+
few: "%{count} dni"
|
|
29
|
+
many: "%{count} dni"
|
|
30
|
+
other: "%{count} dni"
|
|
20
31
|
duration_session: "do zamknięcia przeglądarki"
|
|
21
32
|
own_heading: "Samo ciasteczko zgody"
|
|
22
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
|
@@ -9,6 +9,13 @@ sk:
|
|
|
9
9
|
save: "Uložiť voľbu"
|
|
10
10
|
cancel: "Zrušiť"
|
|
11
11
|
preferences_link: "Nastavenia cookies"
|
|
12
|
+
embed:
|
|
13
|
+
blocked: "Tento obsah sa načítava z inej stránky a vyžaduje váš súhlas pre kategóriu %{category}."
|
|
14
|
+
allow: "Povoliť a zobraziť"
|
|
15
|
+
title_youtube: "Video"
|
|
16
|
+
title_vimeo: "Video"
|
|
17
|
+
title_google_maps: "Mapa"
|
|
18
|
+
title_default: "Vložený obsah"
|
|
12
19
|
policy:
|
|
13
20
|
granted: "súhlas udelený"
|
|
14
21
|
denied: "bez súhlasu"
|
|
@@ -16,7 +23,11 @@ sk:
|
|
|
16
23
|
no_cookies: "Nenastavuje žiadne cookies."
|
|
17
24
|
cookie_name: "Cookie"
|
|
18
25
|
cookie_duration: "Doba uloženia"
|
|
19
|
-
duration_days:
|
|
26
|
+
duration_days:
|
|
27
|
+
one: "%{count} deň"
|
|
28
|
+
few: "%{count} dni"
|
|
29
|
+
many: "%{count} dňa"
|
|
30
|
+
other: "%{count} dní"
|
|
20
31
|
duration_session: "do zatvorenia prehliadača"
|
|
21
32
|
own_heading: "Samotná cookie so súhlasom"
|
|
22
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."
|
|
@@ -5,7 +5,15 @@ module Consently
|
|
|
5
5
|
|
|
6
6
|
# Where the visitor's choice is kept. It is read by JavaScript, so it is a
|
|
7
7
|
# plain cookie rather than a signed one.
|
|
8
|
-
|
|
8
|
+
#
|
|
9
|
+
# Set cookie_domain to ".example.com" when the site spans subdomains -
|
|
10
|
+
# without it a consent given on www does not count on shop.
|
|
11
|
+
attr_accessor :cookie_name, :cookie_max_age, :cookie_path, :cookie_domain
|
|
12
|
+
|
|
13
|
+
# How long a consent stays valid, regardless of the cookie's own lifetime.
|
|
14
|
+
# Guidance across the EU converges on asking again about once a year; nil
|
|
15
|
+
# leaves the cookie to expire on its own.
|
|
16
|
+
attr_accessor :consent_max_age
|
|
9
17
|
|
|
10
18
|
# Bump this whenever the policy changes: an older consent stops counting
|
|
11
19
|
# and the banner asks again.
|
|
@@ -15,10 +23,32 @@ module Consently
|
|
|
15
23
|
# c.enabled = ->(request) { Rails.env.production? }
|
|
16
24
|
attr_accessor :enabled
|
|
17
25
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
26
|
+
# Google's consent mode v2. Three settings:
|
|
27
|
+
#
|
|
28
|
+
# :basic - defaults denied, and Google's own tags stay blocked until
|
|
29
|
+
# the visitor agrees. Nothing about them reaches Google
|
|
30
|
+
# before consent. The default, and the strict reading.
|
|
31
|
+
# :advanced - defaults denied, but Google's tags load right away and
|
|
32
|
+
# send cookieless pings, which is what lets Google Ads
|
|
33
|
+
# model the conversions of visitors who said no. More data,
|
|
34
|
+
# and a request to Google either way - ask your lawyer.
|
|
35
|
+
# false - no consent mode at all.
|
|
36
|
+
#
|
|
37
|
+
# `true` is read as :basic.
|
|
38
|
+
attr_reader :google_consent_mode
|
|
39
|
+
|
|
40
|
+
def google_consent_mode=(mode)
|
|
41
|
+
@google_consent_mode = case mode
|
|
42
|
+
when true, :basic then :basic
|
|
43
|
+
when :advanced then :advanced
|
|
44
|
+
when false, nil then false
|
|
45
|
+
else raise ArgumentError, "google_consent_mode must be :basic, :advanced or false"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def advanced_google_consent_mode?
|
|
50
|
+
google_consent_mode == :advanced
|
|
51
|
+
end
|
|
22
52
|
|
|
23
53
|
# Store a row per decision, as proof of consent. Needs the engine mounted
|
|
24
54
|
# and the migration from `rails g consently:consent_log`.
|
|
@@ -74,9 +104,11 @@ module Consently
|
|
|
74
104
|
@cookie_name = "consently"
|
|
75
105
|
@cookie_max_age = 60 * 60 * 24 * 180 # six months, the usual guidance
|
|
76
106
|
@cookie_path = "/"
|
|
107
|
+
@cookie_domain = nil
|
|
108
|
+
@consent_max_age = nil
|
|
77
109
|
@consent_version = 1
|
|
78
110
|
@enabled = true
|
|
79
|
-
@google_consent_mode =
|
|
111
|
+
@google_consent_mode = :basic
|
|
80
112
|
@stylesheet = true
|
|
81
113
|
@log_consents = false
|
|
82
114
|
@respect_do_not_track = false
|
|
@@ -111,8 +143,8 @@ module Consently
|
|
|
111
143
|
# back to the tags declared outside any scope, and may override them by
|
|
112
144
|
# declaring the same provider again.
|
|
113
145
|
#
|
|
114
|
-
# c.scope "
|
|
115
|
-
# s.tag :google_analytics, id: "G-
|
|
146
|
+
# c.scope "shop.example.com" do |s|
|
|
147
|
+
# s.tag :google_analytics, id: "G-SHOP00001"
|
|
116
148
|
# end
|
|
117
149
|
def scope(name)
|
|
118
150
|
scope = (@scopes[name.to_s] ||= Scope.new)
|
data/lib/consently/consent.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Consently
|
|
|
17
17
|
|
|
18
18
|
# Never raises: a cookie can be truncated, hand-edited or left over from an
|
|
19
19
|
# older format, and none of that should take a page down.
|
|
20
|
-
def self.from_cookie(raw, version:)
|
|
20
|
+
def self.from_cookie(raw, version:, max_age: nil)
|
|
21
21
|
return none if raw.blank?
|
|
22
22
|
|
|
23
23
|
data = begin
|
|
@@ -30,6 +30,7 @@ module Consently
|
|
|
30
30
|
# A consent given against an older policy version counts as no consent:
|
|
31
31
|
# the banner asks again and nothing runs in the meantime.
|
|
32
32
|
return none unless data["v"].to_s == version.to_s
|
|
33
|
+
return none if expired?(data["t"], max_age)
|
|
33
34
|
|
|
34
35
|
new(
|
|
35
36
|
categories: Array(data["c"]).map { |category| category.to_s.to_sym },
|
|
@@ -38,6 +39,18 @@ module Consently
|
|
|
38
39
|
)
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# A consent older than max_age is asked for again, whatever the cookie's
|
|
43
|
+
# own expiry says. An unreadable timestamp is treated as too old: the only
|
|
44
|
+
# safe reading when we cannot tell when it was given.
|
|
45
|
+
def self.expired?(recorded_at, max_age)
|
|
46
|
+
return false if max_age.nil?
|
|
47
|
+
return true if recorded_at.blank?
|
|
48
|
+
|
|
49
|
+
Time.parse(recorded_at.to_s) < Time.now.utc - max_age.to_i
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
41
54
|
def initialize(categories: [], version: nil, recorded_at: nil, given: true)
|
|
42
55
|
@categories = Array(categories).map(&:to_sym).freeze
|
|
43
56
|
@version = version
|
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]
|
|
17
|
+
app.config.assets.precompile += %w[consently.css consently/banner_controller.js consently/embed_controller.js]
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -22,6 +22,15 @@ module Consently
|
|
|
22
22
|
klass.new(**options)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Whether this vendor is one of Google's, which is what advanced
|
|
26
|
+
# consent mode is about: those tags may load before consent because
|
|
27
|
+
# they respect the denied defaults themselves.
|
|
28
|
+
attr_writer :google
|
|
29
|
+
|
|
30
|
+
def google?
|
|
31
|
+
!!@google
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
# Set by each subclass; where a vendor is unambiguous (Plausible does
|
|
26
35
|
# not touch cookies) it can say :necessary and load right away.
|
|
27
36
|
attr_accessor :default_category
|
|
@@ -60,6 +69,10 @@ module Consently
|
|
|
60
69
|
[]
|
|
61
70
|
end
|
|
62
71
|
|
|
72
|
+
def google?
|
|
73
|
+
self.class.google?
|
|
74
|
+
end
|
|
75
|
+
|
|
63
76
|
# Array<Consently::Cookie> - what this tag leaves in the browser.
|
|
64
77
|
def cookies
|
|
65
78
|
self.class.cookie_manifest.map { |attributes| Cookie.new(**attributes, provider: key) }
|
|
@@ -3,6 +3,7 @@ module Consently
|
|
|
3
3
|
class GoogleTagManager < Base
|
|
4
4
|
self.provider_key = :google_tag_manager
|
|
5
5
|
self.default_category = :analytics
|
|
6
|
+
self.google = true
|
|
6
7
|
|
|
7
8
|
# None of its own: whatever it loads brings its own cookies, so list
|
|
8
9
|
# those tags here as well if you manage them through the container.
|
data/lib/consently/version.rb
CHANGED
data/lib/consently.rb
CHANGED
|
@@ -21,7 +21,9 @@ module Consently
|
|
|
21
21
|
append_to_file index, <<~JS
|
|
22
22
|
|
|
23
23
|
import ConsentlyBannerController from "consently/banner_controller"
|
|
24
|
+
import ConsentlyEmbedController from "consently/embed_controller"
|
|
24
25
|
application.register("consently-banner", ConsentlyBannerController)
|
|
26
|
+
application.register("consently-embed", ConsentlyEmbedController)
|
|
25
27
|
JS
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -25,9 +25,16 @@ Consently.configure do |c|
|
|
|
25
25
|
# not - switch it on if you want it treated the same way.
|
|
26
26
|
# c.respect_do_not_track = true
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
# Google consent mode v2. :basic keeps Google's tags off the page until the
|
|
29
|
+
# visitor agrees; :advanced loads them denied, so Ads can model refused
|
|
30
|
+
# conversions - more data, and a request to Google either way.
|
|
31
|
+
c.google_consent_mode = :basic
|
|
32
|
+
|
|
33
|
+
# One consent across subdomains needs the domain spelled out.
|
|
34
|
+
# c.cookie_domain = ".example.com"
|
|
35
|
+
|
|
36
|
+
# Ask again after a while, whatever the cookie's own lifetime says.
|
|
37
|
+
# c.consent_max_age = 12.months
|
|
31
38
|
|
|
32
39
|
# Store a row per decision as proof of consent. Needs the migration from
|
|
33
40
|
# `rails g consently:consent_log` and the engine mounted in routes.rb:
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michał Krzysteczko
|
|
@@ -28,15 +28,19 @@ description: 'Most Rails cookie banners ask for consent and then load Google Ana
|
|
|
28
28
|
Google Ads, Microsoft Clarity, Meta Pixel, Hotjar, Plausible or anything custom
|
|
29
29
|
- and it renders every non-essential one as an inert script the browser will not
|
|
30
30
|
even fetch, then turns them into live scripts the instant the visitor agrees, with
|
|
31
|
-
no page reload.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
and
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
no page reload. Embedded YouTube, Vimeo and Google Maps iframes wait behind a placeholder
|
|
32
|
+
of the same size and appear on consent too, because an iframe sets cookies with
|
|
33
|
+
no script of yours. Google Consent Mode v2 defaults are emitted before any Google
|
|
34
|
+
tag and updated on the click, in basic mode or in advanced mode where Google''s
|
|
35
|
+
own tags load denied so Ads can model refused conversions. GA4 ecommerce events
|
|
36
|
+
are built from your own line items rather than by hand. The banner and its preferences
|
|
37
|
+
panel ship translated into ten European languages, styled in plain CSS with no Tailwind
|
|
38
|
+
and no build step. Your cookie policy page is generated from the same configuration
|
|
39
|
+
- every vendor, every cookie it sets and for how long - so it cannot drift out of
|
|
40
|
+
date. Multi-tenant applications get per-domain or per-shop tag sets from a single
|
|
41
|
+
initializer, one consent can span subdomains, and an optional consent log stores
|
|
42
|
+
proof of each decision in your own database, with no third-party service and nothing
|
|
43
|
+
leaving your infrastructure.'
|
|
40
44
|
email:
|
|
41
45
|
- m.krzysteczko@icloud.com
|
|
42
46
|
executables: []
|
|
@@ -53,9 +57,11 @@ files:
|
|
|
53
57
|
- app/helpers/consently/application_helper.rb
|
|
54
58
|
- app/helpers/consently/tags_helper.rb
|
|
55
59
|
- app/javascript/consently/banner_controller.js
|
|
60
|
+
- app/javascript/consently/embed_controller.js
|
|
56
61
|
- app/models/consently/application_record.rb
|
|
57
62
|
- app/models/consently/consent_record.rb
|
|
58
63
|
- app/views/consently/_banner.html.erb
|
|
64
|
+
- app/views/consently/_embed.html.erb
|
|
59
65
|
- app/views/consently/_policy.html.erb
|
|
60
66
|
- config/importmap.rb
|
|
61
67
|
- config/locales/cs.yml
|
|
@@ -97,6 +103,7 @@ licenses:
|
|
|
97
103
|
- MIT
|
|
98
104
|
metadata:
|
|
99
105
|
homepage_uri: https://github.com/Xeross99/consently
|
|
106
|
+
documentation_uri: https://xeross99.github.io/consently/
|
|
100
107
|
bug_tracker_uri: https://github.com/Xeross99/consently/issues
|
|
101
108
|
changelog_uri: https://github.com/Xeross99/consently/blob/main/CHANGELOG.md
|
|
102
109
|
rubygems_mfa_required: 'true'
|