@cxpinsight/react-native 0.18.4 → 0.19.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.
@@ -239,7 +239,7 @@ function swipeRelease(g) {
239
239
  function surfaceScrolls(interactionKind) {
240
240
  return String(interactionKind || '').toLowerCase() !== 'notify';
241
241
  }
242
- function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
242
+ function SurveyModal({ survey, visible, onSubmit, onDismiss, reply = null }) {
243
243
  const [currentPage, setCurrentPage] = (0, react_2.useState)(0);
244
244
  const [responses, setResponses] = (0, react_2.useState)({});
245
245
  const [showThankYou, setShowThankYou] = (0, react_2.useState)(false);
@@ -609,7 +609,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
609
609
  // server rejection (survey deactivated, cap hit) surfaces a retry
610
610
  // message instead of a false thank-you.
611
611
  const submitAndReset = async () => {
612
- const ok = await onSubmit(buildSubmitPayload());
612
+ const ok = await onSubmit(buildSubmitPayload(), { via: viaRef.current });
613
613
  if (ok === false) {
614
614
  setSubmitError('We couldn’t submit your response. Please try again.');
615
615
  return; // keep the survey open for a retry
@@ -621,6 +621,15 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
621
621
  if (String(survey.interactionType || '')
622
622
  .toLowerCase() === 'notify')
623
623
  return;
624
+ // AND WHEN THE AUTHOR TURNED THE ENDING OFF.
625
+ //
626
+ // showsConfirmation gated the OUTCOME panel below and not this state, so
627
+ // switching the ending off did not remove the ending — it fell through to
628
+ // the generic "Thank you" instead, which is a worse result than leaving it
629
+ // alone: the author lost the reward panel and gained a line they never
630
+ // wrote. Skipped here, so nothing draws and the 2.5s window never opens.
631
+ if (!(0, survey_spec_1.showsConfirmation)(survey))
632
+ return;
624
633
  setShowThankYou(true);
625
634
  const duration = survey.settings?.thankYouDuration ?? 2500;
626
635
  setTimeout(() => {
@@ -750,7 +759,19 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
750
759
  * the interaction at all, because unlike web the card here was never
751
760
  * pressable in the first place. Same defect, one step worse.
752
761
  */
753
- const pressImplied = (0, react_2.useCallback)((key, a) => {
762
+ // WHICH SURFACE WAS PRESSED — 'button' or 'card'.
763
+ //
764
+ // Both routes end in pressImplied, so by the time the answer is built the
765
+ // two are the same choice and nothing downstream can tell them apart. Web
766
+ // has carried this since bf0c4b55 (metadata.via); mobile gained whole-card
767
+ // press without it, so on the platform where nearly all the traffic is, the
768
+ // builder's "Clicked the card" outcome could never be populated.
769
+ //
770
+ // A ref, not state: it is read during the submit that the press itself
771
+ // triggers, and a re-render in between would be a wasted frame.
772
+ const viaRef = (0, react_2.useRef)('button');
773
+ const pressImplied = (0, react_2.useCallback)((key, a, via = 'button') => {
774
+ viaRef.current = via;
754
775
  // "Remind me later" dismisses instead of answering, so the cadence records
755
776
  // a SKIP and brings the interaction back after the author's wait.
756
777
  // Answering would record a decision this person explicitly declined to
@@ -858,22 +879,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
858
879
  // MultipleChoiceQuestion accepts. hasOther appends a synthetic
859
880
  // "Other" entry — full free-text follow-up UX lands in Phase 4.5.
860
881
  function normalizeChoices(question) {
861
- const out = [];
862
- if (Array.isArray(question.options)) {
863
- for (const o of question.options) {
864
- if (!o)
865
- continue;
866
- out.push({ value: String(o.value ?? ''), text: String(o.label ?? o.text ?? o.value ?? '') });
867
- }
868
- }
869
- else if (Array.isArray(question.choices)) {
870
- for (const c of question.choices) {
871
- if (typeof c === 'string')
872
- out.push({ value: c, text: c });
873
- else if (c)
874
- out.push({ value: String(c.value ?? ''), text: String(c.label ?? c.text ?? c.value ?? '') });
875
- }
876
- }
882
+ // WHICHEVER FIELD HAS SOMETHING IN IT — see questionChoices.
883
+ //
884
+ // This asked `Array.isArray(options)` first and took that branch. The
885
+ // server's schema declares `options`, so Mongoose defaults it to `[]` on
886
+ // every question: a question authored with `choices` arrived carrying an
887
+ // empty `options` beside it, this took the empty branch, and the phone
888
+ // drew a choice question with no choices at all. The web preview read
889
+ // `choices` first and was fine, so it looked like a mobile rendering bug
890
+ // and was a field-precedence one.
891
+ const out = (0, survey_spec_1.questionChoices)(question);
877
892
  if (question.hasOther) {
878
893
  out.push({ value: '__other__', text: question.otherLabel || 'Other' });
879
894
  }
@@ -1074,7 +1089,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1074
1089
  // The press is both the answer and the Next — the footer is
1075
1090
  // hidden on this step. Its body is pressImplied, so the CARD
1076
1091
  // can do exactly the same thing when this button is not drawn.
1077
- onPress: () => pressImplied(key, a), accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
1092
+ onPress: () => pressImplied(key, a, 'button'), accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
1078
1093
  styles.impliedBtn,
1079
1094
  // BESIDE THE TEXT, THE BUTTON IS AS WIDE AS ITS LABEL.
1080
1095
  //
@@ -1371,7 +1386,6 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1371
1386
  const fs = baseScale !== 1;
1372
1387
  const qTitleFS = fs ? { fontSize: scaled(15) } : undefined;
1373
1388
  const qDescFS = fs ? { fontSize: scaled(13) } : undefined;
1374
- const thankYouFS = fs ? { fontSize: scaled(18) } : undefined;
1375
1389
  const buttonFS = fs ? { fontSize: scaled(15) } : undefined;
1376
1390
  // Layout tokens (spec §24). String presets map to concrete pixel
1377
1391
  // values; a number passes through verbatim so authors can set
@@ -1750,10 +1764,12 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1750
1764
  * The alternative was a Fade control that worked in the preview and did
1751
1765
  * nothing here, which is the thing this codebase keeps having to undo.
1752
1766
  */
1753
- const fadeSteps = (towards) => {
1754
- if (!imgD.fade)
1767
+ // `fade` is a parameter now, because there are two pictures asking: the
1768
+ // card's and the confirmation's, each with its own slider.
1769
+ const fadeSteps = (towards, fade = imgD.fade) => {
1770
+ if (!fade)
1755
1771
  return null;
1756
- const span = Math.max(2, Math.min(96, imgD.fade)) / 100;
1772
+ const span = Math.max(2, Math.min(96, fade)) / 100;
1757
1773
  const vertical = towards === 'top' || towards === 'bottom';
1758
1774
  // A REAL GRADIENT WHERE THE APP HAS ONE. react-native-svg is an optional
1759
1775
  // peer, already loaded this way by the signature question, and it draws
@@ -1879,6 +1895,96 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
1879
1895
  const halfPadR = contract.cardPaddingRight ?? contract.cardPadding;
1880
1896
  const halfOuter = imgD.side === 'right' ? halfPadR : halfPadL;
1881
1897
  const halfWidth = Math.round(((cardWidth - halfPadL - halfPadR) * imgD.split) / 100) + halfOuter;
1898
+ /**
1899
+ * THE ENDING'S OWN PICTURE, which is not the card's.
1900
+ *
1901
+ * An offer's card sells the thing; its ending confirms it, and those want
1902
+ * different images far more often than the same one. It stands IN FOR the
1903
+ * ✓ glyph rather than sitting above it — a tick over a photograph is two
1904
+ * things saying "done", and the author chose the photograph.
1905
+ */
1906
+ // The card's height, as it was just before the ending replaced it — a floor
1907
+ // for the ending, never a cap, so a taller ending can still grow.
1908
+ const cardHeightRef = (0, react_2.useRef)(0);
1909
+ // A FLOOR AND A FILL, MINUS THE CARD'S OWN PADDING.
1910
+ //
1911
+ // The floor alone left the panel at its natural height inside a taller card,
1912
+ // so a picture told to stretch beside the words took the confirmation's few
1913
+ // lines rather than the card's height. And the floor was the CARD's height
1914
+ // — the outer view, padding included — applied to a panel that sits inside
1915
+ // that padding, which asks the card to grow by twice its own inset every
1916
+ // time the ending appears. The web measures the CONTENT; this is the same
1917
+ // number, arrived at by subtraction because the card is what onLayout sees.
1918
+ const endPadTop = contract.cardPaddingTop ?? contract.cardPadding ?? 0;
1919
+ const endPadBottom = contract.cardPaddingBottom ?? contract.cardPadding ?? 0;
1920
+ const endingHold = cardHeightRef.current > 0
1921
+ ? {
1922
+ minHeight: Math.max(0, cardHeightRef.current - endPadTop - endPadBottom),
1923
+ flexGrow: 1,
1924
+ }
1925
+ : null;
1926
+ // THE CONFIRMATION'S OWN DESIGN, inheriting the card's until it has one —
1927
+ // see confirmationDesign in the spec. It used to read `contract.imageHeight`
1928
+ // and `contract.imageRadius` directly, which tied the icon on this screen to
1929
+ // the photograph on the card: shrinking one shrank the other.
1930
+ // WHERE THE CARD'S WORDS SIT. `titleAlign` was already a survey field; the
1931
+ // other two are new and follow it, because a centred headline over a
1932
+ // left-ragged paragraph is the commonest way a card looks accidental.
1933
+ const cardTitleAlign = (0, survey_spec_1.alignOf)(survey.titleAlign, 'left');
1934
+ const cardDescAlign = (0, survey_spec_1.alignOf)(survey.descriptionAlign, cardTitleAlign);
1935
+ const cardQuestionAlign = (0, survey_spec_1.alignOf)(survey.questionAlign, 'left');
1936
+ const confirmD = (0, survey_spec_1.confirmationDesign)(survey, 'mobile');
1937
+ const endingImgSrc = confirmD.image.src;
1938
+ const endingAlign = confirmD.heading.align === 'start' ? 'flex-start'
1939
+ : confirmD.heading.align === 'end' ? 'flex-end' : 'center';
1940
+ const endingTextAlign = confirmD.heading.align === 'start' ? 'left'
1941
+ : confirmD.heading.align === 'end' ? 'right' : 'center';
1942
+ // LINE HEIGHT MOVES WITH THE SIZE. The stylesheet pins 26 and 20, so a
1943
+ // heading taken to 34 would have been clipped by its own leading.
1944
+ // scaled() FIRST, so the accessibility font scale still multiplies an
1945
+ // authored size — it used to scale a hard-coded 18 and the authored number
1946
+ // would have replaced it outright.
1947
+ const endingHeadSize = scaled(confirmD.heading.size);
1948
+ const endingSubSize = scaled(confirmD.body.size);
1949
+ const endingBodyAlign = confirmD.body.align === 'start' ? 'left'
1950
+ : confirmD.body.align === 'end' ? 'right' : 'center';
1951
+ const endingHeadFS = {
1952
+ fontSize: endingHeadSize,
1953
+ lineHeight: Math.round(endingHeadSize * 1.45),
1954
+ textAlign: endingTextAlign,
1955
+ };
1956
+ const endingSubFS = {
1957
+ fontSize: endingSubSize,
1958
+ lineHeight: Math.round(endingSubSize * confirmD.body.lineHeight),
1959
+ textAlign: endingBodyAlign,
1960
+ };
1961
+ // THE CARD'S WHOLE IMAGE VOCABULARY, on the second screen. It was a band and
1962
+ // nothing else — no Fade, no Dim, no Alignment, no Layout — because the
1963
+ // screen was built as a stack and the design was a hand-picked five fields
1964
+ // of the card's. confirmationDesign now answers with a full ImageDesign, so
1965
+ // there is one set of layouts and both screens draw from it.
1966
+ const cI = confirmD.image;
1967
+ const endingLayout = endingImgSrc ? cI.layout : 'none';
1968
+ const endingSelf = cI.align === 'start' ? 'flex-start'
1969
+ : cI.align === 'end' ? 'flex-end' : 'center';
1970
+ const endingScrim = cI.scrim ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0,
1971
+ backgroundColor: contract.card, opacity: cI.scrim / 100 } })) : null;
1972
+ const endingPicture = (fill) => ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: endingImgSrc }, style: [{ width: '100%', height: '100%' }, fill], resizeMode: cI.fit, accessibilityIgnoresInvertColors: true }));
1973
+ /** The band — the picture as a strip, at its own width and alignment. */
1974
+ const endingImage = (endingLayout === 'above' || endingLayout === 'below') ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: {
1975
+ position: 'relative', overflow: 'hidden',
1976
+ width: `${cI.width}%`, height: cI.height, borderRadius: cI.radius,
1977
+ alignSelf: endingSelf, marginBottom: endingLayout === 'above' ? 16 : 0,
1978
+ marginTop: endingLayout === 'below' ? 16 : 0,
1979
+ }, children: [endingPicture({}), fadeSteps(endingLayout === 'below' ? 'top' : 'bottom', cI.fade), endingScrim] })) : null;
1980
+ // THE SECOND PICTURE, between the card's and the headline. Square, so one
1981
+ // number sizes it — and it replaces the ✓ rather than sitting under it.
1982
+ const endingIcon = confirmD.icon.src ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: confirmD.icon.src }, style: {
1983
+ width: confirmD.icon.size,
1984
+ height: confirmD.icon.size,
1985
+ borderRadius: confirmD.icon.radius,
1986
+ marginBottom: 16,
1987
+ }, resizeMode: confirmD.icon.fit, accessibilityIgnoresInvertColors: true })) : null;
1882
1988
  const cardImageHalf = imgLayout === 'half' ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: {
1883
1989
  position: 'relative',
1884
1990
  overflow: 'hidden',
@@ -2227,7 +2333,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2227
2333
  // uses, which is what keeps a deliberate flick from also counting as a tap.
2228
2334
  if (!st.moved) {
2229
2335
  if (cardTakesPress && cardImpliedList.length) {
2230
- pressImplied(cardImpliedKey, cardImpliedList[0]);
2336
+ pressImplied(cardImpliedKey, cardImpliedList[0], 'card');
2231
2337
  }
2232
2338
  return;
2233
2339
  }
@@ -2273,11 +2379,14 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2273
2379
  const last = qs[qs.length - 1];
2274
2380
  const id = last?.name ?? last?.id ?? '';
2275
2381
  const action = id ? (responses[id] ?? null) : null;
2276
- return (0, survey_spec_1.outcomeFor)({
2382
+ // The SCREEN, not the outcome the host hears: an accepted offer with no
2383
+ // code is 'fulfil' to the host and the author's confirmation here — the
2384
+ // web renderer asks the same question.
2385
+ return (0, survey_spec_1.endingScreenFor)((0, survey_spec_1.outcomeFor)({
2277
2386
  interactionType: sv.interactionType, action,
2278
2387
  affirmative: String(action) === String(acts[0].value),
2279
2388
  reward: sv.reward, compliance: sv.compliance,
2280
- });
2389
+ }), sv);
2281
2390
  }, [survey, responses]);
