@wireai/activation 0.7.0 → 0.9.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 (94) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +198 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +12 -8
  5. package/dist/analytics/index.d.ts +12 -8
  6. package/dist/analytics/index.js +525 -55
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +525 -55
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
  15. package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +620 -13
  19. package/dist/index.d.ts +620 -13
  20. package/dist/index.js +1194 -369
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1015 -201
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-DzU-TqZz.d.ts +86 -0
  41. package/dist/transport-f5VVB5hH.d.mts +86 -0
  42. package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
  43. package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
  44. package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
  45. package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
  46. package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
  47. package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
  48. package/metro/index.d.ts +18 -1
  49. package/metro/index.js +80 -0
  50. package/package.json +9 -1
  51. package/src/WireOnboarding.tsx +4 -0
  52. package/src/analytics/analyticsFacade.ts +55 -10
  53. package/src/analytics/currentSession.ts +1 -1
  54. package/src/cards/CardGridSelectCard.tsx +253 -0
  55. package/src/cards/ChipSelectCard.tsx +1 -1
  56. package/src/cards/SelectionCard.tsx +15 -19
  57. package/src/cards/index.ts +13 -1
  58. package/src/cards/optionSchema.ts +65 -0
  59. package/src/coachmarks/types.ts +1 -1
  60. package/src/components/CardHandoff.tsx +39 -7
  61. package/src/components/DoneCheck.tsx +94 -0
  62. package/src/components/ModalPaneHost.tsx +141 -0
  63. package/src/components/StepDots.tsx +112 -0
  64. package/src/context/deviceId.ts +43 -0
  65. package/src/device/appVersion.ts +29 -8
  66. package/src/device/deviceContext.ts +14 -1
  67. package/src/device/deviceModel.ts +106 -0
  68. package/src/features/cache.ts +1 -1
  69. package/src/haptics/expo-haptics.d.ts +31 -0
  70. package/src/haptics/haptics.ts +58 -0
  71. package/src/icons/IconRegistry.tsx +53 -0
  72. package/src/icons/WireIcon.tsx +85 -0
  73. package/src/icons/expoIcons.ts +165 -0
  74. package/src/icons/index.ts +18 -0
  75. package/src/icons/vocabulary.ts +154 -0
  76. package/src/index.ts +22 -0
  77. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  78. package/src/questionnaire/decision.ts +4 -2
  79. package/src/questionnaire/index.ts +3 -2
  80. package/src/questionnaire/steps.ts +261 -0
  81. package/src/questionnaire/types.ts +33 -12
  82. package/src/reviews/ReviewGate.tsx +250 -152
  83. package/src/reviews/index.ts +7 -3
  84. package/src/reviews/transport.ts +77 -3
  85. package/src/reviews/types.ts +13 -0
  86. package/src/session/persistedSession.ts +1 -1
  87. package/src/session-analytics/lifecycle.ts +1 -1
  88. package/src/session-analytics/useSessionStart.ts +2 -2
  89. package/src/showcase/FeatureShowcase.tsx +1 -1
  90. package/src/showcase/showcaseColors.ts +1 -1
  91. package/src/theme/mergeThemeOver.ts +27 -0
  92. package/src/types.ts +8 -0
  93. package/dist/transport-BeO_Brcu.d.mts +0 -40
  94. package/dist/transport-DLpd1v5_.d.ts +0 -40
@@ -1,5 +1,5 @@
1
- import React4, { createContext, forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useContext } from 'react';
2
- import { StyleSheet, Animated, Easing, BackHandler, Modal, Pressable, Text, AccessibilityInfo, TextInput } from 'react-native';
1
+ import React8, { createContext, forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useContext } from 'react';
2
+ import { StyleSheet, Animated, Easing, BackHandler, Modal, Pressable, Text, AccessibilityInfo, View, TextInput } from 'react-native';
3
3
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
4
  import { SafeAreaView } from 'react-native-safe-area-context';
5
5
 
@@ -88,12 +88,24 @@ var buttonLabelStyle = (f) => {
88
88
  };
89
89
 
90
90
  // src/motion/motionSpec.ts
91
+ var WIRE_BEZIER = [0.16, 1, 0.3, 1];
91
92
  var REDUCED_FADE_MS = 120;
92
93
  var BUTTON_PRESS_SCALE = 0.975;
93
94
  var REDUCED_PRESS_OPACITY = 0.85;
94
95
  var BUTTON_PRESS_SPRING = { friction: 8, tension: 240 };
96
+ var SELECT_FILL_MS = 180;
97
+ var CARD_ENTER_MS = 220;
98
+ var CARD_EXIT_MS = 180;
99
+ var CARD_EXIT_LEAD_MS = 80;
100
+ var CARD_SLIDE_PX = 26;
101
+ var CARD_ENTER_SCALE_FROM = 0.96;
102
+ var CARD_SPRING = { damping: 14, stiffness: 180, mass: 1 };
103
+ var LOADER_HANDOFF_FADE_MS = 120;
104
+ var STATUS_POP_SPRING = { friction: 6, tension: 200 };
105
+ var STATUS_POP_SCALE_FROM = 0.4;
95
106
  var durationScale = 1;
96
107
  var scaledMs = (ms) => Math.round(ms * durationScale);
