consently 0.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ae532d0c3dd92af70f6bd6bfae150b2d61889fbb7ecae3ea1edae521e33605a
4
- data.tar.gz: f0bccd48aba2d92a00e1a1359580b5f051206de246aa205bb3a2a3323df516c9
3
+ metadata.gz: 5a4c3433bcb18de24b5fc9849e955ef217502f51f01e4a281e23a493ae18a681
4
+ data.tar.gz: 1347eb53ce32770929522ada6c86a35a068c450cd738466d88913f98019191c8
5
5
  SHA512:
6
- metadata.gz: 8c2917ed994ffc2db028512616ef551d18471e2e68df786e150337fae51b14c8de902aabd209cf3d65a81b14fcd7aed13135e5b2a8a3f886a217a5e6b4abd348
7
- data.tar.gz: bc74d85205720239b176d6caeab993e1bd08d9c49258f4fc6778dd17d763b808a4705155f32b8f7eec620102b1f4215469d3b523fe51b859c74a6961fafa763e
6
+ metadata.gz: 532e7e0eab42de4a040fedb640ef70cb575cd651d3b871c743cd3259abf9562f681248c25b1d9d2662321c7d904850544404072ac3da75316cd7fa31962b8a3c
7
+ data.tar.gz: 2b4cbaa8b217c5691836b8b425d716e862642f865018a7dc24da428c9eb3861d35bc3a475238a56ae7857df3038e32462e33b3f7bafb9673dc53af727eebad29
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 (2026-10-31)
4
+
5
+ - The banner asks only about categories in use: one with a tag in it (in any
6
+ scope) or one declared with `c.category`. A site with nothing but Google
7
+ Analytics no longer shows a "Marketing" checkbox for marketing it does not
8
+ do. `c.categories = [...]` fixes the list by hand.
9
+ - The banner message follows the categories: analytics alone reads "to
10
+ understand how it is used and improve it", without the "relevant offers"
11
+ that only marketing justifies. Every locale has the new `message_analytics`
12
+ and `message_marketing`.
13
+ - No optional category, no banner: there is nothing to ask.
14
+ - Checkboxes are drawn by the gem's CSS instead of left native, so a reset in
15
+ the host app (`@tailwindcss/forms` turns every checkbox square) no longer
16
+ restyles them. `--consently-checkbox-border` and
17
+ `--consently-checkbox-radius` tune them.
18
+ - **Upgrading:** `consently_embed` raises when its category is not offered,
19
+ since the visitor could never release it. A site whose embeds wait for
20
+ `marketing` without any marketing tag adds `c.category :marketing`.
21
+
22
+ ## 1.0.0 (2026-09-04)
23
+
24
+ - Events speak the language of the page's tags: `gtag('event', ...)` under
25
+ gtag.js, a `dataLayer` push under Google Tag Manager, decided per request
26
+ from the tags in scope or forced with `event_transport`. Before, every event
27
+ was a dataLayer push - which gtag.js ignores, so a shop without a container
28
+ lost its whole ecommerce funnel.
29
+ - An event rendered without consent is held back as an inert script and
30
+ released with the tags when its category is granted, instead of being
31
+ dropped. The page a visitor accepts on now counts its `view_item`.
32
+ - `consently_stream_event` sends an event from a Turbo Stream response - the
33
+ add-to-cart that never renders a page - through a stream action that ships
34
+ with the banner controller; it is sent at once with consent and queued
35
+ until then otherwise.
36
+ - `Consently.track(event, payload, { category })` and `Consently.granted()`
37
+ in JavaScript, on the same queue.
38
+ - `consently_event` is the new name of `consently_data_layer_push`; the old
39
+ one still works.
40
+
3
41
  ## 0.2.1 (2026-08-13)
4
42
 
5
43
  - The banner's custom properties moved from `.consently` to `:root`, so the
data/README.md CHANGED
@@ -102,11 +102,25 @@ c.tag :custom, as: :inline_thing, category: :marketing, inline: "console.log('hi
102
102
 
103
103
  ## Consent
104
104
 
105
- Three categories out of the box - `necessary`, `analytics`, `marketing` - and
106
- you can add your own:
105
+ Three categories out of the box - `necessary`, `analytics`, `marketing` - but
106
+ the banner only asks about the ones you use: a category is offered when a tag
107
+ sits in it (in any scope) or when you declare it. A site running nothing but
108
+ Google Analytics gets a single checkbox, and a message that does not promise
109
+ "relevant offers" it never makes. With only `necessary` tags there is nothing
110
+ to ask, and no banner.
111
+
112
+ Declare a category that only your views use - an embed, an event - or one of
113
+ your own:
114
+
115
+ ```ruby
116
+ c.category :marketing # a YouTube embed waits for it
117
+ c.category :personalization # name it in your locale file
118
+ ```
119
+
120
+ Or fix the list and its order outright (`necessary` is always added):
107
121
 
108
122
  ```ruby
109
- c.category :personalization
123
+ c.categories = %i[analytics marketing]
110
124
  ```
111
125
 
112
126
  `necessary` never waits for a click. Everything else is blocked until the
@@ -201,8 +215,9 @@ status panel - ask for a reload instead:
201
215
  c.reload_after_choice = true
202
216
  ```
203
217
 
204
- Either way a `consently:change` event fires on `document`, carrying the
205
- granted categories, so you can react to it yourself:
218
+ Either way the events waiting on that consent go out too (see below), and a
219
+ `consently:change` event fires on `document`, carrying the granted categories,
220
+ so you can react to it yourself:
206
221
 
207
222
  ```js