2282
2391
  const dismissMs = (0, survey_spec_1.autoDismissMs)(display, survey.delivery);
2283
2392
  const engagedRef = (0, react_2.useRef)(false);
@@ -2527,7 +2636,28 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2527
2636
  // The OVERLAY, not the card — a screen-shaped image with the card in
2528
2637
  // its real position, rather than a card-shaped one the replay player
2529
2638
  // would stretch across the whole device frame.
2530
- ref: (v) => { cardViewRef.current = v; }, pointerEvents: isPopupOverlay ? 'box-none' : 'auto', style: [
2639
+ ref: (v) => { cardViewRef.current = v; },
2640
+ // WITHOUT THIS THE VIEW MAY NOT EXIST NATIVELY, AND THE CAPTURE FAILS.
2641
+ //
2642
+ // React Native collapses views that have no native side effects. For a
2643
+ // POPUP this overlay is exactly that: backgroundColor transparent and
2644
+ // pointerEvents box-none, a container that only positions its child. So
2645
+ // it was optimised away, findNodeHandle had nothing to resolve,
2646
+ // captureRef rejected, captureView returned null, and the recorder fell
2647
+ // back to the host captureScreen — which on iOS returns the TOPMOST
2648
+ // UIWindow.
2649
+ //
2650
+ // That is why every offer and notification frame came back as whatever
2651
+ // native alert happened to be up, floating on flat grey, with the card
2652
+ // nowhere in it. Measured: three survey_shown frames at 73,548 / 73,512
2653
+ // / 73,528 bytes — the same wrong image each time — in a session where
2654
+ // the respondent demonstrably swiped one card away, closed another and
2655
+ // completed a third.
2656
+ //
2657
+ // It fails for popups specifically BECAUSE popups are the transparent
2658
+ // ones; a modal paints a scrim and survives collapsing, which is why
2659
+ // this looked like "offers are broken" rather than "capture is".
2660
+ collapsable: false, pointerEvents: isPopupOverlay ? 'box-none' : 'auto', style: [
2531
2661
  styles.overlay,
2532
2662
  // Transparent for a popup — the app behind it is not being taken over.
2533
2663
  isPopupOverlay && { backgroundColor: 'transparent' },
@@ -2552,10 +2682,19 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2552
2682
  // Feeds the authored-coordinate maths above. Only tracked when a
2553
2683
  // coordinate is actually in play, and only on a real change, so the
2554
2684
  // common case costs nothing and this cannot loop.
2555
- onLayout: hasPopupY ? (e) => {
2685
+ // MEASURED ALWAYS, not only for a coordinate. The second reader is
2686
+ // the confirmation: it replaces the card's whole content, and the
2687
+ // two screens hold different amounts — so the card shrank under a
2688
+ // respondent who had just pressed a button on it, which reads as a
2689
+ // different card arriving rather than the same one answering. The
2690
+ // last height measured BEFORE the ending becomes its floor.
2691
+ onLayout: (e) => {
2556
2692
  const h = Math.round(e.nativeEvent.layout.height);
2557
- setMeasuredCardH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
2558
- } : undefined, style: [
2693
+ if (!showThankYou && h > 0)
2694
+ cardHeightRef.current = h;
2695
+ if (hasPopupY)
2696
+ setMeasuredCardH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
2697
+ }, style: [
2559
2698
  styles.card,
2560
2699
  themeCard,
2561
2700
  enterStyle,
@@ -2575,34 +2714,96 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2575
2714
  ], children: [cardImageBack, closeShown && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt, style: [
2576
2715
  styles.closeButton,
2577
2716
  isRtl ? { left: contract.closeInset } : { right: contract.closeInset },
2578
- ], onPress: () => handleDismiss('close'), activeOpacity: 0.7, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.closeGlyph, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '45deg' }] }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '-45deg' }] }] })] }) })), showThankYou && endingKind !== 'thanks' && (0, survey_spec_1.showsConfirmation)(survey) ? (() => {
2579
- const rw = (0, survey_spec_1.rewardToShow)(survey);
2717
+ ], onPress: () => handleDismiss('close'), activeOpacity: 0.7, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.closeGlyph, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '45deg' }] }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '-45deg' }] }] })] }) })), showThankYou && endingKind !== 'thanks' && endingKind !== 'fulfil' && (0, survey_spec_1.showsConfirmation)(survey) ? (() => {
2580
2718
  const comp = survey.compliance;
2581
- if (endingKind === 'fulfil' && rw) {
2582
- const exp = rw.expiresAt ? new Date(rw.expiresAt) : null;
2583
- 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: (0, survey_spec_1.confirmationText)(survey, 'It’s yours') }), (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()] }))] }));
2584
- }
2585
2719
  if (endingKind === 'hold') {
2586
- 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
2720
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.thankYou, endingHold, { alignItems: endingAlign }], accessibilityLiveRegion: "polite", children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary, endingHeadFS], children: comp?.reviewRequired ? 'Under review' : 'Not completed' }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary, endingSubFS], children: comp?.blockedMessage
2587
2721
  ?? (comp?.reviewRequired
2588
2722
  ? 'We’re checking your details. You’ll be able to continue once that’s done.'
2589
2723
  : 'You need to accept this before you can continue.') })] }));
