testimonials 0.7.2 → 0.7.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9348e14150cc526771fea79c605b3e358104fa849575551a74ea4f022c2354a4
4
- data.tar.gz: a9a79e6cb2a89c4b94c29ddcd0101f75a851a896bc32fa8b28da9bd0063eaca0
3
+ metadata.gz: 34e1f1ffb9b1e7217fed8e7400d9fdc4daca3a55cbbd9de83cc0cf2673d56d34
4
+ data.tar.gz: 7a10eae3c313e84bcee8d214a27c6f7ee869fd72e2ad2d43fbf88fb5538278af
5
5
  SHA512:
6
- metadata.gz: 8791c9a58e51b6492c826d9680be4a9a3509a0e08b92e920b76d73e9d7d0a318884373e6d14d5bbc7dac86260cb12a38068e81cd988bf424535679e0fe8a180f
7
- data.tar.gz: 433e0412f79c440c1f79cac4858d72e70f22eae7558789e1ffc98282c5bab457b2fbb0b2d90f73f566e7ba36918b9048308e2e726eb389874997a8e27f26af7f
6
+ metadata.gz: 815b0b61911a5003afa4998cef859063d050a1695b110410070cad97d6f630ed223632ec35290a1c0996ee357860ce9967edfd328c009f3255cba05cd5afb009
7
+ data.tar.gz: c9abcab68c1a3a1e38d67a90c4cc2a01b98627b0df7f4be7405bee09fe4b66b99e0aaefe18ee6b667ae7709b0e88e6396980823512b504309438b81e03033c3d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.4
4
+
5
+ - Added `config.admin_layout`, letting host apps render the testimonial and NPS
6
+ dashboards inside their own admin layout while keeping the standalone gem
7
+ layout as the default.
8
+
9
+ ## 0.7.3
10
+
11
+ - Redesigned the testimonial and NPS admin dashboards into two-column review
12
+ layouts with status filters, selected-record panes, and refreshed
13
+ before/after screenshots.
14
+ - Kept standalone testimonial and NPS response show pages working
15
+ independently, including scrollable detail content on narrow screens.
16
+
3
17
  ## 0.7.2
4
18
 
5
19
  - Added `mount_testimonials at: "/testimonials"` as the install-time route
@@ -6,6 +6,10 @@ module Testimonials
6
6
 
7
7
  private
8
8
 
9
+ def testimonials_admin_layout
10
+ Testimonials.config.admin_layout
11
+ end
12
+
9
13
  def current_author
10
14
  return @current_author if defined?(@current_author)
11
15
 
@@ -5,9 +5,10 @@ module Testimonials
5
5
  class NpsResponsesController < ApplicationController
6
6
  PER_PAGE = 50
7
7
 
8
- layout 'testimonials/application'
8
+ layout :testimonials_admin_layout
9
9
 
10
10
  before_action :require_admin
11
+ before_action :set_response, only: :show
11
12
 
12
13
  def index
13
14
  scope = NpsResponse.for_tenant(current_tenant)
@@ -21,6 +22,16 @@ module Testimonials
21
22
  @responses = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
22
23
  @more = @responses.size > PER_PAGE
23
24
  @responses = @responses.first(PER_PAGE)
25
+
26
+ @selected_response = scope.find_by(id: params[:response_id]) if params[:response_id].present?
27
+ end
28
+
29
+ def show; end
30
+
31
+ private
32
+
33
+ def set_response
34
+ @response = NpsResponse.for_tenant(current_tenant).find(params[:id])
24
35
  end
25
36
  end
26
37
  end
@@ -4,7 +4,7 @@ module Testimonials
4
4
  class TestimonialsController < ApplicationController
5
5
  PER_PAGE = 50
6
6
 
7
- layout 'testimonials/application', except: :create
7
+ layout :testimonials_admin_layout, except: :create
8
8
 
9
9
  # create is the public widget endpoint; everything else is the dashboard.
10
10
  before_action :require_enabled, only: :create
@@ -32,6 +32,8 @@ module Testimonials
32
32
  @testimonials = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
33
33
  @more = @testimonials.size > PER_PAGE
34
34
  @testimonials = @testimonials.first(PER_PAGE)
35
+
36
+ @selected_testimonial = tenant_scope.find_by(id: params[:testimonial_id]) if params[:testimonial_id].present?
35
37
  end
36
38
 
37
39
  def show; end
@@ -12,7 +12,7 @@
12
12
  <%= javascript_include_tag "#{dashboard_script_path}?v=#{Testimonials::Widget.dashboard_fingerprint}",
13
13
  'data-turbo-track': 'reload', defer: true, nonce: true %>
14
14
  </head>
15
- <body>
15
+ <body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
16
16
  <div class="container">
17
17
  <div class="nav">
18
18
  <%= link_to t('testimonials.dashboard.title', default: 'Testimonials'), root_path,
