consentful 0.1.0.pre1

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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/consentful/application.css +15 -0
  6. data/app/assets/stylesheets/consentful/consent.scss +518 -0
  7. data/app/components/consentful/banner.html.erb +53 -0
  8. data/app/components/consentful/banner.rb +27 -0
  9. data/app/components/consentful/modal.html.erb +19 -0
  10. data/app/components/consentful/modal.rb +21 -0
  11. data/app/components/consentful/preferences.html.erb +167 -0
  12. data/app/components/consentful/preferences.rb +48 -0
  13. data/app/components/consentful/service_detail.html.erb +99 -0
  14. data/app/components/consentful/service_detail.rb +18 -0
  15. data/app/components/consentful/toggle.html.erb +30 -0
  16. data/app/components/consentful/toggle.rb +30 -0
  17. data/app/controllers/concerns/consentful/visitor_cookie.rb +52 -0
  18. data/app/controllers/consentful/admin/base_controller.rb +27 -0
  19. data/app/controllers/consentful/admin/categories_controller.rb +46 -0
  20. data/app/controllers/consentful/admin/copy_versions_controller.rb +113 -0
  21. data/app/controllers/consentful/admin/dashboard_controller.rb +11 -0
  22. data/app/controllers/consentful/admin/records_controller.rb +11 -0
  23. data/app/controllers/consentful/admin/services_controller.rb +80 -0
  24. data/app/controllers/consentful/application_controller.rb +9 -0
  25. data/app/controllers/consentful/consent_controller.rb +132 -0
  26. data/app/helpers/consentful/application_helper.rb +4 -0
  27. data/app/helpers/consentful/consent_helper.rb +23 -0
  28. data/app/javascript/consentful/controllers/banner_controller.js +92 -0
  29. data/app/javascript/consentful/controllers/clipboard_copy_controller.js +30 -0
  30. data/app/javascript/consentful/controllers/modal_controller.js +50 -0
  31. data/app/javascript/consentful/controllers/modal_trigger_controller.js +9 -0
  32. data/app/javascript/consentful/controllers/preferences_controller.js +182 -0
  33. data/app/javascript/consentful/index.js +42 -0
  34. data/app/javascript/consentful/state.js +91 -0
  35. data/app/jobs/consentful/application_job.rb +4 -0
  36. data/app/mailers/consentful/application_mailer.rb +6 -0
  37. data/app/models/consentful/application_record.rb +8 -0
  38. data/app/models/consentful/consent_category.rb +21 -0
  39. data/app/models/consentful/consent_copy_version.rb +49 -0
  40. data/app/models/consentful/consent_record.rb +33 -0
  41. data/app/models/consentful/consent_service.rb +38 -0
  42. data/app/models/consentful/consent_state.rb +80 -0
  43. data/app/models/consentful/consent_visitor_link.rb +26 -0
  44. data/app/presenters/consentful/consent_record_presenter.rb +73 -0
  45. data/app/services/consentful/dashboard_report.rb +45 -0
  46. data/app/services/consentful/sar_lookup.rb +47 -0
  47. data/app/views/consentful/admin/categories/edit.html.erb +22 -0
  48. data/app/views/consentful/admin/categories/index.html.erb +17 -0
  49. data/app/views/consentful/admin/copy_versions/_form.html.erb +34 -0
  50. data/app/views/consentful/admin/copy_versions/edit.html.erb +2 -0
  51. data/app/views/consentful/admin/copy_versions/index.html.erb +34 -0
  52. data/app/views/consentful/admin/copy_versions/new.html.erb +2 -0
  53. data/app/views/consentful/admin/copy_versions/show.html.erb +24 -0
  54. data/app/views/consentful/admin/dashboard/show.html.erb +35 -0
  55. data/app/views/consentful/admin/records/index.html.erb +35 -0
  56. data/app/views/consentful/admin/services/_form.html.erb +42 -0
  57. data/app/views/consentful/admin/services/edit.html.erb +2 -0
  58. data/app/views/consentful/admin/services/index.html.erb +27 -0
  59. data/app/views/consentful/admin/services/new.html.erb +2 -0
  60. data/app/views/consentful/consent/preferences.html.erb +14 -0
  61. data/app/views/layouts/consentful/admin.html.erb +66 -0
  62. data/app/views/layouts/consentful/application.html.erb +17 -0
  63. data/config/routes.rb +25 -0
  64. data/db/migrate/20260522100000_create_consent_categories.rb +19 -0
  65. data/db/migrate/20260522100100_create_consent_services.rb +38 -0
  66. data/db/migrate/20260522100200_create_consent_copy_versions.rb +30 -0
  67. data/db/migrate/20260522100300_create_consent_records.rb +22 -0
  68. data/db/migrate/20260522100400_create_consent_visitor_links.rb +21 -0
  69. data/lib/consentful/configuration.rb +100 -0
  70. data/lib/consentful/engine.rb +5 -0
  71. data/lib/consentful/seed_starter.rb +57 -0
  72. data/lib/consentful/version.rb +3 -0
  73. data/lib/consentful.rb +27 -0
  74. data/lib/generators/consentful/install_generator.rb +29 -0
  75. data/lib/generators/consentful/templates/consentful.rb +35 -0
  76. data/lib/tasks/consentful_tasks.rake +9 -0
  77. metadata +149 -0
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ module Admin
5
+ class ServicesController < BaseController
6
+ ARRAY_FIELDS = %i[cookie_names data_collected data_purposes technologies_used].freeze
7
+
8
+ before_action :set_service, only: [ :edit, :update, :destroy ]
9
+ before_action :set_categories, only: [ :new, :edit, :create, :update ]
10
+
11
+ def index
12
+ @services_by_category = ConsentCategory.includes(:consent_services).order(:position).index_with do |category|
13
+ category.consent_services.order(:position, :name)
14
+ end
15
+
16
+ respond_to do |format|
17
+ format.html
18
+ format.json { render json: export_payload }
19
+ end
20
+ end
21
+
22
+ def new
23
+ @service = ConsentService.new(consent_category: @categories.first, active: true)
24
+ end
25
+
26
+ def edit; end
27
+
28
+ def create
29
+ @service = ConsentService.new(service_params)
30
+ if @service.save
31
+ redirect_to edit_admin_service_path(@service), notice: "Service created."
32
+ else
33
+ render :new, status: :unprocessable_entity
34
+ end
35
+ end
36
+
37
+ def update
38
+ if @service.update(service_params)
39
+ redirect_to edit_admin_service_path(@service), notice: "Service updated."
40
+ else
41
+ render :edit, status: :unprocessable_entity
42
+ end
43
+ end
44
+
45
+ def destroy
46
+ @service.destroy
47
+ redirect_to admin_services_path, notice: "Service deleted."
48
+ end
49
+
50
+ private
51
+
52
+ def set_service
53
+ @service = ConsentService.find(params[:id])
54
+ end
55
+
56
+ def set_categories
57
+ @categories = ConsentCategory.order(:position)
58
+ end
59
+
60
+ def service_params
61
+ attrs = params.require(:consent_service).permit(
62
+ :name, :slug, :consent_category_id, :loader_kind, :loader_ref, :domain, :active,
63
+ :description, :processing_company_name, :processing_company_address, :dpo_email,
64
+ :privacy_url, :legal_basis, :retention_period, :processing_location, :third_country_transfer,
65
+ :cookie_names, :data_collected, :data_purposes, :technologies_used
66
+ )
67
+ ARRAY_FIELDS.each do |field|
68
+ attrs[field] = attrs[field].to_s.split(",").map(&:strip).compact_blank if attrs.key?(field)
69
+ end
70
+ attrs
71
+ end
72
+
73
+ def export_payload
74
+ ConsentService.includes(:consent_category).order(:position, :name).map do |service|
75
+ service.as_json(except: %i[created_at updated_at]).merge(category_key: service.consent_category.key)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class ApplicationController < ActionController::Base
5
+ include Consentful::VisitorCookie
6
+
7
+ layout -> { Consentful.config.layout }
8
+ end
9
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ # Public-facing endpoints:
5
+ # POST /consent — record a choice and persist the cookie
6
+ # GET /consent/state — JSON probe of the current cookie state
7
+ # POST /consent/link — bridge the visitor UUID to a known identifier
8
+ # GET /cookie-preferences — standalone preference centre page
9
+ class ConsentController < ApplicationController
10
+ skip_before_action :verify_authenticity_token, only: [ :update, :link ], raise: false
11
+
12
+ before_action :ensure_visitor_id, only: [ :preferences ]
13
+
14
+ wrap_parameters false
15
+
16
+ def update
17
+ record = build_record
18
+ payload = cookie_payload(record)
19
+
20
+ ActiveRecord::Base.transaction do
21
+ record.save!
22
+ write_consent_cookie(payload)
23
+ end
24
+
25
+ head :no_content
26
+ end
27
+
28
+ def state
29
+ payload = ConsentState.parse(cookies[Consentful.config.cookie_name])
30
+ render json: {
31
+ cookie: payload,
32
+ published_version_id: ConsentCopyVersion.current&.id
33
+ }
34
+ end
35
+
36
+ def preferences
37
+ @published_version = ConsentCopyVersion.current
38
+ @categories = ConsentCategory.includes(:consent_services).order(:position)
39
+ end
40
+
41
+ def link
42
+ type = link_params[:type].to_s.presence_in(ConsentVisitorLink.identifier_types.keys)
43
+ if type.nil?
44
+ head :unprocessable_entity
45
+ return
46
+ end
47
+
48
+ link_consent_identifier!(type: type.to_sym, value: link_params[:value])
49
+ head :no_content
50
+ end
51
+
52
+ private
53
+
54
+ def link_params
55
+ @link_params ||= params.permit(:type, :value)
56
+ end
57
+
58
+ def build_record
59
+ published = ConsentCopyVersion.current
60
+ raise ActionController::BadRequest, "no published consent copy" if published.nil?
61
+
62
+ ConsentRecord.new(
63
+ visitor_uuid: consent_visitor_id,
64
+ consent_copy_version: published,
65
+ category_choices: filtered_categories,
66
+ service_overrides: filtered_services,
67
+ source: source_param,
68
+ user_agent: request.user_agent,
69
+ referer: request.referer
70
+ )
71
+ end
72
+
73
+ def cookie_payload(record)
74
+ {
75
+ version_id: record.consent_copy_version_id,
76
+ categories: filtered_categories,
77
+ services: compact_service_overrides,
78
+ recorded_at: Time.current.to_i
79
+ }
80
+ end
81
+
82
+ def write_consent_cookie(payload)
83
+ cookies[Consentful.config.cookie_name] = consent_cookie_options.merge(
84
+ value: payload.to_json,
85
+ expires: Consentful.config.cookie_expiry_months.months.from_now
86
+ )
87
+ end
88
+
89
+ def filtered_categories
90
+ valid_keys = ConsentCategory.pluck(:key).to_set
91
+ raw = consent_params[:categories].to_h
92
+ raw.each_with_object({}) do |(key, value), acc|
93
+ next unless valid_keys.include?(key.to_s)
94
+
95
+ acc[key.to_s] = ActiveModel::Type::Boolean.new.cast(value)
96
+ end
97
+ end
98
+
99
+ def filtered_services
100
+ valid_slugs = ConsentService.pluck(:slug).to_set
101
+ raw = consent_params[:services].to_h
102
+ raw.each_with_object({}) do |(slug, value), acc|
103
+ next unless valid_slugs.include?(slug.to_s)
104
+
105
+ acc[slug.to_s] = ActiveModel::Type::Boolean.new.cast(value)
106
+ end
107
+ end
108
+
109
+ def compact_service_overrides
110
+ categories = filtered_categories
111
+ services = filtered_services
112
+ category_for_slug = ConsentService.where(slug: services.keys).pluck(:slug, :consent_category_id).to_h
113
+ category_keys = ConsentCategory.where(id: category_for_slug.values.uniq).pluck(:id, :key).to_h
114
+
115
+ services.reject do |slug, value|
116
+ category_id = category_for_slug[slug]
117
+ category_default = categories[category_keys[category_id]]
118
+ value == category_default
119
+ end
120
+ end
121
+
122
+ def source_param
123
+ return :preference_centre unless ConsentRecord.sources.key?(consent_params[:source].to_s)
124
+
125
+ consent_params[:source].to_sym
126
+ end
127
+
128
+ def consent_params
129
+ @consent_params ||= params.permit(:source, categories: {}, services: {})
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,4 @@
1
+ module Consentful
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ # View helpers for gating inline tracking. The host keeps its own
5
+ # `render_tracking_for` partial loader (its tracking partials are app-specific);
6
+ # the engine exposes only the consent decision.
7
+ module ConsentHelper
8
+ def current_consent
9
+ @current_consent ||= ConsentState.from_cookie(cookies[Consentful.config.cookie_name])
10
+ end
11
+
12
+ # Gate for inline tracking scripts. Allows everything while enforcement is
13
+ # off (rollout flag); once on, an unknown slug defaults to denied.
14
+ def consent_allowed?(slug)
15
+ return true unless Consentful.config.enforce
16
+
17
+ service = ConsentService.find_by(slug:)
18
+ return false if service.nil?
19
+
20
+ current_consent.allowed?(service)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,92 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { readConsent, shouldShowBanner, writeOptimistic, applyConsent } from '../state';
3
+
4
+ export default class extends Controller {
5
+ static targets = ['categoryToggle'];
6
+
7
+ static values = {
8
+ expectedVersionId: Number,
9
+ submitPath: String,
10
+ categories: Array,
11
+ };
12
+
13
+ connect() {
14
+ const state = readConsent();
15
+ if (shouldShowBanner(state, this.expectedVersionIdValue)) {
16
+ this.show();
17
+ }
18
+ this.handleConsentUpdated = () => this.hide();
19
+ window.addEventListener('consent:updated', this.handleConsentUpdated);
20
+ }
21
+
22
+ disconnect() {
23
+ window.removeEventListener('consent:updated', this.handleConsentUpdated);
24
+ }
25
+
26
+ show() {
27
+ this.element.classList.remove('c-consent__hidden');
28
+ }
29
+
30
+ hide() {
31
+ this.element.classList.add('c-consent__hidden');
32
+ }
33
+
34
+ accept() {
35
+ const categories = {};
36
+ this.categoriesValue.forEach((category) => {
37
+ categories[category.key] = true;
38
+ });
39
+ this.submit({ categories, source: 'banner_accept_all' });
40
+ }
41
+
42
+ reject() {
43
+ const categories = {};
44
+ this.categoriesValue.forEach((category) => {
45
+ categories[category.key] = category.required === true;
46
+ });
47
+ this.submit({ categories, source: 'banner_reject_all' });
48
+ }
49
+
50
+ save() {
51
+ const categories = this.readToggleSelections();
52
+ this.submit({ categories, source: 'banner_save' });
53
+ }
54
+
55
+ readToggleSelections() {
56
+ const categories = {};
57
+ this.categoryToggleTargets.forEach((input) => {
58
+ const key = input.dataset.categoryKey;
59
+ if (key) categories[key] = input.checked;
60
+ });
61
+ this.categoriesValue.forEach((category) => {
62
+ if (category.required) categories[category.key] = true;
63
+ });
64
+ return categories;
65
+ }
66
+
67
+ async submit({ categories, source }) {
68
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
69
+ const response = await fetch(this.submitPathValue, {
70
+ method: 'POST',
71
+ credentials: 'same-origin',
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ Accept: 'application/json',
75
+ 'X-CSRF-Token': csrfToken || '',
76
+ },
77
+ body: JSON.stringify({ categories, source }),
78
+ });
79
+
80
+ if (!response.ok) return;
81
+
82
+ const optimisticState = {
83
+ version_id: this.expectedVersionIdValue,
84
+ categories,
85
+ services: {},
86
+ recorded_at: Math.floor(Date.now() / 1000),
87
+ };
88
+ writeOptimistic(optimisticState);
89
+ applyConsent(optimisticState);
90
+ this.hide();
91
+ }
92
+ }
@@ -0,0 +1,30 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ text: String,
6
+ successLabel: { type: String, default: 'Copied' },
7
+ resetMs: { type: Number, default: 2000 },
8
+ };
9
+
10
+ async copy(event) {
11
+ const button = event.currentTarget;
12
+ const originalLabel = button.textContent;
13
+ if (!navigator.clipboard?.writeText) return;
14
+
15
+ try {
16
+ await navigator.clipboard.writeText(this.textValue);
17
+ } catch (_err) {
18
+ return;
19
+ }
20
+
21
+ button.textContent = this.successLabelValue;
22
+ button.disabled = true;
23
+
24
+ clearTimeout(this.resetTimer);
25
+ this.resetTimer = setTimeout(() => {
26
+ button.textContent = originalLabel;
27
+ button.disabled = false;
28
+ }, this.resetMsValue);
29
+ }
30
+ }
@@ -0,0 +1,50 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ static targets = ['dialog'];
5
+
6
+ connect() {
7
+ this.previouslyFocused = null;
8
+ this.handleKey = this.handleKey.bind(this);
9
+ this.handleConsentClose = this.handleConsentClose.bind(this);
10
+
11
+ window.addEventListener('consent:open-modal', this.open);
12
+ window.addEventListener('consent:close', this.handleConsentClose);
13
+ }
14
+
15
+ disconnect() {
16
+ window.removeEventListener('consent:open-modal', this.open);
17
+ window.removeEventListener('consent:close', this.handleConsentClose);
18
+ document.removeEventListener('keydown', this.handleKey);
19
+ }
20
+
21
+ open = () => {
22
+ this.previouslyFocused = document.activeElement;
23
+ this.element.classList.remove('c-consent__hidden');
24
+ document.documentElement.style.overflow = 'hidden';
25
+ document.addEventListener('keydown', this.handleKey);
26
+ const focusable = this.element.querySelector(
27
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
28
+ );
29
+ focusable?.focus();
30
+ };
31
+
32
+ close = () => {
33
+ this.element.classList.add('c-consent__hidden');
34
+ document.documentElement.style.overflow = '';
35
+ document.removeEventListener('keydown', this.handleKey);
36
+ this.previouslyFocused?.focus?.();
37
+ };
38
+
39
+ closeFromBackdrop(event) {
40
+ if (event.target === this.element) this.close();
41
+ }
42
+
43
+ handleKey(event) {
44
+ if (event.key === 'Escape') this.close();
45
+ }
46
+
47
+ handleConsentClose() {
48
+ this.close();
49
+ }
50
+ }
@@ -0,0 +1,9 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ open(event) {
5
+ if (!document.getElementById('consent-modal-root')) return;
6
+ event.preventDefault();
7
+ window.dispatchEvent(new CustomEvent('consent:open-modal'));
8
+ }
9
+ }
@@ -0,0 +1,182 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { readConsent, writeOptimistic, applyConsent } from '../state';
3
+
4
+ export default class extends Controller {
5
+ static targets = ['tab', 'tabPanel', 'categoryMaster', 'serviceToggle', 'reference'];
6
+
7
+ static values = {
8
+ expectedVersionId: Number,
9
+ submitPath: String,
10
+ categories: Array,
11
+ };
12
+
13
+ connect() {
14
+ this.hydrateFromCookie();
15
+ this.handleOpenModal = this.handleOpenModal.bind(this);
16
+ window.addEventListener('consent:open-modal', this.handleOpenModal);
17
+ }
18
+
19
+ disconnect() {
20
+ window.removeEventListener('consent:open-modal', this.handleOpenModal);
21
+ }
22
+
23
+ handleOpenModal() {
24
+ this.hydrateFromCookie();
25
+ }
26
+
27
+ hydrateFromCookie() {
28
+ const state = readConsent();
29
+ this.categoryMasterTargets.forEach((input) => {
30
+ const key = input.dataset.categoryKey;
31
+ if (key && Object.prototype.hasOwnProperty.call(state.categories, key)) {
32
+ input.checked = Boolean(state.categories[key]);
33
+ }
34
+ });
35
+ this.serviceToggleTargets.forEach((input) => {
36
+ const slug = input.dataset.serviceSlug;
37
+ const categoryKey = input.dataset.categoryKey;
38
+ if (slug && Object.prototype.hasOwnProperty.call(state.services, slug)) {
39
+ input.checked = Boolean(state.services[slug]);
40
+ } else if (categoryKey && Object.prototype.hasOwnProperty.call(state.categories, categoryKey)) {
41
+ input.checked = Boolean(state.categories[categoryKey]);
42
+ }
43
+ });
44
+ }
45
+
46
+ switchTab(event) {
47
+ const target = event.currentTarget.dataset.tab;
48
+ this.tabTargets.forEach((tab) => {
49
+ const isActive = tab.dataset.tab === target;
50
+ tab.setAttribute('aria-selected', String(isActive));
51
+ tab.classList.toggle('c-consent-preferences__tab--active', isActive);
52
+ });
53
+ this.tabPanelTargets.forEach((panel) => {
54
+ panel.classList.toggle('c-consent__hidden', panel.dataset.tab !== target);
55
+ });
56
+ }
57
+
58
+ toggleSection(event) {
59
+ event.preventDefault();
60
+ const sectionId = event.currentTarget.dataset.sectionTarget;
61
+ const body = this.element.querySelector(`[data-section-body="${sectionId}"]`);
62
+ const chevron = this.element.querySelector(`[data-section-chevron="${sectionId}"]`);
63
+ if (!body) return;
64
+ const isOpen = !body.classList.toggle('c-consent__hidden');
65
+ event.currentTarget.setAttribute('aria-expanded', String(isOpen));
66
+ if (chevron) chevron.style.transform = isOpen ? 'rotate(180deg)' : '';
67
+ }
68
+
69
+ cascadeCategory(event) {
70
+ const masterInput = event.currentTarget;
71
+ const categoryKey = masterInput.dataset.categoryKey;
72
+ const checked = masterInput.checked;
73
+ this.serviceToggleTargets.forEach((toggle) => {
74
+ if (toggle.dataset.categoryKey === categoryKey && !toggle.disabled) {
75
+ toggle.checked = checked;
76
+ }
77
+ });
78
+ }
79
+
80
+ syncServiceToggle(event) {
81
+ const source = event.currentTarget;
82
+ const slug = source.dataset.serviceSlug;
83
+ if (!slug) return;
84
+ this.serviceToggleTargets.forEach((toggle) => {
85
+ if (toggle !== source && toggle.dataset.serviceSlug === slug) {
86
+ toggle.checked = source.checked;
87
+ }
88
+ });
89
+ }
90
+
91
+ accept() {
92
+ const categories = {};
93
+ this.categoriesValue.forEach((category) => {
94
+ categories[category.key] = true;
95
+ });
96
+ this.submit({ categories, services: {}, source: 'preference_centre' });
97
+ }
98
+
99
+ reject() {
100
+ const categories = {};
101
+ this.categoriesValue.forEach((category) => {
102
+ categories[category.key] = category.required === true;
103
+ });
104
+ this.submit({ categories, services: {}, source: 'preference_centre' });
105
+ }
106
+
107
+ save() {
108
+ const categories = {};
109
+ this.categoryMasterTargets.forEach((input) => {
110
+ categories[input.dataset.categoryKey] = input.checked || input.disabled;
111
+ });
112
+ this.categoriesValue.forEach((category) => {
113
+ if (category.required) categories[category.key] = true;
114
+ });
115
+
116
+ const services = {};
117
+ this.serviceToggleTargets.forEach((toggle) => {
118
+ const slug = toggle.dataset.serviceSlug;
119
+ const categoryKey = toggle.dataset.categoryKey;
120
+ if (!slug || toggle.disabled) return;
121
+ if (toggle.checked !== Boolean(categories[categoryKey])) {
122
+ services[slug] = toggle.checked;
123
+ }
124
+ });
125
+
126
+ this.submit({ categories, services, source: 'preference_centre' });
127
+ }
128
+
129
+ close() {
130
+ this.element.dispatchEvent(new CustomEvent('consent:close', { bubbles: true }));
131
+ }
132
+
133
+ async copyReference(event) {
134
+ if (!this.hasReferenceTarget) return;
135
+ const button = event.currentTarget;
136
+ const originalText = button.textContent;
137
+ const value = this.referenceTarget.textContent.trim();
138
+ if (!navigator.clipboard?.writeText) return;
139
+
140
+ try {
141
+ await navigator.clipboard.writeText(value);
142
+ } catch (_err) {
143
+ return;
144
+ }
145
+
146
+ button.textContent = 'Copied';
147
+ button.disabled = true;
148
+
149
+ clearTimeout(this.copyResetTimer);
150
+ this.copyResetTimer = setTimeout(() => {
151
+ button.textContent = originalText;
152
+ button.disabled = false;
153
+ }, 2000);
154
+ }
155
+
156
+ async submit({ categories, services, source }) {
157
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
158
+ const response = await fetch(this.submitPathValue, {
159
+ method: 'POST',
160
+ credentials: 'same-origin',
161
+ headers: {
162
+ 'Content-Type': 'application/json',
163
+ Accept: 'application/json',
164
+ 'X-CSRF-Token': csrfToken || '',
165
+ },
166
+ body: JSON.stringify({ categories, services, source }),
167
+ });
168
+
169
+ if (!response.ok) return;
170
+
171
+ const optimisticState = {
172
+ version_id: this.expectedVersionIdValue,
173
+ categories,
174
+ services,
175
+ recorded_at: Math.floor(Date.now() / 1000),
176
+ };
177
+ writeOptimistic(optimisticState);
178
+ applyConsent(optimisticState);
179
+ this.element.dispatchEvent(new CustomEvent('consent:updated', { bubbles: true }));
180
+ window.dispatchEvent(new CustomEvent('consent:close'));
181
+ }
182
+ }
@@ -0,0 +1,42 @@
1
+ // Engine JS entry point. Hosts import this and register the controllers with
2
+ // their own Stimulus application:
3
+ //
4
+ // import { registerConsentful } from 'consentful';
5
+ // registerConsentful(application, { cookieName: 'scl_consent' });
6
+ //
7
+ // Or cherry-pick the individual controllers / state helpers.
8
+
9
+ import { configureConsent } from './state';
10
+ import BannerController from './controllers/banner_controller';
11
+ import ModalController from './controllers/modal_controller';
12
+ import ModalTriggerController from './controllers/modal_trigger_controller';
13
+ import PreferencesController from './controllers/preferences_controller';
14
+ import ClipboardCopyController from './controllers/clipboard_copy_controller';
15
+
16
+ export {
17
+ configureConsent,
18
+ readConsent,
19
+ isExpired,
20
+ matchesVersion,
21
+ shouldShowBanner,
22
+ isServiceAllowed,
23
+ applyConsent,
24
+ writeOptimistic,
25
+ } from './state';
26
+
27
+ export {
28
+ BannerController,
29
+ ModalController,
30
+ ModalTriggerController,
31
+ PreferencesController,
32
+ ClipboardCopyController,
33
+ };
34
+
35
+ export function registerConsentful(application, { cookieName } = {}) {
36
+ if (cookieName) configureConsent({ cookieName });
37
+ application.register('consentful--banner', BannerController);
38
+ application.register('consentful--modal', ModalController);
39
+ application.register('consentful--modal-trigger', ModalTriggerController);
40
+ application.register('consentful--preferences', PreferencesController);
41
+ application.register('consentful--clipboard', ClipboardCopyController);
42
+ }