2590
2724
  }
2591
2725
  if (endingKind === 'unblock') {
2592
- 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." })] }));
2726
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.thankYou, endingHold, { alignItems: endingAlign }], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary, endingHeadFS], children: "Recorded" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary, endingSubFS], children: "You can carry on." })] }));
2593
2727
  }
2594
- 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.' }) }));
2728
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.thankYou, endingHold, { alignItems: endingAlign }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary, endingSubFS], children: endingKind === 'navigate' ? 'Taking you there…' : 'Closed.' }) }));
2729
+ })() : (showThankYou && (reply === 'pending' || reply === 'failed' || reply === 'later')) ? (() => {
2730
+ // WAITING FOR THE APP, or what came of it — the author's words for
2731
+ // each state (appReply), in the confirmation's own header style.
2732
+ const ar = (0, survey_spec_1.appReply)(survey);
2733
+ const words = reply === 'pending' ? ar.pending : reply === 'failed' ? ar.failed : ar.later;
2734
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.thankYou, endingHold, { alignItems: endingAlign }], accessibilityLiveRegion: reply === 'failed' ? 'assertive' : 'polite', children: [reply === 'pending' && (0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { color: primaryColor, style: { marginBottom: 12 } }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, dirText, fontStyle, themeTextPrimary, endingHeadFS], children: (0, interpolate_1.interpolatePlain)(words, evalCtx) })] }));
2595
2735
  })() : showThankYou ? (() => {
2596
2736
  // The thank-you message can be rich HTML from the web builder.
2597
2737
  // Pull out an embedded <img> (often a data: URI icon) and render
2598
2738
  // it as a real Image — stripHtml would otherwise drop it — then
2599
2739
  // show the remaining text. When an image is present it stands in
2600
2740
  // for the default ✓ glyph.
2741
+ // THE CONFIRMATION'S COMPONENTS, under its headline and body — the
2742
+ // schema's list, derived for anything made before v1.2 (a code
2743
+ // offer gets its code). Same rule the web renderer asks.
2744
+ const endParts = (0, survey_spec_1.confirmationQuestions)(survey);
2745
+ const endReward = (0, survey_spec_1.rewardToShow)(survey);
2746
+ const hasCode = !!endReward && endReward.type === 'code'
2747
+ && endParts.some((q) => String(q.type).toLowerCase() === 'reward');
2601
2748
  const rawThankYou = (0, interpolate_1.interpolatePlain)((0, translationLookup_1.surveyText)(survey, currentLanguage, 'thankYouMessage', survey.settings?.thankYouMessage)
2602
- ?? 'Thank you for your feedback!', evalCtx);
2749
+ ?? (0, survey_spec_1.confirmationDefaults)(survey, hasCode).title, evalCtx);
2750
+ const partViews = endParts.map((q) => {
2751
+ const t = String(q.type).toLowerCase();
2752
+ if (t === 'reward') {
2753
+ if (!hasCode || !endReward)
2754
+ return null;
2755
+ const exp = endReward.expiresAt ? new Date(endReward.expiresAt) : null;
2756
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: { marginTop: 14, alignItems: endingAlign }, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { selectable: true, style: [styles.rewardCode, fontStyle, themeTextPrimary], children: endReward.value }), !!endReward.whereValid && ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, dirText, fontStyle, themeTextSecondary, endingSubFS], children: endReward.whereValid })), !!exp && ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.thankYouSub, dirText, fontStyle, themeTextSecondary, endingSubFS], children: ["Expires ", exp.toLocaleString()] }))] }, q.id));
2757
+ }
2758
+ const words = stripHtml((0, interpolate_1.interpolatePlain)(String(q.html ?? q.text ?? ''), evalCtx));
2759
+ return words ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, dirText, fontStyle, themeTextPrimary, endingSubFS, { marginTop: 12 }], children: words }, q.id)) : null;
2760
+ });
2603
2761
  const imgSrc = firstImageSrc(rawThankYou);