@@ -0,0 +1,43 @@
1
+ <section class="nps-panel">
2
+ <div class="panel-head">
3
+ <h1 class="title-row">
4
+ <span class="badge nps-<%= response.promoter? ? 'promoter' : (response.detractor? ? 'detractor' : 'passive') %>">
5
+ <%= response.score %>
6
+ </span>
7
+ <span class="case-id">#<%= response.id %></span>
8
+ </h1>
9
+ </div>
10
+
11
+ <div class="detail-scroll">
12
+ <div class="card pad">
13
+ <dl>
14
+ <% if response.name.present? || response.author_id.present? %>
15
+ <dt><%= t('testimonials.dashboard.from', default: 'From') %></dt>
16
+ <dd><%= response.name.presence || "##{response.author_id}" %></dd>
17
+ <% end %>
18
+ <% if response.email.present? %>
19
+ <dt><%= t('testimonials.dashboard.email', default: 'Email') %></dt>
20
+ <dd><%= response.email %></dd>
21
+ <% end %>
22
+ <% if response.page_url.present? %>
23
+ <dt><%= t('testimonials.dashboard.page', default: 'Page') %></dt>
24
+ <dd><%= link_to response.page_url, response.page_url, target: '_blank', rel: 'noopener' %></dd>
25
+ <% end %>
26
+ <% if response.locale.present? %>
27
+ <dt><%= t('testimonials.dashboard.locale', default: 'Locale') %></dt>
28
+ <dd><%= response.locale %></dd>
29
+ <% end %>
30
+ <% if response.user_agent.present? %>
31
+ <dt><%= t('testimonials.dashboard.browser', default: 'Browser') %></dt>
32
+ <dd class="muted"><%= response.user_agent %></dd>
33
+ <% end %>
34
+ <dt><%= t('testimonials.dashboard.received', default: 'Received') %></dt>
35
+ <dd><%= response.created_at %></dd>
36
+ </dl>
37
+ </div>
38
+
39
+ <div class="card pad">
40
+ <p class="message"><%= response.comment.presence || t('testimonials.dashboard.no_comment', default: 'No comment') %></p>
41
+ </div>
42
+ </div>
43
+ </section>
@@ -1,3 +1,5 @@
1
+ <% content_for :body_class, 'tm-nps-index' %>
2
+
1
3
  <h1><%= t('testimonials.dashboard.nps', default: 'NPS') %></h1>
2
4
 
3
5
  <div class="stat-row">
@@ -19,49 +21,63 @@
19
21
  </div>
20
22
  </div>
21
23
 
22
- <div class="card">
23
- <% if @responses.any? %>
24
- <table>
25
- <thead>
26
- <tr>
27
- <th><%= t('testimonials.dashboard.score', default: 'Score') %></th>
28
- <th><%= t('testimonials.dashboard.comment', default: 'Comment') %></th>
29
- <th><%= t('testimonials.dashboard.from', default: 'From') %></th>
30
- <th><%= t('testimonials.dashboard.received', default: 'Received') %></th>
31
- </tr>
32
- </thead>
33
- <tbody>
24
+ <div class="dashboard-shell <%= 'has-selected' if @selected_response %>">
25
+ <aside class="dashboard-sidebar" aria-label="<%= t('testimonials.dashboard.nps_responses', default: 'NPS responses') %>">
26
+ <div class="record-list">
27
+ <% if @responses.any? %>
34
28
  <% @responses.each do |response| %>
35
- <tr>
36
- <td>
37
- <span class="badge nps-<%= response.promoter? ? 'promoter' : (response.detractor? ? 'detractor' : 'passive') %>">
38
- <%= response.score %>
29
+ <%= link_to nps_responses_path(page: @page, response_id: response.id),
30
+ class: ['record-row nps-row', ('active' if @selected_response&.id == response.id)].compact do %>
31
+ <span class="record-main">
32
+ <span class="record-topline">
33
+ <span class="badge nps-<%= response.promoter? ? 'promoter' : (response.detractor? ? 'detractor' : 'passive') %>">
34
+ <%= response.score %>
35
+ </span>
36
+ <span class="muted record-time" title="<%= response.created_at %>">
37
+ <%= time_ago_in_words(response.created_at) %>
38
+ </span>
39
+ </span>
40
+ <span class="record-copy"><%= response.comment.presence || t('testimonials.dashboard.no_comment', default: 'No comment') %></span>
41
+ <span class="muted record-meta">
42
+ <span>#<%= response.id %></span>
43
+ <% if response.name.present? || response.author_id.present? %>
44
+ <span><%= response.name.presence || "##{response.author_id}" %></span>
45
+ <% end %>
46
+ <% if response.email.present? %>
47
+ <span><%= response.email %></span>
48
+ <% end %>
39
49
  </span>
40
- </td>
41
- <td class="message"><%= response.comment %></td>
42
- <td>
43
- <%= response.name.presence || (response.author_id.present? ? "##{response.author_id}" : nil) %>
44
- <% if response.email.present? %>
45
- <div class="muted"><%= response.email %></div>
46
- <% end %>
47
- </td>
48
- <td class="muted" title="<%= response.created_at %>">
49
- <%= time_ago_in_words(response.created_at) %>
50
- </td>
51
- </tr>
50
+ </span>
51
+ <% end %>
52
52
  <% end %>
53
- </tbody>
54
- </table>
55
- <% else %>
56
- <div class="empty"><%= t('testimonials.dashboard.empty', default: 'Nothing here yet.') %></div>
57
- <% end %>
58
- </div>
53
+ <% else %>
54
+ <div class="empty"><%= t('testimonials.dashboard.empty', default: 'Nothing here yet.') %></div>
55
+ <% end %>
56
+ </div>
57
+
58
+ <div class="pager">
59
+ <% if @page > 1 %>
60
+ <%= link_to t('testimonials.dashboard.newer', default: '← Newer'), nps_responses_path(page: @page - 1) %>
61
+ <% end %>
62
+ <% if @more %>
63
+ <%= link_to t('testimonials.dashboard.older', default: 'Older →'), nps_responses_path(page: @page + 1) %>
64
+ <% end %>
65
+ </div>
66
+ </aside>
59
67
 
60
- <div class="pager">
61
- <% if @page > 1 %>
62
- <%= link_to t('testimonials.dashboard.newer', default: '← Newer'), nps_responses_path(page: @page - 1) %>
63
- <% end %>
64
- <% if @more %>
65
- <%= link_to t('testimonials.dashboard.older', default: 'Older →'), nps_responses_path(page: @page + 1) %>
66
- <% end %>
68
+ <main class="dashboard-detail" aria-label="<%= t('testimonials.dashboard.current_nps_response', default: 'Current NPS response') %>">
69
+ <% if @selected_response %>
70
+ <p class="mobile-back">
71
+ <%= link_to t('testimonials.dashboard.back_to_nps', default: '← All responses'),
72
+ nps_responses_path(page: @page) %>
73
+ </p>
74
+ <%= render 'response_panel', response: @selected_response %>
75
+ <% else %>
76
+ <div class="empty-state">
77
+ <h2><%= t('testimonials.dashboard.select_response', default: 'Select a response') %></h2>
78
+ <p class="muted"><%= t('testimonials.dashboard.select_response_hint',
79
+ default: 'Open a score from the list to review the comment and source details.') %></p>
80
+ </div>
81
+ <% end %>
82
+ </main>
67
83
  </div>
