@usero/sdk 1.1.4 → 1.1.6

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