testimonials 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/app/controllers/testimonials/nps_responses_controller.rb +11 -0
- data/app/controllers/testimonials/testimonials_controller.rb +2 -0
- data/app/controllers/testimonials/widgets_controller.rb +8 -4
- data/app/views/layouts/testimonials/application.html.erb +5 -88
- data/app/views/layouts/testimonials/collection.html.erb +1 -0
- data/app/views/testimonials/nps_responses/_response_panel.html.erb +43 -0
- data/app/views/testimonials/nps_responses/index.html.erb +57 -41
- data/app/views/testimonials/nps_responses/show.html.erb +8 -0
- data/app/views/testimonials/testimonials/_testimonial_panel.html.erb +111 -0
- data/app/views/testimonials/testimonials/index.html.erb +98 -92
- data/app/views/testimonials/testimonials/show.html.erb +3 -104
- data/config/routes.rb +2 -1
- data/lib/generators/testimonials/install/install_generator.rb +1 -1
- data/lib/generators/testimonials/install/templates/initializer.rb +2 -1
- data/lib/testimonials/dashboard.css +183 -0
- data/lib/testimonials/engine.rb +9 -0
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.rb +13 -5
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5bf8a4df33767c43e6a84f87a635254b995a1754f6b01e0e80103fcf1234ae4
|
|
4
|
+
data.tar.gz: 89c4e0dc0c5a159f5c138c9034d8a2a4cd3f970f16fffda26b305fa4a134ac57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3feb8fe03281e68cd2c380c7273e4ec3d2c273b04bd69af55f88ded834b9501ccebfa5ca21037daf981cc245b057d041b90b80213b6104274b746795d40693d
|
|
7
|
+
data.tar.gz: 61ba044530d515dabdc3b9c445bc13b21067ec199874fdbdf74eb4c0299d09feeb029995193de26186a9008881cb7e089265056706f8d6aba60cd42fb47f3358
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.3
|
|
4
|
+
|
|
5
|
+
- Redesigned the testimonial and NPS admin dashboards into two-column review
|
|
6
|
+
layouts with status filters, selected-record panes, and refreshed
|
|
7
|
+
before/after screenshots.
|
|
8
|
+
- Kept standalone testimonial and NPS response show pages working
|
|
9
|
+
independently, including scrollable detail content on narrow screens.
|
|
10
|
+
|
|
11
|
+
## 0.7.2
|
|
12
|
+
|
|
13
|
+
- Added `mount_testimonials at: "/testimonials"` as the install-time route
|
|
14
|
+
helper. It keeps `config.mount_path` synchronized with the mounted engine
|
|
15
|
+
path while preserving manual `mount Testimonials::Engine` compatibility.
|
|
16
|
+
- Extracted the dashboard stylesheet into a same-origin, fingerprinted
|
|
17
|
+
`/dashboard.css` endpoint and added CSP meta tags to the engine layouts.
|
|
18
|
+
The public widget remains pipeline-free and controller-served.
|
|
19
|
+
|
|
3
20
|
## 0.7.1
|
|
4
21
|
|
|
5
22
|
- Renamed the default admin dashboard title to `Testimonials` across shipped
|
|
@@ -8,6 +8,7 @@ module Testimonials
|
|
|
8
8
|
layout 'testimonials/application'
|
|
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
|
|
@@ -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
|
|
@@ -7,7 +7,7 @@ module Testimonials
|
|
|
7
7
|
# Drive swaps the <body> and re-runs body scripts under the *original*
|
|
8
8
|
# page's CSP header, where a freshly minted inline nonce would be refused.
|
|
9
9
|
class WidgetsController < ApplicationController
|
|
10
|
-
#
|
|
10
|
+
# These assets are static and carry no user data; without this, Rails'
|
|
11
11
|
# cross-origin JavaScript guard refuses to serve it to a plain
|
|
12
12
|
# <script src> request.
|
|
13
13
|
skip_forgery_protection
|
|
@@ -21,16 +21,20 @@ module Testimonials
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def dashboard
|
|
24
|
-
serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint)
|
|
24
|
+
serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint, 'text/javascript')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dashboard_stylesheet
|
|
28
|
+
serve(Widget.dashboard_stylesheet, Widget.dashboard_stylesheet_fingerprint, 'text/css')
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
private
|
|
28
32
|
|
|
29
|
-
def serve(source, fingerprint)
|
|
33
|
+
def serve(source, fingerprint, content_type = 'text/javascript')
|
|
30
34
|
expires_in 1.year, public: true if params[:v] == fingerprint
|
|
31
35
|
return unless stale?(etag: [Testimonials::VERSION, fingerprint])
|
|
32
36
|
|
|
33
|
-
render plain: source, content_type:
|
|
37
|
+
render plain: source, content_type: content_type
|
|
34
38
|
end
|
|
35
39
|
end
|
|
36
40
|
end
|
|
@@ -4,98 +4,15 @@
|
|
|
4
4
|
<title><%= t('testimonials.dashboard.title', default: 'Testimonials') %></title>
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
7
8
|
<%# Same-origin script instead of inline handlers, so delete confirms and
|
|
8
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' %>
|
|
9
12
|
<%= javascript_include_tag "#{dashboard_script_path}?v=#{Testimonials::Widget.dashboard_fingerprint}",
|
|
10
|
-
defer: true, nonce: true %>
|
|
11
|
-
<style>
|
|
12
|
-
:root {
|
|
13
|
-
color-scheme: light dark;
|
|
14
|
-
--bg: #f6f7f9; --surface: #fff; --text: #1c2024; --muted: #6b7280;
|
|
15
|
-
--border: #e5e7eb; --accent: #2563eb; --accent-text: #fff;
|
|
16
|
-
--pending: #d97706; --approved: #16a34a; --archived: #6b7280;
|
|
17
|
-
--star: #f59e0b; --danger: #dc2626;
|
|
18
|
-
--promoter: #16a34a; --passive: #d97706; --detractor: #dc2626;
|
|
19
|
-
}
|
|
20
|
-
@media (prefers-color-scheme: dark) {
|
|
21
|
-
:root {
|
|
22
|
-
--bg: #111418; --surface: #1a1f26; --text: #e6e8ea; --muted: #9aa2ab;
|
|
23
|
-
--border: #2a313a; --accent: #3b82f6;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
* { box-sizing: border-box; }
|
|
27
|
-
body { margin: 0; background: var(--bg); color: var(--text);
|
|
28
|
-
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
29
|
-
a { color: var(--accent); text-decoration: none; }
|
|
30
|
-
a:hover { text-decoration: underline; }
|
|
31
|
-
.container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
32
|
-
h1 { font-size: 22px; margin: 0 0 16px; }
|
|
33
|
-
h1.title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
34
|
-
.nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
|
35
|
-
.nav a { padding: 6px 16px; border: 1px solid var(--border); border-radius: 999px;
|
|
36
|
-
background: var(--surface); color: var(--muted); font-weight: 600; }
|
|
37
|
-
.nav a:hover { text-decoration: none; color: var(--text); }
|
|
38
|
-
.nav a.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
39
|
-
.nav a.active:hover { color: var(--accent-text); }
|
|
40
|
-
.share-link { margin-left: auto; font-size: 13px; font-weight: 400; }
|
|
41
|
-
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
|
|
42
|
-
.tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--muted); }
|
|
43
|
-
.tabs a.active { color: var(--text); font-weight: 600; border: 1px solid var(--border);
|
|
44
|
-
border-bottom: 2px solid var(--surface); background: var(--surface); margin-bottom: -1px; }
|
|
45
|
-
.tabs a:hover { text-decoration: none; color: var(--text); }
|
|
46
|
-
.count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
|
|
47
|
-
background: var(--border); font-size: 12px; text-align: center; }
|
|
48
|
-
.filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
|
|
49
|
-
.filters select, .filters input[type=search] { padding: 6px 8px; border: 1px solid var(--border);
|
|
50
|
-
border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; }
|
|
51
|
-
.filters input[type=search] { flex: 1; max-width: 320px; }
|
|
52
|
-
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
|
53
|
-
.card.pad { padding: 16px; overflow: visible; }
|
|
54
|
-
table { width: 100%; border-collapse: collapse; }
|
|
55
|
-
th, td { padding: 10px 14px; text-align: left; vertical-align: top; }
|
|
56
|
-
th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
|
|
57
|
-
border-bottom: 1px solid var(--border); }
|
|
58
|
-
tr + tr td { border-top: 1px solid var(--border); }
|
|
59
|
-
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
|
60
|
-
color: #fff; white-space: nowrap; }
|
|
61
|
-
.badge.status-pending { background: var(--pending); }
|
|
62
|
-
.badge.status-approved { background: var(--approved); }
|
|
63
|
-
.badge.status-archived { background: var(--archived); }
|
|
64
|
-
.badge.nps-promoter { background: var(--promoter); }
|
|
65
|
-
.badge.nps-passive { background: var(--passive); }
|
|
66
|
-
.badge.nps-detractor { background: var(--detractor); }
|
|
67
|
-
.stars { color: var(--star); letter-spacing: 1px; white-space: nowrap; }
|
|
68
|
-
.stars .off { color: var(--border); }
|
|
69
|
-
.muted { color: var(--muted); font-size: 13px; }
|
|
70
|
-
.pager { margin-top: 16px; display: flex; gap: 16px; }
|
|
71
|
-
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0; }
|
|
72
|
-
.actions form { display: inline; }
|
|
73
|
-
td.row-actions { white-space: nowrap; text-align: right; }
|
|
74
|
-
td.row-actions form { display: inline-block; }
|
|
75
|
-
td.row-actions form + form { margin-left: 6px; }
|
|
76
|
-
button, .button { padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
|
|
77
|
-
background: var(--surface); color: var(--text); font: inherit; }
|
|
78
|
-
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
79
|
-
button.danger { color: var(--danger); }
|
|
80
|
-
button.small { padding: 3px 10px; font-size: 13px; }
|
|
81
|
-
.message { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
82
|
-
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
|
|
83
|
-
dt { color: var(--muted); }
|
|
84
|
-
dd { margin: 0; overflow-wrap: anywhere; }
|
|
85
|
-
video.playback { width: 100%; max-width: 480px; border-radius: 10px; background: #000; }
|
|
86
|
-
img.avatar { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; }
|
|
87
|
-
.empty { padding: 48px 16px; text-align: center; color: var(--muted); }
|
|
88
|
-
.breadcrumb { margin-bottom: 12px; font-size: 13px; }
|
|
89
|
-
.stat-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
90
|
-
.stat { flex: 1; min-width: 140px; background: var(--surface); border: 1px solid var(--border);
|
|
91
|
-
border-radius: 12px; padding: 14px 16px; }
|
|
92
|
-
.stat b { display: block; font-size: 26px; }
|
|
93
|
-
.stat span { color: var(--muted); font-size: 13px; }
|
|
94
|
-
.best-line-form textarea { width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 8px;
|
|
95
|
-
background: var(--surface); color: var(--text); font: inherit; resize: vertical; }
|
|
96
|
-
</style>
|
|
13
|
+
'data-turbo-track': 'reload', defer: true, nonce: true %>
|
|
97
14
|
</head>
|
|
98
|
-
<body>
|
|
15
|
+
<body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
|
|
99
16
|
<div class="container">
|
|
100
17
|
<div class="nav">
|
|
101
18
|
<%= link_to t('testimonials.dashboard.title', default: 'Testimonials'), root_path,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<title><%= t('testimonials.page.title', app: Testimonials.app_name, default: "Share your experience with #{Testimonials.app_name}") %></title>
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
7
8
|
<style>
|
|
8
9
|
:root { color-scheme: light dark; }
|
|
9
10
|
body { margin: 0; background: #f6f7f9; color: #1c2024;
|
|
@@ -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="
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
</
|
|
41
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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,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="
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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="
|
|
30
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
56
|
+
<%= truncate(testimonial.body, length: 120) %>
|
|
54
57
|
<% else %>
|
|
55
58
|
<em><%= t('testimonials.kinds.video', default: 'Video') %></em>
|
|
56
59
|
<% end %>
|
|
57
|
-
|
|
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
|
-
<
|
|
73
|
+
<span class="muted">🎥</span>
|
|
60
74
|
<% end %>
|
|
61
75
|
<% if testimonial.featured? %>
|
|
62
|
-
<
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
@@ -5,10 +5,11 @@ Testimonials::Engine.routes.draw do
|
|
|
5
5
|
# the bottom own "/:id".
|
|
6
6
|
get 'widget.js', to: 'widgets#show', as: :widget
|
|
7
7
|
get 'dashboard.js', to: 'widgets#dashboard', as: :dashboard_script
|
|
8
|
+
get 'dashboard.css', to: 'widgets#dashboard_stylesheet', as: :dashboard_stylesheet
|
|
8
9
|
get 'new', to: 'collection#show', as: :collection
|
|
9
10
|
resources :events, only: :create
|
|
10
11
|
resource :nps, only: :create, controller: 'nps'
|
|
11
|
-
resources :nps_responses, only:
|
|
12
|
+
resources :nps_responses, only: %i[index show]
|
|
12
13
|
|
|
13
14
|
# Read-only JSON for rendering testimonials anywhere. Plain REST: the
|
|
14
15
|
# collection is a noun. Under a same-named mount this reads
|
|
@@ -92,6 +92,7 @@ Testimonials.configure do |config|
|
|
|
92
92
|
# Per-IP throttle for public endpoints (Rails 7.2+; ignored on 7.1).
|
|
93
93
|
# config.rate_limit = { to: 5, within: 1.minute }
|
|
94
94
|
|
|
95
|
-
#
|
|
95
|
+
# Default mount path used by `mount_testimonials`.
|
|
96
|
+
# Override only if you mount the engine manually.
|
|
96
97
|
# config.mount_path = "/testimonials"
|
|
97
98
|
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
--bg: #f6f7f9; --surface: #fff; --text: #1c2024; --muted: #6b7280;
|
|
4
|
+
--border: #e5e7eb; --accent: #2563eb; --accent-text: #fff;
|
|
5
|
+
--pending: #d97706; --approved: #16a34a; --archived: #6b7280;
|
|
6
|
+
--star: #f59e0b; --danger: #dc2626;
|
|
7
|
+
--promoter: #16a34a; --passive: #d97706; --detractor: #dc2626;
|
|
8
|
+
}
|
|
9
|
+
@media (prefers-color-scheme: dark) {
|
|
10
|
+
:root {
|
|
11
|
+
--bg: #111418; --surface: #1a1f26; --text: #e6e8ea; --muted: #9aa2ab;
|
|
12
|
+
--border: #2a313a; --accent: #3b82f6;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body { margin: 0; background: var(--bg); color: var(--text);
|
|
17
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
18
|
+
a { color: var(--accent); text-decoration: none; }
|
|
19
|
+
a:hover { text-decoration: underline; }
|
|
20
|
+
.container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
21
|
+
h1 { font-size: 22px; margin: 0 0 16px; }
|
|
22
|
+
h1.title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
23
|
+
.nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
|
24
|
+
.nav a { padding: 6px 16px; border: 1px solid var(--border); border-radius: 999px;
|
|
25
|
+
background: var(--surface); color: var(--muted); font-weight: 600; }
|
|
26
|
+
.nav a:hover { text-decoration: none; color: var(--text); }
|
|
27
|
+
.nav a.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
28
|
+
.nav a.active:hover { color: var(--accent-text); }
|
|
29
|
+
.share-link { margin-left: auto; font-size: 13px; font-weight: 400; }
|
|
30
|
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
|
|
31
|
+
.tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--muted); }
|
|
32
|
+
.tabs a.active { color: var(--text); font-weight: 600; border: 1px solid var(--border);
|
|
33
|
+
border-bottom: 2px solid var(--surface); background: var(--surface); margin-bottom: -1px; }
|
|
34
|
+
.tabs a:hover { text-decoration: none; color: var(--text); }
|
|
35
|
+
.count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
|
|
36
|
+
background: var(--border); font-size: 12px; text-align: center; }
|
|
37
|
+
.filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
|
|
38
|
+
.filters select, .filters input[type=search] { padding: 6px 8px; border: 1px solid var(--border);
|
|
39
|
+
border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; }
|
|
40
|
+
.filters input[type=search] { flex: 1; max-width: 320px; }
|
|
41
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
|
42
|
+
.card.pad { padding: 16px; overflow: visible; }
|
|
43
|
+
table { width: 100%; border-collapse: collapse; }
|
|
44
|
+
th, td { padding: 10px 14px; text-align: left; vertical-align: top; }
|
|
45
|
+
th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
|
|
46
|
+
border-bottom: 1px solid var(--border); }
|
|
47
|
+
tr + tr td { border-top: 1px solid var(--border); }
|
|
48
|
+
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
|
49
|
+
color: #fff; white-space: nowrap; }
|
|
50
|
+
.badge.status-pending { background: var(--pending); }
|
|
51
|
+
.badge.status-approved { background: var(--approved); }
|
|
52
|
+
.badge.status-archived { background: var(--archived); }
|
|
53
|
+
.badge.nps-promoter { background: var(--promoter); }
|
|
54
|
+
.badge.nps-passive { background: var(--passive); }
|
|
55
|
+
.badge.nps-detractor { background: var(--detractor); }
|
|
56
|
+
.badge.locale { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
|
57
|
+
.stars { color: var(--star); letter-spacing: 1px; white-space: nowrap; }
|
|
58
|
+
.stars .off { color: var(--border); }
|
|
59
|
+
.muted { color: var(--muted); font-size: 13px; }
|
|
60
|
+
.pager { margin-top: 16px; display: flex; gap: 16px; }
|
|
61
|
+
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0; }
|
|
62
|
+
.actions form { display: inline; }
|
|
63
|
+
td.row-actions { white-space: nowrap; text-align: right; }
|
|
64
|
+
td.row-actions form { display: inline-block; }
|
|
65
|
+
td.row-actions form + form { margin-left: 6px; }
|
|
66
|
+
button, .button { padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
|
|
67
|
+
background: var(--surface); color: var(--text); font: inherit; }
|
|
68
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
69
|
+
button.danger { color: var(--danger); }
|
|
70
|
+
button.small { padding: 3px 10px; font-size: 13px; }
|
|
71
|
+
.message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
72
|
+
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
|
|
73
|
+
dt { color: var(--muted); }
|
|
74
|
+
dd { margin: 0; overflow-wrap: anywhere; }
|
|
75
|
+
video.playback { width: 100%; max-width: 480px; border-radius: 10px; background: #000; }
|
|
76
|
+
img.avatar { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; }
|
|
77
|
+
.empty { padding: 48px 16px; text-align: center; color: var(--muted); }
|
|
78
|
+
.breadcrumb { margin-bottom: 12px; font-size: 13px; }
|
|
79
|
+
.case-id { color: var(--muted); font-weight: 400; font-size: 15px; }
|
|
80
|
+
.stat-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
81
|
+
.stat { flex: 1; min-width: 140px; background: var(--surface); border: 1px solid var(--border);
|
|
82
|
+
border-radius: 12px; padding: 14px 16px; }
|
|
83
|
+
.stat b { display: block; font-size: 26px; }
|
|
84
|
+
.stat span { color: var(--muted); font-size: 13px; }
|
|
85
|
+
.best-line-form textarea { width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 8px;
|
|
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
|
+
}
|
data/lib/testimonials/engine.rb
CHANGED
|
@@ -20,5 +20,14 @@ module Testimonials
|
|
|
20
20
|
extend Testimonials::HasTestimonials
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
initializer 'testimonials.routing' do
|
|
25
|
+
ActionDispatch::Routing::Mapper.include(Module.new do
|
|
26
|
+
def mount_testimonials(at: Testimonials.config.mount_path, **options)
|
|
27
|
+
Testimonials.config.mount_path = at
|
|
28
|
+
mount Testimonials::Engine, at:, **options
|
|
29
|
+
end
|
|
30
|
+
end)
|
|
31
|
+
end
|
|
23
32
|
end
|
|
24
33
|
end
|
data/lib/testimonials/version.rb
CHANGED
data/lib/testimonials/widget.rb
CHANGED
|
@@ -5,14 +5,14 @@ require 'digest'
|
|
|
5
5
|
|
|
6
6
|
module Testimonials
|
|
7
7
|
# Serves the self-contained browser widget. The JavaScript is plain ES (no
|
|
8
|
-
# framework, no build step) and
|
|
9
|
-
# Rails app regardless of its CSS or JS setup.
|
|
10
|
-
#
|
|
11
|
-
# host
|
|
12
|
-
# host that *does* run a pipeline never ingests it either.
|
|
8
|
+
# framework, no build step) and injects its own widget styles, so it drops
|
|
9
|
+
# into any Rails app regardless of its CSS or JS setup. The code is served by
|
|
10
|
+
# the engine instead of the host's asset pipeline, and it lives under lib/ so
|
|
11
|
+
# a host that does run a pipeline never ingests it either.
|
|
13
12
|
module Widget
|
|
14
13
|
SOURCE = File.expand_path('widget.js', __dir__)
|
|
15
14
|
DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__)
|
|
15
|
+
DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
|
|
16
16
|
|
|
17
17
|
# Right-to-left scripts, so the form renders mirrored for those locales.
|
|
18
18
|
# Matched on the language subtag, so region variants ("ar-EG") count too.
|
|
@@ -27,6 +27,10 @@ module Testimonials
|
|
|
27
27
|
@dashboard_javascript ||= File.read(DASHBOARD_SOURCE)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def dashboard_stylesheet
|
|
31
|
+
@dashboard_stylesheet ||= File.read(DASHBOARD_STYLESHEET_SOURCE)
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
# Content fingerprints for cache-busting script URLs: a changed file is
|
|
31
35
|
# a changed URL, so no browser can ever run stale widget code — Safari
|
|
32
36
|
# has been caught ignoring must-revalidate on same-URL scripts.
|
|
@@ -38,6 +42,10 @@ module Testimonials
|
|
|
38
42
|
@dashboard_fingerprint ||= Digest::MD5.hexdigest(dashboard_javascript)
|
|
39
43
|
end
|
|
40
44
|
|
|
45
|
+
def dashboard_stylesheet_fingerprint
|
|
46
|
+
@dashboard_stylesheet_fingerprint ||= Digest::MD5.hexdigest(dashboard_stylesheet)
|
|
47
|
+
end
|
|
48
|
+
|
|
41
49
|
# The two <script> tags the helper renders.
|
|
42
50
|
#
|
|
43
51
|
# The config rides in a `type="application/json"` block: it is *data*,
|
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.
|
|
4
|
+
version: 0.7.3
|
|
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
|
|
@@ -98,6 +101,7 @@ files:
|
|
|
98
101
|
- lib/generators/testimonials/tenant/tenant_generator.rb
|
|
99
102
|
- lib/testimonials.rb
|
|
100
103
|
- lib/testimonials/configuration.rb
|
|
104
|
+
- lib/testimonials/dashboard.css
|
|
101
105
|
- lib/testimonials/dashboard.js
|
|
102
106
|
- lib/testimonials/engine.rb
|
|
103
107
|
- lib/testimonials/has_testimonials.rb
|