@@ -0,0 +1,8 @@
1
+ <% content_for :body_class, 'tm-show' %>
2
+
3
+ <div class="breadcrumb">
4
+ <%= link_to t('testimonials.dashboard.nps', default: 'NPS'), nps_responses_path %>
5
+ / #<%= @response.id %>
6
+ </div>
7
+
8
+ <%= render 'response_panel', response: @response %>
@@ -0,0 +1,111 @@
1
+ <section class="testimonial-panel">
2
+ <div class="panel-head">
3
+ <h1 class="title-row">
4
+ <span class="badge status-<%= testimonial.status %>">
5
+ <%= t("testimonials.statuses.#{testimonial.status}", default: testimonial.status.humanize) %>
6
+ </span>
7
+ <% if testimonial.featured? %>
8
+ <span class="badge status-approved">⭐ <%= t('testimonials.dashboard.featured', default: 'Featured') %></span>
9
+ <% end %>
10
+ <% if testimonial.rating %>
11
+ <span class="stars"><%= '★' * testimonial.rating %><span class="off"><%= '★' * (5 - testimonial.rating) %></span></span>
12
+ <% end %>
13
+ <span class="case-id">#<%= testimonial.id %></span>
14
+ </h1>
15
+ </div>
16
+
17
+ <div class="detail-scroll">
18
+ <div class="card pad">
19
+ <dl>
20
+ <dt><%= t('testimonials.dashboard.from', default: 'From') %></dt>
21
+ <dd>
22
+ <% if testimonial.avatar_attached? %>
23
+ <img class="avatar" src="<%= testimonial_avatar_path(testimonial) %>" alt="">
24
+ <% end %>
25
+ <%= testimonial.display_name %>
26
+ <% if testimonial.title_company.present? %> — <%= testimonial.title_company %><% end %>
27
+ </dd>
28
+ <% if testimonial.email.present? %>
29
+ <dt><%= t('testimonials.dashboard.email', default: 'Email') %></dt>
30
+ <dd><%= testimonial.email %></dd>
31
+ <% end %>
32
+ <dt><%= t('testimonials.dashboard.consent', default: 'Consent') %></dt>
33
+ <dd>
34
+ <% if testimonial.consent_given? %>
35
+ ✓ <span class="muted"><%= testimonial.consent_text %></span>
36
+ <% else %>
37
+ 🔒 <span class="muted"><%= testimonial.consent_text.presence ||
38
+ t('testimonials.dashboard.no_consent', default: 'Private use only. Not served by the read API.') %></span>
39
+ <% end %>
40
+ </dd>
41
+ <dt><%= t('testimonials.dashboard.source', default: 'Source') %></dt>
42
+ <dd><%= t("testimonials.sources.#{testimonial.source}", default: testimonial.source.humanize) %></dd>
43
+ <% if testimonial.page_url.present? %>
44
+ <dt><%= t('testimonials.dashboard.page', default: 'Page') %></dt>
45
+ <dd><%= link_to testimonial.page_url, testimonial.page_url, target: '_blank', rel: 'noopener' %></dd>
46
+ <% end %>
47
+ <% if testimonial.locale.present? %>
48
+ <dt><%= t('testimonials.dashboard.locale', default: 'Locale') %></dt>
49
+ <dd><%= testimonial.locale %></dd>
50
+ <% end %>
51
+ <% if testimonial.user_agent.present? %>
52
+ <dt><%= t('testimonials.dashboard.browser', default: 'Browser') %></dt>
53
+ <dd class="muted"><%= testimonial.user_agent %></dd>
54
+ <% end %>
55
+ <dt><%= t('testimonials.dashboard.received', default: 'Received') %></dt>
56
+ <dd><%= testimonial.created_at %></dd>
57
+ </dl>
58
+ </div>
59
+
60
+ <div class="card pad">
61
+ <% if testimonial.video_attached? %>
62
+ <%# The stored poster shows a real frame with no decoding — Safari won't
63
+ paint a fragmented-MP4 frame on its own, so preload/fragment tricks
64
+ leave it black. %>
65
+ <video class="playback" controls preload="metadata"
66
+ poster="<%= testimonial_poster_path(testimonial) if testimonial.poster_attached? %>"
67
+ src="<%= testimonial_video_path(testimonial) %>"></video>
68
+ <p class="muted">
69
+ <%= link_to "⬇ #{t('testimonials.dashboard.download_video', default: 'Download video')}",
70
+ testimonial_video_path(testimonial, download: 1) %>
71
+ </p>
72
+ <% end %>
73
+ <% if testimonial.body.present? %>
74
+ <p class="message"><%= testimonial.body %></p>
75
+ <% end %>
76
+
77
+ <%# The customer's words are never edited — but the admin picks the line
78
+ worth featuring. %>
79
+ <%= form_with model: testimonial, url: testimonial_path(testimonial), method: :patch,
80
+ class: 'best-line-form', local: true do |form| %>
81
+ <label for="testimonial_best_line">
82
+ <%= t('testimonials.dashboard.best_line', default: 'Best line') %>
83
+ </label>
84
+ <p class="muted best-line-hint">
85
+ <%= t('testimonials.dashboard.best_line_hint',
86
+ default: 'The quote your site will show. Leave blank to use the full text. For videos, type the best thing they said.') %>
87
+ </p>
88
+ <%= form.text_area :best_line, rows: 2, id: 'testimonial_best_line' %>
89
+ <button class="small best-line-save">
90
+ <%= t('testimonials.dashboard.save_best_line', default: 'Save') %>
91
+ </button>
92
+ <% end %>
93
+ </div>
94
+
95
+ <div class="actions">
96
+ <% (Testimonials::Testimonial::STATUSES - [testimonial.status]).each do |status| %>
97
+ <%= button_to t("testimonials.dashboard.move_to_#{status}", default: "Mark #{status.humanize.downcase}"),
98
+ testimonial_path(testimonial),
99
+ method: :patch, params: { testimonial: { status: status } },
100
+ class: ('primary' if status == 'approved') %>
101
+ <% end %>
102
+ <%= button_to (testimonial.featured? ? t('testimonials.dashboard.unfeature', default: 'Unfeature') : t('testimonials.dashboard.feature', default: 'Feature')),
103
+ testimonial_path(testimonial),
104
+ method: :patch, params: { testimonial: { featured: !testimonial.featured? } } %>
105
+ <%= button_to t('testimonials.dashboard.delete', default: 'Delete'),
106
+ testimonial_path(testimonial),
107
+ method: :delete, class: 'danger',
108
+ form: { data: { confirm: t('testimonials.dashboard.delete_confirm', default: 'Delete this testimonial?') } } %>
109
+ </div>
110
+ </div>
111
+ </section>
@@ -1,108 +1,114 @@
1
+ <% content_for :body_class, 'tm-index' %>
2
+
1
3
  <h1><%= t('testimonials.dashboard.title', default: 'Testimonials') %></h1>