108
+ var stateChangeDuration = (reduced, durationMs) => reduced ? 0 : durationMs;
97
109
  var lastKnown = false;
98
110
  var useReducedMotion = () => {
99
111
  const [reduced, setReduced] = useState(lastKnown);
@@ -205,7 +217,7 @@ var _Button = ({
205
217
  }
206
218
  );
207
219
  };
208
- var Button = React4.memo(_Button);
220
+ var Button = React8.memo(_Button);
209
221
  var styles = StyleSheet.create({
210
222
  base: { alignItems: "center", justifyContent: "center" },
211
223
  fill: { width: "100%" }
@@ -325,7 +337,7 @@ var _CenteredModal = forwardRef(
325
337
  }
326
338
  );
327
339
  _CenteredModal.displayName = "CenteredModal";
328
- var CenteredModal = React4.memo(_CenteredModal);
340
+ var CenteredModal = React8.memo(_CenteredModal);
329
341
  var styles2 = StyleSheet.create({
330
342
  fill: { flex: 1 },
331
343
  backdrop: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "#000" },
@@ -341,6 +353,319 @@ var styles2 = StyleSheet.create({
341
353
  elevation: 12
342
354
  }
343
355
  });
356
+ var _DoneCheck = ({ theme, size = 48 }) => {
357
+ const reduced = useReducedMotion();
358
+ const pop = useRef(new Animated.Value(reduced ? 1 : 0)).current;
359
+ useEffect(() => {
360
+ if (reduced) {
361
+ pop.setValue(1);
362
+ return;
363
+ }
364
+ const anim = Animated.spring(pop, {
365
+ toValue: 1,
366
+ friction: STATUS_POP_SPRING.friction,
367
+ tension: STATUS_POP_SPRING.tension,
368
+ useNativeDriver: true
369
+ });
370
+ anim.start();
371
+ return () => anim.stop();
372
+ }, [pop, reduced]);
373
+ const scale = pop.interpolate({
374
+ inputRange: [0, 1],
375
+ outputRange: [STATUS_POP_SCALE_FROM, 1]
376
+ });
377
+ return /* @__PURE__ */ jsx(
378
+ Animated.View,
379
+ {
380
+ accessibilityElementsHidden: true,
381
+ importantForAccessibility: "no-hide-descendants",
382
+ style: [
383
+ styles3.ring,
384
+ {
385
+ width: size,
386
+ height: size,
387
+ borderRadius: theme.radius.full,
388
+ backgroundColor: theme.colors.success,
389
+ opacity: pop,
390
+ transform: [{ scale }]
391
+ }
392
+ ],
393
+ children: /* @__PURE__ */ jsx(
394
+ Animated.Text,
395
+ {
396
+ style: [
397
+ styles3.glyph,
398
+ { color: theme.colors.onPrimary, fontSize: Math.round(size * 0.5), fontFamily: theme.fonts.bold }
399
+ ],
400
+ children: "\u2713"
401
+ }
402
+ )
403
+ }
404
+ );
405
+ };
406
+ var DoneCheck = React8.memo(_DoneCheck);
407
+ DoneCheck.displayName = "DoneCheck";
408
+ var styles3 = StyleSheet.create({
409
+ ring: { alignItems: "center", justifyContent: "center", alignSelf: "center" },
410
+ glyph: { includeFontPadding: false, textAlign: "center" }
411
+ });
412
+ var easeWire = Easing.bezier(...WIRE_BEZIER);
413
+ var _CardHandoff = ({
414
+ transitionKey,
415
+ variant = "slide",
416
+ direction = "forward",
417
+ layout = "fill",
418
+ children
419
+ }) => {
420
+ const reduced = useReducedMotion();
421
+ const [leaving, setLeaving] = useState(null);
422
+ const enterT = useRef(new Animated.Value(0)).current;
423
+ const exitT = useRef(new Animated.Value(0)).current;
424
+ const prev = useRef({ key: transitionKey, node: children, variant });
425
+ const running = useRef({});
426
+ const mounted = useRef(false);
427
+ useEffect(() => {
428
+ var _a, _b;
429
+ const last = prev.current;
430
+ prev.current = { key: transitionKey, node: children, variant };
431
+ if (!mounted.current) {
432
+ mounted.current = true;
433
+ running.current.enter = startEnter(enterT, variant, reduced, 0);
434
+ return;
435
+ }
436
+ if (last.key === transitionKey) return;
437
+ (_a = running.current.enter) == null ? void 0 : _a.stop();
438
+ (_b = running.current.exit) == null ? void 0 : _b.stop();
439
+ setLeaving(last);
440
+ enterT.setValue(0);
441
+ exitT.setValue(0);
442
+ const exitDuration = reduced ? REDUCED_FADE_MS : last.variant === "fade" ? LOADER_HANDOFF_FADE_MS : CARD_EXIT_MS;
443
+ const exit = Animated.timing(exitT, {
444
+ toValue: 1,
445
+ duration: scaledMs(exitDuration),
446
+ easing: easeWire,
447
+ useNativeDriver: true
448
+ });
449
+ running.current.exit = exit;
450
+ exit.start();
451
+ const enter = startEnter(
452
+ enterT,
453
+ variant,
454
+ reduced,
455
+ reduced ? 0 : CARD_EXIT_LEAD_MS,
456
+ () => setLeaving(null)
457
+ );
458
+ running.current.enter = enter;
459
+ });
460
+ useEffect(
461
+ () => () => {
462
+ var _a, _b;
463
+ (_a = running.current.enter) == null ? void 0 : _a.stop();
464
+ (_b = running.current.exit) == null ? void 0 : _b.stop();
465
+ },
466
+ []
467
+ );
468
+ const enterStyle = {
469
+ opacity: enterT,
470
+ transform: transformFor(variant, enterT, reduced, "enter", direction)
471
+ };
472
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
473
+ leaving ? /* @__PURE__ */ jsx(
474
+ Animated.View,
475
+ {
476
+ pointerEvents: "none",
477
+ style: [
478
+ StyleSheet.absoluteFill,
479
+ {
480
+ opacity: exitT.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
481
+ // The CURRENT direction drives BOTH layers: one transition, one geography.
482
+ transform: transformFor(leaving.variant, exitT, reduced, "exit", direction)
483
+ }
484
+ ],
485
+ children: leaving.node
486
+ }
487
+ ) : null,
488
+ /* @__PURE__ */ jsx(Animated.View, { style: [layout === "fill" ? styles4.fill : null, enterStyle], children }, variant)
489
+ ] });
490
+ };
491
+ var startEnter = (value, variant, reduced, delay, onDone) => {
492
+ value.setValue(0);
493
+ const anim = !reduced && variant === "spring" ? Animated.spring(value, {
494
+ toValue: 1,
495
+ delay: scaledMs(delay),
496
+ damping: CARD_SPRING.damping,
497
+ stiffness: CARD_SPRING.stiffness,
498
+ mass: CARD_SPRING.mass,
499
+ useNativeDriver: true
500
+ }) : Animated.timing(value, {
501
+ toValue: 1,
502
+ delay: scaledMs(delay),
503
+ duration: scaledMs(
504
+ reduced ? REDUCED_FADE_MS : variant === "fade" ? LOADER_HANDOFF_FADE_MS : CARD_ENTER_MS
505
+ ),
506
+ easing: easeWire,
507
+ useNativeDriver: true
508
+ });
509
+ anim.start(({ finished }) => {
510
+ if (finished) onDone == null ? void 0 : onDone();
511
+ });
512
+ return anim;
513
+ };
514
+ var transformFor = (variant, t, reduced, layer, travel) => {
515
+ if (reduced || variant === "fade") return [];
516
+ if (variant === "spring" && layer === "enter") {
517
+ return [{ scale: t.interpolate({ inputRange: [0, 1], outputRange: [CARD_ENTER_SCALE_FROM, 1] }) }];
518
+ }
519
+ const slide = travel === "forward" ? CARD_SLIDE_PX : -CARD_SLIDE_PX;
520
+ return [
521
+ {
522
+ translateX: t.interpolate({
523
+ inputRange: [0, 1],
524
+ outputRange: layer === "enter" ? [slide, 0] : [0, -slide]
525
+ })
526
+ }
527
+ ];
528
+ };
529
+ var CardHandoff = React8.memo(_CardHandoff);
530
+ var styles4 = StyleSheet.create({
531
+ fill: { flex: 1 }
532
+ });
533
+ var easeWire2 = Easing.bezier(...WIRE_BEZIER);
534
+ var _ModalPaneHost = ({
535
+ paneKey,
536
+ direction = "forward",
537
+ header,
538
+ children
539
+ }) => {
540
+ const reduced = useReducedMotion();
541
+ const height = useRef(new Animated.Value(0)).current;
542
+ const measured = useRef(null);
543
+ const [sized, setSized] = useState(false);
544
+ const paneChanged = useRef(false);
545
+ const running = useRef(null);
546
+ useEffect(() => {
547
+ paneChanged.current = true;
548
+ }, [paneKey]);
549
+ useEffect(() => () => {
550
+ var _a;
551
+ return (_a = running.current) == null ? void 0 : _a.stop();
552
+ }, []);
553
+ const onLayout = useCallback(
554
+ (event) => {
555
+ var _a;
556
+ const next = Math.round(event.nativeEvent.layout.height);
557
+ if (!next) return;
558
+ const previous = measured.current;
559
+ if (previous !== null && Math.abs(previous - next) < 1) return;
560
+ const first = previous === null;
561
+ const ease = !first && !reduced && paneChanged.current;
562
+ paneChanged.current = false;
563
+ measured.current = next;
564
+ (_a = running.current) == null ? void 0 : _a.stop();
565
+ if (!ease) {
566
+ height.setValue(next);
567
+ if (first) setSized(true);
568
+ return;
569
+ }
570
+ const anim = Animated.timing(height, {
571
+ toValue: next,
572
+ duration: scaledMs(CARD_ENTER_MS),
573
+ easing: easeWire2,
574
+ // height is layout, not transform — see the header note.
575
+ useNativeDriver: false
576
+ });
577
+ running.current = anim;
578
+ anim.start();
579
+ },
580
+ [height, reduced]
581
+ );
582
+ return /* @__PURE__ */ jsx(Animated.View, { style: sized ? { height, overflow: "hidden" } : void 0, children: /* @__PURE__ */ jsxs(View, { onLayout, style: styles5.pane, children: [
583
+ header,
584
+ /* @__PURE__ */ jsx(CardHandoff, { transitionKey: paneKey, direction, layout: "auto", variant: "slide", children })
585
+ ] }) });
586
+ };
587
+ var ModalPaneHost = React8.memo(_ModalPaneHost);
588
+ ModalPaneHost.displayName = "ModalPaneHost";
589
+ var styles5 = StyleSheet.create({
590
+ pane: { width: "100%" }
591
+ });
592
+ var easeWire3 = Easing.bezier(...WIRE_BEZIER);
593
+ var Dot = ({ theme, filled, size }) => {
594
+ const reduced = useReducedMotion();
595
+ const fill = useRef(new Animated.Value(filled ? 1 : 0)).current;
596
+ useEffect(() => {
597
+ const anim = Animated.timing(fill, {
598
+ toValue: filled ? 1 : 0,
599
+ duration: scaledMs(stateChangeDuration(reduced, SELECT_FILL_MS)),
600
+ easing: easeWire3,
601
+ useNativeDriver: true
602
+ });
603
+ anim.start();
604
+ return () => anim.stop();
605
+ }, [fill, filled, reduced]);
606
+ return /* @__PURE__ */ jsx(
607
+ View,
608
+ {
609
+ style: {
610
+ width: size,
611
+ height: size,
612
+ borderRadius: theme.radius.full,
613
+ backgroundColor: theme.colors.border
614
+ },
615
+ children: /* @__PURE__ */ jsx(
616
+ Animated.View,
617
+ {
618
+ style: [
619
+ StyleSheet.absoluteFill,
620
+ {
621
+ borderRadius: theme.radius.full,
622
+ backgroundColor: theme.colors.primary,
623
+ opacity: fill
624
+ }
625
+ ]
626
+ }
627
+ )
628
+ }
629
+ );
630
+ };
631
+ var _StepDots = ({ theme, count, index }) => /* @__PURE__ */ jsx(
632
+ View,
633
+ {
634
+ style: [styles6.row, { gap: theme.spacing.sm }],
635
+ accessibilityRole: "progressbar",
636
+ accessibilityLabel: `Step ${index + 1} of ${count}`,
637
+ accessibilityValue: { min: 1, max: count, now: index + 1 },
638
+ children: Array.from({ length: count }, (_, i) => /* @__PURE__ */ jsx(View, { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: /* @__PURE__ */ jsx(Dot, { theme, filled: i <= index, size: theme.spacing.sm }) }, i))
639
+ }
640
+ );
641
+ var StepDots = React8.memo(_StepDots);
642
+ StepDots.displayName = "StepDots";
643
+ var styles6 = StyleSheet.create({
644
+ row: { flexDirection: "row", alignItems: "center", justifyContent: "center" }
645
+ });
646
+
647
+ // src/haptics/haptics.ts
648
+ var play = async (tone) => {
649
+ try {
650
+ const mod = await import('expo-haptics');
651
+ if (!mod) return false;
652
+ if (tone === "selection") {
653
+ await mod.selectionAsync();
654
+ return true;
655
+ }
656
+ if (tone === "light") {
657
+ await mod.impactAsync(mod.ImpactFeedbackStyle.Light);
658
+ return true;
659
+ }
660
+ await mod.notificationAsync(mod.NotificationFeedbackType.Success);
661
+ return true;
662
+ } catch {
663
+ return false;
664
+ }
665
+ };
666
+ var playHaptic = (tone) => {
667
+ void play(tone);
668
+ };
344
669
 
