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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e94002d1ea10654641d354b24f94f18c97a106e71cf737a9fede52de32b6141
4
- data.tar.gz: 5dd2300f3e1b7e8f7c696dd8dc86d5cdd098c7889e6a070f00b379b2f546b105
3
+ metadata.gz: 3ae532d0c3dd92af70f6bd6bfae150b2d61889fbb7ecae3ea1edae521e33605a
4
+ data.tar.gz: f0bccd48aba2d92a00e1a1359580b5f051206de246aa205bb3a2a3323df516c9
5
5
  SHA512:
6
- metadata.gz: 5d47ad72af0dd8538c124c383ebe809605675a05f68ab496a97e88a1f089d9eca4e1e1f779617ffb3361c4d5a0bc7a9eb8dc2ba1b13799b67aeb3e32a6b0856d
7
- data.tar.gz: c56ac8259f0b480dd6d7c34c0649ca2ac02b365cb3e32d894600d8692918bd21d4c76f32cfc537e30481ebe8c16bf65890ceebc1fa7983e0673d4c0184e632df
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
@@ -1,4 +1,4 @@
1
- Copyright TODO: Write your name
1
+ Copyright (c) 2026 Michał Krzysteczko
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Consently
2
2
 
3
+ [![Gem](https://img.shields.io/gem/v/consently?color=%23e9573f)](https://rubygems.org/gems/consently)
4
+ [![Downloads](https://img.shields.io/gem/dt/consently)](https://rubygems.org/gems/consently)
5
+ [![CI](https://github.com/Xeross99/consently/actions/workflows/ci.yml/badge.svg)](https://github.com/Xeross99/consently/actions/workflows/ci.yml)
6
+ [![MIT](https://img.shields.io/badge/licence-MIT-blue)](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, one Stimulus controller, ten languages |
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
+ | ![The banner, with every optional tag blocked](docs/banner.png) | ![The same page a click later, every tag running](docs/running.png) |
36
+
37
+ The preferences panel, one category at a time:
38
+
39
+ ![The preferences panel with a checkbox per category](docs/preferences.png)
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
- The generator writes the initializer and registers the Stimulus controller in
56
- `app/javascript/controllers/index.js`. Nothing else to set up: the banner
57
- brings its own plain CSS, so there is no Tailwind, no build step and no
58
- config file to keep in sync.
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 "trixbrix.eu" do |s|
104
- s.tag :google_analytics, id: "G-TRIX"
137
+ c.scope "shop.example.com" do |s|
138
+ s.tag :google_analytics, id: "G-SHOP00001"
105
139
  end
106
140
 
107
- c.scope "pixelpicture.eu" do |s|
108
- s.tag :google_analytics, id: "G-PIXEL"
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
+ ![The generated cookie policy: categories, vendors, cookies and durations](docs/policy.png)
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
- ## Events
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
+ | ![A placeholder where the video will be](docs/embed-blocked.png) | ![The same box, now playing the video](docs/embed-released.png) |
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
- <%= consently_data_layer_push("purchase", value: 120, currency: "EUR") %>
298
+ <%= consently_embed :youtube, "dQw4w9WgXcQ", ratio: "21 / 9", allow: "autoplay; picture-in-picture" %>
194
299
  ```
195
300
 
196
- Renders nothing when analytics consent is missing.
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 scoped under `.consently`, driven by custom
201
- properties. Most restyling is a few variables in your own stylesheet:
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
- .consently {
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
- | `consently_data_layer_push(event, **payload)` | a dataLayer event, rendered only with analytics consent |
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` | consent mode v2 defaults and updates; on by default |
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
- * Everything is scoped under .consently and driven by custom properties, so
6
- * restyling means overriding a handful of variables rather than forking the
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
- * .consently { --consently-accent: #4f46e5; --consently-radius: 0; }
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
- .consently {
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
- granted = consently_consent.granted?(provider.category)
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(cookies[Consently.config.cookie_name], version: Consently.config.consent_version)
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("purchase", value: 120, currency: "EUR") %>
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 their own importmap.
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
@@ -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: "%{count} dní"
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."
@@ -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: "%{count} Tage"
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."
@@ -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: "%{count} 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."
@@ -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: "%{count} días"
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."
@@ -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: "%{count} jours"
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."
@@ -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: "%{count} nap"
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."
@@ -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: "%{count} giorni"
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."
@@ -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: "%{count} dagen"
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."
@@ -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: "%{count} dni"
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."
@@ -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: "%{count} dní"
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
- attr_accessor :cookie_name, :cookie_max_age, :cookie_path
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
- # Emits Google's consent mode v2 defaults (everything denied) before any
19
- # Google tag, and updates them when the visitor chooses. Leave it on if
20
- # you use any Google product.
21
- attr_accessor :google_consent_mode
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 = true
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 "trixbrix" do |s|
115
- # s.tag :google_analytics, id: "G-TRIX"
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)
@@ -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
@@ -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 GoogleAds < Base
4
4
  self.provider_key = :google_ads
5
5
  self.default_category = :marketing
6
+ self.google = true
6
7
 
7
8
  cookie "_gcl_au", days: 90
8
9
 
@@ -5,6 +5,7 @@ module Consently
5
5
  class GoogleAnalytics < Base
6
6
  self.provider_key = :google_analytics
7
7
  self.default_category = :analytics
8
+ self.google = true
8
9
 
9
10
  # What GA4 leaves behind. The container-scoped one is written per
10
11
  # measurement id, hence the wildcard.
@@ -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.
@@ -1,3 +1,3 @@
1
1
  module Consently
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.1"
3
3
  end
data/lib/consently.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "time"
2
3
  require "active_support"
3
4
  require "active_support/core_ext/object/blank"
4
5
 
@@ -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
- # Google consent mode v2: defaults denied before any Google tag, updated
29
- # when the visitor chooses. Leave on if you use anything Google.
30
- c.google_consent_mode = true
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.0
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. Google Consent Mode v2 defaults are emitted before any Google tag
32
- and updated on the click. The banner and its preferences panel ship translated into
33
- ten European languages, styled in plain CSS with no Tailwind and no build step,
34
- and driven by one Stimulus controller. Your cookie policy page is generated from
35
- the same configuration - every vendor, every cookie it sets and for how long - so
36
- it cannot drift out of date. Multi-tenant applications get per-domain or per-shop
37
- tag sets from a single initializer, and an optional consent log stores proof of
38
- each decision in your own database, with no third-party service and nothing leaving
39
- your infrastructure.'
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'