208
223
  document.addEventListener("consently:change", ({ detail }) => {
@@ -237,7 +252,7 @@ button carries `aria-expanded` and `aria-controls`, reopening the panel moves
237
252
  focus into it, and the animation gives way to `prefers-reduced-motion`. Nobody
238
253
  is trapped in a focus cycle they did not ask for.
239
254
 
240
- ## Ecommerce events
255
+ ## Events
241
256
 
242
257
  GA4 wants a particular shape, and your models are not it. Hand the helper
243
258
  whatever you have:
@@ -249,17 +264,50 @@ whatever you have:
249
264
 
250
265
  Items may be hashes already in GA4 shape, or any object answering to
251
266
  `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.
267
+ line item or a product usually does. Anything else:
255
268
 
256
- Anything else:
269
+ ```erb
270
+ <%= consently_event "newsletter_signup", source: "footer" %>
271
+ ```
272
+
273
+ **Sent the way your tags listen.** A page running gtag.js gets
274
+ `gtag('event', ...)` calls; a page running Google Tag Manager gets `dataLayer`
275
+ pushes, the previous `ecommerce` object cleared first as Google asks. The gem
276
+ decides per request from the tags in scope - a container means the dataLayer,
277
+ any other Google tag means gtag - because a push meant for a container is
278
+ something gtag.js silently ignores, and a whole checkout funnel can go missing
279
+ that way. Force it if you must:
280
+
281
+ ```ruby
282
+ c.event_transport = :data_layer # or :gtag; :auto is the default
283
+ ```
284
+
285
+ **Held back, not dropped.** Without consent an event is rendered the way a tag
286
+ is - an inert `<script type="text/plain">` - and released together with the
287
+ tags the moment its category is granted. The product page a visitor accepts
288
+ on still counts its `view_item`.
289
+
290
+ **From a Turbo Stream.** An add-to-cart that answers with a stream renders no
291
+ page to put a script on. The stream carries the event instead, and the
292
+ JavaScript side sends it at once if the category is granted, or keeps it until
293
+ it is:
257
294
 
258
295
  ```erb
259
- <%= consently_data_layer_push "newsletter_signup", source: "footer" %>
296
+ <%# line_items/create.turbo_stream.erb %>
297
+ <%= turbo_stream.replace "cart", partial: "cart" %>
298
+ <%= consently_stream_event "add_to_cart", items: [@line_item],
299
+ currency: "EUR", value: @line_item.price %>
260
300
  ```
261
301
 
262
- Both render nothing at all when analytics consent is missing.
302
+ Nothing to register: the action arrives with the banner controller.
303
+
304
+ **From your own JavaScript.** The same queue, the same rule:
305
+
306
+ ```js
307
+ Consently.track("newsletter_signup", { source: "footer" })
308
+ Consently.track("lead", { value: 1 }, { category: "marketing", ecommerce: false })
309
+ Consently.granted("analytics") // true or false, right now
310
+ ```
263
311
 
264
312
  ## Embedded videos and maps
265
313
 
@@ -376,13 +424,14 @@ Helpers:
376
424
 
377
425
  | | |
378
426
  | --- | --- |
379
- | `consently_tags` | `<head>`: the stylesheet, consent mode defaults, and every tag (blocked or live) |
427
+ | `consently_tags` | `<head>`: the stylesheet, consent mode defaults, every tag (blocked or live), and what the JavaScript side needs to know |
380
428
  | `consently_noscript_tags` | after `<body>`: GTM and Meta fallbacks, for granted categories only |
381
429
  | `consently_banner` | the banner, the panel, and the JavaScript that releases blocked tags |
382
430
  | `consently_policy` | the generated cookie policy: categories, vendors, cookies, durations |
383
431
  | `consently_preferences_link` | "Cookie settings" link; anything with `data-consently-open` reopens the panel |
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 |
432
+ | `consently_ecommerce(event, items:, **params)` | a GA4 ecommerce event, items mapped from your own objects; held back until consent |
433
+ | `consently_event(event, **payload)` | any other event, gtag or dataLayer as the page's tags expect; held back until consent |
434
+ | `consently_stream_event(event, items:, **payload)` | the same from a Turbo Stream response, sent or queued in the browser |
386
435
  | `consently_embed(kind, id, category:, ratio:)` | a video or map that waits for consent |
387
436
  | `consently_consent` | the current `Consently::Consent`; `granted?(:analytics)` in your own views |
388
437
 
@@ -392,11 +441,13 @@ Configuration:
392
441
  | --- | --- |
393
442
  | `c.tag key, **options` | `id:`, `domain:`, `category:`, `as:` (name it, for two of a kind) |
394
443
  | `c.scope(name) { \|s\| ... }` | tags for one tenant; `c.scope_resolver` decides which one a request is |
395
- | `c.category :name` | a category of your own beside `necessary`, `analytics`, `marketing` |
444
+ | `c.category :name` | offer a category no tag uses: your own, or a built-in one for embeds and events |
445
+ | `c.categories = [...]` | fix the offered categories instead of deriving them from the tags |
396
446
  | `c.consent_version` | bump it and every older consent stops counting |
397
447
  | `c.enabled` | `true`, `false`, or a callable taking the request |
398
448
  | `c.reload_after_choice` | reload once a choice is made; off by default |
399
449
  | `c.google_consent_mode` | `:basic` (default), `:advanced`, or `false` |
450
+ | `c.event_transport` | `:auto` (default: gtag under gtag.js, the dataLayer under a container), `:gtag`, `:data_layer` |
400
451
  | `c.log_consents`, `c.consent_subject` | store proof of each decision, optionally naming who |
401
452
  | `c.stylesheet` | link the banner's CSS; off if you style it yourself |
402
453
  | `c.cookie_name`, `c.cookie_max_age`, `c.cookie_path`, `c.cookie_domain` | where the choice is kept |
@@ -404,8 +455,9 @@ Configuration:
404
455
  | `c.respect_do_not_track`, `c.respect_global_privacy_control` | treat an opt-out signal as a rejection |
405
456
  | `c.consent_required` | who has to be asked at all; false means no banner and everything granted |
406
457
 
407
- JavaScript: a `consently:change` event fires on `document` with the granted
408
- categories.
458
+ JavaScript: `Consently.track(event, payload, { category })` sends or queues
459
+ an event, `Consently.granted(category)` says where things stand, and a
460
+ `consently:change` event fires on `document` with the granted categories.
409
461
 
410
462
  ## Licence
411
463
 
@@ -23,6 +23,8 @@
23
23
  --consently-radius: 0.75rem;
24
24
  --consently-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
25
25
  --consently-max-width: 36rem;
26
+ --consently-checkbox-border: #d1d5db;
27
+ --consently-checkbox-radius: 0.25rem;
26
28
  }
27
29
 
28
30
  .consently {
@@ -90,11 +92,46 @@
90
92
  line-height: 1.5rem;
91
93
  }
92
94
 
93
- .consently__option input {
94
- margin: 0.3rem 0 0;
95
+ /* Drawn by hand rather than left native: CSS resets in the host app
96
+ (@tailwindcss/forms and friends) restyle every [type=checkbox] on the page
97
+ and the banner ends up with square boxes in the wrong colour. These
98
+ selectors outrank such resets, so the box looks the same everywhere. */
99
+ .consently__option input[type="checkbox"] {
100
+ -webkit-appearance: none;
101
+ appearance: none;
102
+ flex: none;
103
+ box-sizing: border-box;
104
+ display: inline-block;
105
+ margin: 0.25rem 0 0;
106
+ padding: 0;
95
107
  width: 1rem;
96
108
  height: 1rem;
97
- accent-color: var(--consently-accent);
109
+ border: 1px solid var(--consently-checkbox-border);
110
+ border-radius: var(--consently-checkbox-radius);
111
+ background: var(--consently-background) none center / 100% 100% no-repeat;
112
+ box-shadow: none;
113
+ cursor: pointer;
114
+ }
115
+
116
+ .consently__option input[type="checkbox"]:checked {
117
+ border-color: transparent;
118
+ background-color: var(--consently-accent);
119
+ background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
120
+ }
121
+
122
+ .consently__option input[type="checkbox"]:disabled {
123
+ cursor: default;
124
+ opacity: 0.6;
125
+ }
126
+
127
+ .consently__option input[type="checkbox"]:focus {
128
+ box-shadow: none;
129
+ outline: none;
130
+ }
131
+
132
+ .consently__option input[type="checkbox"]:focus-visible {
133
+ outline: 2px solid var(--consently-accent);
134
+ outline-offset: 2px;
98
135
  }
99
136
 
100
137
  .consently__option-name {
@@ -9,7 +9,7 @@ module Consently
9
9
  def consently_tags
10
10
  return "".html_safe unless consently_enabled?
11
11
 
12
- parts = []
12
+ parts = [ consently_state_tags ]
13
13
  parts << consently_stylesheet_tag if Consently.config.stylesheet
14
14
  parts << consently_consent_mode_tag if Consently.config.google_consent_mode
15
15
  Consently.tags_for(request).each do |provider|
@@ -42,6 +42,8 @@ module Consently
42
42
  # `consently_preferences_link` has something to reopen.
43
43
  def consently_banner(policy_url: nil)
44
44
  return "".html_safe unless consently_enabled? && Consently.consent_required?(request)
45
+ # Only necessary cookies: nothing to ask, so no banner.
46
+ return "".html_safe if Consently.config.optional_categories.empty?
45
47
 
46
48
  render "consently/banner",
47
49
  consent: consently_consent,
@@ -75,17 +77,21 @@ module Consently
75
77
  end
76
78
  end
77
79
 
78
- # Push an event onto the dataLayer from a view, respecting consent: with
79
- # no analytics consent the event is simply not emitted.
80
+ # An analytics event from a view, sent the way this page's tags expect -
81
+ # gtag('event', ...) under gtag.js, a dataLayer push under Google Tag
82
+ # Manager (see Consently.event_transport_for) - and only with consent.
83
+ # Without it the event is held back the way a tag is: an inert script
84
+ # the banner releases the moment the category is granted, so the page a
85
+ # visitor accepts on still counts.
80
86
  #
81
- # <%= consently_data_layer_push("newsletter_signup", source: "footer") %>
82
- def consently_data_layer_push(event, category: :analytics, **payload)
83
- return "".html_safe unless consently_enabled? && consently_consent.granted?(category)
84
-
85
- payload = payload.merge(event: event)
86
- consently_inline_script "window.dataLayer = window.dataLayer || []; window.dataLayer.push(#{payload.to_json});"
87
+ # <%= consently_event("newsletter_signup", source: "footer") %>
88
+ def consently_event(event, category: :analytics, **payload)
89
+ consently_event_tag(Event.new(event, payload), category)
87
90
  end
88
91
 
92
+ # The name this helper had before it learnt to speak gtag.
93
+ alias_method :consently_data_layer_push, :consently_event
94
+
89
95
  # A GA4 ecommerce event in the shape Google expects, from whatever your
90
96
  # models happen to look like:
91
97
  #
@@ -96,19 +102,32 @@ module Consently
96
102
  # few obvious names (id/sku, name, price, quantity, category, brand,
97
103
  # variant) - a LineItem or a Product usually does.
98
104
  #
99
- # The previous ecommerce object is cleared first, as Google asks, so two
100
- # events on one page cannot bleed into each other.
105
+ # Under Google Tag Manager the previous ecommerce object is cleared
106
+ # first, as Google asks, so two events on one page cannot bleed into
107
+ # each other. Held back without consent, like consently_event.
101
108
  def consently_ecommerce(event, items: [], category: :analytics, **params)
102
- return "".html_safe unless consently_enabled? && consently_consent.granted?(category)
109
+ consently_event_tag(Event.new(event, params.merge(items: consently_ecommerce_items(items)), ecommerce: true), category)
110
+ end
103
111
 
104
- ecommerce = params.merge(items: Array(items).map { |item| consently_ecommerce_item(item) })
105
- payload = { event: event, ecommerce: ecommerce.compact }
112
+ # The same event from a Turbo Stream response - an add-to-cart that
113
+ # never renders a page. Items make it an ecommerce event. The stream
114
+ # carries the event, not a script: the JavaScript side sends it at once
115
+ # if the category is granted and keeps it until then otherwise, so a
116
+ # cart filled before the click is counted after it.
117
+ #
118
+ # <%= consently_stream_event("add_to_cart", items: [ @line_item ], currency: "EUR", value: 12.5) %>
119
+ def consently_stream_event(event, items: nil, category: :analytics, **payload)
120
+ return "".html_safe unless consently_enabled?
106
121
 
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
122
+ payload = payload.merge(items: consently_ecommerce_items(items)) unless items.nil?
123
+ event = Event.new(event, payload, ecommerce: !items.nil?)
124
+
125
+ content_tag("turbo-stream", "",
126
+ action: "consently_event",
127
+ event: event.name,
128
+ category: category,
129
+ ecommerce: event.ecommerce?,
130
+ payload: event.payload.to_json)
112
131
  end
113
132
 
114
133
  # A complete cookie policy for the tags this request would load: every
@@ -132,6 +151,13 @@ module Consently
132
151
  # button that opens the preferences panel; the iframe appears the moment
133
152
  # they agree, without a reload.
134
153
  def consently_embed(kind, identifier, category: :marketing, title: nil, ratio: "16 / 9", **iframe_options)
154
+ # The placeholder sends the visitor to a checkbox; without one the
155
+ # embed could never be released.
156
+ unless Consently.config.category?(category)
157
+ raise ArgumentError, "consently_embed waits for the #{category} category, which the banner does not offer - " \
158
+ "add `c.category :#{category}` to config/initializers/consently.rb"
159
+ end
160
+
135
161
  src = consently_embed_src(kind, identifier)
136
162
  return "".html_safe if src.blank?
137
163
 
@@ -167,6 +193,40 @@ module Consently
167
193
  Consently.enabled?(request)
168
194
  end
169
195
 
196
+ # Two <meta> tags the JavaScript side reads: which categories this page
197
+ # was rendered with, and how events are to be sent. Provisional head
198
+ # elements, so Turbo Drive swaps them on every visit.
199
+ def consently_state_tags
200
+ granted = Consently.config.categories.select { |category| consently_consent.granted?(category) }
201
+
202
+ safe_join([
203
+ tag.meta(name: "consently-granted", content: granted.join(" ")),
204
+ tag.meta(name: "consently-transport", content: consently_event_transport)
205
+ ], "\n")
206
+ end
207
+
208
+ def consently_event_transport
209
+ @consently_event_transport ||= Consently.event_transport_for(request)
210
+ end
211
+
212
+ # Live when the category is granted; otherwise inert, and released by the
213
+ # banner together with the tags the moment it is.
214
+ def consently_event_tag(event, category)
215
+ return "".html_safe unless consently_enabled?
216
+
217
+ javascript = event.to_js(consently_event_transport)
218
+ return consently_inline_script(javascript) if consently_consent.granted?(category)
219
+
220
+ attributes = { type: "text/plain", data: { "consently-category" => category } }
221
+ attributes[:nonce] = content_security_policy_nonce if content_security_policy_nonce.present?
222
+
223
+ content_tag(:script, javascript.html_safe, attributes)
224
+ end
225
+
226
+ def consently_ecommerce_items(items)
227
+ Array(items).map { |item| consently_ecommerce_item(item) }
228
+ end
229
+
170
230
  # Whether this tag may run now. Normally that means consent; under
171
231
  # advanced consent mode Google's own tags also load before it, having
172
232
  # been told to store nothing until it arrives.
@@ -1,4 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ // Loaded for its side effects: the event queue, the Turbo Stream action and
3
+ // window.Consently.track come along with the banner.
4
+ import "consently/events"
2
5
 
3
6
  // The banner, the preferences panel, and the part that actually matters:
4
7
  // turning the blocked <script type="text/plain"> tags into live ones the
@@ -0,0 +1,78 @@
1
+ // Analytics events that wait for consent.
2
+ //
3
+ // Three roads lead here. An event rendered into a page without consent is an
4
+ // inert <script> the banner releases together with the tags - nothing to do
5
+ // on this side. A <turbo-stream action="consently_event"> comes from a
6
+ // response that renders no page, and window.Consently.track() from your own
7
+ // code; those two are sent at once when their category is granted and kept
8
+ // until it is otherwise, so an add-to-cart that happened before the click is
9
+ // counted after it.
10
+ //
11
+ // The page says what is granted and how to send in two <meta> tags written by
12
+ // consently_tags. The banner's consently:change event overrides the first the
13
+ // moment a choice is made, and the next Turbo visit brings a fresh page whose
14
+ // tags already know.
15
+
16
+ const NECESSARY = "necessary"
17
+ const waiting = []
18
+ let decided = null
19
+
20
+ export function track(name, payload = {}, { category = "analytics", ecommerce = false } = {}) {
21
+ const event = { name, payload, category, ecommerce }
22
+
23
+ if (granted(category)) send(event)
24
+ else waiting.push(event)
25
+ }
26
+
27
+ export function granted(category) {
28
+ if (category === NECESSARY) return true
29
+
30
+ return (decided ?? meta("consently-granted").split(" ")).includes(category)
31
+ }
32
+
33
+ function meta(name) {
34
+ return document.querySelector(`meta[name="${name}"]`)?.content ?? ""
35
+ }
36
+
37
+ function send({ name, payload, ecommerce }) {
38
+ window.dataLayer = window.dataLayer || []
39
+
40
+ if (meta("consently-transport") === "gtag") {
41
+ // gtag.js reads the queue, so this is safe before the tag has loaded.
42
+ window.gtag = window.gtag || function () { window.dataLayer.push(arguments) }
43
+ window.gtag("event", name, payload)
44
+ } else if (ecommerce) {
45
+ window.dataLayer.push({ ecommerce: null })
46
+ window.dataLayer.push({ event: name, ecommerce: payload })
47
+ } else {
48
+ window.dataLayer.push({ event: name, ...payload })
49
+ }
50
+ }
51
+
52
+ function release(categories) {
53
+ decided = [...categories, NECESSARY]
54
+
55
+ waiting.splice(0).forEach((event) => (granted(event.category) ? send(event) : waiting.push(event)))
56
+ }
57
+
58
+ document.addEventListener("consently:change", (event) => release(event.detail.categories))
59
+
60
+ // A new page carries the server's reading of the cookie, which also knows
61
+ // about policy versions and expiry; from here on it is the one to trust.
62
+ document.addEventListener("turbo:load", () => { decided = null })
63
+
64
+ // The Turbo Stream action. A listener rather than an entry in
65
+ // Turbo.StreamActions, so the gem imports nothing from Turbo and the order
66
+ // the modules load in does not matter.
67
+ document.addEventListener("turbo:before-stream-render", (event) => {
68
+ const stream = event.target
69
+ if (stream.getAttribute("action") !== "consently_event") return
70
+
71
+ event.preventDefault()
72
+ track(stream.getAttribute("event"), JSON.parse(stream.getAttribute("payload") || "{}"), {
73
+ category: stream.getAttribute("category") || "analytics",
74
+ ecommerce: stream.getAttribute("ecommerce") === "true"
75
+ })
76
+ })
77
+
78
+ window.Consently = Object.assign(window.Consently || {}, { track, granted })
@@ -32,7 +32,9 @@
32
32
  aria-labelledby="consently-message"
33
33
  tabindex="-1">
34
34
  <p class="consently__text" id="consently-message">
35
- <%= t("consently.banner.message") %>
35
+ <%# The message names only what is asked for: a site without marketing
36
+ tags does not promise "relevant offers". %>
37
+ <%= t("consently.banner.message_#{categories.map(&:to_s).sort.join("_")}", default: t("consently.banner.message")) %>
36
38
  <% if policy_url.present? %>
37
39
  <%= link_to t("consently.banner.policy_link"), policy_url, class: "consently__link" %>
38
40
  <% end %>
data/config/importmap.rb CHANGED
@@ -2,3 +2,4 @@
2
2
  # resolves without anyone editing config/importmap.rb.
3
3
  pin "consently/banner_controller", to: "consently/banner_controller.js", preload: true
4
4
  pin "consently/embed_controller", to: "consently/embed_controller.js", preload: true
5
+ pin "consently/events", to: "consently/events.js", preload: true
@@ -2,6 +2,8 @@ cs:
2
2
  consently:
3
3
  banner:
4
4
  message: "Používáme soubory cookie, aby tento web fungoval, a s vaším souhlasem také k tomu, abychom rozuměli, jak jej používáte, a mohli vám ukazovat relevantní nabídky."
5
+ message_analytics: "Používáme soubory cookie, aby tento web fungoval, a s vaším souhlasem také k tomu, abychom rozuměli, jak jej používáte, a mohli jej zlepšovat."
6
+ message_marketing: "Používáme soubory cookie, aby tento web fungoval, a s vaším souhlasem také k tomu, abychom vám mohli ukazovat relevantní nabídky."
5
7
  policy_link: "Přečtěte si naše zásady cookies."
6
8
  accept_all: "Přijmout vše"
7
9
  reject_all: "Odmítnout vše"
@@ -2,6 +2,8 @@ de:
2
2
  consently:
3
3
  banner:
4
4
  message: "Wir verwenden Cookies, damit diese Website funktioniert - und mit Ihrer Einwilligung, um zu verstehen, wie sie genutzt wird, und Ihnen passende Angebote zu zeigen."
5
+ message_analytics: "Wir verwenden Cookies, damit diese Website funktioniert - und mit Ihrer Einwilligung, um zu verstehen, wie sie genutzt wird, und sie zu verbessern."
6
+ message_marketing: "Wir verwenden Cookies, damit diese Website funktioniert - und mit Ihrer Einwilligung, um Ihnen passende Angebote zu zeigen."
5
7
  policy_link: "Zur Cookie-Richtlinie."
6
8
  accept_all: "Alle akzeptieren"
7
9
  reject_all: "Alle ablehnen"
@@ -2,6 +2,8 @@ en:
2
2
  consently:
3
3
  banner:
4
4
  message: "We use cookies to run this site, and - with your consent - to understand how it is used and to show you relevant offers."
5
+ message_analytics: "We use cookies to run this site, and - with your consent - to understand how it is used and improve it."
6
+ message_marketing: "We use cookies to run this site, and - with your consent - to show you relevant offers."
5
7
  policy_link: "Read our cookie policy."
6
8
  accept_all: "Accept all"
7
9
  reject_all: "Reject all"
@@ -2,6 +2,8 @@ es:
2
2
  consently:
3
3
  banner:
4
4
  message: "Usamos cookies para que este sitio funcione y, con tu consentimiento, para entender cómo se usa y mostrarte ofertas relevantes."
5
+ message_analytics: "Usamos cookies para que este sitio funcione y, con tu consentimiento, para entender cómo se usa y mejorarlo."
6
+ message_marketing: "Usamos cookies para que este sitio funcione y, con tu consentimiento, para mostrarte ofertas relevantes."
5
7
  policy_link: "Lee nuestra política de cookies."
6
8
  accept_all: "Aceptar todo"
7
9
  reject_all: "Rechazar todo"
@@ -2,6 +2,8 @@ fr:
2
2
  consently:
3
3
  banner:
4
4
  message: "Nous utilisons des cookies pour faire fonctionner ce site et, avec votre accord, pour comprendre comment il est utilisé et vous proposer des offres pertinentes."
5
+ message_analytics: "Nous utilisons des cookies pour faire fonctionner ce site et, avec votre accord, pour comprendre comment il est utilisé et l'améliorer."
6
+ message_marketing: "Nous utilisons des cookies pour faire fonctionner ce site et, avec votre accord, pour vous proposer des offres pertinentes."
5
7
  policy_link: "Consulter notre politique de cookies."
6
8
  accept_all: "Tout accepter"
7
9
  reject_all: "Tout refuser"
@@ -2,6 +2,8 @@ hu:
2
2
  consently:
3
3
  banner:
4
4
  message: "Sütiket használunk, hogy az oldal működjön, és - a hozzájárulásoddal - hogy megértsük, hogyan használod, illetve releváns ajánlatokat mutassunk."
5
+ message_analytics: "Sütiket használunk, hogy az oldal működjön, és - a hozzájárulásoddal - hogy megértsük, hogyan használod, és fejleszthessük."
6
+ message_marketing: "Sütiket használunk, hogy az oldal működjön, és - a hozzájárulásoddal - hogy releváns ajánlatokat mutassunk."
5
7
  policy_link: "Olvasd el a süti szabályzatunkat."
6
8
  accept_all: "Összes elfogadása"
7
9
  reject_all: "Összes elutasítása"
@@ -2,6 +2,8 @@ it:
2
2
  consently:
3
3
  banner:
4
4
  message: "Usiamo i cookie per far funzionare questo sito e, con il tuo consenso, per capire come viene usato e mostrarti offerte pertinenti."
5
+ message_analytics: "Usiamo i cookie per far funzionare questo sito e, con il tuo consenso, per capire come viene usato e migliorarlo."
6
+ message_marketing: "Usiamo i cookie per far funzionare questo sito e, con il tuo consenso, per mostrarti offerte pertinenti."
5
7
  policy_link: "Leggi la nostra cookie policy."
6
8
  accept_all: "Accetta tutto"
7
9
  reject_all: "Rifiuta tutto"
@@ -2,6 +2,8 @@ nl:
2
2
  consently:
3
3
  banner:
4
4
  message: "We gebruiken cookies om deze site te laten werken en, met jouw toestemming, om te begrijpen hoe de site gebruikt wordt en je relevante aanbiedingen te tonen."
5
+ message_analytics: "We gebruiken cookies om deze site te laten werken en, met jouw toestemming, om te begrijpen hoe de site gebruikt wordt en hem te verbeteren."
6
+ message_marketing: "We gebruiken cookies om deze site te laten werken en, met jouw toestemming, om je relevante aanbiedingen te tonen."
5
7
  policy_link: "Lees ons cookiebeleid."
6
8
  accept_all: "Alles accepteren"
7
9
  reject_all: "Alles weigeren"
@@ -2,6 +2,8 @@ pl:
2
2
  consently:
3
3
  banner:
4
4
  message: "Używamy plików cookie, żeby ta strona działała, a za Twoją zgodą także po to, by rozumieć, jak z niej korzystasz, i pokazywać Ci trafne oferty."
5
+ message_analytics: "Używamy plików cookie, żeby ta strona działała, a za Twoją zgodą także po to, by rozumieć, jak z niej korzystasz, i ją ulepszać."
6
+ message_marketing: "Używamy plików cookie, żeby ta strona działała, a za Twoją zgodą także po to, by pokazywać Ci trafne oferty."
5
7
  policy_link: "Przeczytaj naszą politykę cookies."
6
8
  accept_all: "Akceptuję wszystkie"
7
9
  reject_all: "Odrzuć wszystkie"
@@ -2,6 +2,8 @@ sk:
2
2
  consently:
3
3
  banner:
4
4
  message: "Používame súbory cookie, aby táto stránka fungovala, a s vaším súhlasom aj na to, aby sme rozumeli, ako ju používate, a mohli vám ukazovať relevantné ponuky."
5
+ message_analytics: "Používame súbory cookie, aby táto stránka fungovala, a s vaším súhlasom aj na to, aby sme rozumeli, ako ju používate, a mohli ju zlepšovať."
6
+ message_marketing: "Používame súbory cookie, aby táto stránka fungovala, a s vaším súhlasom aj na to, aby sme vám mohli ukazovať relevantné ponuky."
5
7
  policy_link: "Prečítajte si naše zásady cookies."
6
8
  accept_all: "Prijať všetko"
7
9
  reject_all: "Odmietnuť všetko"
@@ -1,7 +1,9 @@
1
1
  module Consently
2
2
  # Everything the host application sets in config/initializers/consently.rb.
3
3
  class Configuration
4
- DEFAULT_CATEGORIES = %i[necessary analytics marketing].freeze
4
+ # The categories Consently knows by name, in the order the banner lists
5
+ # them. Only the ones in use are offered - see #categories.
6
+ BUILT_IN_CATEGORIES = %i[necessary analytics marketing].freeze
5
7
 
6
8
  # Where the visitor's choice is kept. It is read by JavaScript, so it is a
7
9
  # plain cookie rather than a signed one.
@@ -50,6 +52,21 @@ module Consently
50
52
  google_consent_mode == :advanced
51
53
  end
52
54
 
55
+ # How events are sent: :gtag for a page running gtag.js, :data_layer for
56
+ # one running Google Tag Manager. The default, :auto, decides per request
57
+ # from the tags in its scope (see Consently.event_transport_for) and is
58
+ # right unless you push to a container the gem does not know about.
59
+ attr_reader :event_transport
60
+
61
+ def event_transport=(transport)
62
+ transport = transport&.to_sym
63
+ unless [ :auto, *Event::TRANSPORTS ].include?(transport)
64
+ raise ArgumentError, "event_transport must be :auto, :gtag or :data_layer"
65
+ end
66
+
67
+ @event_transport = transport
68
+ end
69
+
53
70
  # Store a row per decision, as proof of consent. Needs the engine mounted
54
71
  # and the migration from `rails g consently:consent_log`.
55
72
  attr_accessor :log_consents
@@ -98,8 +115,6 @@ module Consently
98
115
  # Nil means every request sees the default configuration.
99
116
  attr_accessor :scope_resolver
100
117
 
101
- attr_reader :categories
102
-
103
118
  def initialize
104
119
  @cookie_name = "consently"
105
120
  @cookie_max_age = 60 * 60 * 24 * 180 # six months, the usual guidance
@@ -109,6 +124,7 @@ module Consently
109
124
  @consent_version = 1
110
125
  @enabled = true
111
126
  @google_consent_mode = :basic
127
+ @event_transport = :auto
112
128
  @stylesheet = true
113
129
  @log_consents = false
114
130
  @respect_do_not_track = false
@@ -118,18 +134,45 @@ module Consently
118
134
  @consent_subject = nil
119
135
  @policy_url = nil
120
136
  @scope_resolver = nil
121
- @categories = DEFAULT_CATEGORIES.dup
137
+ @categories = nil
138
+ @declared_categories = []
122
139
  @scopes = {}
123
140
  end
124
141
 
125
- # A category of your own, shown in the preferences panel alongside the
126
- # built-in ones. Give it a name in your locale file.
142
+ # The categories a visitor is asked about, `necessary` always first.
143
+ #
144
+ # Worked out from the tags (in every scope) plus whatever was declared with
145
+ # `category`: a site running only Google Analytics asks about analytics and
146
+ # nothing else. Asking for consent to marketing nobody does is not just
147
+ # noise - it tells the visitor something about the site that is not true.
148
+ #
149
+ # `categories=` replaces the guess with a fixed list, for categories that
150
+ # only views use (an embed, an event) or to keep the order your own.
151
+ def categories
152
+ return @categories if @categories
153
+
154
+ in_use = (all_tags.map(&:category) + @declared_categories).uniq
155
+ [ Consent::NECESSARY, *(BUILT_IN_CATEGORIES & in_use), *(in_use - BUILT_IN_CATEGORIES) ].uniq
156
+ end
157
+
158
+ def categories=(names)
159
+ @categories = [ Consent::NECESSARY, *Array(names).map(&:to_sym) ].uniq
160
+ end
161
+
162
+ # A category to offer even with no tag in it - one of your own, or a
163
+ # built-in one that only an embed or an event uses. Give your own a name
164
+ # and a description in your locale file.
127
165
  def category(name)
128
166
  name = name.to_sym
129
- @categories << name unless @categories.include?(name)
167
+ @declared_categories << name unless @declared_categories.include?(name)
168
+ @categories << name if @categories && !@categories.include?(name)
130
169
  name
131
170
  end
132
171
 
172
+ def category?(name)
173
+ categories.include?(name.to_sym)
174
+ end
175
+
133
176
  def optional_categories
134
177
  categories - [ Consent::NECESSARY ]
135
178
  end
@@ -162,6 +205,12 @@ module Consently
162
205
  @default_scope ||= Scope.new
163
206
  end
164
207
 
208
+ # Every tag in every scope, overridden ones included: a category one
209
+ # tenant needs is offered to all, since the banner is the same for each.
210
+ def all_tags
211
+ [ default_scope, *@scopes.values ].flat_map { |scope| scope.tags.values }
212
+ end
213
+
165
214
  # A named bundle of tags. Same API as the top-level `tag`, which is what
166
215
  # makes the block form read the way it does.
167
216
  class Scope
@@ -14,7 +14,7 @@ module Consently
14
14
  if app.config.respond_to?(:assets)
15
15
  app.config.assets.paths << root.join("app/javascript")
16
16
  if app.config.assets.respond_to?(:precompile)
17
- app.config.assets.precompile += %w[consently.css consently/banner_controller.js consently/embed_controller.js]
17
+ app.config.assets.precompile += %w[consently.css consently/banner_controller.js consently/embed_controller.js consently/events.js]
18
18
  end
19
19
  end
20
20
  end
@@ -0,0 +1,54 @@
1
+ require "active_support/core_ext/object/json"
2
+
3
+ module Consently
4
+ # One analytics event on its way to the page: a name, a payload, and whether
5
+ # it is a GA4 ecommerce event. It is written as JavaScript for whichever
6
+ # transport the page runs on - gtag('event', ...) for gtag.js, a dataLayer
7
+ # push for Google Tag Manager - so the helpers never have to know which.
8
+ class Event
9
+ TRANSPORTS = %i[gtag data_layer].freeze
10
+
11
+ attr_reader :name, :payload
12
+
13
+ def initialize(name, payload = {}, ecommerce: false)
14
+ @name = name.to_s
15
+ @payload = payload.to_h.compact
16
+ @ecommerce = ecommerce
17
+ end
18
+
19
+ def ecommerce?
20
+ @ecommerce
21
+ end
22
+
23
+ def to_js(transport)
24
+ case transport.to_sym
25
+ when :gtag then gtag_js
26
+ when :data_layer then data_layer_js
27
+ else raise ArgumentError, "Consently: unknown event transport #{transport.inspect}, expected one of #{TRANSPORTS.join(", ")}"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # gtag.js reads the dataLayer as a queue, so an event pushed before the
34
+ # tag has loaded is not lost. That is what lets a released event fire the
35
+ # moment consent arrives, while gtag.js is still on its way.
36
+ def gtag_js
37
+ "window.dataLayer = window.dataLayer || []; " \
38
+ "window.gtag = window.gtag || function(){dataLayer.push(arguments);}; " \
39
+ "gtag('event', #{name.to_json}, #{payload.to_json});"
40
+ end
41
+
42
+ def data_layer_js
43
+ if ecommerce?
44
+ # Cleared first, as Google asks, so two events on one page cannot
45
+ # bleed into each other.
46
+ "window.dataLayer = window.dataLayer || []; " \
47
+ "window.dataLayer.push({ ecommerce: null }); " \
48
+ "window.dataLayer.push(#{{ event: name, ecommerce: payload }.to_json});"
49
+ else
50
+ "window.dataLayer = window.dataLayer || []; window.dataLayer.push(#{payload.merge(event: name).to_json});"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,3 @@
1
1
  module Consently
2
- VERSION = "0.2.1"
2
+ VERSION = "2.0.0"
3
3
  end
data/lib/consently.rb CHANGED
@@ -7,6 +7,7 @@ require "consently/version"
7
7
  require "consently/script"
8
8
  require "consently/cookie"
9
9
  require "consently/consent"
10
+ require "consently/event"
10
11
  require "consently/providers/base"
11
12
  require "consently/providers"
12
13
  require "consently/configuration"
@@ -60,6 +61,20 @@ module Consently
60
61
  resolve(config.enabled, request)
61
62
  end
62
63
 
64
+ # How events reach Google for this request. Google Tag Manager reads
65
+ # dataLayer pushes; gtag.js only acts on gtag('event', ...) calls, and a
66
+ # push meant for a container it silently ignores. :auto looks at the tags
67
+ # in the request's scope: a container present means the dataLayer,
68
+ # otherwise any Google tag means gtag.
69
+ def event_transport_for(request = nil)
70
+ return config.event_transport unless config.event_transport == :auto
71
+
72
+ providers = tags_for(request)
73
+ return :data_layer if providers.any? { |provider| provider.is_a?(Providers::GoogleTagManager) }
74
+
75
+ providers.any?(&:google?) ? :gtag : :data_layer
76
+ end
77
+
63
78
  # Whether this visitor has to be asked. When they do not, every category
64
79
  # counts as granted and no banner is rendered - see config.consent_required.
65
80
  def consent_required?(request = nil)
@@ -44,7 +44,9 @@ c.google_consent_mode = :basic
44
44
  # The log is anonymous unless you say who made the decision:
45
45
  # c.consent_subject = ->(request) { request.env["warden"]&.user&.id }
46
46
 
47
- # A category of your own, on top of :necessary, :analytics and :marketing.
47
+ # The banner offers only the categories your tags use. Declare one that only
48
+ # an embed or an event needs, or one of your own:
49
+ # c.category :marketing
48
50
  # c.category :personalization
49
51
 
50
52
  # Your tags. Each one waits for its category unless that category is
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.2.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michał Krzysteczko
@@ -58,6 +58,7 @@ files:
58
58
  - app/helpers/consently/tags_helper.rb
59
59
  - app/javascript/consently/banner_controller.js
60
60
  - app/javascript/consently/embed_controller.js
61
+ - app/javascript/consently/events.js
61
62
  - app/models/consently/application_record.rb
62
63
  - app/models/consently/consent_record.rb
63
64
  - app/views/consently/_banner.html.erb
@@ -80,6 +81,7 @@ files:
80
81
  - lib/consently/consent.rb
81
82
  - lib/consently/cookie.rb
82
83
  - lib/consently/engine.rb
84
+ - lib/consently/event.rb
83
85
  - lib/consently/providers.rb
84
86
  - lib/consently/providers/base.rb
85
87
  - lib/consently/providers/clarity.rb
@@ -121,7 +123,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
121
123
  - !ruby/object:Gem::Version
122
124
  version: '0'
123
125
  requirements: []
124
- rubygems_version: 4.0.3
126
+ rubygems_version: 4.0.20
125
127
  specification_version: 4
126
128
  summary: GDPR cookie consent for Rails that actually blocks your analytics tags until
127
129
  the visitor agrees