testimonials 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/MIT-LICENSE +20 -0
- data/README.md +175 -0
- data/Rakefile +11 -0
- data/app/controllers/testimonials/api/base_controller.rb +30 -0
- data/app/controllers/testimonials/api/stats_controller.rb +20 -0
- data/app/controllers/testimonials/api/testimonials_controller.rb +41 -0
- data/app/controllers/testimonials/application_controller.rb +50 -0
- data/app/controllers/testimonials/collection_controller.rb +18 -0
- data/app/controllers/testimonials/events_controller.rb +25 -0
- data/app/controllers/testimonials/media_controller.rb +48 -0
- data/app/controllers/testimonials/nps_controller.rb +71 -0
- data/app/controllers/testimonials/nps_responses_controller.rb +25 -0
- data/app/controllers/testimonials/testimonials_controller.rb +194 -0
- data/app/controllers/testimonials/widgets_controller.rb +36 -0
- data/app/helpers/testimonials/widget_helper.rb +83 -0
- data/app/models/testimonials/application_record.rb +7 -0
- data/app/models/testimonials/nps_response.rb +24 -0
- data/app/models/testimonials/prompt_event.rb +60 -0
- data/app/models/testimonials/testimonial.rb +53 -0
- data/app/views/layouts/testimonials/application.html.erb +118 -0
- data/app/views/layouts/testimonials/collection.html.erb +23 -0
- data/app/views/testimonials/collection/show.html.erb +12 -0
- data/app/views/testimonials/nps_responses/index.html.erb +67 -0
- data/app/views/testimonials/testimonials/index.html.erb +108 -0
- data/app/views/testimonials/testimonials/show.html.erb +105 -0
- data/config/locales/testimonials.ar.yml +101 -0
- data/config/locales/testimonials.bg.yml +101 -0
- data/config/locales/testimonials.bn.yml +101 -0
- data/config/locales/testimonials.de.yml +101 -0
- data/config/locales/testimonials.el.yml +101 -0
- data/config/locales/testimonials.en.yml +101 -0
- data/config/locales/testimonials.es.yml +101 -0
- data/config/locales/testimonials.fr.yml +101 -0
- data/config/locales/testimonials.hi.yml +101 -0
- data/config/locales/testimonials.hr.yml +101 -0
- data/config/locales/testimonials.id.yml +101 -0
- data/config/locales/testimonials.it.yml +101 -0
- data/config/locales/testimonials.ja.yml +101 -0
- data/config/locales/testimonials.ko.yml +101 -0
- data/config/locales/testimonials.lb.yml +101 -0
- data/config/locales/testimonials.nl.yml +101 -0
- data/config/locales/testimonials.pl.yml +101 -0
- data/config/locales/testimonials.pt.yml +101 -0
- data/config/locales/testimonials.ro.yml +101 -0
- data/config/locales/testimonials.ru.yml +101 -0
- data/config/locales/testimonials.th.yml +101 -0
- data/config/locales/testimonials.tr.yml +101 -0
- data/config/locales/testimonials.uk.yml +101 -0
- data/config/locales/testimonials.ur.yml +101 -0
- data/config/locales/testimonials.vi.yml +101 -0
- data/config/locales/testimonials.zh-CN.yml +101 -0
- data/config/routes.rb +29 -0
- data/lib/generators/testimonials/install/install_generator.rb +42 -0
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +53 -0
- data/lib/generators/testimonials/install/templates/initializer.rb +70 -0
- data/lib/testimonials/configuration.rb +129 -0
- data/lib/testimonials/dashboard.js +33 -0
- data/lib/testimonials/engine.rb +16 -0
- data/lib/testimonials/prompt_helper.rb +15 -0
- data/lib/testimonials/version.rb +5 -0
- data/lib/testimonials/widget.js +1045 -0
- data/lib/testimonials/widget.rb +170 -0
- data/lib/testimonials.rb +69 -0
- metadata +132 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c3a780f6ec2e1bfb53266936069312dc7821e8f1b28e437aa163e82bbf09bf9a
|
|
4
|
+
data.tar.gz: bf1d9e6f2e44617937ebb35bb8cd3d56528ee5486243c9ce1ceba939bf982b44
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 212b640b1254e063aa7db13385842072e432cd0130503fd0f379115ac146151b9e450aa6dd47e599895815a3298cd169ad7795d5db2ede32a5f0d1548fb3fde6
|
|
7
|
+
data.tar.gz: 7b585a4caf94d6d70531e52eb0d0fca1d389ebcbe11a10638002b1a9eeee23ee0c976ee38392493719831da1bb30574cf8694151ca0199369c6901228706fd33
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial release: in-app testimonial widget (text + video), public collection
|
|
6
|
+
page, prompt throttling, `testimonial_prompt!`, NPS with promoter auto-routing,
|
|
7
|
+
triage dashboard, read API (`/api/testimonials`, `/api/stats`), localized
|
|
8
|
+
best-practice questions.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Yaroslav Shmarov
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# testimonials
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/testimonials)
|
|
4
|
+
[](https://github.com/yshmarov/testimonials-engine/actions/workflows/ci.yml)
|
|
5
|
+
[](MIT-LICENSE)
|
|
6
|
+
|
|
7
|
+
Testimonials, reviews and NPS for Rails. Self-hosted alternative to
|
|
8
|
+
Testimonial.to / Senja / Delighted.
|
|
9
|
+
|
|
10
|
+
`testimonials` collects customer testimonials — text and **video** — from
|
|
11
|
+
inside your app with an iOS-style "Enjoying this app?" prompt, from a
|
|
12
|
+
shareable public page, and from NPS promoters routed straight into the
|
|
13
|
+
testimonial ask. Everything lands in your own database with a minimal
|
|
14
|
+
dashboard to approve, feature, and pick each one’s best line. Display is **headless**: render
|
|
15
|
+
approved testimonials with your own markup via the models or a JSON API —
|
|
16
|
+
copy-paste examples included.
|
|
17
|
+
|
|
18
|
+
- **Zero UI dependencies.** The widget is plain JavaScript and styles itself.
|
|
19
|
+
No Tailwind, no Stimulus, no importmap, no build step. Works with Turbo
|
|
20
|
+
Drive and strict nonce-based CSP out of the box.
|
|
21
|
+
- **Prompt at the right moment.** Call `testimonial_prompt!` in a controller at
|
|
22
|
+
your success moments (subscription renewed, milestone hit). Built-in
|
|
23
|
+
throttling means you can call it liberally — nobody gets nagged.
|
|
24
|
+
- **Real attribution.** Signed-in users are attributed server-side to your
|
|
25
|
+
user records; guests leave name and email. No external tool can do this.
|
|
26
|
+
- **Video testimonials** recorded in the browser (MediaRecorder) with a
|
|
27
|
+
review step, or uploaded as a file. Plain multipart upload via Active
|
|
28
|
+
Storage.
|
|
29
|
+
- **NPS built in.** 0–10 with a comment; promoters (9–10) are immediately
|
|
30
|
+
offered the testimonial form, detractors flow into `on_detractor` (pairs
|
|
31
|
+
well with [feedback_engine](https://github.com/yshmarov/feedback-engine)).
|
|
32
|
+
- **Consent is first-class.** A consent checkbox with a stored text snapshot;
|
|
33
|
+
the API only ever serves approved **and** consented records, and never
|
|
34
|
+
emails.
|
|
35
|
+
- **26 languages**, including localized best-practice guiding questions.
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
1. Add `<%= testimonials_tag %>` to your layout. Nothing is visible until
|
|
40
|
+
the widget opens: from an eligible `testimonial_prompt!`, from any element with
|
|
41
|
+
`data-testimonial-prompt`, or from `window.Testimonials.open()`.
|
|
42
|
+
2. The prompt starts as a small star card ("Enjoying MyApp? ★★★★★ / Not
|
|
43
|
+
now"). Tapping a star expands into the full form: guiding questions,
|
|
44
|
+
text, optional video recording, consent.
|
|
45
|
+
3. Submissions land in `testimonials_testimonials` as `pending`. You
|
|
46
|
+
approve, feature, and pick best lines at the mount path (`/testimonials`).
|
|
47
|
+
4. You render approved testimonials wherever you like — see
|
|
48
|
+
[`examples/`](examples/) for a wall of love, quote card, rating badge,
|
|
49
|
+
JSON-LD rich snippets, and a static-site (Astro) recipe.
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Ruby >= 3.2
|
|
54
|
+
- Rails >= 7.1
|
|
55
|
+
- Active Storage (only if you want video/avatar uploads)
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Gemfile
|
|
61
|
+
gem "testimonials"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bundle install
|
|
66
|
+
bin/rails generate testimonials:install
|
|
67
|
+
bin/rails db:migrate
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The generator writes `config/initializers/testimonials.rb`, creates the
|
|
71
|
+
migration, and mounts the engine at `/testimonials`. Then add the widget to your
|
|
72
|
+
layout:
|
|
73
|
+
|
|
74
|
+
```erb
|
|
75
|
+
<%= testimonials_tag %>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Prompting users
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class InvoicesController < ApplicationController
|
|
82
|
+
def create
|
|
83
|
+
# ...
|
|
84
|
+
testimonial_prompt! if current_user.invoices.count == 10 # a success moment
|
|
85
|
+
redirect_to invoices_path
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The widget auto-opens on the next rendered page **if** the throttle allows:
|
|
91
|
+
|
|
92
|
+
- submitted a testimonial → never auto-prompted again
|
|
93
|
+
- dismissed → not again within `reprompt_after` (default 90 days)
|
|
94
|
+
- auto-prompted `max_prompts` times (default 3) → never again
|
|
95
|
+
|
|
96
|
+
Explicit opens bypass throttling: any element with `data-testimonial-prompt`
|
|
97
|
+
(or `data-testimonial-prompt="nps"`), or `window.Testimonials.open()` /
|
|
98
|
+
`window.Testimonials.openNps()`.
|
|
99
|
+
|
|
100
|
+
`testimonial_prompt!(:nps)` prompts for an NPS score instead.
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
Everything lives in `config/initializers/testimonials.rb`; every option has
|
|
105
|
+
a working default. The essentials:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Testimonials.configure do |config|
|
|
109
|
+
config.app_name = "SupeRails"
|
|
110
|
+
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
111
|
+
config.user_display = ->(user) { { name: user.name, email: user.email } }
|
|
112
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
113
|
+
config.on_submit = ->(record) { SlackNotifier.ping(record) }
|
|
114
|
+
config.on_detractor = ->(nps) { FeedbackEngine::Feedback.create!(kind: "other", message: nps.comment.to_s) }
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Guiding questions, localized
|
|
119
|
+
|
|
120
|
+
The questions shown above the form ("How has %{app} helped you?") fight
|
|
121
|
+
blank-page paralysis — they are prompts, not form fields. By default they
|
|
122
|
+
come from the gem's own locale files, so all 26 languages get good questions
|
|
123
|
+
with your app name interpolated. Override with literal strings or a lambda
|
|
124
|
+
for host-side i18n:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
config.questions = ["What convinced you to try %{app}?", "What changed since?"]
|
|
128
|
+
config.questions = -> { I18n.t("reviews.questions") } # your own locale keys
|
|
129
|
+
config.questions = [] # hide the section
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## The public collection page
|
|
133
|
+
|
|
134
|
+
`/testimonials/new` is a standalone, self-styled page for customers outside the
|
|
135
|
+
app — drop the link into an email or DM. Guests leave name, email, optional
|
|
136
|
+
title/company and photo. Disable with `config.public_collection = false`.
|
|
137
|
+
|
|
138
|
+
## The read API
|
|
139
|
+
|
|
140
|
+
- `GET /testimonials/api/testimonials` — approved + consented records only;
|
|
141
|
+
filters: `featured=1`, `min_rating=4`, `kind=video`, `limit=12`.
|
|
142
|
+
- `GET /testimonials/api/stats` — `count`, `average_rating`, `ratings_count`,
|
|
143
|
+
`nps_score`.
|
|
144
|
+
|
|
145
|
+
Admin-only by default. Set `config.public_api = true` to serve them without
|
|
146
|
+
auth (CORS `*`) — ideal for a separate static marketing site that renders
|
|
147
|
+
your wall of love at build time. Emails and author ids are never serialized.
|
|
148
|
+
Video and avatar files are handed out by testimonial id through the same
|
|
149
|
+
gate, with Range support so `<video>` tags just work.
|
|
150
|
+
|
|
151
|
+
## NPS
|
|
152
|
+
|
|
153
|
+
`testimonial_prompt!(:nps)` (or the widget API) asks the classic 0–10 question.
|
|
154
|
+
Promoters are offered the testimonial form right away — but only if the
|
|
155
|
+
testimonial throttle would allow it. Detractors trigger `on_detractor`.
|
|
156
|
+
The dashboard shows your NPS score and every response at
|
|
157
|
+
`/testimonials/nps_responses`. Disable with `config.nps = false`.
|
|
158
|
+
|
|
159
|
+
## Dashboard
|
|
160
|
+
|
|
161
|
+
Browse at the mount path: pending → approved → archived tabs, search,
|
|
162
|
+
quick-approve, inline video playback, feature toggle, and a “Best line” picker —
|
|
163
|
+
the customer's words are never editable, but you choose the pull-quote.
|
|
164
|
+
Gated by `config.authorize_admin` (development-only until you set it).
|
|
165
|
+
|
|
166
|
+
## Testing
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
bundle exec rake test
|
|
170
|
+
bundle exec rubocop
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
module Api
|
|
5
|
+
# Read-only JSON. With config.public_api the endpoints answer anyone
|
|
6
|
+
# (approved + consented records only, CORS open for static marketing
|
|
7
|
+
# sites); otherwise they answer only requests that pass authorize_admin.
|
|
8
|
+
class BaseController < ApplicationController
|
|
9
|
+
skip_forgery_protection
|
|
10
|
+
|
|
11
|
+
before_action :authorize_api
|
|
12
|
+
after_action :allow_cross_origin
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def authorize_api
|
|
17
|
+
return if Testimonials.config.public_api
|
|
18
|
+
return if Testimonials.admin?(request)
|
|
19
|
+
|
|
20
|
+
head :forbidden
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Public testimonials are meant to be fetched from anywhere — a static
|
|
24
|
+
# marketing site at build time or in the browser.
|
|
25
|
+
def allow_cross_origin
|
|
26
|
+
response.set_header('Access-Control-Allow-Origin', '*') if Testimonials.config.public_api
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
module Api
|
|
5
|
+
# Powers rating badges: "★ 4.9 from 87 reviews".
|
|
6
|
+
class StatsController < BaseController
|
|
7
|
+
def show
|
|
8
|
+
scope = Testimonial.publishable
|
|
9
|
+
rated = scope.where.not(rating: nil)
|
|
10
|
+
|
|
11
|
+
render json: {
|
|
12
|
+
count: scope.count,
|
|
13
|
+
average_rating: rated.average(:rating)&.to_f&.round(2),
|
|
14
|
+
ratings_count: rated.count,
|
|
15
|
+
nps_score: (NpsResponse.score if Testimonials.config.nps)
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
module Api
|
|
5
|
+
class TestimonialsController < BaseController
|
|
6
|
+
MAX_LIMIT = 100
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
scope = Testimonial.publishable.featured_first
|
|
10
|
+
scope = scope.where(featured: true) if params[:featured].present?
|
|
11
|
+
scope = scope.where(kind: params[:kind]) if Testimonial::KINDS.include?(params[:kind])
|
|
12
|
+
scope = scope.where(rating: params[:min_rating].to_i..) if params[:min_rating].present?
|
|
13
|
+
limit = params[:limit].to_i.clamp(1, MAX_LIMIT)
|
|
14
|
+
limit = MAX_LIMIT if params[:limit].blank?
|
|
15
|
+
|
|
16
|
+
render json: { testimonials: scope.limit(limit).map { |t| serialize(t) } }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Name, title and media only — never emails, never author ids. The
|
|
22
|
+
# customer consented to publication, not to leaking contact details.
|
|
23
|
+
def serialize(testimonial)
|
|
24
|
+
{
|
|
25
|
+
id: testimonial.id,
|
|
26
|
+
kind: testimonial.kind,
|
|
27
|
+
body: testimonial.body,
|
|
28
|
+
best_line: testimonial.best_line,
|
|
29
|
+
quote: testimonial.quote,
|
|
30
|
+
rating: testimonial.rating,
|
|
31
|
+
name: testimonial.name,
|
|
32
|
+
title_company: testimonial.title_company,
|
|
33
|
+
featured: testimonial.featured?,
|
|
34
|
+
created_at: testimonial.created_at.iso8601,
|
|
35
|
+
avatar_url: (testimonial_avatar_url(testimonial) if testimonial.avatar_attached?),
|
|
36
|
+
video_url: (testimonial_video_url(testimonial) if testimonial.video_attached?)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def current_author
|
|
10
|
+
return @current_author if defined?(@current_author)
|
|
11
|
+
|
|
12
|
+
@current_author = Testimonials.config.current_user.call(request)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def current_author_id
|
|
16
|
+
current_author.respond_to?(:id) ? current_author.id : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def require_enabled
|
|
20
|
+
head :forbidden unless Testimonials.enabled?(request)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render_rate_limited
|
|
24
|
+
message = I18n.t('testimonials.error_rate_limited',
|
|
25
|
+
default: 'Too many submissions. Please wait a moment and try again.')
|
|
26
|
+
render json: { errors: [message] }, status: :too_many_requests
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Server-side gate for the dashboard. Default: development only.
|
|
30
|
+
def require_admin
|
|
31
|
+
return if Testimonials.admin?(request)
|
|
32
|
+
|
|
33
|
+
render plain: 'Forbidden. Set Testimonials.config.authorize_admin to grant access.',
|
|
34
|
+
status: :forbidden
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Guests get a permanent random token so the throttle ledger can
|
|
38
|
+
# remember them across visits. Signed-in users are keyed by author_id
|
|
39
|
+
# instead and never receive the cookie.
|
|
40
|
+
def ensure_visitor_token
|
|
41
|
+
return if current_author
|
|
42
|
+
|
|
43
|
+
cookies[:testimonials_vid].presence || begin
|
|
44
|
+
token = SecureRandom.base58(24)
|
|
45
|
+
cookies.permanent[:testimonials_vid] = { value: token, httponly: true, same_site: :lax }
|
|
46
|
+
token
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# The standalone public collection page at "#{mount_path}/new" — the link
|
|
5
|
+
# you drop into an email or DM so customers outside the app can leave a
|
|
6
|
+
# testimonial. Toggled by config.public_collection (ON by default).
|
|
7
|
+
class CollectionController < ApplicationController
|
|
8
|
+
layout 'testimonials/collection'
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
head :not_found and return unless Testimonials.config.public_collection
|
|
12
|
+
head :forbidden and return unless Testimonials.enabled?(request)
|
|
13
|
+
|
|
14
|
+
# A signed-in visitor edits their existing review instead of adding one.
|
|
15
|
+
@existing = current_author_id && Testimonial.where(author_id: current_author_id.to_s).newest_first.first
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# The widget reports auto-prompt lifecycle here: "shown" when it opened
|
|
5
|
+
# itself, "dismissed" when the user closed it without submitting.
|
|
6
|
+
# ("submitted" is recorded server-side by the create endpoints.) These
|
|
7
|
+
# events feed PromptEvent.eligible?, which is what keeps auto-prompts from
|
|
8
|
+
# nagging anyone.
|
|
9
|
+
class EventsController < ApplicationController
|
|
10
|
+
CLIENT_ACTIONS = %w[shown dismissed].freeze
|
|
11
|
+
|
|
12
|
+
before_action :require_enabled
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
kind = params[:kind].to_s
|
|
16
|
+
action = params[:event_action].to_s
|
|
17
|
+
head :unprocessable_entity and return unless PromptEvent::KINDS.include?(kind) &&
|
|
18
|
+
CLIENT_ACTIONS.include?(action)
|
|
19
|
+
|
|
20
|
+
PromptEvent.record!(kind: kind, action: action,
|
|
21
|
+
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
22
|
+
head :no_content
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Hands out video and avatar files by testimonial id, so consumers never
|
|
5
|
+
# need to know about Active Storage. Admins can fetch anything; everyone
|
|
6
|
+
# else only reaches media of publishable (approved + consented)
|
|
7
|
+
# testimonials, and only when the read API is public. The actual bytes are
|
|
8
|
+
# served by Active Storage's own controllers (signed, expiring URLs, with
|
|
9
|
+
# Range support — video elements need that).
|
|
10
|
+
class MediaController < ApplicationController
|
|
11
|
+
before_action :set_testimonial
|
|
12
|
+
before_action :authorize_media
|
|
13
|
+
|
|
14
|
+
def video
|
|
15
|
+
head :not_found and return unless @testimonial.video_attached?
|
|
16
|
+
|
|
17
|
+
redirect_to main_app.rails_blob_path(@testimonial.video_file, disposition: disposition)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def avatar
|
|
21
|
+
head :not_found and return unless @testimonial.avatar_attached?
|
|
22
|
+
|
|
23
|
+
redirect_to main_app.rails_blob_path(@testimonial.avatar, disposition: disposition)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# ?download=1 turns the redirect into a file download instead of
|
|
29
|
+
# inline playback.
|
|
30
|
+
def disposition
|
|
31
|
+
params[:download].present? ? 'attachment' : 'inline'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def set_testimonial
|
|
35
|
+
@testimonial = Testimonial.find(params[:id])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def authorize_media
|
|
39
|
+
return if Testimonials.admin?(request)
|
|
40
|
+
# Authors can always see their own media — the widget replays their
|
|
41
|
+
# attached video when they edit their review.
|
|
42
|
+
return if current_author_id.present? && @testimonial.author_id == current_author_id.to_s
|
|
43
|
+
return if Testimonials.config.public_api && @testimonial.approved? && @testimonial.consent_given?
|
|
44
|
+
|
|
45
|
+
head :forbidden
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
class NpsController < ApplicationController
|
|
5
|
+
before_action :require_enabled
|
|
6
|
+
before_action :require_nps
|
|
7
|
+
|
|
8
|
+
if respond_to?(:rate_limit) && Testimonials.config.rate_limit
|
|
9
|
+
rate_limit(**Testimonials.config.rate_limit, only: :create, with: -> { render_rate_limited })
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
nps = NpsResponse.new(nps_params)
|
|
14
|
+
nps.locale = I18n.locale.to_s
|
|
15
|
+
nps.user_agent = request.user_agent
|
|
16
|
+
attribute_author(nps)
|
|
17
|
+
|
|
18
|
+
if nps.save
|
|
19
|
+
PromptEvent.record!(kind: 'nps', action: 'submitted',
|
|
20
|
+
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
21
|
+
notify_host(nps)
|
|
22
|
+
notify_detractor(nps) if nps.detractor?
|
|
23
|
+
render json: { offer_testimonial: offer_testimonial?(nps) }, status: :created
|
|
24
|
+
else
|
|
25
|
+
render json: { errors: nps.errors.full_messages }, status: :unprocessable_entity
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def require_nps
|
|
32
|
+
head :forbidden unless Testimonials.config.nps
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def nps_params
|
|
36
|
+
params.require(:nps).permit(:score, :comment, :email, :page_url)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def attribute_author(nps)
|
|
40
|
+
author = current_author
|
|
41
|
+
return if author.nil?
|
|
42
|
+
|
|
43
|
+
nps.author_id = author.id.to_s if author.respond_to?(:id)
|
|
44
|
+
display = Testimonials.config.user_display.call(author) || {}
|
|
45
|
+
nps.name = display[:name].presence
|
|
46
|
+
nps.email = display[:email].presence || nps.email
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A promoter gets the testimonial ask right away — but only if the
|
|
50
|
+
# throttle ledger would allow a testimonial prompt anyway.
|
|
51
|
+
def offer_testimonial?(nps)
|
|
52
|
+
nps.promoter? && PromptEvent.eligible?(
|
|
53
|
+
kind: 'testimonial',
|
|
54
|
+
author_id: current_author_id,
|
|
55
|
+
visitor_token: cookies[:testimonials_vid]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def notify_host(nps)
|
|
60
|
+
Testimonials.config.on_submit.call(nps)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Rails.logger.error("testimonials: on_submit hook raised #{e.class}: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def notify_detractor(nps)
|
|
66
|
+
Testimonials.config.on_detractor.call(nps)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Rails.logger.error("testimonials: on_detractor hook raised #{e.class}: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# The NPS side of the dashboard: overall score plus the response list.
|
|
5
|
+
class NpsResponsesController < ApplicationController
|
|
6
|
+
PER_PAGE = 50
|
|
7
|
+
|
|
8
|
+
layout 'testimonials/application'
|
|
9
|
+
|
|
10
|
+
before_action :require_admin
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
@score = NpsResponse.score
|
|
14
|
+
@total = NpsResponse.count
|
|
15
|
+
@promoters = NpsResponse.where(score: 9..10).count
|
|
16
|
+
@passives = NpsResponse.where(score: 7..8).count
|
|
17
|
+
@detractors = NpsResponse.where(score: 0..6).count
|
|
18
|
+
|
|
19
|
+
@page = [params[:page].to_i, 1].max
|
|
20
|
+
@responses = NpsResponse.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
21
|
+
@more = @responses.size > PER_PAGE
|
|
22
|
+
@responses = @responses.first(PER_PAGE)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|