@cxpinsight/react-native 0.9.0 → 0.10.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.
@@ -242,13 +242,22 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
242
242
  // defaults to "visible". Fail-open on parse errors — see
243
243
  // expressionEval.ts.
244
244
  const isVisible = (q) => {
245
- if (q.showWhen && !(0, expressionEval_1.evaluateCondition)(q.showWhen, evalCtx))
245
+ // Prefer the tree the server compiled; fall back to parsing the string.
246
+ // Guarded inside evaluateConditionCompiled, because an app on
247
+ // survey-spec@0.4.0 has no tree walker at all.
248
+ const qc = q;
249
+ if (q.showWhen && !(0, expressionEval_1.evaluateConditionCompiled)(q.showWhen, qc.showWhenCompiled, evalCtx))
246
250
  return false;
247
- if (q.hideWhen && (0, expressionEval_1.evaluateCondition)(q.hideWhen, evalCtx))
251
+ if (q.hideWhen && (0, expressionEval_1.evaluateConditionCompiled)(q.hideWhen, qc.hideWhenCompiled, evalCtx))
248
252
  return false;
249
253
  return true;
250
254
  };
251
255
  const currentQuestions = (pages[currentPage]?.elements ?? []).filter(isVisible);
256
+ // A step ending in an implied question has no Next/Submit — its action
257
+ // buttons are the answer, and pressing one commits. Shared with the web SDK
258
+ // and the React renderer so a Notify/Offer/Comply template behaves the same
259
+ // on a phone as it does on a website. Suppresses the footer below.
260
+ const impliedNow = (0, survey_spec_1.impliedStepActions)(currentQuestions);
252
261
  const updateResponse = (0, react_1.useCallback)((name, value) => {
253
262
  setResponses((prev) => ({ ...prev, [name]: value }));
254
263
  // Live-clear the error entry the moment the new answer passes
@@ -276,7 +285,8 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
276
285
  if (!Array.isArray(q.branches) || q.branches.length === 0)
277
286
  continue;
278
287
  for (const b of q.branches) {
279
- if (b.when && !(0, expressionEval_1.evaluateCondition)(b.when, evalCtx))
288
+ const bc = b;
289
+ if (b.when && !(0, expressionEval_1.evaluateConditionCompiled)(b.when, bc.whenCompiled, evalCtx))
280
290
  continue;
281
291
  if (b?.target === 'flow') {
282
292
  if (!b.flowId)
@@ -383,6 +393,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
383
393
  setForkStack([]);
384
394
  }, duration);
385
395
  };
396
+ // Kept fresh on every render so an implied action's deferred press runs the
397
+ // current closure rather than the one captured when the button mounted.
398
+ const handleNextRef = react_1.default.useRef(() => { });
386
399
  const handleNext = () => {
387
400
  const pageErrors = validateCurrentPage();
388
401
  if (Object.keys(pageErrors).length > 0) {
@@ -438,6 +451,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
438
451
  setHistory((h) => [...h, { pageIndex: currentPage, flow: currentFlow }]);
439
452
  setCurrentPage(linearNext);
440
453
  };
454
+ handleNextRef.current = handleNext;
441
455
  const handleBack = () => {
442
456
  // Non-linear navigation: pop the actual history stack, not just
443
457
  // currentPage - 1 (which would skip over a jump). Any errors
@@ -463,6 +477,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
463
477
  setCurrentPage((p) => p - 1);
464
478
  }
465
479
  };
480
+ // Kept fresh so the memoized pan responder never dismisses through a stale
481
+ // closure — the same reason handleNextRef exists.
482
+ const handleDismissRef = (0, react_1.useRef)(() => { });
466
483
  const handleDismiss = () => {
467
484
  setCurrentPage(0);
468
485
  setResponses({});
@@ -474,6 +491,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
474
491
  setForkStack([]);
475
492
  onDismiss();
476
493
  };
494
+ handleDismissRef.current = handleDismiss;
477
495
  // Normalize the v1.1 options[] {value,label} and the legacy
478
496
  // choices[] (string | {value,text}) into a single shape that
479
497
  // MultipleChoiceQuestion accepts. hasOther appends a synthetic
@@ -500,7 +518,30 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
500
518
  }
