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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +175 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/testimonials/api/base_controller.rb +30 -0
  7. data/app/controllers/testimonials/api/stats_controller.rb +20 -0
  8. data/app/controllers/testimonials/api/testimonials_controller.rb +41 -0
  9. data/app/controllers/testimonials/application_controller.rb +50 -0
  10. data/app/controllers/testimonials/collection_controller.rb +18 -0
  11. data/app/controllers/testimonials/events_controller.rb +25 -0
  12. data/app/controllers/testimonials/media_controller.rb +48 -0
  13. data/app/controllers/testimonials/nps_controller.rb +71 -0
  14. data/app/controllers/testimonials/nps_responses_controller.rb +25 -0
  15. data/app/controllers/testimonials/testimonials_controller.rb +194 -0
  16. data/app/controllers/testimonials/widgets_controller.rb +36 -0
  17. data/app/helpers/testimonials/widget_helper.rb +83 -0
  18. data/app/models/testimonials/application_record.rb +7 -0
  19. data/app/models/testimonials/nps_response.rb +24 -0
  20. data/app/models/testimonials/prompt_event.rb +60 -0
  21. data/app/models/testimonials/testimonial.rb +53 -0
  22. data/app/views/layouts/testimonials/application.html.erb +118 -0
  23. data/app/views/layouts/testimonials/collection.html.erb +23 -0
  24. data/app/views/testimonials/collection/show.html.erb +12 -0
  25. data/app/views/testimonials/nps_responses/index.html.erb +67 -0
  26. data/app/views/testimonials/testimonials/index.html.erb +108 -0
  27. data/app/views/testimonials/testimonials/show.html.erb +105 -0
  28. data/config/locales/testimonials.ar.yml +101 -0
  29. data/config/locales/testimonials.bg.yml +101 -0
  30. data/config/locales/testimonials.bn.yml +101 -0
  31. data/config/locales/testimonials.de.yml +101 -0
  32. data/config/locales/testimonials.el.yml +101 -0
  33. data/config/locales/testimonials.en.yml +101 -0
  34. data/config/locales/testimonials.es.yml +101 -0
  35. data/config/locales/testimonials.fr.yml +101 -0
  36. data/config/locales/testimonials.hi.yml +101 -0
  37. data/config/locales/testimonials.hr.yml +101 -0
  38. data/config/locales/testimonials.id.yml +101 -0
  39. data/config/locales/testimonials.it.yml +101 -0
  40. data/config/locales/testimonials.ja.yml +101 -0
  41. data/config/locales/testimonials.ko.yml +101 -0
  42. data/config/locales/testimonials.lb.yml +101 -0
  43. data/config/locales/testimonials.nl.yml +101 -0
  44. data/config/locales/testimonials.pl.yml +101 -0
  45. data/config/locales/testimonials.pt.yml +101 -0
  46. data/config/locales/testimonials.ro.yml +101 -0
  47. data/config/locales/testimonials.ru.yml +101 -0
  48. data/config/locales/testimonials.th.yml +101 -0
  49. data/config/locales/testimonials.tr.yml +101 -0
  50. data/config/locales/testimonials.uk.yml +101 -0
  51. data/config/locales/testimonials.ur.yml +101 -0
  52. data/config/locales/testimonials.vi.yml +101 -0
  53. data/config/locales/testimonials.zh-CN.yml +101 -0
  54. data/config/routes.rb +29 -0
  55. data/lib/generators/testimonials/install/install_generator.rb +42 -0
  56. data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +53 -0
  57. data/lib/generators/testimonials/install/templates/initializer.rb +70 -0
  58. data/lib/testimonials/configuration.rb +129 -0
  59. data/lib/testimonials/dashboard.js +33 -0
  60. data/lib/testimonials/engine.rb +16 -0
  61. data/lib/testimonials/prompt_helper.rb +15 -0
  62. data/lib/testimonials/version.rb +5 -0
  63. data/lib/testimonials/widget.js +1045 -0
  64. data/lib/testimonials/widget.rb +170 -0
  65. data/lib/testimonials.rb +69 -0
  66. 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
+ [![Gem Version](https://img.shields.io/gem/v/testimonials)](https://rubygems.org/gems/testimonials)
4
+ [![CI](https://github.com/yshmarov/testimonials-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/testimonials-engine/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.pattern = 'test/**/*_test.rb'
9
+ end
10
+
11
+ task default: :test
@@ -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