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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +4 -1
  3. data/CHANGELOG.md +45 -0
  4. data/README.md +2 -0
  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/nps_responses_controller.rb +1 -4
  9. data/app/controllers/testimonials/submissions_controller.rb +169 -0
  10. data/app/controllers/testimonials/testimonials_controller.rb +6 -166
  11. data/app/views/layouts/testimonials/application.html.erb +5 -8
  12. data/app/views/testimonials/nps_responses/index.html.erb +154 -152
  13. data/app/views/testimonials/nps_responses/show.html.erb +7 -5
  14. data/app/views/testimonials/shared/_dashboard.html.erb +18 -0
  15. data/app/views/testimonials/testimonials/index.html.erb +110 -108
  16. data/app/views/testimonials/testimonials/show.html.erb +7 -5
  17. data/config/routes.rb +18 -5
  18. data/lib/generators/testimonials/install/install_generator.rb +2 -6
  19. data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +3 -3
  20. data/lib/generators/testimonials/install/templates/initializer.rb.tt +10 -2
  21. data/lib/generators/testimonials/migration_helpers.rb +35 -0
  22. data/lib/generators/testimonials/nps/nps_generator.rb +2 -6
  23. data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +1 -1
  24. data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +2 -6
  25. data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +1 -1
  26. data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
  27. data/lib/testimonials/configuration.rb +24 -1
  28. data/lib/testimonials/dashboard.css +278 -221
  29. data/lib/testimonials/version.rb +1 -1
  30. data/lib/testimonials.rb +8 -0
  31. 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
- <%# 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 %>
@@ -1,169 +1,171 @@
1
1
  <% content_for :body_class, 'tm-nps-index' %>
2
2
 