345
670
  // src/reviews/feedbackForm.ts
346
671
  var FEEDBACK_MIN_LINES = 4;
@@ -355,6 +680,18 @@ var feedbackInputHeights = (fonts, verticalPadding = 0) => {
355
680
  };
356
681
  };
357
682
 
683
+ // src/theme/mergeThemeOver.ts
684
+ var mergeThemeOver = (base, override) => {
685
+ if (!override) return base;
686
+ return {
687
+ colors: { ...base.colors, ...override.colors },
688
+ fonts: { ...base.fonts, ...override.fonts },
689
+ radius: { ...base.radius, ...override.radius },
690
+ spacing: { ...base.spacing, ...override.spacing },
691
+ button: { ...base.button, ...override.button }
692
+ };
693
+ };
694
+
358
695
  // src/reviews/decision.ts
359
696
  var decideReview = (local, decision) => decision != null ? decision : local;
360
697
  var resolveRules = (config) => {
@@ -402,6 +739,92 @@ var buildQuestionnaireSubmission = (input) => {
402
739
  };
403
740
  };
404
741
 
742
+ // src/questionnaire/steps.ts
743
+ var REQUIRED_STEPS = {
744
+ opinion: false,
745
+ improve: false,
746
+ suggestions: true
747
+ };
748
+ var DEFAULT_STEP_LABELS = {
749
+ opinion: "What do you think about the app?",
750
+ improve: "How can we improve?",
751
+ suggestions: "Any suggestions?"
752
+ };
753
+ var STEP_PLACEHOLDERS = {
754
+ opinion: "e.g. it feels fast and clear",
755
+ improve: "e.g. fewer steps to start",
756
+ suggestions: "e.g. a dark theme"
757
+ };
758
+ var buildQuestionnaireSteps = (questionnaire) => {
759
+ var _a, _b, _c;
760
+ return [
761
+ {
762
+ key: "opinion",
763
+ label: (_a = questionnaire.opinion_label) != null ? _a : DEFAULT_STEP_LABELS.opinion,
764
+ placeholder: STEP_PLACEHOLDERS.opinion,
765
+ required: REQUIRED_STEPS.opinion
766
+ },
767
+ {
768
+ key: "improve",
769
+ label: (_b = questionnaire.improve_label) != null ? _b : DEFAULT_STEP_LABELS.improve,
770
+ placeholder: STEP_PLACEHOLDERS.improve,
771
+ required: REQUIRED_STEPS.improve
772
+ },
773
+ {
774
+ key: "suggestions",
775
+ label: (_c = questionnaire.suggestions_label) != null ? _c : DEFAULT_STEP_LABELS.suggestions,
776
+ placeholder: STEP_PLACEHOLDERS.suggestions,
777
+ required: REQUIRED_STEPS.suggestions
778
+ }
779
+ ];
780
+ };
781
+ var initialStepState = () => ({
782
+ index: 0,
783
+ drafts: {},
784
+ direction: "forward",
785
+ done: false
786
+ });
787
+ var currentStep = (steps, state) => steps[state.index];
788
+ var isLastStep = (steps, index) => index >= steps.length - 1;
789
+ var canGoBack = (state) => state.index > 0;
790
+ var canSkipStep = (steps, state) => {
791
+ var _a, _b;
792
+ return !((_b = (_a = steps[state.index]) == null ? void 0 : _a.required) != null ? _b : false);
793
+ };
794
+ var canCommitStep = (steps, state, value) => {
795
+ const step = steps[state.index];
796
+ if (!step) return false;
797
+ return !step.required || value.trim().length > 0;
798
+ };
799
+ var stashDraft = (steps, state, value) => {
800
+ const step = steps[state.index];
801
+ if (!step) return state;
802
+ return { ...state, drafts: { ...state.drafts, [step.key]: value } };
803
+ };
804
+ var commitStep = (steps, state, value) => {
805
+ const step = steps[state.index];
806
+ if (!step) return state;
807
+ if (!canCommitStep(steps, state, value)) return state;
808
+ const { drafts } = stashDraft(steps, state, value);
809
+ const last = isLastStep(steps, state.index);
810
+ return {
811
+ index: last ? state.index : state.index + 1,
812
+ drafts,
813
+ direction: "forward",
814
+ done: last
815
+ };
816
+ };
817
+ var skipStep = (steps, state) => commitStep(steps, state, "");
818
+ var goBack = (state) => state.index <= 0 ? state : { ...state, index: state.index - 1, direction: "backward" };
819
+ var toAnswers = (drafts) => {
820
+ var _a, _b, _c;
821
+ return {
822
+ opinion: (_a = drafts.opinion) != null ? _a : "",
823
+ improve: (_b = drafts.improve) != null ? _b : "",
824
+ suggestions: (_c = drafts.suggestions) != null ? _c : ""
825
+ };
826
+ };
827
+
405
828
  // src/questionnaire/transport.ts