2604
2762
  const thankYouText = stripHtml(rawThankYou);
2605
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [imgSrc ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: imgSrc }, style: styles.thankYouImage, resizeMode: "contain" })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouEmoji, { color: primaryColor }], children: "\u2713" })), thankYouText ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, dirText, fontStyle, themeTextPrimary, thankYouFS], children: thankYouText })) : null] }));
2763
+ // THE WORDS, AS ONE BLOCK — so a layout can put them beside the
2764
+ // picture, after it, or on top of it without three copies. The ✓
2765
+ // stands in only when NEITHER picture is set; it used to stand in
2766
+ // for the first alone, so a badge added to a card that already had
2767
+ // a photograph drew the photograph and swallowed the badge.
2768
+ const endWords = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [endingIcon || ((endingImgSrc || imgSrc || hasCode) ? null : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouEmoji, { color: primaryColor }], children: "\u2713" }))), thankYouText ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, dirText, fontStyle, themeTextPrimary, endingHeadFS], children: thankYouText })) : null, (() => {
2769
+ const sub = (0, survey_spec_1.confirmationSubtext)(survey, (0, survey_spec_1.confirmationDefaults)(survey, hasCode).body);
2770
+ return sub ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, dirText, fontStyle, themeTextSecondary, endingSubFS], children: (0, interpolate_1.interpolatePlain)(sub, evalCtx) })) : null;
2771
+ })(), partViews] }));
2772
+ // THE CARD'S OWN PADDING, undone on the picture's side only. The
2773
+ // panel's 48 used to sit INSIDE the card's, so the same picture at
2774
+ // the same Height drew smaller here than on the card.
2775
+ const endPad = 0;
2776
+ if (endingLayout === 'beside') {
2777
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.thankYou, endingHold, {
2778
+ alignItems: 'stretch', flexDirection: 'row', gap: 12,
2779
+ }], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: {
2780
+ position: 'relative', overflow: 'hidden', alignSelf: endingSelf,
2781
+ width: cI.size, height: cI.size, borderRadius: cI.radius,
2782
+ }, children: [endingPicture({}), endingScrim] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flex: 1, minWidth: 0, alignItems: endingAlign, justifyContent: 'center' }, children: endWords })] }));
2783
+ }
2784
+ if (endingLayout === 'half') {
2785
+ const vert = cI.side === 'top';
2786
+ // Negative margins undo the panel's own padding on the picture's
2787
+ // side only — the words keep theirs, which is what "bleeds to
2788
+ // the edges" means on the card too.
2789
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.thankYou, endingHold, {
2790
+ alignItems: 'stretch',
2791
+ flexDirection: vert ? 'column' : (cI.side === 'right' ? 'row-reverse' : 'row'),
2792
+ gap: vert ? 0 : 14,
2793
+ }], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: {
2794
+ position: 'relative', overflow: 'hidden',
2795
+ ...(vert
2796
+ ? { marginTop: -endPad, marginLeft: -endPad, marginRight: -endPad,
2797
+ marginBottom: 16, height: cI.height }
2798
+ : { marginTop: -endPad, marginBottom: -endPad,
2799
+ [cI.side === 'right' ? 'marginRight' : 'marginLeft']: -endPad,
2800
+ width: `${cI.split}%` }),
2801
+ }, children: [endingPicture({}), fadeSteps(vert ? 'bottom' : (cI.side === 'right' ? 'left' : 'right'), cI.fade), endingScrim] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flex: 1, minWidth: 0, alignItems: endingAlign, justifyContent: 'center' }, children: endWords })] }));
2802
+ }
2803
+ if (endingLayout === 'background') {
2804
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.thankYou, endingHold, { alignItems: endingAlign }], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { pointerEvents: "none", style: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, overflow: 'hidden' }, children: [endingPicture({}), endingScrim] }), endWords] }));
2805
+ }
2806
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.thankYou, endingHold, { alignItems: endingAlign }], children: [endingLayout === 'above' ? endingImage : null, (!endingImgSrc && imgSrc) ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: imgSrc }, style: styles.thankYouImage, resizeMode: "contain" })) : null, endWords, endingLayout === 'below' ? endingImage : null] }));
2606
2807
  })() : ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: halfSide
