@usero/sdk 1.1.3 → 1.1.5

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.
@@ -22,6 +22,19 @@ function readTesterName(override) {
22
22
  }
23
23
  return void 0;
24
24
  }
25
+ function getAdoptSessionId() {
26
+ if (typeof window === "undefined" || typeof window.location === "undefined") return null;
27
+ try {
28
+ const params = new URLSearchParams(window.location.search);
29
+ const raw = params.get("uts");
30
+ if (!raw) return null;
31
+ const cleaned = raw.trim().slice(0, 64);
32
+ if (!/^[a-z0-9]+$/i.test(cleaned)) return null;
33
+ return cleaned;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
25
38
  function getTestSlug(queryParam) {
26
39
  if (typeof window === "undefined" || typeof window.location === "undefined") return null;
27
40
  try {
@@ -332,78 +345,225 @@ function buildIndicator(host, store, callbacks) {
332
345
  .btn-ghost { background: transparent; color: rgba(255,255,255,0.7); }
333
346
  .btn-ghost:hover { background: rgba(255,255,255,0.10); color: #fff; }
334
347
 
335
- /* Thanks overlay + end-of-test note */
348
+ /* ---- Finished screen (complete + ended-early). Usero warm-stone palette,
349
+ shadow-DOM scoped so host CSS can't leak in. Scrollable so the primary
350
+ action stays reachable on a short phone with the keyboard open. ---- */
336
351
  .thanks {
337
352
  position: fixed; inset: 0;
338
- display: grid; place-items: center;
339
- background: rgba(15, 15, 17, 0.78);
340
- backdrop-filter: blur(6px);
341
- -webkit-backdrop-filter: blur(6px);
342
- color: #fff;
343
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
353
+ display: flex; align-items: flex-start; justify-content: center;
354
+ background: rgba(28, 25, 23, 0.62);
355
+ backdrop-filter: blur(8px);
356
+ -webkit-backdrop-filter: blur(8px);
357
+ color: #1c1917;
358
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
344
359
  z-index: 2147483647;
345
- padding: 24px;
346
- text-align: center;
360
+ padding: 24px 16px calc(env(safe-area-inset-bottom, 0px) + 24px);
361
+ overflow-y: auto;
362
+ -webkit-overflow-scrolling: touch;
347
363
  }
348
364
  .thanks-card {
349
- background: #fff; color: #111;
350
- border-radius: 18px; padding: 28px 24px;
351
- max-width: 420px; width: 100%;
352
- box-shadow: 0 20px 50px rgba(0,0,0,0.25);
365
+ background: #fff; color: #1c1917;
366
+ border-radius: 22px; padding: 30px 24px 24px;
367
+ max-width: 400px; width: 100%;
368
+ margin: auto 0;
369
+ box-shadow: 0 24px 60px rgba(28, 25, 23, 0.28), 0 2px 8px rgba(28, 25, 23, 0.12);
353
370
  text-align: left;
371
+ animation: thanks-in 0.34s cubic-bezier(0.16, 1, 0.3, 1);
372
+ }
373
+ @keyframes thanks-in {
374
+ from { opacity: 0; transform: translateY(14px) scale(0.985); }
375
+ to { opacity: 1; transform: translateY(0) scale(1); }
354
376
  }
355
377
  .thanks-card .head { text-align: center; }
356
- .thanks h2 { margin: 0 0 6px; font-size: 20px; }
357
- .thanks .lede { margin: 0 0 18px; font-size: 14px; line-height: 1.45; color: #4b5563; text-align: center; }
378
+ .thanks h2 {
379
+ margin: 0 0 7px; font-size: 22px; line-height: 1.2;
380
+ font-weight: 600; letter-spacing: -0.018em; color: #1c1917;
381
+ }
382
+ .thanks .lede {
383
+ margin: 0 auto 22px; font-size: 14.5px; line-height: 1.5;
384
+ color: #57534e; text-align: center; max-width: 30ch;
385
+ }
386
+
387
+ /* Status medallion: green tick when complete, warm ring when ended early */
358
388
  .thanks .check {
359
- width: 44px; height: 44px; border-radius: 50%;
360
- background: #10b981; color: #fff;
389
+ width: 56px; height: 56px; border-radius: 50%;
361
390
  display: grid; place-items: center;
362
- margin: 0 auto 12px;
363
- font-size: 22px;
391
+ margin: 0 auto 16px;
392
+ }
393
+ .thanks .check.ok {
394
+ background: #ecfdf5;
395
+ box-shadow: inset 0 0 0 1px rgba(16,185,129,0.22);
396
+ color: #059669;
397
+ }
398
+ .thanks .check.ok svg { width: 26px; height: 26px; }
399
+ .thanks .check.early {
400
+ background: #fff7ed;
401
+ box-shadow: inset 0 0 0 1px rgba(234,88,12,0.20);
402
+ color: #ea580c;
403
+ }
404
+ .thanks .check.early svg { width: 24px; height: 24px; }
405
+
406
+ /* Verified-checks list (complete) / progress list (ended early) */
407
+ .thanks .checks {
408
+ list-style: none; margin: 0 0 4px; padding: 0;
409
+ border: 1px solid #f0eeec; border-radius: 14px;
410
+ background: #fafaf9; overflow: hidden;
411
+ }
412
+ .thanks .checks li {
413
+ display: flex; align-items: center; gap: 11px;
414
+ padding: 12px 14px; font-size: 14px; color: #292524;
415
+ border-top: 1px solid #f0eeec;
416
+ }
417
+ .thanks .checks li:first-child { border-top: 0; }
418
+ .thanks .checks .ic {
419
+ width: 20px; height: 20px; border-radius: 50%;
420
+ display: grid; place-items: center; flex-shrink: 0;
421
+ }
422
+ .thanks .checks .ic.done { background: #d1fae5; color: #059669; }
423
+ .thanks .checks .ic.todo { background: #f5f5f4; color: #a8a29e; box-shadow: inset 0 0 0 1px #e7e5e4; }
424
+ .thanks .checks .ic svg { width: 12px; height: 12px; }
425
+ .thanks .checks li.muted-row { color: #78716c; }
426
+
427
+ /* Payout block (complete) */
428
+ .thanks .payout { margin-top: 20px; }
429
+ .thanks .payout-q {
430
+ font-size: 12px; font-weight: 600; letter-spacing: 0.04em;
431
+ text-transform: uppercase; color: #a8a29e;
432
+ margin: 0 0 10px;
433
+ }
434
+ .thanks .pay-primary {
435
+ width: 100%; box-sizing: border-box;
436
+ appearance: none; border: 0; cursor: pointer;
437
+ background: #ea580c; color: #fff;
438
+ padding: 15px 18px; border-radius: 14px;
439
+ font: inherit; font-weight: 600; font-size: 15.5px;
440
+ line-height: 1.3; text-align: center;
441
+ box-shadow: 0 6px 16px rgba(234, 88, 12, 0.28);
442
+ transition: background 0.15s ease, transform 0.07s ease, box-shadow 0.15s ease;
443
+ }
444
+ .thanks .pay-primary:hover { background: #c2410c; }
445
+ .thanks .pay-primary:active { transform: scale(0.985); }
446
+ .thanks .pay-primary:focus-visible { outline: 2px solid #ea580c; outline-offset: 2px; }
447
+ .thanks .pay-primary[disabled] { opacity: 0.6; cursor: progress; box-shadow: none; }
448
+ .thanks .pay-primary .amt { font-variant-numeric: tabular-nums; }
449
+ .thanks .pay-alt {
450
+ display: block; width: 100%;
451
+ margin-top: 12px; padding: 4px;
452
+ background: none; border: 0; cursor: pointer;
453
+ font: inherit; font-size: 13px; font-weight: 500;
454
+ color: #78716c; text-align: center;
455
+ text-decoration: underline; text-underline-offset: 2px;
456
+ transition: color 0.15s ease;
457
+ }
458
+ .thanks .pay-alt:hover { color: #44403c; }
459
+ .thanks .pay-alt:focus-visible { outline: 2px solid #ea580c; outline-offset: 2px; border-radius: 6px; }
460
+ .thanks [hidden] { display: none !important; }
461
+
462
+ /* Alternate-email expander */
463
+ .thanks .pay-edit { margin-top: 14px; animation: pop-in 0.2s cubic-bezier(0.2,0.8,0.2,1); }
464
+ .thanks .pay-edit[hidden] { display: none; }
465
+ .thanks .pay-label {
466
+ display: block; margin: 0 0 7px;
467
+ font-size: 13px; font-weight: 500; color: #44403c;
468
+ }
469
+ .thanks .pay-input {
470
+ width: 100%; box-sizing: border-box;
471
+ padding: 12px 13px;
472
+ background: #fff; border: 1px solid #e7e5e4; border-radius: 11px;
473
+ font: inherit; font-size: 15px; color: #1c1917;
474
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
475
+ }
476
+ .thanks .pay-input:focus {
477
+ outline: none; border-color: #ea580c;
478
+ box-shadow: 0 0 0 3px rgba(234, 88, 12, 0.16);
479
+ }
480
+ .thanks .pay-input::placeholder { color: #a8a29e; }
481
+ .thanks .pay-eta {
482
+ margin: 14px 0 0; font-size: 12px; line-height: 1.45;
483
+ color: #a8a29e; text-align: center;
484
+ }
485
+
486
+ /* Ended-early "what unlocks the reward" note */
487
+ .thanks .early-note {
488
+ display: flex; align-items: flex-start; gap: 10px;
489
+ margin-top: 18px; padding: 13px 14px;
490
+ background: #fff7ed; border: 1px solid #fed7aa; border-radius: 13px;
491
+ font-size: 13.5px; line-height: 1.45; color: #9a3412;
492
+ }
493
+ .thanks .early-note svg { width: 17px; height: 17px; flex-shrink: 0; margin-top: 1px; color: #ea580c; }
494
+ .thanks .early-actions { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
495
+ .thanks .resume-btn {
496
+ width: 100%; box-sizing: border-box;
497
+ appearance: none; border: 0; cursor: pointer;
498
+ background: #ea580c; color: #fff;
499
+ padding: 15px 18px; border-radius: 14px;
500
+ font: inherit; font-weight: 600; font-size: 15.5px;
501
+ box-shadow: 0 6px 16px rgba(234, 88, 12, 0.28);
502
+ transition: background 0.15s ease, transform 0.07s ease;
503
+ }
504
+ .thanks .resume-btn:hover { background: #c2410c; }
505
+ .thanks .resume-btn:active { transform: scale(0.985); }
506
+ .thanks .resume-btn:focus-visible { outline: 2px solid #ea580c; outline-offset: 2px; }
507
+ .thanks .exit-btn {
508
+ width: 100%; box-sizing: border-box;
509
+ appearance: none; border: 0; background: none; cursor: pointer;
510
+ padding: 4px; font: inherit; font-size: 13px; line-height: 1.45;
511
+ color: #78716c; text-align: center;
512
+ }
513
+ .thanks .exit-btn:hover { color: #44403c; }
514
+ .thanks .exit-btn:focus-visible { outline: 2px solid #ea580c; outline-offset: 2px; border-radius: 6px; }
515
+
516
+ /* End-of-test note (shown after payout is set, complete path only) */
517
+ .thanks .note-section {
518
+ margin-top: 22px; padding-top: 20px;
519
+ border-top: 1px solid #f0eeec;
364
520
  }
365
521
  .thanks .end-label {
366
522
  display: block; margin: 0 0 8px;
367
- font-size: 13px; font-weight: 500; color: #374151;
523
+ font-size: 13px; font-weight: 500; color: #44403c;
368
524
  }
369
525
  .thanks .end-textarea {
370
526
  width: 100%; box-sizing: border-box;
371
- min-height: 96px; resize: vertical;
372
- padding: 11px 12px;
373
- background: #f9fafb;
374
- border: 1px solid #e5e7eb;
375
- border-radius: 10px;
376
- font: inherit; font-size: 14px; line-height: 1.5;
377
- color: #111;
378
- transition: border-color 0.15s ease, background 0.15s ease;
527
+ min-height: 84px; resize: vertical;
528
+ padding: 12px 13px;
529
+ background: #fafaf9;
530
+ border: 1px solid #e7e5e4;
531
+ border-radius: 12px;
532
+ font: inherit; font-size: 14.5px; line-height: 1.5;
533
+ color: #1c1917;
534
+ transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
379
535
  }
380
536
  .thanks .end-textarea:focus {
381
- outline: none; border-color: #111; background: #fff;
537
+ outline: none; border-color: #ea580c; background: #fff;
538
+ box-shadow: 0 0 0 3px rgba(234, 88, 12, 0.14);
382
539
  }
383
- .thanks .end-textarea::placeholder { color: #9ca3af; }
540
+ .thanks .end-textarea::placeholder { color: #a8a29e; }
384
541
  .thanks .end-actions {
385
542
  display: flex; gap: 10px; margin-top: 14px;
386
543
  }
387
544
  .thanks .end-actions button {
388
545
  flex: 1;
389
- appearance: none; border: 1px solid #e5e7eb;
390
- background: #fff; color: #111;
391
- padding: 11px 14px; border-radius: 10px;
546
+ appearance: none; border: 1px solid #e7e5e4;
547
+ background: #fff; color: #44403c;
548
+ padding: 12px 14px; border-radius: 12px;
392
549
  font: inherit; font-weight: 600; font-size: 14px;
393
550
  cursor: pointer;
394
551
  transition: background 0.15s ease, border-color 0.15s ease;
395
552
  }
396
- .thanks .end-actions button:hover { background: #f3f4f6; }
553
+ .thanks .end-actions button:hover { background: #fafaf9; border-color: #d6d3d1; }
397
554
  .thanks .end-actions button.primary {
398
- background: #111; color: #fff; border-color: #111;
555
+ background: #1c1917; color: #fff; border-color: #1c1917; flex: 1.4;
399
556
  }
400
- .thanks .end-actions button.primary:hover { background: #1f2937; border-color: #1f2937; }
401
- .thanks .end-actions button:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
557
+ .thanks .end-actions button.primary:hover { background: #292524; border-color: #292524; }
558
+ .thanks .end-actions button:focus-visible { outline: 2px solid #ea580c; outline-offset: 2px; }
402
559
  .thanks .end-hint {
403
- margin: 10px 0 0; font-size: 11.5px; color: #9ca3af; text-align: center;
560
+ margin: 11px 0 0; font-size: 11.5px; color: #a8a29e; text-align: center;
404
561
  }
405
562
  .thanks .end-sent {
406
- margin-top: 14px; text-align: center; color: #4b5563; font-size: 13px;
563
+ margin-top: 16px; text-align: center; color: #57534e; font-size: 13.5px; line-height: 1.45;
564
+ }
565
+ @media (prefers-reduced-motion: reduce) {
566
+ .thanks-card, .thanks .pay-edit { animation: none; }
407
567
  }
408
568
 
409
569
  @keyframes pulse {
@@ -481,6 +641,10 @@ function buildIndicator(host, store, callbacks) {
481
641
  var MIC_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0v-4a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 8 0M8 11.5v3M5.5 14.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
482
642
  var MIC_MUTED_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v3.2L10 11V3.5a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 6.5 3.12M12 7.5a4 4 0 0 1-.3 1.5M8 11.5v3M5.5 14.5h5M2 2l12 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
483
643
  var NOTE_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M3 3.5A1.5 1.5 0 0 1 4.5 2h7A1.5 1.5 0 0 1 13 3.5V10a1.5 1.5 0 0 1-1.5 1.5H7L4 14v-2.5h-.5A1.5 1.5 0 0 1 2 10V3.5A1.5 1.5 0 0 1 3.5 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
644
+ var TICK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 12.5 10 17.5 19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
645
+ var TICK_SM_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5 6.5 11.5 12.5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
646
+ var CLOCK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="8.4" stroke="currentColor" stroke-width="2"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
647
+ var SPARK_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 1.5 9.5 6.5 14.5 8 9.5 9.5 8 14.5 6.5 9.5 1.5 8 6.5 6.5Z" fill="currentColor"/></svg>`;
484
648
  function installTasksToggle(bar, finishBtn, store, onToggleTasks) {
485
649
  const tasksBtn = document.createElement("button");
486
650
  tasksBtn.type = "button";
@@ -717,43 +881,216 @@ function closeNotePopover(store) {
717
881
  store.notesPopoverOpen = false;
718
882
  store.notePopoverAtMs = null;
719
883
  }
884
+ function escapeHtml(value) {
885
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
886
+ }
887
+ function isValidEmail(value) {
888
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
889
+ }
720
890
  function showThanksScreen(root, opts) {
721
891
  const overlay = document.createElement("div");
722
892
  overlay.className = "thanks";
893
+ overlay.setAttribute("role", "dialog");
894
+ overlay.setAttribute("aria-modal", "true");
723
895
  const card = document.createElement("div");
724
896
  card.className = "thanks-card";
897
+ overlay.appendChild(card);
898
+ root.appendChild(overlay);
899
+ if (opts.payment && !opts.payment.qualified) {
900
+ renderEndedEarly(card, opts);
901
+ return;
902
+ }
903
+ renderComplete(card, opts);
904
+ }
905
+ function checksList(rows) {
906
+ const items = rows.map((r) => {
907
+ const icClass = r.done ? "ic done" : "ic todo";
908
+ const icon = r.done ? TICK_SM_SVG : "";
909
+ const liClass = r.muted ? ' class="muted-row"' : "";
910
+ return `<li${liClass}><span class="${icClass}" aria-hidden="true">${icon}</span><span>${escapeHtml(r.label)}</span></li>`;
911
+ }).join("");
912
+ return `<ul class="checks">${items}</ul>`;
913
+ }
914
+ function renderComplete(card, opts) {
915
+ const payment = opts.payment;
916
+ const reward = payment?.reward ?? null;
917
+ const defaultEmail = payment?.payoutEmail ?? null;
918
+ const tasksTotal = payment?.tasksTotal ?? 0;
919
+ const head = document.createElement("div");
920
+ head.className = "head";
921
+ const lede = reward ? `We have your recording. Confirm where to send your ${escapeHtml(reward)} and the team will review it shortly.` : "We have your recording. Thanks for taking the time to walk us through it.";
922
+ head.innerHTML = `
923
+ <div class="check ok" aria-hidden="true">${TICK_ICON_SVG}</div>
924
+ <h2>You're done.</h2>
925
+ <p class="lede">${lede}</p>
926
+ ${tasksTotal > 0 ? checksList([
927
+ { label: tasksTotal === 1 ? "1 task completed" : `All ${tasksTotal} tasks completed`, done: true },
928
+ { label: "Voice recording captured", done: true },
929
+ { label: "Screen replay uploaded", done: true }
930
+ ]) : checksList([
931
+ { label: "Voice recording captured", done: true },
932
+ { label: "Screen replay uploaded", done: true }
933
+ ])}
934
+ `;
935
+ card.appendChild(head);
936
+ if (!payment) {
937
+ appendNoteSection(card, opts, "Your session was saved. Anything you would add?");
938
+ return;
939
+ }
940
+ renderPayout(card, opts, reward, defaultEmail);
941
+ }
942
+ function renderPayout(card, opts, reward, defaultEmail) {
943
+ const wrap = document.createElement("div");
944
+ wrap.className = "payout";
945
+ const rewardLabel = reward ?? "my reward";
946
+ const haveDefault = !!defaultEmail && isValidEmail(defaultEmail);
947
+ wrap.innerHTML = `
948
+ <p class="payout-q">Where should we send ${escapeHtml(reward ?? "your reward")}?</p>
949
+ <button type="button" class="pay-primary" ${haveDefault ? "" : "hidden"}>
950
+ Send <span class="amt">${escapeHtml(rewardLabel)}</span>${haveDefault ? ` to ${escapeHtml(defaultEmail)}` : ""}
951
+ </button>
952
+ <button type="button" class="pay-alt">${haveDefault ? "Use a different email" : "Add your payout email"}</button>
953
+ <div class="pay-edit" ${haveDefault ? "hidden" : ""}>
954
+ <label class="pay-label" for="usero-payout-email">Payout email</label>
955
+ <input id="usero-payout-email" class="pay-input" type="email" inputmode="email"
956
+ autocomplete="email" placeholder="you@example.com" value="${haveDefault ? "" : escapeHtml(defaultEmail ?? "")}" />
957
+ </div>
958
+ <p class="pay-eta">Reward arrives within about 2 days of the team reviewing it.</p>
959
+ `;
960
+ card.appendChild(wrap);
961
+ const primary = wrap.querySelector(".pay-primary");
962
+ const altLink = wrap.querySelector(".pay-alt");
963
+ const editBox = wrap.querySelector(".pay-edit");
964
+ const emailInput = wrap.querySelector(".pay-input");
965
+ if (!primary || !altLink || !editBox || !emailInput) return;
966
+ const confirm = async (destination) => {
967
+ primary.disabled = true;
968
+ altLink.style.pointerEvents = "none";
969
+ const ok = await opts.onPayout(destination);
970
+ wrap.remove();
971
+ const confirmedTo = destination ?? defaultEmail;
972
+ const sentMsg = confirmedTo ? `${reward ? `${reward} is` : "Your reward is"} set to go to ${confirmedTo}.` : "Your reward is on its way.";
973
+ const note = ok ? sentMsg : `${sentMsg} (We will retry sending the details.)`;
974
+ appendNoteSection(card, opts, `${note} Anything you would add before you go?`);
975
+ };
976
+ primary.addEventListener("click", () => {
977
+ void confirm(null);
978
+ });
979
+ const openEditor = () => {
980
+ primary.hidden = true;
981
+ altLink.hidden = true;
982
+ editBox.hidden = false;
983
+ if (!editBox.querySelector(".pay-confirm")) {
984
+ const btn = document.createElement("button");
985
+ btn.type = "button";
986
+ btn.className = "pay-primary pay-confirm";
987
+ btn.style.marginTop = "12px";
988
+ btn.textContent = reward ? `Send ${reward} here` : "Use this email";
989
+ editBox.appendChild(btn);
990
+ btn.addEventListener("click", () => void submitEmail());
991
+ }
992
+ window.requestAnimationFrame(() => emailInput.focus({ preventScroll: true }));
993
+ };
994
+ const submitEmail = async () => {
995
+ const value = emailInput.value.trim().toLowerCase();
996
+ if (!isValidEmail(value)) {
997
+ emailInput.focus();
998
+ emailInput.style.borderColor = "#dc2626";
999
+ return;
1000
+ }
1001
+ await confirm(value);
1002
+ };
1003
+ altLink.addEventListener("click", openEditor);
1004
+ emailInput.addEventListener("input", () => {
1005
+ emailInput.style.borderColor = "";
1006
+ });
1007
+ emailInput.addEventListener("keydown", (e) => {
1008
+ if (e.key === "Enter") {
1009
+ e.preventDefault();
1010
+ void submitEmail();
1011
+ }
1012
+ });
1013
+ }
1014
+ function renderEndedEarly(card, opts) {
1015
+ const payment = opts.payment;
1016
+ const done = payment?.tasksDone ?? 0;
1017
+ const total = payment?.tasksTotal ?? 0;
1018
+ const reward = payment?.reward ?? null;
725
1019
  const head = document.createElement("div");
726
1020
  head.className = "head";
1021
+ const lede = total > 0 ? `We saw ${done} of ${total} ${total === 1 ? "task" : "tasks"} finished. No worries, you can pick up right where you left off.` : "It looks like the session ended before you finished. No worries, you can pick up where you left off.";
727
1022
  head.innerHTML = `
728
- <div class="check" aria-hidden="true">&#10003;</div>
729
- <h2>Thanks for testing</h2>
730
- <p class="lede">Your session was saved. One last thing if you have a moment.</p>
1023
+ <div class="check early" aria-hidden="true">${CLOCK_ICON_SVG}</div>
1024
+ <h2>Looks like you stopped early</h2>
1025
+ <p class="lede">${lede}</p>
731
1026
  `;
1027
+ card.appendChild(head);
1028
+ if (total > 0) {
1029
+ const rows = [];
1030
+ for (let i = 0; i < total; i += 1) {
1031
+ rows.push({ label: `Task ${i + 1}`, done: i < done });
1032
+ }
1033
+ const list = document.createElement("div");
1034
+ list.innerHTML = checksList(rows);
1035
+ const ul = list.firstElementChild;
1036
+ if (ul) card.appendChild(ul);
1037
+ }
1038
+ const note = document.createElement("div");
1039
+ note.className = "early-note";
1040
+ note.innerHTML = `${SPARK_ICON_SVG}<span><strong style="font-weight:600">Resume the test.</strong> ${reward ? `Your ${escapeHtml(reward)} reward unlocks` : "The reward unlocks"} once all ${total > 0 ? total : "the"} ${total === 1 ? "task is" : "tasks are"} done.</span>`;
1041
+ card.appendChild(note);
1042
+ const actions = document.createElement("div");
1043
+ actions.className = "early-actions";
1044
+ const resume = document.createElement("button");
1045
+ resume.type = "button";
1046
+ resume.className = "resume-btn";
1047
+ resume.textContent = "Resume where I left off";
1048
+ const exit = document.createElement("button");
1049
+ exit.type = "button";
1050
+ exit.className = "exit-btn";
1051
+ exit.textContent = "Thanks for trying. No reward this time since the tasks weren't finished.";
1052
+ actions.appendChild(resume);
1053
+ actions.appendChild(exit);
1054
+ card.appendChild(actions);
1055
+ resume.addEventListener("click", () => {
1056
+ const overlay = card.closest(".thanks");
1057
+ if (overlay instanceof HTMLElement) overlay.remove();
1058
+ opts.onResume();
1059
+ });
1060
+ exit.addEventListener("click", () => {
1061
+ card.innerHTML = "";
1062
+ const sent = document.createElement("p");
1063
+ sent.className = "end-sent";
1064
+ sent.textContent = "Thanks for giving it a go. You can close this tab now.";
1065
+ card.appendChild(sent);
1066
+ });
1067
+ }
1068
+ function appendNoteSection(card, opts, prompt) {
1069
+ const section = document.createElement("div");
1070
+ section.className = "note-section";
732
1071
  const form = document.createElement("form");
733
1072
  form.noValidate = true;
734
1073
  form.innerHTML = `
735
- <label class="end-label" for="usero-end-note">Anything you would add?</label>
1074
+ <label class="end-label" for="usero-end-note">${escapeHtml(prompt)}</label>
736
1075
  <textarea
737
1076
  id="usero-end-note"
738
1077
  class="end-textarea"
739
- rows="4"
1078
+ rows="3"
740
1079
  placeholder="Confusing bits, things you liked, what you'd change..."
741
1080
  ></textarea>
742
1081
  <div class="end-actions">
743
1082
  <button type="button" class="skip">Skip</button>
744
- <button type="submit" class="primary">Send</button>
1083
+ <button type="submit" class="primary">Send feedback</button>
745
1084
  </div>
746
1085
  <p class="end-hint">Cmd or Ctrl plus Enter to send. Either button is fine.</p>
747
1086
  `;
748
- card.appendChild(head);
749
- card.appendChild(form);
750
- overlay.appendChild(card);
751
- root.appendChild(overlay);
1087
+ section.appendChild(form);
1088
+ card.appendChild(section);
752
1089
  const ta = form.querySelector("#usero-end-note");
753
1090
  const skipBtn = form.querySelector("button.skip");
754
1091
  if (!ta || !skipBtn) return;
755
1092
  const swapToSent = (message) => {
756
- form.remove();
1093
+ section.remove();
757
1094
  const sent = document.createElement("p");
758
1095
  sent.className = "end-sent";
759
1096
  sent.textContent = message;
@@ -792,7 +1129,8 @@ function showThanksScreen(root, opts) {
792
1129
  showError("Couldn't save your note. Try again?");
793
1130
  }
794
1131
  } else {
795
- swapToSent("All good. You can close this tab.");
1132
+ opts.onSkip();
1133
+ swapToSent("All set. You can close this tab.");
796
1134
  }
797
1135
  };
798
1136
  form.addEventListener("submit", (e) => {
@@ -838,6 +1176,31 @@ async function createSession(apiUrl, slug, testerName) {
838
1176
  return null;
839
1177
  }
840
1178
  }
1179
+ async function adoptSession(apiUrl, sessionId) {
1180
+ try {
1181
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/adopt`, {
1182
+ method: "GET"
1183
+ });
1184
+ if (!res.ok) return null;
1185
+ const json = await res.json();
1186
+ if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
1187
+ return { sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
1188
+ } catch {
1189
+ return null;
1190
+ }
1191
+ }
1192
+ function parsePaymentSummary(raw) {
1193
+ if (typeof raw !== "object" || raw === null) return null;
1194
+ const p = raw;
1195
+ if (typeof p.qualified !== "boolean") return null;
1196
+ return {
1197
+ qualified: p.qualified,
1198
+ reward: typeof p.reward === "string" ? p.reward : null,
1199
+ payoutEmail: typeof p.payoutEmail === "string" ? p.payoutEmail : null,
1200
+ tasksDone: typeof p.tasksDone === "number" ? p.tasksDone : 0,
1201
+ tasksTotal: typeof p.tasksTotal === "number" ? p.tasksTotal : 0
1202
+ };
1203
+ }
841
1204
  async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
842
1205
  try {
843
1206
  const body = {
@@ -864,11 +1227,42 @@ async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {})
864
1227
  body: JSON.stringify(body),
865
1228
  keepalive: true
866
1229
  });
867
- return res.ok;
1230
+ if (!res.ok) return { ok: false, payment: null };
1231
+ let payment = null;
1232
+ try {
1233
+ const json = await res.json();
1234
+ payment = parsePaymentSummary(json.payment);
1235
+ } catch {
1236
+ }
1237
+ return { ok: true, payment };
868
1238
  } catch {
869
- return false;
1239
+ return { ok: false, payment: null };
870
1240
  }
871
1241
  }
1242
+ async function postPayout(apiUrl, sessionId, destination, logger) {
1243
+ const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/payout`;
1244
+ const body = { method: "email" };
1245
+ if (destination) body.destination = destination;
1246
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1247
+ try {
1248
+ const res = await fetch(url, {
1249
+ method: "POST",
1250
+ headers: { "Content-Type": "application/json" },
1251
+ body: JSON.stringify(body),
1252
+ keepalive: true
1253
+ });
1254
+ if (res.ok) return true;
1255
+ if (res.status >= 400 && res.status < 500) {
1256
+ logger.warn(`payout rejected with ${res.status}`);
1257
+ return false;
1258
+ }
1259
+ } catch (err) {
1260
+ logger.warn(`payout attempt ${attempt + 1} failed`, err);
1261
+ }
1262
+ await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
1263
+ }
1264
+ return false;
1265
+ }
872
1266
  async function postNoteOnce(apiUrl, sessionId, atMs, text, logger) {
873
1267
  try {
874
1268
  const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
@@ -1035,35 +1429,51 @@ async function finishFlow(store, ctx, opts) {
1035
1429
  if (store.replayOffsetAtStartMs !== null) {
1036
1430
  replayLinkage.replayOffsetMs = store.replayOffsetAtStartMs;
1037
1431
  }
1432
+ let payment = null;
1038
1433
  if (store.sessionId) {
1039
1434
  const unackedNotes = store.notes.filter((n) => !n.acked).map((n) => ({ atMs: n.atMs, text: n.text }));
1040
- const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1435
+ const result = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1041
1436
  mutedSegments: store.mutedSegments,
1042
1437
  notes: unackedNotes,
1043
1438
  ...replayLinkage
1044
1439
  });
1045
- if (ok) {
1440
+ if (result.ok) {
1441
+ payment = result.payment;
1046
1442
  for (const n of store.notes) {
1047
1443
  if (!n.acked) n.acked = true;
1048
1444
  }
1049
1445
  }
1050
- store.indicatorState = ok ? "done" : "error";
1446
+ store.indicatorState = result.ok ? "done" : "error";
1051
1447
  } else {
1052
1448
  store.indicatorState = "error";
1053
1449
  }
1054
1450
  renderIndicatorState(store);
1055
1451
  if (opts.showThanks && store.indicatorRoot && store.indicatorState === "done") {
1056
1452
  showThanksScreen(store.indicatorRoot, {
1453
+ payment,
1454
+ onPayout: async (destination) => {
1455
+ if (!store.sessionId) return false;
1456
+ return postPayout(store.options.apiUrl, store.sessionId, destination, ctx.logger);
1457
+ },
1458
+ onResume: () => {
1459
+ store.finishFlowRan = false;
1460
+ store.indicatorState = "recording";
1461
+ store.startedAt = Date.now();
1462
+ store.muted = false;
1463
+ store.mutedSinceMs = null;
1464
+ renderIndicatorState(store);
1465
+ void startRecording(store, ctx);
1466
+ },
1057
1467
  onSubmitNote: async (text) => {
1058
1468
  if (!store.sessionId) return;
1059
1469
  store.endNote = text;
1060
1470
  const stillUnacked = store.notes.filter((n) => !n.acked).map((n) => ({ atMs: n.atMs, text: n.text }));
1061
- const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1471
+ const result = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1062
1472
  endNote: text,
1063
1473
  notes: stillUnacked,
1064
1474
  ...replayLinkage
1065
1475
  });
1066
- if (!ok) throw new Error("finalise failed");
1476
+ if (!result.ok) throw new Error("finalise failed");
1067
1477
  for (const n of store.notes) {
1068
1478
  if (!n.acked) n.acked = true;
1069
1479
  }
@@ -1204,10 +1614,11 @@ function userTest(options = {}) {
1204
1614
  store.pageHideHandler = pageHide;
1205
1615
  window.addEventListener("pagehide", pageHide);
1206
1616
  void (async () => {
1207
- const created = await createSession(apiUrl, slug, readTesterName(merged.testerName));
1617
+ const adoptId = getAdoptSessionId();
1618
+ const created = adoptId ? await adoptSession(apiUrl, adoptId) : await createSession(apiUrl, slug, readTesterName(merged.testerName));
1208
1619
  if (store.cancelled) return;
1209
1620
  if (!created) {
1210
- ctx.logger.error("failed to create user-test session");
1621
+ ctx.logger.error(adoptId ? "failed to adopt user-test session" : "failed to create user-test session");
1211
1622
  store.indicatorState = "error";
1212
1623
  renderIndicatorState(store);
1213
1624
  return;