406
829
  var submitQuestionnaireResponse = (target, id, submission) => {
407
830
  if (!(target == null ? void 0 : target.serverUrl) || !id) return;
@@ -438,16 +861,6 @@ var fetchQuestionnaireDecision = async (target, options = {}) => {
438
861
  return null;
439
862
  }
440
863
  };
441
- var mergeThemeOver = (base, override) => {
442
- if (!override) return base;
443
- return {
444
- colors: { ...base.colors, ...override.colors },
445
- fonts: { ...base.fonts, ...override.fonts },
446
- radius: { ...base.radius, ...override.radius },
447
- spacing: { ...base.spacing, ...override.spacing },
448
- button: { ...base.button, ...override.button }
449
- };
450
- };
451
864
  var _QuestionnaireGate = ({
452
865
  questionnaire,
453
866
  target,
@@ -459,150 +872,208 @@ var _QuestionnaireGate = ({
459
872
  theme: themeOverride,
460
873
  dismissible = true
461
874
  }) => {
462
- var _a, _b, _c, _d;
875
+ var _a, _b, _c;
463
876
  const active = useOnboardingTheme();
464
877
  const t = useMemo(() => mergeThemeOver(active, themeOverride), [active, themeOverride]);
465
- const modalRef = React4.useRef(null);
878
+ const modalRef = useRef(null);
466
879
  const id = questionnaire.id;
880
+ const steps = useMemo(
881
+ () => buildQuestionnaireSteps({
882
+ opinion_label: questionnaire.opinion_label,
883
+ improve_label: questionnaire.improve_label,
884
+ suggestions_label: questionnaire.suggestions_label
885
+ }),
886
+ [questionnaire.opinion_label, questionnaire.improve_label, questionnaire.suggestions_label]
887
+ );
467
888
  const [phase, setPhase] = useState("form");
468
- const [opinion, setOpinion] = useState("");
469
- const [improve, setImprove] = useState("");
470
- const [suggestions, setSuggestions] = useState("");
471
- const shownRef = React4.useRef(false);
472
- const onShownRef = React4.useRef(onShown);
473
- const onEventRef = React4.useRef(onEvent);
889
+ const [state, setState] = useState(initialStepState);
890
+ const stateRef = useRef(state);
891
+ stateRef.current = state;
892
+ const draftRef = useRef("");
893
+ const [answered, setAnswered] = useState(false);
894
+ const answeredRef = useRef(false);
895
+ const seedDraft = useCallback((value) => {
896
+ draftRef.current = value;
897
+ const has = value.trim().length > 0;
898
+ answeredRef.current = has;
899
+ setAnswered(has);
900
+ }, []);
901
+ const handleChangeText = useCallback((text) => {
902
+ draftRef.current = text;
903
+ const has = text.trim().length > 0;
904
+ if (has === answeredRef.current) return;
905
+ answeredRef.current = has;
906
+ setAnswered(has);
907
+ }, []);
908
+ const shownRef = useRef(false);
909
+ const onShownRef = useRef(onShown);
910
+ const onEventRef = useRef(onEvent);
474
911
  onShownRef.current = onShown;
475
912
  onEventRef.current = onEvent;
476
- React4.useEffect(() => {
913
+ React8.useEffect(() => {
477
914
  var _a2, _b2;
478
915
  if (shownRef.current) return;
479
916
  shownRef.current = true;
480
917
  (_a2 = onShownRef.current) == null ? void 0 : _a2.call(onShownRef);
481
918
  (_b2 = onEventRef.current) == null ? void 0 : _b2.call(onEventRef, { name: "questionnaire_prompt_shown", id });
482
919
  }, []);
483
- const resolveTimer = React4.useRef(null);
484
- React4.useEffect(() => {
920
+ const resolveTimer = useRef(null);
921
+ React8.useEffect(() => {
485
922
  return () => {
486
923
  if (resolveTimer.current) clearTimeout(resolveTimer.current);
487
924
  };
488
925
  }, []);
489
- const resolve = useCallback(() => {
490
- onResolved == null ? void 0 : onResolved();
491
- }, [onResolved]);
492
- const canSend = opinion.trim().length > 0;
493
- const send = useCallback(() => {
494
- if (!canSend) return;
495
- const submission = buildQuestionnaireSubmission({
496
- answers: { opinion, improve, suggestions },
497
- sessionId,
498
- meta
499
- });
500
- submitQuestionnaireResponse(target, id, submission);
501
- onEvent == null ? void 0 : onEvent({ name: "questionnaire_submitted", id });
502
- setPhase("thanks");
503
- if (resolveTimer.current) clearTimeout(resolveTimer.current);
504
- resolveTimer.current = setTimeout(() => {
505
- var _a2;
506
- (_a2 = modalRef.current) == null ? void 0 : _a2.close();
507
- }, 1500);
508
- }, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent]);
509
- const handleDismissTap = useCallback(() => {
926
+ const submittedRef = useRef(false);
927
+ const handleDismissed = useCallback(() => {
510
928
  var _a2;
511
- onEvent == null ? void 0 : onEvent({ name: "questionnaire_dismissed", id });
512
- (_a2 = modalRef.current) == null ? void 0 : _a2.close();
513
- }, [onEvent, id]);
514
- const title = (_a = questionnaire.title) != null ? _a : "How's it going so far?";
515
- const opinionLabel = (_b = questionnaire.opinion_label) != null ? _b : "What do you think about the app?";
516
- const improveLabel = (_c = questionnaire.improve_label) != null ? _c : "How can we improve? (optional)";
517
- const suggestionsLabel = (_d = questionnaire.suggestions_label) != null ? _d : "Any suggestions? (optional)";
518
- const opinionBox = feedbackInputHeights(t.fonts, t.spacing.md);
519
- const content = /* @__PURE__ */ jsxs(Fragment, { children: [
520
- /* @__PURE__ */ jsx(Text, { style: [headingStyle(t.fonts), { color: t.colors.text }], children: title }),
521
- phase === "form" && /* @__PURE__ */ jsxs(Fragment, { children: [
522
- /* @__PURE__ */ jsx(Text, { style: [captionStyle(t.fonts), { color: t.colors.textMuted }], children: "A few words is plenty. Only the first box is required." }),
523
- /* @__PURE__ */ jsx(Text, { style: [captionStyle(t.fonts), { color: t.colors.textMuted }], children: opinionLabel }),
524
- /* @__PURE__ */ jsx(
525
- TextInput,
526
- {
527
- value: opinion,
528
- onChangeText: setOpinion,
529
- placeholder: "e.g. it feels fast and clear",
530
- placeholderTextColor: t.colors.textMuted,
531
- multiline: true,
532
- textAlignVertical: "top",
533
- style: [
534
- bodyStyle(t.fonts),
535
- styles3.input,
536
- {
537
- color: t.colors.text,
538
- backgroundColor: t.colors.background,
539
- borderRadius: t.radius.md,
540
- borderColor: t.colors.border,
541
- padding: t.spacing.md,
542
- minHeight: opinionBox.minHeight,
543
- maxHeight: opinionBox.maxHeight
544
- }
545
- ]
546
- }
547
- ),
548
- /* @__PURE__ */ jsx(Text, { style: [captionStyle(t.fonts), { color: t.colors.textMuted }], children: improveLabel }),
549
- /* @__PURE__ */ jsx(
550
- TextInput,
551
- {
552
- value: improve,
553
- onChangeText: setImprove,
554
- placeholder: "e.g. fewer steps to start",
555
- placeholderTextColor: t.colors.textMuted,
556
- style: [
557
- bodyStyle(t.fonts),
558
- styles3.input,
559
- {
560
- color: t.colors.text,
561
- backgroundColor: t.colors.background,
562
- borderRadius: t.radius.md,
563
- borderColor: t.colors.border,
564
- paddingHorizontal: t.spacing.md,
565
- paddingVertical: t.spacing.sm,
566
- minHeight: t.button.height
567
- }
568
- ]
569
- }
570
- ),
571
- /* @__PURE__ */ jsx(Text, { style: [captionStyle(t.fonts), { color: t.colors.textMuted }], children: suggestionsLabel }),
572
- /* @__PURE__ */ jsx(
573
- TextInput,
574
- {
575
- value: suggestions,
576
- onChangeText: setSuggestions,
577
- placeholder: "e.g. a dark theme",
578
- placeholderTextColor: t.colors.textMuted,
579
- style: [
580
- bodyStyle(t.fonts),
581
- styles3.input,
582
- {
583
- color: t.colors.text,
584
- backgroundColor: t.colors.background,
585
- borderRadius: t.radius.md,
586
- borderColor: t.colors.border,
587
- paddingHorizontal: t.spacing.md,
588
- paddingVertical: t.spacing.sm,
589
- minHeight: t.button.height
590
- }
591
- ]
592
- }
593
- ),
594
- /* @__PURE__ */ jsx(Button, { title: "Send", onPress: send, variant: "primary", fullWidth: true, disabled: !canSend }),
595
- dismissible && /* @__PURE__ */ jsx(Pressable, { onPress: handleDismissTap, hitSlop: 8, children: /* @__PURE__ */ jsx(Text, { style: [captionStyle(t.fonts), styles3.link, { color: t.colors.textMuted }], children: "Not now" }) })
596
- ] }),
597
- phase === "thanks" && /* @__PURE__ */ jsx(Text, { style: [bodyStyle(t.fonts), { color: t.colors.textMuted }], children: "Thank you. This helps us make it better." })
929
+ if (!submittedRef.current) {
930
+ (_a2 = onEventRef.current) == null ? void 0 : _a2.call(onEventRef, { name: "questionnaire_dismissed", id });
931
+ }
932
+ onResolved == null ? void 0 : onResolved();
933
+ }, [id, onResolved]);
934
+ const finish = useCallback(
935
+ (drafts) => {
936
+ const submission = buildQuestionnaireSubmission({
937
+ answers: toAnswers(drafts),
938
+ sessionId,
939
+ meta
940
+ });
941
+ submitQuestionnaireResponse(target, id, submission);
942
+ submittedRef.current = true;
943
+ onEvent == null ? void 0 : onEvent({ name: "questionnaire_submitted", id });
944
+ playHaptic("success");
945
+ setPhase("thanks");
946
+ if (resolveTimer.current) clearTimeout(resolveTimer.current);
947
+ resolveTimer.current = setTimeout(() => {
948
+ var _a2;
949
+ (_a2 = modalRef.current) == null ? void 0 : _a2.close();
950
+ }, 1500);
951
+ },
952
+ [sessionId, meta, target, id, onEvent]
953
+ );
954
+ const applyTransition = useCallback(
955
+ (next) => {
956
+ var _a2, _b2, _c2;
957
+ if (next === stateRef.current) return;
958
+ setState(next);
959
+ if (next.done) {
960
+ finish(next.drafts);
961
+ return;
962
+ }
963
+ seedDraft((_c2 = next.drafts[(_b2 = (_a2 = steps[next.index]) == null ? void 0 : _a2.key) != null ? _b2 : "opinion"]) != null ? _c2 : "");
964
+ playHaptic("light");
965
+ },
966
+ [finish, seedDraft, steps]
967
+ );
968
+ const handleNext = useCallback(() => {
969
+ applyTransition(commitStep(steps, stateRef.current, draftRef.current));
970
+ }, [applyTransition, steps]);
971
+ const handleSkip = useCallback(() => {
972
+ applyTransition(skipStep(steps, stateRef.current));
973
+ }, [applyTransition, steps]);
974
+ const handleBack = useCallback(() => {
975
+ var _a2, _b2, _c2;
976
+ const next = goBack(stashDraft(steps, stateRef.current, draftRef.current));
977
+ setState(next);
978
+ seedDraft((_c2 = next.drafts[(_b2 = (_a2 = steps[next.index]) == null ? void 0 : _a2.key) != null ? _b2 : "opinion"]) != null ? _c2 : "");
979
+ playHaptic("light");
980
+ }, [seedDraft, steps]);
981
+ const step = currentStep(steps, state);
982
+ const last = isLastStep(steps, state.index);
983
+ const canSkip = canSkipStep(steps, state);
984
+ const sendDisabled = ((_a = step == null ? void 0 : step.required) != null ? _a : false) && !answered;
985
+ const answerBox = feedbackInputHeights(t.fonts, t.spacing.md);
986
+ const linkStyle = [captionStyle(t.fonts), styles7.link, { color: t.colors.textMuted }];
987
+ const header = phase === "form" ? /* @__PURE__ */ jsxs(View, { style: [styles7.header, { marginBottom: t.spacing.md }], children: [
988
+ /* @__PURE__ */ jsx(View, { style: styles7.headerSide, children: canGoBack(state) && /* @__PURE__ */ jsx(
989
+ Pressable,
990
+ {
991
+ onPress: handleBack,
992
+ hitSlop: 12,
993
+ accessibilityRole: "button",
994
+ accessibilityLabel: "Back to the previous question",
995
+ children: /* @__PURE__ */ jsx(Text, { style: [headingStyle(t.fonts), styles7.backGlyph, { color: t.colors.textMuted }], children: "\u2039" })
996
+ }
997
+ ) }),
998
+ /* @__PURE__ */ jsx(StepDots, { theme: t, count: steps.length, index: state.index }),
999
+ /* @__PURE__ */ jsx(View, { style: styles7.headerSide })
1000
+ ] }) : null;
1001
+ const formPane = step ? /* @__PURE__ */ jsxs(View, { style: { gap: t.spacing.md }, children: [
1002
+ /* @__PURE__ */ jsx(Text, { style: [headingStyle(t.fonts), { color: t.colors.text }], children: step.label }),
1003
+ /* @__PURE__ */ jsx(
1004
+ TextInput,
1005
+ {
1006
+ defaultValue: (_b = state.drafts[step.key]) != null ? _b : "",
1007
+ onChangeText: handleChangeText,
1008
+ placeholder: step.placeholder,
1009
+ placeholderTextColor: t.colors.textMuted,
1010
+ accessibilityLabel: step.label,
1011
+ multiline: true,
1012
+ textAlignVertical: "top",
1013
+ style: [
1014
+ bodyStyle(t.fonts),
1015
+ styles7.input,
1016
+ {
1017
+ color: t.colors.text,
1018
+ backgroundColor: t.colors.background,
1019
+ borderRadius: t.radius.md,
1020
+ borderColor: t.colors.border,
1021
+ padding: t.spacing.md,
1022
+ minHeight: answerBox.minHeight,
1023
+ maxHeight: answerBox.maxHeight
1024
+ }
1025
+ ]
1026
+ },
1027
+ step.key
1028
+ ),
1029
+ /* @__PURE__ */ jsx(
1030
+ Button,
1031
+ {
1032
+ title: last ? "Send" : "Next",
1033
+ onPress: handleNext,
1034
+ variant: "primary",
1035
+ fullWidth: true,
1036
+ disabled: sendDisabled
1037
+ }
1038
+ ),
1039
+ canSkip && /* @__PURE__ */ jsx(
1040
+ Pressable,
1041
+ {
1042
+ onPress: handleSkip,
1043
+ hitSlop: 8,
1044
+ accessibilityRole: "button",
1045
+ accessibilityLabel: "Skip this question",
1046
+ children: /* @__PURE__ */ jsx(Text, { style: linkStyle, children: "Skip step" })
1047
+ }
1048
+ )
1049
+ ] }) : null;
1050
+ const thanksPane = /* @__PURE__ */ jsxs(View, { style: { gap: t.spacing.md }, children: [
1051
+ /* @__PURE__ */ jsx(DoneCheck, { theme: t }),
1052
+ /* @__PURE__ */ jsx(Text, { style: [headingStyle(t.fonts), styles7.centerText, { color: t.colors.text }], children: "Thank you" }),
1053
+ /* @__PURE__ */ jsx(Text, { style: [bodyStyle(t.fonts), styles7.centerText, { color: t.colors.textMuted }], children: "This helps us make it better." })
598
1054
  ] });
599
- return /* @__PURE__ */ jsx(CenteredModal, { ref: modalRef, theme: t, dismissible, onDismiss: resolve, children: content });
1055
+ return /* @__PURE__ */ jsx(CenteredModal, { ref: modalRef, theme: t, dismissible, onDismiss: handleDismissed, children: /* @__PURE__ */ jsx(
1056
+ ModalPaneHost,
1057
+ {
1058
+ paneKey: phase === "thanks" ? "thanks" : (_c = step == null ? void 0 : step.key) != null ? _c : "done",
1059
+ direction: state.direction,
1060
+ header,
1061
+ children: phase === "thanks" ? thanksPane : formPane
1062
+ }
1063
+ ) });
600
1064
  };
601
- var QuestionnaireGate = React4.memo(_QuestionnaireGate);
1065
+ var QuestionnaireGate = React8.memo(_QuestionnaireGate);
602
1066
  QuestionnaireGate.displayName = "QuestionnaireGate";
603
- var styles3 = StyleSheet.create({
1067
+ var styles7 = StyleSheet.create({
604
1068
  input: { borderWidth: 1 },
605
- link: { textAlign: "center", textDecorationLine: "underline" }
1069
+ link: { textAlign: "center", textDecorationLine: "underline" },
1070
+ centerText: { textAlign: "center" },
1071
+ header: { flexDirection: "row", alignItems: "center" },
1072
+ // Equal flex on both sides keeps the dots optically centred whether Back is drawn or not.
1073
+ // `flex-start` matters: without it the back Pressable stretches across the whole left
1074
+ // third of the card, so a tap anywhere near the dots would navigate back.
1075
+ headerSide: { flex: 1, alignItems: "flex-start" },
1076
+ backGlyph: { includeFontPadding: false }
606
1077
  });
607
1078
 
608
1079
  // src/coachmarks/runtime.ts