2
4
 
3
- <div class="tabs">
4
- <% Testimonials::Testimonial::STATUSES.each do |status| %>
5
- <%= link_to testimonials_path(status: status, kind: @kind, q: @query),
6
- class: ('active' if status == @status) do %>
7
- <%= t("testimonials.statuses.#{status}", default: status.humanize) %>
8
- <span class="count"><%= @counts.fetch(status, 0) %></span>
9
- <% end %>
10
- <% end %>
11
- </div>
5
+ <div class="dashboard-shell <%= 'has-selected' if @selected_testimonial %>">
6
+ <aside class="dashboard-sidebar" aria-label="<%= t('testimonials.dashboard.testimonials', default: 'Testimonials') %>">
7
+ <div class="tabs">
8
+ <% Testimonials::Testimonial::STATUSES.each do |status| %>
9
+ <%= link_to testimonials_path(status: status, kind: @kind, q: @query),
10
+ class: ('active' if status == @status) do %>
11
+ <%= t("testimonials.statuses.#{status}", default: status.humanize) %>
12
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
13
+ <% end %>
14
+ <% end %>
15
+ </div>
12
16
 
13
- <form class="filters" method="get">
14
- <input type="hidden" name="status" value="<%= @status %>">
15
- <select name="kind" data-autosubmit="true">
16
- <option value=""><%= t('testimonials.dashboard.all_kinds', default: 'All kinds') %></option>
17
- <% Testimonials::Testimonial::KINDS.each do |kind| %>
18
- <option value="<%= kind %>" <%= 'selected' if kind == @kind %>>
19
- <%= t("testimonials.kinds.#{kind}", default: kind.humanize) %>
20
- </option>
21
- <% end %>
22
- </select>
23
- <input type="search" name="q" value="<%= @query %>"
24
- placeholder="<%= t('testimonials.dashboard.search_placeholder', default: 'Search text, name, email…') %>"
25
- aria-label="<%= t('testimonials.dashboard.search', default: 'Search') %>">
26
- <button><%= t('testimonials.dashboard.search', default: 'Search') %></button>
27
- </form>
17
+ <form class="filters" method="get">
18
+ <input type="hidden" name="status" value="<%= @status %>">
19
+ <select name="kind" data-autosubmit="true">
20
+ <option value=""><%= t('testimonials.dashboard.all_kinds', default: 'All kinds') %></option>
21
+ <% Testimonials::Testimonial::KINDS.each do |kind| %>
22
+ <option value="<%= kind %>" <%= 'selected' if kind == @kind %>>
23
+ <%= t("testimonials.kinds.#{kind}", default: kind.humanize) %>
24
+ </option>
25
+ <% end %>
26
+ </select>
27
+ <input type="search" name="q" value="<%= @query %>"
28
+ placeholder="<%= t('testimonials.dashboard.search_placeholder', default: 'Search text, name, email…') %>"
29
+ aria-label="<%= t('testimonials.dashboard.search', default: 'Search') %>">
30
+ <button><%= t('testimonials.dashboard.search', default: 'Search') %></button>
31
+ </form>
28
32
 
29
- <div class="card">
30
- <% if @testimonials.any? %>
31
- <table>
32
- <thead>
33
- <tr>
34
- <th><%= t('testimonials.dashboard.rating', default: 'Rating') %></th>
35
- <th><%= t('testimonials.dashboard.testimonial', default: 'Testimonial') %></th>
36
- <th><%= t('testimonials.dashboard.from', default: 'From') %></th>
37
- <th><%= t('testimonials.dashboard.consent', default: 'Consent') %></th>
38
- <th><%= t('testimonials.dashboard.received', default: 'Received') %></th>
39
- <th></th>
40
- </tr>
41
- </thead>
42
- <tbody>
33
+ <div class="record-list">
34
+ <% if @testimonials.any? %>
43
35
  <% @testimonials.each do |testimonial| %>
