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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49da88f23d3967043d08ff98641b737b357b67b1bffc65cf4ac2f5e9ffad181c
4
- data.tar.gz: '091174dcf7d158e560f6c047593ef9d8b9dd7c1a4be3461f4cde4d1923c9ea84'
3
+ metadata.gz: a660a9eadbc3bfa58f9fe6bfc2d9f4feebe867fe536fe0755a156c9b931aca49
4
+ data.tar.gz: 73c439422ee9c88ee56836338dc8be3b9e41063cec3009bad76025673ca675d6
5
5
  SHA512:
6
- metadata.gz: e45f348b081c38c7e85bcad7fa2823594c75100e4ace6be42390e793f7438e748a4048a0a753d5f246222333fc241ddd227e8059e6e94ea0f63c11899269335d
7
- data.tar.gz: 17c9adb10e7922c9d32fc7931e809c3f398983571f109e1705e6f224f22328463d1d06ff8e7416ed210a802a841822bfa3d84d3f060f7cc9b01c7d1897d8aaa6
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
  [![Gem Version](https://img.shields.io/gem/v/testimonials)](https://rubygems.org/gems/testimonials)
4
+ [![Downloads](https://img.shields.io/gem/dt/testimonials)](https://rubygems.org/gems/testimonials)
4
5
  [![CI](https://github.com/yshmarov/testimonials/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/testimonials/actions/workflows/ci.yml)
5
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
7
+ [![GitHub stars](https://img.shields.io/github/stars/yshmarov/testimonials?style=social)](https://github.com/yshmarov/testimonials/stargazers)
6
8
 
7
- Testimonials, reviews and NPS for Rails. Self-hosted alternative to
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
- `testimonials` collects customer testimonials text and **video** from
11
- inside your app with an iOS-style "Enjoying this app?" prompt, from a
12
- shareable public page, and from NPS promoters routed straight into the
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
+ ![The testimonials widget: star prompt, full form, and video review step](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/hero.jpg)
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
+ | ![The star prompt card](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/01-prompt.png) | ![The expanded testimonial form](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/02-form.png) |
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
+ | ![The camera check step](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/03-camera-check.jpg) | ![Reviewing the recorded video](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/04-video-review.jpg) |
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
+ | ![The form with a video attached](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/05-video-attached.png) | ![The triage dashboard](https://raw.githubusercontent.com/yshmarov/testimonials/main/docs/screenshots/dashboard-index.png) |
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 # a success moment
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
- - submitted a testimonial → never auto-prompted again
93
- - dismissed not again within `reprompt_after` (default 90 days)
94
- - auto-prompted `max_prompts` times (default 3) never again
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: any element with `data-testimonial-prompt`
97
- (or `data-testimonial-prompt="nps"`), or `window.Testimonials.open()` /
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
- `testimonial_prompt!(:nps)` prompts for an NPS score instead.
101
-
102
- ## Configuration
103
-
104
- Everything lives in `config/initializers/testimonials.rb`; every option has
105
- a working default. The essentials:
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 = "SupeRails"
110
- config.current_user = ->(request) { request.env["warden"]&.user }
111
- config.user_display = ->(user) { { name: user.name, email: user.email } }
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 = ->(record) { SlackNotifier.ping(record) }
114
- config.on_detractor = ->(nps) { FeedbackEngine::Feedback.create!(kind: "other", message: nps.comment.to_s) }
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
- `current_user` (and any admin gate) receives the raw request, so it works
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
- ### Guiding questions, localized
172
+ <details>
173
+ <summary><b>Guiding questions, localized</b></summary>
133
174
 
134
- The questions shown above the form ("How has %{app} helped you?") fight
135
- blank-page paralysis — they are prompts, not form fields. By default they
136
- come from the gem's own locale files, so all 26 languages get good questions
137
- with your app name interpolated. Override with literal strings or a lambda
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
- ## The public collection page
186
+ </details>
147
187
 
148
- `/testimonials/new` is a standalone, self-styled page for customers outside the
149
- app — drop the link into an email or DM. Guests leave name, email, optional
150
- title/company and photo. Disable with `config.public_collection = false`.
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
- - `GET /testimonials/api/testimonials` approved + consented records only;
155
- filters: `featured=1`, `min_rating=4`, `kind=video`, `limit=12`.
156
- - `GET /testimonials/api/stats` `count`, `average_rating`, `ratings_count`,
157
- `nps_score`.
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. Set `config.public_api = true` to serve them without
160
- auth (CORS `*`) — ideal for a separate static marketing site that renders
161
- your wall of love at build time. Emails and author ids are never serialized.
162
- Video and avatar files are handed out by testimonial id through the same
163
- gate, with Range support so `<video>` tags just work.
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)` (or the widget API) asks the classic 0–10 question.
168
- Promoters are offered the testimonial form right away — but only if the
169
- testimonial throttle would allow it. Detractors trigger `on_detractor`.
170
- The dashboard shows your NPS score and every response at
171
- `/testimonials/nps_responses`. Disable with `config.nps = false`.
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
- ## Dashboard
233
+ ## The public collection page
174
234
 
175
- Browse at the mount path: pending approved archived tabs, search,
176
- quick-approve, inline video playback, feature toggle, and a “Best line” picker
177
- the customer's words are never editable, but you choose the pull-quote.
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, and read API — with one resolver:
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** a GlobalID, an id, a subdomain, a slug. The gem
190
- never takes a foreign key into your models and never needs to know what an
191
- Organization is; it just stamps the key on each submission and scopes every
192
- read (dashboard, `/api/testimonials`, `/api/stats`, NPS, the public collection
193
- page) to it. `nil` (the default) is a single global collection single-tenant
194
- apps need none of this and keep working unchanged.
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: your `authorize_admin` says *who* is an admin, the
197
- `tenant` resolver says *which* tenant they're in, so an org admin sees only
198
- their own tenant's dashboard and API. "One review per user" becomes one per
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
- Optional model sugar — a veneer over the string key, not a new coupling:
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. Key by something other than GlobalID? Pass a matching resolver to both:
214
- `has_testimonials(key: ->(o){ o.subdomain })` and the same in `config.tenant`.
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
- **Upgrading an existing install:** the `tenant` column is additive and
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
- ## Testing
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- VERSION = '0.5.1'
4
+ VERSION = '0.5.2'
5
5
  end
@@ -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
- wrap.appendChild(attachedVideoChip(form, wrap, null, function () {
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;
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.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov