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
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testimonials
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ 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 %>