testimonials 0.1.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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +175 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/testimonials/api/base_controller.rb +30 -0
  7. data/app/controllers/testimonials/api/stats_controller.rb +20 -0
  8. data/app/controllers/testimonials/api/testimonials_controller.rb +41 -0
  9. data/app/controllers/testimonials/application_controller.rb +50 -0
  10. data/app/controllers/testimonials/collection_controller.rb +18 -0
  11. data/app/controllers/testimonials/events_controller.rb +25 -0
  12. data/app/controllers/testimonials/media_controller.rb +48 -0
  13. data/app/controllers/testimonials/nps_controller.rb +71 -0
  14. data/app/controllers/testimonials/nps_responses_controller.rb +25 -0
  15. data/app/controllers/testimonials/testimonials_controller.rb +194 -0
  16. data/app/controllers/testimonials/widgets_controller.rb +36 -0
  17. data/app/helpers/testimonials/widget_helper.rb +83 -0
  18. data/app/models/testimonials/application_record.rb +7 -0
  19. data/app/models/testimonials/nps_response.rb +24 -0
  20. data/app/models/testimonials/prompt_event.rb +60 -0
  21. data/app/models/testimonials/testimonial.rb +53 -0
  22. data/app/views/layouts/testimonials/application.html.erb +118 -0
  23. data/app/views/layouts/testimonials/collection.html.erb +23 -0
  24. data/app/views/testimonials/collection/show.html.erb +12 -0
  25. data/app/views/testimonials/nps_responses/index.html.erb +67 -0
  26. data/app/views/testimonials/testimonials/index.html.erb +108 -0
  27. data/app/views/testimonials/testimonials/show.html.erb +105 -0
  28. data/config/locales/testimonials.ar.yml +101 -0
  29. data/config/locales/testimonials.bg.yml +101 -0
  30. data/config/locales/testimonials.bn.yml +101 -0
  31. data/config/locales/testimonials.de.yml +101 -0
  32. data/config/locales/testimonials.el.yml +101 -0
  33. data/config/locales/testimonials.en.yml +101 -0
  34. data/config/locales/testimonials.es.yml +101 -0
  35. data/config/locales/testimonials.fr.yml +101 -0
  36. data/config/locales/testimonials.hi.yml +101 -0
  37. data/config/locales/testimonials.hr.yml +101 -0
  38. data/config/locales/testimonials.id.yml +101 -0
  39. data/config/locales/testimonials.it.yml +101 -0
  40. data/config/locales/testimonials.ja.yml +101 -0
  41. data/config/locales/testimonials.ko.yml +101 -0
  42. data/config/locales/testimonials.lb.yml +101 -0
  43. data/config/locales/testimonials.nl.yml +101 -0
  44. data/config/locales/testimonials.pl.yml +101 -0
  45. data/config/locales/testimonials.pt.yml +101 -0
  46. data/config/locales/testimonials.ro.yml +101 -0
  47. data/config/locales/testimonials.ru.yml +101 -0
  48. data/config/locales/testimonials.th.yml +101 -0
  49. data/config/locales/testimonials.tr.yml +101 -0
  50. data/config/locales/testimonials.uk.yml +101 -0
  51. data/config/locales/testimonials.ur.yml +101 -0
  52. data/config/locales/testimonials.vi.yml +101 -0
  53. data/config/locales/testimonials.zh-CN.yml +101 -0
  54. data/config/routes.rb +29 -0
  55. data/lib/generators/testimonials/install/install_generator.rb +42 -0
  56. data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +53 -0
  57. data/lib/generators/testimonials/install/templates/initializer.rb +70 -0
  58. data/lib/testimonials/configuration.rb +129 -0
  59. data/lib/testimonials/dashboard.js +33 -0
  60. data/lib/testimonials/engine.rb +16 -0
  61. data/lib/testimonials/prompt_helper.rb +15 -0
  62. data/lib/testimonials/version.rb +5 -0
  63. data/lib/testimonials/widget.js +1045 -0
  64. data/lib/testimonials/widget.rb +170 -0
  65. data/lib/testimonials.rb +69 -0
  66. metadata +132 -0
