testimonials 0.7.9 → 0.8.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +210 -0
  3. data/CHANGELOG.md +68 -0
  4. data/README.md +30 -4
  5. data/app/controllers/concerns/testimonials/request_context.rb +75 -0
  6. data/app/controllers/testimonials/application_controller.rb +10 -56
  7. data/app/controllers/testimonials/dashboard_controller.rb +40 -0
  8. data/app/controllers/testimonials/events_controller.rb +9 -0
  9. data/app/controllers/testimonials/nps_responses_controller.rb +1 -4
  10. data/app/controllers/testimonials/submissions_controller.rb +169 -0
  11. data/app/controllers/testimonials/testimonials_controller.rb +6 -166
  12. data/app/helpers/testimonials/widget_helper.rb +4 -0
  13. data/app/models/testimonials/prompt_event.rb +12 -0
  14. data/app/views/layouts/testimonials/application.html.erb +5 -8
  15. data/app/views/testimonials/nps_responses/index.html.erb +154 -152
  16. data/app/views/testimonials/nps_responses/show.html.erb +7 -5
  17. data/app/views/testimonials/shared/_dashboard.html.erb +18 -0
  18. data/app/views/testimonials/testimonials/index.html.erb +110 -108
  19. data/app/views/testimonials/testimonials/show.html.erb +7 -5
  20. data/config/routes.rb +18 -5
  21. data/examples/README.md +17 -0
  22. data/examples/badge.html.erb +13 -0
  23. data/examples/json_ld.html.erb +28 -0
  24. data/examples/static_site.md +43 -0
  25. data/examples/testimonial_card.html.erb +17 -0
  26. data/examples/wall_of_love.html.erb +43 -0
  27. data/lib/generators/testimonials/install/install_generator.rb +22 -10
  28. data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +7 -5
  29. data/lib/generators/testimonials/install/templates/initializer.rb.tt +25 -2
  30. data/lib/generators/testimonials/migration_helpers.rb +35 -0
  31. data/lib/generators/testimonials/nps/nps_generator.rb +2 -6
  32. data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +1 -1
  33. data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +35 -0
  34. data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +17 -0
  35. data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +11 -8
  36. data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
  37. data/lib/testimonials/configuration.rb +34 -1
  38. data/lib/testimonials/dashboard.css +278 -221
  39. data/lib/testimonials/prompt_helper.rb +4 -0
  40. data/lib/testimonials/seeds.rb +3 -2
  41. data/lib/testimonials/version.rb +1 -1
  42. data/lib/testimonials/widget.js +4 -1
  43. data/lib/testimonials/widget.rb +3 -0
  44. data/lib/testimonials.rb +8 -0
  45. metadata +15 -1
data/config/routes.rb CHANGED
@@ -22,14 +22,27 @@ Testimonials::Engine.routes.draw do
22
22
  end
23
23
 
24
24
  # Media by testimonial id, gated (admin, author, or public_api + publishable).
25
- get ':id/video', to: 'media#video', as: :testimonial_video, constraints: { id: /\d+/ }
26
- get ':id/poster', to: 'media#poster', as: :testimonial_poster, constraints: { id: /\d+/ }
27
- get ':id/avatar', to: 'media#avatar', as: :testimonial_avatar, constraints: { id: /\d+/ }
25
+ get ':id/video', to: 'media#video', as: :testimonial_video
26
+ get ':id/poster', to: 'media#poster', as: :testimonial_poster
27
+ get ':id/avatar', to: 'media#avatar', as: :testimonial_avatar
28
28
 
29
29
  # Flat, human URLs: the mount path IS the resource. /testimonials is the
30
30
  # dashboard, POST /testimonials the widget endpoint, /testimonials/2 a testimonial.
31
- resources :testimonials, path: '', only: %i[create index show update destroy],
32
- constraints: { id: /\d+/ }
31
+ #
32
+ # No `id: /\d+/` constraint: it made these routes bigint-only, which forced
33
+ # the tables to be bigint too, which meant a uuid-keyed host could never
34
+ # attach a video or avatar (its active_storage_attachments.record_id is a
35
+ # uuid column). The constraint was never load-bearing — every fixed-name
36
+ # route above is declared first, so ordering already does the disambiguating,
37
+ # and an id that matches no record still 404s, just via RecordNotFound rather
38
+ # than a routing error.
39
+ #
40
+ # POST goes to SubmissionsController, not to this resource: the widget's write
41
+ # endpoint is public and the rest is staff-only, and only the staff half
42
+ # inherits config.base_controller_class. Sharing one controller would put a
43
+ # host's admin authentication in front of a member leaving a review.
44
+ post '', to: 'submissions#create', as: :submissions
45
+ resources :testimonials, path: '', only: %i[index show update destroy]
33
46
 
