testimonials 0.7.9 → 0.8.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +210 -0
  3. data/CHANGELOG.md +68 -0
  4. data/README.md +30 -4
  5. data/app/controllers/concerns/testimonials/request_context.rb +75 -0
  6. data/app/controllers/testimonials/application_controller.rb +10 -56
  7. data/app/controllers/testimonials/dashboard_controller.rb +40 -0
  8. data/app/controllers/testimonials/events_controller.rb +9 -0
  9. data/app/controllers/testimonials/nps_responses_controller.rb +1 -4
  10. data/app/controllers/testimonials/submissions_controller.rb +169 -0
  11. data/app/controllers/testimonials/testimonials_controller.rb +6 -166
  12. data/app/helpers/testimonials/widget_helper.rb +4 -0
  13. data/app/models/testimonials/prompt_event.rb +12 -0
  14. data/app/views/layouts/testimonials/application.html.erb +5 -8
  15. data/app/views/testimonials/nps_responses/index.html.erb +154 -152
  16. data/app/views/testimonials/nps_responses/show.html.erb +7 -5
  17. data/app/views/testimonials/shared/_dashboard.html.erb +18 -0
  18. data/app/views/testimonials/testimonials/index.html.erb +110 -108
  19. data/app/views/testimonials/testimonials/show.html.erb +7 -5
  20. data/config/routes.rb +18 -5
  21. data/examples/README.md +17 -0
  22. data/examples/badge.html.erb +13 -0
  23. data/examples/json_ld.html.erb +28 -0
  24. data/examples/static_site.md +43 -0
  25. data/examples/testimonial_card.html.erb +17 -0
  26. data/examples/wall_of_love.html.erb +43 -0
  27. data/lib/generators/testimonials/install/install_generator.rb +22 -10
  28. data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +7 -5
  29. data/lib/generators/testimonials/install/templates/initializer.rb.tt +25 -2
  30. data/lib/generators/testimonials/migration_helpers.rb +35 -0
  31. data/lib/generators/testimonials/nps/nps_generator.rb +2 -6
  32. data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +1 -1
  33. data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +35 -0
  34. data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +17 -0
  35. data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +11 -8
  36. data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
  37. data/lib/testimonials/configuration.rb +34 -1
  38. data/lib/testimonials/dashboard.css +278 -221
  39. data/lib/testimonials/prompt_helper.rb +4 -0
  40. data/lib/testimonials/seeds.rb +3 -2
  41. data/lib/testimonials/version.rb +1 -1
  42. data/lib/testimonials/widget.js +4 -1
  43. data/lib/testimonials/widget.rb +3 -0
  44. data/lib/testimonials.rb +8 -0
  45. metadata +15 -1
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testimonials
4
+ # The widget's write endpoint: POST to the mount path.
5
+ #
6
+ # Public, so it stays on ApplicationController and never inherits a host's
7
+ # admin base controller — a member leaving a review must not be asked for a
8
+ # staff session. The dashboard's read/triage actions live in
9
+ # TestimonialsController, which does inherit it.
10
+ class SubmissionsController < ApplicationController
11
+ before_action :require_enabled
12
+
13
+ # Throttle the public endpoint per IP so one user or bot can't flood the
14
+ # table (a submission may carry a video). Uses the rate limiter built
15
+ # into Rails 7.2+ (backed by Rails.cache); on Rails 7.1 this is a no-op.
16
+ # Tune or disable via config.rate_limit — read once at boot.
17
+ if respond_to?(:rate_limit) && Testimonials.config.rate_limit
18
+ rate_limit(**Testimonials.config.rate_limit, only: :create, with: -> { render_rate_limited })
19
+ end
20
+
21
+ def create
22
+ testimonial = build_testimonial
23
+
24
+ error = attach_video(testimonial) || attach_avatar(testimonial)
25
+ return render json: { errors: [error] }, status: :unprocessable_entity if error
26
+
27
+ if testimonial.save
28
+ # Purge only after a valid save, so a failed update can't strand a
29
+ # review with its video already gone.
30
+ if remove_video? && testimonial.video_attached?
31
+ testimonial.video_file.purge
32
+ testimonial.poster.purge if testimonial.poster_attached?
33
+ end
34
+ record_submission
35
+ notify_host(testimonial)
36
+ head :created
37
+ else
38
+ render json: { errors: testimonial.errors.full_messages }, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # One review per signed-in user: a re-submission edits their existing
45
+ # review in place (the App Store model). The edit goes back through
46
+ # moderation, and the admin's best_line is cleared — it may no longer
47
+ # match the new wording. Guests have no reliable identity, so each guest
48
+ # submission stays a new record.
49
+ def build_testimonial
50
+ testimonial = existing_testimonial || Testimonial.new
51
+ testimonial.assign_attributes(testimonial_params)
52
+ if testimonial.persisted?
53
+ testimonial.status = 'pending'
54
+ testimonial.best_line = nil
55
+ end
56
+ testimonial.kind = wants_video?(testimonial) ? 'video' : 'text'
57
+ testimonial.source = 'widget' unless Testimonial::SOURCES.include?(testimonial.source)
58
+ testimonial.consent_text = Testimonials.consent_text_for(testimonial.consent_given?)
59
+ testimonial.locale = I18n.locale.to_s
60
+ testimonial.tenant = current_tenant
61
+ testimonial.user_agent = request.user_agent
62
+ attribute_author(testimonial)
63
+ testimonial
64
+ end
65
+
66
+ # One review per signed-in user, *per tenant*: the same person can leave a
67
+ # separate review in each tenant, but only one within any single tenant.
68
+ def existing_testimonial
69
+ return if current_author_id.blank?
70
+
71
+ tenant_scope.where(author_id: current_author_id.to_s).newest_first.first
72
+ end
73
+
74
+ # A fresh upload makes it a video review; on an edit without a new
75
+ # upload, an already-attached video stays — unless the user removed it.
76
+ def wants_video?(testimonial)
77
+ return true if params.dig(:testimonial, :video_file).present?
78
+
79
+ testimonial.video_attached? && !remove_video?
80
+ end
81
+
82
+ def remove_video?
83
+ params.dig(:testimonial, :remove_video).present? && params.dig(:testimonial, :video_file).blank?
84
+ end
85
+
86
+ def testimonial_params
87
+ params.require(:testimonial)
88
+ .permit(:body, :rating, :consent_given, :page_url, :source,
89
+ :name, :email, :title_company)
90
+ end
91
+
92
+ # Signed-in users are attributed server-side — the widget never sends
93
+ # (and the endpoint never trusts) contact fields for them.
94
+ def attribute_author(testimonial)
95
+ author = current_author
96
+ return if author.nil?
97
+
98
+ testimonial.author_id = author.id.to_s if author.respond_to?(:id)
99
+ display = Testimonials.config.user_display.call(author) || {}
100
+ testimonial.name = display[:name].presence || testimonial.name
101
+ testimonial.email = display[:email].presence || testimonial.email
102
+ testimonial.title_company = display[:title_company].presence || testimonial.title_company
103
+ end
104
+
105
+ # Returns an error message, or nil when everything is fine.
106
+ def attach_video(testimonial)
107
+ file = params.dig(:testimonial, :video_file)
108
+ return nil if file.blank?
109
+ return error_label(:error_save) unless Testimonials.config.video_enabled?
110
+ return error_label(:error_video_too_large) if file.size > Testimonials.config.max_video_size
111
+ return error_label(:error_save) unless file.content_type.to_s.start_with?('video/', 'audio/')
112
+
113
+ testimonial.video_file.attach(file)
114
+ attach_poster(testimonial)
115
+ nil
116
+ end
117
+
118
+ # The poster rides with a new video upload. It's best-effort — a missing
119
+ # or malformed poster never blocks the testimonial. Attaching replaces any
120
+ # previous poster; a new video with no usable poster (e.g. an uploaded
121
+ # file, which we can't frame-grab) drops the stale one so it can't show a
122
+ # thumbnail from the wrong video.
123
+ def attach_poster(testimonial)
124
+ file = params.dig(:testimonial, :poster)
125
+ usable = file.present? && file.content_type.to_s.start_with?('image/') &&
126
+ file.size <= Testimonials.config.max_avatar_size
127
+
128
+ if usable
129
+ testimonial.poster.attach(file)
130
+ elsif testimonial.poster_attached?
131
+ testimonial.poster.purge
132
+ end
133
+ end
134
+
135
+ def attach_avatar(testimonial)
136
+ file = params.dig(:testimonial, :avatar)
137
+ return nil if file.blank?
138
+ return error_label(:error_save) unless Testimonials.config.avatars_enabled?
139
+ return error_label(:error_save) if file.size > Testimonials.config.max_avatar_size
140
+ return error_label(:error_save) unless file.content_type.to_s.start_with?('image/')
141
+
142
+ testimonial.avatar.attach(file)
143
+ nil
144
+ end
145
+
146
+ def record_submission
147
+ PromptEvent.record!(kind: 'testimonial', action: 'submitted', tenant: current_tenant,
148
+ author_id: current_author_id, visitor_token: ensure_visitor_token)
149
+ end
150
+
151
+ # The host's hook must never turn a saved submission into a 500 — the
152
+ # testimonial is in the database; notification failures are the host's
153
+ # logs' problem.
154
+ def notify_host(record)
155
+ Testimonials.config.on_submit.call(record)
156
+ rescue StandardError => e
157
+ Rails.logger.error("testimonials: on_submit hook raised #{e.class}: #{e.message}")
158
+ end
159
+
160
+ def error_label(key)
161
+ defaults = {
162
+ error_save: 'Could not send. Please try again.',
163
+ error_video_too_large: 'The video is too large (max %{size} MB).'
164
+ }
165
+ I18n.t(key, scope: :testimonials, default: defaults[key],
166
+ size: Testimonials.config.max_video_size / (1024 * 1024))
167
+ end
168
+ end
169
+ end
@@ -1,24 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- class TestimonialsController < ApplicationController
4
+ # The testimonial queue: triage, search, best lines. Staff only.
5
+ #
6
+ # POST to the mount path is SubmissionsController — the public write endpoint
7
+ # cannot share a controller with these, because this one inherits whatever the
8
+ # host set as base_controller_class.
9
+ class TestimonialsController < DashboardController
5
10
  PER_PAGE = 50