@@ -0,0 +1,1045 @@
1
+ /*
2
+ * testimonials widget — self-contained, no framework, no build step.
3
+ *
4
+ * Reads its config from the <script type="application/json"
5
+ * data-testimonials-config> the server renders — re-read on every render so
6
+ * a Turbo visit always reflects the current page's config.
7
+ *
8
+ * Nothing is visible until the widget opens: from the server (an eligible
9
+ * `testimonial_prompt!` arrives as config.autoOpen), from any element carrying
10
+ * `data-testimonial-prompt` (or `data-testimonial-prompt="nps"`), or from
11
+ * window.Testimonials.open() / .openNps().
12
+ *
13
+ * Flow, modeled on iOS's "leave a review": a small star card first; tapping
14
+ * a star expands into the full form (text or recorded video, consent, guest
15
+ * contact fields). NPS asks 0–10, and promoters are offered the testimonial
16
+ * form right after. Auto-opens report shown/dismissed to the throttle
17
+ * ledger; explicit opens don't.
18
+ *
19
+ * On the public collection page the same code renders the form inline into
20
+ * <div data-testimonials-inline> (config.mode === "page").
21
+ */
22
+ (function () {
23
+ "use strict";
24
+
25
+ var config = readConfig();
26
+ if (!config || window.__testimonialsLoaded) return;
27
+ window.__testimonialsLoaded = true;
28
+
29
+ var Z = 2147482000;
30
+ var overlay = null;
31
+ var lastFocused = null;
32
+ var state = null; // one open session: { kind, auto, stage, rating, videoBlob, ... }
33
+ var media = { stream: null, recorder: null, chunks: [], timer: null };
34
+
35
+ function ready(fn) {
36
+ if (document.readyState === "loading") {
37
+ document.addEventListener("DOMContentLoaded", fn);
38
+ } else {
39
+ fn();
40
+ }
41
+ }
42
+
43
+ ready(function () {
44
+ document.addEventListener("keydown", handleKeydown);
45
+ document.addEventListener("click", handleOpenerClick, true);
46
+
47
+ render();
48
+ document.addEventListener("turbo:load", render);
49
+
50
+ window.Testimonials = {
51
+ open: function () { openReview(false); },
52
+ openNps: function () { openNps(false); }
53
+ };
54
+ });
55
+
56
+ function readConfig() {
57
+ var el = document.querySelector("script[data-testimonials-config]");
58
+ if (!el) return null;
59
+ try {
60
+ return JSON.parse(el.textContent);
61
+ } catch (e) {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function render() {
67
+ config = readConfig() || config;
68
+ injectStyles();
69
+
70
+ if (config.mode === "page") {
71
+ renderInline();
72
+ return;
73
+ }
74
+
75
+ // Each server-granted auto-open fires exactly once, even if Turbo
76
+ // re-runs render() for the same document.
77
+ var el = document.querySelector("script[data-testimonials-config]");
78
+ if (config.autoOpen && el && !el.dataset.testimonialsHandled) {
79
+ el.dataset.testimonialsHandled = "1";
80
+ setTimeout(function () {
81
+ if (config.autoOpen === "nps") openNps(true);
82
+ else openReview(true);
83
+ }, 600);
84
+ }
85
+ }
86
+
87
+ function handleOpenerClick(event) {
88
+ var opener = event.target && event.target.closest
89
+ ? event.target.closest("[data-testimonial-prompt]")
90
+ : null;
91
+ if (!opener) return;
92
+ event.preventDefault();
93
+ event.stopPropagation();
94
+ if (opener.getAttribute("data-testimonial-prompt") === "nps") openNps(false);
95
+ else openReview(false);
96
+ }
97
+
98
+ function handleKeydown(event) {
99
+ if (event.key === "Escape" && overlay) close();
100
+ }
101
+
102
+ // The backdrop only dismisses while nothing can be lost: the initial star
103
+ // or NPS card, and the thanks screen. Once a form is on screen, closing
104
+ // takes the × button (or Esc) — a stray click must not eat someone's
105
+ // half-written testimonial.
106
+ function dismissible() {
107
+ return !state || state.submitted ||
108
+ state.stage === "rate" || state.stage === "nps" || state.stage === "thanks";
109
+ }
110
+
111
+ // --- open / close -----------------------------------------------------------
112
+
113
+ function openReview(auto) {
114
+ if (!openSession("testimonial", auto)) return;
115
+ if (config.existing) {
116
+ // One review per user: editing replaces it, so skip the star card and
117
+ // open the form pre-filled.
118
+ prefillExisting();
119
+ showStage(formStage(config.labels.updateTitle));
120
+ } else {
121
+ showStage(rateStage());
122
+ }
123
+ }
124
+
125
+ function openNps(auto) {
126
+ if (!config.nps.enabled) return openReview(auto);
127
+ if (!openSession("nps", auto)) return;
128
+ showStage(npsStage());
129
+ }
130
+
131
+ function openSession(kind, auto) {
132
+ if (overlay) return false;
133
+ state = { kind: kind, auto: !!auto, rating: 0, submitted: false, videoBlob: null, source: "widget" };
134
+ lastFocused = document.activeElement;
135
+
136
+ overlay = document.createElement("div");
137
+ overlay.id = "tml-overlay";
138
+ overlay.addEventListener("mousedown", function (event) {
139
+ if (event.target === overlay && dismissible()) close();
140
+ });
141
+
142
+ var dialog = document.createElement("div");
143
+ dialog.id = "tml-dialog";
144
+ dialog.setAttribute("role", "dialog");
145
+ dialog.setAttribute("aria-modal", "true");
146
+ if (config.rtl) dialog.setAttribute("dir", "rtl");
147
+
148
+ overlay.appendChild(dialog);
149
+ document.body.appendChild(overlay);
150
+ overlay.addEventListener("keydown", trapFocus);
151
+
152
+ if (auto) postEvent(kind, "shown");
153
+ return true;
154
+ }
155
+
156
+ function close() {
157
+ if (!overlay) return;
158
+ stopMedia();
159
+ if (state && state.playbackUrl) URL.revokeObjectURL(state.playbackUrl);
160
+ if (state && state.auto && !state.submitted) postEvent(state.kind, "dismissed");
161
+ overlay.remove();
162
+ overlay = null;
163
+ state = null;
164
+ if (lastFocused && lastFocused.focus) lastFocused.focus();
165
+ }
166
+
167
+ function dialogEl() {
168
+ return overlay ? overlay.querySelector("#tml-dialog") : document.querySelector("[data-testimonials-inline] .tml-inline");
169
+ }
170
+
171
+ function showStage(node) {
172
+ var dialog = dialogEl();
173
+ if (!dialog) return;
174
+ stopMedia();
175
+ dialog.textContent = "";
176
+ dialog.appendChild(node);
177
+ var focusable = dialog.querySelector("textarea, input, button");
178
+ if (focusable && overlay) focusable.focus();
179
+ }
180
+
181
+ function trapFocus(event) {
182
+ if (event.key !== "Tab" || !overlay) return;
183
+ var focusable = overlay.querySelectorAll("button, select, textarea, input, a[href], video");
184
+ if (!focusable.length) return;
185
+ var first = focusable[0];
186
+ var last = focusable[focusable.length - 1];
187
+ if (event.shiftKey && document.activeElement === first) {
188
+ event.preventDefault();
189
+ last.focus();
190
+ } else if (!event.shiftKey && document.activeElement === last) {
191
+ event.preventDefault();
192
+ first.focus();
193
+ }
194
+ }
195
+
196
+ // --- inline (public collection page) ----------------------------------------
197
+
198
+ function renderInline() {
199
+ var host = document.querySelector("[data-testimonials-inline]");
200
+ if (!host || host.querySelector(".tml-inline")) return;
201
+ var card = document.createElement("div");
202
+ card.className = "tml-inline";
203
+ if (config.rtl) card.setAttribute("dir", "rtl");
204
+ host.appendChild(card);
205
+ state = { kind: "testimonial", auto: false, rating: 0, submitted: false, videoBlob: null, source: "page" };
206
+ if (config.existing) prefillExisting();
207
+ card.appendChild(formStage(config.existing ? config.labels.updateTitle : config.labels.shareTitle));
208
+ }
209
+
210
+ // --- stage 1: the iOS-style star card ---------------------------------------
211
+
212
+ function rateStage() {
213
+ state.stage = "rate";
214
+ var stage = el("div", "tml-stage");
215
+ stage.appendChild(el("h2", "tml-title", config.labels.enjoying));
216
+
217
+ var stars = el("div", "tml-stars tml-stars-big");
218
+ for (var i = 1; i <= 5; i++) {
219
+ stars.appendChild(starButton(i, function (value) {
220
+ state.rating = value;
221
+ showStage(formStage(config.labels.shareTitle));
222
+ }));
223
+ }
224
+ stage.appendChild(stars);
225
+ stage.appendChild(notNowButton());
226
+ return stage;
227
+ }
228
+
229
+ function starButton(value, onPick) {
230
+ var button = el("button", "tml-star", "★");
231
+ button.type = "button";
232
+ button.setAttribute("aria-label", config.labels.rateAria.replace("%{count}", value));
233
+ button.addEventListener("mouseenter", function () { paintStars(button.parentNode, value); });
234
+ button.addEventListener("mouseleave", function () { paintStars(button.parentNode, state.rating); });
235
+ button.addEventListener("click", function () {
236
+ paintStars(button.parentNode, value);
237
+ onPick(value);
238
+ });
239
+ return button;
240
+ }
241
+
242
+ function paintStars(container, upto) {
243
+ var stars = container.querySelectorAll(".tml-star");
244
+ for (var i = 0; i < stars.length; i++) {
245
+ stars[i].classList.toggle("tml-star-on", i < upto);
246
+ }
247
+ }
248
+
249
+ function notNowButton() {
250
+ var button = el("button", "tml-plain", config.labels.notNow);
251
+ button.type = "button";
252
+ button.addEventListener("click", close);
253
+ return button;
254
+ }
255
+
256
+ function prefillExisting() {
257
+ state.rating = config.existing.rating || 0;
258
+ state.body = config.existing.body || "";
259
+ state.consent = !!config.existing.consent;
260
+ state.existingVideoUrl = config.existing.videoUrl || null;
261
+ }
262
+
263
+ // --- stage 2: the testimonial form ------------------------------------------
264
+
265
+ function formStage(title) {
266
+ state.stage = "form";
267
+ state.formTitle = title;
268
+ var stage = el("div", "tml-stage");
269
+ stage.appendChild(header(title));
270
+
271
+ var form = document.createElement("form");
272
+ form.addEventListener("submit", function (event) {
273
+ event.preventDefault();
274
+ submitTestimonial(form);
275
+ });
276
+
277
+ var stars = el("div", "tml-stars");
278
+ for (var i = 1; i <= 5; i++) {
279
+ stars.appendChild(starButton(i, function (value) { state.rating = value; }));
280
+ }
281
+ paintStars(stars, state.rating);
282
+ form.appendChild(stars);
283
+
284
+ var questions = questionsBox();
285
+ if (questions) form.appendChild(questions);
286
+
287
+ // Video first: it's the submission we want most, and the inviting panel
288
+ // reads as the primary path with text as the fallback below it.
289
+ if (config.video.enabled) form.appendChild(videoControl(form));
290
+
291
+ var textarea = document.createElement("textarea");
292
+ textarea.name = "body";
293
+ textarea.rows = 4;
294
+ textarea.placeholder = config.labels.messagePlaceholder;
295
+ textarea.value = state.body || "";
296
+ form.appendChild(field(config.labels.message, textarea));
297
+ if (!config.authenticated) {
298
+ appendGuestFields(form);
299
+ restoreDraftFields(form);
300
+ }
301
+ form.appendChild(consentField());
302
+
303
+ var error = el("p", "tml-error");
304
+ error.hidden = true;
305
+ form.appendChild(error);
306
+
307
+ var actions = el("div", "tml-actions");
308
+ actions.appendChild(secondaryButton(config.labels.cancel, close));
309
+ var submit = el("button", "tml-primary", config.labels.submit);
310
+ submit.type = "submit";
311
+ actions.appendChild(submit);
312
+ form.appendChild(actions);
313
+
314
+ stage.appendChild(form);
315
+ return stage;
316
+ }
317
+
318
+ function appendGuestFields(form) {
319
+ var name = document.createElement("input");
320
+ name.type = "text";
321
+ name.name = "name";
322
+ name.autocomplete = "name";
323
+ form.appendChild(field(config.labels.name, name));
324
+
325
+ var email = document.createElement("input");
326
+ email.type = "email";
327
+ email.name = "email";
328
+ email.autocomplete = "email";
329
+ form.appendChild(field(config.labels.email, email));
330
+
331
+ var title = document.createElement("input");
332
+ title.type = "text";
333
+ title.name = "title_company";
334
+ title.autocomplete = "organization-title";
335
+ form.appendChild(field(config.labels.titleCompany + " (" + config.labels.optional + ")", title));
336
+
337
+ if (config.mode === "page" && config.avatars.enabled) {
338
+ var avatar = document.createElement("input");
339
+ avatar.type = "file";
340
+ avatar.name = "avatar";
341
+ avatar.accept = "image/*";
342
+ form.appendChild(field(config.labels.photo + " (" + config.labels.optional + ")", avatar));
343
+ }
344
+ }
345
+
346
+ function questionsBox() {
347
+ if (!config.questions.length) return null;
348
+ var box = el("div", "tml-questions");
349
+ box.appendChild(el("strong", null, config.labels.questionsTitle));
350
+ var list = document.createElement("ul");
351
+ config.questions.forEach(function (question) {
352
+ list.appendChild(el("li", null, question));
353
+ });
354
+ box.appendChild(list);
355
+ return box;
356
+ }
357
+
358
+ function consentField() {
359
+ var wrap = el("label", "tml-consent");
360
+ var checkbox = document.createElement("input");
361
+ checkbox.type = "checkbox";
362
+ checkbox.name = "consent";
363
+ // Pre-checked when editing a review that already granted consent, and
364
+ // kept across stage round trips — an update must never silently revoke
365
+ // (or invent) publication consent.
366
+ checkbox.checked = !!state.consent;
367
+ checkbox.addEventListener("change", function () { state.consent = checkbox.checked; });
368
+ wrap.appendChild(checkbox);
369
+ wrap.appendChild(el("span", null, config.consent));
370
+ return wrap;
371
+ }
372
+
373
+ // --- video ------------------------------------------------------------------
374
+
375
+ function videoControl(form) {
376
+ var wrap = el("div", "tml-field");
377
+
378
+ if (state.videoBlob) {
379
+ wrap.appendChild(attachedVideoChip(form, wrap, null, function () {
380
+ state.videoBlob = null;
381
+ }));
382
+ return wrap;
383
+ }
384
+
385
+ // Editing a review that already has a video: show it, playable, with a
386
+ // remove control. Removing reveals the record panel again.
387
+ if (state.existingVideoUrl && !state.removeExistingVideo) {
388
+ var playback = document.createElement("video");
389
+ playback.className = "tml-video";
390
+ playback.controls = true;
391
+ playback.playsInline = true;
392
+ playback.preload = "metadata";
393
+ playback.src = state.existingVideoUrl + "#t=0.1";
394
+ wrap.appendChild(playback);
395
+ wrap.appendChild(attachedVideoChip(form, wrap, playback, function () {
396
+ state.removeExistingVideo = true;
397
+ }));
398
+ return wrap;
399
+ }
400
+
401
+ // The strongest testimonials are videos — this is a big, inviting
402
+ // panel-button rather than a modest secondary action.
403
+ var button = el("button", "tml-record");
404
+ button.type = "button";
405
+ button.appendChild(el("span", "tml-record-label", "🎥 " + config.labels.recordVideo));
406
+ button.appendChild(el("span", "tml-record-hint", config.labels.recordVideoHint));
407
+ button.addEventListener("click", function () {
408
+ // The form is about to be replaced by the recorder — keep what the
409
+ // user already typed so it survives the round trip.
410
+ saveDraft(form);
411
+ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder) {
412
+ showStage(videoStage());
413
+ } else {
414
+ pickVideoFile();
415
+ }
416
+ });
417
+ wrap.appendChild(button);
418
+ return wrap;
419
+ }
420
+
421
+ function attachedVideoChip(form, wrap, playback, onRemove) {
422
+ var chip = el("div", "tml-chip");
423
+ chip.appendChild(el("span", "tml-chip-label", "🎥 " + config.labels.videoAttached));
424
+ var remove = el("button", "tml-chip-remove", config.labels.remove);
425
+ remove.type = "button";
426
+ remove.addEventListener("click", function () {
427
+ onRemove();
428
+ if (playback) playback.remove();
429
+ wrap.replaceWith(videoControl(form));
430
+ });
431
+ chip.appendChild(remove);
432
+ return chip;
433
+ }
434
+
435
+ // Recording lives in its own stage: camera check -> 3-2-1 -> recording ->
436
+ // review. The guiding questions stay pinned above the preview the whole
437
+ // time, so nobody freezes on camera wondering what to say.
438
+ function videoStage() {
439
+ state.stage = "video";
440
+ var stage = el("div", "tml-stage");
441
+ stage.appendChild(header(config.labels.videoCheckTitle));
442
+
443
+ var questions = questionsBox();
444
+ if (questions) stage.appendChild(questions);
445
+
446
+ var frame = el("div", "tml-preview");
447
+ var preview = document.createElement("video");
448
+ preview.className = "tml-video";
449
+ preview.autoplay = true;
450
+ preview.muted = true;
451
+ preview.playsInline = true;
452
+ frame.appendChild(preview);
453
+
454
+ var timer = el("div", "tml-timer");
455
+ timer.hidden = true;
456
+ frame.appendChild(timer);
457
+
458
+ var countdown = el("div", "tml-countdown");
459
+ countdown.hidden = true;
460
+ frame.appendChild(countdown);
461
+ stage.appendChild(frame);
462
+
463
+ var hint = el("p", "tml-hint", config.labels.videoHint);
464
+ stage.appendChild(hint);
465
+
466
+ var error = el("p", "tml-error");
467
+ error.hidden = true;
468
+ stage.appendChild(error);
469
+
470
+ var actions = el("div", "tml-actions");
471
+ var start = el("button", "tml-primary", config.labels.startRecording);
472
+ start.type = "button";
473
+ var stop = el("button", "tml-primary", config.labels.stopRecording);
474
+ stop.type = "button";
475
+ stop.hidden = true;
476
+ var back = secondaryButton(config.labels.cancel, function () {
477
+ showStage(formStage(state.formTitle || config.labels.shareTitle));
478
+ });
479
+ actions.appendChild(back);
480
+ actions.appendChild(start);
481
+ actions.appendChild(stop);
482
+ stage.appendChild(actions);
483
+
484
+ var upload = el("button", "tml-plain", config.labels.uploadInstead);
485
+ upload.type = "button";
486
+ upload.addEventListener("click", function () {
487
+ stopMedia();
488
+ pickVideoFile();
489
+ });
490
+ stage.appendChild(upload);
491
+
492
+ navigator.mediaDevices.getUserMedia({
493
+ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
494
+ audio: true
495
+ })
496
+ .then(function (stream) {
497
+ media.stream = stream;
498
+ preview.srcObject = stream;
499
+ })
500
+ .catch(function () {
501
+ error.textContent = config.labels.errorCamera;
502
+ error.hidden = false;
503
+ start.disabled = true;
504
+ });
505
+
506
+ start.addEventListener("click", function () {
507
+ if (!media.stream) return;
508
+ start.disabled = true;
509
+ // Once recording is committed, the only ways out are Finish or Cancel.
510
+ upload.hidden = true;
511
+ runCountdown(countdown, 3, function () {
512
+ start.hidden = true;
513
+ stop.hidden = false;
514
+ beginRecording(timer, stop);
515
+ });
516
+ });
517
+
518
+ stop.addEventListener("click", function () {
519
+ if (media.recorder && media.recorder.state !== "inactive") media.recorder.stop();
520
+ clearInterval(media.timer);
521
+ });
522
+
523
+ return stage;
524
+ }
525
+
526
+ function runCountdown(node, from, onDone) {
527
+ var left = from;
528
+ node.hidden = false;
529
+ node.textContent = String(left);
530
+ media.timer = setInterval(function () {
531
+ left -= 1;
532
+ if (left <= 0) {
533
+ clearInterval(media.timer);
534
+ // Clear the text as well as hiding: the overlay's display:flex rule
535
+ // outranks the [hidden] UA style, so belt and braces.
536
+ node.textContent = "";
537
+ node.hidden = true;
538
+ onDone();
539
+ } else {
540
+ node.textContent = String(left);
541
+ }
542
+ }, 800);
543
+ }
544
+
545
+ function beginRecording(timer, stop) {
546
+ media.chunks = [];
547
+ var recorder;
548
+ try {
549
+ recorder = new MediaRecorder(media.stream, { mimeType: recordingMimeType() });
550
+ } catch (e) {
551
+ recorder = new MediaRecorder(media.stream);
552
+ }
553
+ media.recorder = recorder;
554
+ recorder.addEventListener("dataavailable", function (event) {
555
+ if (event.data && event.data.size) media.chunks.push(event.data);
556
+ });
557
+ recorder.addEventListener("stop", function () {
558
+ // A teardown (Cancel, close) also stops the recorder — only a real
559
+ // Finish, where this recorder is still the active one, goes to review.
560
+ if (media.recorder !== recorder) return;
561
+ var type = recorder.mimeType || "video/webm";
562
+ var blob = new Blob(media.chunks, { type: type.split(";")[0] });
563
+ showStage(reviewStage(blob));
564
+ });
565
+
566
+ // Safari can mute or end the camera track mid-recording (its camera
567
+ // pause UI, device handoff, another app grabbing the camera). The
568
+ // recorder doesn't notice and keeps writing audio-only — a silently
569
+ // corrupt file whose video freezes at that point. Fail visible instead:
570
+ // finish the take immediately, so the user reviews a valid clip.
571
+ var videoTrack = media.stream.getVideoTracks()[0];
572
+ if (videoTrack) {
573
+ var finishEarly = function () {
574
+ if (media.recorder === recorder && recorder.state !== "inactive") stop.click();
575
+ };
576
+ videoTrack.addEventListener("mute", finishEarly);
577
+ videoTrack.addEventListener("ended", finishEarly);
578
+ }
579
+
580
+ // A 1s timeslice keeps Safari's muxer flushing as it goes; the chunks
581
+ // concatenate into the final blob.
582
+ recorder.start(1000);
583
+ startTimer(timer, function () { stop.click(); });
584
+ }
585
+
586
+ function recordingMimeType() {
587
+ var candidates = ["video/mp4", "video/webm;codecs=vp9,opus", "video/webm"];
588
+ for (var i = 0; i < candidates.length; i++) {
589
+ if (window.MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
590
+ }
591
+ return "";
592
+ }
593
+
594
+ function startTimer(timer, onLimit) {
595
+ var left = config.video.maxSeconds;
596
+ timer.hidden = false;
597
+ timer.textContent = "● " + formatSeconds(left);
598
+ media.timer = setInterval(function () {
599
+ left -= 1;
600
+ timer.textContent = "● " + formatSeconds(left);
601
+ if (left <= 0) {
602
+ clearInterval(media.timer);
603
+ onLimit();
604
+ }
605
+ }, 1000);
606
+ }
607
+
608
+ function formatSeconds(total) {
609
+ var minutes = Math.floor(total / 60);
610
+ var seconds = total % 60;
611
+ return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
612
+ }
613
+
614
+ function reviewStage(blob) {
615
+ state.stage = "review";
616
+ var stage = el("div", "tml-stage");
617
+ stage.appendChild(header(config.labels.shareTitle));
618
+
619
+ var playback = document.createElement("video");
620
+ playback.className = "tml-video";
621
+ playback.controls = true;
622
+ playback.playsInline = true;
623
+ // The playback URL lives on the session, NOT on `media`: showStage()
624
+ // tears media down on every transition, which used to revoke this URL
625
+ // the instant the review screen appeared (Safari: WebKitBlobResource
626
+ // error 1, dead player). Revoked on close or when re-recording.
627
+ if (state.playbackUrl) URL.revokeObjectURL(state.playbackUrl);
628
+ state.playbackUrl = URL.createObjectURL(blob);
629
+ // No #t=0.1 here, on purpose: seeking a blob of Safari's own fragmented
630
+ // MP4 recording can stall the video track while audio keeps playing.
631
+ // The fragment-thumbnail trick is only for server-served files; a
632
+ // just-recorded clip gets played immediately anyway.
633
+ playback.src = state.playbackUrl;
634
+ stage.appendChild(playback);
635
+
636
+ var error = el("p", "tml-error");
637
+ error.hidden = true;
638
+ stage.appendChild(error);
639
+
640
+ var actions = el("div", "tml-actions");
641
+ actions.appendChild(secondaryButton(config.labels.recordAgain, function () {
642
+ showStage(videoStage());
643
+ }));
644
+ var use = el("button", "tml-primary", config.labels.useVideo);
645
+ use.type = "button";
646
+ use.addEventListener("click", function () {
647
+ if (blob.size > config.video.maxSize) {
648
+ error.textContent = config.labels.errorVideoTooLarge;
649
+ error.hidden = false;
650
+ return;
651
+ }
652
+ state.videoBlob = blob;
653
+ showStage(formStage(state.formTitle || config.labels.shareTitle));
654
+ });
655
+ actions.appendChild(use);
656
+ stage.appendChild(actions);
657
+ return stage;
658
+ }
659
+
660
+ function pickVideoFile() {
661
+ var input = document.createElement("input");
662
+ input.type = "file";
663
+ input.accept = "video/*";
664
+ input.addEventListener("change", function () {
665
+ var file = input.files && input.files[0];
666
+ if (!file) return;
667
+ state.videoBlob = file;
668
+ showStage(formStage(state.formTitle || config.labels.shareTitle));
669
+ });
670
+ input.click();
671
+ }
672
+
673
+ function stopMedia() {
674
+ if (media.timer) clearInterval(media.timer);
675
+ if (media.recorder && media.recorder.state !== "inactive") {
676
+ try { media.recorder.stop(); } catch (e) { /* already stopped */ }
677
+ }
678
+ if (media.stream) {
679
+ media.stream.getTracks().forEach(function (track) { track.stop(); });
680
+ }
681
+ media = { stream: null, recorder: null, chunks: [], timer: null };
682
+ }
683
+
684
+ // --- NPS ----------------------------------------------------------------------
685
+
686
+ function npsStage() {
687
+ state.stage = "nps";
688
+ var stage = el("div", "tml-stage");
689
+ stage.appendChild(header(config.labels.npsQuestion));
690
+
691
+ var row = el("div", "tml-nps");
692
+ for (var score = 0; score <= 10; score++) {
693
+ row.appendChild(npsButton(score));
694
+ }
695
+ stage.appendChild(row);
696
+
697
+ var legend = el("div", "tml-nps-legend");
698
+ legend.appendChild(el("span", null, config.labels.npsLow));
699
+ legend.appendChild(el("span", null, config.labels.npsHigh));
700
+ stage.appendChild(legend);
701
+
702
+ stage.appendChild(notNowButton());
703
+ return stage;
704
+ }
705
+
706
+ function npsButton(score) {
707
+ var button = el("button", "tml-nps-score", String(score));
708
+ button.type = "button";
709
+ button.addEventListener("click", function () {
710
+ state.rating = score;
711
+ showStage(npsCommentStage(score));
712
+ });
713
+ return button;
714
+ }
715
+
716
+ function npsCommentStage(score) {
717
+ state.stage = "form";
718
+ var stage = el("div", "tml-stage");
719
+ stage.appendChild(header(config.labels.npsQuestion));
720
+
721
+ var picked = el("div", "tml-nps-picked", String(score));
722
+ stage.appendChild(picked);
723
+
724
+ var form = document.createElement("form");
725
+ form.addEventListener("submit", function (event) {
726
+ event.preventDefault();
727
+ submitNps(form, score);
728
+ });
729
+
730
+ var textarea = document.createElement("textarea");
731
+ textarea.name = "comment";
732
+ textarea.rows = 3;
733
+ form.appendChild(field(config.labels.npsCommentLabel + " (" + config.labels.optional + ")", textarea));
734
+
735
+ if (!config.authenticated) {
736
+ var email = document.createElement("input");
737
+ email.type = "email";
738
+ email.name = "email";
739
+ email.autocomplete = "email";
740
+ form.appendChild(field(config.labels.email + " (" + config.labels.optional + ")", email));
741
+ }
742
+
743
+ var error = el("p", "tml-error");
744
+ error.hidden = true;
745
+ form.appendChild(error);
746
+
747
+ var actions = el("div", "tml-actions");
748
+ actions.appendChild(secondaryButton(config.labels.cancel, close));
749
+ var submit = el("button", "tml-primary", config.labels.submit);
750
+ submit.type = "submit";
751
+ actions.appendChild(submit);
752
+ form.appendChild(actions);
753
+
754
+ stage.appendChild(form);
755
+ return stage;
756
+ }
757
+
758
+ function submitNps(form, score) {
759
+ var data = new FormData();
760
+ data.append("nps[score]", score);
761
+ data.append("nps[comment]", form.querySelector("textarea[name=comment]").value.trim());
762
+ var email = form.querySelector("input[name=email]");
763
+ if (email && email.value) data.append("nps[email]", email.value.trim());
764
+ data.append("nps[page_url]", window.location.href);
765
+
766
+ postForm(config.endpoints.nps, data, form, function (body) {
767
+ state.submitted = true;
768
+ if (body && body.offer_testimonial) {
769
+ // The server said this promoter is still eligible for a testimonial
770
+ // ask; this second prompt is an auto-open, so it reports itself.
771
+ state.kind = "testimonial";
772
+ state.submitted = false;
773
+ state.auto = true;
774
+ state.rating = 0;
775
+ state.source = "nps";
776
+ postEvent("testimonial", "shown");
777
+ showStage(formStage(config.labels.promoterTitle));
778
+ } else {
779
+ thanks();
780
+ }
781
+ });
782
+ }
783
+
784
+ // --- submit -------------------------------------------------------------------
785
+
786
+ function submitTestimonial(form) {
787
+ var body = form.querySelector("textarea[name=body]").value.trim();
788
+ var keepsExistingVideo = state.existingVideoUrl && !state.removeExistingVideo && !state.videoBlob;
789
+ if (!body && !state.videoBlob && !keepsExistingVideo) {
790
+ return showError(form, config.labels.errorBlank);
791
+ }
792
+
793
+ if (!config.authenticated) {
794
+ var name = form.querySelector("input[name=name]");
795
+ var email = form.querySelector("input[name=email]");
796
+ if (!name.value.trim() || !email.value.trim()) {
797
+ return showError(form, config.labels.errorContact);
798
+ }
799
+ }
800
+
801
+ var data = new FormData();
802
+ data.append("testimonial[body]", body);
803
+ if (state.rating) data.append("testimonial[rating]", state.rating);
804
+ data.append("testimonial[consent_given]", form.querySelector("input[name=consent]").checked ? "1" : "0");
805
+ data.append("testimonial[page_url]", window.location.href);
806
+ data.append("testimonial[source]", state.source);
807
+ ["name", "email", "title_company"].forEach(function (key) {
808
+ var input = form.querySelector('input[name="' + key + '"]');
809
+ if (input && input.value.trim()) data.append("testimonial[" + key + "]", input.value.trim());
810
+ });
811
+ var avatar = form.querySelector("input[name=avatar]");
812
+ if (avatar && avatar.files && avatar.files[0]) {
813
+ data.append("testimonial[avatar]", avatar.files[0]);
814
+ }
815
+ if (state.videoBlob) {
816
+ var extension = (state.videoBlob.type || "video/webm").indexOf("mp4") > -1 ? "mp4" : "webm";
817
+ data.append("testimonial[video_file]", state.videoBlob, "testimonial." + extension);
818
+ } else if (state.removeExistingVideo) {
819
+ data.append("testimonial[remove_video]", "1");
820
+ }
821
+
822
+ postForm(config.endpoints.testimonials, data, form, function () {
823
+ state.submitted = true;
824
+ thanks();
825
+ });
826
+ }
827
+
828
+ function postForm(endpoint, data, form, onSuccess) {
829
+ var submit = form.querySelector(".tml-primary");
830
+ if (submit) submit.disabled = true;
831
+
832
+ fetch(endpoint, {
833
+ method: "POST",
834
+ headers: csrfHeaders(),
835
+ body: data,
836
+ credentials: "same-origin"
837
+ })
838
+ .then(function (response) {
839
+ if (response.ok) {
840
+ return response.json().catch(function () { return {}; }).then(onSuccess);
841
+ }
842
+ return response
843
+ .json()
844
+ .catch(function () { return {}; })
845
+ .then(function (body) {
846
+ var messages = body && body.errors;
847
+ showError(form, (messages && messages[0]) || config.labels.errorSave);
848
+ });
849
+ })
850
+ .catch(function () {
851
+ showError(form, config.labels.errorSave);
852
+ })
853
+ .finally(function () {
854
+ if (submit) submit.disabled = false;
855
+ });
856
+ }
857
+
858
+ function postEvent(kind, action) {
859
+ var data = new FormData();
860
+ data.append("kind", kind);
861
+ data.append("event_action", action);
862
+ fetch(config.endpoints.events, {
863
+ method: "POST",
864
+ headers: csrfHeaders(),
865
+ body: data,
866
+ credentials: "same-origin",
867
+ keepalive: true
868
+ }).catch(function () { /* the ledger is best-effort */ });
869
+ }
870
+
871
+ function thanks() {
872
+ if (state) state.stage = "thanks";
873
+ var dialog = dialogEl();
874
+ if (!dialog) return;
875
+ dialog.textContent = "";
876
+ var note = el("div", "tml-thanks");
877
+ note.appendChild(el("div", "tml-thanks-mark", "✓"));
878
+ note.appendChild(el("p", null, config.labels.thanks));
879
+ dialog.appendChild(note);
880
+ if (overlay) setTimeout(close, 2200);
881
+ }
882
+
883
+ // --- drafts ---------------------------------------------------------------------
884
+
885
+ function saveDraft(form) {
886
+ var textarea = form.querySelector("textarea[name=body]");
887
+ if (textarea) state.body = textarea.value;
888
+ var consent = form.querySelector("input[name=consent]");
889
+ if (consent) state.consent = consent.checked;
890
+ state.fields = {};
891
+ ["name", "email", "title_company"].forEach(function (key) {
892
+ var input = form.querySelector('input[name="' + key + '"]');
893
+ if (input) state.fields[key] = input.value;
894
+ });
895
+ }
896
+
897
+ function restoreDraftFields(form) {
898
+ if (!state.fields) return;
899
+ Object.keys(state.fields).forEach(function (key) {
900
+ var input = form.querySelector('input[name="' + key + '"]');
901
+ if (input) input.value = state.fields[key];
902
+ });
903
+ }
904
+
905
+ // --- small helpers ------------------------------------------------------------
906
+
907
+ function el(tag, className, text) {
908
+ var node = document.createElement(tag);
909
+ if (className) node.className = className;
910
+ if (text != null) node.textContent = text;
911
+ return node;
912
+ }
913
+
914
+ function header(title) {
915
+ var head = el("div", "tml-head");
916
+ head.appendChild(el("h2", "tml-title", title));
917
+ if (overlay) {
918
+ var closeButton = el("button", "tml-x", "×");
919
+ closeButton.type = "button";
920
+ closeButton.setAttribute("aria-label", config.labels.close);
921
+ closeButton.addEventListener("click", close);
922
+ head.appendChild(closeButton);
923
+ }
924
+ return head;
925
+ }
926
+
927
+ function field(labelText, control) {
928
+ var wrap = el("label", "tml-field");
929
+ wrap.appendChild(el("span", null, labelText));
930
+ wrap.appendChild(control);
931
+ return wrap;
932
+ }
933
+
934
+ function secondaryButton(text, onClick) {
935
+ var button = el("button", "tml-secondary", text);
936
+ button.type = "button";
937
+ button.addEventListener("click", onClick);
938
+ return button;
939
+ }
940
+
941
+ function csrfHeaders() {
942
+ var meta = document.querySelector('meta[name="csrf-token"]');
943
+ return meta ? { "X-CSRF-Token": meta.content } : {};
944
+ }
945
+
946
+ function showError(form, text) {
947
+ var error = form.querySelector(".tml-error");
948
+ if (!error) return;
949
+ error.textContent = text;
950
+ error.hidden = false;
951
+ }
952
+
953
+ // --- styles -------------------------------------------------------------------
954
+
955
+ function injectStyles() {
956
+ if (document.getElementById("tml-styles")) return;
957
+ var css = [
958
+ "#tml-overlay{position:fixed;inset:0;z-index:" + Z + ";background:rgba(0,0,0,.45);",
959
+ "display:flex;align-items:center;justify-content:center;padding:16px}",
960
+ "#tml-dialog,.tml-inline{width:100%;max-width:440px;max-height:92vh;overflow:auto;",
961
+ "background:#fff;color:#1c2024;border-radius:14px;padding:20px;",
962
+ "font:14px/1.5 system-ui,-apple-system,sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.35)}",
963
+ ".tml-inline{box-shadow:none;border:1px solid #e5e7eb;max-width:520px;margin:0 auto;",
964
+ "max-height:none;overflow:visible}",
965
+ ".tml-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin:0 0 12px}",
966
+ ".tml-title{margin:0;font-size:17px}",
967
+ ".tml-x{border:0;background:none;font-size:22px;line-height:1;cursor:pointer;color:inherit;padding:2px 6px}",
968
+ ".tml-stage>.tml-title{display:block;text-align:center;margin-bottom:12px}",
969
+ ".tml-stars{display:flex;justify-content:center;gap:4px;margin:4px 0 12px}",
970
+ ".tml-star{border:0;background:none;cursor:pointer;font-size:26px;line-height:1;padding:2px;color:#d1d5db}",
971
+ ".tml-stars-big .tml-star{font-size:38px}",
972
+ ".tml-star-on{color:#f59e0b}",
973
+ ".tml-plain{display:block;margin:4px auto 0;border:0;background:none;cursor:pointer;",
974
+ "color:#2563eb;font:inherit;padding:6px}",
975
+ ".tml-questions{margin:0 0 12px;padding:10px 12px;border-radius:10px;background:rgba(37,99,235,.07);font-size:13px}",
976
+ ".tml-questions strong{display:block;margin-bottom:4px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#2563eb}",
977
+ ".tml-questions ul{margin:0;padding-inline-start:18px}",
978
+ ".tml-field{display:block;margin-bottom:12px}",
979
+ ".tml-field>span{display:block;margin-bottom:4px;font-weight:600}",
980
+ "#tml-dialog textarea,#tml-dialog input[type=text],#tml-dialog input[type=email],#tml-dialog input[type=file],",
981
+ ".tml-inline textarea,.tml-inline input[type=text],.tml-inline input[type=email],.tml-inline input[type=file]",
982
+ "{width:100%;box-sizing:border-box;padding:8px;border:1px solid #d1d5db;border-radius:8px;",
983
+ "background:inherit;color:inherit;font:inherit}",
984
+ ".tml-stage textarea{resize:vertical}",
985
+ ".tml-consent{display:flex;gap:8px;align-items:flex-start;margin-bottom:12px;font-size:13px;cursor:pointer}",
986
+ ".tml-consent input{margin-top:3px}",
987
+ ".tml-error{color:#dc2626;margin:0 0 12px}",
988
+ ".tml-hint{color:#6b7280;font-size:13px;margin:8px 0}",
989
+ ".tml-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap}",
990
+ ".tml-actions button{padding:8px 14px;border-radius:8px;cursor:pointer;font:inherit}",
991
+ ".tml-actions .tml-plain{margin:0}",
992
+ ".tml-secondary{border:1px solid #d1d5db;background:none;color:inherit;padding:8px 14px;border-radius:8px;cursor:pointer;font:inherit}",
993
+ ".tml-primary{border:0;background:#2563eb;color:#fff;font-weight:600;padding:8px 14px;border-radius:8px;cursor:pointer;font:inherit}",
994
+ ".tml-primary:disabled{opacity:.6;cursor:default}",
995
+ ".tml-record{display:flex;flex-direction:column;align-items:center;gap:2px;width:100%;",
996
+ "padding:12px;border:1.5px dashed #2563eb;border-radius:10px;cursor:pointer;",
997
+ "background:rgba(37,99,235,.06);font:inherit}",
998
+ ".tml-record:hover{background:rgba(37,99,235,.12)}",
999
+ ".tml-record-label{color:#2563eb;font-weight:600}",
1000
+ ".tml-record-hint{font-size:12px;color:#6b7280}",
1001
+ ".tml-chip{display:flex;align-items:center;justify-content:space-between;gap:8px;",
1002
+ "padding:10px 12px;border-radius:10px;background:rgba(37,99,235,.07)}",
1003
+ ".tml-chip-label{font-weight:600}",
1004
+ ".tml-chip-remove{border:0;background:none;color:#2563eb;cursor:pointer;font:inherit;padding:2px 6px}",
1005
+ ".tml-video{display:block;width:100%;border-radius:10px;background:#000;aspect-ratio:4/3;margin:0 0 12px}",
1006
+ ".tml-preview{position:relative;margin-bottom:4px}",
1007
+ ".tml-preview .tml-video{margin-bottom:0}",
1008
+ ".tml-timer{position:absolute;top:8px;inset-inline-end:8px;background:rgba(0,0,0,.55);color:#fff;",
1009
+ "padding:2px 10px;border-radius:999px;font-weight:600;font-size:13px}",
1010
+ ".tml-countdown{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;",
1011
+ "font-size:72px;font-weight:700;color:#fff;text-shadow:0 2px 16px rgba(0,0,0,.6)}",
1012
+ ".tml-countdown[hidden]{display:none}",
1013
+ ".tml-nps{display:flex;gap:4px;justify-content:center;flex-wrap:wrap;margin:4px 0 6px}",
1014
+ ".tml-nps-score{min-width:32px;padding:8px 0;border:1px solid #d1d5db;border-radius:8px;",
1015
+ "background:none;color:inherit;cursor:pointer;font:inherit}",
1016
+ ".tml-nps-score:hover{background:#2563eb;border-color:#2563eb;color:#fff}",
1017
+ ".tml-nps-legend{display:flex;justify-content:space-between;font-size:12px;color:#6b7280;margin-bottom:8px}",
1018
+ ".tml-nps-picked{width:44px;height:44px;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;",
1019
+ "border-radius:10px;background:#2563eb;color:#fff;font-size:18px;font-weight:700}",
1020
+ ".tml-thanks{text-align:center;padding:16px 0}",
1021
+ ".tml-thanks-mark{width:44px;height:44px;margin:0 auto 10px;display:flex;align-items:center;justify-content:center;",
1022
+ "border-radius:50%;background:#16a34a;color:#fff;font-size:22px}",
1023
+ "@media (prefers-color-scheme:dark){",
1024
+ "#tml-dialog,.tml-inline{background:#1a1f26;color:#e6e8ea}",
1025
+ ".tml-inline{border-color:#2a313a}",
1026
+ "#tml-dialog textarea,#tml-dialog input[type=text],#tml-dialog input[type=email],#tml-dialog input[type=file],",
1027
+ ".tml-inline textarea,.tml-inline input[type=text],.tml-inline input[type=email],.tml-inline input[type=file]",
1028
+ "{border-color:#2a313a}",
1029
+ ".tml-secondary{border-color:#2a313a}",
1030
+ ".tml-star{color:#3a4149}",
1031
+ ".tml-star-on{color:#f59e0b}",
1032
+ ".tml-nps-score{border-color:#2a313a}",
1033
+ ".tml-record{border-color:#3b82f6;background:rgba(59,130,246,.1)}",
1034
+ ".tml-record:hover{background:rgba(59,130,246,.18)}",
1035
+ ".tml-record-label,.tml-chip-remove{color:#3b82f6}",
1036
+ ".tml-chip{background:rgba(59,130,246,.12)}",
1037
+ ".tml-hint,.tml-nps-legend,.tml-record-hint{color:#9aa2ab}",
1038
+ "}"
1039
+ ].join("");
1040
+ var style = document.createElement("style");
1041
+ style.id = "tml-styles";
1042
+ style.textContent = css;
1043
+ document.head.appendChild(style);
1044
+ }
1045
+ })();