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.
- checksums.yaml +4 -4
- data/AGENTS.md +210 -0
- data/CHANGELOG.md +68 -0
- data/README.md +30 -4
- data/app/controllers/concerns/testimonials/request_context.rb +75 -0
- data/app/controllers/testimonials/application_controller.rb +10 -56
- data/app/controllers/testimonials/dashboard_controller.rb +40 -0
- data/app/controllers/testimonials/events_controller.rb +9 -0
- data/app/controllers/testimonials/nps_responses_controller.rb +1 -4
- data/app/controllers/testimonials/submissions_controller.rb +169 -0
- data/app/controllers/testimonials/testimonials_controller.rb +6 -166
- data/app/helpers/testimonials/widget_helper.rb +4 -0
- data/app/models/testimonials/prompt_event.rb +12 -0
- data/app/views/layouts/testimonials/application.html.erb +5 -8
- data/app/views/testimonials/nps_responses/index.html.erb +154 -152
- data/app/views/testimonials/nps_responses/show.html.erb +7 -5
- data/app/views/testimonials/shared/_dashboard.html.erb +18 -0
- data/app/views/testimonials/testimonials/index.html.erb +110 -108
- data/app/views/testimonials/testimonials/show.html.erb +7 -5
- data/config/routes.rb +18 -5
- data/examples/README.md +17 -0
- data/examples/badge.html.erb +13 -0
- data/examples/json_ld.html.erb +28 -0
- data/examples/static_site.md +43 -0
- data/examples/testimonial_card.html.erb +17 -0
- data/examples/wall_of_love.html.erb +43 -0
- data/lib/generators/testimonials/install/install_generator.rb +22 -10
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +7 -5
- data/lib/generators/testimonials/install/templates/initializer.rb.tt +25 -2
- data/lib/generators/testimonials/migration_helpers.rb +35 -0
- data/lib/generators/testimonials/nps/nps_generator.rb +2 -6
- data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +1 -1
- data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +35 -0
- data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +17 -0
- data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +11 -8
- data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
- data/lib/testimonials/configuration.rb +34 -1
- data/lib/testimonials/dashboard.css +278 -221
- data/lib/testimonials/prompt_helper.rb +4 -0
- data/lib/testimonials/seeds.rb +3 -2
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +4 -1
- data/lib/testimonials/widget.rb +3 -0
- data/lib/testimonials.rb +8 -0
- 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
|
-
|
|
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
|
-
<%#
|
|
9
|
-
|
|
10
|
-
|
|
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="
|
|
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 %>
|