501
519
  return out;
502
520
  }
503
- function renderQuestion(question) {
521
+ /**
522
+ * Compiled props, under the names the existing renderers already read.
523
+ *
524
+ * The renderers were written against the author's question shape
525
+ * (minValue/maxValue, options); the server's compiler emits the contract's
526
+ * names (min/max). Translating onto a COPY avoids touching thirty-nine
527
+ * renderers, and author fields win — the compiler derives csat's 1..5 because
528
+ * the author stated none, and must never override a range they did state.
529
+ */
530
+ function applyRenderProps(q) {
531
+ const r = q.render;
532
+ if (!r || !r.props)
533
+ return q;
534
+ const out = { ...q };
535
+ const MAP = { min: 'minValue', max: 'maxValue' };
536
+ for (const [k, v] of Object.entries(r.props)) {
537
+ const target = MAP[k] || k;
538
+ if (out[target] === undefined || out[target] === null)
539
+ out[target] = v;
540
+ }
541
+ return out;
542
+ }
543
+ function renderQuestion(rawQuestion) {
544
+ const question = applyRenderProps(rawQuestion);
504
545
  const key = question.id || question.name;
505
546
  const val = responses[key];
506
547
  // Shown when a question type needs an optional peer the host app did not
@@ -650,15 +691,45 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
650
691
  // labels come from the shared spec so "Activate discount" reads the same
651
692
  // here as on a website and on a link.
652
693
  'implied': () => {
653
- const actions = (0, survey_spec_1.impliedActions)(question);
694
+ // Same resolver the thank-you text uses, so `myapp://cart/{{id}}`
695
+ // opens the customer's cart rather than a literal deep link nothing
696
+ // can handle.
697
+ const actions = (0, survey_spec_1.impliedActions)(question, (t) => (0, interpolate_1.interpolatePlain)(t, evalCtx));
654
698
  const list = actions.length ? actions : [{ value: 'acknowledged', label: 'Continue' }];
655
- return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.impliedActions, children: list.map((a, i) => ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: () => updateResponse(key, a.value), accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
699
+ // Consent choices render identically — see impliedEqualProminence.
700
+ const equal = (0, survey_spec_1.impliedEqualProminence)([question]);
701
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.impliedActions, children: list.map((a, i) => ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, {
702
+ // The press is both the answer and the Next — the footer is
703
+ // hidden on this step. Deferred a tick so setResponses has
704
+ // flushed before handleNext validates what it just replaced.
705
+ onPress: () => {
706
+ // "Remind me later" dismisses instead of answering, so the
707
+ // cadence records a SKIP and brings the interaction back
708
+ // after the author's wait. Answering would record a decision
709
+ // this person explicitly declined to make and retire it
710
+ // against maxSubmissions. It does not follow the action's
711
+ // link either — postponing is not a request to go anywhere.
712
+ if (a.defer) {
713
+ handleDismissRef.current();
714
+ return;
715
+ }
716
+ updateResponse(key, a.value);
717
+ setTimeout(() => handleNextRef.current(), 0);
718
+ // Follow the action's deep link. `myapp://cart` is the point
719
+ // of this on a phone; Linking refuses a scheme the OS has no
720
+ // handler for, so a bad target fails quietly rather than
721
+ // crashing the survey. The href already passed
722
+ // safeActionHref in the shared spec.
723
+ if (a.href) {
724
+ react_native_1.Linking.openURL(a.href).catch(() => { });
725
+ }
726
+ }, accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
656
727
  styles.impliedBtn,
657
728
  // The first action is the affirmative one. An author who
658
729
  // lists "Decline" first gets it emphasised — their call.
659
- i === 0 ? styles.impliedPrimary : styles.impliedSecondary,
730
+ (i === 0 && !equal) ? styles.impliedPrimary : styles.impliedSecondary,
660
731
  val === a.value && styles.impliedChosen,
661
- ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: i === 0 ? styles.impliedPrimaryLabel : styles.impliedSecondaryLabel, children: a.label }) }, a.value || String(i)))) }));
732
+ ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: (i === 0 && !equal) ? styles.impliedPrimaryLabel : styles.impliedSecondaryLabel, children: a.label }) }, a.value || String(i)))) }));
662
733
  },