44
- <tr>
45
- <td>
46
- <% if testimonial.rating %>
47
- <span class="stars"><%= '' * testimonial.rating %><span class="off"><%= '★' * (5 - testimonial.rating) %></span></span>
48
- <% end %>
49
- </td>
50
- <td>
51
- <%= link_to testimonial_path(testimonial) do %>
36
+ <%= link_to testimonials_path(status: @status, kind: @kind, q: @query, page: @page,
37
+ testimonial_id: testimonial.id),
38
+ class: ['record-row testimonial-row',
39
+ ('active' if @selected_testimonial&.id == testimonial.id)].compact do %>
40
+ <span class="record-main">
41
+ <span class="record-topline">
42
+ <% if testimonial.rating %>
43
+ <span class="stars"><%= '★' * testimonial.rating %><span class="off"><%= '★' * (5 - testimonial.rating) %></span></span>
44
+ <% else %>
45
+ <span class="badge locale"><%= t("testimonials.kinds.#{testimonial.kind}", default: testimonial.kind.humanize) %></span>
46
+ <% end %>
47
+ <span class="badge status-<%= testimonial.status %>">
48
+ <%= t("testimonials.statuses.#{testimonial.status}", default: testimonial.status.humanize) %>
49
+ </span>
50
+ <span class="muted record-time" title="<%= testimonial.created_at %>">
51
+ <%= time_ago_in_words(testimonial.created_at) %>
52
+ </span>
53
+ </span>
54
+ <span class="record-copy">
52
55
  <% if testimonial.body.present? %>
53
- <%= truncate(testimonial.body, length: 110) %>
56
+ <%= truncate(testimonial.body, length: 120) %>
54
57
  <% else %>
55
58
  <em><%= t('testimonials.kinds.video', default: 'Video') %></em>
56
59
  <% end %>
57
- <% end %>
60
+ </span>
61
+ <span class="muted record-meta">
62
+ <span>#<%= testimonial.id %></span>
63
+ <% if testimonial.display_name.present? %>
64
+ <span><%= testimonial.display_name %></span>
65
+ <% end %>
66
+ <% if testimonial.title_company.present? %>
67
+ <span><%= testimonial.title_company %></span>
68
+ <% end %>
69
+ </span>
70
+ </span>
71
+ <span class="record-side">
58
72
  <% if testimonial.video_attached? %>
59
- <div class="muted">🎥 <%= t('testimonials.kinds.video', default: 'Video') %></div>
73
+ <span class="muted">🎥</span>
60
74
  <% end %>
61
75
  <% if testimonial.featured? %>
62
- <div class="muted">⭐ <%= t('testimonials.dashboard.featured', default: 'Featured') %></div>
63
- <% end %>
64
- </td>
65
- <td>
66
- <%= testimonial.display_name %>
67
- <% if testimonial.title_company.present? %>
68
- <div class="muted"><%= testimonial.title_company %></div>
69
- <% end %>
70
- </td>
71
- <td><%= testimonial.consent_given? ? '✓' : '—' %></td>
72
- <td class="muted" title="<%= testimonial.created_at %>">
73
- <%= time_ago_in_words(testimonial.created_at) %>
74
- </td>
75
- <td class="row-actions">
76
- <%# Inline status triage; feature / best line / delete live on the show page. %>
77
- <% unless testimonial.approved? %>
78
- <%= button_to t('testimonials.dashboard.move_to_approved', default: 'Approve'),
79
- testimonial_path(testimonial),
80
- method: :patch, params: { testimonial: { status: 'approved' } },
81
- class: 'small primary' %>
76
+ <span class="muted">⭐</span>
82
77
  <% end %>
83
- <% unless testimonial.archived? %>
84
- <%= button_to t('testimonials.dashboard.move_to_archived', default: 'Archive'),
85
- testimonial_path(testimonial),
86
- method: :patch, params: { testimonial: { status: 'archived' } },
87
- class: 'small' %>
88
- <% end %>
89
- </td>
90
- </tr>
78
+ <span class="muted"><%= testimonial.consent_given? ? '✓' : '—' %></span>
79
+ </span>
80
+ <% end %>
91
81
  <% end %>
92
- </tbody>
93
- </table>
94
- <% else %>
95
- <div class="empty"><%= t('testimonials.dashboard.empty', default: 'Nothing here yet.') %></div>
96
- <% end %>
97
- </div>
82
+ <% else %>
83
+ <div class="empty"><%= t('testimonials.dashboard.empty', default: 'Nothing here yet.') %></div>
84
+ <% end %>
85
+ </div>
98
86
 
99
- <div class="pager">
100
- <% if @page > 1 %>
101
- <%= link_to t('testimonials.dashboard.newer', default: '← Newer'),
102
- testimonials_path(status: @status, kind: @kind, q: @query, page: @page - 1) %>
103
- <% end %>
104
- <% if @more %>
105
- <%= link_to t('testimonials.dashboard.older', default: 'Older →'),
106
- testimonials_path(status: @status, kind: @kind, q: @query, page: @page + 1) %>
107
- <% end %>
87
+ <div class="pager">
88
+ <% if @page > 1 %>
89
+ <%= link_to t('testimonials.dashboard.newer', default: '← Newer'),
90
+ testimonials_path(status: @status, kind: @kind, q: @query, page: @page - 1) %>
91
+ <% end %>
92
+ <% if @more %>
93
+ <%= link_to t('testimonials.dashboard.older', default: 'Older →'),
94
+ testimonials_path(status: @status, kind: @kind, q: @query, page: @page + 1) %>
95
+ <% end %>
96
+ </div>
97
+ </aside>
98
+
99
+ <main class="dashboard-detail" aria-label="<%= t('testimonials.dashboard.current_testimonial', default: 'Current testimonial') %>">
100
+ <% if @selected_testimonial %>
101
+ <p class="mobile-back">
102
+ <%= link_to t('testimonials.dashboard.back', default: '← All testimonials'),
103
+ testimonials_path(status: @status, kind: @kind, q: @query, page: @page) %>
104
+ </p>
105
+ <%= render 'testimonial_panel', testimonial: @selected_testimonial %>
106
+ <% else %>
107
+ <div class="empty-state">
108
+ <h2><%= t('testimonials.dashboard.select_testimonial', default: 'Select a testimonial') %></h2>
109
+ <p class="muted"><%= t('testimonials.dashboard.select_testimonial_hint',
110
+ default: 'Open a review from the list to approve, feature, or tune the best line.') %></p>
111
+ </div>
112
+ <% end %>
113
+ </main>
108
114
  </div>
