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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +207 -0
  3. data/CHANGELOG.md +38 -0
  4. data/README.md +46 -0
  5. data/app/controllers/testimonials/events_controller.rb +9 -0
  6. data/app/controllers/testimonials/nps_responses_controller.rb +7 -0
  7. data/app/helpers/testimonials/widget_helper.rb +4 -0
  8. data/app/models/testimonials/prompt_event.rb +12 -0
  9. data/app/views/testimonials/nps_responses/index.html.erb +4 -4
  10. data/examples/README.md +17 -0
  11. data/examples/badge.html.erb +13 -0
  12. data/examples/json_ld.html.erb +28 -0
  13. data/examples/static_site.md +43 -0
  14. data/examples/testimonial_card.html.erb +17 -0
  15. data/examples/wall_of_love.html.erb +43 -0
  16. data/lib/generators/testimonials/install/install_generator.rb +29 -2
  17. data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +4 -0
  18. data/lib/generators/testimonials/install/templates/{initializer.rb → initializer.rb.tt} +23 -0
  19. data/lib/generators/testimonials/nps/nps_generator.rb +37 -0
  20. data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +20 -0
  21. data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +39 -0
  22. data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +17 -0
  23. data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +11 -4
  24. data/lib/testimonials/configuration.rb +10 -0
  25. data/lib/testimonials/prompt_helper.rb +4 -0
  26. data/lib/testimonials/seeds.rb +4 -2
  27. data/lib/testimonials/version.rb +1 -1
  28. data/lib/testimonials/widget.js +4 -1
  29. data/lib/testimonials/widget.rb +3 -0
  30. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f77889bc1fe3b99ca3b75dad0936498a35afa29249bceb5c9000ef090b275cfc
4
- data.tar.gz: 0bd9fdedb0d9b11be9c4b8ebdea7277d4f45c249723b5c43dc93bb7b2e74cb7b
3
+ metadata.gz: f7ed14e07e92b6d456853cf8b9c04ece86f36d17873091f515f5c47e393894ca
4
+ data.tar.gz: c05b6be1e36b1592eabe270c18f7da4a15dafdca192a01141389141b76a37aca
5
5
  SHA512:
6
- metadata.gz: 2b60920030a7e8099116b1d8abd8e31f98fbf9b984f4c3faaebf7fd3449ac232574016c0b0d5c62ea0ecd6647500e2646baf8264c657ed913df98ab7fa71c207
7
- data.tar.gz: 3c5b5e7209197b41f28cb90240b3c232123ad91833e14be80031af275becf143f9376316d36857c9de7db844eb8630aa570de8bc09d0a14173a534da4f309980
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
+ | ![The NPS prompt: how likely are you to recommend us, scored 0 to 10](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/nps-01-score.jpg) | ![The follow-up asking for the main reason behind the score](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/nps-02-comment.png) |
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. This dashboard
6
- still reads history when config.nps is off, but that page 404s — so the
7
- link needs both switches. %>
8
- <% if Testimonials.config.public_collection && Testimonials.config.nps %>
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') %>
@@ -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
- copy_file 'initializer.rb', 'config/initializers/testimonials.rb'
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
- say 'Optional: run `bin/rails testimonials:seed_demo` for sample testimonials and NPS.'
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
@@ -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
- add_index :testimonials_nps_responses, %i[tenant score]
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
@@ -90,8 +90,10 @@ module Testimonials
90
90
  def self.load!(tenant: nil)
91
91
  {
92
92
  testimonials: load_testimonials!(tenant: tenant),
93
- nps_responses: load_nps_responses!(tenant: tenant),
94
- prompt_events: load_prompt_events!(tenant: tenant)
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- VERSION = '0.7.8'
4
+ VERSION = '0.7.10'
5
5
  end
@@ -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);
@@ -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.8
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