663
734
  'signature': () => {
664
735
  if (!(0, SignatureQuestion_1.isSignatureAvailable)())
@@ -677,6 +748,37 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
677
748
  // still runs. Server-side validation will reject any junk.
678
749
  return ((0, jsx_runtime_1.jsx)(TextQuestion_1.TextQuestion, { value: val ?? '', onChange: (v) => updateResponse(key, v), placeholder: question.placeholder, contract: contract }));
679
750
  };
751
+ // ── Primitive dispatch ────────────────────────────────────────────
752
+ //
753
+ // The server compiles a question type to one of eight primitives plus a
754
+ // presentation hint. Dispatching on THAT means a question type this build
755
+ // has never heard of still renders — which matters more here than on the
756
+ // web, where a new type arrives with the next SDK fetch. On mobile it would
757
+ // otherwise need an App Store release and every respondent updating.
758
+ //
759
+ // The markup is the existing markup: this maps a primitive onto the
760
+ // renderer that already draws it. Anything without an entry — slider, and
761
+ // the grid/group/display/capture families — falls through to the type
762
+ // table exactly as before.
763
+ const PRIMITIVES = {
764
+ 'choice_one:list': 'radio',
765
+ 'choice_one:dropdown': 'dropdown',
766
+ 'choice_one:toggle': 'binary',
767
+ 'choice_one:images': 'image_picker',
768
+ 'choice_one:scale': 'nps',
769
+ 'choice_one:stars': 'rating',
770
+ 'choice_one:actions': 'implied',
771
+ 'choice_many:list': 'checkbox',
772
+ 'choice_many:rank': 'ranking',
773
+ 'text:single': 'text',
774
+ 'text:multi': 'comment',
775
+ };
776
+ const render = question.render;
777
+ const primName = render && render.primitive
778
+ ? PRIMITIVES[`${render.primitive}:${render.presentation || ''}`]
779
+ : undefined;
780
+ if (primName && RENDERERS[primName])
781
+ return RENDERERS[primName]();
680
782
  const _fn = RENDERERS[String(question.type || '')] || renderUnsupported;
681
783
  return _fn();
682
784
  }
@@ -1154,6 +1256,75 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1154
1256
  useNativeDriver: true,
1155
1257
  }).start();
1156
1258
  }, [visible, needsTopSlide, enterAnim]);
1259
+ // ── Swipe to dismiss ────────────────────────────────────────────────
1260
+ //
1261
+ // A push notification goes away when you flick it. The mobile display slice
1262
+ // drops the ✕ for exactly that reason, so this is the affordance that
1263
+ // replaces it — without it the banner would simply be stuck on screen.
1264
+ //
1265
+ // canSwipeToDismiss requires a popup and vetoes `mustShow`, which is what
1266
+ // keeps a compliance takeover — showClose:false for the OPPOSITE reason —
1267
+ // from ever becoming flickable.
1268
+ const swipeable = (0, survey_spec_1.canSwipeToDismiss)(display, survey.delivery);
1269
+ const swipeXY = (0, react_1.useRef)(new react_native_1.Animated.ValueXY({ x: 0, y: 0 })).current;
1270
+ const swipeResponder = (0, react_1.useMemo)(() => react_native_1.PanResponder.create({
1271
+ onStartShouldSetPanResponder: () => false,
1272
+ // Claim the gesture only once it is clearly a drag, so a tap on the action
1273
+ // button still presses the button.
1274
+ onMoveShouldSetPanResponder: (_e, g) => swipeable && (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8),
1275
+ onPanResponderMove: (_e, g) => {
1276
+ const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
1277
+ // A top banner travels up, never down — dragging it down is a pull.
1278
+ const dy = placement === 'top' ? Math.min(0, g.dy) : placement === 'bottom' ? Math.max(0, g.dy) : g.dy;
1279
+ swipeXY.setValue(horizontal ? { x: g.dx, y: 0 } : { x: 0, y: dy });
1280
+ },
1281
+ onPanResponderRelease: (_e, g) => {
1282
+ const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
1283
+ const moved = horizontal ? g.dx : g.dy;
1284
+ const fast = Math.abs(horizontal ? g.vx : g.vy) > 0.5;
1285
+ if (Math.abs(moved) >= 56 || fast) {
1286
+ const to = moved < 0 ? -SCREEN_HEIGHT : SCREEN_HEIGHT;
1287
+ react_native_1.Animated.timing(swipeXY, {
1288
+ toValue: horizontal ? { x: moved < 0 ? -SCREEN_WIDTH : SCREEN_WIDTH, y: 0 } : { x: 0, y: to },
1289
+ duration: 180, useNativeDriver: true,
1290
+ }).start(() => { swipeXY.setValue({ x: 0, y: 0 }); handleDismissRef.current(); });
1291
+ return;
1292
+ }
1293
+ react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
1294
+ },
1295
+ }), [swipeable, placement, swipeXY]);
1296
+ // Auto-dismiss. Same rule as web (autoDismissMs vetoes fullscreen and
1297
+ // mustShow, clamps to 2s). Cancelled for good once the respondent touches
1298
+ // anything — a timer that fires after someone has started answering would
1299
+ // pull the survey out from under them.
1300
+ // Which ending this interaction gets — see the render block below.
1301
+ const endingKind = (0, react_1.useMemo)(() => {
1302
+ const sv = survey;
1303
+ const qs = (survey.rawQuestions ?? []);
1304
+ const acts = (0, survey_spec_1.impliedStepActions)(qs) || [];
1305
+ if (!acts.length)
1306
+ return 'thanks';
1307
+ const last = qs[qs.length - 1];
1308
+ const id = last?.name ?? last?.id ?? '';
1309
+ const action = id ? (responses[id] ?? null) : null;
1310
+ return (0, survey_spec_1.outcomeFor)({
1311
+ interactionType: sv.interactionType, action,
1312
+ affirmative: String(action) === String(acts[0].value),
1313
+ reward: sv.reward, compliance: sv.compliance,
1314
+ });
1315
+ }, [survey, responses]);
1316
+ const dismissMs = (0, survey_spec_1.autoDismissMs)(display, survey.delivery);
1317
+ const engagedRef = (0, react_1.useRef)(false);
1318
+ (0, react_1.useEffect)(() => {
1319
+ if (!visible || !dismissMs)
1320
+ return;
1321
+ engagedRef.current = false;
1322
+ const t = setTimeout(() => {
1323
+ if (!engagedRef.current)
1324
+ handleDismissRef.current();
1325
+ }, dismissMs);
1326
+ return () => clearTimeout(t);
1327
+ }, [visible, dismissMs]);
1157
1328
  const enterStyle = needsTopSlide
1158
1329
  ? {
1159
1330
  opacity: enterAnim,
@@ -1309,10 +1480,11 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1309
1480
  // A sheet sits flush against its edge, so the overlay loses its gutter.
1310
1481
  isSheet && { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 },
1311
1482
  isFullscreen && styles.overlayFullscreen,
1312
- ], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View, { style: [
1483
+ ], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View, { onTouchStart: () => { engagedRef.current = true; }, ...(swipeable ? swipeResponder.panHandlers : {}), style: [
1313
1484
  styles.card,
1314
1485
  themeCard,
1315
1486
  enterStyle,
1487
+ swipeable && { transform: swipeXY.getTranslateTransform() },
1316
1488
  maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH - 40, maxWidth), maxWidth },
1317
1489
  cornerRadius !== undefined && !isFullscreen && { borderRadius: cornerRadius },
1318
1490
  // cardFullscreen fills; an explicit content height/width below OVERRIDES
@@ -1325,7 +1497,24 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1325
1497
  ], children: [display?.showClose !== false && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [
1326
1498
  styles.closeButton,
1327
1499
  isRtl ? { left: 12 } : { right: 12 },
1328
- ], onPress: handleDismiss, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.closeText, themeTextSecondary], children: "\u2715" }) })), showThankYou ? (() => {
1500
+ ], onPress: handleDismiss, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.closeText, themeTextSecondary], children: "\u2715" }) })), showThankYou && endingKind !== 'thanks' ? (() => {
1501
+ const rw = (0, survey_spec_1.rewardToShow)(survey);
1502
+ const comp = survey.compliance;
1503
+ if (endingKind === 'fulfil' && rw) {
1504
+ const exp = rw.expiresAt ? new Date(rw.expiresAt) : null;
1505
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: "It\u2019s yours" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: "Use this at checkout." }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { selectable: true, style: [styles.rewardCode, fontStyle, themeTextPrimary], children: rw.value }), !!rw.whereValid && ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: rw.whereValid })), !!exp && ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: ["Expires ", exp.toLocaleString()] }))] }));
1506
+ }
1507
+ if (endingKind === 'hold') {
1508
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, accessibilityLiveRegion: "polite", children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: comp?.reviewRequired ? 'Under review' : 'Not completed' }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: comp?.blockedMessage
1509
+ ?? (comp?.reviewRequired
1510
+ ? 'We’re checking your details. You’ll be able to continue once that’s done.'
1511
+ : 'You need to accept this before you can continue.') })] }));
1512
+ }
1513
+ if (endingKind === 'unblock') {
1514
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: "Recorded" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: "You can carry on." })] }));
1515
+ }
1516
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.thankYou, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: endingKind === 'navigate' ? 'Taking you there…' : 'Closed.' }) }));
1517
+ })() : showThankYou ? (() => {
1329
1518
  // The thank-you message can be rich HTML from the web builder.
1330
1519
  // Pull out an embedded <img> (often a data: URI icon) and render
1331
1520
  // it as a real Image — stripHtml would otherwise drop it — then
@@ -1408,7 +1597,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1408
1597
  // Right-align parity with web: Back hugs the left edge while
1409
1598
  // Next/Submit sits on the right (web uses margin-right:auto).
1410
1599
  buttonAlign === 'right' && { marginRight: 'auto' },
1411
- ], onPress: handleBack, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.backButtonText, fontStyle, themeTextSecondary, buttonFS], children: "Back" }) })), (() => {
1600
+ ], onPress: handleBack, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.backButtonText, fontStyle, themeTextSecondary, buttonFS], children: "Back" }) })), impliedNow.length > 0 ? null : (() => {
1412
1601
  // Reflect the actual outcome of pressing Next: if a
1413
1602
  // branch resolves to __end__ we're submitting even
1414
1603
  // when we're not on the last page. Cheap to evaluate
@@ -1719,5 +1908,22 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
1719
1908
  textAlign: 'center',
1720
1909
  lineHeight: 26,
1721
1910
  },
1911
+ thankYouSub: {
1912
+ fontSize: 14,
1913
+ textAlign: 'center',
1914
+ lineHeight: 20,
1915
+ marginTop: 6,
1916
+ },
1917
+ // Selectable so the code can be copied by long-press — there is no clipboard
1918
+ // button here because RN's clipboard is an optional peer, and a code you can
1919
+ // select is better than a button that may not exist.
1920
+ rewardCode: {
1921
+ fontSize: 22,
1922
+ fontWeight: '700',
1923
+ letterSpacing: 1.5,
1924
+ textAlign: 'center',
1925
+ marginTop: 14,
1926
+ marginBottom: 10,
1927
+ },
1722
1928
  });
1723
1929
  //# sourceMappingURL=SurveyModal.js.map