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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +4 -1
  3. data/CHANGELOG.md +66 -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 -4
  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/templates/add_tenant_to_testimonials.rb.tt +0 -1
  27. data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
  28. data/lib/testimonials/configuration.rb +24 -1
  29. data/lib/testimonials/dashboard.css +278 -221
  30. data/lib/testimonials/version.rb +1 -1
  31. data/lib/testimonials.rb +8 -0
  32. metadata +6 -1
@@ -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
@@ -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>