testimonials 0.5.2 → 0.7.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/CHANGELOG.md +27 -0
- data/README.md +1 -0
- data/app/models/testimonials/testimonial.rb +7 -3
- data/lib/generators/testimonials/install/templates/initializer.rb +6 -0
- data/lib/testimonials/configuration.rb +7 -0
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +83 -18
- 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: 691265663845bee9822f3ec8cb013ca50b36f5e17a763775aab8b6e3c9e74517
|
|
4
|
+
data.tar.gz: 983d03927120255fd34eb9d37221ab7c3476c4afc58a31de151f9a44c423c3c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e8d6cc2b5f8cad129912bdb4b0fb73a5348c63e7e7879649bdfc43d07e443abc440d2863ec2aa03e960c6a2f18aba96b66bc2664f2fe52858a68dba0106a966
|
|
7
|
+
data.tar.gz: a4a1f73006da86ff677bf341e64d2f847fa95386bafb893b20680463ea431953650566a8eeced1812808b0328212fbde5873f78d3ce9a4911081fd948cf34a1a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- New `config.storage_service`: store uploads (video, its poster frame, guest
|
|
6
|
+
avatars) on a named Active Storage service from the host's
|
|
7
|
+
`config/storage.yml` instead of the environment default. Point it at a
|
|
8
|
+
dedicated bucket or folder — or a service entry carrying provider options
|
|
9
|
+
like Cloudinary's `folder:`/`tags:` — to keep testimonial media separate
|
|
10
|
+
from the rest of the media library. `nil` (the default) keeps today's
|
|
11
|
+
behavior.
|
|
12
|
+
|
|
13
|
+
## 0.6.0
|
|
14
|
+
|
|
15
|
+
- **The full-screen mobile dialog now survives the on-screen keyboard.** On
|
|
16
|
+
phones the dialog is a fixed, full-height (`100dvh`) element, but iOS Safari
|
|
17
|
+
raises the keyboard without resizing it — so the composer and Submit/Cancel
|
|
18
|
+
row slid behind the keyboard. While the dialog is open the widget now tracks
|
|
19
|
+
`window.visualViewport` and pins the overlay (and the dialog filling it) to
|
|
20
|
+
the visible viewport, so the action row sits just above the keyboard. Gated on
|
|
21
|
+
the `max-width:480px` mobile media query, so desktop is untouched; browsers
|
|
22
|
+
without `visualViewport` no-op and keep the previous behavior. Shared change
|
|
23
|
+
across the gem family.
|
|
24
|
+
- The "🎥 Video attached | Remove" row under a video preview is replaced with a
|
|
25
|
+
discreet round ✕ button in the top-right corner of the preview (the standard
|
|
26
|
+
"remove attachment" affordance) — clearly a delete control, but no longer a
|
|
27
|
+
full-width, easy-to-mis-tap target. Applies to both a just-recorded/uploaded
|
|
28
|
+
clip and an existing video being edited; remove behavior is unchanged.
|
|
29
|
+
|
|
3
30
|
## 0.5.2
|
|
4
31
|
|
|
5
32
|
- A just-recorded or just-uploaded video now shows a playable preview on the
|
data/README.md
CHANGED
|
@@ -132,6 +132,7 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
132
132
|
| `max_video_size` | `50.megabytes` | Enforced server-side |
|
|
133
133
|
| `avatars` | `true` | Headshot upload for guests on the public page |
|
|
134
134
|
| `max_avatar_size` | `5.megabytes` | Enforced server-side |
|
|
135
|
+
| `storage_service` | app default | Active Storage service for uploads (a `storage.yml` key) |
|
|
135
136
|
| `reprompt_after` | `90.days` | Cooldown after a dismissal |
|
|
136
137
|
| `max_prompts` | `3` | Lifetime auto-prompt cap per user |
|
|
137
138
|
| `public_collection` | `true` | The shareable `/testimonials/new` page |
|
|
@@ -10,9 +10,13 @@ module Testimonials
|
|
|
10
10
|
SOURCES = %w[widget page nps].freeze
|
|
11
11
|
|
|
12
12
|
if defined?(::ActiveStorage)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
# Read at class load, which happens after the host's initializers, so
|
|
14
|
+
# a storage_service set in config/initializers/testimonials.rb is
|
|
15
|
+
# visible here. nil falls through to the environment's default service.
|
|
16
|
+
storage = Testimonials.config.storage_service
|
|
17
|
+
has_one_attached :video_file, service: storage
|
|
18
|
+
has_one_attached :poster, service: storage # a still frame for the video, captured at record time
|
|
19
|
+
has_one_attached :avatar, service: storage
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
validates :kind, inclusion: { in: KINDS }
|
|
@@ -55,6 +55,12 @@ Testimonials.configure do |config|
|
|
|
55
55
|
# config.avatars = true
|
|
56
56
|
# config.max_avatar_size = 5.megabytes
|
|
57
57
|
|
|
58
|
+
# Store uploads (video, poster frame, avatars) on a specific Active Storage
|
|
59
|
+
# service from config/storage.yml — e.g. a dedicated bucket or folder that
|
|
60
|
+
# keeps testimonial media apart from the rest of your library. Default:
|
|
61
|
+
# your environment's default service.
|
|
62
|
+
# config.storage_service = :testimonials
|
|
63
|
+
|
|
58
64
|
# Auto-prompt throttling. Explicit opens (user clicked your link) bypass it.
|
|
59
65
|
# config.reprompt_after = 90.days
|
|
60
66
|
# config.max_prompts = 3
|
|
@@ -52,6 +52,12 @@ module Testimonials
|
|
|
52
52
|
attr_accessor :avatars
|
|
53
53
|
attr_accessor :max_avatar_size
|
|
54
54
|
|
|
55
|
+
# The Active Storage service that stores uploads — video, its poster
|
|
56
|
+
# frame, guest avatars — as a service name from the host's
|
|
57
|
+
# config/storage.yml (e.g. a dedicated bucket or folder). nil, the
|
|
58
|
+
# default, uses the environment's default service.
|
|
59
|
+
attr_accessor :storage_service
|
|
60
|
+
|
|
55
61
|
# Auto-prompt throttling. A user who dismissed the widget is not
|
|
56
62
|
# auto-prompted again within `reprompt_after`; a user auto-prompted
|
|
57
63
|
# `max_prompts` times without submitting is never auto-prompted again;
|
|
@@ -109,6 +115,7 @@ module Testimonials
|
|
|
109
115
|
@max_video_size = 50 * 1024 * 1024
|
|
110
116
|
@avatars = true
|
|
111
117
|
@max_avatar_size = 5 * 1024 * 1024
|
|
118
|
+
@storage_service = nil
|
|
112
119
|
@reprompt_after = 90 * 24 * 60 * 60
|
|
113
120
|
@max_prompts = 3
|
|
114
121
|
@consent_text = nil
|
data/lib/testimonials/version.rb
CHANGED
data/lib/testimonials/widget.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
var savedOverflow = null; // documentElement overflow to restore when the modal closes
|
|
33
33
|
var state = null; // one open session: { kind, auto, stage, rating, videoBlob, ... }
|
|
34
34
|
var media = { stream: null, recorder: null, chunks: [], timer: null };
|
|
35
|
+
var viewportHandler = null; // visualViewport listener while the dialog is open
|
|
35
36
|
|
|
36
37
|
function ready(fn) {
|
|
37
38
|
if (document.readyState === "loading") {
|
|
@@ -154,13 +155,69 @@
|
|
|
154
155
|
document.body.appendChild(overlay);
|
|
155
156
|
lockScroll();
|
|
156
157
|
overlay.addEventListener("keydown", trapFocus);
|
|
158
|
+
startViewportTracking();
|
|
157
159
|
|
|
158
160
|
if (auto) postEvent(kind, "shown");
|
|
159
161
|
return true;
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
// --- mobile keyboard --------------------------------------------------------
|
|
165
|
+
// On phones the dialog is full-screen at height:100dvh, but iOS Safari shows
|
|
166
|
+
// the on-screen keyboard WITHOUT resizing that fixed element — so the action
|
|
167
|
+
// row gets hidden behind the keyboard. While the dialog is open we track
|
|
168
|
+
// window.visualViewport and pin the full-screen overlay (and the dialog that
|
|
169
|
+
// fills it) to the currently-visible area, so Submit/Cancel sit just above the
|
|
170
|
+
// keyboard. The dialog itself is a static-positioned flex child, so `top` on
|
|
171
|
+
// it does nothing — we move the fixed overlay for position and size the dialog
|
|
172
|
+
// to match. Desktop is untouched: everything is gated on the mobile media
|
|
173
|
+
// query, and browsers without visualViewport no-op (today's behavior).
|
|
174
|
+
|
|
175
|
+
function applyViewport() {
|
|
176
|
+
if (!overlay) return;
|
|
177
|
+
var dialog = overlay.querySelector("#tml-dialog");
|
|
178
|
+
var vv = window.visualViewport;
|
|
179
|
+
if (vv && window.matchMedia("(max-width:480px)").matches) {
|
|
180
|
+
overlay.style.top = vv.offsetTop + "px";
|
|
181
|
+
overlay.style.height = vv.height + "px";
|
|
182
|
+
overlay.style.bottom = "auto"; // height + top win; drop the inset:0 bottom
|
|
183
|
+
if (dialog) dialog.style.height = vv.height + "px";
|
|
184
|
+
} else {
|
|
185
|
+
// Not the mobile full-screen layout: clear every override so the
|
|
186
|
+
// stylesheet's centered desktop rules apply cleanly.
|
|
187
|
+
overlay.style.top = "";
|
|
188
|
+
overlay.style.height = "";
|
|
189
|
+
overlay.style.bottom = "";
|
|
190
|
+
if (dialog) dialog.style.height = "";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function startViewportTracking() {
|
|
195
|
+
if (!window.visualViewport) return;
|
|
196
|
+
viewportHandler = function () { applyViewport(); };
|
|
197
|
+
window.visualViewport.addEventListener("resize", viewportHandler);
|
|
198
|
+
window.visualViewport.addEventListener("scroll", viewportHandler);
|
|
199
|
+
applyViewport(); // pin once on open
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stopViewportTracking() {
|
|
203
|
+
if (viewportHandler && window.visualViewport) {
|
|
204
|
+
window.visualViewport.removeEventListener("resize", viewportHandler);
|
|
205
|
+
window.visualViewport.removeEventListener("scroll", viewportHandler);
|
|
206
|
+
}
|
|
207
|
+
viewportHandler = null;
|
|
208
|
+
// Clear inline styles so a later desktop open starts from the stylesheet.
|
|
209
|
+
if (overlay) {
|
|
210
|
+
overlay.style.top = "";
|
|
211
|
+
overlay.style.height = "";
|
|
212
|
+
overlay.style.bottom = "";
|
|
213
|
+
var dialog = overlay.querySelector("#tml-dialog");
|
|
214
|
+
if (dialog) dialog.style.height = "";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
162
218
|
function close() {
|
|
163
219
|
if (!overlay) return;
|
|
220
|
+
stopViewportTracking();
|
|
164
221
|
stopMedia();
|
|
165
222
|
if (state && state.playbackUrl) URL.revokeObjectURL(state.playbackUrl);
|
|
166
223
|
if (state && state.posterUrl) URL.revokeObjectURL(state.posterUrl);
|
|
@@ -194,7 +251,12 @@
|
|
|
194
251
|
dialog.textContent = "";
|
|
195
252
|
dialog.appendChild(node);
|
|
196
253
|
var focusable = dialog.querySelector("textarea, input, button");
|
|
197
|
-
if (focusable && overlay)
|
|
254
|
+
if (focusable && overlay) {
|
|
255
|
+
focusable.focus();
|
|
256
|
+
// With the viewport pinned above the keyboard the shrink handles most of
|
|
257
|
+
// it; nudge the focused field fully into view for the rest.
|
|
258
|
+
if (focusable.scrollIntoView) focusable.scrollIntoView({ block: "nearest" });
|
|
259
|
+
}
|
|
198
260
|
}
|
|
199
261
|
|
|
200
262
|
function trapFocus(event) {
|
|
@@ -429,8 +491,7 @@
|
|
|
429
491
|
state.posterUrl = URL.createObjectURL(state.posterBlob);
|
|
430
492
|
recorded.poster = state.posterUrl;
|
|
431
493
|
}
|
|
432
|
-
wrap.appendChild(recorded)
|
|
433
|
-
wrap.appendChild(attachedVideoChip(form, wrap, recorded, function () {
|
|
494
|
+
wrap.appendChild(videoPreview(form, wrap, recorded, function () {
|
|
434
495
|
state.videoBlob = null;
|
|
435
496
|
}));
|
|
436
497
|
return wrap;
|
|
@@ -448,8 +509,7 @@
|
|
|
448
509
|
// fix for Safari, which won't paint a fragmented-MP4 frame on its own.
|
|
449
510
|
if (state.existingPosterUrl) playback.poster = state.existingPosterUrl;
|
|
450
511
|
playback.src = state.existingVideoUrl;
|
|
451
|
-
wrap.appendChild(playback)
|
|
452
|
-
wrap.appendChild(attachedVideoChip(form, wrap, playback, function () {
|
|
512
|
+
wrap.appendChild(videoPreview(form, wrap, playback, function () {
|
|
453
513
|
state.removeExistingVideo = true;
|
|
454
514
|
}));
|
|
455
515
|
return wrap;
|
|
@@ -475,18 +535,21 @@
|
|
|
475
535
|
return wrap;
|
|
476
536
|
}
|
|
477
537
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
538
|
+
// A discreet "remove attachment" affordance: the video preview wrapped so a
|
|
539
|
+
// small round ✕ can sit in its top-right corner (overlaid on the clip, clear
|
|
540
|
+
// of the centered play button), instead of a full-width, easy-to-mis-tap row.
|
|
541
|
+
function videoPreview(form, wrap, playback, onRemove) {
|
|
542
|
+
var box = el("div", "tml-video-wrap");
|
|
543
|
+
box.appendChild(playback);
|
|
544
|
+
var remove = el("button", "tml-video-remove", "✕");
|
|
482
545
|
remove.type = "button";
|
|
546
|
+
remove.setAttribute("aria-label", config.labels.remove);
|
|
483
547
|
remove.addEventListener("click", function () {
|
|
484
548
|
onRemove();
|
|
485
|
-
if (playback) playback.remove();
|
|
486
549
|
wrap.replaceWith(videoControl(form));
|
|
487
550
|
});
|
|
488
|
-
|
|
489
|
-
return
|
|
551
|
+
box.appendChild(remove);
|
|
552
|
+
return box;
|
|
490
553
|
}
|
|
491
554
|
|
|
492
555
|
// Recording lives in its own stage: camera check -> 3-2-1 -> recording ->
|
|
@@ -1117,11 +1180,14 @@
|
|
|
1117
1180
|
".tml-record:hover{background:rgba(37,99,235,.12)}",
|
|
1118
1181
|
".tml-record-label{color:#2563eb;font-weight:600}",
|
|
1119
1182
|
".tml-record-hint{font-size:12px;color:#6b7280}",
|
|
1120
|
-
".tml-chip{display:flex;align-items:center;justify-content:space-between;gap:8px;",
|
|
1121
|
-
"padding:10px 12px;border-radius:10px;background:rgba(37,99,235,.07)}",
|
|
1122
|
-
".tml-chip-label{font-weight:600}",
|
|
1123
|
-
".tml-chip-remove{border:0;background:none;color:#2563eb;cursor:pointer;font:inherit;padding:2px 6px}",
|
|
1124
1183
|
".tml-video{display:block;width:100%;border-radius:10px;background:#000;aspect-ratio:4/3;margin:0 0 12px}",
|
|
1184
|
+
// A recorded/uploaded/existing clip with a discreet corner remove control.
|
|
1185
|
+
".tml-video-wrap{position:relative;margin:0 0 12px}",
|
|
1186
|
+
".tml-video-wrap .tml-video{margin:0}",
|
|
1187
|
+
".tml-video-remove{position:absolute;top:8px;inset-inline-end:8px;width:28px;height:28px;padding:0;",
|
|
1188
|
+
"display:flex;align-items:center;justify-content:center;border:0;border-radius:50%;",
|
|
1189
|
+
"background:rgba(0,0,0,.6);color:#fff;font-size:15px;line-height:1;cursor:pointer}",
|
|
1190
|
+
".tml-video-remove:hover{background:rgba(0,0,0,.8)}",
|
|
1125
1191
|
".tml-preview{position:relative;margin-bottom:4px}",
|
|
1126
1192
|
".tml-preview .tml-video{margin-bottom:0}",
|
|
1127
1193
|
".tml-timer{position:absolute;top:8px;inset-inline-end:8px;background:rgba(0,0,0,.55);color:#fff;",
|
|
@@ -1151,8 +1217,7 @@
|
|
|
1151
1217
|
".tml-nps-score{border-color:#2a313a}",
|
|
1152
1218
|
".tml-record{border-color:#3b82f6;background:rgba(59,130,246,.1)}",
|
|
1153
1219
|
".tml-record:hover{background:rgba(59,130,246,.18)}",
|
|
1154
|
-
".tml-record-label
|
|
1155
|
-
".tml-chip{background:rgba(59,130,246,.12)}",
|
|
1220
|
+
".tml-record-label{color:#3b82f6}",
|
|
1156
1221
|
".tml-hint,.tml-nps-legend,.tml-record-hint{color:#9aa2ab}",
|
|
1157
1222
|
"}",
|
|
1158
1223
|
// The dialog is the scrollable region; keep its scroll from chaining
|