@@ -1,109 +1,8 @@
1
+ <% content_for :body_class, 'tm-show' %>
2
+
1
3
  <div class="breadcrumb">
2
4
  <%= link_to t('testimonials.dashboard.title', default: 'Testimonials'), root_path %>
3
5
  / #<%= @testimonial.id %>
4
6
  </div>
5
7
 
6
- <h1 class="title-row">
7
- <span class="badge status-<%= @testimonial.status %>">
8
- <%= t("testimonials.statuses.#{@testimonial.status}", default: @testimonial.status.humanize) %>
9
- </span>
10
- <% if @testimonial.featured? %>
11
- <span class="badge status-approved">⭐ <%= t('testimonials.dashboard.featured', default: 'Featured') %></span>
12
- <% end %>
13
- <% if @testimonial.rating %>
14
- <span class="stars"><%= '★' * @testimonial.rating %><span class="off"><%= '★' * (5 - @testimonial.rating) %></span></span>
15
- <% end %>
16
- </h1>
17
-
18
- <div class="card pad" style="margin-bottom: 16px;">
19
- <% if @testimonial.video_attached? %>
20
- <%# The stored poster shows a real frame with no decoding — Safari won't
21
- paint a fragmented-MP4 frame on its own, so preload/fragment tricks
22
- leave it black. %>
23
- <video class="playback" controls preload="metadata"
24
- poster="<%= testimonial_poster_path(@testimonial) if @testimonial.poster_attached? %>"
25
- src="<%= testimonial_video_path(@testimonial) %>"></video>
26
- <p class="muted">
27
- <%= link_to "⬇ #{t('testimonials.dashboard.download_video', default: 'Download video')}",
28
- testimonial_video_path(@testimonial, download: 1) %>
29
- </p>
30
- <% end %>
31
- <% if @testimonial.body.present? %>
32
- <p class="message"><%= @testimonial.body %></p>
33
- <% end %>
34
-
35
- <%# The customer's words are never edited — but the admin picks the line
36
- worth featuring. %>
37
- <%= form_with model: @testimonial, url: testimonial_path(@testimonial), method: :patch,
38
- class: 'best-line-form', local: true do |form| %>
39
- <label for="testimonial_best_line">
40
- <%= t('testimonials.dashboard.best_line', default: 'Best line') %>
41
- </label>
42
- <p class="muted" style="margin: 2px 0 6px;">
43
- <%= t('testimonials.dashboard.best_line_hint',
44
- default: 'The quote your site will show. Leave blank to use the full text. For videos, type the best thing they said.') %>
45
- </p>
46
- <%= form.text_area :best_line, rows: 2, id: 'testimonial_best_line' %>
47
- <button class="small" style="margin-top: 8px;">
48
- <%= t('testimonials.dashboard.save_best_line', default: 'Save') %>
49
- </button>
50
- <% end %>
51
- </div>
52
-
53
- <div class="card pad" style="margin-bottom: 16px;">
54
- <dl>
55
- <dt><%= t('testimonials.dashboard.from', default: 'From') %></dt>
56
- <dd>
57
- <% if @testimonial.avatar_attached? %>
58
- <img class="avatar" src="<%= testimonial_avatar_path(@testimonial) %>" alt="">
59
- <% end %>
60
- <%= @testimonial.display_name %>
61
- <% if @testimonial.title_company.present? %> — <%= @testimonial.title_company %><% end %>
62
- </dd>
63
- <% if @testimonial.email.present? %>
64
- <dt><%= t('testimonials.dashboard.email', default: 'Email') %></dt>
65
- <dd><%= @testimonial.email %></dd>
66
- <% end %>
67
- <dt><%= t('testimonials.dashboard.consent', default: 'Consent') %></dt>
68
- <dd>
69
- <% if @testimonial.consent_given? %>
70
- ✓ <span class="muted"><%= @testimonial.consent_text %></span>
71
- <% else %>
72
- 🔒 <span class="muted"><%= @testimonial.consent_text.presence ||
73
- t('testimonials.dashboard.no_consent', default: 'Private use only. Not served by the read API.') %></span>
74
- <% end %>
75
- </dd>
76
- <dt><%= t('testimonials.dashboard.source', default: 'Source') %></dt>
77
- <dd><%= t("testimonials.sources.#{@testimonial.source}", default: @testimonial.source.humanize) %></dd>
78
- <% if @testimonial.page_url.present? %>
79
- <dt><%= t('testimonials.dashboard.page', default: 'Page') %></dt>
80
- <dd><%= link_to @testimonial.page_url, @testimonial.page_url, target: '_blank', rel: 'noopener' %></dd>
81
- <% end %>
82
- <% if @testimonial.locale.present? %>
83
- <dt><%= t('testimonials.dashboard.locale', default: 'Locale') %></dt>
84
- <dd><%= @testimonial.locale %></dd>
85
- <% end %>
86
- <% if @testimonial.user_agent.present? %>
87
- <dt><%= t('testimonials.dashboard.browser', default: 'Browser') %></dt>
88
- <dd class="muted"><%= @testimonial.user_agent %></dd>
89
- <% end %>
90
- <dt><%= t('testimonials.dashboard.received', default: 'Received') %></dt>
91
- <dd><%= @testimonial.created_at %></dd>
92
- </dl>
93
- </div>
94
-
95
- <div class="actions">
96
- <% (Testimonials::Testimonial::STATUSES - [@testimonial.status]).each do |status| %>
97
- <%= button_to t("testimonials.dashboard.move_to_#{status}", default: "Mark #{status.humanize.downcase}"),
98
- testimonial_path(@testimonial),
99
- method: :patch, params: { testimonial: { status: status } },
100
- class: ('primary' if status == 'approved') %>
101
- <% end %>
102
- <%= button_to (@testimonial.featured? ? t('testimonials.dashboard.unfeature', default: 'Unfeature') : t('testimonials.dashboard.feature', default: 'Feature')),
103
- testimonial_path(@testimonial),
104
- method: :patch, params: { testimonial: { featured: !@testimonial.featured? } } %>
105
- <%= button_to t('testimonials.dashboard.delete', default: 'Delete'),
106
- testimonial_path(@testimonial),
107
- method: :delete, class: 'danger',
108
- form: { data: { confirm: t('testimonials.dashboard.delete_confirm', default: 'Delete this testimonial?') } } %>
109
- </div>
8
+ <%= render 'testimonial_panel', testimonial: @testimonial %>
data/config/routes.rb CHANGED
@@ -9,7 +9,7 @@ Testimonials::Engine.routes.draw do
9
9
  get 'new', to: 'collection#show', as: :collection
