testimonials 0.7.8 → 0.7.10
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 +207 -0
- data/CHANGELOG.md +38 -0
- data/README.md +46 -0
- data/app/controllers/testimonials/events_controller.rb +9 -0
- data/app/controllers/testimonials/nps_responses_controller.rb +7 -0
- data/app/helpers/testimonials/widget_helper.rb +4 -0
- data/app/models/testimonials/prompt_event.rb +12 -0
- data/app/views/testimonials/nps_responses/index.html.erb +4 -4
- 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 +29 -2
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +4 -0
- data/lib/generators/testimonials/install/templates/{initializer.rb → initializer.rb.tt} +23 -0
- data/lib/generators/testimonials/nps/nps_generator.rb +37 -0
- data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +20 -0
- data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +39 -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 -4
- data/lib/testimonials/configuration.rb +10 -0
- data/lib/testimonials/prompt_helper.rb +4 -0
- data/lib/testimonials/seeds.rb +4 -2
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +4 -1
- data/lib/testimonials/widget.rb +3 -0
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7ed14e07e92b6d456853cf8b9c04ece86f36d17873091f515f5c47e393894ca
|
|
4
|
+
data.tar.gz: c05b6be1e36b1592eabe270c18f7da4a15dafdca192a01141389141b76a37aca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff625ff280d473f959693892b942f52faa9b9e4f00ae139e7d07a94424d08da845ef387df6639cf1b37c536740aa0aa8d2350cd51416fc332a9b54bf22d810de
|
|
7
|
+
data.tar.gz: 2f721d94802015c6061a159d8e01797d09a2e1f5c453397f4519ed11508a464607009908685108b4c94d149336a5b1bf1d4d3998a0d04917402eee98a9780fa0
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Instructions for coding agents. Two audiences:
|
|
4
|
+
|
|
5
|
+
- **[Installing testimonials into a Rails app](#installing-into-a-rails-app)** — you are working in a host app and were asked to add testimonials, reviews, or NPS.
|
|
6
|
+
- **[Working on the gem itself](#working-on-the-gem-itself)** — you are working in this repository.
|
|
7
|
+
|
|
8
|
+
Requirements: Ruby >= 3.2, Rails >= 7.1. Active Storage only for video and avatar uploads.
|
|
9
|
+
|
|
10
|
+
If you are in a host app and this file is not in front of you, it ships inside the gem: `cat "$(bundle show testimonials)/AGENTS.md"`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installing into a Rails app
|
|
15
|
+
|
|
16
|
+
### 1. Decide two things first — they are install-time flags
|
|
17
|
+
|
|
18
|
+
Both features can be added later, but skipping them now means smaller schema and less code reaching the database. Ask the user only if the answer isn't already obvious from their request.
|
|
19
|
+
|
|
20
|
+
| Question | If no |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| Do they want NPS (the 0–10 "how likely are you to recommend" survey)? | `--skip-nps` |
|
|
23
|
+
| Should the widget ever **open itself** — `testimonial_prompt!` at a success moment — rather than only on a click? | `--skip-prompt-events` |
|
|
24
|
+
|
|
25
|
+
Default to a full install when unsure. "Just let users leave reviews from a button in the menu" is the common case for `--skip-prompt-events`.
|
|
26
|
+
|
|
27
|
+
### 2. Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle add testimonials
|
|
31
|
+
bin/rails generate testimonials:install # + any --skip-* flags from step 1
|
|
32
|
+
bin/rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The generator writes `config/initializers/testimonials.rb`, one migration, and `mount_testimonials at: "/testimonials"` into `config/routes.rb`. Read the initializer it wrote — every option is documented there in comments, and it is the source of truth over any summary of it, including this file.
|
|
36
|
+
|
|
37
|
+
Every `config.…` line below belongs inside the `Testimonials.configure do |config|` block in that initializer. Uncomment and edit in place rather than appending a second `configure` block.
|
|
38
|
+
|
|
39
|
+
### 3. Wire the three things the generator cannot
|
|
40
|
+
|
|
41
|
+
**a. The widget tag.** Nothing renders until this is on the page:
|
|
42
|
+
|
|
43
|
+
```erb
|
|
44
|
+
<%# app/views/layouts/application.html.erb, before </body> %>
|
|
45
|
+
<%= testimonials_tag %>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The helper is injected into ActionView by the engine — no include, no import, no asset pipeline entry. It renders nothing until the widget is opened, and it must be present on any page where the widget can open.
|
|
49
|
+
|
|
50
|
+
**b. `authorize_admin` — do this before deploying.** The dashboard at `/testimonials` defaults to **development only**. It fails closed, so shipping without this is not an open door — it is a 403 telling you to set it.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**c. Attribution**, if the app has users. Optional, but without it every submission is a guest.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
config.current_user = ->(request) { request.env["warden"]&.user } # Devise/Warden
|
|
60
|
+
config.user_display = ->(user) { { name: user.name, email: user.email } }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> **Every gate receives the raw `request`, not a controller.** `current_user`, `authorize_admin`, `enabled` and `tenant` are lambdas over `ActionDispatch::Request`. Writing `->(request) { current_user }` is the single most common mistake here — that method does not exist in this scope. Resolve the user *from the request*: Warden env, a signed cookie, `Current.user` if the app sets it in a middleware/`before_action` that has already run.
|
|
64
|
+
|
|
65
|
+
Rails 8 built-in auth:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
config.current_user = lambda do |request|
|
|
69
|
+
token = request.cookies["session_token"]
|
|
70
|
+
Session.find_signed(token)&.user if token
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 4. Verify
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bin/rails routes | grep testimonials # engine mounted
|
|
78
|
+
bin/rails testimonials:seed_demo # optional sample data, idempotent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then, in the running app: load any page and confirm `data-testimonials-config` appears in the HTML source, and that `/testimonials` renders the dashboard in development. The widget stays invisible until opened — an empty-looking page is correct.
|
|
82
|
+
|
|
83
|
+
### Opening the widget
|
|
84
|
+
|
|
85
|
+
| Way | Code | Throttled? |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| Your own button | `<%= testimonials_button %>`, or any element with `data-testimonial-prompt` (`="nps"` for NPS) | No |
|
|
88
|
+
| JavaScript | `window.Testimonials.open()` / `window.Testimonials.openNps()` | No |
|
|
89
|
+
| From a controller, at a success moment | `testimonial_prompt!` / `testimonial_prompt!(:nps)` | Yes |
|
|
90
|
+
|
|
91
|
+
`testimonial_prompt!` is available in every controller (the engine includes it). It sets a flash and the widget auto-opens on the next rendered page **if** the throttle allows — already submitted, recently dismissed, or prompted `max_prompts` times means it stays shut. Call it liberally; that is the design.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
def create
|
|
95
|
+
# ...
|
|
96
|
+
testimonial_prompt! if current_user.invoices.count == 10
|
|
97
|
+
redirect_to invoices_path
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**On a `--skip-prompt-events` install `testimonial_prompt!` is a no-op** — with no history there is nothing to throttle with, and a prompt nothing can throttle would reopen on every page. Use an explicit opener instead, or add the ledger (see below).
|
|
102
|
+
|
|
103
|
+
### Rendering what was collected
|
|
104
|
+
|
|
105
|
+
The gem ships **no display UI** on purpose, and its own views are for the dashboard and the public pages only. Render approved testimonials with the host app's own markup:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Testimonials::Testimonial.publishable.featured_first.limit(6) # approved + publicly consented
|
|
109
|
+
Testimonials::Testimonial.publishable.for_tenant(key) # multi-tenant
|
|
110
|
+
Testimonials::NpsResponse.score # −100..100
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`examples/` in the gem has copy-paste starting points: `wall_of_love.html.erb`, `testimonial_card.html.erb`, `badge.html.erb`, `json_ld.html.erb` (schema.org), `static_site.md`. Prefer adapting one over inventing a layout.
|
|
114
|
+
|
|
115
|
+
There is also a read API — `GET /testimonials/api/testimonials` (filters: `featured=1`, `min_rating=4`, `kind=video`, `limit=12`) and `GET /testimonials/api/stats`. Admin-only unless `config.public_api = true`. Emails and author ids are never serialized.
|
|
116
|
+
|
|
117
|
+
### Adding a skipped feature later
|
|
118
|
+
|
|
119
|
+
Order matters: **migrate first, then flip the flag.** The flag is what the code checks, so setting it before the table exists is the one broken state.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bin/rails generate testimonials:nps # then db:migrate, then config.nps = true
|
|
123
|
+
bin/rails generate testimonials:prompt_events # then db:migrate, then config.prompt_events = true
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Both land on exactly the schema a full install produces, tenant column and indexes included.
|
|
127
|
+
|
|
128
|
+
### Multi-tenancy
|
|
129
|
+
|
|
130
|
+
One resolver returning an **opaque key** — GlobalID, id, subdomain, slug. The gem never takes a foreign key into host models:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Optional sugar on a host model (`has_testimonials` is available on every Active Record class already):
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
class Organization < ApplicationRecord
|
|
140
|
+
has_testimonials # keyed by to_gid.to_s — must match config.tenant
|
|
141
|
+
end
|
|
142
|
+
organization.testimonials.approved
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`bin/rails generate testimonials:tenant` exists **only** to add the `tenant` column to installs made before it existed. A fresh install already has it, and running that generator will fail on a duplicate column. Do not run it as part of a new install.
|
|
146
|
+
|
|
147
|
+
### Do not
|
|
148
|
+
|
|
149
|
+
- **Do not hand-write the migration or the initializer.** Use the generators; the templates track the schema.
|
|
150
|
+
- **Do not copy `widget.js` into `app/javascript`, or add a `<script>` tag for it.** `testimonials_tag` renders both tags itself, and the engine serves the code at `/testimonials/widget.js` with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
151
|
+
- **Do not edit views inside the gem.** Override the dashboard's shell with `config.admin_layout = "admin/application"` if it needs to live inside an existing admin.
|
|
152
|
+
- **Do not check whether a table exists to decide if a feature is on.** `Testimonials.config.nps` and `Testimonials.config.prompt_events` are the source of truth, deliberately — nothing in the gem introspects the schema at runtime.
|
|
153
|
+
- **Do not set config outside the initializer.** `rate_limit` in particular is read once when the controller class loads; assigning config in a controller or per-request is a global mutation across the process.
|
|
154
|
+
- **Do not add `testimonials_tag` more than once per page,** and do not put it in a partial that some pages skip while still calling `testimonial_prompt!` — the prompt is consumed from the flash by whatever renders next.
|
|
155
|
+
|
|
156
|
+
### Configuration worth knowing
|
|
157
|
+
|
|
158
|
+
Everything is optional; a fresh install works with zero config. Full list with comments is in the generated initializer.
|
|
159
|
+
|
|
160
|
+
| Option | Default | Note |
|
|
161
|
+
| --- | --- | --- |
|
|
162
|
+
| `authorize_admin` | development only | **Set before deploying.** |
|
|
163
|
+
| `current_user`, `user_display`, `tenant`, `enabled` | no-ops | Lambdas over the raw request. |
|
|
164
|
+
| `nps` | `true` | `false` = no prompt, no public NPS page, no dashboard tab, no `nps_score`. |
|
|
165
|
+
| `prompt_events` | `true` | `false` = no prompt history, and therefore no auto-prompts at all. |
|
|
166
|
+
| `reprompt_after`, `max_prompts` | `90.days`, `3` | Throttle for auto-prompts only. |
|
|
167
|
+
| `video`, `avatars` | `true` | Need Active Storage; they self-disable when it isn't loaded. |
|
|
168
|
+
| `public_collection` | `true` | `/testimonials/new` and `/testimonials/nps/new`. Shareable links. |
|
|
169
|
+
| `public_api` | `false` | Serves the read API without auth, CORS `*`. |
|
|
170
|
+
| `on_submit`, `on_detractor` | no-ops | Run inline after save — keep fast or enqueue a job. |
|
|
171
|
+
| `mount_path` | `"/testimonials"` | Must match `mount_testimonials at:` in routes. |
|
|
172
|
+
| `rate_limit` | `{ to: 5, within: 60 }` | Rails 7.2+; ignored on 7.1. `nil` disables. |
|
|
173
|
+
|
|
174
|
+
Turbo Drive and nonce-based CSP work out of the box; the widget config rides in a `type="application/json"` block (data, not code) and the code is a same-origin `src` script. 26 locales ship with the gem, including localized guiding questions.
|
|
175
|
+
|
|
176
|
+
### Common failure modes
|
|
177
|
+
|
|
178
|
+
| Symptom | Cause |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| `/testimonials` returns 403 "Set Testimonials.config.authorize_admin to grant access" | Exactly what it says: still at the development-only default. |
|
|
181
|
+
| Widget never appears | `testimonials_tag` missing from the rendered layout, or `config.enabled` returning false. |
|
|
182
|
+
| `testimonial_prompt!` never opens anything | `config.prompt_events` is false, or the user is throttled (submitted / dismissed recently / hit `max_prompts`). |
|
|
183
|
+
| `no such table: testimonials_nps_responses` (or `..._prompt_events`) | A flag was flipped to `true` before running the matching generator and `db:migrate`. |
|
|
184
|
+
| `/testimonials/nps_responses` 404s | `config.nps` is false. The rows are still there; flip it back to read them. |
|
|
185
|
+
| Video recording missing | Active Storage not installed, or `config.video = false`. |
|
|
186
|
+
| `undefined local variable current_user` in the initializer | A gate lambda treated its argument as a controller. It is a `request`. |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Working on the gem itself
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
bundle exec rake test # minitest, dummy app under test/dummy
|
|
194
|
+
bundle exec rubocop # must be clean
|
|
195
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Layout: `app/` controllers, models, dashboard views · `lib/testimonials/` config, widget JS/CSS, seeds, engine · `lib/generators/testimonials/` install, nps, prompt_events, tenant · `config/locales/` 26 locales · `test/` minitest, `test/dummy` the host app · `examples/` display snippets for hosts.
|
|
199
|
+
|
|
200
|
+
Conventions this codebase holds to — follow them rather than the first thing that works:
|
|
201
|
+
|
|
202
|
+
- **Optional features are guarded by a config flag, never by schema introspection.** `--skip-nps` and `--skip-prompt-events` leave tables out; nothing may ask the database whether a table exists, and nothing may touch the database at boot. Guard at the entry points (one `before_action`, one model class method) so the flag is checked once, not at six call sites.
|
|
203
|
+
- **Every install shape gets a test that drops the table for real** (`test/integration/skip_nps_test.rb`, `skip_prompt_events_test.rb`), so a slipped guard raises `no such table` instead of passing quietly. Generator tests pin each migration and initializer shape and check the ERB still compiles as Ruby.
|
|
204
|
+
- **The widget is plain ES5-style JS in `lib/testimonials/widget.js`**, served by the engine, no build step, no framework. It reads its config from a JSON block on every `turbo:load`.
|
|
205
|
+
- **Display stays headless.** New display UI belongs in `examples/`, not in `app/views`.
|
|
206
|
+
- Every user-facing change bumps `lib/testimonials/version.rb` and adds a `CHANGELOG.md` entry that says what it costs, not only what it adds.
|
|
207
|
+
- Commit messages are prose that explains the tradeoff — read `git log` before writing one.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.10
|
|
4
|
+
|
|
5
|
+
- The prompt-history ledger is now optional too. `bin/rails generate
|
|
6
|
+
testimonials:install --skip-prompt-events` leaves out the
|
|
7
|
+
`testimonials_prompt_events` table and writes `config.prompt_events = false`,
|
|
8
|
+
for apps that open the widget from their own button. `bin/rails generate
|
|
9
|
+
testimonials:prompt_events` adds the table later, like `testimonials:nps`.
|
|
10
|
+
- With the flag off **nothing auto-opens**: `testimonial_prompt!` is a no-op,
|
|
11
|
+
because the history exists only to throttle auto-prompts and a prompt nothing
|
|
12
|
+
can throttle would reopen on every page. Explicit opens
|
|
13
|
+
(`data-testimonial-prompt`, `window.Testimonials.open()`), both public pages,
|
|
14
|
+
the dashboard and every submission path work unchanged.
|
|
15
|
+
- `POST /testimonials/events` is refused when the flag is off, and the widget
|
|
16
|
+
stops posting shown/dismissed at all (`promptEvents.enabled` in its config).
|
|
17
|
+
- `testimonials:seed_demo` skips the demo prompt history when the ledger is
|
|
18
|
+
off, and the `testimonials:tenant` migration skips either optional table when
|
|
19
|
+
it isn't there.
|
|
20
|
+
- Adds `AGENTS.md`: install and integration instructions written for coding
|
|
21
|
+
agents, covering the request-shaped config lambdas, both skip flags, and the
|
|
22
|
+
mistakes agents actually make. It ships inside the gem, so
|
|
23
|
+
`cat "$(bundle show testimonials)/AGENTS.md"` works from a host app —
|
|
24
|
+
`examples/` now ships for the same reason.
|
|
25
|
+
|
|
26
|
+
## 0.7.9
|
|
27
|
+
|
|
28
|
+
- NPS is now optional at install time. `bin/rails generate testimonials:install
|
|
29
|
+
--skip-nps` leaves out the `testimonials_nps_responses` table and writes
|
|
30
|
+
`config.nps = false`, so an app that only wants testimonials carries neither
|
|
31
|
+
the table nor the NPS tab. `bin/rails generate testimonials:nps` adds the
|
|
32
|
+
table later, mirroring the `testimonials:tenant` upgrade path.
|
|
33
|
+
- The guard is `config.nps`, not table introspection — nothing checks the
|
|
34
|
+
schema at runtime and nothing touches the database at boot. Consequently
|
|
35
|
+
`/testimonials/nps_responses` now 404s when `config.nps` is false instead of
|
|
36
|
+
still serving history; flip the flag back to read old responses. The
|
|
37
|
+
dashboard nav already hid the tab, so only a typed URL reached it.
|
|
38
|
+
- `testimonials:seed_demo` skips NPS rows when the flow is off, and the
|
|
39
|
+
`testimonials:tenant` migration skips the NPS table when it isn't there.
|
|
40
|
+
|
|
3
41
|
## 0.7.8
|
|
4
42
|
|
|
5
43
|
- The NPS dashboard now says what the score means. The card carries the band
|
data/README.md
CHANGED
|
@@ -33,6 +33,24 @@ bin/rails db:migrate
|
|
|
33
33
|
The generator writes the initializer, the migration, and mounts the engine at
|
|
34
34
|
`/testimonials`. Nothing renders until the widget is opened.
|
|
35
35
|
|
|
36
|
+
Two parts are optional at install time, and skipping either leaves out its
|
|
37
|
+
table:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bin/rails generate testimonials:install --skip-nps # no NPS at all
|
|
41
|
+
bin/rails generate testimonials:install --skip-prompt-events # no auto-prompts
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--skip-nps` drops the 0–10 survey: no table, no dashboard tab, no prompt.
|
|
45
|
+
`--skip-prompt-events` drops the prompt history — for apps that open the widget
|
|
46
|
+
from their own button and never call `testimonial_prompt!`, since that history
|
|
47
|
+
exists only to throttle auto-prompts. See
|
|
48
|
+
[Prompting users](#prompting-users) for what changes.
|
|
49
|
+
|
|
50
|
+
Changed your mind later: `bin/rails generate testimonials:nps` or
|
|
51
|
+
`bin/rails generate testimonials:prompt_events`, migrate, and set the matching
|
|
52
|
+
flag (`config.nps`, `config.prompt_events`) to `true`.
|
|
53
|
+
|
|
36
54
|
Optional demo data:
|
|
37
55
|
|
|
38
56
|
```bash
|
|
@@ -50,6 +68,11 @@ refreshes those records instead of duplicating them.
|
|
|
50
68
|
|
|
51
69
|
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want video/avatar uploads.
|
|
52
70
|
|
|
71
|
+
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
72
|
+
steps in the order an agent needs them, plus the gates it tends to get wrong
|
|
73
|
+
and the things it should not do. It ships inside the gem, so
|
|
74
|
+
`cat "$(bundle show testimonials)/AGENTS.md"` works from any app that bundles it.
|
|
75
|
+
|
|
53
76
|
## What you get
|
|
54
77
|
|
|
55
78
|
| | |
|
|
@@ -123,6 +146,15 @@ The widget auto-opens on the next rendered page **if** the throttle allows:
|
|
|
123
146
|
`data-testimonial-prompt` (or `="nps"`), or `window.Testimonials.open()` /
|
|
124
147
|
`window.Testimonials.openNps()`.
|
|
125
148
|
|
|
149
|
+
That history lives in `testimonials_prompt_events`, one row per shown,
|
|
150
|
+
dismissed or submitted, and it is optional. `config.prompt_events = false` —
|
|
151
|
+
what `--skip-prompt-events` writes at install — keeps no history at all, and so
|
|
152
|
+
**nothing auto-opens**: `testimonial_prompt!` becomes a no-op, because a prompt
|
|
153
|
+
nothing can throttle would reopen on every page. Explicit opens, the public
|
|
154
|
+
pages, and everything above them are untouched, so an app that opens the widget
|
|
155
|
+
from its own button loses nothing. Want auto-prompts back:
|
|
156
|
+
`bin/rails generate testimonials:prompt_events`, migrate, flip the flag.
|
|
157
|
+
|
|
126
158
|
## Configure
|
|
127
159
|
|
|
128
160
|
Everything is optional — a fresh install works with zero config. In
|
|
@@ -146,6 +178,7 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
146
178
|
| `storage_service` | app default | Active Storage service for uploads (a `storage.yml` key) |
|
|
147
179
|
| `reprompt_after` | `90.days` | Cooldown after a dismissal |
|
|
148
180
|
| `max_prompts` | `3` | Lifetime auto-prompt cap per user |
|
|
181
|
+
| `prompt_events` | `true` | The prompt history the throttle reads. `false` = no history, no auto-prompts |
|
|
149
182
|
| `public_collection` | `true` | The shareable `/testimonials/new` and `/testimonials/nps/new` pages |
|
|
150
183
|
| `public_api` | `false` | Serve the read API without auth (CORS `*`) |
|
|
151
184
|
| `nps` | `true` | The 0–10 NPS flow |
|
|
@@ -242,16 +275,29 @@ throttle would allow it. Detractors (0–6) trigger `on_detractor`, which pairs
|
|
|
242
275
|
well with [ideasbugs](https://github.com/yshmarov/ideasbugs). Your score and
|
|
243
276
|
every response live at `/testimonials/nps_responses`.
|
|
244
277
|
|
|
278
|
+
| 1. One question, eleven taps | 2. The reason, optional |
|
|
279
|
+
| --- | --- |
|
|
280
|
+
|  |  |
|
|
281
|
+
| Ten seconds of work, and "Not now" is always there. | The comment is where the actionable half lives. Promoters go straight on to the testimonial form. |
|
|
282
|
+
|
|
245
283
|
The score is `% promoters − % detractors`, from −100 to +100, and the dashboard
|
|
246
284
|
reads it against the usual benchmarks so nobody has to go and google them:
|
|
247
285
|
below 0 needs work, 0–29 is good, 30–69 is great, 70 and up is world-class. A
|
|
248
286
|
scale under the number shows where you sit, and under 30 responses the card
|
|
249
287
|
says the sample is too thin to read much into.
|
|
250
288
|
|
|
289
|
+
<img src="https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/nps-dashboard.jpg" alt="The NPS dashboard: a score of 41 badged Great, a scale marking where it sits between −100 and +100, promoter/passive/detractor counts, and the collapsed panel explaining the formula and the benchmarks" width="820">
|
|
290
|
+
|
|
251
291
|
`/testimonials/nps/new` is the same question on its own page — the link for an
|
|
252
292
|
email campaign, where there is no app session to prompt inside of. A promoter
|
|
253
293
|
who scores 9–10 gets the testimonial form right there on the page.
|
|
254
294
|
|
|
295
|
+
NPS is optional. `config.nps = false` — what `--skip-nps` writes at install —
|
|
296
|
+
turns off the prompt, the public page, the `/api/stats` score and the dashboard
|
|
297
|
+
tab, and `/testimonials/nps_responses` then 404s. Turning it off on an install
|
|
298
|
+
that has the table hides existing responses along with everything else; flip it
|
|
299
|
+
back to read them.
|
|
300
|
+
|
|
255
301
|
## The public pages
|
|
256
302
|
|
|
257
303
|
Two standalone, self-styled pages for people outside the app — drop either link
|
|
@@ -10,6 +10,9 @@ module Testimonials
|
|
|
10
10
|
CLIENT_ACTIONS = %w[shown dismissed].freeze
|
|
11
11
|
|
|
12
12
|
before_action :require_enabled
|
|
13
|
+
# An install run with --skip-prompt-events has no ledger to write to, and
|
|
14
|
+
# the widget it serves knows not to post here in the first place.
|
|
15
|
+
before_action :require_prompt_events
|
|
13
16
|
|
|
14
17
|
def create
|
|
15
18
|
kind = params[:kind].to_s
|
|
@@ -21,5 +24,11 @@ module Testimonials
|
|
|
21
24
|
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
22
25
|
head :no_content
|
|
23
26
|
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def require_prompt_events
|
|
31
|
+
head :forbidden unless Testimonials.config.prompt_events
|
|
32
|
+
end
|
|
24
33
|
end
|
|
25
34
|
end
|
|
@@ -8,6 +8,9 @@ module Testimonials
|
|
|
8
8
|
layout :testimonials_admin_layout
|
|
9
9
|
|
|
10
10
|
before_action :require_admin
|
|
11
|
+
# An install run with --skip-nps has no table behind these pages, and the
|
|
12
|
+
# nav already hides the tab whenever config.nps is off.
|
|
13
|
+
before_action :require_nps
|
|
11
14
|
before_action :set_response, only: :show
|
|
12
15
|
|
|
13
16
|
def index
|
|
@@ -31,6 +34,10 @@ module Testimonials
|
|
|
31
34
|
|
|
32
35
|
private
|
|
33
36
|
|
|
37
|
+
def require_nps
|
|
38
|
+
head :not_found unless Testimonials.config.nps
|
|
39
|
+
end
|
|
40
|
+
|
|
34
41
|
def set_response
|
|
35
42
|
@response = NpsResponse.for_tenant(current_tenant).find(params[:id])
|
|
36
43
|
end
|
|
@@ -72,6 +72,10 @@ module Testimonials
|
|
|
72
72
|
kind = flash[:testimonials_prompt].to_s
|
|
73
73
|
return unless Testimonials::PromptEvent::KINDS.include?(kind)
|
|
74
74
|
return if kind == 'nps' && !Testimonials.config.nps
|
|
75
|
+
# Without the ledger there is no record of who has been asked already, so
|
|
76
|
+
# an auto-open would reopen on every page a testimonial_prompt! reaches.
|
|
77
|
+
# An install with --skip-prompt-events opens on a click and nothing else.
|
|
78
|
+
return unless Testimonials.config.prompt_events
|
|
75
79
|
|
|
76
80
|
author = testimonials_author
|
|
77
81
|
return unless Testimonials::PromptEvent.eligible?(
|
|
@@ -12,6 +12,12 @@ module Testimonials
|
|
|
12
12
|
# * shown max_prompts times -> never auto-prompted for that kind again
|
|
13
13
|
#
|
|
14
14
|
# Explicit opens (the user clicked something) bypass all of this.
|
|
15
|
+
#
|
|
16
|
+
# The ledger is optional: an install run with --skip-prompt-events has no
|
|
17
|
+
# table behind this class and config.prompt_events is false, so record! is a
|
|
18
|
+
# no-op and eligible? answers without a query. Every write and every read
|
|
19
|
+
# goes through the two methods below, so that flag is the only guard needed
|
|
20
|
+
# — nothing here asks the schema at runtime.
|
|
15
21
|
class PromptEvent < ApplicationRecord
|
|
16
22
|
KINDS = %w[testimonial nps].freeze
|
|
17
23
|
ACTIONS = %w[shown dismissed submitted].freeze
|
|
@@ -21,6 +27,7 @@ module Testimonials
|
|
|
21
27
|
|
|
22
28
|
class << self
|
|
23
29
|
def record!(kind:, action:, author_id: nil, visitor_token: nil, tenant: nil)
|
|
30
|
+
return unless Testimonials.config.prompt_events
|
|
24
31
|
return if author_id.blank? && visitor_token.blank?
|
|
25
32
|
|
|
26
33
|
create!(kind: kind.to_s, action: action.to_s, tenant: tenant.presence,
|
|
@@ -34,6 +41,11 @@ module Testimonials
|
|
|
34
41
|
return false unless KINDS.include?(kind)
|
|
35
42
|
# No identity, no history: a brand-new visitor is always eligible.
|
|
36
43
|
return true if author_id.blank? && visitor_token.blank?
|
|
44
|
+
# No ledger, no history either — the same answer, one flag earlier.
|
|
45
|
+
# This is not a licence to nag: with the ledger off nothing auto-opens
|
|
46
|
+
# (see WidgetHelper#testimonials_auto_open), and the one caller left is
|
|
47
|
+
# the promoter offered the form inside a flow they opened themselves.
|
|
48
|
+
return true unless Testimonials.config.prompt_events
|
|
37
49
|
|
|
38
50
|
history = subject(author_id, visitor_token, tenant).where(kind: kind)
|
|
39
51
|
config = Testimonials.config
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
<div class="page-head">
|
|
4
4
|
<h1><%= t('testimonials.dashboard.nps', default: 'NPS') %></h1>
|
|
5
|
-
<%# The shareable NPS page, same idea as the testimonial one.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
<% if Testimonials.config.public_collection
|
|
5
|
+
<%# The shareable NPS page, same idea as the testimonial one. config.nps is
|
|
6
|
+
already true here — the controller 404s otherwise — so only the public
|
|
7
|
+
switch is left to check. %>
|
|
8
|
+
<% if Testimonials.config.public_collection %>
|
|
9
9
|
<a class="button share-link" href="<%= nps_collection_url %>" target="_blank" rel="noopener"
|
|
10
10
|
title="<%= nps_collection_url %>">
|
|
11
11
|
🔗 <%= t('testimonials.dashboard.nps_page', default: 'Ask for NPS scores') %>
|
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>
|
|
@@ -12,8 +12,24 @@ module Testimonials
|
|
|
12
12
|
|
|
13
13
|
desc 'Installs testimonials: config initializer, migration, and engine mount.'
|
|
14
14
|
|
|
15
|
+
# Two parts of the gem an app can genuinely not want. Skipping either
|
|
16
|
+
# leaves out its table and writes the matching config flag as false, so
|
|
17
|
+
# nothing ever reaches a table that isn't there — no runtime
|
|
18
|
+
# introspection, no boot-time database call. `testimonials:nps` and
|
|
19
|
+
# `testimonials:prompt_events` add them later.
|
|
20
|
+
class_option :skip_nps, type: :boolean, default: false,
|
|
21
|
+
desc: 'Leave out the NPS table and turn the NPS flow off'
|
|
22
|
+
|
|
23
|
+
# The throttle ledger only earns its rows if the app auto-prompts. An
|
|
24
|
+
# app that opens the widget from its own button writes one row per
|
|
25
|
+
# dismissal for nothing, so it can leave the table out — and then
|
|
26
|
+
# testimonial_prompt! no longer auto-opens, since nothing could throttle
|
|
27
|
+
# it. Explicit opens are unaffected.
|
|
28
|
+
class_option :skip_prompt_events, type: :boolean, default: false,
|
|
29
|
+
desc: 'Leave out the prompt-history table; no auto-prompts, only explicit opens'
|
|
30
|
+
|
|
15
31
|
def create_initializer
|
|
16
|
-
|
|
32
|
+
template 'initializer.rb.tt', 'config/initializers/testimonials.rb'
|
|
17
33
|
end
|
|
18
34
|
|
|
19
35
|
def create_migration_file
|
|
@@ -29,8 +45,19 @@ module Testimonials
|
|
|
29
45
|
say "\ntestimonials installed. Run `rails db:migrate`, then add", :green
|
|
30
46
|
say '`<%= testimonials_tag %>` before </body> in your layout.'
|
|
31
47
|
say 'Triage testimonials at /testimonials (development only until you set config.authorize_admin).'
|
|
32
|
-
|
|
48
|
+
samples = options[:skip_nps] ? 'testimonials' : 'testimonials and NPS'
|
|
49
|
+
say "Optional: run `bin/rails testimonials:seed_demo` for sample #{samples}."
|
|
33
50
|
say "Collect from outside the app via /testimonials/new.\n"
|
|
51
|
+
|
|
52
|
+
if options[:skip_nps]
|
|
53
|
+
say 'Installed without NPS. Add it later with `bin/rails generate testimonials:nps`.', :yellow
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return unless options[:skip_prompt_events]
|
|
57
|
+
|
|
58
|
+
say 'Installed without prompt history, so testimonial_prompt! will not auto-open the ' \
|
|
59
|
+
'widget — open it from your own button. Add it later with ' \
|
|
60
|
+
'`bin/rails generate testimonials:prompt_events`.', :yellow
|
|
34
61
|
end
|
|
35
62
|
|
|
36
63
|
private
|
|
@@ -26,6 +26,7 @@ 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
|
+
<% unless options[:skip_nps] -%>
|
|
29
30
|
|
|
30
31
|
create_table :testimonials_nps_responses do |t|
|
|
31
32
|
t.integer :score, null: false
|
|
@@ -41,6 +42,8 @@ class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
41
42
|
t.timestamps
|
|
42
43
|
end
|
|
43
44
|
add_index :testimonials_nps_responses, %i[tenant score]
|
|
45
|
+
<% end -%>
|
|
46
|
+
<% unless options[:skip_prompt_events] -%>
|
|
44
47
|
|
|
45
48
|
create_table :testimonials_prompt_events do |t|
|
|
46
49
|
t.string :kind, null: false
|
|
@@ -53,5 +56,6 @@ class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
53
56
|
end
|
|
54
57
|
add_index :testimonials_prompt_events, %i[tenant author_id kind]
|
|
55
58
|
add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
|
|
59
|
+
<% end -%>
|
|
56
60
|
end
|
|
57
61
|
end
|
|
@@ -69,6 +69,21 @@ Testimonials.configure do |config|
|
|
|
69
69
|
# config.reprompt_after = 90.days
|
|
70
70
|
# config.max_prompts = 3
|
|
71
71
|
|
|
72
|
+
# The prompt history the throttle reads (testimonials_prompt_events).
|
|
73
|
+
<% if options[:skip_prompt_events] -%>
|
|
74
|
+
# Installed with --skip-prompt-events, so there is no table and no history is
|
|
75
|
+
# written. Nothing is throttled, so nothing auto-opens either:
|
|
76
|
+
# testimonial_prompt! is a no-op here and the widget opens from your own
|
|
77
|
+
# button, data-testimonial-prompt, or window.Testimonials.open(). To turn
|
|
78
|
+
# auto-prompts on later: `bin/rails generate testimonials:prompt_events &&
|
|
79
|
+
# bin/rails db:migrate`, then flip this to true.
|
|
80
|
+
config.prompt_events = false
|
|
81
|
+
<% else -%>
|
|
82
|
+
# False keeps no history — and then nothing auto-opens, since nothing could
|
|
83
|
+
# throttle it. For apps that only open the widget on a click.
|
|
84
|
+
# config.prompt_events = true
|
|
85
|
+
<% end -%>
|
|
86
|
+
|
|
72
87
|
# The public-use consent line, stored verbatim when a customer picks
|
|
73
88
|
# "use publicly". nil = localized default ("You can use my testimonial
|
|
74
89
|
# publicly in your marketing and sales."). The private-use line is
|
|
@@ -84,7 +99,15 @@ Testimonials.configure do |config|
|
|
|
84
99
|
# config.public_api = false
|
|
85
100
|
|
|
86
101
|
# NPS surveys. Promoters (9–10) are offered the testimonial form right away.
|
|
102
|
+
<% if options[:skip_nps] -%>
|
|
103
|
+
# Installed with --skip-nps, so there is no testimonials_nps_responses table
|
|
104
|
+
# and the NPS flow, dashboard tab and public page stay off. To add it later:
|
|
105
|
+
# `bin/rails generate testimonials:nps && bin/rails db:migrate`, then flip
|
|
106
|
+
# this to true.
|
|
107
|
+
config.nps = false
|
|
108
|
+
<% else -%>
|
|
87
109
|
# config.nps = true
|
|
110
|
+
<% end -%>
|
|
88
111
|
# config.nps_reprompt_after = 90.days
|
|
89
112
|
|
|
90
113
|
# Called with each saved Testimonial or NpsResponse — notify Slack, email…
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module Testimonials
|
|
7
|
+
module Generators
|
|
8
|
+
# For apps that installed with --skip-nps and later want the 0–10 survey:
|
|
9
|
+
# creates the testimonials_nps_responses table. A full install already has
|
|
10
|
+
# it. Additive and safe — nothing reads the table until config.nps is true.
|
|
11
|
+
#
|
|
12
|
+
# bin/rails generate testimonials:nps && bin/rails db:migrate
|
|
13
|
+
class NpsGenerator < Rails::Generators::Base
|
|
14
|
+
include ActiveRecord::Generators::Migration
|
|
15
|
+
|
|
16
|
+
source_root File.expand_path('templates', __dir__)
|
|
17
|
+
|
|
18
|
+
desc 'Adds the NPS responses table for an install that skipped it.'
|
|
19
|
+
|
|
20
|
+
def create_migration_file
|
|
21
|
+
migration_template 'create_testimonials_nps_responses.rb.tt',
|
|
22
|
+
'db/migrate/create_testimonials_nps_responses.rb'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def post_install
|
|
26
|
+
say "\nNPS table queued. Run `rails db:migrate`, then set", :green
|
|
27
|
+
say 'config.nps = true in config/initializers/testimonials.rb to turn the flow on.'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def migration_version
|
|
33
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateTestimonialsNpsResponses < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :testimonials_nps_responses do |t|
|
|
6
|
+
t.integer :score, null: false
|
|
7
|
+
t.text :comment
|
|
8
|
+
t.string :author_id
|
|
9
|
+
t.string :name
|
|
10
|
+
t.string :email
|
|
11
|
+
t.string :page_url
|
|
12
|
+
t.string :user_agent
|
|
13
|
+
t.string :locale
|
|
14
|
+
t.string :tenant # opaque per-tenant key; nil = single global collection
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
add_index :testimonials_nps_responses, %i[tenant score]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module Testimonials
|
|
7
|
+
module Generators
|
|
8
|
+
# For apps that installed with --skip-prompt-events and later want
|
|
9
|
+
# auto-prompts: creates the testimonials_prompt_events table, the ledger
|
|
10
|
+
# the throttle reads. A full install already has it. Additive and safe —
|
|
11
|
+
# nothing reads or writes the table until config.prompt_events is true.
|
|
12
|
+
#
|
|
13
|
+
# bin/rails generate testimonials:prompt_events && bin/rails db:migrate
|
|
14
|
+
class PromptEventsGenerator < Rails::Generators::Base
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path('templates', __dir__)
|
|
18
|
+
|
|
19
|
+
desc 'Adds the prompt history table for an install that skipped it.'
|
|
20
|
+
|
|
21
|
+
def create_migration_file
|
|
22
|
+
migration_template 'create_testimonials_prompt_events.rb.tt',
|
|
23
|
+
'db/migrate/create_testimonials_prompt_events.rb'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def post_install
|
|
27
|
+
say "\nPrompt history table queued. Run `rails db:migrate`, then set", :green
|
|
28
|
+
say 'config.prompt_events = true in config/initializers/testimonials.rb to let'
|
|
29
|
+
say 'testimonial_prompt! auto-open the widget again.'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def migration_version
|
|
35
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
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 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,12 +3,19 @@
|
|
|
3
3
|
class AddTenantToTestimonials < ActiveRecord::Migration<%= migration_version %>
|
|
4
4
|
def change
|
|
5
5
|
add_column :testimonials_testimonials, :tenant, :string
|
|
6
|
-
add_column :testimonials_nps_responses, :tenant, :string
|
|
7
|
-
add_column :testimonials_prompt_events, :tenant, :string
|
|
8
|
-
|
|
9
6
|
add_index :testimonials_testimonials, %i[tenant status]
|
|
10
7
|
add_index :testimonials_testimonials, %i[tenant status consent_given]
|
|
11
|
-
|
|
8
|
+
|
|
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
|
+
|
|
16
|
+
return unless table_exists?(:testimonials_prompt_events)
|
|
17
|
+
|
|
18
|
+
add_column :testimonials_prompt_events, :tenant, :string
|
|
12
19
|
add_index :testimonials_prompt_events, %i[tenant author_id kind]
|
|
13
20
|
add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
|
|
14
21
|
end
|
|
@@ -69,6 +69,15 @@ module Testimonials
|
|
|
69
69
|
# (clicking your link) always work.
|
|
70
70
|
attr_accessor :reprompt_after, :max_prompts
|
|
71
71
|
|
|
72
|
+
# The throttling ledger itself (testimonials_prompt_events). Off means no
|
|
73
|
+
# per-user prompt history is written — and therefore no auto-prompts:
|
|
74
|
+
# `testimonial_prompt!` stops opening the widget, because a prompt that
|
|
75
|
+
# can't be throttled would reopen on every page. Explicit opens — your
|
|
76
|
+
# own button, `data-testimonial-prompt`, `window.Testimonials.open()`,
|
|
77
|
+
# the public pages — are unaffected. For apps that only ever open the
|
|
78
|
+
# widget on a click; `--skip-prompt-events` writes it at install time.
|
|
79
|
+
attr_accessor :prompt_events
|
|
80
|
+
|
|
72
81
|
# Consent line stored verbatim with each submission. nil uses the
|
|
73
82
|
# localized default.
|
|
74
83
|
attr_accessor :consent_text
|
|
@@ -123,6 +132,7 @@ module Testimonials
|
|
|
123
132
|
@storage_service = nil
|
|
124
133
|
@reprompt_after = 90 * 24 * 60 * 60
|
|
125
134
|
@max_prompts = 3
|
|
135
|
+
@prompt_events = true
|
|
126
136
|
@consent_text = nil
|
|
127
137
|
@public_collection = true
|
|
128
138
|
@public_api = false
|
|
@@ -7,6 +7,10 @@ module Testimonials
|
|
|
7
7
|
# throttle allows, so calling this liberally is safe: users who submitted,
|
|
8
8
|
# recently dismissed, or were already prompted max_prompts times are left
|
|
9
9
|
# alone.
|
|
10
|
+
#
|
|
11
|
+
# An install with `config.prompt_events = false` keeps no prompt history, so
|
|
12
|
+
# there is nothing to throttle with and this is a no-op — that app opens the
|
|
13
|
+
# widget on a click instead.
|
|
10
14
|
module PromptHelper
|
|
11
15
|
def testimonial_prompt!(kind = :testimonial)
|
|
12
16
|
flash[:testimonials_prompt] = kind.to_s
|
data/lib/testimonials/seeds.rb
CHANGED
|
@@ -90,8 +90,10 @@ module Testimonials
|
|
|
90
90
|
def self.load!(tenant: nil)
|
|
91
91
|
{
|
|
92
92
|
testimonials: load_testimonials!(tenant: tenant),
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
# An install run with --skip-nps or --skip-prompt-events has no table
|
|
94
|
+
# to seed into.
|
|
95
|
+
nps_responses: (Testimonials.config.nps ? load_nps_responses!(tenant: tenant) : []),
|
|
96
|
+
prompt_events: (Testimonials.config.prompt_events ? load_prompt_events!(tenant: tenant) : [])
|
|
95
97
|
}
|
|
96
98
|
end
|
|
97
99
|
|
data/lib/testimonials/version.rb
CHANGED
data/lib/testimonials/widget.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* a star expands into the full form (text or recorded video, consent, guest
|
|
15
15
|
* contact fields). NPS asks 0–10, and promoters are offered the testimonial
|
|
16
16
|
* form right after. Auto-opens report shown/dismissed to the throttle
|
|
17
|
-
* ledger; explicit opens don't
|
|
17
|
+
* ledger; explicit opens don't, and an install without the ledger
|
|
18
|
+
* (config.promptEvents.enabled false) reports nothing at all.
|
|
18
19
|
*
|
|
19
20
|
* On the public standalone pages the same code renders inline into
|
|
20
21
|
* <div data-testimonials-inline>: the testimonial form (config.mode ===
|
|
@@ -1054,6 +1055,8 @@
|
|
|
1054
1055
|
}
|
|
1055
1056
|
|
|
1056
1057
|
function postEvent(kind, action) {
|
|
1058
|
+
// No ledger on this install (--skip-prompt-events): nothing to report to.
|
|
1059
|
+
if (!config.promptEvents || !config.promptEvents.enabled) return;
|
|
1057
1060
|
var data = new FormData();
|
|
1058
1061
|
data.append("kind", kind);
|
|
1059
1062
|
data.append("event_action", action);
|
data/lib/testimonials/widget.rb
CHANGED
|
@@ -107,6 +107,9 @@ module Testimonials
|
|
|
107
107
|
maxSize: config.max_avatar_size.to_i
|
|
108
108
|
},
|
|
109
109
|
nps: { enabled: config.nps ? true : false },
|
|
110
|
+
# The throttle ledger. Off, and the widget skips the shown/dismissed
|
|
111
|
+
# posts: the endpoint would only refuse them.
|
|
112
|
+
promptEvents: { enabled: config.prompt_events ? true : false },
|
|
110
113
|
labels: labels
|
|
111
114
|
}
|
|
112
115
|
# Escape "</" so a value can't close the <script> block early.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: testimonials
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -38,6 +38,7 @@ executables: []
|
|
|
38
38
|
extensions: []
|
|
39
39
|
extra_rdoc_files: []
|
|
40
40
|
files:
|
|
41
|
+
- AGENTS.md
|
|
41
42
|
- CHANGELOG.md
|
|
42
43
|
- MIT-LICENSE
|
|
43
44
|
- README.md
|
|
@@ -95,9 +96,19 @@ files:
|
|
|
95
96
|
- config/locales/testimonials.vi.yml
|
|
96
97
|
- config/locales/testimonials.zh-CN.yml
|
|
97
98
|
- config/routes.rb
|
|
99
|
+
- examples/README.md
|
|
100
|
+
- examples/badge.html.erb
|
|
101
|
+
- examples/json_ld.html.erb
|
|
102
|
+
- examples/static_site.md
|
|
103
|
+
- examples/testimonial_card.html.erb
|
|
104
|
+
- examples/wall_of_love.html.erb
|
|
98
105
|
- lib/generators/testimonials/install/install_generator.rb
|
|
99
106
|
- lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt
|
|
100
|
-
- lib/generators/testimonials/install/templates/initializer.rb
|
|
107
|
+
- lib/generators/testimonials/install/templates/initializer.rb.tt
|
|
108
|
+
- lib/generators/testimonials/nps/nps_generator.rb
|
|
109
|
+
- lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt
|
|
110
|
+
- lib/generators/testimonials/prompt_events/prompt_events_generator.rb
|
|
111
|
+
- lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt
|
|
101
112
|
- lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt
|
|
102
113
|
- lib/generators/testimonials/tenant/tenant_generator.rb
|
|
103
114
|
- lib/tasks/testimonials_tasks.rake
|