6
11
 
7
- layout :testimonials_admin_layout, except: :create
8
-
9
- # create is the public widget endpoint; everything else is the dashboard.
10
- before_action :require_enabled, only: :create
11
- before_action :require_admin, except: :create
12
12
  before_action :set_testimonial, only: %i[show update destroy]
13
13
 
14
- # Throttle the public endpoint per IP so one user or bot can't flood the
15
- # table (a submission may carry a video). Uses the rate limiter built
16
- # into Rails 7.2+ (backed by Rails.cache); on Rails 7.1 this is a no-op.
17
- # Tune or disable via config.rate_limit — read once at boot.
18
- if respond_to?(:rate_limit) && Testimonials.config.rate_limit
19
- rate_limit(**Testimonials.config.rate_limit, only: :create, with: -> { render_rate_limited })
20
- end
21
-
22
14
  def index
23
15
  @status = Testimonial::STATUSES.include?(params[:status]) ? params[:status] : 'pending'
24
16
  @kind = Testimonial::KINDS.include?(params[:kind]) ? params[:kind] : nil
@@ -48,39 +40,12 @@ module Testimonials
48
40
  redirect_to root_path, status: :see_other
49
41
  end
50
42
 
51
- def create
52
- testimonial = build_testimonial
53
-
54
- error = attach_video(testimonial) || attach_avatar(testimonial)
55
- return render json: { errors: [error] }, status: :unprocessable_entity if error
56
-
57
- if testimonial.save
58
- # Purge only after a valid save, so a failed update can't strand a
59
- # review with its video already gone.
60
- if remove_video? && testimonial.video_attached?
61
- testimonial.video_file.purge
62
- testimonial.poster.purge if testimonial.poster_attached?
63
- end
64
- record_submission
65
- notify_host(testimonial)
66
- head :created
67
- else
68
- render json: { errors: testimonial.errors.full_messages }, status: :unprocessable_entity
69
- end
70
- end
71
-
72
43
  private