2607
2808
  ? { flexDirection: imgD.side === 'right' ? 'row-reverse' : 'row',
2608
2809
  alignItems: 'stretch' }
@@ -2656,9 +2857,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2656
2857
  : imgD.align === 'end' ? 'flex-end' : 'center',
2657
2858
  }, resizeMode: imgD.fit })) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: (besideImage || notifyBeside) ? { flex: 1, minWidth: 0 } : undefined, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.title, dirText, fontStyle, themeTextPrimary, titleFS, notifyTitleFS,
2658
2859
  closeReserve,
2659
- survey.titleAlign
2660
- ? { textAlign: survey.titleAlign }
2661
- : null], children: (0, interpolate_1.interpolatePlain)(
2860
+ { textAlign: cardTitleAlign }], children: (0, interpolate_1.interpolatePlain)(
2662
2861
  // Title is plain text; strip any stray rich-text markup +
2663
2862
  // decode &nbsp; so it never shows literal HTML on native.
2664
2863
  String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
@@ -2672,6 +2871,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2672
2871
  const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
2673
2872
  const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim();
2674
2873
  return desc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.description, dirText, fontStyle, themeTextSecondary, descFS, notifyDescFS,
2874
+ { textAlign: cardDescAlign },
2675
2875
  // Takes the width the button leaves, and no more.
2676
2876
  notifyBeside && { flex: 1, minWidth: 0, marginTop: 2 }], children: (0, interpolate_1.interpolatePlain)(desc, evalCtx) })) : null;
