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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a660a9eadbc3bfa58f9fe6bfc2d9f4feebe867fe536fe0755a156c9b931aca49
4
- data.tar.gz: 73c439422ee9c88ee56836338dc8be3b9e41063cec3009bad76025673ca675d6
3
+ metadata.gz: 691265663845bee9822f3ec8cb013ca50b36f5e17a763775aab8b6e3c9e74517
4
+ data.tar.gz: 983d03927120255fd34eb9d37221ab7c3476c4afc58a31de151f9a44c423c3c7
5
5
  SHA512:
6
- metadata.gz: 1198770a514947ebc0a23a78a19f29a690c906c03c12e2135b8aa7a4538d4a6f3b406ae0822123ff4b3763e8502111298f1d9b37791dba9254305fc3634fb426
7
- data.tar.gz: 6b5585808618f90d8826fdd4f49a142442ac230417e1a2d0272b755b8b497c4fb31f05fef262c02650715b68af94c6f1b36b1ee7d07830a51a82e4f114a65b58
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
- has_one_attached :video_file
14
- has_one_attached :poster # a still frame for the video, captured at record time
15
- has_one_attached :avatar
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- VERSION = '0.5.2'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -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) focusable.focus();
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
- function attachedVideoChip(form, wrap, playback, onRemove) {
479
- var chip = el("div", "tml-chip");
480
- chip.appendChild(el("span", "tml-chip-label", "🎥 " + config.labels.videoAttached));
481
- var remove = el("button", "tml-chip-remove", config.labels.remove);
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
- chip.appendChild(remove);
489
- return chip;
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,.tml-chip-remove{color:#3b82f6}",
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
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.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov