consently 0.1.0 → 0.2.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 +14 -0
- data/README.md +141 -16
- data/app/assets/stylesheets/consently.css +38 -0
- 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 +7 -0
- data/config/locales/de.yml +7 -0
- data/config/locales/en.yml +7 -0
- data/config/locales/es.yml +7 -0
- data/config/locales/fr.yml +7 -0
- data/config/locales/hu.yml +7 -0
- data/config/locales/it.yml +7 -0
- data/config/locales/nl.yml +7 -0
- data/config/locales/pl.yml +7 -0
- data/config/locales/sk.yml +7 -0
- 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 +16 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b563d7b5602bb17c10a2ac0ee0b04c6c59642df56920a36e292d808bfef5c89b
|
|
4
|
+
data.tar.gz: 0ae29edf9f32feb8fc79cbb7e2717e0a68fee7bef0dafdb38b9e4e5f1523b754
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e71581b2492efb315e7679f1a5d534dd45d8771fe6b552d75b946e763678bad95fccf4bffaa9f03371500ffafbb237a520d5e0c7e4552a616ba18b320fbf6b8a
|
|
7
|
+
data.tar.gz: a108dbed5dd145646f649807a953693963d6da9c4eb2ec64eca666301b5dd6e569a1699d71c4000dc879348242dbe547eb17da87558b9bd6deea2e34461ebaca
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-08-13)
|
|
4
|
+
|
|
5
|
+
- `consently_embed` for videos and maps: a YouTube or Vimeo iframe, or a
|
|
6
|
+
Google map, waits behind a placeholder until its category is granted and
|
|
7
|
+
then appears without a reload.
|
|
8
|
+
- `consently_ecommerce` builds GA4 ecommerce events from your own line items
|
|
9
|
+
or products, clearing the previous `ecommerce` object the way Google asks.
|
|
10
|
+
- `google_consent_mode` now takes `:basic` (the previous behaviour) or
|
|
11
|
+
`:advanced`, which lets Google's own tags load denied so Ads can model the
|
|
12
|
+
conversions of visitors who refused.
|
|
13
|
+
- `cookie_domain`, so one consent covers every subdomain.
|
|
14
|
+
- `consent_max_age`, to ask again after a year without bumping the policy
|
|
15
|
+
version.
|
|
16
|
+
|
|
3
17
|
## 0.1.0 (2026-08-12)
|
|
4
18
|
|
|
5
19
|
First release.
|
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
|
|
@@ -12,12 +17,24 @@ pageview.
|
|
|
12
17
|
| --- | --- |
|
|
13
18
|
| **Blocks** | GA4, Google Tag Manager, Google Ads, Microsoft Clarity, Meta Pixel, Hotjar, Plausible, anything custom |
|
|
14
19
|
| **Google Consent Mode v2** | denied by default before any Google tag, updated on the click |
|
|
15
|
-
| **Banner** | plain CSS, no Tailwind, no build step,
|
|
20
|
+
| **Banner** | plain CSS, no Tailwind, no build step, ten languages |
|
|
21
|
+
| **Embeds** | YouTube, Vimeo and Google Maps iframes wait behind a placeholder too |
|
|
22
|
+
| **Ecommerce** | GA4 `purchase`, `add_to_cart` and friends, built from your own line items |
|
|
16
23
|
| **Cookie policy** | generated from the same config - every vendor, every cookie, every duration |
|
|
17
24
|
| **Multi-tenant** | different tags per domain or shop from one initializer |
|
|
18
25
|
| **Proof of consent** | optional log in your own database; no third-party service, nothing leaves your servers |
|
|
19
26
|
| **Install** | one initializer, three helpers in your layout |
|
|
20
27
|
|
|
28
|
+
Before the click, and right after it - same page, no reload in between:
|
|
29
|
+
|
|
30
|
+
| Blocked | Running |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
|  |  |
|
|
33
|
+
|
|
34
|
+
The preferences panel, one category at a time:
|
|
35
|
+
|
|
36
|
+

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

|
|
162
|
+
|
|
116
163
|
## Proof of consent
|
|
117
164
|
|
|
118
165
|
```bash
|
|
@@ -187,13 +234,88 @@ button carries `aria-expanded` and `aria-controls`, reopening the panel moves
|
|
|
187
234
|
focus into it, and the animation gives way to `prefers-reduced-motion`. Nobody
|
|
188
235
|
is trapped in a focus cycle they did not ask for.
|
|
189
236
|
|
|
190
|
-
##
|
|
237
|
+
## Ecommerce events
|
|
238
|
+
|
|
239
|
+
GA4 wants a particular shape, and your models are not it. Hand the helper
|
|
240
|
+
whatever you have:
|
|
241
|
+
|
|
242
|
+
```erb
|
|
243
|
+
<%= consently_ecommerce "purchase", items: @order.line_items,
|
|
244
|
+
value: @order.total, currency: "EUR", transaction_id: @order.number %>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Items may be hashes already in GA4 shape, or any object answering to
|
|
248
|
+
`sku`/`id`, `name`, `price`, `quantity`, `category`, `brand`, `variant` - a
|
|
249
|
+
line item or a product usually does. The previous `ecommerce` object is
|
|
250
|
+
cleared first, as Google asks, so two events on one page cannot bleed into
|
|
251
|
+
each other.
|
|
252
|
+
|
|
253
|
+
Anything else:
|
|
191
254
|
|
|
192
255
|
```erb
|
|
193
|
-
<%= consently_data_layer_push
|
|
256
|
+
<%= consently_data_layer_push "newsletter_signup", source: "footer" %>
|
|
194
257
|
```
|
|
195
258
|
|
|
196
|
-
|
|
259
|
+
Both render nothing at all when analytics consent is missing.
|
|
260
|
+
|
|
261
|
+
## Embedded videos and maps
|
|
262
|
+
|
|
263
|
+
Blocking scripts is half the job: a YouTube iframe sets cookies on its own.
|
|
264
|
+
|
|
265
|
+
```erb
|
|
266
|
+
<%= consently_embed :youtube, "dQw4w9WgXcQ" %>
|
|
267
|
+
<%= consently_embed :vimeo, "76979871", category: :analytics %>
|
|
268
|
+
<%= consently_embed :google_maps, "Bahnhofstrasse 12, Berlin" %>
|
|
269
|
+
<%= consently_embed :custom, "https://example.com/widget", title: "Widget", ratio: "4 / 3" %>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Until the category is granted the visitor gets a placeholder the same size as
|
|
273
|
+
the embed - so nothing jumps - with a button that opens the preferences panel.
|
|
274
|
+
The iframe appears the moment they agree, without a reload.
|
|
275
|
+
|
|
276
|
+
| Waiting for consent | After the click |
|
|
277
|
+
| --- | --- |
|
|
278
|
+
|  |  |
|
|
279
|
+
|
|
280
|
+
What the page holds until then is only the address:
|
|
281
|
+
|
|
282
|
+
```html
|
|
283
|
+
<div class="consently-embed"
|
|
284
|
+
data-controller="consently-embed"
|
|
285
|
+
data-consently-embed-category-value="marketing"
|
|
286
|
+
data-consently-embed-src-value="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ">
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
No iframe, no request to YouTube, no cookie - and `youtube-nocookie.com` is
|
|
290
|
+
what gets embedded once there is consent. `ratio:` sets the box (`16 / 9` by
|
|
291
|
+
default), `category:` decides which consent releases it, and any other option
|
|
292
|
+
is passed straight to the iframe:
|
|
293
|
+
|
|
294
|
+
```erb
|
|
295
|
+
<%= consently_embed :youtube, "dQw4w9WgXcQ", ratio: "21 / 9", allow: "autoplay; picture-in-picture" %>
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Your own markup can do the same thing without the helper: anything carrying
|
|
299
|
+
`data-consently-open` opens the preferences panel, and the `consently:change`
|
|
300
|
+
event tells you what was granted.
|
|
301
|
+
|
|
302
|
+
## Google consent mode: basic or advanced
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
c.google_consent_mode = :basic # default
|
|
306
|
+
c.google_consent_mode = :advanced
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Basic** keeps Google's tags off the page until consent: nothing about the
|
|
310
|
+
visitor reaches Google before they agree.
|
|
311
|
+
|
|
312
|
+
**Advanced** loads them right away with everything denied, so they send
|
|
313
|
+
cookieless pings and Google Ads can model the conversions of visitors who said
|
|
314
|
+
no. More data for you, a request to Google either way. Which one is defensible
|
|
315
|
+
is a legal call, not a technical one - the gem simply does what you set.
|
|
316
|
+
|
|
317
|
+
Either way the defaults are emitted before any Google tag and updated the
|
|
318
|
+
moment the visitor chooses.
|
|
197
319
|
|
|
198
320
|
## Styling
|
|
199
321
|
|
|
@@ -251,7 +373,9 @@ Helpers:
|
|
|
251
373
|
| `consently_banner` | the banner, the panel, and the JavaScript that releases blocked tags |
|
|
252
374
|
| `consently_policy` | the generated cookie policy: categories, vendors, cookies, durations |
|
|
253
375
|
| `consently_preferences_link` | "Cookie settings" link; anything with `data-consently-open` reopens the panel |
|
|
254
|
-
| `
|
|
376
|
+
| `consently_ecommerce(event, items:, **params)` | a GA4 ecommerce event, items mapped from your own objects |
|
|
377
|
+
| `consently_data_layer_push(event, **payload)` | any other dataLayer event, rendered only with analytics consent |
|
|
378
|
+
| `consently_embed(kind, id, category:, ratio:)` | a video or map that waits for consent |
|
|
255
379
|
| `consently_consent` | the current `Consently::Consent`; `granted?(:analytics)` in your own views |
|
|
256
380
|
|
|
257
381
|
Configuration:
|
|
@@ -264,10 +388,11 @@ Configuration:
|
|
|
264
388
|
| `c.consent_version` | bump it and every older consent stops counting |
|
|
265
389
|
| `c.enabled` | `true`, `false`, or a callable taking the request |
|
|
266
390
|
| `c.reload_after_choice` | reload once a choice is made; off by default |
|
|
267
|
-
| `c.google_consent_mode` |
|
|
391
|
+
| `c.google_consent_mode` | `:basic` (default), `:advanced`, or `false` |
|
|
268
392
|
| `c.log_consents`, `c.consent_subject` | store proof of each decision, optionally naming who |
|
|
269
393
|
| `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 |
|
|
394
|
+
| `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path`, `c.cookie_domain` | where the choice is kept |
|
|
395
|
+
| `c.consent_max_age` | ask again after this long, whatever the cookie says |
|
|
271
396
|
| `c.respect_do_not_track`, `c.respect_global_privacy_control` | treat an opt-out signal as a rejection |
|
|
272
397
|
| `c.consent_required` | who has to be asked at all; false means no banner and everything granted |
|
|
273
398
|
|
|
@@ -146,6 +146,44 @@
|
|
|
146
146
|
display: none !important;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/* Embedded videos and maps. The placeholder keeps the same box as the iframe
|
|
150
|
+
it replaces, so nothing on the page jumps when consent arrives. */
|
|
151
|
+
.consently-embed {
|
|
152
|
+
position: relative;
|
|
153
|
+
aspect-ratio: var(--consently-embed-ratio, 16 / 9);
|
|
154
|
+
width: 100%;
|
|
155
|
+
border-radius: 0.5rem;
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
background: #f3f4f6;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.consently-embed__frame {
|
|
161
|
+
width: 100%;
|
|
162
|
+
height: 100%;
|
|
163
|
+
border: 0;
|
|
164
|
+
display: block;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.consently-embed__placeholder {
|
|
168
|
+
position: absolute;
|
|
169
|
+
inset: 0;
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
align-items: center;
|
|
173
|
+
justify-content: center;
|
|
174
|
+
gap: 0.75rem;
|
|
175
|
+
padding: 1.5rem;
|
|
176
|
+
text-align: center;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.consently-embed__text {
|
|
180
|
+
margin: 0;
|
|
181
|
+
max-width: 28rem;
|
|
182
|
+
font-size: 0.875rem;
|
|
183
|
+
line-height: 1.5rem;
|
|
184
|
+
color: #4b5563;
|
|
185
|
+
}
|
|
186
|
+
|
|
149
187
|
/* The generated cookie policy. Deliberately quiet: it lands inside someone
|
|
150
188
|
else's page and should inherit its typography. */
|
|
151
189
|
.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"
|
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"
|
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"
|
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"
|
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é"
|
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"
|
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"
|
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"
|
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"
|
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"
|
|
@@ -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.
|
|
4
|
+
version: 0.2.0
|
|
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
|