3
- <div class="page-head">
4
- <h1><%= t('testimonials.dashboard.nps', default: 'NPS') %></h1>
5
- <%# The shareable NPS page, same idea as the testimonial one. config.nps is
6
- already true here the controller 404s otherwise so only the public
7
- switch is left to check. %>
8
- <% if Testimonials.config.public_collection %>
9
- <a class="button share-link" href="<%= nps_collection_url %>" target="_blank" rel="noopener"
10
- title="<%= nps_collection_url %>">
11
- 🔗 <%= t('testimonials.dashboard.nps_page', default: 'Ask for NPS scores') %>
12
- </a>
13
- <% end %>
14
- </div>
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
- labels are digits on purpose — no translation needed for "< 0". %>
18
- <% bands = [
19
- { key: 'needs-work', range: '< 0',
20
- name: t('testimonials.dashboard.nps_band_needs_work', default: 'Needs work'),
21
- note: t('testimonials.dashboard.nps_guide_needs_work', default: 'More detractors than promoters.') },
22
- { key: 'good', range: '0 – 29',
23
- name: t('testimonials.dashboard.nps_band_good', default: 'Good'),
24
- note: t('testimonials.dashboard.nps_guide_good', default: 'Where most companies land.') },
25
- { key: 'great', range: '30 – 69',
26
- name: t('testimonials.dashboard.nps_band_great', default: 'Great'),
27
- note: t('testimonials.dashboard.nps_guide_great', default: 'Strong loyalty.') },
28
- { key: 'excellent', range: '70 +',
29
- name: t('testimonials.dashboard.nps_band_excellent', default: 'Excellent'),
30
- note: t('testimonials.dashboard.nps_guide_excellent', default: 'World-class, and rare.') }
31
- ] %>
32
- <% current = bands.find { |b| b[:key] == @band.to_s.tr('_', '-') } if @band %>
33
- <% if current
34
- band_class = "badge nps-band-#{current[:key]}"
35
- meter_label = "#{@score} — #{current[:name]} #{current[:note]}"
36
- end %>
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
- <div class="stat stat-nps">
40
- <div class="stat-headline">
41
- <b><%= @score.nil? ? '—' : @score %></b>
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
- <span class="<%= band_class %>"><%= current[:name] %></span>
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
- <span><%= t('testimonials.dashboard.nps_score', default: 'NPS score') %></span>
47
- <%# Where this score sits on the −100..100 scale, so the number reads as
48
- good or bad at a glance. Kept LTR: it is a number line, not prose. %>
49
- <% if current %>
50
- <div class="nps-meter" role="img" aria-label="<%= meter_label %>">
51
- <div class="nps-meter-track">
52
- <i class="nps-seg nps-band-needs-work"></i><i class="nps-seg nps-band-good"></i>
53
- <i class="nps-seg nps-band-great"></i><i class="nps-seg nps-band-excellent"></i>
54
- <%# Position is data, not markup: dashboard.js places it, so a strict
55
- style-src has no inline style to refuse. %>
56
- <i class="nps-marker" data-nps-marker="<%= @score %>"></i>
57
- </div>
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
- wonders whether their number is any good, out of the way the rest of the time. %>
86
- <details class="nps-guide">
87
- <summary><%= t('testimonials.dashboard.nps_guide', default: 'How is this calculated, and what is a good score?') %></summary>
88
- <div class="nps-guide-body">
89
- <p><%= t('testimonials.dashboard.nps_guide_formula',
90
- default: 'NPS = the % of promoters minus the % of detractors. Passives count in the total but on ' \
91
- 'neither side, so they pull the score toward zero. The result runs from −100 to +100 — ' \
92
- 'a score, not a percentage.') %></p>
93
- <ul class="nps-bands">
94
- <% bands.each do |band| %>
95
- <% badge_class = "badge nps-band-#{band[:key]}" %>
96
- <li>
97
- <span class="<%= badge_class %>"><%= band[:name] %></span>
98
- <b><%= band[:range] %></b>
99
- <span class="muted"><%= band[:note] %></span>
100
- </li>
101
- <% end %>
102
- </ul>
103
- <p class="muted"><%= t('testimonials.dashboard.nps_guide_caveat',
104
- default: 'Benchmarks move with the industry — software often sits near 40, airlines ' \
105
- 'and internet providers near 0. The trend over time tells you more than any ' \
106
- 'single number, and the comments tell you more than the score.') %></p>
107
- </div>
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
- <aside class="dashboard-sidebar" aria-label="<%= t('testimonials.dashboard.nps_responses', default: 'NPS responses') %>">
112
- <div class="record-list">
113
- <% if @responses.any? %>
114
- <% @responses.each do |response| %>
115
- <%= link_to nps_responses_path(page: @page, response_id: response.id),
116
- class: ['record-row nps-row', ('active' if @selected_response&.id == response.id)].compact do %>
117
- <span class="record-main">
118
- <span class="record-topline">
119
- <span class="badge nps-<%= response.promoter? ? 'promoter' : (response.detractor? ? 'detractor' : 'passive') %>">
120
- <%= response.score %>
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="muted record-time" title="<%= response.created_at %>">
123
- <%= time_ago_in_words(response.created_at) %>
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
- <span class="record-copy"><%= response.comment.presence || t('testimonials.dashboard.no_comment', default: 'No comment') %></span>
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
- <% else %>
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
- <main class="dashboard-detail" aria-label="<%= t('testimonials.dashboard.current_nps_response', default: 'Current NPS response') %>">
155
- <% if @selected_response %>
156
- <p class="mobile-back">
157
- <%= link_to t('testimonials.dashboard.back_to_nps', default: '← All responses'),
158
- nps_responses_path(page: @page) %>
159
- </p>
160
- <%= render 'response_panel', response: @selected_response %>
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
- <% end %>
168
- </main>
169
- </div>
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
- <div class="breadcrumb">
4
- <%= link_to t('testimonials.dashboard.nps', default: 'NPS'), nps_responses_path %>
5
- / #<%= @response.id %>
6
- </div>
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>