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.
- checksums.yaml +4 -4
- data/AGENTS.md +210 -0
- data/CHANGELOG.md +68 -0
- data/README.md +30 -4
- data/app/controllers/concerns/testimonials/request_context.rb +75 -0
- data/app/controllers/testimonials/application_controller.rb +10 -56
- data/app/controllers/testimonials/dashboard_controller.rb +40 -0
- data/app/controllers/testimonials/events_controller.rb +9 -0
- data/app/controllers/testimonials/nps_responses_controller.rb +1 -4
- data/app/controllers/testimonials/submissions_controller.rb +169 -0
- data/app/controllers/testimonials/testimonials_controller.rb +6 -166
- data/app/helpers/testimonials/widget_helper.rb +4 -0
- data/app/models/testimonials/prompt_event.rb +12 -0
- data/app/views/layouts/testimonials/application.html.erb +5 -8
- data/app/views/testimonials/nps_responses/index.html.erb +154 -152
- data/app/views/testimonials/nps_responses/show.html.erb +7 -5
- data/app/views/testimonials/shared/_dashboard.html.erb +18 -0
- data/app/views/testimonials/testimonials/index.html.erb +110 -108
- data/app/views/testimonials/testimonials/show.html.erb +7 -5
- data/config/routes.rb +18 -5
- data/examples/README.md +17 -0
- data/examples/badge.html.erb +13 -0
- data/examples/json_ld.html.erb +28 -0
- data/examples/static_site.md +43 -0
- data/examples/testimonial_card.html.erb +17 -0
- data/examples/wall_of_love.html.erb +43 -0
- data/lib/generators/testimonials/install/install_generator.rb +22 -10
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +7 -5
- data/lib/generators/testimonials/install/templates/initializer.rb.tt +25 -2
- data/lib/generators/testimonials/migration_helpers.rb +35 -0
- data/lib/generators/testimonials/nps/nps_generator.rb +2 -6
- data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +1 -1
- data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +35 -0
- data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +17 -0
- data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +11 -8
- data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
- data/lib/testimonials/configuration.rb +34 -1
- data/lib/testimonials/dashboard.css +278 -221
- data/lib/testimonials/prompt_helper.rb +4 -0
- data/lib/testimonials/seeds.rb +3 -2
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +4 -1
- data/lib/testimonials/widget.rb +3 -0
- data/lib/testimonials.rb +8 -0
- 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
|
|
26
|
-
get ':id/poster', to: 'media#poster', as: :testimonial_poster
|
|
27
|
-
get ':id/avatar', to: 'media#avatar', as: :testimonial_avatar
|
|
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
|
-
|
|
32
|
-
|
|
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
|
data/examples/README.md
ADDED
|
@@ -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
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# ever reaches a table that isn't there — no runtime
|
|
18
|
-
# boot-time database call. `testimonials:nps`
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
58
|
+
return unless options[:skip_prompt_events]
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
17
|
-
#
|
|
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
|
data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt
ADDED
|
@@ -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
|
-
#
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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 =
|
|
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
|