consently 0.2.0 → 1.0.0

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