10
10
  resources :events, only: :create
11
11
  resource :nps, only: :create, controller: 'nps'
12
- resources :nps_responses, only: :index
12
+ resources :nps_responses, only: %i[index show]
13
13
 
14
14
  # Read-only JSON for rendering testimonials anywhere. Plain REST: the
15
15
  # collection is a noun. Under a same-named mount this reads
@@ -12,6 +12,10 @@ Testimonials.configure do |config|
12
12
  # Who can triage testimonials at the mount path. Defaults to development
13
13
  # only — override before deploying.
14
14
  # config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
15
+ #
16
+ # Render the dashboard inside your app's admin layout. Default: the gem's
17
+ # standalone dashboard layout.
18
+ # config.admin_layout = "admin/application"
15
19
 
16
20
  # Attribute submissions to a user (optional). Return an object responding
17
21
  # to #id, or nil. Receives the request.
@@ -17,6 +17,10 @@ module Testimonials
17
17
  # only — override it before deploying, e.g. with an admin check.
18
18
  attr_accessor :authorize_admin
19
19
 
20
+ # Layout used by the built-in dashboard. Override this to render
21
+ # Testimonials inside your app's admin shell, e.g. "admin/application".
22
+ attr_accessor :admin_layout
23
+
20
24
  # Resolve the current user for attribution (optional). Return an object
21
25
  # responding to #id, or nil. Receives the request.
22
26
  attr_accessor :current_user
@@ -104,6 +108,7 @@ module Testimonials
104
108
  @app_name = nil
105
109
  @enabled = ->(_request) { true }
106
110
  @authorize_admin = ->(_request) { Rails.env.development? }
111
+ @admin_layout = 'testimonials/application'
107
112
  @current_user = ->(_request) {}
108
113
  @tenant = ->(_request) {}
109
114
  @user_display = lambda { |user|
@@ -53,6 +53,7 @@ tr + tr td { border-top: 1px solid var(--border); }
53
53
  .badge.nps-promoter { background: var(--promoter); }
54
54
  .badge.nps-passive { background: var(--passive); }
55
55
  .badge.nps-detractor { background: var(--detractor); }
56
+ .badge.locale { background: transparent; color: var(--muted); border: 1px solid var(--border); }
56
57
  .stars { color: var(--star); letter-spacing: 1px; white-space: nowrap; }
57
58
  .stars .off { color: var(--border); }
58
59
  .muted { color: var(--muted); font-size: 13px; }
@@ -67,7 +68,7 @@ button, .button { padding: 8px 14px; border: 1px solid var(--border); border-rad
67
68
  button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
68
69
  button.danger { color: var(--danger); }
69
70
  button.small { padding: 3px 10px; font-size: 13px; }
70
- .message { white-space: pre-wrap; overflow-wrap: anywhere; }
71
+ .message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
71
72
  dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
72
73
  dt { color: var(--muted); }
73
74
  dd { margin: 0; overflow-wrap: anywhere; }
@@ -75,6 +76,7 @@ video.playback { width: 100%; max-width: 480px; border-radius: 10px; background:
75
76
  img.avatar { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; }
76
77
  .empty { padding: 48px 16px; text-align: center; color: var(--muted); }
77
78
  .breadcrumb { margin-bottom: 12px; font-size: 13px; }
79
+ .case-id { color: var(--muted); font-weight: 400; font-size: 15px; }
78
80
  .stat-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
79
81
  .stat { flex: 1; min-width: 140px; background: var(--surface); border: 1px solid var(--border);
80
82
  border-radius: 12px; padding: 14px 16px; }
@@ -82,3 +84,100 @@ img.avatar { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; }
82
84
  .stat span { color: var(--muted); font-size: 13px; }
83
85
  .best-line-form textarea { width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 8px;
84
86
  background: var(--surface); color: var(--text); font: inherit; resize: vertical; }
87
+ .best-line-hint { margin: 2px 0 6px; }
88
+ .best-line-save { margin-top: 8px; }
89
+ .panel-head { flex: 0 0 auto; }
90
+ .panel-head h1 { margin-bottom: 12px; }
91
+ .detail-scroll { min-height: 0; display: flex; flex-direction: column; gap: 12px; }
92
+ .testimonial-panel, .nps-panel { min-width: 0; display: flex; flex-direction: column; }
93
+ .testimonial-panel .actions { flex: 0 0 auto; }
94
+ .testimonial-panel .detail-scroll > .actions { margin: 0; }
95
+
96
+ /* Desktop dashboard: queue on the left, selected record on the right. */
97
+ .tm-index, .tm-index .container,
98
+ .tm-nps-index, .tm-nps-index .container { height: 100vh; height: 100dvh; }
99
+ .tm-index, .tm-nps-index { overflow: hidden; }
100
+ .tm-index .container, .tm-nps-index .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
101
+ .tm-index .nav, .tm-nps-index .nav,
102
+ .tm-index .container > h1, .tm-nps-index .container > h1,
103
+ .tm-nps-index .stat-row { flex: 0 0 auto; }
104
+ .tm-index .container > h1, .tm-nps-index .container > h1 { margin-bottom: 14px; }
105
+ .tm-nps-index .stat-row { margin-bottom: 14px; }
106
+ .dashboard-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(330px, 430px) 1fr;
107
+ gap: 16px; }
108
+ .dashboard-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column;
109
+ background: var(--surface); border: 1px solid var(--border);
110
+ border-radius: 8px; overflow: hidden; }
111
+ .dashboard-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent;
112
+ border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
113
+ .dashboard-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center;
114
+ justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px;
115
+ border-radius: 8px; color: var(--muted); font-weight: 600; }
116
+ .dashboard-sidebar .tabs a.active { color: var(--text); border: 0; background: transparent; box-shadow: none; margin: 0; }
117
+ .dashboard-sidebar .tabs a.active::after { content: ""; position: absolute; left: 20px; right: 20px; bottom: 0;
118
+ height: 2px; border-radius: 999px; background: var(--accent); }
119
+ .dashboard-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--text); background: var(--bg); }
120
+ .dashboard-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--muted) 14%, transparent); }
121
+ .dashboard-sidebar .tabs a.active .count { background: var(--border); }
122
+ .dashboard-sidebar .filters { flex: 0 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto;
123
+ margin: 0; padding: 12px; border-bottom: 1px solid var(--border); }
124
+ .dashboard-sidebar .filters select { grid-column: 1 / -1; width: 100%; }
125
+ .dashboard-sidebar .filters input[type=search] { min-width: 0; max-width: none; }
126
+ .record-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
127
+ .record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px;
128
+ color: var(--text); border-bottom: 1px solid var(--border); }
129
+ .record-row:hover { text-decoration: none; background: var(--bg); }
130
+ .record-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface));
131
+ box-shadow: inset 3px 0 0 var(--accent); }
132
+ .record-main { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
133
+ .record-topline, .record-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
134
+ .record-copy, .record-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
135
+ .record-copy { display: block; font-weight: 600; }
136
+ .record-time { margin-left: auto; white-space: nowrap; }
137
+ .record-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
138
+ gap: 8px; padding-top: 1px; }
139
+ .dashboard-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); }
140
+ .dashboard-detail { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
141
+ .dashboard-detail .testimonial-panel,
142
+ .dashboard-detail .nps-panel { flex: 1 1 auto; min-height: 0; }
143
+ .dashboard-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
144
+ .dashboard-detail .actions { margin-bottom: 0; }
145
+ .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
146
+ .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
147
+ .empty-state p { margin: 0; }
148
+ .mobile-back { display: none; }
149
+
150
+ /* Standalone show pages keep normal page scrolling. */
151
+ .tm-show { min-height: 100vh; overflow: auto; }
152
+ .tm-show .detail-scroll { overflow: visible; }
153
+ @media (max-width: 760px) {
154
+ body { font-size: 14px; }
155
+ .container { padding: 14px 10px 24px; }
156
+ .tm-index, .tm-nps-index, .tm-show { overflow-x: hidden; }
157
+ .dashboard-detail, .testimonial-panel, .nps-panel, .detail-scroll, .card, dl, dd,
158
+ .best-line-form, .best-line-form textarea { min-width: 0; max-width: 100%; }
159
+ .testimonial-panel .card, .nps-panel .card { width: calc(100vw - 20px); }
160
+ .best-line-form textarea { width: 100%; }
161
+ .message, .best-line-hint, dd, .muted { overflow-wrap: anywhere; }
162
+ .message, .best-line-hint, dd, .muted { max-width: calc(100vw - 54px); }
163
+ .tm-index, .tm-nps-index { overflow-y: auto; overflow-x: hidden; }
164
+ .tm-index, .tm-index .container,
165
+ .tm-nps-index, .tm-nps-index .container { min-height: 100vh; height: auto; }
166
+ .tm-index .container, .tm-nps-index .container { padding-bottom: 10px; }
167
+ .dashboard-shell { display: block; min-height: calc(100vh - 76px); }
168
+ .dashboard-sidebar, .dashboard-detail { min-height: calc(100vh - 76px); }
169
+ .dashboard-shell.has-selected .dashboard-sidebar { display: none; }
170
+ .dashboard-shell:not(.has-selected) .dashboard-detail { display: none; }
171
+ .record-list { overflow: visible; }
172
+ .record-row { padding: 12px; }
173
+ .record-meta { display: none; }
174
+ .dashboard-detail .testimonial-panel,
175
+ .dashboard-detail .nps-panel { min-height: calc(100vh - 64px); }
176
+ .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
177
+ .panel-head h1 { font-size: 18px; }
178
+ dl { grid-template-columns: 1fr; gap: 3px; }
179
+ .stat-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
180
+ .stat { min-width: 0; padding: 10px 12px; }
181
+ .stat b { font-size: 22px; }
182
+ .share-link { flex: 1 0 100%; margin-left: 0; text-align: center; }
183
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- VERSION = '0.7.2'
4
+ VERSION = '0.7.4'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: testimonials
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -61,7 +61,10 @@ files:
61
61
  - app/views/layouts/testimonials/application.html.erb
62
62
  - app/views/layouts/testimonials/collection.html.erb
63
63
  - app/views/testimonials/collection/show.html.erb
64
+ - app/views/testimonials/nps_responses/_response_panel.html.erb
64
65
  - app/views/testimonials/nps_responses/index.html.erb
66
+ - app/views/testimonials/nps_responses/show.html.erb
67
+ - app/views/testimonials/testimonials/_testimonial_panel.html.erb
65
68
  - app/views/testimonials/testimonials/index.html.erb
66
69
  - app/views/testimonials/testimonials/show.html.erb
67
70
  - config/locales/testimonials.ar.yml