73
44
 
74
45
  def set_testimonial
75
46
  @testimonial = tenant_scope.find(params[:id])
76
47
  end
77
48
 
78
- # Every dashboard query starts here, so an admin can only ever load,
79
- # triage, or delete records in their own tenant — a cross-tenant id 404s.
80
- def tenant_scope
81
- Testimonial.for_tenant(current_tenant)
82
- end
83
-
84
49
  def admin_params
85
50
  params.require(:testimonial).permit(:status, :featured, :best_line)
86
51
  end
@@ -97,130 +62,5 @@ module Testimonials
97
62
  q: pattern
98
63
  )
99
64
  end
100
-
101
- # One review per signed-in user: a re-submission edits their existing
102
- # review in place (the App Store model). The edit goes back through
103
- # moderation, and the admin's best_line is cleared — it may no longer
104
- # match the new wording. Guests have no reliable identity, so each guest
105
- # submission stays a new record.
106
- def build_testimonial
107
- testimonial = existing_testimonial || Testimonial.new
108
- testimonial.assign_attributes(testimonial_params)
109
- if testimonial.persisted?
110
- testimonial.status = 'pending'
111
- testimonial.best_line = nil
112
- end
113
- testimonial.kind = wants_video?(testimonial) ? 'video' : 'text'
114
- testimonial.source = 'widget' unless Testimonial::SOURCES.include?(testimonial.source)
115
- testimonial.consent_text = Testimonials.consent_text_for(testimonial.consent_given?)
116
- testimonial.locale = I18n.locale.to_s
117
- testimonial.tenant = current_tenant
118
- testimonial.user_agent = request.user_agent
119
- attribute_author(testimonial)
120
- testimonial
121
- end
122
-
123
- # One review per signed-in user, *per tenant*: the same person can leave a
124
- # separate review in each tenant, but only one within any single tenant.
125
- def existing_testimonial
126
- return if current_author_id.blank?
127
-
128
- tenant_scope.where(author_id: current_author_id.to_s).newest_first.first
129
- end
130
-
131
- # A fresh upload makes it a video review; on an edit without a new
132
- # upload, an already-attached video stays — unless the user removed it.
133
- def wants_video?(testimonial)
134
- return true if params.dig(:testimonial, :video_file).present?
135
-
136
- testimonial.video_attached? && !remove_video?
137
- end
138
-
139
- def remove_video?
140
- params.dig(:testimonial, :remove_video).present? && params.dig(:testimonial, :video_file).blank?
141
- end
142
-
143
- def testimonial_params
144
- params.require(:testimonial)
145
- .permit(:body, :rating, :consent_given, :page_url, :source,
146
- :name, :email, :title_company)
147
- end
148
-
149
- # Signed-in users are attributed server-side — the widget never sends
150
- # (and the endpoint never trusts) contact fields for them.
151
- def attribute_author(testimonial)
152
- author = current_author
153
- return if author.nil?
154
-
155
- testimonial.author_id = author.id.to_s if author.respond_to?(:id)
156
- display = Testimonials.config.user_display.call(author) || {}
157
- testimonial.name = display[:name].presence || testimonial.name
158
- testimonial.email = display[:email].presence || testimonial.email
159
- testimonial.title_company = display[:title_company].presence || testimonial.title_company
160
- end
161
-
162
- # Returns an error message, or nil when everything is fine.
163
- def attach_video(testimonial)
164
- file = params.dig(:testimonial, :video_file)
165
- return nil if file.blank?
166
- return error_label(:error_save) unless Testimonials.config.video_enabled?
167
- return error_label(:error_video_too_large) if file.size > Testimonials.config.max_video_size
168
- return error_label(:error_save) unless file.content_type.to_s.start_with?('video/', 'audio/')
169
-
170
- testimonial.video_file.attach(file)
171
- attach_poster(testimonial)
172
- nil
173
- end
174
-
175
- # The poster rides with a new video upload. It's best-effort — a missing
176
- # or malformed poster never blocks the testimonial. Attaching replaces any
177
- # previous poster; a new video with no usable poster (e.g. an uploaded
178
- # file, which we can't frame-grab) drops the stale one so it can't show a
179
- # thumbnail from the wrong video.
180
- def attach_poster(testimonial)
181
- file = params.dig(:testimonial, :poster)
182
- usable = file.present? && file.content_type.to_s.start_with?('image/') &&
183
- file.size <= Testimonials.config.max_avatar_size
184
-
185
- if usable
186
- testimonial.poster.attach(file)
187
- elsif testimonial.poster_attached?
188
- testimonial.poster.purge
189
- end
190
- end
191
-
192
- def attach_avatar(testimonial)
193
- file = params.dig(:testimonial, :avatar)
194
- return nil if file.blank?
195
- return error_label(:error_save) unless Testimonials.config.avatars_enabled?
196
- return error_label(:error_save) if file.size > Testimonials.config.max_avatar_size
197
- return error_label(:error_save) unless file.content_type.to_s.start_with?('image/')
198
-
199
- testimonial.avatar.attach(file)
200
- nil
201
- end
202
-
203
- def record_submission
204
- PromptEvent.record!(kind: 'testimonial', action: 'submitted', tenant: current_tenant,
205
- author_id: current_author_id, visitor_token: ensure_visitor_token)
206
- end
207
-
208
- # The host's hook must never turn a saved submission into a 500 — the
209
- # testimonial is in the database; notification failures are the host's
210
- # logs' problem.
211
- def notify_host(record)
212
- Testimonials.config.on_submit.call(record)
213
- rescue StandardError => e
214
- Rails.logger.error("testimonials: on_submit hook raised #{e.class}: #{e.message}")
215
- end
216
-
217
- def error_label(key)
218
- defaults = {
219
- error_save: 'Could not send. Please try again.',
220
- error_video_too_large: 'The video is too large (max %{size} MB).'
221
- }
222
- I18n.t(key, scope: :testimonials, default: defaults[key],
223
- size: Testimonials.config.max_video_size / (1024 * 1024))
224
- end
225
65
  end