2677
2877
  })(), notifyActionQ ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flexShrink: 0, marginLeft: 12 }, children: renderQuestion(notifyActionQ) })) : null] })] }), deadlineEl, (() => {
@@ -2727,7 +2927,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2727
2927
  // have. Web spends 12px above the action and nothing
2728
2928
  // below it (`.cxp-notify-action-below`); this matches.
2729
2929
  isNotifyBanner && { marginTop: 12, marginBottom: 0 },
2730
- ], children: [!(0, survey_spec_1.showsQuestionLabel)(q) ? null : ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS],
2930
+ ], children: [!(0, survey_spec_1.showsQuestionLabel)(q) ? null : ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS, { textAlign: cardQuestionAlign }],
2731
2931
  // The asterisk below is decoration: a screen reader
2732
2932
  // reads it as "star", or skips it, so a required
2733
2933
  // question sounded exactly like an optional one until
@@ -2736,7 +2936,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2736
2936
  if (isImplied)
2737
2937
  return null;
2738
2938
  const qDesc = (0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'description', q.description);
2739
- return qDesc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.questionDesc, dirText, fontStyle, themeTextMuted, qDescFS], children: (0, interpolate_1.interpolatePlain)(qDesc, evalCtx) })) : null;
2939
+ return qDesc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.questionDesc, dirText, fontStyle, themeTextMuted, qDescFS, { textAlign: cardQuestionAlign }], children: (0, interpolate_1.interpolatePlain)(qDesc, evalCtx) })) : null;
2740
2940
  })(), renderQuestion(q), errors[rowKey] ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.errorText, dirText, fontStyle],
