testimonials 0.5.1 → 0.5.2
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/CHANGELOG.md +7 -0
- data/README.md +205 -123
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +20 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a660a9eadbc3bfa58f9fe6bfc2d9f4feebe867fe536fe0755a156c9b931aca49
|
|
4
|
+
data.tar.gz: 73c439422ee9c88ee56836338dc8be3b9e41063cec3009bad76025673ca675d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1198770a514947ebc0a23a78a19f29a690c906c03c12e2135b8aa7a4538d4a6f3b406ae0822123ff4b3763e8502111298f1d9b37791dba9254305fc3634fb426
|
|
7
|
+
data.tar.gz: 6b5585808618f90d8826fdd4f49a142442ac230417e1a2d0272b755b8b497c4fb31f05fef262c02650715b68af94c6f1b36b1ee7d07830a51a82e4f114a65b58
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.2
|
|
4
|
+
|
|
5
|
+
- A just-recorded or just-uploaded video now shows a playable preview on the
|
|
6
|
+
form (with its captured poster), matching the review step and the
|
|
7
|
+
edit-existing case — previously "Use this video" left only a "Video
|
|
8
|
+
attached" chip with no preview until the review was saved and reopened.
|
|
9
|
+
|
|
3
10
|
## 0.5.1
|
|
4
11
|
|
|
5
12
|
- The widget's injected stylesheet now refreshes when its content changes
|
data/README.md
CHANGED
|
@@ -1,60 +1,18 @@
|
|
|
1
1
|
# testimonials
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/testimonials)
|
|
4
|
+
[](https://rubygems.org/gems/testimonials)
|
|
4
5
|
[](https://github.com/yshmarov/testimonials/actions/workflows/ci.yml)
|
|
5
6
|
[](MIT-LICENSE)
|
|
7
|
+
[](https://github.com/yshmarov/testimonials/stargazers)
|
|
6
8
|
|
|
7
|
-
Testimonials, reviews and NPS for Rails
|
|
9
|
+
**Testimonials, reviews and NPS for Rails.** Text and **video**, collected
|
|
10
|
+
inside your own app, stored in your own database. Self-hosted alternative to
|
|
8
11
|
Testimonial.to / Senja / Delighted.
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
testimonial ask. Everything lands in your own database with a minimal
|
|
14
|
-
dashboard to approve, feature, and pick each one’s best line. Display is **headless**: render
|
|
15
|
-
approved testimonials with your own markup via the models or a JSON API —
|
|
16
|
-
copy-paste examples included.
|
|
17
|
-
|
|
18
|
-
- **Zero UI dependencies.** The widget is plain JavaScript and styles itself.
|
|
19
|
-
No Tailwind, no Stimulus, no importmap, no build step. Works with Turbo
|
|
20
|
-
Drive and strict nonce-based CSP out of the box.
|
|
21
|
-
- **Prompt at the right moment.** Call `testimonial_prompt!` in a controller at
|
|
22
|
-
your success moments (subscription renewed, milestone hit). Built-in
|
|
23
|
-
throttling means you can call it liberally — nobody gets nagged.
|
|
24
|
-
- **Real attribution.** Signed-in users are attributed server-side to your
|
|
25
|
-
user records; guests leave name and email. No external tool can do this.
|
|
26
|
-
- **Video testimonials** recorded in the browser (MediaRecorder) with a
|
|
27
|
-
review step, or uploaded as a file. Plain multipart upload via Active
|
|
28
|
-
Storage.
|
|
29
|
-
- **NPS built in.** 0–10 with a comment; promoters (9–10) are immediately
|
|
30
|
-
offered the testimonial form, detractors flow into `on_detractor` (pairs
|
|
31
|
-
well with [feedback_engine](https://github.com/yshmarov/feedback-engine)).
|
|
32
|
-
- **Consent is first-class.** Customers choose public or private use in their
|
|
33
|
-
own words, with a stored text snapshot; the read API only ever serves
|
|
34
|
-
approved records marked for **public** use, and never emails.
|
|
35
|
-
- **26 languages**, including localized best-practice guiding questions.
|
|
36
|
-
|
|
37
|
-
## How it works
|
|
38
|
-
|
|
39
|
-
1. Add `<%= testimonials_tag %>` to your layout. Nothing is visible until
|
|
40
|
-
the widget opens: from an eligible `testimonial_prompt!`, from any element with
|
|
41
|
-
`data-testimonial-prompt`, or from `window.Testimonials.open()`.
|
|
42
|
-
2. The prompt starts as a small star card ("Enjoying MyApp? ★★★★★ / Not
|
|
43
|
-
now"). Tapping a star expands into the full form: guiding questions,
|
|
44
|
-
text, optional video recording, consent.
|
|
45
|
-
3. Submissions land in `testimonials_testimonials` as `pending`. You
|
|
46
|
-
approve, feature, and pick best lines at the mount path (`/testimonials`).
|
|
47
|
-
4. You render approved testimonials wherever you like — see
|
|
48
|
-
[`examples/`](examples/) for a wall of love, quote card, rating badge,
|
|
49
|
-
JSON-LD rich snippets, and a static-site (Astro) recipe.
|
|
50
|
-
|
|
51
|
-
## Requirements
|
|
52
|
-
|
|
53
|
-
- Ruby >= 3.2
|
|
54
|
-
- Rails >= 7.1
|
|
55
|
-
- Active Storage (only if you want video/avatar uploads)
|
|
56
|
-
|
|
57
|
-
## Installation
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## Install
|
|
58
16
|
|
|
59
17
|
```ruby
|
|
60
18
|
# Gemfile
|
|
@@ -67,21 +25,76 @@ bin/rails generate testimonials:install
|
|
|
67
25
|
bin/rails db:migrate
|
|
68
26
|
```
|
|
69
27
|
|
|
70
|
-
The generator writes `config/initializers/testimonials.rb`, creates the
|
|
71
|
-
migration, and mounts the engine at `/testimonials`. Then add the widget to your
|
|
72
|
-
layout:
|
|
73
|
-
|
|
74
28
|
```erb
|
|
29
|
+
<%# app/views/layouts/application.html.erb %>
|
|
75
30
|
<%= testimonials_tag %>
|
|
76
31
|
```
|
|
77
32
|
|
|
33
|
+
The generator writes the initializer, the migration, and mounts the engine at
|
|
34
|
+
`/testimonials`. Nothing renders until the widget is opened.
|
|
35
|
+
|
|
36
|
+
> [!IMPORTANT]
|
|
37
|
+
> The dashboard defaults to **development only**. Set `authorize_admin` before
|
|
38
|
+
> you deploy — see [Configure](#configure).
|
|
39
|
+
|
|
40
|
+
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want video/avatar uploads.
|
|
41
|
+
|
|
42
|
+
## What you get
|
|
43
|
+
|
|
44
|
+
| | |
|
|
45
|
+
| --------------- | ----------------------------------------------------------------------- |
|
|
46
|
+
| **Widget** | iOS-style star prompt → rating, guiding questions, text, video, consent |
|
|
47
|
+
| **Video** | Recorded in-browser (MediaRecorder) with a review step, or uploaded |
|
|
48
|
+
| **NPS** | 0–10 + comment. Promoters routed into the testimonial ask |
|
|
49
|
+
| **Public page** | `/testimonials/new` — a shareable link for customers outside the app |
|
|
50
|
+
| **Dashboard** | Pending → approved → archived, search, feature toggle, best-line picker |
|
|
51
|
+
| **Display** | Headless. Your markup, via the models or a JSON API |
|
|
52
|
+
| **Deps** | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step |
|
|
53
|
+
| **Auth** | Lambdas over the raw request — Devise, Rails 8 auth, anything |
|
|
54
|
+
| **i18n** | 26 languages, including localized guiding questions |
|
|
55
|
+
| **Turbo/CSP** | Turbo Drive and strict nonce-based CSP out of the box |
|
|
56
|
+
|
|
57
|
+
## Why self-host it
|
|
58
|
+
|
|
59
|
+
| | `testimonials` | A third-party embed |
|
|
60
|
+
| ----------------------------------- | ------------------------------------ | ------------------------------- |
|
|
61
|
+
| Cost | Free, MIT | Monthly subscription |
|
|
62
|
+
| Where testimonials live | Your database | The vendor's |
|
|
63
|
+
| Attribution to *your* user records | Server-side, from the session | Whatever the visitor types |
|
|
64
|
+
| Prompting at in-app success moments | One method call in a controller | Not reachable from your backend |
|
|
65
|
+
| Page weight | One `<script>`, no framework, no CDN | Third-party bundle + requests |
|
|
66
|
+
| Consent record | Stored text snapshot, in your rows | Vendor's terms |
|
|
67
|
+
| Multi-tenant | Built in, one resolver | Usually a plan upgrade |
|
|
68
|
+
| Rendering | Your markup, your CSS | Their widget, their branding |
|
|
69
|
+
| If the vendor disappears | Nothing happens | You lose the wall of love |
|
|
70
|
+
|
|
71
|
+
## The whole flow
|
|
72
|
+
|
|
73
|
+
| 1. A star card appears at a success moment | 2. Tapping a star expands the full form |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
|  |  |
|
|
76
|
+
| Nothing renders until it's eligible. "Not now" is always one tap away. | Guiding questions fight blank-page paralysis. Video is offered, never required. |
|
|
77
|
+
| **3. Camera check before recording** | **4. Review it before sending** |
|
|
78
|
+
|  |  |
|
|
79
|
+
| A countdown and a preview, so nobody is caught mid-blink. | Record again as often as they like. The blob stays in the browser until Send. |
|
|
80
|
+
| **5. Attached, written, consented** | **6. Lands in your dashboard as `pending`** |
|
|
81
|
+
|  |  |
|
|
82
|
+
| Consent is an explicit choice in the customer's own words. | Nothing is public until you approve it. |
|
|
83
|
+
|
|
78
84
|
## Prompting users
|
|
79
85
|
|
|
86
|
+
Call it at a success moment. Throttling is built in, so call it liberally:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
testimonial_prompt! # ask for a testimonial
|
|
90
|
+
testimonial_prompt!(:nps) # ask for an NPS score instead
|
|
91
|
+
```
|
|
92
|
+
|
|
80
93
|
```ruby
|
|
81
94
|
class InvoicesController < ApplicationController
|
|
82
95
|
def create
|
|
83
96
|
# ...
|
|
84
|
-
testimonial_prompt! if current_user.invoices.count == 10
|
|
97
|
+
testimonial_prompt! if current_user.invoices.count == 10
|
|
85
98
|
redirect_to invoices_path
|
|
86
99
|
end
|
|
87
100
|
end
|
|
@@ -89,53 +102,80 @@ end
|
|
|
89
102
|
|
|
90
103
|
The widget auto-opens on the next rendered page **if** the throttle allows:
|
|
91
104
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
| Situation | Result |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| Already submitted | Never auto-prompted again |
|
|
108
|
+
| Dismissed | Not again within `reprompt_after` (90 days) |
|
|
109
|
+
| Auto-prompted `max_prompts` times (3) | Never again |
|
|
95
110
|
|
|
96
|
-
Explicit opens bypass throttling
|
|
97
|
-
|
|
111
|
+
**Explicit opens bypass throttling** — any element with
|
|
112
|
+
`data-testimonial-prompt` (or `="nps"`), or `window.Testimonials.open()` /
|
|
98
113
|
`window.Testimonials.openNps()`.
|
|
99
114
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
## Configure
|
|
116
|
+
|
|
117
|
+
Everything is optional — a fresh install works with zero config. In
|
|
118
|
+
`config/initializers/testimonials.rb`:
|
|
119
|
+
|
|
120
|
+
| Option | Default | What it does |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `authorize_admin` | development only | **Who can read the dashboard.** Override before deploying |
|
|
123
|
+
| `app_name` | Rails app name | Shown as "Enjoying %{app}?" and in the questions |
|
|
124
|
+
| `enabled` | everyone | Who gets the widget. `false` hides it and rejects posts |
|
|
125
|
+
| `current_user` | `nil` | Attribute a submission to a user. Receives the request |
|
|
126
|
+
| `user_display` | `{ name:, email: }` | Turns that user into stored attribution |
|
|
127
|
+
| `tenant` | `nil` | One collection per tenant — see [Multi-tenancy](#multi-tenancy) |
|
|
128
|
+
| `questions` | localized built-ins | Guiding prompts. Array, callable, or `[]` to hide |
|
|
129
|
+
| `consent_text` | localized default | The wording customers agree to |
|
|
130
|
+
| `video` | `true` | Video recording and upload (needs Active Storage) |
|
|
131
|
+
| `max_video_seconds` | `120` | Recording cap |
|
|
132
|
+
| `max_video_size` | `50.megabytes` | Enforced server-side |
|
|
133
|
+
| `avatars` | `true` | Headshot upload for guests on the public page |
|
|
134
|
+
| `max_avatar_size` | `5.megabytes` | Enforced server-side |
|
|
135
|
+
| `reprompt_after` | `90.days` | Cooldown after a dismissal |
|
|
136
|
+
| `max_prompts` | `3` | Lifetime auto-prompt cap per user |
|
|
137
|
+
| `public_collection` | `true` | The shareable `/testimonials/new` page |
|
|
138
|
+
| `public_api` | `false` | Serve the read API without auth (CORS `*`) |
|
|
139
|
+
| `nps` | `true` | The 0–10 NPS flow |
|
|
140
|
+
| `nps_reprompt_after` | `90.days` | Cooldown between NPS asks |
|
|
141
|
+
| `rate_limit` | `{ to: 5, within: 1.minute }` | Per-IP throttle (Rails 7.2+). `nil` disables |
|
|
142
|
+
| `mount_path` | `"/testimonials"` | Keep in sync with `mount` in `routes.rb` |
|
|
143
|
+
| `on_submit` | no-op | Runs after each save — Slack, email, CRM |
|
|
144
|
+
| `on_detractor` | no-op | Runs for NPS 0–6 |
|
|
145
|
+
|
|
146
|
+
A typical initializer:
|
|
106
147
|
|
|
107
148
|
```ruby
|
|
108
149
|
Testimonials.configure do |config|
|
|
109
|
-
config.app_name
|
|
110
|
-
config.current_user
|
|
111
|
-
config.user_display
|
|
150
|
+
config.app_name = "SupeRails"
|
|
151
|
+
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
152
|
+
config.user_display = ->(user) { { name: user.name, email: user.email } }
|
|
112
153
|
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
113
|
-
config.on_submit
|
|
114
|
-
config.on_detractor
|
|
154
|
+
config.on_submit = ->(record) { SlackNotifier.ping(record) }
|
|
155
|
+
config.on_detractor = ->(nps) { Ideasbugs::Feedback.create!(kind: "other", message: nps.comment.to_s) }
|
|
115
156
|
end
|
|
116
157
|
```
|
|
117
158
|
|
|
118
|
-
|
|
119
|
-
with whatever auth you have:
|
|
159
|
+
Gates receive the **raw request**, so they work with any auth:
|
|
120
160
|
|
|
121
161
|
```ruby
|
|
122
|
-
# Devise / Warden
|
|
162
|
+
# Devise / Warden
|
|
123
163
|
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
124
164
|
|
|
125
|
-
# Rails 8 built-in auth (bin/rails generate authentication)
|
|
165
|
+
# Rails 8 built-in auth (bin/rails generate authentication)
|
|
126
166
|
config.current_user = lambda do |request|
|
|
127
167
|
token = request.cookies["session_token"]
|
|
128
168
|
Session.find_signed(token)&.user if token
|
|
129
169
|
end
|
|
130
170
|
```
|
|
131
171
|
|
|
132
|
-
|
|
172
|
+
<details>
|
|
173
|
+
<summary><b>Guiding questions, localized</b></summary>
|
|
133
174
|
|
|
134
|
-
The questions
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
for host-side i18n:
|
|
175
|
+
The questions above the form ("How has %{app} helped you?") fight blank-page
|
|
176
|
+
paralysis — they are prompts, not form fields. By default they come from the
|
|
177
|
+
gem's locale files, so all 26 languages get good questions with your app name
|
|
178
|
+
interpolated.
|
|
139
179
|
|
|
140
180
|
```ruby
|
|
141
181
|
config.questions = ["What convinced you to try %{app}?", "What changed since?"]
|
|
@@ -143,62 +183,83 @@ config.questions = -> { I18n.t("reviews.questions") } # your own locale keys
|
|
|
143
183
|
config.questions = [] # hide the section
|
|
144
184
|
```
|
|
145
185
|
|
|
146
|
-
|
|
186
|
+
</details>
|
|
147
187
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
188
|
+
## Dashboard
|
|
189
|
+
|
|
190
|
+
Pending → approved → archived tabs, search, quick-approve, inline video
|
|
191
|
+
playback, feature toggle, and a **Best line** picker.
|
|
192
|
+
|
|
193
|
+
<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">
|
|
194
|
+
|
|
195
|
+
Every submission keeps its provenance — who sent it, the exact consent text
|
|
196
|
+
they agreed to, the page, locale, browser, timestamp. **Best line** is the only
|
|
197
|
+
thing you write: the customer's words stay verbatim, you just choose which
|
|
198
|
+
sentence your landing page pulls out.
|
|
151
199
|
|
|
152
200
|
## The read API
|
|
153
201
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
202
|
+
| Endpoint | Returns |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| `GET /testimonials/api/testimonials` | Approved + publicly-consented records. Filters: `featured=1`, `min_rating=4`, `kind=video`, `limit=12` |
|
|
205
|
+
| `GET /testimonials/api/stats` | `count`, `average_rating`, `ratings_count`, `nps_score` |
|
|
158
206
|
|
|
159
|
-
Admin-only by default.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
207
|
+
Admin-only by default. `config.public_api = true` serves them without auth
|
|
208
|
+
(CORS `*`) — ideal for a static marketing site rendering your wall of love at
|
|
209
|
+
build time. **Emails and author ids are never serialized.** Video and avatar
|
|
210
|
+
files go through the same gate, with Range support so `<video>` just works.
|
|
211
|
+
|
|
212
|
+
## Rendering what you collect
|
|
213
|
+
|
|
214
|
+
The gem ships **no display UI** on purpose. [`examples/`](examples/) has
|
|
215
|
+
copy-paste starting points:
|
|
216
|
+
|
|
217
|
+
| Example | What it renders |
|
|
218
|
+
| --- | --- |
|
|
219
|
+
| [`wall_of_love.html.erb`](examples/wall_of_love.html.erb) | A responsive grid of text + video testimonials |
|
|
220
|
+
| [`testimonial_card.html.erb`](examples/testimonial_card.html.erb) | A single quote card for a landing/pricing page |
|
|
221
|
+
| [`badge.html.erb`](examples/badge.html.erb) | The "★ 4.9 from 87 reviews" chip |
|
|
222
|
+
| [`json_ld.html.erb`](examples/json_ld.html.erb) | schema.org markup for Google rich snippets |
|
|
223
|
+
| [`static_site.md`](examples/static_site.md) | Rendering on a separate marketing site (Astro & friends) |
|
|
164
224
|
|
|
165
225
|
## NPS
|
|
166
226
|
|
|
167
|
-
`testimonial_prompt!(:nps)`
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
`/testimonials/nps_responses`.
|
|
227
|
+
`testimonial_prompt!(:nps)` asks the classic 0–10 question. Promoters (9–10)
|
|
228
|
+
are offered the testimonial form immediately — but only if the testimonial
|
|
229
|
+
throttle would allow it. Detractors (0–6) trigger `on_detractor`, which pairs
|
|
230
|
+
well with [ideasbugs](https://github.com/yshmarov/ideasbugs). Your score and
|
|
231
|
+
every response live at `/testimonials/nps_responses`.
|
|
172
232
|
|
|
173
|
-
##
|
|
233
|
+
## The public collection page
|
|
174
234
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
Gated by `config.authorize_admin` (development-only until you set it).
|
|
235
|
+
`/testimonials/new` is a standalone, self-styled page for customers outside the
|
|
236
|
+
app — drop the link into an email or DM. Guests leave name, email, optional
|
|
237
|
+
title/company and photo. Disable with `config.public_collection = false`.
|
|
179
238
|
|
|
180
239
|
## Multi-tenancy
|
|
181
240
|
|
|
182
241
|
Scope testimonials to a tenant — each Organization (or Product, Course, Store)
|
|
183
|
-
with its own collection, dashboard, widget
|
|
242
|
+
with its own collection, dashboard, widget and read API — with one resolver:
|
|
184
243
|
|
|
185
244
|
```ruby
|
|
186
245
|
config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
|
|
187
246
|
```
|
|
188
247
|
|
|
189
|
-
Return an **opaque key
|
|
190
|
-
|
|
191
|
-
Organization is
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
248
|
+
Return an **opaque key**: a GlobalID, an id, a subdomain, a slug. The gem never
|
|
249
|
+
takes a foreign key into your models and never needs to know what an
|
|
250
|
+
Organization is. It stamps the key on each submission and scopes every read —
|
|
251
|
+
dashboard, `/api/testimonials`, `/api/stats`, NPS, the public page. `nil` — the
|
|
252
|
+
default — is a single global collection, so single-tenant apps need none of
|
|
253
|
+
this.
|
|
195
254
|
|
|
196
|
-
Authorization composes:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
user **per tenant**.
|
|
255
|
+
Authorization composes: `authorize_admin` says *who* is an admin, `tenant` says
|
|
256
|
+
*which* tenant they're in. "One review per user" becomes one per user **per
|
|
257
|
+
tenant**.
|
|
200
258
|
|
|
201
|
-
|
|
259
|
+
<details>
|
|
260
|
+
<summary><b>Optional model sugar, and upgrading an existing install</b></summary>
|
|
261
|
+
|
|
262
|
+
A veneer over the string key, not a new coupling:
|
|
202
263
|
|
|
203
264
|
```ruby
|
|
204
265
|
class Organization < ApplicationRecord
|
|
@@ -210,21 +271,42 @@ organization.testimonials_nps # NPS responses for this tenant
|
|
|
210
271
|
```
|
|
211
272
|
|
|
212
273
|
Apps that skip the concern still get full multi-tenancy from the resolver
|
|
213
|
-
alone.
|
|
214
|
-
`has_testimonials(key: ->(o){ o.subdomain })` and the same in
|
|
274
|
+
alone. Keying by something other than GlobalID? Pass a matching resolver to
|
|
275
|
+
both: `has_testimonials(key: ->(o){ o.subdomain })` and the same in
|
|
276
|
+
`config.tenant`.
|
|
277
|
+
|
|
278
|
+
**Upgrading:** the `tenant` column is additive and nullable. Run
|
|
279
|
+
`bin/rails generate testimonials:tenant && bin/rails db:migrate` (a fresh
|
|
280
|
+
install already includes it). Existing rows keep a `nil` tenant — the global
|
|
281
|
+
collection — so nothing changes until you set `config.tenant`.
|
|
282
|
+
|
|
283
|
+
</details>
|
|
284
|
+
|
|
285
|
+
## Who's using it
|
|
215
286
|
|
|
216
|
-
|
|
217
|
-
nullable. Run `bin/rails generate testimonials:tenant && bin/rails db:migrate`
|
|
218
|
-
(a fresh install already includes it). Existing rows keep a `nil` tenant — the
|
|
219
|
-
global collection — so nothing changes until you set `config.tenant`.
|
|
287
|
+
- [PaidCollabs](https://paidcollabs.com) — the screenshots above
|
|
220
288
|
|
|
221
|
-
|
|
289
|
+
Shipping it? [Open a PR](https://github.com/yshmarov/testimonials/pulls) and
|
|
290
|
+
add yourself.
|
|
291
|
+
|
|
292
|
+
## Development
|
|
222
293
|
|
|
223
294
|
```bash
|
|
224
295
|
bundle exec rake test
|
|
225
296
|
bundle exec rubocop
|
|
226
297
|
```
|
|
227
298
|
|
|
299
|
+
Bug reports and pull requests welcome. The fastest way to help is to install it
|
|
300
|
+
in a real app and
|
|
301
|
+
[tell me where it hurt](https://github.com/yshmarov/testimonials/issues).
|
|
302
|
+
|
|
303
|
+
## Also by the same author
|
|
304
|
+
|
|
305
|
+
- [ideasbugs](https://github.com/yshmarov/ideasbugs) — in-app bug reports and
|
|
306
|
+
feature requests. Pairs with this gem's NPS detractor hook.
|
|
307
|
+
- [SupeRails](https://superails.com) — Rails screencasts.
|
|
308
|
+
|
|
228
309
|
## License
|
|
229
310
|
|
|
230
|
-
MIT.
|
|
311
|
+
MIT. If it saved you a subscription, a
|
|
312
|
+
[⭐](https://github.com/yshmarov/testimonials) is a fair trade.
|
data/lib/testimonials/version.rb
CHANGED
data/lib/testimonials/widget.js
CHANGED
|
@@ -410,8 +410,27 @@
|
|
|
410
410
|
function videoControl(form) {
|
|
411
411
|
var wrap = el("div", "tml-field");
|
|
412
412
|
|
|
413
|
+
// A just-recorded or just-uploaded video: show it playable on the form,
|
|
414
|
+
// not only as a chip — the same preview the review step showed, carried
|
|
415
|
+
// over so "Use this video" doesn't make the clip visually vanish.
|
|
413
416
|
if (state.videoBlob) {
|
|
414
|
-
|
|
417
|
+
var recorded = document.createElement("video");
|
|
418
|
+
recorded.className = "tml-video";
|
|
419
|
+
recorded.controls = true;
|
|
420
|
+
recorded.playsInline = true;
|
|
421
|
+
recorded.preload = "metadata";
|
|
422
|
+
// Fresh object URL for the current blob (revoked in close()); reuse the
|
|
423
|
+
// captured poster so Safari paints a frame instead of black.
|
|
424
|
+
if (state.playbackUrl) URL.revokeObjectURL(state.playbackUrl);
|
|
425
|
+
state.playbackUrl = URL.createObjectURL(state.videoBlob);
|
|
426
|
+
recorded.src = state.playbackUrl;
|
|
427
|
+
if (state.posterBlob) {
|
|
428
|
+
if (state.posterUrl) URL.revokeObjectURL(state.posterUrl);
|
|
429
|
+
state.posterUrl = URL.createObjectURL(state.posterBlob);
|
|
430
|
+
recorded.poster = state.posterUrl;
|
|
431
|
+
}
|
|
432
|
+
wrap.appendChild(recorded);
|
|
433
|
+
wrap.appendChild(attachedVideoChip(form, wrap, recorded, function () {
|
|
415
434
|
state.videoBlob = null;
|
|
416
435
|
}));
|
|
417
436
|
return wrap;
|