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,91 @@
1
+ import Cookies from 'js-cookie';
2
+
3
+ // Cookie name is configurable so it matches the host's Consentful.config.cookie_name.
4
+ // Call configureConsent({ cookieName }) once at boot (registerConsentful does this).
5
+ let cookieName = 'consent';
6
+ const EXPIRY_MONTHS = 12;
7
+ const EMPTY_STATE = Object.freeze({
8
+ version_id: null,
9
+ categories: {},
10
+ services: {},
11
+ recorded_at: null,
12
+ });
13
+
14
+ export function configureConsent({ cookieName: name } = {}) {
15
+ if (name) cookieName = name;
16
+ }
17
+
18
+ export function readConsent() {
19
+ const raw = Cookies.get(cookieName);
20
+ if (!raw) return { ...EMPTY_STATE };
21
+
22
+ try {
23
+ const parsed = JSON.parse(raw);
24
+ return {
25
+ version_id: parsed.version_id ?? null,
26
+ categories: parsed.categories ?? {},
27
+ services: parsed.services ?? {},
28
+ recorded_at: parsed.recorded_at ?? null,
29
+ };
30
+ } catch (_err) {
31
+ return { ...EMPTY_STATE };
32
+ }
33
+ }
34
+
35
+ export function isExpired(state) {
36
+ if (!state.recorded_at) return true;
37
+ const recordedMs = state.recorded_at * 1000;
38
+ const expiryMs = EXPIRY_MONTHS * 30 * 24 * 60 * 60 * 1000;
39
+ return Date.now() - recordedMs > expiryMs;
40
+ }
41
+
42
+ export function matchesVersion(state, expectedVersionId) {
43
+ if (!expectedVersionId || !state.version_id) return false;
44
+ return Number(state.version_id) === Number(expectedVersionId);
45
+ }
46
+
47
+ export function shouldShowBanner(state, expectedVersionId) {
48
+ if (!state.recorded_at) return true;
49
+ if (isExpired(state)) return true;
50
+ if (!matchesVersion(state, expectedVersionId)) return true;
51
+ return false;
52
+ }
53
+
54
+ export function isServiceAllowed(state, { slug, categoryKey, categoryRequired }) {
55
+ if (categoryRequired) return true;
56
+ if (Object.prototype.hasOwnProperty.call(state.services, slug)) {
57
+ return Boolean(state.services[slug]);
58
+ }
59
+ return Boolean(state.categories[categoryKey]);
60
+ }
61
+
62
+ export function applyConsent(state, categoryGtagKeys = {}) {
63
+ if (typeof window === 'undefined') return;
64
+
65
+ window.dataLayer = window.dataLayer || [];
66
+ window.dataLayer.push({
67
+ event: 'consent_update',
68
+ consent: {
69
+ version_id: state.version_id,
70
+ categories: state.categories,
71
+ services: state.services,
72
+ },
73
+ });
74
+
75
+ if (typeof window.gtag === 'function' && Object.keys(categoryGtagKeys).length > 0) {
76
+ const update = {};
77
+ for (const [categoryKey, signals] of Object.entries(categoryGtagKeys)) {
78
+ const allowed = state.categories[categoryKey] ? 'granted' : 'denied';
79
+ for (const signal of signals) update[signal] = allowed;
80
+ }
81
+ window.gtag('consent', 'update', update);
82
+ }
83
+ }
84
+
85
+ export function writeOptimistic(state) {
86
+ Cookies.set(cookieName, JSON.stringify(state), {
87
+ expires: EXPIRY_MONTHS * 30,
88
+ sameSite: 'lax',
89
+ secure: window.location.protocol === 'https:',
90
+ });
91
+ }
@@ -0,0 +1,4 @@
1
+ module Consentful
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module Consentful
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module Consentful
2
+ # Engine base class. Deliberately inherits straight from ActiveRecord::Base
3
+ # rather than the host's ApplicationRecord, which may wire primary/replica
4
+ # connection switching the engine should not inherit.
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class ConsentCategory < ApplicationRecord
5
+ # Unprefixed table name (isolate_namespace would otherwise expect
6
+ # `consentful_consent_categories`); keeps parity with an extracting host.
7
+ self.table_name = "consent_categories"
8
+
9
+ has_many :consent_services, dependent: :restrict_with_error
10
+
11
+ validates :key, presence: true, uniqueness: true
12
+ validates :key,
13
+ inclusion: { in: ->(_) { Consentful.config.category_keys } },
14
+ if: -> { Consentful.config.category_keys.present? }
15
+ validates :name, presence: true
16
+ validates :position, presence: true, numericality: { only_integer: true }
17
+
18
+ default_scope { order(position: :asc) }
19
+ scope :required_categories, -> { where(required: true) }
20
+ end
21
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Consentful
6
+ class ConsentCopyVersion < ApplicationRecord
7
+ self.table_name = "consent_copy_versions"
8
+
9
+ STATUSES = { draft: 0, published: 1, archived: 2 }.freeze
10
+
11
+ has_many :consent_records, dependent: :restrict_with_error
12
+
13
+ enum :status, STATUSES
14
+
15
+ validates :digest, presence: true
16
+
17
+ before_validation :recalculate_digest
18
+
19
+ default_scope { order(created_at: :desc) }
20
+
21
+ def self.current
22
+ published.first
23
+ end
24
+
25
+ # `published_by` is a free-form identity string (e.g. an admin email)
26
+ # resolved by the host via Consentful.config.identify — not an FK.
27
+ def publish!(published_by = nil)
28
+ transaction do
29
+ self.class.published.where.not(id:).find_each { |v| v.update!(status: :archived) }
30
+ update!(status: :published, published_at: Time.current, published_by:)
31
+ end
32
+ end
33
+
34
+ def archive!
35
+ update!(status: :archived)
36
+ end
37
+
38
+ private
39
+
40
+ def recalculate_digest
41
+ payload = [
42
+ banner_title, banner_body, modal_intro,
43
+ accept_label, reject_label, manage_label, save_label,
44
+ category_blurbs.to_json
45
+ ].join("|")
46
+ self.digest = Digest::SHA256.hexdigest(payload)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class ConsentRecord < ApplicationRecord
5
+ self.table_name = "consent_records"
6
+
7
+ SOURCES = {
8
+ banner_accept_all: 0,
9
+ banner_reject_all: 1,
10
+ banner_save: 2,
11
+ preference_centre: 3,
12
+ expiry_reprompt: 4
13
+ }.freeze
14
+
15
+ belongs_to :consent_copy_version
16
+
17
+ enum :source, SOURCES
18
+
19
+ validates :visitor_uuid, presence: true
20
+ validates :recorded_at, presence: true
21
+
22
+ before_validation :default_recorded_at, on: :create
23
+
24
+ scope :for_visitor, ->(uuid) { where(visitor_uuid: uuid) }
25
+ scope :within, ->(range) { where(recorded_at: range) }
26
+
27
+ private
28
+
29
+ def default_recorded_at
30
+ self.recorded_at ||= Time.current
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class ConsentService < ApplicationRecord
5
+ self.table_name = "consent_services"
6
+
7
+ LOADER_KINDS = { rails_partial: 0, gtm_tag: 1, inline_view: 2, external_only: 3 }.freeze
8
+
9
+ LOADER_KIND_LABELS = {
10
+ "rails_partial" => "Site-wide tag (managed in code)",
11
+ "gtm_tag" => "Google Tag Manager",
12
+ "inline_view" => "Single-page tag",
13
+ "external_only" => "External resource (no tag)"
14
+ }.freeze
15
+
16
+ REQUIRED_DISCLOSURE_FIELDS = %i[description processing_company_name legal_basis retention_period processing_location].freeze
17
+
18
+ belongs_to :consent_category
19
+
20
+ enum :loader_kind, LOADER_KINDS
21
+
22
+ validates :name, presence: true
23
+ validates :slug, presence: true, uniqueness: true,
24
+ format: { with: /\A[a-z0-9_]+\z/, message: "may only contain lowercase letters, digits and underscores" }
25
+ validates :position, presence: true, numericality: { only_integer: true }
26
+
27
+ scope :active, -> { where(active: true) }
28
+ default_scope { order(:position, :name) }
29
+
30
+ def loader_kind_label
31
+ LOADER_KIND_LABELS.fetch(loader_kind, loader_kind.to_s)
32
+ end
33
+
34
+ def disclosure_complete?
35
+ REQUIRED_DISCLOSURE_FIELDS.all? { |f| public_send(f).present? }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ # Plain value object over the consent cookie payload. Mirrors the JS
5
+ # implementation in the engine's state.js — keep the two in lockstep.
6
+ class ConsentState
7
+ EXPIRY = 12.months
8
+ EMPTY_COOKIE = {
9
+ "version_id" => nil,
10
+ "categories" => {},
11
+ "services" => {},
12
+ "recorded_at" => nil
13
+ }.freeze
14
+
15
+ attr_reader :version_id, :categories, :services, :recorded_at
16
+
17
+ def self.from_cookie(raw)
18
+ payload = parse(raw)
19
+ new(
20
+ version_id: payload["version_id"],
21
+ categories: payload["categories"] || {},
22
+ services: payload["services"] || {},
23
+ recorded_at: payload["recorded_at"]
24
+ )
25
+ end
26
+
27
+ def self.empty
28
+ new(version_id: nil, categories: {}, services: {}, recorded_at: nil)
29
+ end
30
+
31
+ def self.parse(raw)
32
+ return EMPTY_COOKIE if raw.blank?
33
+
34
+ JSON.parse(raw)
35
+ rescue JSON::ParserError
36
+ EMPTY_COOKIE
37
+ end
38
+
39
+ def initialize(version_id:, categories:, services:, recorded_at:)
40
+ @version_id = version_id
41
+ @categories = (categories || {}).transform_keys(&:to_s)
42
+ @services = (services || {}).transform_keys(&:to_s)
43
+ @recorded_at = recorded_at
44
+ end
45
+
46
+ def allowed?(service)
47
+ return true if service.consent_category&.required?
48
+
49
+ override = @services[service.slug]
50
+ return override unless override.nil?
51
+
52
+ !!@categories[service.consent_category.key]
53
+ end
54
+
55
+ def category_allowed?(category_key)
56
+ key = category_key.to_s
57
+ return true if key == Consentful.config.required_category_key
58
+
59
+ !!@categories[key]
60
+ end
61
+
62
+ def expired?
63
+ return true if @recorded_at.nil?
64
+
65
+ Time.zone.at(@recorded_at) < EXPIRY.ago
66
+ rescue TypeError, ArgumentError
67
+ true
68
+ end
69
+
70
+ def matches_version?(published_version)
71
+ return false if published_version.nil? || @version_id.nil?
72
+
73
+ @version_id.to_i == published_version.id
74
+ end
75
+
76
+ def recorded?
77
+ @version_id.present? && @recorded_at.present?
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class ConsentVisitorLink < ApplicationRecord
5
+ self.table_name = "consent_visitor_links"
6
+
7
+ # Engine ships `email` only; hosts extend via
8
+ # Consentful.config.visitor_identifier_types in an initializer.
9
+ enum :identifier_type, Consentful.config.visitor_identifier_types
10
+
11
+ validates :visitor_uuid, presence: true
12
+ validates :identifier_value, presence: true,
13
+ uniqueness: { scope: [ :visitor_uuid, :identifier_type ] }
14
+ validates :linked_at, presence: true
15
+
16
+ before_validation :default_linked_at, on: :create
17
+
18
+ scope :for_value, ->(type, value) { where(identifier_type: type, identifier_value: value) }
19
+
20
+ private
21
+
22
+ def default_linked_at
23
+ self.linked_at ||= Time.current
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class ConsentRecordPresenter
5
+ ALLOWED = "allowed"
6
+ DENIED = "denied"
7
+
8
+ def initialize(record)
9
+ @record = record
10
+ end
11
+
12
+ def email_text
13
+ [
14
+ header,
15
+ summary_block,
16
+ categories_block,
17
+ service_overrides_block,
18
+ context_block,
19
+ copy_text_block
20
+ ].compact.join("\n\n")
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :record
26
+
27
+ def header
28
+ "Consent event recorded #{record.recorded_at.strftime('%Y-%m-%d %H:%M:%S')}"
29
+ end
30
+
31
+ def summary_block
32
+ [
33
+ "Source: #{record.source.to_s.humanize}",
34
+ "Visitor reference: #{record.visitor_uuid}",
35
+ "Copy version: ##{record.consent_copy_version_id}"
36
+ ].join("\n")
37
+ end
38
+
39
+ def categories_block
40
+ return if record.category_choices.blank?
41
+
42
+ lines = record.category_choices.map { |key, value| " - #{key}: #{value ? ALLOWED : DENIED}" }
43
+ "Categories chosen:\n#{lines.join("\n")}"
44
+ end
45
+
46
+ def service_overrides_block
47
+ return if record.service_overrides.blank?
48
+
49
+ lines = record.service_overrides.map { |slug, value| " - #{slug}: #{value ? ALLOWED : DENIED}" }
50
+ "Per-service overrides:\n#{lines.join("\n")}"
51
+ end
52
+
53
+ def context_block
54
+ parts = []
55
+ parts << "User agent: #{record.user_agent}" if record.user_agent.present?
56
+ parts << "Referer: #{record.referer}" if record.referer.present?
57
+ return if parts.empty?
58
+
59
+ parts.join("\n")
60
+ end
61
+
62
+ def copy_text_block
63
+ version = record.consent_copy_version
64
+ return if version.nil?
65
+
66
+ lines = [ "Copy shown to the visitor:" ]
67
+ lines << " Title: #{version.banner_title}" if version.banner_title.present?
68
+ lines << " Body: #{version.banner_body}" if version.banner_body.present?
69
+ lines << " Modal intro: #{version.modal_intro}" if version.modal_intro.present?
70
+ lines.join("\n")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ class DashboardReport
5
+ DEFAULT_DAYS = 30
6
+
7
+ attr_reader :days, :since
8
+
9
+ def initialize(days: nil)
10
+ @days = (days.presence&.to_i || DEFAULT_DAYS).clamp(1, 365)
11
+ @since = @days.days.ago
12
+ end
13
+
14
+ def total_records
15
+ @total_records ||= scope.count
16
+ end
17
+
18
+ def category_acceptance
19
+ @category_acceptance ||= ConsentCategory.order(:position).map do |category|
20
+ accepted = scope.where("category_choices ->> ? = 'true'", category.key).count
21
+ { category:, accepted:, total: total_records, rate: percentage(accepted, total_records) }
22
+ end
23
+ end
24
+
25
+ def source_breakdown
26
+ counts = scope.group(:source).count
27
+ ConsentRecord.sources.keys.map do |source|
28
+ count = counts.fetch(source, 0)
29
+ { source: source.to_sym, count:, rate: percentage(count, total_records) }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def scope
36
+ @scope ||= ConsentRecord.within(@since..Time.current)
37
+ end
38
+
39
+ def percentage(numerator, denominator)
40
+ return 0.0 if denominator.zero?
41
+
42
+ (numerator * 100.0 / denominator).round(1)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consentful
4
+ # Resolves a SAR enquiry (email or visitor UUID) into every consent_record
5
+ # we hold for that subject, with each event's copy version snapshot inlined
6
+ # so the response shows exactly what the visitor saw when they consented.
7
+ #
8
+ # Two entry points:
9
+ # - Email: looked up via consent_visitor_links → all matching visitor UUIDs
10
+ # - Visitor UUID: used directly
11
+ class SarLookup
12
+ attr_reader :email, :visitor_uuid
13
+
14
+ def initialize(email: nil, visitor_uuid: nil)
15
+ @email = Consentful.normalize_value(:email, email).presence
16
+ @visitor_uuid = visitor_uuid.to_s.strip.presence
17
+ end
18
+
19
+ def given_any_input?
20
+ @email.present? || @visitor_uuid.present?
21
+ end
22
+
23
+ def visitor_uuids
24
+ @visitor_uuids ||=
25
+ if @visitor_uuid
26
+ [ @visitor_uuid ]
27
+ elsif @email
28
+ ConsentVisitorLink.for_value(:email, @email).pluck(:visitor_uuid).uniq
29
+ else
30
+ []
31
+ end
32
+ end
33
+
34
+ def records
35
+ @records ||= ConsentRecord
36
+ .includes(:consent_copy_version)
37
+ .where(visitor_uuid: visitor_uuids)
38
+ .order(recorded_at: :desc)
39
+ end
40
+
41
+ def linked_identifiers
42
+ @linked_identifiers ||= ConsentVisitorLink
43
+ .where(visitor_uuid: visitor_uuids)
44
+ .order(linked_at: :asc)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,22 @@
1
+ <h1>Edit category</h1>
2
+
3
+ <%= form_with model: @category, scope: :consent_category, url: admin_category_path(@category) do |f| %>
4
+ <div class="cf-card">
5
+ <div class="cf-field">
6
+ <label>Key</label>
7
+ <input type="text" value="<%= @category.key %>" disabled>
8
+ <p class="cf-hint">The key is fixed; it identifies the category in cookies and code.</p>
9
+ </div>
10
+ <div class="cf-field"><%= f.label :name %><%= f.text_field :name %></div>
11
+ <div class="cf-field"><%= f.label :description %><%= f.text_area :description %></div>
12
+ <div class="cf-field">
13
+ <%= f.label :gtag_keys, "Google consent-mode signals" %>
14
+ <%= f.text_field :gtag_keys, value: @category.gtag_keys.join(", ") %>
15
+ <p class="cf-hint">Comma-separated, e.g. analytics_storage, ad_storage.</p>
16
+ </div>
17
+ <div class="cf-actions">
18
+ <%= f.submit "Save", class: "cf-btn cf-btn--primary" %>
19
+ <%= link_to "Cancel", admin_categories_path, class: "cf-btn" %>
20
+ </div>
21
+ </div>
22
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <h1>Categories</h1>
2
+
3
+ <table class="cf-table">
4
+ <thead><tr><th>Position</th><th>Key</th><th>Name</th><th>Required</th><th>Consent-mode signals</th><th></th></tr></thead>
5
+ <tbody>
6
+ <% @categories.each do |category| %>
7
+ <tr>
8
+ <td><%= category.position %></td>
9
+ <td><code><%= category.key %></code></td>
10
+ <td><%= category.name %></td>
11
+ <td><%= category.required? ? "Yes" : "No" %></td>
12
+ <td class="cf-muted"><%= category.gtag_keys.join(", ") %></td>
13
+ <td><%= link_to "Edit", edit_admin_category_path(category), class: "cf-btn" %></td>
14
+ </tr>
15
+ <% end %>
16
+ </tbody>
17
+ </table>
@@ -0,0 +1,34 @@
1
+ <%= form_with model: @version, scope: :consent_copy_version,
2
+ url: (@version.persisted? ? admin_copy_version_path(@version) : admin_copy_versions_path) do |f| %>
3
+ <% if @version.errors.any? %>
4
+ <div class="cf-flash cf-flash--alert"><%= @version.errors.full_messages.to_sentence %></div>
5
+ <% end %>
6
+
7
+ <div class="cf-card">
8
+ <h2>Banner &amp; modal copy</h2>
9
+ <div class="cf-field"><%= f.label :banner_title %><%= f.text_field :banner_title %></div>
10
+ <div class="cf-field"><%= f.label :banner_body %><%= f.text_area :banner_body %></div>
11
+ <div class="cf-field"><%= f.label :modal_intro %><%= f.text_area :modal_intro %></div>
12
+ <div class="cf-field"><%= f.label :accept_label %><%= f.text_field :accept_label %></div>
13
+ <div class="cf-field"><%= f.label :reject_label %><%= f.text_field :reject_label %></div>
14
+ <div class="cf-field"><%= f.label :manage_label %><%= f.text_field :manage_label %></div>
15
+ <div class="cf-field"><%= f.label :save_label %><%= f.text_field :save_label %></div>
16
+ </div>
17
+
18
+ <% if (@version.category_blurbs || {}).any? %>
19
+ <div class="cf-card">
20
+ <h2>Category blurbs</h2>
21
+ <% @version.category_blurbs.each do |key, blurb| %>
22
+ <div class="cf-field">
23
+ <label><%= key.to_s.humanize %></label>
24
+ <%= text_area_tag "consent_copy_version[category_blurbs][#{key}]", (blurb.is_a?(Hash) ? blurb["description"] : blurb) %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ <% end %>
29
+
30
+ <div class="cf-actions">
31
+ <%= f.submit (@version.persisted? ? "Save draft" : "Create draft"), class: "cf-btn cf-btn--primary" %>
32
+ <%= link_to "Cancel", admin_copy_versions_path, class: "cf-btn" %>
33
+ </div>
34
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h1>Edit draft #<%= @version.id %></h1>
2
+ <%= render "form" %>
@@ -0,0 +1,34 @@
1
+ <h1>Consent copy versions</h1>
2
+
3
+ <div class="cf-actions" style="margin-bottom:16px;">
4
+ <%= link_to "New draft", new_admin_copy_version_path, class: "cf-btn cf-btn--primary" %>
5
+ </div>
6
+
7
+ <table class="cf-table">
8
+ <thead><tr><th>ID</th><th>Status</th><th>Banner title</th><th>Published</th><th>By</th><th>Actions</th></tr></thead>
9
+ <tbody>
10
+ <% @versions.each do |version| %>
11
+ <tr>
12
+ <td>#<%= version.id %></td>
13
+ <td><span class="cf-badge cf-badge--<%= version.status %>"><%= version.status %></span></td>
14
+ <td><%= version.banner_title %></td>
15
+ <td class="cf-muted"><%= version.published_at&.strftime("%Y-%m-%d %H:%M") %></td>
16
+ <td class="cf-muted"><%= version.published_by %></td>
17
+ <td>
18
+ <div class="cf-actions">
19
+ <%= link_to "View", admin_copy_version_path(version), class: "cf-btn" %>
20
+ <% if version.draft? %>
21
+ <%= link_to "Edit", edit_admin_copy_version_path(version), class: "cf-btn" %>
22
+ <%= button_to "Publish", publish_admin_copy_version_path(version), class: "cf-btn cf-btn--gold" %>
23
+ <%= button_to "Delete", admin_copy_version_path(version), method: :delete, class: "cf-btn cf-btn--danger", form: { data: { turbo_confirm: "Delete this draft?" } } %>
24
+ <% elsif version.published? %>
25
+ <%= button_to "Archive", archive_admin_copy_version_path(version), class: "cf-btn" %>
26
+ <% else %>
27
+ <%= button_to "Clone to draft", restore_admin_copy_version_path(version), class: "cf-btn" %>
28
+ <% end %>
29
+ </div>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
@@ -0,0 +1,2 @@
1
+ <h1>New draft</h1>
2
+ <%= render "form" %>
@@ -0,0 +1,24 @@
1
+ <h1>Copy version #<%= @version.id %> <span class="cf-badge cf-badge--<%= @version.status %>"><%= @version.status %></span></h1>
2
+
3
+ <div class="cf-card">
4
+ <p><strong><%= @version.banner_title %></strong></p>
5
+ <p><%= @version.banner_body %></p>
6
+ <p class="cf-muted"><%= @version.modal_intro %></p>
7
+ <p class="cf-muted">Buttons: <%= [@version.accept_label, @version.reject_label, @version.manage_label, @version.save_label].compact_blank.join(" · ") %></p>
8
+ </div>
9
+
10
+ <div class="cf-card">
11
+ <p class="cf-muted">Content digest</p>
12
+ <code><%= @version.digest %></code>
13
+ <button type="button" class="cf-btn"
14
+ data-controller="consentful--clipboard"
15
+ data-consentful--clipboard-text-value="<%= @version.digest %>"
16
+ data-action="consentful--clipboard#copy">Copy digest</button>
17
+ </div>
18
+
19
+ <div class="cf-actions">
20
+ <%= link_to "Back", admin_copy_versions_path, class: "cf-btn" %>
21
+ <% if @version.draft? %>
22
+ <%= link_to "Edit", edit_admin_copy_version_path(@version), class: "cf-btn cf-btn--primary" %>
23
+ <% end %>
24
+ </div>