226
66
  end
@@ -72,6 +72,10 @@ module Testimonials
72
72
  kind = flash[:testimonials_prompt].to_s
73
73
  return unless Testimonials::PromptEvent::KINDS.include?(kind)
74
74
  return if kind == 'nps' && !Testimonials.config.nps
75
+ # Without the ledger there is no record of who has been asked already, so
76
+ # an auto-open would reopen on every page a testimonial_prompt! reaches.
77
+ # An install with --skip-prompt-events opens on a click and nothing else.
78
+ return unless Testimonials.config.prompt_events
75
79
 
76
80
  author = testimonials_author
77
81
  return unless Testimonials::PromptEvent.eligible?(
@@ -12,6 +12,12 @@ module Testimonials
12
12
  # * shown max_prompts times -> never auto-prompted for that kind again
13
13
  #
14
14
  # Explicit opens (the user clicked something) bypass all of this.
15
+ #
16
+ # The ledger is optional: an install run with --skip-prompt-events has no
17
+ # table behind this class and config.prompt_events is false, so record! is a
18
+ # no-op and eligible? answers without a query. Every write and every read
19
+ # goes through the two methods below, so that flag is the only guard needed
20
+ # — nothing here asks the schema at runtime.
15
21
  class PromptEvent < ApplicationRecord
16
22
  KINDS = %w[testimonial nps].freeze
17
23
  ACTIONS = %w[shown dismissed submitted].freeze
@@ -21,6 +27,7 @@ module Testimonials
21
27
 
22
28
  class << self
23
29
  def record!(kind:, action:, author_id: nil, visitor_token: nil, tenant: nil)
30
+ return unless Testimonials.config.prompt_events
24
31
  return if author_id.blank? && visitor_token.blank?
25
32
 
26
33
  create!(kind: kind.to_s, action: action.to_s, tenant: tenant.presence,
@@ -34,6 +41,11 @@ module Testimonials
34
41
  return false unless KINDS.include?(kind)
35
42
  # No identity, no history: a brand-new visitor is always eligible.
36
43
  return true if author_id.blank? && visitor_token.blank?
44
+ # No ledger, no history either — the same answer, one flag earlier.
45
+ # This is not a licence to nag: with the ledger off nothing auto-opens
46
+ # (see WidgetHelper#testimonials_auto_open), and the one caller left is
47
+ # the promoter offered the form inside a flow they opened themselves.
48
+ return true unless Testimonials.config.prompt_events
37
49
 
38
50
  history = subject(author_id, visitor_token, tenant).where(kind: kind)
39
51
  config = Testimonials.config
@@ -5,16 +5,13 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
- <%# Same-origin script instead of inline handlers, so delete confirms and
9
- the auto-submitting filter work under strict script-src CSPs. %>
10
- <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Testimonials::Widget.dashboard_stylesheet_fingerprint}",
11
- 'data-turbo-track': 'reload' %>
12
- <%= javascript_include_tag "#{dashboard_script_path}?v=#{Testimonials::Widget.dashboard_fingerprint}",
13
- 'data-turbo-track': 'reload', defer: true, nonce: true %>
8
+ <%# The dashboard's stylesheet and script are declared by the views
9
+ (testimonials/shared/_dashboard), not here, so they survive a host
10
+ replacing this layout via config.admin_layout. %>
14
11
  </head>
15
12
  <body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
16
- <div class="container">
17
- <div class="nav">
13
+ <div class="tml-page">
14
+ <div class="tml-nav">
18
15
  <%= link_to t('testimonials.dashboard.title', default: 'Testimonials'), root_path,
19
16
  class: ('active' unless controller_name == 'nps_responses') %>
20
17
  <% if Testimonials.config.nps %>