testimonials 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +8 -2
- data/CHANGELOG.md +45 -0
- data/README.md +56 -2
- data/Rakefile +9 -1
- data/SECURITY.md +57 -0
- data/app/controllers/concerns/testimonials/request_context.rb +17 -0
- data/app/controllers/testimonials/media_controller.rb +28 -8
- data/app/controllers/testimonials/nps_controller.rb +1 -0
- data/app/controllers/testimonials/submissions_controller.rb +1 -0
- data/lib/testimonials/dashboard.css +14 -0
- data/lib/testimonials/seeds.rb +22 -12
- data/lib/testimonials/version.rb +1 -1
- data/{app/helpers → lib}/testimonials/widget_helper.rb +4 -24
- data/lib/testimonials.rb +1 -0
- metadata +11 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d114855497d2d5186be09b6e76bab5f0d78e079012cfb765dd2399b61271e191
|
|
4
|
+
data.tar.gz: 0c3c906e8dd2aa883cf0261565b2c4f118c0ab8825c06683cbce18f4e59e3a17
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9a26a9dd5f656c84cf657ed34bda8f130d3783be1efc98987d8bf438abb25728b6c34dc8c62b28e6e39a65a9a117baf411542da563ddfe9549b96edf1e545a9
|
|
7
|
+
data.tar.gz: de494a843ba1747d54a500815eaa2e212b99bc66b5302f6e59aed64f0ea7afba73a1257af1967a5052e441db5a59f6ba56ec9cb578938e18e91450f4d761f88d
|
data/AGENTS.md
CHANGED
|
@@ -5,7 +5,7 @@ Instructions for coding agents. Two audiences:
|
|
|
5
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
6
|
- **[Working on the gem itself](#working-on-the-gem-itself)** — you are working in this repository.
|
|
7
7
|
|
|
8
|
-
Requirements: Ruby >= 3.2, Rails >= 7.1. Active Storage only for video and avatar uploads.
|
|
8
|
+
Requirements: Ruby >= 3.2, Rails >= 7.1 and < 9. Active Storage only for video and avatar uploads.
|
|
9
9
|
|
|
10
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
11
|
|
|
@@ -196,8 +196,14 @@ Turbo Drive and nonce-based CSP work out of the box; the widget config rides in
|
|
|
196
196
|
|
|
197
197
|
## Working on the gem itself
|
|
198
198
|
|
|
199
|
+
The development toolchain is pinned to Ruby 4.0.5 in `mise.toml`. Do not use
|
|
200
|
+
macOS's `/usr/bin/ruby`. In shells, hooks, or automation where mise activation
|
|
201
|
+
is uncertain, explicitly prepend the configured Ruby, for example
|
|
202
|
+
`PATH="$(mise where ruby)/bin:$PATH" bundle exec rake test`.
|
|
203
|
+
|
|
199
204
|
```bash
|
|
200
205
|
bundle exec rake test # minitest, dummy app under test/dummy
|
|
206
|
+
bundle exec rake test:system # real Chrome: NPS, testimonial, moderation, public read
|
|
201
207
|
bundle exec rubocop # must be clean
|
|
202
208
|
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
203
209
|
```
|
|
@@ -210,6 +216,6 @@ Conventions this codebase holds to — follow them rather than the first thing t
|
|
|
210
216
|
- **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.
|
|
211
217
|
- **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`.
|
|
212
218
|
- **Display stays headless.** New display UI belongs in `examples/`, not in `app/views`.
|
|
213
|
-
- **`lib/testimonials/dashboard.css` is half shared.** Everything above the `GEM-SPECIFIC` banner is the design system all five gems in the family ship — the same tokens, the same `.page-head`/`.tabs`/`.filters`/`.card`/`.badge`/`button`, the same `.dashboard-shell` + `.record-row` + `.detail-panel` two-pane dashboard — identical in every repo apart from the `tml` prefix. Diff it against a sibling before changing it, and carry the change to the other four. Anything only this gem has goes below the banner. New dashboard markup reuses the shared class names rather than inventing a domain-specific one.
|
|
219
|
+
- **`lib/testimonials/dashboard.css` is half shared.** Everything above the `GEM-SPECIFIC` banner is the design system all five gems in the family ship — the same tokens, the same `.page-head`/`.tabs`/`.filters`/`.card`/`.badge`/`button`/`.status-switch`, the same `.dashboard-shell` + `.record-row` + `.detail-panel` two-pane dashboard — identical in every repo apart from the `tml` prefix. Diff it against a sibling before changing it, and carry the change to the other four. Anything only this gem has goes below the banner. New dashboard markup reuses the shared class names rather than inventing a domain-specific one.
|
|
214
220
|
- 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.
|
|
215
221
|
- Commit messages are prose that explains the tradeoff — read `git log` before writing one.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0 - 2026-08-11
|
|
4
|
+
|
|
5
|
+
- **The 1.x record names are settled.** `Testimonials::Testimonial`,
|
|
6
|
+
`Testimonials::NpsResponse`, and `Testimonials::PromptEvent` are now
|
|
7
|
+
documented as the stable model constants rather than being hidden behind a
|
|
8
|
+
generic `Post` name. Their existing optional-install boundaries are
|
|
9
|
+
unchanged.
|
|
10
|
+
- **A real Chrome test now proves the revenue-adjacent path:** an NPS promoter
|
|
11
|
+
is offered the testimonial form, submits text with public consent, lands in
|
|
12
|
+
pending moderation, is approved, and appears through the public read API
|
|
13
|
+
without leaking email. Capybara and Selenium are development/test
|
|
14
|
+
dependencies only.
|
|
15
|
+
- **Uploaded videos, posters, and avatars stay behind the engine gate.** Media
|
|
16
|
+
responses now stream full and byte-range data directly after authorization
|
|
17
|
+
instead of redirecting to a reusable signed Active Storage URL. Code that
|
|
18
|
+
inspected a media response's `Location` header must consume the engine URL
|
|
19
|
+
directly; browser `<video>` and image URLs are unchanged.
|
|
20
|
+
- **Hosts that load Action View from an initializer boot normally.** The widget
|
|
21
|
+
helper is loaded from `lib` before the engine registers its Action View hook;
|
|
22
|
+
`testimonials_tag` and the opener helpers are unchanged.
|
|
23
|
+
- Added a security policy documenting stored testimonial, NPS, prompt-event,
|
|
24
|
+
consent, media, deletion, and retention boundaries. GitHub private
|
|
25
|
+
vulnerability reporting is the disclosure channel. Submitted page URLs now
|
|
26
|
+
accept only HTTP(S) and drop query strings/fragments before storage, keeping
|
|
27
|
+
tokens out of the database and executable schemes out of dashboard links.
|
|
28
|
+
- Supported combinations are Ruby 3.2 through 4.0 and Rails 7.1 through 8.1;
|
|
29
|
+
Rails now has a `< 9` upper bound. Release validation runs the browser test,
|
|
30
|
+
lint and JavaScript checks, builds the gem, verifies RubyGems indexing, and
|
|
31
|
+
creates the GitHub Release.
|
|
32
|
+
- Existing 0.x installs need no migration, initializer change, constant alias,
|
|
33
|
+
or model rename. The new Rails upper bound and non-redirecting media response
|
|
34
|
+
are the only compatibility costs.
|
|
35
|
+
|
|
36
|
+
## 0.9.1
|
|
37
|
+
|
|
38
|
+
- **The demo data now teaches responsible collection.** Its testimonials cover
|
|
39
|
+
success-moment prompting, pending moderation, public consent, private
|
|
40
|
+
archiving, best lines, and the shared approval rules for text and video. The
|
|
41
|
+
promoter, passive, and detractor comments explain why the balanced sample has
|
|
42
|
+
an NPS of zero.
|
|
43
|
+
- The bundled video remains intentionally generic: it proves protected upload
|
|
44
|
+
and playback without pretending to be customer evidence. Rerunning
|
|
45
|
+
`testimonials:seed_demo` refreshes the existing demo rows and can overwrite
|
|
46
|
+
edits to them; non-demo customer records are not touched.
|
|
47
|
+
|
|
3
48
|
## 0.9.0
|
|
4
49
|
|
|
5
50
|
- **One design system across the family.** The stylesheet now opens with a
|
data/README.md
CHANGED
|
@@ -66,7 +66,8 @@ refreshes those records instead of duplicating them.
|
|
|
66
66
|
> The dashboard defaults to **development only**. Set `authorize_admin` before
|
|
67
67
|
> you deploy — see [Configure](#configure).
|
|
68
68
|
|
|
69
|
-
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want
|
|
69
|
+
Ruby >= 3.2 · Rails >= 7.1 and < 9 · Active Storage only if you want
|
|
70
|
+
video/avatar uploads.
|
|
70
71
|
|
|
71
72
|
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
72
73
|
steps in the order an agent needs them, plus the gates it tends to get wrong
|
|
@@ -240,7 +241,7 @@ playback, feature toggle, and a **Best line** picker.
|
|
|
240
241
|
<img src="https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/dashboard-show.jpg" alt="A single testimonial in the dashboard: video playback, best-line picker, attribution metadata, and approve/archive/feature/delete actions" width="620">
|
|
241
242
|
|
|
242
243
|
Every submission keeps its provenance — who sent it, the exact consent text
|
|
243
|
-
they agreed to, the page, locale, browser, timestamp. **Best line** is the only
|
|
244
|
+
they agreed to, the query-free page URL, locale, browser, timestamp. **Best line** is the only
|
|
244
245
|
thing you write: the customer's words stay verbatim, you just choose which
|
|
245
246
|
sentence your landing page pulls out.
|
|
246
247
|
|
|
@@ -255,6 +256,8 @@ Admin-only by default. `config.public_api = true` serves them without auth
|
|
|
255
256
|
(CORS `*`) — ideal for a static marketing site rendering your wall of love at
|
|
256
257
|
build time. **Emails and author ids are never serialized.** Video and avatar
|
|
257
258
|
files go through the same gate, with Range support so `<video>` just works.
|
|
259
|
+
The media response stays on that authorized engine URL with a private,
|
|
260
|
+
`no-store` cache policy; it never redirects to a reusable signed blob URL.
|
|
258
261
|
|
|
259
262
|
## Rendering what you collect
|
|
260
263
|
|
|
@@ -358,6 +361,49 @@ collection — so nothing changes until you set `config.tenant`.
|
|
|
358
361
|
|
|
359
362
|
</details>
|
|
360
363
|
|
|
364
|
+
## Compatibility and public API
|
|
365
|
+
|
|
366
|
+
The following are the model and integration contracts that 1.x will keep
|
|
367
|
+
stable under semantic versioning:
|
|
368
|
+
|
|
369
|
+
- `Testimonials::Testimonial`, including `KINDS`, `STATUSES`, `SOURCES`, its
|
|
370
|
+
persisted fields, attachment predicates, status predicates, `for_tenant`,
|
|
371
|
+
`newest_first`, `approved`, `publishable`, `featured_first`, `display_name`,
|
|
372
|
+
and `quote`.
|
|
373
|
+
- `Testimonials::NpsResponse`, including its persisted fields, `for_tenant`,
|
|
374
|
+
`newest_first`, `promoter?`, `passive?`, `detractor?`, `.score`, and `.band`
|
|
375
|
+
when NPS is installed.
|
|
376
|
+
- `Testimonials::PromptEvent`, including `KINDS`, `ACTIONS`, its persisted
|
|
377
|
+
fields, `.eligible?`, and `.record!` when prompt events are installed.
|
|
378
|
+
- `has_testimonials`, `Testimonials.configure`, `mount_testimonials`,
|
|
379
|
+
`testimonials_tag`, the opener helpers/attributes, and `testimonial_prompt!`.
|
|
380
|
+
- `config.on_submit` and `config.on_detractor`, each receiving the documented
|
|
381
|
+
saved record.
|
|
382
|
+
|
|
383
|
+
Those domain model names are intentional and will not become a generic `Post`
|
|
384
|
+
during 1.x. Engine controllers, partials, CSS classes, generated HTML, and
|
|
385
|
+
widget implementation objects are private. Incompatible changes to the public
|
|
386
|
+
list above wait for a new major version; a deprecation normally ships first.
|
|
387
|
+
|
|
388
|
+
### Upgrading from 0.x
|
|
389
|
+
|
|
390
|
+
Version 1.0.0 keeps the existing domain models and schema. There is no
|
|
391
|
+
migration, initializer change, compatibility alias, or rename: continue using
|
|
392
|
+
`Testimonials::Testimonial`, `Testimonials::NpsResponse`, and
|
|
393
|
+
`Testimonials::PromptEvent`. Rails 9 is excluded until a 1.x release explicitly
|
|
394
|
+
adds it. Media URLs are unchanged, but their responses now contain the gated
|
|
395
|
+
bytes instead of redirecting to an Active Storage URL.
|
|
396
|
+
|
|
397
|
+
## Security and deletion
|
|
398
|
+
|
|
399
|
+
See [SECURITY.md](https://github.com/yshmarov/testimonials/blob/main/SECURITY.md)
|
|
400
|
+
for private vulnerability reporting and the stored-data, consent, media,
|
|
401
|
+
deletion, and retention boundaries. In short: the public API is off by default;
|
|
402
|
+
when enabled it returns only approved, publicly consented records without email
|
|
403
|
+
or author ids. Deleting the live testimonial, NPS, and prompt-event rows is the
|
|
404
|
+
host's responsibility, as are object-store, backup, analytics, cache, and export
|
|
405
|
+
retention policies.
|
|
406
|
+
|
|
361
407
|
## Who's using it
|
|
362
408
|
|
|
363
409
|
- [PaidCollabs](https://paidcollabs.com) — the screenshots above
|
|
@@ -369,9 +415,17 @@ add yourself.
|
|
|
369
415
|
|
|
370
416
|
```bash
|
|
371
417
|
bundle exec rake test
|
|
418
|
+
bundle exec rake test:system
|
|
372
419
|
bundle exec rubocop
|
|
420
|
+
node --check lib/testimonials/widget.js
|
|
421
|
+
node --check lib/testimonials/dashboard.js
|
|
373
422
|
```
|
|
374
423
|
|
|
424
|
+
The default suite covers the Rails engine contract. The system task drives a
|
|
425
|
+
real headless Chrome session from NPS through promoter handoff, testimonial
|
|
426
|
+
submission, moderation, and public read. CI runs Rails 7.1, 7.2, 8.0, and 8.1
|
|
427
|
+
against Ruby 3.2 through 4.0.
|
|
428
|
+
|
|
375
429
|
Bug reports and pull requests welcome. The fastest way to help is to install it
|
|
376
430
|
in a real app and
|
|
377
431
|
[tell me where it hurt](https://github.com/yshmarov/testimonials/issues).
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,15 @@ require 'rake/testtask'
|
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |t|
|
|
7
7
|
t.libs << 'test'
|
|
8
|
-
t.
|
|
8
|
+
t.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
namespace :test do
|
|
12
|
+
desc 'Run browser (system) tests'
|
|
13
|
+
Rake::TestTask.new(:system) do |task|
|
|
14
|
+
task.libs << 'test'
|
|
15
|
+
task.test_files = FileList['test/system/**/*_test.rb']
|
|
16
|
+
end
|
|
9
17
|
end
|
|
10
18
|
|
|
11
19
|
task default: :test
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are released for the latest 1.x version. If a report also
|
|
6
|
+
affects the newest 0.x release, a backport may be published when the fix is
|
|
7
|
+
small and the affected application cannot upgrade immediately.
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Please do not open a public issue for a vulnerability. Use
|
|
12
|
+
[GitHub private vulnerability reporting](https://github.com/yshmarov/testimonials/security/advisories/new)
|
|
13
|
+
and include the affected version, a minimal reproduction, impact, and any known
|
|
14
|
+
workaround. Do not include credentials, session cookies, private application
|
|
15
|
+
URLs, customer submissions, uploaded media, or production database contents.
|
|
16
|
+
|
|
17
|
+
Public disclosure should wait until a fixed version is available and affected
|
|
18
|
+
users have had a reasonable opportunity to upgrade.
|
|
19
|
+
|
|
20
|
+
## Data and deployment boundary
|
|
21
|
+
|
|
22
|
+
The public collection pages and widget are controlled by `config.enabled`;
|
|
23
|
+
the dashboard is separately protected by `authorize_admin` and fails closed
|
|
24
|
+
outside development until the host grants access. `config.public_api` is off by
|
|
25
|
+
default. When enabled, it serves only approved testimonials carrying public
|
|
26
|
+
consent and never serializes email or `author_id`.
|
|
27
|
+
|
|
28
|
+
Testimonials can store their text, rating, best line, moderation status,
|
|
29
|
+
consent choice and exact consent-text snapshot, attribution, source, page URL,
|
|
30
|
+
user agent, locale, tenant key, timestamps, and optional video, poster, and
|
|
31
|
+
avatar uploads. Page URLs are accepted only as HTTP(S) and stored without query
|
|
32
|
+
strings or fragments. NPS responses can store the score, comment, attribution,
|
|
33
|
+
the same query-free page URL, user agent, locale, tenant key, and timestamps.
|
|
34
|
+
Prompt events store the prompt kind/action, author id or pseudonymous visitor
|
|
35
|
+
token, tenant key, and timestamps.
|
|
36
|
+
|
|
37
|
+
Uploaded media streams through the engine's authorization gate with private,
|
|
38
|
+
non-stored responses; the route never redirects to a reusable signed blob URL.
|
|
39
|
+
Hosts own the storage service, bucket policy, backups, retention periods,
|
|
40
|
+
access controls, and incident response. Enabling the public API deliberately
|
|
41
|
+
makes approved, publicly consented testimonial media readable to anyone who
|
|
42
|
+
has its engine URL.
|
|
43
|
+
|
|
44
|
+
Delete records when an application policy or customer request requires it:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
Testimonials::Testimonial.find(id).destroy!
|
|
48
|
+
Testimonials::NpsResponse.find(id).destroy!
|
|
49
|
+
Testimonials::PromptEvent.where(author_id: user.id.to_s).delete_all
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Destroying a testimonial lets Rails apply its configured Active Storage
|
|
53
|
+
attachment lifecycle. Confirm deletion separately in object storage, replicas,
|
|
54
|
+
backups, caches, analytics, notifications, and exports; deleting live database
|
|
55
|
+
rows does not rewrite those systems. Consent is evidence of the choice made at
|
|
56
|
+
submission time, not a substitute for the host's withdrawal and retention
|
|
57
|
+
process.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
3
5
|
module Testimonials
|
|
4
6
|
# Who is asking, which tenant they are in, and the gates that answer both.
|
|
5
7
|
#
|
|
@@ -41,6 +43,21 @@ module Testimonials
|
|
|
41
43
|
Testimonial.for_tenant(current_tenant)
|
|
42
44
|
end
|
|
43
45
|
|
|
46
|
+
# Browser URLs can carry password-reset tokens, signed ids, and campaign
|
|
47
|
+
# details in their query or fragment. Store only a safe HTTP(S) page origin
|
|
48
|
+
# and path; invalid or executable schemes become nil before the dashboard
|
|
49
|
+
# ever renders them as links.
|
|
50
|
+
def clean_page_url(value)
|
|
51
|
+
uri = URI.parse(value.to_s)
|
|
52
|
+
return unless uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
|
|
53
|
+
|
|
54
|
+
uri.query = nil
|
|
55
|
+
uri.fragment = nil
|
|
56
|
+
uri.to_s
|
|
57
|
+
rescue URI::InvalidURIError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
44
61
|
def require_enabled
|
|
45
62
|
head :forbidden unless Testimonials.enabled?(request)
|
|
46
63
|
end
|
|
@@ -5,34 +5,50 @@ module Testimonials
|
|
|
5
5
|
# need to know about Active Storage. Admins can fetch anything; everyone
|
|
6
6
|
# else only reaches media of publishable (approved + consented)
|
|
7
7
|
# testimonials, and only when the read API is public. The actual bytes are
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# streamed here after authorization. Keeping the browser on this route means
|
|
9
|
+
# an Active Storage URL cannot outlive the gate; Range support remains for
|
|
10
|
+
# video playback and seeking.
|
|
10
11
|
class MediaController < ApplicationController
|
|
12
|
+
include ActiveStorage::Streaming if defined?(::ActiveStorage::Streaming)
|
|
13
|
+
|
|
11
14
|
before_action :set_testimonial
|
|
12
15
|
before_action :authorize_media
|
|
13
16
|
|
|
14
17
|
def video
|
|
15
18
|
head :not_found and return unless @testimonial.video_attached?
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
stream(@testimonial.video_file)
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def poster
|
|
21
24
|
head :not_found and return unless @testimonial.poster_attached?
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
stream(@testimonial.poster)
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def avatar
|
|
27
30
|
head :not_found and return unless @testimonial.avatar_attached?
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
stream(@testimonial.avatar)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
private
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
def stream(attachment)
|
|
38
|
+
blob = attachment.blob
|
|
39
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
40
|
+
if request.headers['Range'].present?
|
|
41
|
+
send_blob_byte_range_data(blob, request.headers['Range'], disposition: disposition)
|
|
42
|
+
else
|
|
43
|
+
response.headers['Accept-Ranges'] = 'bytes'
|
|
44
|
+
response.headers['Content-Length'] = blob.byte_size.to_s
|
|
45
|
+
send_blob_stream(blob, disposition: disposition)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ?download=1 explicitly requests a file download. Without it we request
|
|
50
|
+
# inline playback; Active Storage may still force attachment disposition
|
|
51
|
+
# for content types outside the host's safe-inline allowlist.
|
|
36
52
|
def disposition
|
|
37
53
|
params[:download].present? ? 'attachment' : 'inline'
|
|
38
54
|
end
|
|
@@ -48,7 +64,11 @@ module Testimonials
|
|
|
48
64
|
# Authors can always see their own media — the widget replays their
|
|
49
65
|
# attached video when they edit their review.
|
|
50
66
|
return if current_author_id.present? && @testimonial.author_id == current_author_id.to_s
|
|
51
|
-
|
|
67
|
+
|
|
68
|
+
if Testimonials.config.public_api && @testimonial.approved? && @testimonial.consent_given?
|
|
69
|
+
response.set_header('Access-Control-Allow-Origin', '*')
|
|
70
|
+
return
|
|
71
|
+
end
|
|
52
72
|
|
|
53
73
|
head :forbidden
|
|
54
74
|
end
|
|
@@ -55,6 +55,7 @@ module Testimonials
|
|
|
55
55
|
end
|
|
56
56
|
testimonial.kind = wants_video?(testimonial) ? 'video' : 'text'
|
|
57
57
|
testimonial.source = 'widget' unless Testimonial::SOURCES.include?(testimonial.source)
|
|
58
|
+
testimonial.page_url = clean_page_url(testimonial.page_url)
|
|
58
59
|
testimonial.consent_text = Testimonials.consent_text_for(testimonial.consent_given?)
|
|
59
60
|
testimonial.locale = I18n.locale.to_s
|
|
60
61
|
testimonial.tenant = current_tenant
|
|
@@ -144,6 +144,19 @@
|
|
|
144
144
|
& .actions form { display: inline; }
|
|
145
145
|
& .form-actions { padding-top: 12px; }
|
|
146
146
|
|
|
147
|
+
/* Status switch: one pill holding every state a record can be in, the state it
|
|
148
|
+
is in now lit. Each other segment is a submit button, so moving a record
|
|
149
|
+
takes one click and no JavaScript; the current segment is a `type="button"`
|
|
150
|
+
so clicking it cannot re-file the record it is already filed under. Two
|
|
151
|
+
states or five, the pill sizes itself. A gem tints the lit segment with its
|
|
152
|
+
own status colour — see the `GEM-SPECIFIC` section. */
|
|
153
|
+
& .status-switch { display: inline-flex; flex: 0 0 auto; margin: 0; padding: 3px; border: 1px solid var(--tml-border); border-radius: 999px; background: var(--tml-bg); }
|
|
154
|
+
& .status-switch button { min-height: 30px; padding: 0 12px; border: 0; border-radius: 999px; background: none; color: var(--tml-muted); font-size: 13px; font-weight: 700; white-space: nowrap; transition: background .16s ease, color .16s ease; }
|
|
155
|
+
& .status-switch button:hover { border: 0; background: color-mix(in srgb, var(--tml-muted) 12%, transparent); color: var(--tml-text); }
|
|
156
|
+
& .status-switch button.current { background: var(--tml-surface); color: var(--tml-text); box-shadow: 0 1px 2px rgba(15, 23, 42, .12); cursor: default; }
|
|
157
|
+
& .status-switch button.current:hover { background: var(--tml-surface); }
|
|
158
|
+
& .panel-head .status-switch { margin-left: auto; }
|
|
159
|
+
|
|
147
160
|
& .card { overflow: hidden; border: 1px solid var(--tml-border); border-radius: 12px; background: var(--tml-surface); }
|
|
148
161
|
& .card.pad { padding: 16px; overflow: visible; }
|
|
149
162
|
& table { width: 100%; border-collapse: collapse; }
|
|
@@ -266,6 +279,7 @@
|
|
|
266
279
|
& .dashboard-detail .detail-panel { min-height: calc(100vh - 64px); }
|
|
267
280
|
& .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
|
|
268
281
|
& .panel-head h1 { font-size: 18px; }
|
|
282
|
+
& .status-switch button { padding: 0 9px; font-size: 12px; }
|
|
269
283
|
& dl { grid-template-columns: 1fr; gap: 3px; }
|
|
270
284
|
& dd { margin-bottom: 8px; }
|
|
271
285
|
}
|
data/lib/testimonials/seeds.rb
CHANGED
|
@@ -9,8 +9,10 @@ module Testimonials
|
|
|
9
9
|
{
|
|
10
10
|
seed_id: 'approved-founder',
|
|
11
11
|
kind: 'text',
|
|
12
|
-
body: 'We
|
|
13
|
-
|
|
12
|
+
body: 'We call testimonial_prompt! after a customer completes onboarding, when the value is still ' \
|
|
13
|
+
'fresh. The prompt feels earned, and the quotes are much more specific than the ones we used ' \
|
|
14
|
+
'to request by email.',
|
|
15
|
+
best_line: 'Ask after a real success moment and the quotes become specific.',
|
|
14
16
|
rating: 5,
|
|
15
17
|
status: 'approved',
|
|
16
18
|
featured: true,
|
|
@@ -18,7 +20,7 @@ module Testimonials
|
|
|
18
20
|
consent_text: 'You can use my testimonial publicly in your marketing and sales.',
|
|
19
21
|
name: 'Maya Chen',
|
|
20
22
|
email: 'maya@example.com',
|
|
21
|
-
title_company: 'Founder,
|
|
23
|
+
title_company: 'Founder, Demo CRM',
|
|
22
24
|
source: 'widget',
|
|
23
25
|
page_url: '/onboarding/complete',
|
|
24
26
|
locale: 'en'
|
|
@@ -26,7 +28,9 @@ module Testimonials
|
|
|
26
28
|
{
|
|
27
29
|
seed_id: 'pending-operator',
|
|
28
30
|
kind: 'text',
|
|
29
|
-
body: '
|
|
31
|
+
body: 'This response has public consent, but it is still pending. That separation gives our team a ' \
|
|
32
|
+
'chance to verify the quote, choose a best line, and approve it before anything appears on the ' \
|
|
33
|
+
'wall of love.',
|
|
30
34
|
rating: 4,
|
|
31
35
|
status: 'pending',
|
|
32
36
|
featured: false,
|
|
@@ -34,7 +38,7 @@ module Testimonials
|
|
|
34
38
|
consent_text: 'You can use my testimonial publicly in your marketing and sales.',
|
|
35
39
|
name: 'Jon Bell',
|
|
36
40
|
email: 'jon@example.com',
|
|
37
|
-
title_company: 'Operations Lead,
|
|
41
|
+
title_company: 'Operations Lead, Example Co',
|
|
38
42
|
source: 'nps',
|
|
39
43
|
page_url: '/reports',
|
|
40
44
|
locale: 'en'
|
|
@@ -42,7 +46,8 @@ module Testimonials
|
|
|
42
46
|
{
|
|
43
47
|
seed_id: 'private-archive',
|
|
44
48
|
kind: 'text',
|
|
45
|
-
body: '
|
|
49
|
+
body: 'I am happy for the team to keep this feedback internally, but I did not consent to public ' \
|
|
50
|
+
'use. Archiving it keeps the learning without making it publishable.',
|
|
46
51
|
rating: 3,
|
|
47
52
|
status: 'archived',
|
|
48
53
|
featured: false,
|
|
@@ -58,8 +63,10 @@ module Testimonials
|
|
|
58
63
|
{
|
|
59
64
|
seed_id: 'approved-video',
|
|
60
65
|
kind: 'video',
|
|
61
|
-
body: '
|
|
62
|
-
|
|
66
|
+
body: 'The attached clip is intentionally generic demo media. It proves the upload, review, and ' \
|
|
67
|
+
'protected playback path; replace it with a real customer recording before using this record ' \
|
|
68
|
+
'anywhere public.',
|
|
69
|
+
best_line: 'Video follows the same consent and approval workflow as text.',
|
|
63
70
|
rating: 5,
|
|
64
71
|
status: 'approved',
|
|
65
72
|
featured: true,
|
|
@@ -67,7 +74,7 @@ module Testimonials
|
|
|
67
74
|
consent_text: 'You can use my testimonial publicly in your marketing and sales.',
|
|
68
75
|
name: 'Alex Rivera',
|
|
69
76
|
email: 'alex@example.com',
|
|
70
|
-
title_company: 'Head of Product,
|
|
77
|
+
title_company: 'Head of Product, Demo Video Co',
|
|
71
78
|
source: 'page',
|
|
72
79
|
page_url: '/testimonials/new',
|
|
73
80
|
locale: 'en'
|
|
@@ -76,9 +83,12 @@ module Testimonials
|
|
|
76
83
|
|
|
77
84
|
NPS_RESPONSES = [
|
|
78
85
|
{ seed_id: 'promoter', score: 10,
|
|
79
|
-
comment: '
|
|
80
|
-
{ seed_id: 'passive', score: 8,
|
|
81
|
-
|
|
86
|
+
comment: 'A 9 or 10 is a promoter. One promoter contributes positively to the NPS score.' },
|
|
87
|
+
{ seed_id: 'passive', score: 8,
|
|
88
|
+
comment: 'A 7 or 8 is passive. Keep the feedback, but this response does not move the NPS score.' },
|
|
89
|
+
{ seed_id: 'detractor', score: 4,
|
|
90
|
+
comment: 'A score from 0 to 6 is a detractor. With one promoter and one detractor, this demo board ' \
|
|
91
|
+
'totals 0 NPS.' }
|
|
82
92
|
].freeze
|
|
83
93
|
|
|
84
94
|
PROMPT_EVENTS = [
|
data/lib/testimonials/version.rb
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Testimonials
|
|
4
|
-
# Included into the host's ActionView.
|
|
5
|
-
# before
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# `data-testimonial-prompt` attribute, or by `window.Testimonials.open()`.
|
|
9
|
-
#
|
|
10
|
-
# `testimonials_button` renders a ready-made opener with a localized
|
|
11
|
-
# label ("Leave a review", or "Update your review" once the user has one),
|
|
12
|
-
# so hosts get a user-facing entry point without any i18n setup.
|
|
4
|
+
# Included into the host's ActionView. This lives under lib and is required
|
|
5
|
+
# before the engine registers its Action View load hook: a host initializer
|
|
6
|
+
# may load Action View before Rails sets up application autoloaders, when an
|
|
7
|
+
# app/helpers constant cannot be resolved yet.
|
|
13
8
|
module WidgetHelper
|
|
14
9
|
def testimonials_tag
|
|
15
10
|
return unless Testimonials.enabled?(request)
|
|
@@ -23,19 +18,12 @@ module Testimonials
|
|
|
23
18
|
).html_safe
|
|
24
19
|
end
|
|
25
20
|
|
|
26
|
-
# A plain, unstyled <button> so it picks up the host's own styles. Put it
|
|
27
|
-
# anywhere on a page that also renders testimonials_tag. Pass label: to
|
|
28
|
-
# override the localized default, and any other options (class:, etc.)
|
|
29
|
-
# through to the tag.
|
|
30
21
|
def testimonials_button(label: nil, **)
|
|
31
22
|
return unless Testimonials.enabled?(request)
|
|
32
23
|
|
|
33
24
|
tag.button(label || testimonials_cta_label, type: 'button', 'data-testimonial-prompt': '', **)
|
|
34
25
|
end
|
|
35
26
|
|
|
36
|
-
# The localized call-to-action on its own — "Leave a review", or "Update
|
|
37
|
-
# your review" once the user has one — for hosts composing their own
|
|
38
|
-
# opener markup (icons, list items) around a data-testimonial-prompt element.
|
|
39
27
|
def testimonials_cta_label
|
|
40
28
|
if testimonials_existing_testimonial
|
|
41
29
|
I18n.t(:update_title, scope: :testimonials, default: 'Update your review')
|
|
@@ -52,8 +40,6 @@ module Testimonials
|
|
|
52
40
|
@testimonials_author = Testimonials.config.current_user.call(request)
|
|
53
41
|
end
|
|
54
42
|
|
|
55
|
-
# One review per signed-in user (per tenant): this is the record the widget
|
|
56
|
-
# opens pre-filled, and the one the create endpoint updates in place.
|
|
57
43
|
def testimonials_existing_testimonial
|
|
58
44
|
return @testimonials_existing_testimonial if defined?(@testimonials_existing_testimonial)
|
|
59
45
|
|
|
@@ -65,16 +51,10 @@ module Testimonials
|
|
|
65
51
|
end
|
|
66
52
|
end
|
|
67
53
|
|
|
68
|
-
# A prompt requested via testimonial_prompt! rides the flash, so it survives
|
|
69
|
-
# the usual redirect-after-create. It only reaches the page if the
|
|
70
|
-
# throttle ledger agrees — call testimonial_prompt! as often as you like.
|
|
71
54
|
def testimonials_auto_open
|
|
72
55
|
kind = flash[:testimonials_prompt].to_s
|
|
73
56
|
return unless Testimonials::PromptEvent::KINDS.include?(kind)
|
|
74
57
|
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
58
|
return unless Testimonials.config.prompt_events
|
|
79
59
|
|
|
80
60
|
author = testimonials_author
|
data/lib/testimonials.rb
CHANGED
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.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -16,6 +16,9 @@ dependencies:
|
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '7.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -23,6 +26,9 @@ dependencies:
|
|
|
23
26
|
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
28
|
version: '7.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
26
32
|
description: |
|
|
27
33
|
A mountable Rails engine that collects customer testimonials — text and
|
|
28
34
|
video — and NPS scores from inside your app. An iOS-style "Enjoying this
|
|
@@ -43,6 +49,7 @@ files:
|
|
|
43
49
|
- MIT-LICENSE
|
|
44
50
|
- README.md
|
|
45
51
|
- Rakefile
|
|
52
|
+
- SECURITY.md
|
|
46
53
|
- app/controllers/concerns/testimonials/request_context.rb
|
|
47
54
|
- app/controllers/testimonials/api/base_controller.rb
|
|
48
55
|
- app/controllers/testimonials/api/stats_controller.rb
|
|
@@ -57,7 +64,6 @@ files:
|
|
|
57
64
|
- app/controllers/testimonials/submissions_controller.rb
|
|
58
65
|
- app/controllers/testimonials/testimonials_controller.rb
|
|
59
66
|
- app/controllers/testimonials/widgets_controller.rb
|
|
60
|
-
- app/helpers/testimonials/widget_helper.rb
|
|
61
67
|
- app/models/testimonials/application_record.rb
|
|
62
68
|
- app/models/testimonials/nps_response.rb
|
|
63
69
|
- app/models/testimonials/prompt_event.rb
|
|
@@ -129,12 +135,13 @@ files:
|
|
|
129
135
|
- lib/testimonials/version.rb
|
|
130
136
|
- lib/testimonials/widget.js
|
|
131
137
|
- lib/testimonials/widget.rb
|
|
138
|
+
- lib/testimonials/widget_helper.rb
|
|
132
139
|
homepage: https://github.com/yshmarov/testimonials
|
|
133
140
|
licenses:
|
|
134
141
|
- MIT
|
|
135
142
|
metadata:
|
|
136
143
|
homepage_uri: https://github.com/yshmarov/testimonials
|
|
137
|
-
source_code_uri: https://github.com/yshmarov/testimonials
|
|
144
|
+
source_code_uri: https://github.com/yshmarov/testimonials/tree/main
|
|
138
145
|
changelog_uri: https://github.com/yshmarov/testimonials/blob/main/CHANGELOG.md
|
|
139
146
|
bug_tracker_uri: https://github.com/yshmarov/testimonials/issues
|
|
140
147
|
rubygems_mfa_required: 'true'
|
|
@@ -152,7 +159,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
152
159
|
- !ruby/object:Gem::Version
|
|
153
160
|
version: '0'
|
|
154
161
|
requirements: []
|
|
155
|
-
rubygems_version:
|
|
162
|
+
rubygems_version: 4.0.16
|
|
156
163
|
specification_version: 4
|
|
157
164
|
summary: 'Testimonials, reviews and NPS for Rails: in-app collection widget, public
|
|
158
165
|
collection page, triage dashboard, and a read API.'
|