testimonials 0.7.10 → 0.8.1
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 +4 -1
- data/CHANGELOG.md +66 -0
- data/README.md +2 -0
- 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/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/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/lib/generators/testimonials/install/install_generator.rb +2 -6
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +3 -4
- data/lib/generators/testimonials/install/templates/initializer.rb.tt +10 -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 +2 -6
- data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +1 -1
- data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +0 -1
- data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
- data/lib/testimonials/configuration.rb +24 -1
- data/lib/testimonials/dashboard.css +278 -221
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials.rb +8 -0
- metadata +6 -1
|
@@ -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
|
|
@@ -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 %>
|
|
@@ -1,169 +1,171 @@
|
|
|
1
1
|
<% content_for :body_class, 'tm-nps-index' %>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
<%= render layout: 'testimonials/shared/dashboard' do %>
|
|
4
|
+
<div class="page-head">
|
|
5
|
+
<h1><%= t('testimonials.dashboard.nps', default: 'NPS') %></h1>
|
|
6
|
+
<%# The shareable NPS page, same idea as the testimonial one. config.nps is
|
|
7
|
+
already true here — the controller 404s otherwise — so only the public
|
|
8
|
+
switch is left to check. %>
|
|
9
|
+
<% if Testimonials.config.public_collection %>
|
|
10
|
+
<a class="button share-link" href="<%= nps_collection_url %>" target="_blank" rel="noopener"
|
|
11
|
+
title="<%= nps_collection_url %>">
|
|
12
|
+
🔗 <%= t('testimonials.dashboard.nps_page', default: 'Ask for NPS scores') %>
|
|
13
|
+
</a>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
15
16
|
|
|
16
|
-
<%# How the industry reads a score, so nobody has to go and google it. Range
|
|
17
|
-
|
|
18
|
-
<% bands = [
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<% current = bands.find { |b| b[:key] == @band.to_s.tr('_', '-') } if @band %>
|
|
33
|
-
<% if current
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
17
|
+
<%# How the industry reads a score, so nobody has to go and google it. Range
|
|
18
|
+
labels are digits on purpose — no translation needed for "< 0". %>
|
|
19
|
+
<% bands = [
|
|
20
|
+
{ key: 'needs-work', range: '< 0',
|
|
21
|
+
name: t('testimonials.dashboard.nps_band_needs_work', default: 'Needs work'),
|
|
22
|
+
note: t('testimonials.dashboard.nps_guide_needs_work', default: 'More detractors than promoters.') },
|
|
23
|
+
{ key: 'good', range: '0 – 29',
|
|
24
|
+
name: t('testimonials.dashboard.nps_band_good', default: 'Good'),
|
|
25
|
+
note: t('testimonials.dashboard.nps_guide_good', default: 'Where most companies land.') },
|
|
26
|
+
{ key: 'great', range: '30 – 69',
|
|
27
|
+
name: t('testimonials.dashboard.nps_band_great', default: 'Great'),
|
|
28
|
+
note: t('testimonials.dashboard.nps_guide_great', default: 'Strong loyalty.') },
|
|
29
|
+
{ key: 'excellent', range: '70 +',
|
|
30
|
+
name: t('testimonials.dashboard.nps_band_excellent', default: 'Excellent'),
|
|
31
|
+
note: t('testimonials.dashboard.nps_guide_excellent', default: 'World-class, and rare.') }
|
|
32
|
+
] %>
|
|
33
|
+
<% current = bands.find { |b| b[:key] == @band.to_s.tr('_', '-') } if @band %>
|
|
34
|
+
<% if current
|
|
35
|
+
band_class = "badge nps-band-#{current[:key]}"
|
|
36
|
+
meter_label = "#{@score} — #{current[:name]} #{current[:note]}"
|
|
37
|
+
end %>
|
|
37
38
|
|
|
38
|
-
<div class="stat-row">
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
<div class="stat-row">
|
|
40
|
+
<div class="stat stat-nps">
|
|
41
|
+
<div class="stat-headline">
|
|
42
|
+
<b><%= @score.nil? ? '—' : @score %></b>
|
|
43
|
+
<% if current %>
|
|
44
|
+
<span class="<%= band_class %>"><%= current[:name] %></span>
|
|
45
|
+
<% end %>
|
|
46
|
+
</div>
|
|
47
|
+
<span><%= t('testimonials.dashboard.nps_score', default: 'NPS score') %></span>
|
|
48
|
+
<%# Where this score sits on the −100..100 scale, so the number reads as
|
|
49
|
+
good or bad at a glance. Kept LTR: it is a number line, not prose. %>
|
|
42
50
|
<% if current %>
|
|
43
|
-
<
|
|
51
|
+
<div class="nps-meter" role="img" aria-label="<%= meter_label %>">
|
|
52
|
+
<div class="nps-meter-track">
|
|
53
|
+
<i class="nps-seg nps-band-needs-work"></i><i class="nps-seg nps-band-good"></i>
|
|
54
|
+
<i class="nps-seg nps-band-great"></i><i class="nps-seg nps-band-excellent"></i>
|
|
55
|
+
<%# Position is data, not markup: dashboard.js places it, so a strict
|
|
56
|
+
style-src has no inline style to refuse. %>
|
|
57
|
+
<i class="nps-marker" data-nps-marker="<%= @score %>"></i>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="nps-meter-ticks" aria-hidden="true">
|
|
60
|
+
<span class="tick-min">−100</span><span class="tick-zero">0</span>
|
|
61
|
+
<span class="tick-good">30</span><span class="tick-great">70</span>
|
|
62
|
+
<span class="tick-max">100</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
66
|
+
<% if @total.positive? && @total < Testimonials::NpsResponse::THIN_SAMPLE %>
|
|
67
|
+
<p class="muted nps-sample"><%= t('testimonials.dashboard.nps_sample_note', count: @total,
|
|
68
|
+
default: 'Based on %{count} responses — too few to read much into.') %></p>
|
|
44
69
|
<% end %>
|
|
45
70
|
</div>
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<div class="nps-meter-ticks" aria-hidden="true">
|
|
59
|
-
<span class="tick-min">−100</span><span class="tick-zero">0</span>
|
|
60
|
-
<span class="tick-good">30</span><span class="tick-great">70</span>
|
|
61
|
-
<span class="tick-max">100</span>
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
<% end %>
|
|
65
|
-
<% if @total.positive? && @total < Testimonials::NpsResponse::THIN_SAMPLE %>
|
|
66
|
-
<p class="muted nps-sample"><%= t('testimonials.dashboard.nps_sample_note', count: @total,
|
|
67
|
-
default: 'Based on %{count} responses — too few to read much into.') %></p>
|
|
68
|
-
<% end %>
|
|
69
|
-
</div>
|
|
70
|
-
<div class="stat">
|
|
71
|
-
<b><%= @promoters %></b>
|
|
72
|
-
<span><%= t('testimonials.dashboard.promoters', default: 'Promoters (9–10)') %></span>
|
|
73
|
-
</div>
|
|
74
|
-
<div class="stat">
|
|
75
|
-
<b><%= @passives %></b>
|
|
76
|
-
<span><%= t('testimonials.dashboard.passives', default: 'Passives (7–8)') %></span>
|
|
77
|
-
</div>
|
|
78
|
-
<div class="stat">
|
|
79
|
-
<b><%= @detractors %></b>
|
|
80
|
-
<span><%= t('testimonials.dashboard.detractors', default: 'Detractors (0–6)') %></span>
|
|
71
|
+
<div class="stat">
|
|
72
|
+
<b><%= @promoters %></b>
|
|
73
|
+
<span><%= t('testimonials.dashboard.promoters', default: 'Promoters (9–10)') %></span>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="stat">
|
|
76
|
+
<b><%= @passives %></b>
|
|
77
|
+
<span><%= t('testimonials.dashboard.passives', default: 'Passives (7–8)') %></span>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="stat">
|
|
80
|
+
<b><%= @detractors %></b>
|
|
81
|
+
<span><%= t('testimonials.dashboard.detractors', default: 'Detractors (0–6)') %></span>
|
|
82
|
+
</div>
|
|
81
83
|
</div>
|
|
82
|
-
</div>
|
|
83
84
|
|
|
84
|
-
<%# The formula and the benchmarks, closed by default — there when someone
|
|
85
|
-
|
|
86
|
-
<details class="nps-guide">
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
</details>
|
|
85
|
+
<%# The formula and the benchmarks, closed by default — there when someone
|
|
86
|
+
wonders whether their number is any good, out of the way the rest of the time. %>
|
|
87
|
+
<details class="nps-guide">
|
|
88
|
+
<summary><%= t('testimonials.dashboard.nps_guide', default: 'How is this calculated, and what is a good score?') %></summary>
|
|
89
|
+
<div class="nps-guide-body">
|
|
90
|
+
<p><%= t('testimonials.dashboard.nps_guide_formula',
|
|
91
|
+
default: 'NPS = the % of promoters minus the % of detractors. Passives count in the total but on ' \
|
|
92
|
+
'neither side, so they pull the score toward zero. The result runs from −100 to +100 — ' \
|
|
93
|
+
'a score, not a percentage.') %></p>
|
|
94
|
+
<ul class="nps-bands">
|
|
95
|
+
<% bands.each do |band| %>
|
|
96
|
+
<% badge_class = "badge nps-band-#{band[:key]}" %>
|
|
97
|
+
<li>
|
|
98
|
+
<span class="<%= badge_class %>"><%= band[:name] %></span>
|
|
99
|
+
<b><%= band[:range] %></b>
|
|
100
|
+
<span class="muted"><%= band[:note] %></span>
|
|
101
|
+
</li>
|
|
102
|
+
<% end %>
|
|
103
|
+
</ul>
|
|
104
|
+
<p class="muted"><%= t('testimonials.dashboard.nps_guide_caveat',
|
|
105
|
+
default: 'Benchmarks move with the industry — software often sits near 40, airlines ' \
|
|
106
|
+
'and internet providers near 0. The trend over time tells you more than any ' \
|
|
107
|
+
'single number, and the comments tell you more than the score.') %></p>
|
|
108
|
+
</div>
|
|
109
|
+
</details>
|
|
109
110
|
|
|
110
|
-
<div class="dashboard-shell <%= 'has-selected' if @selected_response %>">
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
<div class="dashboard-shell <%= 'has-selected' if @selected_response %>">
|
|
112
|
+
<aside class="dashboard-sidebar" aria-label="<%= t('testimonials.dashboard.nps_responses', default: 'NPS responses') %>">
|
|
113
|
+
<div class="record-list">
|
|
114
|
+
<% if @responses.any? %>
|
|
115
|
+
<% @responses.each do |response| %>
|
|
116
|
+
<%= link_to nps_responses_path(page: @page, response_id: response.id),
|
|
117
|
+
class: ['record-row nps-row', ('active' if @selected_response&.id == response.id)].compact do %>
|
|
118
|
+
<span class="record-main">
|
|
119
|
+
<span class="record-topline">
|
|
120
|
+
<span class="badge nps-<%= response.promoter? ? 'promoter' : (response.detractor? ? 'detractor' : 'passive') %>">
|
|
121
|
+
<%= response.score %>
|
|
122
|
+
</span>
|
|
123
|
+
<span class="muted record-time" title="<%= response.created_at %>">
|
|
124
|
+
<%= time_ago_in_words(response.created_at) %>
|
|
125
|
+
</span>
|
|
121
126
|
</span>
|
|
122
|
-
<span class="
|
|
123
|
-
|
|
127
|
+
<span class="record-copy"><%= response.comment.presence || t('testimonials.dashboard.no_comment', default: 'No comment') %></span>
|
|
128
|
+
<span class="muted record-meta">
|
|
129
|
+
<span>#<%= response.id %></span>
|
|
130
|
+
<% if response.name.present? || response.author_id.present? %>
|
|
131
|
+
<span><%= response.name.presence || "##{response.author_id}" %></span>
|
|
132
|
+
<% end %>
|
|
133
|
+
<% if response.email.present? %>
|
|
134
|
+
<span><%= response.email %></span>
|
|
135
|
+
<% end %>
|
|
124
136
|
</span>
|
|
125
137
|
</span>
|
|
126
|
-
|
|
127
|
-
<span class="muted record-meta">
|
|
128
|
-
<span>#<%= response.id %></span>
|
|
129
|
-
<% if response.name.present? || response.author_id.present? %>
|
|
130
|
-
<span><%= response.name.presence || "##{response.author_id}" %></span>
|
|
131
|
-
<% end %>
|
|
132
|
-
<% if response.email.present? %>
|
|
133
|
-
<span><%= response.email %></span>
|
|
134
|
-
<% end %>
|
|
135
|
-
</span>
|
|
136
|
-
</span>
|
|
138
|
+
<% end %>
|
|
137
139
|
<% end %>
|
|
140
|
+
<% else %>
|
|
141
|
+
<div class="empty"><%= t('testimonials.dashboard.empty', default: 'Nothing here yet.') %></div>
|
|
138
142
|
<% end %>
|
|
139
|
-
|
|
140
|
-
<div class="empty"><%= t('testimonials.dashboard.empty', default: 'Nothing here yet.') %></div>
|
|
141
|
-
<% end %>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<div class="pager">
|
|
145
|
-
<% if @page > 1 %>
|
|
146
|
-
<%= link_to t('testimonials.dashboard.newer', default: '← Newer'), nps_responses_path(page: @page - 1) %>
|
|
147
|
-
<% end %>
|
|
148
|
-
<% if @more %>
|
|
149
|
-
<%= link_to t('testimonials.dashboard.older', default: 'Older →'), nps_responses_path(page: @page + 1) %>
|
|
150
|
-
<% end %>
|
|
151
|
-
</div>
|
|
152
|
-
</aside>
|
|
143
|
+
</div>
|
|
153
144
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<% else %>
|
|
162
|
-
<div class="empty-state">
|
|
163
|
-
<h2><%= t('testimonials.dashboard.select_response', default: 'Select a response') %></h2>
|
|
164
|
-
<p class="muted"><%= t('testimonials.dashboard.select_response_hint',
|
|
165
|
-
default: 'Open a score from the list to review the comment and source details.') %></p>
|
|
145
|
+
<div class="pager">
|
|
146
|
+
<% if @page > 1 %>
|
|
147
|
+
<%= link_to t('testimonials.dashboard.newer', default: '← Newer'), nps_responses_path(page: @page - 1) %>
|
|
148
|
+
<% end %>
|
|
149
|
+
<% if @more %>
|
|
150
|
+
<%= link_to t('testimonials.dashboard.older', default: 'Older →'), nps_responses_path(page: @page + 1) %>
|
|
151
|
+
<% end %>
|
|
166
152
|
</div>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
153
|
+
</aside>
|
|
154
|
+
|
|
155
|
+
<main class="dashboard-detail" aria-label="<%= t('testimonials.dashboard.current_nps_response', default: 'Current NPS response') %>">
|
|
156
|
+
<% if @selected_response %>
|
|
157
|
+
<p class="mobile-back">
|
|
158
|
+
<%= link_to t('testimonials.dashboard.back_to_nps', default: '← All responses'),
|
|
159
|
+
nps_responses_path(page: @page) %>
|
|
160
|
+
</p>
|
|
161
|
+
<%= render 'response_panel', response: @selected_response %>
|
|
162
|
+
<% else %>
|
|
163
|
+
<div class="empty-state">
|
|
164
|
+
<h2><%= t('testimonials.dashboard.select_response', default: 'Select a response') %></h2>
|
|
165
|
+
<p class="muted"><%= t('testimonials.dashboard.select_response_hint',
|
|
166
|
+
default: 'Open a score from the list to review the comment and source details.') %></p>
|
|
167
|
+
</div>
|
|
168
|
+
<% end %>
|
|
169
|
+
</main>
|
|
170
|
+
</div>
|
|
171
|
+
<% end %>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<% content_for :body_class, 'tm-show' %>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
<%= render layout: 'testimonials/shared/dashboard' do %>
|
|
4
|
+
<div class="breadcrumb">
|
|
5
|
+
<%= link_to t('testimonials.dashboard.nps', default: 'NPS'), nps_responses_path %>
|
|
6
|
+
/ #<%= @response.id %>
|
|
7
|
+
</div>
|
|
7
8
|
|
|
8
|
-
<%= render 'response_panel', response: @response %>
|
|
9
|
+
<%= render 'response_panel', response: @response %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# The dashboard's own shell, rendered by the views rather than the layout.
|
|
2
|
+
That is deliberate: `config.admin_layout` lets a host replace the layout, and
|
|
3
|
+
assets declared in a layout the host replaces simply vanish — the dashboard
|
|
4
|
+
then renders unstyled with its delete confirmations and auto-submitting
|
|
5
|
+
filter dead. Declaring them here means every layout works, host or gem, and
|
|
6
|
+
a host has nothing to wire up.
|
|
7
|
+
|
|
8
|
+
The `tml-dashboard` wrapper is what scopes dashboard.css (see the comment at
|
|
9
|
+
the top of that file), so it has to enclose the content, not precede it. %>
|
|
10
|
+
<%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Testimonials::Widget.dashboard_stylesheet_fingerprint}",
|
|
11
|
+
'data-turbo-track': 'reload' %>
|
|
12
|
+
<%# Same-origin script instead of inline handlers, so delete confirms and the
|
|
13
|
+
auto-submitting filter work under strict script-src CSPs. %>
|
|
14
|
+
<%= javascript_include_tag "#{dashboard_script_path}?v=#{Testimonials::Widget.dashboard_fingerprint}",
|
|
15
|
+
'data-turbo-track': 'reload', defer: true, nonce: true %>
|
|
16
|
+
<div class="tml-dashboard">
|
|
17
|
+
<%= yield %>
|
|
18
|
+
</div>
|