34
47
  root to: 'testimonials#index'
35
48
  end
@@ -0,0 +1,17 @@
1
+ # Display examples
2
+
3
+ testimonials deliberately ships **no display UI** — approved testimonials are
4
+ yours to render, via `Testimonials::Testimonial.publishable` inside the app or
5
+ via the JSON API anywhere else. These files are copy-paste starting points so
6
+ you can see the end result in minutes. Take them, restyle them, own them.
7
+
8
+ | File | What it renders |
9
+ | --- | --- |
10
+ | [`wall_of_love.html.erb`](wall_of_love.html.erb) | A responsive grid of text + video testimonials |
11
+ | [`testimonial_card.html.erb`](testimonial_card.html.erb) | A single quote card for a landing/pricing page |
12
+ | [`badge.html.erb`](badge.html.erb) | The "★ 4.9 from 87 reviews" chip |
13
+ | [`json_ld.html.erb`](json_ld.html.erb) | schema.org AggregateRating + Review markup for Google rich snippets |
14
+ | [`static_site.md`](static_site.md) | Rendering testimonials on a separate marketing site (Astro & friends) from the public API |
15
+
16
+ In-app examples read the models directly — no HTTP, no `public_api` needed.
17
+ The static-site example is the one place the public API earns its keep.
@@ -0,0 +1,13 @@
1
+ <%# The "★ 4.9 from 87 reviews" chip. Same numbers the API's /api/stats serves. %>
2
+
3
+ <% scope = Testimonials::Testimonial.publishable.where.not(rating: nil) %>
4
+ <% if (count = scope.count).positive? %>
5
+ <a href="#wall-of-love"
6
+ style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px;
7
+ border: 1px solid #e5e7eb; border-radius: 999px; text-decoration: none;
8
+ font: 600 14px system-ui, sans-serif; color: inherit;">
9
+ <span style="color: #f59e0b;">★</span>
10
+ <%= scope.average(:rating).to_f.round(1) %>
11
+ <span style="font-weight: 400; color: #6b7280;">from <%= count %> reviews</span>
12
+ </a>
13
+ <% end %>
@@ -0,0 +1,28 @@
1
+ <%# schema.org structured data for Google rich snippets (star ratings in
2
+ search results). Put it in the <head> of the page that shows the reviews. %>
3
+
4
+ <% scope = Testimonials::Testimonial.publishable %>
5
+ <% rated = scope.where.not(rating: nil) %>
6
+ <% if rated.exists? %>
7
+ <script type="application/ld+json">
8
+ <%= raw({
9
+ '@context' => 'https://schema.org',
10
+ '@type' => 'Product',
11
+ name: Testimonials.app_name,
12
+ aggregateRating: {
13
+ '@type' => 'AggregateRating',
14
+ ratingValue: rated.average(:rating).to_f.round(2),
15
+ reviewCount: rated.count
16
+ },
17
+ review: scope.featured_first.limit(5).map do |testimonial|
18
+ {
19
+ '@type' => 'Review',
20
+ reviewBody: testimonial.quote,
21
+ author: { '@type' => 'Person', name: testimonial.name },
22
+ reviewRating: testimonial.rating &&
23
+ { '@type' => 'Rating', ratingValue: testimonial.rating }
24
+ }.compact
25
+ end
26
+ }.to_json) %>
27
+ </script>
28
+ <% end %>
@@ -0,0 +1,43 @@
1
+ # Testimonials on a separate marketing site
2
+
3
+ Your Rails app collects and curates; your marketing site (Astro, Eleventy,
4
+ plain HTML on Cloudflare — anything) renders. Turn on the public API:
5
+
6
+ ```ruby
7
+ # config/initializers/testimonials.rb
8
+ config.public_api = true
9
+ ```
10
+
11
+ `GET https://app.example.com/testimonials/api/testimonials` now serves approved +
12
+ consented records to anyone (CORS `*`), and `/testimonials/api/stats` serves the
13
+ badge numbers. Emails are never included.
14
+
15
+ ## Astro example (build-time fetch)
16
+
17
+ ```astro
18
+ ---
19
+ const res = await fetch("https://app.example.com/testimonials/api/testimonials?limit=12");
20
+ const { testimonials } = await res.json();
21
+ const stats = await (await fetch("https://app.example.com/testimonials/api/stats")).json();
22
+ ---
23
+
24
+ <p>★ {stats.average_rating} from {stats.count} reviews</p>
25
+
26
+ <div class="wall">
27
+ {testimonials.map((t) => (
28
+ <figure>
29
+ {t.rating && <div>{"★".repeat(t.rating)}</div>}
30
+ {t.video_url && <video controls preload="metadata" poster={t.poster_url} src={t.video_url}></video>}
31
+ {t.quote && <blockquote>“{t.quote}”</blockquote>}
32
+ <figcaption>
33
+ {t.avatar_url && <img src={t.avatar_url} alt="" width="36" height="36" />}
34
+ <b>{t.name}</b> {t.title_company && <span>— {t.title_company}</span>}
35
+ </figcaption>
36
+ </figure>
37
+ ))}
38
+ </div>
39
+ ```
40
+
41
+ Media URLs (`video_url`, `avatar_url`) point back at the Rails app, which
42
+ redirects to signed Active Storage URLs with Range support — videos play
43
+ directly in a `<video>` tag.
@@ -0,0 +1,17 @@
1
+ <%# One hero quote for a pricing or landing page. Pick it explicitly, or take
2
+ the newest featured one as below. %>
3
+
4
+ <% testimonial = Testimonials::Testimonial.publishable.where(featured: true).newest_first.first %>
5
+ <% if testimonial %>
6
+ <figure style="max-width: 560px; margin: 0 auto; padding: 24px; border: 1px solid #e5e7eb;
7
+ border-radius: 16px; font: 17px/1.6 system-ui, sans-serif; text-align: center;">
8
+ <% if testimonial.rating %>
9
+ <div style="color: #f59e0b; letter-spacing: 3px; margin-bottom: 8px;"><%= '★' * testimonial.rating %></div>
10
+ <% end %>
11
+ <blockquote style="margin: 0 0 16px; font-weight: 500;">“<%= testimonial.quote %>”</blockquote>
12
+ <figcaption style="font-size: 14px; color: #6b7280;">
13
+ <b style="color: inherit;"><%= testimonial.name %></b>
14
+ <% if testimonial.title_company.present? %> — <%= testimonial.title_company %><% end %>
15
+ </figcaption>
16
+ </figure>
17
+ <% end %>
@@ -0,0 +1,43 @@
1
+ <%# A wall of love: drop into any view of the host app and restyle at will.
2
+ Videos stream through the engine's gated media route (admin, or
3
+ public_api + publishable). %>
4
+
5
+ <style>
6
+ .wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
7
+ .wall-card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 18px; background: #fff;
8
+ font: 15px/1.5 system-ui, sans-serif; break-inside: avoid; }
9
+ .wall-stars { color: #f59e0b; letter-spacing: 2px; margin-bottom: 6px; }
10
+ .wall-quote { margin: 0 0 12px; }
11
+ .wall-author { display: flex; align-items: center; gap: 10px; }
12
+ .wall-author img { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
13
+ .wall-author b { display: block; font-size: 14px; }
14
+ .wall-author span { font-size: 13px; color: #6b7280; }
15
+ .wall-card video { width: 100%; border-radius: 10px; background: #000; margin-bottom: 10px; }
16
+ </style>
17
+
18
+ <div class="wall">
19
+ <% Testimonials::Testimonial.publishable.featured_first.limit(12).each do |testimonial| %>
20
+ <figure class="wall-card">
21
+ <% if testimonial.rating %>
22
+ <div class="wall-stars"><%= '★' * testimonial.rating %></div>
23
+ <% end %>
24
+ <% if testimonial.video_attached? %>
25
+ <video controls preload="metadata"
26
+ poster="<%= testimonials.testimonial_poster_path(testimonial) if testimonial.poster_attached? %>"
27
+ src="<%= testimonials.testimonial_video_path(testimonial) %>"></video>
28
+ <% end %>
29
+ <% if testimonial.quote.present? %>
30
+ <blockquote class="wall-quote">“<%= testimonial.quote %>”</blockquote>
31
+ <% end %>
32
+ <figcaption class="wall-author">
33
+ <% if testimonial.avatar_attached? %>
34
+ <img src="<%= testimonials.testimonial_avatar_path(testimonial) %>" alt="">
35
+ <% end %>
36
+ <div>
37
+ <b><%= testimonial.name %></b>
38
+ <span><%= testimonial.title_company %></span>
39
+ </div>
40
+ </figcaption>
41
+ </figure>
42
+ <% end %>
43
+ </div>
@@ -2,23 +2,34 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module Testimonials
7
8
  module Generators
8
9
  class InstallGenerator < Rails::Generators::Base
9
10
  include ActiveRecord::Generators::Migration
11
+ include MigrationHelpers
10
12
 
11
13
  source_root File.expand_path('templates', __dir__)
12
14
 
13
15
  desc 'Installs testimonials: config initializer, migration, and engine mount.'
14
16
 
15
- # NPS is the one part of the gem an app can genuinely not want. Skipping
16
- # it leaves out the table and writes `config.nps = false`, so nothing
17
- # ever reaches a table that isn't there — no runtime introspection, no
18
- # boot-time database call. `testimonials:nps` adds it later.
17
+ # Two parts of the gem an app can genuinely not want. Skipping either
18
+ # leaves out its table and writes the matching config flag as false, so
19
+ # nothing ever reaches a table that isn't there — no runtime
20
+ # introspection, no boot-time database call. `testimonials:nps` and
21
+ # `testimonials:prompt_events` add them later.
19
22
  class_option :skip_nps, type: :boolean, default: false,
20
23
  desc: 'Leave out the NPS table and turn the NPS flow off'
21
24
 
25
+ # The throttle ledger only earns its rows if the app auto-prompts. An
26
+ # app that opens the widget from its own button writes one row per
27
+ # dismissal for nothing, so it can leave the table out — and then
28
+ # testimonial_prompt! no longer auto-opens, since nothing could throttle
29
+ # it. Explicit opens are unaffected.
30
+ class_option :skip_prompt_events, type: :boolean, default: false,
31
+ desc: 'Leave out the prompt-history table; no auto-prompts, only explicit opens'
32
+
22
33
  def create_initializer
23
34
  template 'initializer.rb.tt', 'config/initializers/testimonials.rb'
24
35
  end
@@ -39,15 +50,16 @@ module Testimonials
39
50
  samples = options[:skip_nps] ? 'testimonials' : 'testimonials and NPS'
40
51
  say "Optional: run `bin/rails testimonials:seed_demo` for sample #{samples}."
41
52
  say "Collect from outside the app via /testimonials/new.\n"
42
- return unless options[:skip_nps]
43
53
 
44
- say 'Installed without NPS. Add it later with `bin/rails generate testimonials:nps`.', :yellow
45
- end
54
+ if options[:skip_nps]
55
+ say 'Installed without NPS. Add it later with `bin/rails generate testimonials:nps`.', :yellow
56
+ end
46
57
 
47
- private
58
+ return unless options[:skip_prompt_events]
48
59
 
49
- def migration_version
50
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
60
+ say 'Installed without prompt history, so testimonial_prompt! will not auto-open the ' \
61
+ 'widget — open it from your own button. Add it later with ' \
62
+ '`bin/rails generate testimonials:prompt_events`.', :yellow
51
63
  end
52
64
  end
53
65
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- create_table :testimonials_testimonials do |t|
5
+ create_table :testimonials_testimonials<%= primary_key_type_option %> do |t|
6
6
  t.string :kind, null: false, default: 'text'
7
7
  t.text :body
8
8
  t.integer :rating
@@ -26,9 +26,9 @@ class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
26
26
  add_index :testimonials_testimonials, :status
27
27
  add_index :testimonials_testimonials, %i[tenant status]
28
28
  add_index :testimonials_testimonials, %i[tenant status consent_given]
29
-
30
29
  <% unless options[:skip_nps] -%>
31
- create_table :testimonials_nps_responses do |t|
30
+
31
+ create_table :testimonials_nps_responses<%= primary_key_type_option %> do |t|
32
32
  t.integer :score, null: false
33
33
  t.text :comment
34
34
  t.string :author_id
@@ -42,9 +42,10 @@ class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
42
42
  t.timestamps
43
43
  end
44
44
  add_index :testimonials_nps_responses, %i[tenant score]
45
-
46
45
  <% end -%>
47
- create_table :testimonials_prompt_events do |t|
46
+ <% unless options[:skip_prompt_events] -%>
47
+
48
+ create_table :testimonials_prompt_events<%= primary_key_type_option %> do |t|
48
49
  t.string :kind, null: false
49
50
  t.string :action, null: false
50
51
  t.string :author_id
@@ -55,5 +56,6 @@ class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
55
56
  end
56
57
  add_index :testimonials_prompt_events, %i[tenant author_id kind]
57
58
  add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
59
+ <% end -%>
58
60
  end
59
61
  end
@@ -13,9 +13,17 @@ Testimonials.configure do |config|
13
13
  # only — override before deploying.
14
14
  # config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
15
15
  #
16
- # Render the dashboard inside your app's admin layout. Default: the gem's
17
- # standalone dashboard layout.
16
+ # Two ways to put the dashboard inside an admin you already have.
17
+ #
18
+ # The layout only — the gem's controllers, your shell:
18
19
  # config.admin_layout = "admin/application"
20
+ #
21
+ # Or the whole stack. Name the controller your own admin inherits from and
22
+ # the dashboard picks up its layout, helpers, authentication and any request
23
+ # context its before_actions set up. Only the dashboard inherits it; the
24
+ # widget's endpoints stay public, so this cannot ask a member leaving a
25
+ # review for a staff session.
26
+ # config.base_controller_class = "Admin::BaseController"
19
27
 
20
28
  # Attribute submissions to a user (optional). Return an object responding
21
29
  # to #id, or nil. Receives the request.
@@ -69,6 +77,21 @@ Testimonials.configure do |config|
69
77
  # config.reprompt_after = 90.days
70
78
  # config.max_prompts = 3
71
79
 
80
+ # The prompt history the throttle reads (testimonials_prompt_events).
81
+ <% if options[:skip_prompt_events] -%>
82
+ # Installed with --skip-prompt-events, so there is no table and no history is
83
+ # written. Nothing is throttled, so nothing auto-opens either:
84
+ # testimonial_prompt! is a no-op here and the widget opens from your own
85
+ # button, data-testimonial-prompt, or window.Testimonials.open(). To turn
86
+ # auto-prompts on later: `bin/rails generate testimonials:prompt_events &&
87
+ # bin/rails db:migrate`, then flip this to true.
88
+ config.prompt_events = false
89
+ <% else -%>
90
+ # False keeps no history — and then nothing auto-opens, since nothing could
91
+ # throttle it. For apps that only open the widget on a click.
92
+ # config.prompt_events = true
93
+ <% end -%>
94
+
72
95
  # The public-use consent line, stored verbatim when a customer picks
73
96
  # "use publicly". nil = localized default ("You can use my testimonial
74
97
  # publicly in your marketing and sales."). The private-use line is
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testimonials
4
+ module Generators
5
+ # Shared bits every migration-writing generator needs.
6
+ module MigrationHelpers
7
+ private
8
+
9
+ def migration_version
10
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
11
+ end
12
+
13
+ # Follow the host's own key type instead of forcing bigint. An app that
14
+ # keys its models with uuids sets this, and its
15
+ # `active_storage_attachments.record_id` is then a uuid column — bigint
16
+ # tables here could never take a video or avatar attachment, because a
17
+ # uuid foreign key has nowhere to point.
18
+ #
19
+ # Same lookup Rails' own Active Storage, Action Text and Action Mailbox
20
+ # migrations do, so a host that set it once gets consistent tables from
21
+ # all of them.
22
+ #
23
+ # Rendered as a `create_table` option rather than a bare value, because a
24
+ # template is expanded at generate time: emitting the method name would
25
+ # put `id: primary_key_type` in the migration, where nothing defines it.
26
+ # A host with no setting gets no option at all, so the migration reads
27
+ # exactly as it always did.
28
+ def primary_key_type_option
29
+ config = Rails.configuration.generators
30
+ type = config.options[config.orm][:primary_key_type]
31
+ type ? ", id: :#{type}" : ''
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module Testimonials
7
8
  module Generators
@@ -12,6 +13,7 @@ module Testimonials
12
13
  # bin/rails generate testimonials:nps && bin/rails db:migrate
13
14
  class NpsGenerator < Rails::Generators::Base
14
15
  include ActiveRecord::Generators::Migration
16
+ include MigrationHelpers
15
17
 
16
18
  source_root File.expand_path('templates', __dir__)
17
19
 
@@ -26,12 +28,6 @@ module Testimonials
26
28
  say "\nNPS table queued. Run `rails db:migrate`, then set", :green
27
29
  say 'config.nps = true in config/initializers/testimonials.rb to turn the flow on.'
28
30
  end
29
-
30
- private
31
-
32
- def migration_version
33
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
34
- end
35
31
  end
36
32
  end
37
33
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  class CreateTestimonialsNpsResponses < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- create_table :testimonials_nps_responses do |t|
5
+ create_table :testimonials_nps_responses<%= primary_key_type_option %> do |t|
6
6
  t.integer :score, null: false
7
7
  t.text :comment
8
8
  t.string :author_id
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
6
+
7
+ module Testimonials
8
+ module Generators
9
+ # For apps that installed with --skip-prompt-events and later want
10
+ # auto-prompts: creates the testimonials_prompt_events table, the ledger
11
+ # the throttle reads. A full install already has it. Additive and safe —
12
+ # nothing reads or writes the table until config.prompt_events is true.
13
+ #
14
+ # bin/rails generate testimonials:prompt_events && bin/rails db:migrate
15
+ class PromptEventsGenerator < Rails::Generators::Base
16
+ include ActiveRecord::Generators::Migration
17
+ include MigrationHelpers
18
+
19
+ source_root File.expand_path('templates', __dir__)
20
+
21
+ desc 'Adds the prompt history table for an install that skipped it.'
22
+
23
+ def create_migration_file
24
+ migration_template 'create_testimonials_prompt_events.rb.tt',
25
+ 'db/migrate/create_testimonials_prompt_events.rb'
26
+ end
27
+
28
+ def post_install
29
+ say "\nPrompt history table queued. Run `rails db:migrate`, then set", :green
30
+ say 'config.prompt_events = true in config/initializers/testimonials.rb to let'
31
+ say 'testimonial_prompt! auto-open the widget again.'
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTestimonialsPromptEvents < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :testimonials_prompt_events<%= primary_key_type_option %> do |t|
6
+ t.string :kind, null: false
7
+ t.string :action, null: false
8
+ t.string :author_id
9
+ t.string :visitor_token
10
+ t.string :tenant # opaque per-tenant key; nil = single global collection
11
+
12
+ t.timestamps
13
+ end
14
+ add_index :testimonials_prompt_events, %i[tenant author_id kind]
15
+ add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
16
+ end
17
+ end
@@ -3,17 +3,20 @@
3
3
  class AddTenantToTestimonials < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
5
  add_column :testimonials_testimonials, :tenant, :string
6
- add_column :testimonials_prompt_events, :tenant, :string
7
-
8
6
  add_index :testimonials_testimonials, %i[tenant status]
9
7
  add_index :testimonials_testimonials, %i[tenant status consent_given]
10
- add_index :testimonials_prompt_events, %i[tenant author_id kind]
11
- add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
12
8
 
13
- # An install run with --skip-nps has no NPS table to add the column to.
14
- return unless table_exists?(:testimonials_nps_responses)
9
+ # The optional tables: an install run with --skip-nps or
10
+ # --skip-prompt-events has no table to add the column to.
11
+ if table_exists?(:testimonials_nps_responses)
12
+ add_column :testimonials_nps_responses, :tenant, :string
13
+ add_index :testimonials_nps_responses, %i[tenant score]
14
+ end
15
15
 
16
- add_column :testimonials_nps_responses, :tenant, :string
17
- add_index :testimonials_nps_responses, %i[tenant score]
16
+ return unless table_exists?(:testimonials_prompt_events)
17
+
18
+ add_column :testimonials_prompt_events, :tenant, :string
19
+ add_index :testimonials_prompt_events, %i[tenant author_id kind]
20
+ add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
18
21
  end
19
22
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module Testimonials
7
8
  module Generators
@@ -15,6 +16,7 @@ module Testimonials
15
16
  # bin/rails generate testimonials:tenant && bin/rails db:migrate
16
17
  class TenantGenerator < Rails::Generators::Base
17
18
  include ActiveRecord::Generators::Migration
19
+ include MigrationHelpers
18
20
 
19
21
  source_root File.expand_path('templates', __dir__)
20
22
 
@@ -29,12 +31,6 @@ module Testimonials
29
31
  say "\ntenant column queued. Run `rails db:migrate`, then set", :green
30
32
  say 'config.tenant in config/initializers/testimonials.rb to scope per tenant.'
31
33
  end
32
-
33
- private
34
-
35
- def migration_version
36
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
37
- end
38
34
  end
39
35
  end
40
36
  end
@@ -5,6 +5,10 @@ module Testimonials
5
5
  # works with zero configuration; the hooks below let an app decide who gets
6
6
  # prompted, who can triage, and how submissions are attributed.
7
7
  class Configuration
8
+ # The gem's own dashboard layout. Compared against, so DashboardController
9
+ # can tell "the host left this alone" from "the host chose this".
10
+ DEFAULT_ADMIN_LAYOUT = 'testimonials/application'
11
+
8
12
  # Shown in the widget ("Enjoying %{app}?") and interpolated into the
9
13
  # default questions. nil resolves to the Rails application name.
10
14
  attr_accessor :app_name
@@ -19,8 +23,26 @@ module Testimonials
19
23
 
20
24
  # Layout used by the built-in dashboard. Override this to render
21
25
  # Testimonials inside your app's admin shell, e.g. "admin/application".
26
+ # The lighter of the two ways to adopt a host's chrome; see
27
+ # base_controller_class for the other.
22
28
  attr_accessor :admin_layout
23
29
 
30
+ # The controller the DASHBOARD inherits from, as a String so it resolves
31
+ # lazily rather than at config time. Default: a plain
32
+ # 'ActionController::Base', where `authorize_admin` is the only gate.
33
+ #
34
+ # Name the controller your own admin already inherits from and the
35
+ # dashboard adopts that whole stack — layout, helpers, authentication, and
36
+ # any request context your before_actions set up (a `Current` attribute the
37
+ # layout reads, say). `admin_layout` covers only the layout, which leaves a
38
+ # host layout calling its own helpers to raise NameError under the engine's
39
+ # isolated namespace.
40
+ #
41
+ # Only the dashboard uses it. The widget's endpoints stay on the engine's
42
+ # own public controller, so an admin base controller here can never demand
43
+ # a staff session from a member leaving a review.
44
+ attr_accessor :base_controller_class
45
+
24
46
  # Resolve the current user for attribution (optional). Return an object
25
47
  # responding to #id, or nil. Receives the request.
26
48
  attr_accessor :current_user
@@ -69,6 +91,15 @@ module Testimonials
69
91
  # (clicking your link) always work.
70
92
  attr_accessor :reprompt_after, :max_prompts
71
93
 
94
+ # The throttling ledger itself (testimonials_prompt_events). Off means no
95
+ # per-user prompt history is written — and therefore no auto-prompts:
96
+ # `testimonial_prompt!` stops opening the widget, because a prompt that
97
+ # can't be throttled would reopen on every page. Explicit opens — your
98
+ # own button, `data-testimonial-prompt`, `window.Testimonials.open()`,
99
+ # the public pages — are unaffected. For apps that only ever open the
100
+ # widget on a click; `--skip-prompt-events` writes it at install time.
101
+ attr_accessor :prompt_events
102
+
72
103
  # Consent line stored verbatim with each submission. nil uses the
73
104
  # localized default.
74
105
  attr_accessor :consent_text
@@ -108,7 +139,8 @@ module Testimonials
108
139
  @app_name = nil
109
140
  @enabled = ->(_request) { true }
110
141
  @authorize_admin = ->(_request) { Rails.env.development? }
111
- @admin_layout = 'testimonials/application'
142
+ @admin_layout = DEFAULT_ADMIN_LAYOUT
143
+ @base_controller_class = 'ActionController::Base'
112
144
  @current_user = ->(_request) {}
113
145
  @tenant = ->(_request) {}
114
146
  @user_display = lambda { |user|
@@ -123,6 +155,7 @@ module Testimonials
123
155
  @storage_service = nil
124
156
  @reprompt_after = 90 * 24 * 60 * 60
125
157
  @max_prompts = 3
158
+ @prompt_events = true
126
159
  @consent_text = nil
127
160
  @public_collection = true
128
161
  @public_api = false