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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/consentful/application.css +15 -0
- data/app/assets/stylesheets/consentful/consent.scss +518 -0
- data/app/components/consentful/banner.html.erb +53 -0
- data/app/components/consentful/banner.rb +27 -0
- data/app/components/consentful/modal.html.erb +19 -0
- data/app/components/consentful/modal.rb +21 -0
- data/app/components/consentful/preferences.html.erb +167 -0
- data/app/components/consentful/preferences.rb +48 -0
- data/app/components/consentful/service_detail.html.erb +99 -0
- data/app/components/consentful/service_detail.rb +18 -0
- data/app/components/consentful/toggle.html.erb +30 -0
- data/app/components/consentful/toggle.rb +30 -0
- data/app/controllers/concerns/consentful/visitor_cookie.rb +52 -0
- data/app/controllers/consentful/admin/base_controller.rb +27 -0
- data/app/controllers/consentful/admin/categories_controller.rb +46 -0
- data/app/controllers/consentful/admin/copy_versions_controller.rb +113 -0
- data/app/controllers/consentful/admin/dashboard_controller.rb +11 -0
- data/app/controllers/consentful/admin/records_controller.rb +11 -0
- data/app/controllers/consentful/admin/services_controller.rb +80 -0
- data/app/controllers/consentful/application_controller.rb +9 -0
- data/app/controllers/consentful/consent_controller.rb +132 -0
- data/app/helpers/consentful/application_helper.rb +4 -0
- data/app/helpers/consentful/consent_helper.rb +23 -0
- data/app/javascript/consentful/controllers/banner_controller.js +92 -0
- data/app/javascript/consentful/controllers/clipboard_copy_controller.js +30 -0
- data/app/javascript/consentful/controllers/modal_controller.js +50 -0
- data/app/javascript/consentful/controllers/modal_trigger_controller.js +9 -0
- data/app/javascript/consentful/controllers/preferences_controller.js +182 -0
- data/app/javascript/consentful/index.js +42 -0
- data/app/javascript/consentful/state.js +91 -0
- data/app/jobs/consentful/application_job.rb +4 -0
- data/app/mailers/consentful/application_mailer.rb +6 -0
- data/app/models/consentful/application_record.rb +8 -0
- data/app/models/consentful/consent_category.rb +21 -0
- data/app/models/consentful/consent_copy_version.rb +49 -0
- data/app/models/consentful/consent_record.rb +33 -0
- data/app/models/consentful/consent_service.rb +38 -0
- data/app/models/consentful/consent_state.rb +80 -0
- data/app/models/consentful/consent_visitor_link.rb +26 -0
- data/app/presenters/consentful/consent_record_presenter.rb +73 -0
- data/app/services/consentful/dashboard_report.rb +45 -0
- data/app/services/consentful/sar_lookup.rb +47 -0
- data/app/views/consentful/admin/categories/edit.html.erb +22 -0
- data/app/views/consentful/admin/categories/index.html.erb +17 -0
- data/app/views/consentful/admin/copy_versions/_form.html.erb +34 -0
- data/app/views/consentful/admin/copy_versions/edit.html.erb +2 -0
- data/app/views/consentful/admin/copy_versions/index.html.erb +34 -0
- data/app/views/consentful/admin/copy_versions/new.html.erb +2 -0
- data/app/views/consentful/admin/copy_versions/show.html.erb +24 -0
- data/app/views/consentful/admin/dashboard/show.html.erb +35 -0
- data/app/views/consentful/admin/records/index.html.erb +35 -0
- data/app/views/consentful/admin/services/_form.html.erb +42 -0
- data/app/views/consentful/admin/services/edit.html.erb +2 -0
- data/app/views/consentful/admin/services/index.html.erb +27 -0
- data/app/views/consentful/admin/services/new.html.erb +2 -0
- data/app/views/consentful/consent/preferences.html.erb +14 -0
- data/app/views/layouts/consentful/admin.html.erb +66 -0
- data/app/views/layouts/consentful/application.html.erb +17 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260522100000_create_consent_categories.rb +19 -0
- data/db/migrate/20260522100100_create_consent_services.rb +38 -0
- data/db/migrate/20260522100200_create_consent_copy_versions.rb +30 -0
- data/db/migrate/20260522100300_create_consent_records.rb +22 -0
- data/db/migrate/20260522100400_create_consent_visitor_links.rb +21 -0
- data/lib/consentful/configuration.rb +100 -0
- data/lib/consentful/engine.rb +5 -0
- data/lib/consentful/seed_starter.rb +57 -0
- data/lib/consentful/version.rb +3 -0
- data/lib/consentful.rb +27 -0
- data/lib/generators/consentful/install_generator.rb +29 -0
- data/lib/generators/consentful/templates/consentful.rb +35 -0
- data/lib/tasks/consentful_tasks.rake +9 -0
- 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,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,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
|
+
}
|