2741
2941
  // Announced when it appears, and reachable afterwards:
2742
2942
  // a message that is only spoken once leaves someone who
@@ -2766,7 +2966,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
2766
2966
  // Right-align parity with web: Back hugs the left edge while
2767
2967
  // Next/Submit sits on the right (web uses margin-right:auto).
2768
2968
  buttonAlign === 'right' && { marginRight: 'auto' },
2769
- ], 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 : (() => {
2969
+ ], 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 || !(0, survey_spec_1.drawsOwnSubmit)(survey.interactionType) ? null : (() => {
2770
2970
  // Reflect the actual outcome of pressing Next: if a
2771
2971
  // branch resolves to __end__ we're submitting even
2772
2972
  // when we're not on the last page. Cheap to evaluate
@@ -3139,7 +3339,12 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
3139
3339
  fontWeight: '700',
3140
3340
  },
3141
3341
  thankYou: {
3142
- padding: 48,
3342
+ // NO PADDING OF ITS OWN. The card already pads its content; a second inset
3343
+ // here made the confirmation's box narrower and shorter than the card's,
3344
+ // so the same picture at the same Width and Height drew smaller on the
3345
+ // second screen with no control to explain it. The components space
3346
+ // themselves with their own margins, exactly as they do on the card.
3347
+ paddingVertical: 8,
3143
3348
  alignItems: 'center',
3144
3349
  justifyContent: 'center',
3145
3350
  },
@@ -3149,8 +3354,9 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
3149
3354
  marginBottom: 16,
3150
3355
  },
3151
3356
  thankYouImage: {
3152
- width: 72,
3153
- height: 72,
3357
+ // FULL WIDTH, and the height comes from the contract at the render site.
3358
+ // 72x72 was a glyph's footprint, not a picture's.
3359
+ width: '100%',
3154
3360
  marginBottom: 16,
3155
3361
  },
3156
3362
  thankYouTitle: {
@@ -3161,6 +3367,15 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
3161
3367
  lineHeight: 26,
3162
3368
  },
3163
3369
  thankYouSub: {
3370
+ // A COLOUR OF ITS OWN, because the override beside it is LIGHT-ONLY.
3371
+ //
3372
+ // themeTextSecondary is `isLight ? {...} : undefined`, on the assumption
3373
+ // that every style it decorates already carries the DARK colour. This one
3374
+ // carried none, so on a dark card it fell through to React Native's
3375
+ // default Text colour — black — and the confirmation's sub-line was dark
3376
+ // text on a dark background. The title above it was fine because it had
3377
+ // `c.headingColor`; that difference is the whole bug.
3378
+ color: c.textSecondary,
3164
3379
  fontSize: 14,
3165
3380
  textAlign: 'center',
3166
3381
  lineHeight: 20,
@@ -3170,6 +3385,8 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
3170
3385
  // button here because RN's clipboard is an optional peer, and a code you can
3171
3386
  // select is better than a button that may not exist.
3172
3387
  rewardCode: {
3388
+ // Same fault, and worse: this is the discount code itself.
3389
+ color: c.textPrimary,
3173
3390
  fontSize: 22,
3174
3391
  fontWeight: '700',
3175
3392
  letterSpacing: 1.5,