testimonials 0.7.10 → 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 +4 -1
- data/CHANGELOG.md +45 -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 -3
- 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/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
|
@@ -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>
|