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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
class TestimonialsController < ApplicationController
|
|
5
|
+
PER_PAGE = 50
|
|
6
|
+
|
|
7
|
+
layout 'testimonials/application', except: :create
|
|
8
|
+
|
|
9
|
+
# create is the public widget endpoint; everything else is the dashboard.
|
|
10
|
+
before_action :require_enabled, only: :create
|
|
11
|
+
before_action :require_admin, except: :create
|
|
12
|
+
before_action :set_testimonial, only: %i[show update destroy]
|
|
13
|
+
|
|
14
|
+
# Throttle the public endpoint per IP so one user or bot can't flood the
|
|
15
|
+
# table (a submission may carry a video). Uses the rate limiter built
|
|
16
|
+
# into Rails 7.2+ (backed by Rails.cache); on Rails 7.1 this is a no-op.
|
|
17
|
+
# Tune or disable via config.rate_limit — read once at boot.
|
|
18
|
+
if respond_to?(:rate_limit) && Testimonials.config.rate_limit
|
|
19
|
+
rate_limit(**Testimonials.config.rate_limit, only: :create, with: -> { render_rate_limited })
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def index
|
|
23
|
+
@status = Testimonial::STATUSES.include?(params[:status]) ? params[:status] : 'pending'
|
|
24
|
+
@kind = Testimonial::KINDS.include?(params[:kind]) ? params[:kind] : nil
|
|
25
|
+
@query = params[:q].to_s.strip.presence
|
|
26
|
+
@counts = Testimonial.group(:status).count
|
|
27
|
+
|
|
28
|
+
scope = Testimonial.where(status: @status)
|
|
29
|
+
scope = scope.where(kind: @kind) if @kind
|
|
30
|
+
scope = search(scope) if @query
|
|
31
|
+
@page = [params[:page].to_i, 1].max
|
|
32
|
+
@testimonials = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
33
|
+
@more = @testimonials.size > PER_PAGE
|
|
34
|
+
@testimonials = @testimonials.first(PER_PAGE)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def show; end
|
|
38
|
+
|
|
39
|
+
def update
|
|
40
|
+
@testimonial.update!(admin_params)
|
|
41
|
+
redirect_back fallback_location: testimonial_path(@testimonial), status: :see_other
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def destroy
|
|
45
|
+
@testimonial.destroy!
|
|
46
|
+
redirect_to root_path, status: :see_other
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create
|
|
50
|
+
testimonial = build_testimonial
|
|
51
|
+
|
|
52
|
+
error = attach_video(testimonial) || attach_avatar(testimonial)
|
|
53
|
+
return render json: { errors: [error] }, status: :unprocessable_entity if error
|
|
54
|
+
|
|
55
|
+
if testimonial.save
|
|
56
|
+
# Purge only after a valid save, so a failed update can't strand a
|
|
57
|
+
# review with its video already gone.
|
|
58
|
+
testimonial.video_file.purge if remove_video? && testimonial.video_attached?
|
|
59
|
+
record_submission
|
|
60
|
+
notify_host(testimonial)
|
|
61
|
+
head :created
|
|
62
|
+
else
|
|
63
|
+
render json: { errors: testimonial.errors.full_messages }, status: :unprocessable_entity
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def set_testimonial
|
|
70
|
+
@testimonial = Testimonial.find(params[:id])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def admin_params
|
|
74
|
+
params.require(:testimonial).permit(:status, :featured, :best_line)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Case-insensitive match on the free-text columns. LOWER() keeps it
|
|
78
|
+
# portable across SQLite/PostgreSQL/MySQL, and the explicit ESCAPE makes
|
|
79
|
+
# the sanitized backslash escapes work on SQLite, which has no default
|
|
80
|
+
# LIKE escape character.
|
|
81
|
+
def search(scope)
|
|
82
|
+
pattern = "%#{Testimonial.sanitize_sql_like(@query.downcase)}%"
|
|
83
|
+
scope.where(
|
|
84
|
+
"LOWER(COALESCE(body, '')) LIKE :q ESCAPE '\\' OR LOWER(COALESCE(name, '')) LIKE :q ESCAPE '\\' " \
|
|
85
|
+
"OR LOWER(COALESCE(email, '')) LIKE :q ESCAPE '\\'",
|
|
86
|
+
q: pattern
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One review per signed-in user: a re-submission edits their existing
|
|
91
|
+
# review in place (the App Store model). The edit goes back through
|
|
92
|
+
# moderation, and the admin's best_line is cleared — it may no longer
|
|
93
|
+
# match the new wording. Guests have no reliable identity, so each guest
|
|
94
|
+
# submission stays a new record.
|
|
95
|
+
def build_testimonial
|
|
96
|
+
testimonial = existing_testimonial || Testimonial.new
|
|
97
|
+
testimonial.assign_attributes(testimonial_params)
|
|
98
|
+
if testimonial.persisted?
|
|
99
|
+
testimonial.status = 'pending'
|
|
100
|
+
testimonial.best_line = nil
|
|
101
|
+
end
|
|
102
|
+
testimonial.kind = wants_video?(testimonial) ? 'video' : 'text'
|
|
103
|
+
testimonial.source = 'widget' unless Testimonial::SOURCES.include?(testimonial.source)
|
|
104
|
+
testimonial.consent_text = testimonial.consent_given? ? Testimonials.consent_text : nil
|
|
105
|
+
testimonial.locale = I18n.locale.to_s
|
|
106
|
+
testimonial.user_agent = request.user_agent
|
|
107
|
+
attribute_author(testimonial)
|
|
108
|
+
testimonial
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def existing_testimonial
|
|
112
|
+
return if current_author_id.blank?
|
|
113
|
+
|
|
114
|
+
Testimonial.where(author_id: current_author_id.to_s).newest_first.first
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A fresh upload makes it a video review; on an edit without a new
|
|
118
|
+
# upload, an already-attached video stays — unless the user removed it.
|
|
119
|
+
def wants_video?(testimonial)
|
|
120
|
+
return true if params.dig(:testimonial, :video_file).present?
|
|
121
|
+
|
|
122
|
+
testimonial.video_attached? && !remove_video?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def remove_video?
|
|
126
|
+
params.dig(:testimonial, :remove_video).present? && params.dig(:testimonial, :video_file).blank?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def testimonial_params
|
|
130
|
+
params.require(:testimonial)
|
|
131
|
+
.permit(:body, :rating, :consent_given, :page_url, :source,
|
|
132
|
+
:name, :email, :title_company)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Signed-in users are attributed server-side — the widget never sends
|
|
136
|
+
# (and the endpoint never trusts) contact fields for them.
|
|
137
|
+
def attribute_author(testimonial)
|
|
138
|
+
author = current_author
|
|
139
|
+
return if author.nil?
|
|
140
|
+
|
|
141
|
+
testimonial.author_id = author.id.to_s if author.respond_to?(:id)
|
|
142
|
+
display = Testimonials.config.user_display.call(author) || {}
|
|
143
|
+
testimonial.name = display[:name].presence || testimonial.name
|
|
144
|
+
testimonial.email = display[:email].presence || testimonial.email
|
|
145
|
+
testimonial.title_company = display[:title_company].presence || testimonial.title_company
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns an error message, or nil when everything is fine.
|
|
149
|
+
def attach_video(testimonial)
|
|
150
|
+
file = params.dig(:testimonial, :video_file)
|
|
151
|
+
return nil if file.blank?
|
|
152
|
+
return error_label(:error_save) unless Testimonials.config.video_enabled?
|
|
153
|
+
return error_label(:error_video_too_large) if file.size > Testimonials.config.max_video_size
|
|
154
|
+
return error_label(:error_save) unless file.content_type.to_s.start_with?('video/', 'audio/')
|
|
155
|
+
|
|
156
|
+
testimonial.video_file.attach(file)
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def attach_avatar(testimonial)
|
|
161
|
+
file = params.dig(:testimonial, :avatar)
|
|
162
|
+
return nil if file.blank?
|
|
163
|
+
return error_label(:error_save) unless Testimonials.config.avatars_enabled?
|
|
164
|
+
return error_label(:error_save) if file.size > Testimonials.config.max_avatar_size
|
|
165
|
+
return error_label(:error_save) unless file.content_type.to_s.start_with?('image/')
|
|
166
|
+
|
|
167
|
+
testimonial.avatar.attach(file)
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def record_submission
|
|
172
|
+
PromptEvent.record!(kind: 'testimonial', action: 'submitted',
|
|
173
|
+
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The host's hook must never turn a saved submission into a 500 — the
|
|
177
|
+
# testimonial is in the database; notification failures are the host's
|
|
178
|
+
# logs' problem.
|
|
179
|
+
def notify_host(record)
|
|
180
|
+
Testimonials.config.on_submit.call(record)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
Rails.logger.error("testimonials: on_submit hook raised #{e.class}: #{e.message}")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def error_label(key)
|
|
186
|
+
defaults = {
|
|
187
|
+
error_save: 'Could not send. Please try again.',
|
|
188
|
+
error_video_too_large: 'The video is too large (max %{size} MB).'
|
|
189
|
+
}
|
|
190
|
+
I18n.t(key, scope: :testimonials, default: defaults[key],
|
|
191
|
+
size: Testimonials.config.max_video_size / (1024 * 1024))
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Serves the widget JavaScript as a plain same-origin script. Same-origin
|
|
5
|
+
# matters: under a `script-src 'self'` (or nonce-based) CSP, an external
|
|
6
|
+
# script from the app's own host is always allowed — including when Turbo
|
|
7
|
+
# Drive swaps the <body> and re-runs body scripts under the *original*
|
|
8
|
+
# page's CSP header, where a freshly minted inline nonce would be refused.
|
|
9
|
+
class WidgetsController < ApplicationController
|
|
10
|
+
# The script is static and carries no user data; without this, Rails'
|
|
11
|
+
# cross-origin JavaScript guard refuses to serve it to a plain
|
|
12
|
+
# <script src> request.
|
|
13
|
+
skip_forgery_protection
|
|
14
|
+
|
|
15
|
+
# The script URLs carry a content fingerprint (?v=<md5>), so stale code
|
|
16
|
+
# is structurally impossible: new code means a new URL. The canonical
|
|
17
|
+
# fingerprinted URL is immutable and gets long-lived caching; anything
|
|
18
|
+
# else only ETag-revalidates.
|
|
19
|
+
def show
|
|
20
|
+
serve(Widget.javascript, Widget.fingerprint)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dashboard
|
|
24
|
+
serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def serve(source, fingerprint)
|
|
30
|
+
expires_in 1.year, public: true if params[:v] == fingerprint
|
|
31
|
+
return unless stale?(etag: [Testimonials::VERSION, fingerprint])
|
|
32
|
+
|
|
33
|
+
render plain: source, content_type: 'text/javascript'
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Included into the host's ActionView. Drop `<%= testimonials_tag %>`
|
|
5
|
+
# before </body> in your layout; it renders nothing unless reviews are
|
|
6
|
+
# enabled for the request. The widget stays invisible until opened — by a
|
|
7
|
+
# `testimonial_prompt!` from a controller (throttled), by any element with a
|
|
8
|
+
# `data-testimonial-prompt` attribute, or by `window.Testimonials.open()`.
|
|
9
|
+
#
|
|
10
|
+
# `testimonials_button` renders a ready-made opener with a localized
|
|
11
|
+
# label ("Leave a review", or "Update your review" once the user has one),
|
|
12
|
+
# so hosts get a user-facing entry point without any i18n setup.
|
|
13
|
+
module WidgetHelper
|
|
14
|
+
def testimonials_tag
|
|
15
|
+
return unless Testimonials.enabled?(request)
|
|
16
|
+
|
|
17
|
+
Widget.snippet(
|
|
18
|
+
locale: I18n.locale,
|
|
19
|
+
authenticated: testimonials_author.present?,
|
|
20
|
+
auto_open: testimonials_auto_open,
|
|
21
|
+
existing: testimonials_existing_testimonial,
|
|
22
|
+
nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
|
|
23
|
+
).html_safe
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A plain, unstyled <button> so it picks up the host's own styles. Put it
|
|
27
|
+
# anywhere on a page that also renders testimonials_tag. Pass label: to
|
|
28
|
+
# override the localized default, and any other options (class:, etc.)
|
|
29
|
+
# through to the tag.
|
|
30
|
+
def testimonials_button(label: nil, **)
|
|
31
|
+
return unless Testimonials.enabled?(request)
|
|
32
|
+
|
|
33
|
+
tag.button(label || testimonials_cta_label, type: 'button', 'data-testimonial-prompt': '', **)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The localized call-to-action on its own — "Leave a review", or "Update
|
|
37
|
+
# your review" once the user has one — for hosts composing their own
|
|
38
|
+
# opener markup (icons, list items) around a data-testimonial-prompt element.
|
|
39
|
+
def testimonials_cta_label
|
|
40
|
+
if testimonials_existing_testimonial
|
|
41
|
+
I18n.t(:update_title, scope: :testimonials, default: 'Update your review')
|
|
42
|
+
else
|
|
43
|
+
I18n.t(:cta, scope: :testimonials, default: 'Leave a review')
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def testimonials_author
|
|
50
|
+
return @testimonials_author if defined?(@testimonials_author)
|
|
51
|
+
|
|
52
|
+
@testimonials_author = Testimonials.config.current_user.call(request)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One review per signed-in user: this is the record the widget opens
|
|
56
|
+
# pre-filled, and the one the create endpoint updates in place.
|
|
57
|
+
def testimonials_existing_testimonial
|
|
58
|
+
return @testimonials_existing_testimonial if defined?(@testimonials_existing_testimonial)
|
|
59
|
+
|
|
60
|
+
author = testimonials_author
|
|
61
|
+
@testimonials_existing_testimonial =
|
|
62
|
+
author.respond_to?(:id) ? Testimonial.where(author_id: author.id.to_s).newest_first.first : nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A prompt requested via testimonial_prompt! rides the flash, so it survives
|
|
66
|
+
# the usual redirect-after-create. It only reaches the page if the
|
|
67
|
+
# throttle ledger agrees — call testimonial_prompt! as often as you like.
|
|
68
|
+
def testimonials_auto_open
|
|
69
|
+
kind = flash[:testimonials_prompt].to_s
|
|
70
|
+
return unless Testimonials::PromptEvent::KINDS.include?(kind)
|
|
71
|
+
return if kind == 'nps' && !Testimonials.config.nps
|
|
72
|
+
|
|
73
|
+
author = testimonials_author
|
|
74
|
+
return unless Testimonials::PromptEvent.eligible?(
|
|
75
|
+
kind: kind,
|
|
76
|
+
author_id: author.respond_to?(:id) ? author.id : nil,
|
|
77
|
+
visitor_token: cookies[:testimonials_vid]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
kind
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# One answer to "How likely are you to recommend us?" (0–10).
|
|
5
|
+
class NpsResponse < ApplicationRecord
|
|
6
|
+
validates :score, presence: true, inclusion: { in: 0..10 }
|
|
7
|
+
|
|
8
|
+
scope :newest_first, -> { order(id: :desc) }
|
|
9
|
+
|
|
10
|
+
def promoter? = score >= 9
|
|
11
|
+
def passive? = score.between?(7, 8)
|
|
12
|
+
def detractor? = score <= 6
|
|
13
|
+
|
|
14
|
+
# The classic Net Promoter Score: %promoters − %detractors, −100..100.
|
|
15
|
+
def self.score(scope = all)
|
|
16
|
+
total = scope.count
|
|
17
|
+
return nil if total.zero?
|
|
18
|
+
|
|
19
|
+
promoters = scope.where(score: 9..10).count
|
|
20
|
+
detractors = scope.where(score: 0..6).count
|
|
21
|
+
((promoters - detractors) * 100.0 / total).round
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# The throttling ledger. Every auto-open is a "shown", every close without
|
|
5
|
+
# submitting is a "dismissed", every saved submission is a "submitted" —
|
|
6
|
+
# keyed by author_id for signed-in users, by a visitor cookie otherwise.
|
|
7
|
+
# eligible? reads this history so the widget never nags:
|
|
8
|
+
#
|
|
9
|
+
# * submitted a testimonial -> never auto-prompted for one again
|
|
10
|
+
# * submitted NPS -> not again within nps_reprompt_after
|
|
11
|
+
# * shown or dismissed recently -> not again within reprompt_after
|
|
12
|
+
# * shown max_prompts times -> never auto-prompted for that kind again
|
|
13
|
+
#
|
|
14
|
+
# Explicit opens (the user clicked something) bypass all of this.
|
|
15
|
+
class PromptEvent < ApplicationRecord
|
|
16
|
+
KINDS = %w[testimonial nps].freeze
|
|
17
|
+
ACTIONS = %w[shown dismissed submitted].freeze
|
|
18
|
+
|
|
19
|
+
validates :kind, inclusion: { in: KINDS }
|
|
20
|
+
validates :action, inclusion: { in: ACTIONS }
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def record!(kind:, action:, author_id: nil, visitor_token: nil)
|
|
24
|
+
return if author_id.blank? && visitor_token.blank?
|
|
25
|
+
|
|
26
|
+
create!(kind: kind.to_s, action: action.to_s,
|
|
27
|
+
author_id: author_id.presence, visitor_token: visitor_token.presence)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def eligible?(kind:, author_id: nil, visitor_token: nil)
|
|
31
|
+
kind = kind.to_s
|
|
32
|
+
return false unless KINDS.include?(kind)
|
|
33
|
+
# No identity, no history: a brand-new visitor is always eligible.
|
|
34
|
+
return true if author_id.blank? && visitor_token.blank?
|
|
35
|
+
|
|
36
|
+
history = subject(author_id, visitor_token).where(kind: kind)
|
|
37
|
+
config = Testimonials.config
|
|
38
|
+
|
|
39
|
+
return false if submitted_recently?(history, kind, config)
|
|
40
|
+
return false if history.where(action: %w[shown dismissed])
|
|
41
|
+
.exists?(created_at: (Time.current - config.reprompt_after)..)
|
|
42
|
+
|
|
43
|
+
history.where(action: 'shown').count < config.max_prompts
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def subject(author_id, visitor_token)
|
|
49
|
+
author_id.present? ? where(author_id: author_id.to_s) : where(visitor_token: visitor_token)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def submitted_recently?(history, kind, config)
|
|
53
|
+
submitted = history.where(action: 'submitted')
|
|
54
|
+
return submitted.exists? if kind == 'testimonial'
|
|
55
|
+
|
|
56
|
+
submitted.exists?(created_at: (Time.current - config.nps_reprompt_after)..)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# One testimonial — text or video. Author attribution is optional and
|
|
5
|
+
# stored as loose fields (no foreign key to the host's user table) so the
|
|
6
|
+
# model is portable across apps with different user models.
|
|
7
|
+
class Testimonial < ApplicationRecord
|
|
8
|
+
KINDS = %w[text video].freeze
|
|
9
|
+
STATUSES = %w[pending approved archived].freeze
|
|
10
|
+
SOURCES = %w[widget page nps].freeze
|
|
11
|
+
|
|
12
|
+
if defined?(::ActiveStorage)
|
|
13
|
+
has_one_attached :video_file
|
|
14
|
+
has_one_attached :avatar
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
validates :kind, inclusion: { in: KINDS }
|
|
18
|
+
validates :status, inclusion: { in: STATUSES }
|
|
19
|
+
validates :source, inclusion: { in: SOURCES }
|
|
20
|
+
validates :body, presence: true, if: -> { kind == 'text' }
|
|
21
|
+
validates :rating, inclusion: { in: 1..5 }, allow_nil: true
|
|
22
|
+
|
|
23
|
+
scope :newest_first, -> { order(id: :desc) }
|
|
24
|
+
scope :approved, -> { where(status: 'approved') }
|
|
25
|
+
# What the read API serves: approved by an admin AND consented by the
|
|
26
|
+
# customer. Both, always.
|
|
27
|
+
scope :publishable, -> { approved.where(consent_given: true) }
|
|
28
|
+
scope :featured_first, -> { order(featured: :desc, id: :desc) }
|
|
29
|
+
|
|
30
|
+
STATUSES.each do |status|
|
|
31
|
+
define_method(:"#{status}?") { self.status == status }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def video? = kind == 'video'
|
|
35
|
+
|
|
36
|
+
def video_attached?
|
|
37
|
+
respond_to?(:video_file) && video_file.attached?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def avatar_attached?
|
|
41
|
+
respond_to?(:avatar) && avatar.attached?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def display_name
|
|
45
|
+
name.presence || (author_id.present? ? "##{author_id}" : nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The curated pull-quote when the admin picked one, else the full text.
|
|
49
|
+
def quote
|
|
50
|
+
best_line.presence || body
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= t('testimonials.dashboard.title', default: 'Testimonials') %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%# Same-origin script instead of inline handlers, so delete confirms and
|
|
8
|
+
the auto-submitting filter work under strict script-src CSPs. %>
|
|
9
|
+
<%= 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>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<div class="container">
|
|
100
|
+
<div class="nav">
|
|
101
|
+
<%= link_to t('testimonials.dashboard.title', default: 'Testimonials'), root_path,
|
|
102
|
+
class: ('active' unless controller_name == 'nps_responses') %>
|
|
103
|
+
<% if Testimonials.config.nps %>
|
|
104
|
+
<%= link_to t('testimonials.dashboard.nps', default: 'NPS'), nps_responses_path,
|
|
105
|
+
class: ('active' if controller_name == 'nps_responses') %>
|
|
106
|
+
<% end %>
|
|
107
|
+
<%# The shareable collection page — drop this link into an email or DM. %>
|
|
108
|
+
<% if Testimonials.config.public_collection %>
|
|
109
|
+
<a class="button share-link" href="<%= collection_url %>" target="_blank" rel="noopener"
|
|
110
|
+
title="<%= collection_url %>">
|
|
111
|
+
🔗 <%= t('testimonials.dashboard.collection_page', default: 'Ask for testimonials') %>
|
|
112
|
+
</a>
|
|
113
|
+
<% end %>
|
|
114
|
+
</div>
|
|
115
|
+
<%= yield %>
|
|
116
|
+
</div>
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= t('testimonials.page.title', app: Testimonials.app_name, default: "Share your experience with #{Testimonials.app_name}") %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
body { margin: 0; background: #f6f7f9; color: #1c2024;
|
|
10
|
+
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
11
|
+
@media (prefers-color-scheme: dark) { body { background: #111418; color: #e6e8ea; } }
|
|
12
|
+
.container { max-width: 640px; margin: 0 auto; padding: 48px 16px 64px; }
|
|
13
|
+
h1 { font-size: 28px; text-align: center; margin: 0 0 8px; }
|
|
14
|
+
.subtitle { text-align: center; color: #6b7280; margin: 0 0 32px; }
|
|
15
|
+
@media (prefers-color-scheme: dark) { .subtitle { color: #9aa2ab; } }
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div class="container">
|
|
20
|
+
<%= yield %>
|
|
21
|
+
</div>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<h1><%= t('testimonials.page.title', app: Testimonials.app_name, default: "Share your experience with #{Testimonials.app_name}") %></h1>
|
|
2
|
+
<p class="subtitle"><%= t('testimonials.page.subtitle', default: 'Your words help others decide — thank you!') %></p>
|
|
3
|
+
|
|
4
|
+
<div data-testimonials-inline></div>
|
|
5
|
+
|
|
6
|
+
<%= Testimonials::Widget.snippet(
|
|
7
|
+
locale: I18n.locale,
|
|
8
|
+
authenticated: Testimonials.config.current_user.call(request).present?,
|
|
9
|
+
mode: 'page',
|
|
10
|
+
existing: @existing,
|
|
11
|
+
nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
|
|
12
|
+
).html_safe %>
|