@cxpinsight/react-native 0.2.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 (253) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/babel.js +101 -0
  4. package/instrumented/Pressable.d.ts +2 -0
  5. package/instrumented/Pressable.js +2 -0
  6. package/instrumented/ScrollView.d.ts +2 -0
  7. package/instrumented/ScrollView.js +2 -0
  8. package/instrumented/TextInput.d.ts +2 -0
  9. package/instrumented/TextInput.js +2 -0
  10. package/instrumented/TouchableOpacity.d.ts +2 -0
  11. package/instrumented/TouchableOpacity.js +2 -0
  12. package/lib/CXPinsight.d.ts +113 -0
  13. package/lib/CXPinsight.d.ts.map +1 -0
  14. package/lib/CXPinsight.js +831 -0
  15. package/lib/CXPinsight.js.map +1 -0
  16. package/lib/CXPinsightProvider.d.ts +19 -0
  17. package/lib/CXPinsightProvider.d.ts.map +1 -0
  18. package/lib/CXPinsightProvider.js +382 -0
  19. package/lib/CXPinsightProvider.js.map +1 -0
  20. package/lib/core/autoScreenTracker.d.ts +20 -0
  21. package/lib/core/autoScreenTracker.d.ts.map +1 -0
  22. package/lib/core/autoScreenTracker.js +66 -0
  23. package/lib/core/autoScreenTracker.js.map +1 -0
  24. package/lib/core/deepLinkTracker.d.ts +17 -0
  25. package/lib/core/deepLinkTracker.d.ts.map +1 -0
  26. package/lib/core/deepLinkTracker.js +81 -0
  27. package/lib/core/deepLinkTracker.js.map +1 -0
  28. package/lib/core/deviceInfo.d.ts +14 -0
  29. package/lib/core/deviceInfo.d.ts.map +1 -0
  30. package/lib/core/deviceInfo.js +166 -0
  31. package/lib/core/deviceInfo.js.map +1 -0
  32. package/lib/core/eventQueue.d.ts +23 -0
  33. package/lib/core/eventQueue.d.ts.map +1 -0
  34. package/lib/core/eventQueue.js +240 -0
  35. package/lib/core/eventQueue.js.map +1 -0
  36. package/lib/core/lifecycle.d.ts +18 -0
  37. package/lib/core/lifecycle.d.ts.map +1 -0
  38. package/lib/core/lifecycle.js +127 -0
  39. package/lib/core/lifecycle.js.map +1 -0
  40. package/lib/core/mobileRecorder.d.ts +15 -0
  41. package/lib/core/mobileRecorder.d.ts.map +1 -0
  42. package/lib/core/mobileRecorder.js +231 -0
  43. package/lib/core/mobileRecorder.js.map +1 -0
  44. package/lib/core/network.d.ts +10 -0
  45. package/lib/core/network.d.ts.map +1 -0
  46. package/lib/core/network.js +74 -0
  47. package/lib/core/network.js.map +1 -0
  48. package/lib/core/networkCapture.d.ts +22 -0
  49. package/lib/core/networkCapture.d.ts.map +1 -0
  50. package/lib/core/networkCapture.js +100 -0
  51. package/lib/core/networkCapture.js.map +1 -0
  52. package/lib/core/scrollCapture.d.ts +48 -0
  53. package/lib/core/scrollCapture.d.ts.map +1 -0
  54. package/lib/core/scrollCapture.js +221 -0
  55. package/lib/core/scrollCapture.js.map +1 -0
  56. package/lib/core/session.d.ts +15 -0
  57. package/lib/core/session.d.ts.map +1 -0
  58. package/lib/core/session.js +157 -0
  59. package/lib/core/session.js.map +1 -0
  60. package/lib/core/storage.d.ts +11 -0
  61. package/lib/core/storage.d.ts.map +1 -0
  62. package/lib/core/storage.js +59 -0
  63. package/lib/core/storage.js.map +1 -0
  64. package/lib/core/triggerEngine.d.ts +56 -0
  65. package/lib/core/triggerEngine.d.ts.map +1 -0
  66. package/lib/core/triggerEngine.js +432 -0
  67. package/lib/core/triggerEngine.js.map +1 -0
  68. package/lib/index.d.ts +7 -0
  69. package/lib/index.d.ts.map +1 -0
  70. package/lib/index.js +11 -0
  71. package/lib/index.js.map +1 -0
  72. package/lib/instrumented/Pressable.d.ts +9 -0
  73. package/lib/instrumented/Pressable.d.ts.map +1 -0
  74. package/lib/instrumented/Pressable.js +96 -0
  75. package/lib/instrumented/Pressable.js.map +1 -0
  76. package/lib/instrumented/ScrollView.d.ts +6 -0
  77. package/lib/instrumented/ScrollView.d.ts.map +1 -0
  78. package/lib/instrumented/ScrollView.js +101 -0
  79. package/lib/instrumented/ScrollView.js.map +1 -0
  80. package/lib/instrumented/TextInput.d.ts +9 -0
  81. package/lib/instrumented/TextInput.d.ts.map +1 -0
  82. package/lib/instrumented/TextInput.js +75 -0
  83. package/lib/instrumented/TextInput.js.map +1 -0
  84. package/lib/instrumented/TouchableOpacity.d.ts +11 -0
  85. package/lib/instrumented/TouchableOpacity.d.ts.map +1 -0
  86. package/lib/instrumented/TouchableOpacity.js +96 -0
  87. package/lib/instrumented/TouchableOpacity.js.map +1 -0
  88. package/lib/instrumented/index.d.ts +19 -0
  89. package/lib/instrumented/index.d.ts.map +1 -0
  90. package/lib/instrumented/index.js +28 -0
  91. package/lib/instrumented/index.js.map +1 -0
  92. package/lib/instrumented/interactionObserver.d.ts +29 -0
  93. package/lib/instrumented/interactionObserver.d.ts.map +1 -0
  94. package/lib/instrumented/interactionObserver.js +38 -0
  95. package/lib/instrumented/interactionObserver.js.map +1 -0
  96. package/lib/setup/AccessibilityScanner.d.ts +32 -0
  97. package/lib/setup/AccessibilityScanner.d.ts.map +1 -0
  98. package/lib/setup/AccessibilityScanner.js +127 -0
  99. package/lib/setup/AccessibilityScanner.js.map +1 -0
  100. package/lib/setup/ElementIdentifier.d.ts +65 -0
  101. package/lib/setup/ElementIdentifier.d.ts.map +1 -0
  102. package/lib/setup/ElementIdentifier.js +114 -0
  103. package/lib/setup/ElementIdentifier.js.map +1 -0
  104. package/lib/setup/ElementRegistry.d.ts +22 -0
  105. package/lib/setup/ElementRegistry.d.ts.map +1 -0
  106. package/lib/setup/ElementRegistry.js +47 -0
  107. package/lib/setup/ElementRegistry.js.map +1 -0
  108. package/lib/setup/SetupCodeEntry.d.ts +15 -0
  109. package/lib/setup/SetupCodeEntry.d.ts.map +1 -0
  110. package/lib/setup/SetupCodeEntry.js +198 -0
  111. package/lib/setup/SetupCodeEntry.js.map +1 -0
  112. package/lib/setup/SetupController.d.ts +29 -0
  113. package/lib/setup/SetupController.d.ts.map +1 -0
  114. package/lib/setup/SetupController.js +189 -0
  115. package/lib/setup/SetupController.js.map +1 -0
  116. package/lib/setup/SetupOverlay.d.ts +10 -0
  117. package/lib/setup/SetupOverlay.d.ts.map +1 -0
  118. package/lib/setup/SetupOverlay.js +408 -0
  119. package/lib/setup/SetupOverlay.js.map +1 -0
  120. package/lib/setup/TapInterceptor.d.ts +19 -0
  121. package/lib/setup/TapInterceptor.d.ts.map +1 -0
  122. package/lib/setup/TapInterceptor.js +98 -0
  123. package/lib/setup/TapInterceptor.js.map +1 -0
  124. package/lib/setup/TouchObserver.d.ts +25 -0
  125. package/lib/setup/TouchObserver.d.ts.map +1 -0
  126. package/lib/setup/TouchObserver.js +88 -0
  127. package/lib/setup/TouchObserver.js.map +1 -0
  128. package/lib/setup/TouchPatcher.d.ts +27 -0
  129. package/lib/setup/TouchPatcher.d.ts.map +1 -0
  130. package/lib/setup/TouchPatcher.js +182 -0
  131. package/lib/setup/TouchPatcher.js.map +1 -0
  132. package/lib/setup/index.d.ts +13 -0
  133. package/lib/setup/index.d.ts.map +1 -0
  134. package/lib/setup/index.js +45 -0
  135. package/lib/setup/index.js.map +1 -0
  136. package/lib/setup/useCXPElement.d.ts +25 -0
  137. package/lib/setup/useCXPElement.d.ts.map +1 -0
  138. package/lib/setup/useCXPElement.js +33 -0
  139. package/lib/setup/useCXPElement.js.map +1 -0
  140. package/lib/survey/SurveyModal.d.ts +10 -0
  141. package/lib/survey/SurveyModal.d.ts.map +1 -0
  142. package/lib/survey/SurveyModal.js +1650 -0
  143. package/lib/survey/SurveyModal.js.map +1 -0
  144. package/lib/survey/expressionEval.d.ts +10 -0
  145. package/lib/survey/expressionEval.d.ts.map +1 -0
  146. package/lib/survey/expressionEval.js +287 -0
  147. package/lib/survey/expressionEval.js.map +1 -0
  148. package/lib/survey/frequencyCap.d.ts +15 -0
  149. package/lib/survey/frequencyCap.d.ts.map +1 -0
  150. package/lib/survey/frequencyCap.js +116 -0
  151. package/lib/survey/frequencyCap.js.map +1 -0
  152. package/lib/survey/handsFreeVoice.d.ts +30 -0
  153. package/lib/survey/handsFreeVoice.d.ts.map +1 -0
  154. package/lib/survey/handsFreeVoice.js +312 -0
  155. package/lib/survey/handsFreeVoice.js.map +1 -0
  156. package/lib/survey/interpolate.d.ts +6 -0
  157. package/lib/survey/interpolate.d.ts.map +1 -0
  158. package/lib/survey/interpolate.js +61 -0
  159. package/lib/survey/interpolate.js.map +1 -0
  160. package/lib/survey/pagesFromV1.d.ts +6 -0
  161. package/lib/survey/pagesFromV1.d.ts.map +1 -0
  162. package/lib/survey/pagesFromV1.js +83 -0
  163. package/lib/survey/pagesFromV1.js.map +1 -0
  164. package/lib/survey/questions/CSATQuestion.d.ts +16 -0
  165. package/lib/survey/questions/CSATQuestion.d.ts.map +1 -0
  166. package/lib/survey/questions/CSATQuestion.js +37 -0
  167. package/lib/survey/questions/CSATQuestion.js.map +1 -0
  168. package/lib/survey/questions/DateQuestion.d.ts +20 -0
  169. package/lib/survey/questions/DateQuestion.d.ts.map +1 -0
  170. package/lib/survey/questions/DateQuestion.js +103 -0
  171. package/lib/survey/questions/DateQuestion.js.map +1 -0
  172. package/lib/survey/questions/ImagePickerQuestion.d.ts +33 -0
  173. package/lib/survey/questions/ImagePickerQuestion.d.ts.map +1 -0
  174. package/lib/survey/questions/ImagePickerQuestion.js +68 -0
  175. package/lib/survey/questions/ImagePickerQuestion.js.map +1 -0
  176. package/lib/survey/questions/ImageQuestion.d.ts +26 -0
  177. package/lib/survey/questions/ImageQuestion.d.ts.map +1 -0
  178. package/lib/survey/questions/ImageQuestion.js +24 -0
  179. package/lib/survey/questions/ImageQuestion.js.map +1 -0
  180. package/lib/survey/questions/MatrixCell.d.ts +45 -0
  181. package/lib/survey/questions/MatrixCell.d.ts.map +1 -0
  182. package/lib/survey/questions/MatrixCell.js +54 -0
  183. package/lib/survey/questions/MatrixCell.js.map +1 -0
  184. package/lib/survey/questions/MatrixDropdownQuestion.d.ts +29 -0
  185. package/lib/survey/questions/MatrixDropdownQuestion.d.ts.map +1 -0
  186. package/lib/survey/questions/MatrixDropdownQuestion.js +42 -0
  187. package/lib/survey/questions/MatrixDropdownQuestion.js.map +1 -0
  188. package/lib/survey/questions/MatrixDynamicQuestion.d.ts +26 -0
  189. package/lib/survey/questions/MatrixDynamicQuestion.d.ts.map +1 -0
  190. package/lib/survey/questions/MatrixDynamicQuestion.js +52 -0
  191. package/lib/survey/questions/MatrixDynamicQuestion.js.map +1 -0
  192. package/lib/survey/questions/MatrixQuestion.d.ts +38 -0
  193. package/lib/survey/questions/MatrixQuestion.d.ts.map +1 -0
  194. package/lib/survey/questions/MatrixQuestion.js +64 -0
  195. package/lib/survey/questions/MatrixQuestion.js.map +1 -0
  196. package/lib/survey/questions/MultiTextQuestion.d.ts +19 -0
  197. package/lib/survey/questions/MultiTextQuestion.d.ts.map +1 -0
  198. package/lib/survey/questions/MultiTextQuestion.js +64 -0
  199. package/lib/survey/questions/MultiTextQuestion.js.map +1 -0
  200. package/lib/survey/questions/MultipleChoiceQuestion.d.ts +25 -0
  201. package/lib/survey/questions/MultipleChoiceQuestion.d.ts.map +1 -0
  202. package/lib/survey/questions/MultipleChoiceQuestion.js +111 -0
  203. package/lib/survey/questions/MultipleChoiceQuestion.js.map +1 -0
  204. package/lib/survey/questions/NPSQuestion.d.ts +18 -0
  205. package/lib/survey/questions/NPSQuestion.d.ts.map +1 -0
  206. package/lib/survey/questions/NPSQuestion.js +62 -0
  207. package/lib/survey/questions/NPSQuestion.js.map +1 -0
  208. package/lib/survey/questions/PanelDynamicQuestion.d.ts +50 -0
  209. package/lib/survey/questions/PanelDynamicQuestion.d.ts.map +1 -0
  210. package/lib/survey/questions/PanelDynamicQuestion.js +62 -0
  211. package/lib/survey/questions/PanelDynamicQuestion.js.map +1 -0
  212. package/lib/survey/questions/RankingQuestion.d.ts +34 -0
  213. package/lib/survey/questions/RankingQuestion.d.ts.map +1 -0
  214. package/lib/survey/questions/RankingQuestion.js +85 -0
  215. package/lib/survey/questions/RankingQuestion.js.map +1 -0
  216. package/lib/survey/questions/RatingQuestion.d.ts +20 -0
  217. package/lib/survey/questions/RatingQuestion.d.ts.map +1 -0
  218. package/lib/survey/questions/RatingQuestion.js +53 -0
  219. package/lib/survey/questions/RatingQuestion.js.map +1 -0
  220. package/lib/survey/questions/TextQuestion.d.ts +17 -0
  221. package/lib/survey/questions/TextQuestion.d.ts.map +1 -0
  222. package/lib/survey/questions/TextQuestion.js +46 -0
  223. package/lib/survey/questions/TextQuestion.js.map +1 -0
  224. package/lib/survey/styleContract.d.ts +230 -0
  225. package/lib/survey/styleContract.d.ts.map +1 -0
  226. package/lib/survey/styleContract.js +365 -0
  227. package/lib/survey/styleContract.js.map +1 -0
  228. package/lib/survey/themePalette.d.ts +56 -0
  229. package/lib/survey/themePalette.d.ts.map +1 -0
  230. package/lib/survey/themePalette.js +63 -0
  231. package/lib/survey/themePalette.js.map +1 -0
  232. package/lib/survey/translationLookup.d.ts +4 -0
  233. package/lib/survey/translationLookup.d.ts.map +1 -0
  234. package/lib/survey/translationLookup.js +62 -0
  235. package/lib/survey/translationLookup.js.map +1 -0
  236. package/lib/survey/validate.d.ts +3 -0
  237. package/lib/survey/validate.d.ts.map +1 -0
  238. package/lib/survey/validate.js +159 -0
  239. package/lib/survey/validate.js.map +1 -0
  240. package/lib/survey/variablesResolver.d.ts +10 -0
  241. package/lib/survey/variablesResolver.d.ts.map +1 -0
  242. package/lib/survey/variablesResolver.js +107 -0
  243. package/lib/survey/variablesResolver.js.map +1 -0
  244. package/lib/survey/voiceParsers.d.ts +47 -0
  245. package/lib/survey/voiceParsers.d.ts.map +1 -0
  246. package/lib/survey/voiceParsers.js +310 -0
  247. package/lib/survey/voiceParsers.js.map +1 -0
  248. package/lib/types.d.ts +349 -0
  249. package/lib/types.d.ts.map +1 -0
  250. package/lib/types.js +20 -0
  251. package/lib/types.js.map +1 -0
  252. package/metro.js +55 -0
  253. package/package.json +78 -0
@@ -0,0 +1,1650 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SurveyModal = SurveyModal;
37
+ const jsx_runtime_1 = require("react/jsx-runtime");
38
+ const react_1 = __importStar(require("react"));
39
+ const react_native_1 = require("react-native");
40
+ const types_1 = require("../types");
41
+ const storage = __importStar(require("../core/storage"));
42
+ const NPSQuestion_1 = require("./questions/NPSQuestion");
43
+ const CSATQuestion_1 = require("./questions/CSATQuestion");
44
+ const TextQuestion_1 = require("./questions/TextQuestion");
45
+ const MultipleChoiceQuestion_1 = require("./questions/MultipleChoiceQuestion");
46
+ const RankingQuestion_1 = require("./questions/RankingQuestion");
47
+ const MatrixQuestion_1 = require("./questions/MatrixQuestion");
48
+ const ImageQuestion_1 = require("./questions/ImageQuestion");
49
+ const ImagePickerQuestion_1 = require("./questions/ImagePickerQuestion");
50
+ const MatrixDropdownQuestion_1 = require("./questions/MatrixDropdownQuestion");
51
+ const MatrixDynamicQuestion_1 = require("./questions/MatrixDynamicQuestion");
52
+ const PanelDynamicQuestion_1 = require("./questions/PanelDynamicQuestion");
53
+ const DateQuestion_1 = require("./questions/DateQuestion");
54
+ const MultiTextQuestion_1 = require("./questions/MultiTextQuestion");
55
+ const expressionEval_1 = require("./expressionEval");
56
+ const interpolate_1 = require("./interpolate");
57
+ const handsFreeVoice_1 = require("./handsFreeVoice");
58
+ const voiceParsers_1 = require("./voiceParsers");
59
+ const variablesResolver_1 = require("./variablesResolver");
60
+ const deviceInfo_1 = require("../core/deviceInfo");
61
+ const translationLookup_1 = require("./translationLookup");
62
+ const validate_1 = require("./validate");
63
+ const pagesFromV1_1 = require("./pagesFromV1");
64
+ const themePalette_1 = require("./themePalette");
65
+ const styleContract_1 = require("./styleContract");
66
+ const CXPinsight_1 = require("../CXPinsight");
67
+ // ISO 639-1 codes for right-to-left scripts. When the respondent picks
68
+ // one of these in the language switcher, the modal flips to RTL even if
69
+ // the survey has no top-level `direction` set.
70
+ const RTL_LANGUAGES = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'ug', 'yi', 'dv']);
71
+ // Author-provided rich text (thank-you message especially) can arrive as
72
+ // HTML from the web builder. React Native <Text> renders tags literally,
73
+ // so collapse block boundaries to newlines, drop remaining tags, and decode
74
+ // the handful of entities that show up in practice.
75
+ // Pull the first <img> src out of an HTML string (supports data: URIs, which
76
+ // React Native's <Image> renders natively). Returns undefined if none.
77
+ function firstImageSrc(html) {
78
+ const m = /<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/i.exec(String(html));
79
+ return m ? m[1] : undefined;
80
+ }
81
+ function stripHtml(input) {
82
+ return String(input)
83
+ .replace(/<\s*(br|\/p|\/div|\/h[1-6]|\/li)\s*\/?>/gi, '\n')
84
+ .replace(/<[^>]+>/g, '')
85
+ .replace(/&nbsp;/gi, ' ')
86
+ .replace(/&amp;/gi, '&')
87
+ .replace(/&lt;/gi, '<')
88
+ .replace(/&gt;/gi, '>')
89
+ .replace(/&quot;/gi, '"')
90
+ .replace(/&#39;/gi, "'")
91
+ .replace(/\n{3,}/g, '\n\n')
92
+ .trim();
93
+ }
94
+ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
95
+ const [currentPage, setCurrentPage] = (0, react_1.useState)(0);
96
+ const [responses, setResponses] = (0, react_1.useState)({});
97
+ const [showThankYou, setShowThankYou] = (0, react_1.useState)(false);
98
+ // Back-navigation history. Non-linear jumps via branches mean the
99
+ // previous page is NOT always currentPage - 1; the stack records
100
+ // the pages the respondent actually walked through. Matches the
101
+ // web renderer's history stack (see SurveyRenderer.tsx).
102
+ // v1.1 multi-flow (spec §28): also records the flow the respondent
103
+ // was on at that point — Back after a fork restores BOTH pieces of
104
+ // state.
105
+ const [history, setHistory] = (0, react_1.useState)([]);
106
+ // Multi-flow state (spec §28). currentFlow tracks which flow the
107
+ // respondent is walking through right now. undefined = root flow.
108
+ // When a fork branch fires we push the origin onto forkStack and
109
+ // switch currentFlow to the branch's target flowId.
110
+ const [currentFlow, setCurrentFlow] = (0, react_1.useState)(undefined);
111
+ const [forkStack, setForkStack] = (0, react_1.useState)([]);
112
+ // Per-question validation errors surfaced on Next / Submit.
113
+ // Populated when a required question has no answer; cleared when
114
+ // the respondent supplies one. Keyed by question id/name so a
115
+ // page with multiple required questions can flag several at once.
116
+ // Web renderer keeps a `touched` set to live-re-validate after the
117
+ // first flag; mobile MVP just clears on updateResponse for now.
118
+ const [errors, setErrors] = (0, react_1.useState)({});
119
+ // Set when the server rejects a submit (returns false). Shown near the
120
+ // footer; cleared on retry / navigation / dismiss.
121
+ const [submitError, setSubmitError] = (0, react_1.useState)('');
122
+ // Hands-free voice state. voiceIdxRef walks the current page's questions;
123
+ // voiceDoneRef flips once the last page has been submitted by voice.
124
+ const voiceIdxRef = react_1.default.useRef(0);
125
+ const voiceDoneRef = react_1.default.useRef(false);
126
+ const [voiceActive, setVoiceActive] = (0, react_1.useState)(false);
127
+ const [voicePhase, setVoicePhase] = (0, react_1.useState)('idle');
128
+ const [voiceTranscript, setVoiceTranscript] = (0, react_1.useState)('');
129
+ // Selected language for the current session. Initialized to
130
+ // survey.defaultLanguage; the useEffect below hydrates from
131
+ // AsyncStorage on mount so a repeat respondent keeps their
132
+ // previous pick. Only restored if that language is still in the
133
+ // current survey's languages[] — an older stored value that's no
134
+ // longer offered silently falls back to defaultLanguage.
135
+ const [currentLanguage, setCurrentLanguageRaw] = (0, react_1.useState)(survey.defaultLanguage);
136
+ const setCurrentLanguage = (0, react_1.useCallback)((next) => {
137
+ setCurrentLanguageRaw(next);
138
+ // Fire-and-forget — persistence is a "nice to have", never
139
+ // block the switcher UX on a slow storage write.
140
+ storage.set(types_1.STORAGE_KEYS.SURVEY_LANGUAGE, next).catch(() => { });
141
+ }, []);
142
+ (0, react_1.useEffect)(() => {
143
+ let cancelled = false;
144
+ // Device locale → the survey's matching offered language, when the respondent
145
+ // has no saved pick — so an Arabic device opens the survey in Arabic instead of
146
+ // defaultLanguage. A saved manual switch still wins over the device locale.
147
+ let deviceLang;
148
+ try {
149
+ const raw = (0, deviceInfo_1.getDeviceInfo)();
150
+ const primary = (typeof raw.language === 'string' ? raw.language : '').split(/[-_]/)[0].toLowerCase();
151
+ deviceLang = primary
152
+ ? survey.languages?.find((l) => l.split(/[-_]/)[0].toLowerCase() === primary)
153
+ : undefined;
154
+ }
155
+ catch { /* no locale available → stays defaultLanguage */ }
156
+ storage.get(types_1.STORAGE_KEYS.SURVEY_LANGUAGE).then((saved) => {
157
+ if (cancelled)
158
+ return;
159
+ if (saved && (!survey.languages || survey.languages.includes(saved))) {
160
+ setCurrentLanguageRaw(saved);
161
+ return;
162
+ }
163
+ if (deviceLang)
164
+ setCurrentLanguageRaw(deviceLang); // else stays defaultLanguage (initial state)
165
+ }).catch(() => { if (!cancelled && deviceLang)
166
+ setCurrentLanguageRaw(deviceLang); });
167
+ return () => { cancelled = true; };
168
+ // survey.id is stable per survey mount; re-hydrate if a new survey
169
+ // presents (which is a fresh modal instance anyway, but belt-and-braces).
170
+ }, [survey.id, survey.languages]);
171
+ // Multi-flow paging (spec §28). When rawQuestions/rawSections are
172
+ // available (Provider populates them alongside the derived `pages`
173
+ // field), re-paginate per-flow whenever currentFlow changes.
174
+ // Otherwise fall back to the caller-supplied `pages` — kept as a
175
+ // backwards-compat path for embeds that construct SurveyDefinition
176
+ // manually.
177
+ const presentationMode = typeof survey.settings?.presentationMode === 'string'
178
+ ? survey.settings.presentationMode
179
+ : undefined;
180
+ const pages = (0, react_1.useMemo)(() => {
181
+ if (survey.rawQuestions) {
182
+ return (0, pagesFromV1_1.pagesFromV1)({ questions: survey.rawQuestions, sections: survey.rawSections }, currentFlow, presentationMode);
183
+ }
184
+ return survey.pages ?? [];
185
+ }, [survey.rawQuestions, survey.rawSections, survey.pages, currentFlow, presentationMode]);
186
+ const totalPages = pages.length;
187
+ const isLastPage = currentPage >= totalPages - 1;
188
+ // Variables resolver (spec §22.2). Merges SDK identify() data with
189
+ // survey-defined variable[].defaultValue + calculated expressions;
190
+ // recomputes on every render because calculated variables can
191
+ // reference answers. sdkIdentifyData is the mobile analogue of
192
+ // web's url params — the only user-supplied source path on mobile.
193
+ // P4 — Attribute Context: the SDK's captured signals auto-seed as default
194
+ // variables ({{user.plan}}, {{device.os}}, {{device.language}}). Stable per
195
+ // modal instance; geo.* is server-side and not available on-device here.
196
+ //
197
+ // Signals augmented beyond the wire deviceInfo:
198
+ // • theme — OS light/dark from Appearance.getColorScheme() (parity
199
+ // with the web widget's `prefers-color-scheme`)
200
+ // • language — primary tag (e.g. 'en' from 'en-US') so authors
201
+ // don't have to match against the full BCP-47 string
202
+ // • languageTag — the full tag, kept for cases that need the region
203
+ // • screenClass — 'mobile' | 'tablet' | 'desktop' (from expo-device
204
+ // deviceType), for authors keying on layout tier
205
+ // A change listener re-mounts the attribute context on OS theme toggles
206
+ // so mid-session `{{device.theme}}` conditions stay accurate.
207
+ const [colorScheme, setColorScheme] = (0, react_1.useState)(() => react_native_1.Appearance.getColorScheme() ?? undefined);
208
+ (0, react_1.useEffect)(() => {
209
+ const sub = react_native_1.Appearance.addChangeListener((prefs) => {
210
+ setColorScheme(prefs.colorScheme ?? undefined);
211
+ });
212
+ return () => { sub?.remove?.(); };
213
+ }, []);
214
+ const attributeContext = (0, react_1.useMemo)(() => {
215
+ const identity = (CXPinsight_1.CXPinsight._getUserIdentity() ?? {});
216
+ const { id: _omitId, ...userTraits } = identity;
217
+ const raw = (0, deviceInfo_1.getDeviceInfo)();
218
+ const langTag = typeof raw.language === 'string' ? raw.language : 'en';
219
+ const langPrimary = langTag.split(/[-_]/)[0].toLowerCase();
220
+ const screenClass = raw.deviceType || 'mobile';
221
+ const deviceSignals = {
222
+ ...raw,
223
+ language: langPrimary, // override wire language with primary tag
224
+ languageTag: langTag, // full BCP-47 tag under a distinct key
225
+ theme: colorScheme || 'light', // OS light/dark
226
+ screenClass, // 'mobile' | 'tablet' | 'desktop'
227
+ };
228
+ return (0, variablesResolver_1.buildAttributeContext)({
229
+ device: deviceSignals,
230
+ user: userTraits,
231
+ });
232
+ }, [colorScheme]);
233
+ const evalCtx = {
234
+ answers: responses,
235
+ variables: (0, variablesResolver_1.resolveVariables)(survey.variables, { sdkIdentifyData: CXPinsight_1.CXPinsight._getUserIdentity() }, responses, attributeContext),
236
+ };
237
+ // showWhen / hideWhen filter (spec §7). Web renderer semantics:
238
+ // showWhen truthy AND hideWhen falsy → visible. A missing condition
239
+ // defaults to "visible". Fail-open on parse errors — see
240
+ // expressionEval.ts.
241
+ const isVisible = (q) => {
242
+ if (q.showWhen && !(0, expressionEval_1.evaluateCondition)(q.showWhen, evalCtx))
243
+ return false;
244
+ if (q.hideWhen && (0, expressionEval_1.evaluateCondition)(q.hideWhen, evalCtx))
245
+ return false;
246
+ return true;
247
+ };
248
+ const currentQuestions = (pages[currentPage]?.elements ?? []).filter(isVisible);
249
+ const updateResponse = (0, react_1.useCallback)((name, value) => {
250
+ setResponses((prev) => ({ ...prev, [name]: value }));
251
+ // Live-clear the error entry the moment the new answer passes
252
+ // validation. Re-runs the same validator handleNext would fire,
253
+ // scoped to just this question — an email field flagged with
254
+ // "invalid format" clears when the respondent finishes typing
255
+ // a valid address, not on the next Next click. Requires looking
256
+ // up the question, which we do by scanning the flat question
257
+ // list (survey pages already computed above).
258
+ setErrors((prev) => {
259
+ if (!prev[name])
260
+ return prev;
261
+ const q = pages.flatMap((p) => p.elements).find((el) => (el.id || el.name) === name);
262
+ if (!q)
263
+ return prev;
264
+ const err = (0, validate_1.validateAnswer)(q, value);
265
+ if (err)
266
+ return prev; // still invalid → keep the error
267
+ const { [name]: _dropped, ...rest } = prev;
268
+ return rest;
269
+ });
270
+ }, [pages]);
271
+ const resolveNextTarget = () => {
272
+ for (const q of currentQuestions) {
273
+ if (!Array.isArray(q.branches) || q.branches.length === 0)
274
+ continue;
275
+ for (const b of q.branches) {
276
+ if (b.when && !(0, expressionEval_1.evaluateCondition)(b.when, evalCtx))
277
+ continue;
278
+ if (b?.target === 'flow') {
279
+ if (!b.flowId)
280
+ continue;
281
+ return { kind: 'fork', flowId: b.flowId, rejoinAt: b.rejoinAt };
282
+ }
283
+ if (!b.goto)
284
+ continue;
285
+ if (b.goto === '__end__')
286
+ return { kind: 'end' };
287
+ // Find the page containing the goto question in the CURRENT
288
+ // flow's page list. Uses raw page contents (not the visible-
289
+ // filtered ones) so a jump works even when the target
290
+ // question happens to be hidden on the source page's
291
+ // visibility state — the target page re-runs visibility on
292
+ // render.
293
+ for (let i = 0; i < pages.length; i++) {
294
+ // A goto whose target sits on the CURRENT page isn't a real
295
+ // navigation — it can't "jump" to a question already on screen.
296
+ // This matters because mobile paginates by section: a VOC
297
+ // template's intra-section branch (e.g. rating → its own
298
+ // follow-up comment) points at a question on the same page, so
299
+ // returning {page: currentPage} would trap the respondent here
300
+ // forever ("Next does nothing"). Skip it and fall through to
301
+ // linear next. (Web paginates by pageBreak, so its goto targets
302
+ // land on other pages and this case never arises there.)
303
+ if (i === currentPage)
304
+ continue;
305
+ if (pages[i].elements.some((el) => (el.id || el.name) === b.goto)) {
306
+ return { kind: 'page', index: i };
307
+ }
308
+ }
309
+ }
310
+ }
311
+ return null;
312
+ };
313
+ // Given a question id, find its page index in a set of pages.
314
+ // Returns -1 if not present. Used by rejoinAt to land the
315
+ // respondent back on the page containing the origin question.
316
+ const findPageForQuestion = (searchPages, qId) => {
317
+ for (let i = 0; i < searchPages.length; i++) {
318
+ if (searchPages[i].elements.some((el) => (el.id || el.name) === qId))
319
+ return i;
320
+ }
321
+ return -1;
322
+ };
323
+ // Build the submit payload — only keep answers for questions that
324
+ // are visible under the current evalCtx. A question that the user
325
+ // answered before a later answer hid it via showWhen/hideWhen
326
+ // shouldn't ship its stale value to the server (spec §7.6).
327
+ // Walks every page's raw elements (not currentQuestions — that's
328
+ // already been visible-filtered to the current page) so this
329
+ // sweep catches hidden answers from earlier pages too.
330
+ const buildSubmitPayload = () => {
331
+ const out = {};
332
+ for (const p of pages) {
333
+ for (const q of p.elements) {
334
+ if (!isVisible(q))
335
+ continue;
336
+ const k = q.id || q.name;
337
+ if (k in responses)
338
+ out[k] = responses[k];
339
+ }
340
+ }
341
+ return out;
342
+ };
343
+ // Per-page validation. Runs the full spec §8 validator against
344
+ // every visible question — required check + type-specific format
345
+ // (email/url/number/tel regex, maxLength/minLength, min/maxCount,
346
+ // min/maxValue) + author-declared rules[] array. Returns the
347
+ // errors map (empty = OK to advance). Hidden questions are
348
+ // skipped: a question the respondent can't see can't fail
349
+ // validation.
350
+ const validateCurrentPage = () => {
351
+ const out = {};
352
+ for (const q of currentQuestions) {
353
+ const k = q.id || q.name;
354
+ const err = (0, validate_1.validateAnswer)(q, responses[k]);
355
+ if (err)
356
+ out[k] = err;
357
+ }
358
+ return out;
359
+ };
360
+ // Post-submit reset — factored out because both the __end__ path
361
+ // and the natural last-page path share it. Awaits the submit so a
362
+ // server rejection (survey deactivated, cap hit) surfaces a retry
363
+ // message instead of a false thank-you.
364
+ const submitAndReset = async () => {
365
+ const ok = await onSubmit(buildSubmitPayload());
366
+ if (ok === false) {
367
+ setSubmitError('We couldn’t submit your response. Please try again.');
368
+ return; // keep the survey open for a retry
369
+ }
370
+ setSubmitError('');
371
+ setShowThankYou(true);
372
+ const duration = survey.settings?.thankYouDuration ?? 2500;
373
+ setTimeout(() => {
374
+ setShowThankYou(false);
375
+ setCurrentPage(0);
376
+ setResponses({});
377
+ setHistory([]);
378
+ setErrors({});
379
+ setCurrentFlow(undefined);
380
+ setForkStack([]);
381
+ }, duration);
382
+ };
383
+ const handleNext = () => {
384
+ const pageErrors = validateCurrentPage();
385
+ if (Object.keys(pageErrors).length > 0) {
386
+ setErrors(pageErrors);
387
+ return; // block navigation until fixed
388
+ }
389
+ if (Object.keys(errors).length > 0)
390
+ setErrors({});
391
+ const branchTarget = resolveNextTarget();
392
+ // Fork branch (spec §28). Push origin flow + rejoinAt onto the
393
+ // fork stack, switch currentFlow, land on page 0 of the newly-
394
+ // paginated flow. useMemo recomputes `pages` on the currentFlow
395
+ // change so the next render already reflects the new flow.
396
+ if (branchTarget?.kind === 'fork') {
397
+ setHistory((h) => [...h, { pageIndex: currentPage, flow: currentFlow }]);
398
+ setForkStack((s) => [...s, { flow: currentFlow, rejoinAt: branchTarget.rejoinAt }]);
399
+ setCurrentFlow(branchTarget.flowId);
400
+ setCurrentPage(0);
401
+ return;
402
+ }
403
+ // Early-end short-circuit.
404
+ if (branchTarget?.kind === 'end') {
405
+ submitAndReset();
406
+ return;
407
+ }
408
+ // Linear next OR skip-to: figure out the next page in the current
409
+ // flow. If we're already on the last page of the current flow AND
410
+ // there's a fork frame on the stack, pop it (rejoinAt → origin
411
+ // page, else submit).
412
+ const linearNext = branchTarget?.kind === 'page' ? branchTarget.index : currentPage + 1;
413
+ if (linearNext >= totalPages) {
414
+ if (forkStack.length > 0) {
415
+ const frame = forkStack[forkStack.length - 1];
416
+ setForkStack((s) => s.slice(0, -1));
417
+ setHistory((h) => [...h, { pageIndex: currentPage, flow: currentFlow }]);
418
+ setCurrentFlow(frame.flow);
419
+ // The rejoinAt lookup needs the ORIGIN flow's page set — we
420
+ // can't compute it from the current `pages` memo (that's for
421
+ // the current flow). Compute it inline off raw arrays.
422
+ if (frame.rejoinAt && survey.rawQuestions) {
423
+ const originPages = (0, pagesFromV1_1.pagesFromV1)({ questions: survey.rawQuestions, sections: survey.rawSections }, frame.flow, presentationMode);
424
+ const targetPage = findPageForQuestion(originPages, frame.rejoinAt);
425
+ setCurrentPage(targetPage >= 0 ? targetPage : 0);
426
+ }
427
+ else {
428
+ setCurrentPage(0);
429
+ }
430
+ return;
431
+ }
432
+ submitAndReset();
433
+ return;
434
+ }
435
+ setHistory((h) => [...h, { pageIndex: currentPage, flow: currentFlow }]);
436
+ setCurrentPage(linearNext);
437
+ };
438
+ const handleBack = () => {
439
+ // Non-linear navigation: pop the actual history stack, not just
440
+ // currentPage - 1 (which would skip over a jump). Any errors
441
+ // flagged on the current page clear on Back — the respondent
442
+ // isn't trying to advance anymore.
443
+ //
444
+ // History entries record BOTH pageIndex AND flow (spec §28) so a
445
+ // Back from inside a forked flow restores the origin flow's
446
+ // pagination. If we're popping back to a different flow, also
447
+ // pop the corresponding forkStack frame — otherwise a subsequent
448
+ // Next would end up re-processing the same fork.
449
+ setErrors({});
450
+ if (history.length > 0) {
451
+ const prev = history[history.length - 1];
452
+ setHistory((h) => h.slice(0, -1));
453
+ if (prev.flow !== currentFlow && forkStack.length > 0) {
454
+ setForkStack((s) => s.slice(0, -1));
455
+ }
456
+ setCurrentFlow(prev.flow);
457
+ setCurrentPage(prev.pageIndex);
458
+ }
459
+ else if (currentPage > 0) {
460
+ setCurrentPage((p) => p - 1);
461
+ }
462
+ };
463
+ const handleDismiss = () => {
464
+ setCurrentPage(0);
465
+ setResponses({});
466
+ setShowThankYou(false);
467
+ setHistory([]);
468
+ setErrors({});
469
+ setSubmitError('');
470
+ setCurrentFlow(undefined);
471
+ setForkStack([]);
472
+ onDismiss();
473
+ };
474
+ // Normalize the v1.1 options[] {value,label} and the legacy
475
+ // choices[] (string | {value,text}) into a single shape that
476
+ // MultipleChoiceQuestion accepts. hasOther appends a synthetic
477
+ // "Other" entry — full free-text follow-up UX lands in Phase 4.5.
478
+ function normalizeChoices(question) {
479
+ const out = [];
480
+ if (Array.isArray(question.options)) {
481
+ for (const o of question.options) {
482
+ if (!o)
483
+ continue;
484
+ out.push({ value: String(o.value ?? ''), text: String(o.label ?? o.text ?? o.value ?? '') });
485
+ }
486
+ }
487
+ else if (Array.isArray(question.choices)) {
488
+ for (const c of question.choices) {
489
+ if (typeof c === 'string')
490
+ out.push({ value: c, text: c });
491
+ else if (c)
492
+ out.push({ value: String(c.value ?? ''), text: String(c.label ?? c.text ?? c.value ?? '') });
493
+ }
494
+ }
495
+ if (question.hasOther) {
496
+ out.push({ value: '__other__', text: question.otherLabel || 'Other' });
497
+ }
498
+ return out;
499
+ }
500
+ function renderQuestion(question) {
501
+ const key = question.id || question.name;
502
+ const val = responses[key];
503
+ switch (question.type) {
504
+ case 'nps':
505
+ return ((0, jsx_runtime_1.jsx)(NPSQuestion_1.NPSQuestion, { value: val ?? null, onChange: (v) => updateResponse(key, v), contract: contract }));
506
+ case 'rating':
507
+ case 'csat':
508
+ case 'ces':
509
+ return ((0, jsx_runtime_1.jsx)(CSATQuestion_1.CSATQuestion, { value: val ?? null, onChange: (v) => updateResponse(key, v), maxStars: question.maxValue ?? question.rateMax ?? 5, contract: contract }));
510
+ case 'comment':
511
+ return ((0, jsx_runtime_1.jsx)(TextQuestion_1.TextQuestion, { value: val ?? '', onChange: (v) => updateResponse(key, v), placeholder: question.placeholder, multiline: true, contract: contract }));
512
+ case 'text':
513
+ case 'email':
514
+ case 'number':
515
+ return ((0, jsx_runtime_1.jsx)(TextQuestion_1.TextQuestion, { value: val ?? '', onChange: (v) => updateResponse(key, v), placeholder: question.placeholder, multiline: false, contract: contract }));
516
+ case 'radio':
517
+ case 'radiogroup':
518
+ case 'dropdown':
519
+ return ((0, jsx_runtime_1.jsx)(MultipleChoiceQuestion_1.MultipleChoiceQuestion, { choices: normalizeChoices(question), value: val ?? '', onChange: (v) => updateResponse(key, v), mode: "radiogroup", isRtl: isRtl, contract: contract }));
520
+ case 'checkbox':
521
+ return ((0, jsx_runtime_1.jsx)(MultipleChoiceQuestion_1.MultipleChoiceQuestion, { choices: normalizeChoices(question), value: val ?? [], onChange: (v) => updateResponse(key, v), mode: "checkbox", isRtl: isRtl, contract: contract }));
522
+ case 'binary':
523
+ case 'boolean':
524
+ case 'yesno':
525
+ return ((0, jsx_runtime_1.jsx)(MultipleChoiceQuestion_1.MultipleChoiceQuestion, { choices: [
526
+ { value: 'true', text: question.yesLabel || 'Yes' },
527
+ { value: 'false', text: question.noLabel || 'No' },
528
+ ], value: val ?? '', onChange: (v) => updateResponse(key, v), mode: "radiogroup", isRtl: isRtl, contract: contract }));
529
+ case 'date':
530
+ // Native picker when the optional peer dep
531
+ // (@react-native-community/datetimepicker) is installed; falls
532
+ // back to a text input with an ISO-8601 hint otherwise. Consumer
533
+ // apps that don't opt into the picker still get a working
534
+ // (unbranded) input.
535
+ if ((0, DateQuestion_1.isNativeDatePickerAvailable)()) {
536
+ return ((0, jsx_runtime_1.jsx)(DateQuestion_1.DateQuestion, { value: val ?? '', onChange: (v) => updateResponse(key, v), min: typeof question.min === 'string' ? question.min : undefined, max: typeof question.max === 'string' ? question.max : undefined, placeholder: question.placeholder || 'Select a date', contract: contract, isRtl: isRtl }));
537
+ }
538
+ return ((0, jsx_runtime_1.jsx)(TextQuestion_1.TextQuestion, { value: val ?? '', onChange: (v) => updateResponse(key, v), placeholder: question.placeholder || 'YYYY-MM-DD', multiline: false, contract: contract }));
539
+ case 'content':
540
+ case 'html':
541
+ case 'description':
542
+ case 'pageBreak':
543
+ case 'expression':
544
+ // Display-only — the title/description block already covers
545
+ // section headers and static content. pageBreak items are
546
+ // filtered out of pages before we get here; guarding anyway.
547
+ return null;
548
+ case 'multiText':
549
+ case 'multi_text':
550
+ case 'multipletext': {
551
+ // Multi-text — labeled sub-inputs, one answer object keyed by item
552
+ // id. Matches the web renderer's MultiTextRenderer shape. Note the
553
+ // builder emits camelCase 'multiText'; the earlier snake_case-only
554
+ // case never triggered because this switch reads question.type
555
+ // raw (unlike SurveyModal's other type-family switches which lower).
556
+ const items = (Array.isArray(question.items) ? question.items : []);
557
+ return ((0, jsx_runtime_1.jsx)(MultiTextQuestion_1.MultiTextQuestion, { items: items, value: val ?? {}, onChange: (v) => updateResponse(key, v), contract: contract, isRtl: isRtl }));
558
+ }
559
+ case 'ranking':
560
+ return ((0, jsx_runtime_1.jsx)(RankingQuestion_1.RankingQuestion, { options: question.options || [], value: val ?? null, onChange: (v) => updateResponse(key, v), contract: contract, isRtl: isRtl }));
561
+ case 'matrix':
562
+ return ((0, jsx_runtime_1.jsx)(MatrixQuestion_1.MatrixQuestion
563
+ /* SurveyQuestion.rows is typed `number` (textarea line count) and
564
+ `columns` isn't on the type at all — matrix was never modelled on
565
+ mobile, which is why it fell through to the desktop hint. The
566
+ server does send both as arrays for matrix questions, so read
567
+ them through a shape-specific view; MatrixQuestion guards with
568
+ Array.isArray, so a wrong shape renders empty rather than throws. */
569
+ , {
570
+ /* SurveyQuestion.rows is typed `number` (textarea line count) and
571
+ `columns` isn't on the type at all — matrix was never modelled on
572
+ mobile, which is why it fell through to the desktop hint. The
573
+ server does send both as arrays for matrix questions, so read
574
+ them through a shape-specific view; MatrixQuestion guards with
575
+ Array.isArray, so a wrong shape renders empty rather than throws. */
576
+ rows: (question.rows) || [], columns: (question.columns) || [], value: val ?? null, onChange: (v) => updateResponse(key, v), contract: contract, isRtl: isRtl }));
577
+ case 'image': {
578
+ // Display-only; collects no answer. Reads through a shape-specific view
579
+ // because these fields aren't on SurveyQuestion (same gap as matrix).
580
+ const img = question;
581
+ return ((0, jsx_runtime_1.jsx)(ImageQuestion_1.ImageQuestion, { imageUrl: img.imageUrl, alt: img.alt, width: img.width, height: img.height, contract: contract }));
582
+ }
583
+ case 'image_picker':
584
+ case 'imagePicker': {
585
+ const ip = question;
586
+ return ((0, jsx_runtime_1.jsx)(ImagePickerQuestion_1.ImagePickerQuestion, { options: question.options || [], multiSelect: ip.multiSelect, value: val, onChange: (v) => updateResponse(key, v), contract: contract, isRtl: isRtl }));
587
+ }
588
+ // The server's canonical names are camelCase (survey.routes.js:77-81).
589
+ // Only the snake_case aliases were listed here, so these cases never
590
+ // matched a real payload and fell through to `default` — which renders a
591
+ // TEXT INPUT. A signature or file-upload question showed a text box the
592
+ // respondent could type anything into. Both spellings are handled now.
593
+ case 'matrixDropdown':
594
+ case 'matrix_dropdown': {
595
+ const md = question;
596
+ return ((0, jsx_runtime_1.jsx)(MatrixDropdownQuestion_1.MatrixDropdownQuestion, { rows: md.rows || [], columns: md.columns || [], value: val ?? null, onChange: (v) => updateResponse(key, v), contract: contract, isRtl: isRtl }));
597
+ }
598
+ case 'matrixDynamic':
599
+ case 'matrix_dynamic': {
600
+ const mdy = question;
601
+ return ((0, jsx_runtime_1.jsx)(MatrixDynamicQuestion_1.MatrixDynamicQuestion, { columns: mdy.columns || [], value: val ?? null, onChange: (v) => updateResponse(key, v), minRowCount: mdy.minRowCount, maxRowCount: mdy.maxRowCount, addRowLabel: mdy.addRowLabel, removeRowLabel: mdy.removeRowLabel, contract: contract, isRtl: isRtl }));
602
+ }
603
+ case 'panelDynamic':
604
+ case 'panel_dynamic': {
605
+ const pd = question;
606
+ return ((0, jsx_runtime_1.jsx)(PanelDynamicQuestion_1.PanelDynamicQuestion, { template: pd.template, value: val ?? null, onChange: (v) => updateResponse(key, v), minPanelCount: pd.minPanelCount, maxPanelCount: pd.maxPanelCount, addPanelLabel: pd.addPanelLabel, removePanelLabel: pd.removePanelLabel, panelTitle: pd.panelTitle, contract: contract, isRtl: isRtl }));
607
+ }
608
+ case 'signature':
609
+ case 'fileUpload':
610
+ case 'file_upload':
611
+ // Phase 4.5 targets. Native mobile UX for these is heavy
612
+ // (multi-column grids on small screens, camera integration,
613
+ // canvas). Graceful degrade to a "please complete on
614
+ // desktop" hint per ADR §12.
615
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.desktopHint, children: "This question type isn't supported on mobile yet \u2014 please complete this survey on desktop." }));
616
+ default:
617
+ // Unknown type — fall back to a text input so the survey
618
+ // still runs. Server-side validation will reject any junk.
619
+ return ((0, jsx_runtime_1.jsx)(TextQuestion_1.TextQuestion, { value: val ?? '', onChange: (v) => updateResponse(key, v), placeholder: question.placeholder, contract: contract }));
620
+ }
621
+ }
622
+ // Device-conditional palette variations. Author defines an ordered list
623
+ // of `{when: [{attr, op, value}], overrides: {theme?, primaryColor?}}`;
624
+ // we walk it top-to-bottom, pick the first entry whose conditions all
625
+ // match the current device signals, and layer its overrides over the
626
+ // base branding. The keys in `deviceForMatch` MUST match what the
627
+ // ThemeVariationsBlock DEVICE_ATTR_OPTIONS surfaces (theme, language,
628
+ // platform, screenClass, os, timezone) or the picker will emit
629
+ // conditions that never match — kept in lockstep with the web widget's
630
+ // resolveVariationOverrides in services/unified-sdk/src/survey.js.
631
+ const deviceForMatch = (() => {
632
+ const raw = (0, deviceInfo_1.getDeviceInfo)();
633
+ const langTag = typeof raw.language === 'string' ? raw.language : 'en';
634
+ return {
635
+ theme: colorScheme || 'light',
636
+ language: langTag.split(/[-_]/)[0].toLowerCase(),
637
+ platform: String(raw.platform || 'mobile').toLowerCase(),
638
+ screenClass: String(raw.deviceType || 'mobile').toLowerCase(),
639
+ os: typeof raw.os === 'string' ? raw.os : '',
640
+ timezone: typeof raw.timezone === 'string' ? raw.timezone : '',
641
+ };
642
+ })();
643
+ const activeOverrides = (() => {
644
+ const variations = survey.branding?.variations;
645
+ if (!Array.isArray(variations) || variations.length === 0)
646
+ return undefined;
647
+ for (const v of variations) {
648
+ const conds = Array.isArray(v.when) ? v.when : [];
649
+ const allMatch = conds.every((c) => {
650
+ const actual = deviceForMatch[c.attr];
651
+ return c.op === '!=' ? String(actual) !== String(c.value) : String(actual) === String(c.value);
652
+ });
653
+ if (allMatch)
654
+ return v.overrides || {};
655
+ }
656
+ return undefined;
657
+ })();
658
+ // Branding — read primaryColor from survey.branding with the SDK
659
+ // default (#D6F551 lime) as fallback. A matching variation's
660
+ // primaryColor override wins over the base branding. Applied as inline
661
+ // overrides on the modal chrome (Submit button + thank-you check) AND
662
+ // on every question component via the primaryColor prop.
663
+ const primaryColor = activeOverrides?.primaryColor
664
+ || survey.branding?.primaryColor
665
+ || styleContract_1.DARK_CONTRACT.accent; // the SDK default accent, defined once
666
+ // ── Hands-free voice controller (parity with web SDK / link) ────────
667
+ // A ref carries the latest render values so the (stable) controller always
668
+ // reads fresh page/question state — commit advances the page and awaits the
669
+ // re-render before the session reads the next question.
670
+ const voiceRef = react_1.default.useRef({ currentQuestions: [], isLastPage: false, language: 'en', intro: '', outro: '', updateResponse: () => { }, handleNext: () => { } });
671
+ const voiceLangM = currentLanguage || survey.defaultLanguage || 'en';
672
+ // Voice-only viability across the whole survey (all pages' questions).
673
+ const voiceAudit = (0, react_1.useMemo)(() => (0, voiceParsers_1.auditVoiceQuestions)(pages.flatMap((p) => p.elements)), [pages]);
674
+ const voiceIntroM = [
675
+ String(survey.title || '').replace(/<[^>]*>/g, '').trim(),
676
+ String(survey.description || '').replace(/<[^>]*>/g, '').trim(),
677
+ voiceAudit.skippable.length ? (0, voiceParsers_1.voiceDisclaimer)(voiceAudit.skippable.length, voiceLangM) : '',
678
+ ].filter(Boolean).join('. ');
679
+ const voiceOutroM = String((survey.settings?.thankYouMessage) || survey.thankYouMessage || '').replace(/<[^>]*>/g, '').trim();
680
+ voiceRef.current = { currentQuestions, isLastPage, language: voiceLangM, intro: voiceIntroM, outro: voiceOutroM, updateResponse, handleNext };
681
+ const buildVoiceController = (0, react_1.useCallback)(() => {
682
+ const advance = () => {
683
+ voiceIdxRef.current++;
684
+ const v = voiceRef.current;
685
+ if (voiceIdxRef.current < v.currentQuestions.length)
686
+ return Promise.resolve();
687
+ if (v.isLastPage) {
688
+ voiceDoneRef.current = true;
689
+ v.handleNext();
690
+ return Promise.resolve();
691
+ }
692
+ v.handleNext();
693
+ voiceIdxRef.current = 0;
694
+ // Wait for React to commit the next page before the loop reads getState.
695
+ return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
696
+ };
697
+ const keyOf = (q) => String(q.id || q.name || '');
698
+ return {
699
+ getState: () => {
700
+ const v = voiceRef.current;
701
+ if (voiceDoneRef.current)
702
+ return { done: true, language: v.language, outro: v.outro };
703
+ const qs = v.currentQuestions;
704
+ if (voiceIdxRef.current >= qs.length)
705
+ return { done: true, language: v.language, outro: v.outro };
706
+ const q = qs[voiceIdxRef.current];
707
+ return { done: false, question: q, qid: keyOf(q), language: v.language, intro: v.intro, outro: v.outro };
708
+ },
709
+ commit: (value) => { const v = voiceRef.current; const q = v.currentQuestions[voiceIdxRef.current]; if (q)
710
+ v.updateResponse(keyOf(q), value); return advance(); },
711
+ skip: () => advance(),
712
+ next: () => advance(),
713
+ commitMatrixCell: (_rowKey, _val, partial) => { const v = voiceRef.current; const q = v.currentQuestions[voiceIdxRef.current]; if (q)
714
+ v.updateResponse(keyOf(q), partial); },
715
+ onPhase: (p) => setVoicePhase(p),
716
+ onTranscript: (t) => setVoiceTranscript(t),
717
+ onStop: () => { setVoiceActive(false); setVoicePhase('idle'); setVoiceTranscript(''); },
718
+ };
719
+ }, [currentLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
720
+ const toggleVoice = (0, react_1.useCallback)(() => {
721
+ if ((0, handsFreeVoice_1.isVoiceActive)()) {
722
+ (0, handsFreeVoice_1.stopVoice)();
723
+ return;
724
+ }
725
+ // Voice-only: a REQUIRED question that can't be voiced blocks voice entirely.
726
+ if (voiceAudit.blocking.length) {
727
+ react_native_1.Alert.alert("Voice mode isn't available", "This survey has a required question that needs manual input (e.g., a file upload), which can't be answered by voice. Please answer it normally.");
728
+ return;
729
+ }
730
+ voiceDoneRef.current = false;
731
+ voiceIdxRef.current = 0;
732
+ setVoiceActive(true);
733
+ (0, handsFreeVoice_1.startVoice)(buildVoiceController());
734
+ }, [buildVoiceController, voiceAudit.blocking.length]);
735
+ // Reset the walker when the page changes via MANUAL navigation (voice manages
736
+ // its own index across its advances); always stop voice on unmount.
737
+ (0, react_1.useEffect)(() => { if (!(0, handsFreeVoice_1.isVoiceActive)())
738
+ voiceIdxRef.current = 0; }, [currentPage, currentFlow]);
739
+ (0, react_1.useEffect)(() => () => { (0, handsFreeVoice_1.stopVoice)(); }, []);
740
+ const voiceOn = (0, handsFreeVoice_1.isVoiceSupported)() && survey.voiceMode !== false;
741
+ const showVoiceMic = voiceOn && !(history.length > 0 || currentPage > 0);
742
+ // branding.typography.fontFamily overrides the system font on every
743
+ // text node the modal renders. The consumer app is responsible for
744
+ // bundling the font file (Expo font.loadAsync or the RN native
745
+ // fonts pipeline) — RN silently falls back to system when a named
746
+ // family isn't installed, so an unknown value doesn't break the
747
+ // survey, just renders in the default font. Passed as a style
748
+ // object (not a bare string) so it composes with the base
749
+ // StyleSheet under React Native's style-array flattening.
750
+ // #91 — font cascade (parity with web): per-language override ▶ survey base
751
+ // font ▶ Application survey-default font ▶ system. Per-language lets an Arabic
752
+ // locale use a different family; exact tag tried before the primary subtag.
753
+ // A detached survey carries its own theme and stops resolving up to the
754
+ // application — see the note in the web SDK's appDefaults().
755
+ const _detached = Boolean(survey.settings?.themeDetached);
756
+ const _asdMobile = _detached ? {} : (survey.applicationSurveyDefaults || {});
757
+ const _fontByLang = survey.branding?.typography?.fontByLanguage;
758
+ const _langPrimary = (currentLanguage || '').split('-')[0] || '';
759
+ const fontFamily = (_fontByLang && ((currentLanguage ? _fontByLang[currentLanguage] : undefined) || _fontByLang[_langPrimary])) ||
760
+ survey.branding?.typography?.fontFamily ||
761
+ _asdMobile.font ||
762
+ undefined;
763
+ const fontStyle = fontFamily ? { fontFamily } : undefined;
764
+ // #91 — effective progress bar (parity with web): survey.progressBar ▶
765
+ // applicationSurveyDefaults.progressBar ▶ built-in. '' / undefined = inherit;
766
+ // boolean true → shown, false → hidden. Mobile renders one bar position, so
767
+ // any non-'off' mode shows it. Default (nothing set) stays 'shown' to preserve
768
+ // prior mobile behavior (bar always visible on multi-page surveys).
769
+ // `!= null`: a cleared override is null, not undefined — JSON drops undefined.
770
+ const _pbRaw = (survey.progressBar != null && survey.progressBar !== '')
771
+ ? survey.progressBar
772
+ : (_asdMobile.progressBar || undefined);
773
+ const _pbMode = typeof _pbRaw === 'boolean' ? (_pbRaw ? 'top' : 'off') : (_pbRaw || 'top');
774
+ const showProgressBar = _pbMode !== 'off';
775
+ // The mode was computed correctly here and then thrown away one line later —
776
+ // every non-'off' value rendered a single bar at the top. Grepping for the
777
+ // field found it and the code read as finished, which is why this survived
778
+ // two passes. Now the position is actually used, as it is on web and link.
779
+ const showProgressTop = showProgressBar && (_pbMode === 'top' || _pbMode === 'both');
780
+ const showProgressBottom = showProgressBar && (_pbMode === 'bottom' || _pbMode === 'both');
781
+ // "1 / 3" used to ride along with the bar unconditionally. It is a separate
782
+ // switch on the other two renderers, so it is one here too.
783
+ const effShowPageNumbers = typeof survey.showPageNumbers === 'boolean'
784
+ ? survey.showPageNumbers
785
+ : _asdMobile.showPageNumbers === true;
786
+ // #94 — effective question numbering (parity with web): survey.showQuestionNumbers
787
+ // ▶ applicationSurveyDefaults.showQuestionNumbers ▶ off. '' / non-boolean = inherit.
788
+ // When on, answerable questions get a "Q1.", "Q2." … prefix, numbered across the
789
+ // WHOLE survey (stable across pages) and skipping display-only elements.
790
+ const effShowQuestionNumbers = typeof survey.showQuestionNumbers === 'boolean'
791
+ ? survey.showQuestionNumbers
792
+ : _asdMobile.showQuestionNumbers === 'on';
793
+ const questionNumberById = (() => {
794
+ const map = {};
795
+ if (!effShowQuestionNumbers)
796
+ return map;
797
+ const NON_ANSWERABLE = new Set(['content', 'html', 'description', 'pagebreak', 'expression']);
798
+ let n = 0;
799
+ for (const pg of pages) {
800
+ for (const el of (pg.elements || [])) {
801
+ // Lowercased: the builder writes 'pageBreak' and an API-created survey
802
+ // may write 'pagebreak'. The web SDK has always compared case-
803
+ // insensitively; these two surfaces did not, so the same survey
804
+ // numbered its questions differently depending on where it was opened.
805
+ if (NON_ANSWERABLE.has(String(el.type || '').toLowerCase()))
806
+ continue;
807
+ const key = el.id || el.name;
808
+ if (!key)
809
+ continue;
810
+ n += 1;
811
+ map[key] = n;
812
+ }
813
+ }
814
+ return map;
815
+ })();
816
+ // branding.typography.baseFontSize scales every proportional text
817
+ // node by (baseFontSize / 15). 15 is the SDK's baseline body-text
818
+ // size (question title in the base StyleSheet); scaling around it
819
+ // preserves the relative visual hierarchy — a 3× baseFontSize
820
+ // triples the title AND description together instead of blowing
821
+ // out one. Fixed-size UI chrome (single-char glyphs, tiny code
822
+ // labels) is intentionally left unscaled.
823
+ const baseScale = (() => {
824
+ const v = survey.branding?.typography?.baseFontSize;
825
+ if (typeof v !== 'number' || v <= 0)
826
+ return 1;
827
+ return v / 15;
828
+ })();
829
+ const scaled = (n) => Math.round(n * baseScale);
830
+ const fs = baseScale !== 1;
831
+ const titleFS = fs ? { fontSize: scaled(20) } : undefined;
832
+ const descFS = fs ? { fontSize: scaled(14) } : undefined;
833
+ const qTitleFS = fs ? { fontSize: scaled(15) } : undefined;
834
+ const qDescFS = fs ? { fontSize: scaled(13) } : undefined;
835
+ const thankYouFS = fs ? { fontSize: scaled(18) } : undefined;
836
+ const buttonFS = fs ? { fontSize: scaled(15) } : undefined;
837
+ // Layout tokens (spec §24). String presets map to concrete pixel
838
+ // values; a number passes through verbatim so authors can set
839
+ // exact sizes when the presets don't fit. Undefined = keep the
840
+ // SDK defaults on the base StyleSheet.
841
+ const maxWidth = (() => {
842
+ const v = survey.branding?.layout?.maxWidth;
843
+ if (typeof v === 'number')
844
+ return v;
845
+ if (v === 'narrow')
846
+ return 360;
847
+ if (v === 'standard')
848
+ return 420; // matches the SDK default
849
+ if (v === 'wide')
850
+ return 560;
851
+ // 'full' was advertised by the API and mapped nowhere. undefined here means
852
+ // "no cap", which on a phone IS full width — so it is expressed rather than
853
+ // left to coincidence.
854
+ if (v === 'full')
855
+ return undefined;
856
+ return undefined;
857
+ })();
858
+ const cornerRadius = (() => {
859
+ const v = survey.branding?.layout?.cornerRadius;
860
+ if (typeof v === 'number')
861
+ return v;
862
+ // Aligned with the other two renderers. This CHANGES native appearance for
863
+ // surveys using these keywords — subtle was 12 here and 8 elsewhere,
864
+ // rounded 24 against 16 — which is the divergence worth removing: one
865
+ // keyword must not mean two shapes. `style.cardRadius` sets an exact value
866
+ // for anyone who wants the old numbers back.
867
+ if (v === 'none')
868
+ return 0;
869
+ if (v === 'sharp')
870
+ return 4;
871
+ if (v === 'subtle')
872
+ return 8;
873
+ if (v === 'rounded')
874
+ return 16;
875
+ if (v === 'pill')
876
+ return 999;
877
+ return undefined;
878
+ })();
879
+ // density affects breathing-room paddings: header, body, footer,
880
+ // and the between-question gap. Tap targets (button padding, option
881
+ // row padding, TextInput padding) intentionally stay fixed so a
882
+ // 'compact' survey doesn't shrink the interactive hit area below
883
+ // WCAG's 24×24 minimum.
884
+ const density = survey.branding?.layout?.density;
885
+ const dens = (comp, comf, spac) => density === 'compact' ? comp :
886
+ density === 'spacious' ? spac :
887
+ comf; // 'comfortable' or undefined
888
+ const padHeader = density ? { padding: dens(16, 24, 32), paddingBottom: dens(8, 12, 16) } : undefined;
889
+ const padBody = density ? { paddingHorizontal: dens(16, 24, 32) } : undefined;
890
+ const padFooter = density ? { padding: dens(16, 24, 32), paddingTop: dens(12, 16, 20) } : undefined;
891
+ const gapQBlock = density ? { marginBottom: dens(12, 20, 28) } : undefined;
892
+ // Theme palette (spec §24). Dark is the SDK default; light flips
893
+ // every surface color the modal + question components render.
894
+ // `palette` is passed through to every question component so the
895
+ // sweep lands in one place — a3e6838 landed just modal chrome,
896
+ // this pass extends into every input surface and question row.
897
+ // Theme resolution rules (parity with web widget), highest priority first:
898
+ // 1. A matching variation's `overrides.theme` — the author explicitly
899
+ // set a palette for this device profile
900
+ // 2. `branding.theme === 'light' | 'dark'` — author pinned the base
901
+ // 3. `branding.theme === 'auto'` or absent — follow device (Appearance)
902
+ // 4. fallback → 'dark' (SDK default when OS reports neither)
903
+ // Authors who want brand consistency pin light/dark on the base; authors
904
+ // who want per-device palettes use variations; authors who want to meet
905
+ // respondents where they are leave everything on 'auto'. Colour scheme
906
+ // changes mid-session re-render via the useState/Appearance listener
907
+ // above (attributeContext is memoized on colorScheme too).
908
+ const authorTheme = survey.branding?.theme;
909
+ // For 'auto'/absent, follow the end-user's preference, highest first:
910
+ // 1. user.theme — an identify() trait the host keeps in sync with its own
911
+ // theme toggle: CXPinsight.identify({ theme: 'light'|'dark' }). Recommended.
912
+ // 2. init({ theme }) config — still honored for backward compat.
913
+ // 3. the OS colour scheme (Appearance). init({theme}) is also reported as
914
+ // device.theme for analytics/targeting regardless.
915
+ const _pref = (v) => {
916
+ const t = typeof v === 'string' ? v.toLowerCase() : '';
917
+ return t === 'light' || t === 'dark' ? t : undefined;
918
+ };
919
+ const _userTheme = _pref(CXPinsight_1.CXPinsight._getUserIdentity()?.theme);
920
+ const _initTheme = _pref(CXPinsight_1.CXPinsight._getConfig()?.theme);
921
+ const resolvedTheme = activeOverrides?.theme === 'light' || activeOverrides?.theme === 'dark'
922
+ ? activeOverrides.theme
923
+ : authorTheme === 'light' || authorTheme === 'dark'
924
+ ? authorTheme
925
+ : (_userTheme || _initTheme || (colorScheme === 'light' ? 'light' : colorScheme === 'dark' ? 'dark' : 'dark'));
926
+ const isLight = resolvedTheme === 'light';
927
+ // Layer palette overrides: SDK-init (host app) > survey.branding.palette
928
+ // > built-in defaults. themePalette applies precedence per-key so a
929
+ // partial-override slot on either layer only shadows THAT key.
930
+ // Palette can be "linked" (one shared light/dark map for web+mobile, default)
931
+ // or diverged (palette.linked===false with per-platform palette.web/.mobile
932
+ // layered over the shared map per-surface). This SDK is 'mobile'.
933
+ const surveyPaletteOverride = (() => {
934
+ const pal = survey.branding?.palette;
935
+ if (!pal)
936
+ return undefined;
937
+ const shared = pal[resolvedTheme];
938
+ const over = pal.linked === false ? pal.mobile?.[resolvedTheme] : undefined;
939
+ if (over)
940
+ return { ...(shared || {}), ...over };
941
+ return shared;
942
+ })();
943
+ const initPaletteOverride = CXPinsight_1.CXPinsight._getInitBrandingPalette?.()?.[resolvedTheme];
944
+ const palette = (0, themePalette_1.themePalette)(resolvedTheme, surveyPaletteOverride, initPaletteOverride);
945
+ // Full style contract — the same layering as `palette`, widened from 9 fields
946
+ // to every surface the question components draw.
947
+ //
948
+ // Note this is passed in BOTH themes, unlike `palette` which was gated behind
949
+ // `isLight`. That is safe and deliberate: DARK_CONTRACT's per-component values
950
+ // were audited against the components' own StyleSheet literals (the values
951
+ // dark actually rendered), and LIGHT_CONTRACT's mirror the light palette. So
952
+ // an unstyled survey renders identically to before in either theme, while a
953
+ // branded one now gets its brand applied on mobile as well as web.
954
+ //
955
+ // Memoized on the actual inputs, not just wrapped: resolveStyleContract()
956
+ // returns a fresh object each call, so an unmemoized value would give
957
+ // `styles` a new identity every render and call StyleSheet.create() every
958
+ // time — which leaks entries in RN's style registry over a long session.
959
+ const contract = (0, react_1.useMemo)(() => (0, styleContract_1.resolveStyleContract)({
960
+ theme: resolvedTheme,
961
+ primaryColor,
962
+ // survey.branding.palette is written by SurveyPaletteEditor in the
963
+ // AUTHORED vocabulary (bg/cardBg/inputBg/…), so it must be translated.
964
+ // init()'s brandingPalette is a code API documented in the SDK
965
+ // vocabulary, so it passes through unmapped.
966
+ // branding.palette[theme] — colours, authored vocabulary, needs mapping.
967
+ // branding.style — shape/spacing/type, already canonical contract names,
968
+ // and theme-independent so it is NOT read per-mode. Merged on top so a
969
+ // radius set once applies in both themes, exactly as it does on web.
970
+ surveyOverride: {
971
+ ...(0, styleContract_1.fromAuthoredPalette)(surveyPaletteOverride),
972
+ ...(survey.branding?.style || {}),
973
+ },
974
+ initOverride: initPaletteOverride,
975
+ }),
976
+ // Serialised because both override objects are rebuilt on each render by
977
+ // their own lookups; comparing by identity would defeat the memo.
978
+ [resolvedTheme, primaryColor,
979
+ JSON.stringify(surveyPaletteOverride), JSON.stringify(initPaletteOverride),
980
+ JSON.stringify(survey.branding?.style)]);
981
+ const styles = (0, react_1.useMemo)(() => makeStyles(contract), [contract]);
982
+ const themeCard = isLight ? { backgroundColor: palette.card, borderColor: palette.cardBorder } : undefined;
983
+ const themeTextPrimary = isLight ? { color: palette.textPrimary } : undefined;
984
+ const themeTextSecondary = isLight ? { color: palette.textSecondary } : undefined;
985
+ const themeTextMuted = isLight ? { color: palette.textMuted } : undefined;
986
+ // RTL (spec §26). Applies `writingDirection: 'rtl'` on every text
987
+ // node so Arabic/Hebrew content reads correctly. Full flex-flip
988
+ // (row reversal on button rows) is a follow-up — this pass just
989
+ // fixes the reading order which is the visible bug for now.
990
+ // RTL is driven by the ACTIVE language, not just the survey-level
991
+ // `direction`. A multilingual survey (e.g. en default + ar) has no
992
+ // top-level direction, so switching to Arabic must flip the modal to
993
+ // RTL on its own — otherwise Arabic renders left-to-right ("not theme
994
+ // compliant"). survey.direction:'rtl' still forces RTL globally when
995
+ // an author sets it. Covers the RTL scripts a survey is likely to use.
996
+ const isRtl = survey.direction === 'rtl' ||
997
+ (currentLanguage ? RTL_LANGUAGES.has(currentLanguage) : false);
998
+ const dirText = isRtl ? { writingDirection: 'rtl', textAlign: 'right' } : undefined;
999
+ // P73 — presentation. animation → Modal animationType; mode 'fullscreen' → edge-to-edge
1000
+ // card (drop the centered maxWidth cap + corner radius, fill the screen).
1001
+ // The application's presentation defaults, MOBILE slice, with the survey's
1002
+ // own values on top. This merge did not exist: `display` read the survey
1003
+ // alone, so everything set on the Application theme page — mode, position,
1004
+ // width, height, animation, and the shared chrome — reached the link renderer
1005
+ // and (since the parallel fix) the web SDK, and died here. Precedence matches
1006
+ // the other two exactly: survey ▶ application ▶ built-in, field by field, with
1007
+ // '' meaning inherit rather than override-with-nothing.
1008
+ const display = (0, react_1.useMemo)(() => {
1009
+ const a = _asdMobile;
1010
+ // Both layers may store display per platform — web and mobile positions are
1011
+ // different decisions, and a survey that could only store one made editing
1012
+ // either frame overwrite the other. A display with no slices IS the value.
1013
+ const pick = (d) => {
1014
+ if (!d || typeof d !== 'object')
1015
+ return {};
1016
+ // `link` is a slice marker too — see the note in the web SDK.
1017
+ if (d.web === undefined && d.mobile === undefined && d.link === undefined)
1018
+ return d;
1019
+ return (d.mobile || {});
1020
+ };
1021
+ const slice = pick(a.display);
1022
+ const out = { ...slice };
1023
+ // Chrome is stored beside display on an application because it is shared
1024
+ // furniture, but every renderer reads it FROM display.
1025
+ ['buttonAlign', 'showClose', 'showBack'].forEach((k) => {
1026
+ if (out[k] === undefined && a[k] !== undefined)
1027
+ out[k] = a[k];
1028
+ });
1029
+ const own = pick(survey.settings?.display);
1030
+ Object.keys(own).forEach((k) => {
1031
+ if (own[k] !== undefined && own[k] !== null && own[k] !== '')
1032
+ out[k] = own[k];
1033
+ });
1034
+ return out;
1035
+ }, [survey.settings?.display, _asdMobile]);
1036
+ // Parity fix: an UNSET animation used to mean 'fade' here and 'slide' on web,
1037
+ // so the same authored survey entered differently per platform for a value the
1038
+ // author never chose. Slide is the shared default — it is also what a native
1039
+ // sheet does, so nothing about the mobile feel is lost.
1040
+ const animationType = display?.animation === 'fade' ? 'fade' : display?.animation === 'none' ? 'none' : 'slide';
1041
+ const isFullscreen = display?.mode === 'fullscreen';
1042
+ const isDrawer = display?.mode === 'modal';
1043
+ // Position — previously unread here, which is why a phone could never show a
1044
+ // bottom sheet, the one presentation users actually expect on mobile.
1045
+ //
1046
+ // Left and right are dropped rather than approximated: a side drawer is
1047
+ // 420px of horizontal room a handset does not have, and the sheet is the
1048
+ // native equivalent. A drawer with nothing set anchors to the BOTTOM (the
1049
+ // platform convention); anything else centres, as before.
1050
+ const rawPos = display?.position;
1051
+ const placement = rawPos === 'top' ? 'top'
1052
+ : rawPos === 'bottom' ? 'bottom'
1053
+ : rawPos === 'left' || rawPos === 'right' ? 'bottom'
1054
+ : rawPos === 'center' ? 'center'
1055
+ : isDrawer ? 'bottom' : 'center';
1056
+ const isSheet = isDrawer && (placement === 'top' || placement === 'bottom');
1057
+ // Popup coordinate. RN cannot centre on a percentage the way the web SDK does
1058
+ // — transform:translateY takes points, not percentages, and percentage margins
1059
+ // resolve against the parent WIDTH — so the free y is bucketed into the three
1060
+ // placements RN can express cleanly. x is ignored entirely; a phone centres.
1061
+ const popupY = !isDrawer && !isFullscreen && display?.y != null ? parseFloat(String(display.y)) : NaN;
1062
+ const effPlacement = !isNaN(popupY) ? (popupY < 33 ? 'top' : popupY > 67 ? 'bottom' : 'center') : placement;
1063
+ const overlayPlacement = {
1064
+ justifyContent: effPlacement === 'top' ? 'flex-start'
1065
+ : effPlacement === 'bottom' ? 'flex-end'
1066
+ : 'center',
1067
+ };
1068
+ // A sheet is flush to its edge and spans the full width, and only the corners
1069
+ // whose edges are exposed stay round — the same rule the web SDK derives.
1070
+ // Directional entrance. RN's Modal only knows one slide — upward from the
1071
+ // bottom — so a TOP sheet used to fly up from the far edge, past its own
1072
+ // resting place, and settle backwards. Every other case is already right:
1073
+ // a bottom sheet and a centred popup both rise, and fade has no direction.
1074
+ // So the Modal animation is handed over ONLY for a top sheet, and the card
1075
+ // animates itself down from above instead.
1076
+ const needsTopSlide = animationType === 'slide' && isSheet && placement === 'top';
1077
+ const modalAnimation = needsTopSlide ? 'none' : animationType;
1078
+ const enterAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
1079
+ (0, react_1.useEffect)(() => {
1080
+ if (!needsTopSlide)
1081
+ return;
1082
+ if (!visible) {
1083
+ enterAnim.setValue(0);
1084
+ return;
1085
+ }
1086
+ enterAnim.setValue(0);
1087
+ react_native_1.Animated.timing(enterAnim, {
1088
+ toValue: 1,
1089
+ duration: 280,
1090
+ easing: react_native_1.Easing.out(react_native_1.Easing.cubic),
1091
+ // Transform and opacity only, so this runs off the JS thread and does not
1092
+ // stutter while the survey's first page is still laying out.
1093
+ useNativeDriver: true,
1094
+ }).start();
1095
+ }, [visible, needsTopSlide, enterAnim]);
1096
+ const enterStyle = needsTopSlide
1097
+ ? {
1098
+ opacity: enterAnim,
1099
+ transform: [{
1100
+ translateY: enterAnim.interpolate({
1101
+ inputRange: [0, 1],
1102
+ // Its own height is not known before layout, so it travels a screen
1103
+ // third — far enough to read as entering from off-screen, short
1104
+ // enough that the easing still lands crisply.
1105
+ outputRange: [-SCREEN_HEIGHT / 3, 0],
1106
+ }),
1107
+ }],
1108
+ }
1109
+ : null;
1110
+ const sheetRadius = cornerRadius !== undefined ? cornerRadius : 16;
1111
+ const sheetStyle = isSheet ? {
1112
+ width: '100%',
1113
+ maxWidth: undefined,
1114
+ borderTopLeftRadius: placement === 'bottom' ? sheetRadius : 0,
1115
+ borderTopRightRadius: placement === 'bottom' ? sheetRadius : 0,
1116
+ borderBottomLeftRadius: placement === 'top' ? sheetRadius : 0,
1117
+ borderBottomRightRadius: placement === 'top' ? sheetRadius : 0,
1118
+ } : null;
1119
+ // Safe areas. Deliberately built from react-native primitives rather than
1120
+ // react-native-safe-area-context: this package declares no dependencies, and
1121
+ // adding one would force every host app to install it.
1122
+ const topInset = react_native_1.Platform.OS === 'android' ? (react_native_1.StatusBar.currentHeight || 0) : 44;
1123
+ const bottomInset = react_native_1.Platform.OS === 'ios' ? 34 : 0;
1124
+ const safePad = isFullscreen
1125
+ ? { paddingTop: topInset, paddingBottom: bottomInset }
1126
+ : isSheet && placement === 'bottom' ? { paddingBottom: bottomInset }
1127
+ : isSheet && placement === 'top' ? { paddingTop: topInset }
1128
+ : null;
1129
+ // Action-button layout. UNSET keeps the native full-width split (default);
1130
+ // 'full' is explicit full-width; right/center/left shrink to content + align.
1131
+ const buttonAlign = display?.buttonAlign;
1132
+ const alignsAuto = !!buttonAlign && buttonAlign !== 'full';
1133
+ const footerAlign = alignsAuto ? { justifyContent: buttonAlign === 'center' ? 'center' : buttonAlign === 'left' ? 'flex-start' : 'flex-end' } : null;
1134
+ const btnAutoWidth = alignsAuto ? { flex: 0, paddingHorizontal: 24 } : null;
1135
+ // display.height → CARD height (non-fullscreen). Fullscreen keeps the card
1136
+ // full-bleed and constrains the CONTENT instead (fsContent below), so the
1137
+ // survey background fills and only the content is contained — like web.
1138
+ const heightStyle = (() => {
1139
+ if (isFullscreen)
1140
+ return null;
1141
+ const h = display?.height;
1142
+ if (!h || h === 'auto')
1143
+ return null;
1144
+ // Parity fix: 'tall' was 92% here and 85vh/90vh on web, so the same keyword
1145
+ // produced a visibly different card per platform.
1146
+ if (h === 'tall')
1147
+ return { height: '85%', maxHeight: '90%' };
1148
+ if (h === 'fullscreen')
1149
+ return { height: '92%', maxHeight: '92%' };
1150
+ // Parity fix: vh was silently ignored here while web accepted it, so an
1151
+ // authored '60vh' worked on one platform and did nothing on the other. The
1152
+ // overlay fills the screen, so a viewport unit IS a percentage in this frame.
1153
+ const vh = /^(\d+(?:\.\d+)?)vh$/.exec(String(h));
1154
+ if (vh)
1155
+ return { height: `${vh[1]}%`, maxHeight: '92%' };
1156
+ const pct = /^(\d+(?:\.\d+)?)%$/.exec(String(h));
1157
+ if (pct)
1158
+ return { height: `${pct[1]}%`, maxHeight: '92%' };
1159
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(String(h));
1160
+ if (px)
1161
+ return { height: Number(px[1]), maxHeight: '92%' };
1162
+ return null;
1163
+ })();
1164
+ // display.width → card width (non-fullscreen), parity with the web SDK. px is
1165
+ // capped to the screen; % passes through. Overrides the layout maxWidth below.
1166
+ const widthStyle = (() => {
1167
+ if (isFullscreen)
1168
+ return null;
1169
+ // A sheet spans its edge; width does not apply to it, and the web SDK
1170
+ // already forces width:100%/maxWidth:none on a horizontal drawer. This
1171
+ // style is composed AFTER sheetStyle, so without this guard an authored
1172
+ // width silently overrode the sheet here and nowhere else — the same survey
1173
+ // rendering edge-to-edge on web and inset on a phone.
1174
+ if (isSheet)
1175
+ return null;
1176
+ const w = display?.width;
1177
+ if (!w || w === 'auto')
1178
+ return null;
1179
+ const vw = /^(\d+(?:\.\d+)?)vw$/.exec(String(w));
1180
+ if (vw)
1181
+ return { width: `${vw[1]}%` };
1182
+ const pct = /^(\d+(?:\.\d+)?)%$/.exec(String(w));
1183
+ if (pct)
1184
+ return { width: `${pct[1]}%` };
1185
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(String(w));
1186
+ if (px)
1187
+ return { width: Math.min(SCREEN_WIDTH - 40, Number(px[1])), maxWidth: Number(px[1]) };
1188
+ return null;
1189
+ })();
1190
+ // Fullscreen ONLY: the card fills (survey bg), and these constrain the CONTENT
1191
+ // region (ScrollView) — maxHeight caps the scroll area, maxWidth centres it —
1192
+ // so a Content height/width contains the content without shrinking the survey.
1193
+ const fsContent = (() => {
1194
+ if (!isFullscreen)
1195
+ return null;
1196
+ const out = {};
1197
+ // display.y — distance from the top of the screen to the top of the content.
1198
+ // Replaces the old Top/Middle/Bottom alignment, which could only act on free
1199
+ // space and so did nothing once the content outgrew the screen. Same control
1200
+ // and same field as the web SDK and the link surface, so one setting reads
1201
+ // identically on all three.
1202
+ const yRaw = display?.y;
1203
+ if (yRaw != null && yRaw !== '') {
1204
+ const n = typeof yRaw === 'number' ? yRaw : Number(String(yRaw).replace(/px$/, ''));
1205
+ if (Number.isFinite(n) && n > 0)
1206
+ out.marginTop = n;
1207
+ }
1208
+ const h = display?.height;
1209
+ if (h && h !== 'auto') {
1210
+ if (h === 'tall')
1211
+ out.maxHeight = '85%';
1212
+ else {
1213
+ const vhx = /^(\d+(?:\.\d+)?)vh$/.exec(String(h));
1214
+ const pct = /^(\d+(?:\.\d+)?)%$/.exec(String(h));
1215
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(String(h));
1216
+ if (vhx)
1217
+ out.maxHeight = `${vhx[1]}%`;
1218
+ else if (pct)
1219
+ out.maxHeight = `${pct[1]}%`;
1220
+ else if (px)
1221
+ out.maxHeight = Number(px[1]);
1222
+ }
1223
+ }
1224
+ const w = display?.width;
1225
+ if (w && w !== 'auto') {
1226
+ const pct = /^(\d+(?:\.\d+)?)%$/.exec(String(w));
1227
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(String(w));
1228
+ if (pct)
1229
+ out.maxWidth = `${pct[1]}%`;
1230
+ else if (px)
1231
+ out.maxWidth = Number(px[1]);
1232
+ if (out.maxWidth !== undefined) {
1233
+ out.alignSelf = 'center';
1234
+ out.width = '100%';
1235
+ }
1236
+ }
1237
+ return Object.keys(out).length ? out : null;
1238
+ })();
1239
+ // Built once and placed at whichever end (or both) the mode asks for, so the
1240
+ // two positions cannot drift apart in styling or accessibility wiring.
1241
+ const progressEl = totalPages > 1 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [effShowPageNumbers && ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.progress, children: [currentPage + 1, " / ", totalPages] })), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.progressBarTrack, accessible: true, accessibilityRole: "progressbar", accessibilityLabel: `Page ${currentPage + 1} of ${totalPages}`, accessibilityValue: { min: 1, max: totalPages, now: currentPage + 1 }, accessibilityLiveRegion: "polite", children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
1242
+ styles.progressBarFill,
1243
+ { width: `${((currentPage + 1) / totalPages) * 100}%`, backgroundColor: primaryColor },
1244
+ ] }) })] })) : null;
1245
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Modal, { visible: visible, transparent: true, animationType: modalAnimation, onRequestClose: handleDismiss, children: (0, jsx_runtime_1.jsxs)(react_native_1.KeyboardAvoidingView, { style: [
1246
+ styles.overlay,
1247
+ overlayPlacement,
1248
+ // A sheet sits flush against its edge, so the overlay loses its gutter.
1249
+ isSheet && { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 },
1250
+ isFullscreen && styles.overlayFullscreen,
1251
+ ], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View, { style: [
1252
+ styles.card,
1253
+ themeCard,
1254
+ enterStyle,
1255
+ maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH - 40, maxWidth), maxWidth },
1256
+ cornerRadius !== undefined && !isFullscreen && { borderRadius: cornerRadius },
1257
+ // cardFullscreen fills; an explicit content height/width below OVERRIDES
1258
+ // the fill so a fullscreen survey can be contained to that size.
1259
+ isFullscreen && styles.cardFullscreen,
1260
+ sheetStyle,
1261
+ heightStyle,
1262
+ widthStyle,
1263
+ safePad,
1264
+ ], children: [display?.showClose !== false && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [
1265
+ styles.closeButton,
1266
+ isRtl ? { left: 12 } : { right: 12 },
1267
+ ], onPress: handleDismiss, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.closeText, themeTextSecondary], children: "\u2715" }) })), showThankYou ? (() => {
1268
+ // The thank-you message can be rich HTML from the web builder.
1269
+ // Pull out an embedded <img> (often a data: URI icon) and render
1270
+ // it as a real Image — stripHtml would otherwise drop it — then
1271
+ // show the remaining text. When an image is present it stands in
1272
+ // for the default ✓ glyph.
1273
+ const rawThankYou = (0, interpolate_1.interpolatePlain)((0, translationLookup_1.surveyText)(survey, currentLanguage, 'thankYouMessage', survey.settings?.thankYouMessage)
1274
+ ?? 'Thank you for your feedback!', evalCtx);
1275
+ const imgSrc = firstImageSrc(rawThankYou);
1276
+ const thankYouText = stripHtml(rawThankYou);
1277
+ 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] }));
1278
+ })() : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [survey.languages && survey.languages.length > 1 && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
1279
+ styles.langSwitcher,
1280
+ isRtl && { paddingRight: 12, paddingLeft: 48, flexDirection: 'row-reverse' },
1281
+ ], children: survey.languages.map((code) => {
1282
+ const isActive = code === currentLanguage;
1283
+ return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: () => setCurrentLanguage(code), activeOpacity: 0.7, style: [
1284
+ styles.langChip,
1285
+ // Light theme: the chip's default white-on-white border/fill
1286
+ // is invisible on the light card — repaint from the palette.
1287
+ isLight && { borderColor: palette.inputBorder, backgroundColor: palette.input },
1288
+ isActive && { borderColor: primaryColor, backgroundColor: `${primaryColor}1A` },
1289
+ ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
1290
+ styles.langChipText,
1291
+ isLight && { color: palette.textSecondary },
1292
+ isActive && { color: primaryColor },
1293
+ ], children: code.toUpperCase() }) }, code));
1294
+ }) })), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
1295
+ styles.header,
1296
+ padHeader,
1297
+ isRtl && { paddingLeft: 48, paddingRight: 24 },
1298
+ ], children: [((survey.branding?.showLogo !== false && survey.branding?.logo)
1299
+ || survey.branding?.brandName) && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
1300
+ styles.brandRow,
1301
+ // Logo position, same rule as the other two renderers.
1302
+ (survey.branding?.logoAlign === 'center'
1303
+ ? { justifyContent: 'center' }
1304
+ : survey.branding?.logoAlign === 'right'
1305
+ ? { justifyContent: 'flex-end' }
1306
+ : null),
1307
+ ], children: [survey.branding?.showLogo !== false && survey.branding?.logo ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: survey.branding.logo }, style: styles.brandLogo, resizeMode: "contain", accessible: !!survey.branding.brandName, accessibilityLabel: survey.branding.brandName })) : null, survey.branding?.brandName ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.brandName, dirText, fontStyle, themeTextSecondary], children: survey.branding.brandName })) : null] })), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.title, dirText, fontStyle, themeTextPrimary, titleFS,
1308
+ survey.titleAlign
1309
+ ? { textAlign: survey.titleAlign }
1310
+ : null], children: (0, interpolate_1.interpolatePlain)(
1311
+ // Title is plain text; strip any stray rich-text markup +
1312
+ // decode &nbsp; so it never shows literal HTML on native.
1313
+ String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
1314
+ .replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' '), evalCtx) }), (() => {
1315
+ // Description is authored as rich HTML; native Text can't render
1316
+ // markup, so strip tags + decode &nbsp; to plain text.
1317
+ const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
1318
+ const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim();
1319
+ return desc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.description, dirText, fontStyle, themeTextSecondary, descFS], children: (0, interpolate_1.interpolatePlain)(desc, evalCtx) })) : null;
1320
+ })(), showProgressTop && progressEl] }), (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { style: [styles.body, padBody, fsContent], showsVerticalScrollIndicator: false, children: currentQuestions.map((q) => {
1321
+ // v1.1 uses `id`+`required`; legacy uses `name`+`isRequired`.
1322
+ // The provider normalizes both onto the same object so
1323
+ // either lookup works.
1324
+ const rowKey = q.id || q.name;
1325
+ const isRequired = q.required || q.isRequired;
1326
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.questionBlock, gapQBlock], children: [(0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS], children: [questionNumberById[rowKey] ? `Q${questionNumberById[rowKey]}. ` : '', (0, interpolate_1.interpolatePlain)((0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'text', q.title), evalCtx), isRequired && (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.required, children: " *" })] }), (() => {
1327
+ const qDesc = (0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'description', q.description);
1328
+ 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;
1329
+ })(), renderQuestion(q), errors[rowKey] ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.errorText, dirText, fontStyle], children: errors[rowKey] })) : null] }, rowKey));
1330
+ }) }), submitError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.submitError, dirText, fontStyle], children: submitError })) : null, showProgressBottom && (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padFooter, children: progressEl }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
1331
+ styles.footer,
1332
+ padFooter,
1333
+ // Flip button order for RTL so the primary (Next / Submit)
1334
+ // action sits on the left where an Arabic reader
1335
+ // expects to find the forward affordance in their
1336
+ // text-flow direction.
1337
+ isRtl && { flexDirection: 'row-reverse' },
1338
+ footerAlign,
1339
+ ], children: [showVoiceMic && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [
1340
+ styles.voiceMic,
1341
+ { backgroundColor: voiceActive ? '#ef4444' : primaryColor },
1342
+ buttonAlign === 'right' && { marginRight: 'auto' },
1343
+ ], onPress: toggleVoice, accessibilityLabel: voiceActive ? 'Stop hands-free voice' : 'Answer with voice', activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.voiceMicGlyph, children: "\uD83C\uDFA4" }) })), display?.showBack !== false && (history.length > 0 || currentPage > 0) && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [
1344
+ styles.backButton,
1345
+ isLight && { backgroundColor: palette.input, borderColor: palette.inputBorder },
1346
+ btnAutoWidth,
1347
+ // Right-align parity with web: Back hugs the left edge while
1348
+ // Next/Submit sits on the right (web uses margin-right:auto).
1349
+ buttonAlign === 'right' && { marginRight: 'auto' },
1350
+ ], onPress: handleBack, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.backButtonText, fontStyle, themeTextSecondary, buttonFS], children: "Back" }) })), (() => {
1351
+ // Reflect the actual outcome of pressing Next: if a
1352
+ // branch resolves to __end__ we're submitting even
1353
+ // when we're not on the last page. Cheap to evaluate
1354
+ // here — same call handleNext will make.
1355
+ const target = resolveNextTarget();
1356
+ // Reflect the actual outcome of pressing Next. A __end__
1357
+ // branch always submits. A fork branch or a page/linear-
1358
+ // next never submits. Otherwise: last-page + no fork
1359
+ // frame left on the stack = submit.
1360
+ const willSubmit = target?.kind === 'end' ||
1361
+ (target == null && isLastPage && forkStack.length === 0);
1362
+ const isSingleButton = history.length === 0 && currentPage === 0;
1363
+ return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity
1364
+ // Full-width ONLY when alignment is unset (native default) or
1365
+ // explicitly 'full'. When the author picked right/center/left,
1366
+ // respect it even on the first page (was forced full-width,
1367
+ // so mobile ignored the setting on page 1 — unlike web).
1368
+ , {
1369
+ // Full-width ONLY when alignment is unset (native default) or
1370
+ // explicitly 'full'. When the author picked right/center/left,
1371
+ // respect it even on the first page (was forced full-width,
1372
+ // so mobile ignored the setting on page 1 — unlike web).
1373
+ style: [styles.nextButton, ((isSingleButton && !alignsAuto) || buttonAlign === 'full') && styles.nextButtonFull, btnAutoWidth, { backgroundColor: primaryColor }], onPress: handleNext, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.nextButtonText, fontStyle, buttonFS], children: willSubmit ? 'Submit' : 'Next' }) }));
1374
+ })()] })] }))] }), (0, jsx_runtime_1.jsx)(handsFreeVoice_1.VoiceOverlay, { visible: voiceActive, phase: voicePhase, transcript: voiceTranscript, onStop: () => (0, handsFreeVoice_1.stopVoice)() })] }) }));
1375
+ }
1376
+ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = react_native_1.Dimensions.get('window');
1377
+ /**
1378
+ * Contract-driven styles. Was a static StyleSheet with 31 hardcoded colours;
1379
+ * every one now resolves from the style contract, so a branded survey brands the
1380
+ * modal chrome too — not just its question components.
1381
+ *
1382
+ * DARK_CONTRACT's values were audited against this file's previous literals, so
1383
+ * an unstyled dark modal renders identically. In light theme some chrome
1384
+ * surfaces previously fell through to the dark literals (only card/text had
1385
+ * isLight overrides); those now correctly use the light values.
1386
+ */
1387
+ const makeStyles = (c) => react_native_1.StyleSheet.create({
1388
+ overlay: {
1389
+ flex: 1,
1390
+ backgroundColor: c.ground,
1391
+ justifyContent: 'center',
1392
+ alignItems: 'center',
1393
+ padding: 20,
1394
+ },
1395
+ card: {
1396
+ width: Math.min(SCREEN_WIDTH - 40, 420),
1397
+ maxHeight: '85%',
1398
+ backgroundColor: c.card,
1399
+ borderRadius: c.cardRadius,
1400
+ borderWidth: 1,
1401
+ borderColor: c.cardBorder,
1402
+ overflow: 'hidden',
1403
+ },
1404
+ // P73 — fullscreen presentation: edge-to-edge, no centered card.
1405
+ overlayFullscreen: {
1406
+ padding: 0,
1407
+ justifyContent: 'flex-start',
1408
+ },
1409
+ cardFullscreen: {
1410
+ width: '100%',
1411
+ maxWidth: undefined,
1412
+ height: '100%',
1413
+ maxHeight: '100%',
1414
+ borderRadius: 0,
1415
+ borderWidth: 0,
1416
+ },
1417
+ closeButton: {
1418
+ position: 'absolute',
1419
+ top: 12,
1420
+ // left / right applied inline based on survey.direction — see
1421
+ // the closeButton render site above.
1422
+ zIndex: 10,
1423
+ width: 32,
1424
+ height: 32,
1425
+ borderRadius: 16,
1426
+ backgroundColor: c.closeBg,
1427
+ alignItems: 'center',
1428
+ justifyContent: 'center',
1429
+ },
1430
+ closeText: {
1431
+ color: c.closeText,
1432
+ fontSize: 16,
1433
+ },
1434
+ header: {
1435
+ // Per-side padding maps onto the three stacked regions this modal already
1436
+ // uses: the header owns the TOP edge, the footer owns the BOTTOM, and all
1437
+ // three share the sides. Each falls back to the single value, so unlinking
1438
+ // and editing one edge cannot zero the others.
1439
+ paddingTop: c.cardPaddingTop ?? c.cardPadding,
1440
+ paddingLeft: c.cardPaddingLeft ?? c.cardPadding,
1441
+ paddingBottom: 12,
1442
+ // 48 is the room the close button needs; a larger authored right padding
1443
+ // wins, a smaller one would put the title under the ✕.
1444
+ paddingRight: Math.max(48, c.cardPaddingRight ?? c.cardPadding),
1445
+ },
1446
+ // Branding row above the title — logo image + optional brand name
1447
+ // caption. Center-aligned; the logo is capped small enough that
1448
+ // portrait screens don't blow it out.
1449
+ brandRow: {
1450
+ alignItems: 'center',
1451
+ marginBottom: 12,
1452
+ },
1453
+ brandLogo: {
1454
+ width: 120,
1455
+ height: 36,
1456
+ maxWidth: '100%',
1457
+ },
1458
+ brandName: {
1459
+ color: c.brandNameColor,
1460
+ fontSize: 12,
1461
+ fontWeight: '600',
1462
+ letterSpacing: 0.5,
1463
+ textTransform: 'uppercase',
1464
+ marginTop: 6,
1465
+ },
1466
+ title: {
1467
+ color: c.headingColor,
1468
+ fontSize: c.fontSizeHeading,
1469
+ fontWeight: '700',
1470
+ },
1471
+ description: {
1472
+ color: c.descriptionColor,
1473
+ fontSize: 14,
1474
+ marginTop: 6,
1475
+ lineHeight: 20,
1476
+ },
1477
+ progress: {
1478
+ color: c.progressText,
1479
+ fontSize: 12,
1480
+ marginTop: 8,
1481
+ },
1482
+ // Filled-bar progress. Track is a thin muted stripe; fill uses
1483
+ // branding.primaryColor via inline style so branded surveys
1484
+ // show the accent through the bar rather than the SDK default.
1485
+ progressBarTrack: {
1486
+ height: 3,
1487
+ backgroundColor: c.progressTrack,
1488
+ borderRadius: 2,
1489
+ marginTop: 6,
1490
+ overflow: 'hidden',
1491
+ },
1492
+ progressBarFill: {
1493
+ height: '100%',
1494
+ borderRadius: 2,
1495
+ },
1496
+ body: {
1497
+ paddingLeft: c.cardPaddingLeft ?? c.cardPadding,
1498
+ paddingRight: c.cardPaddingRight ?? c.cardPadding,
1499
+ },
1500
+ questionBlock: {
1501
+ marginBottom: c.sectionGap,
1502
+ },
1503
+ questionTitle: {
1504
+ color: c.textPrimary,
1505
+ fontSize: c.fontSizeLabel,
1506
+ fontWeight: '600',
1507
+ marginBottom: 4,
1508
+ },
1509
+ required: {
1510
+ color: c.requiredMark,
1511
+ },
1512
+ questionDesc: {
1513
+ color: c.textMuted,
1514
+ fontSize: 13,
1515
+ marginBottom: 4,
1516
+ },
1517
+ // Validation error surfaced under a required question when Next
1518
+ // is pressed without an answer. Red (#f87171 — RN-friendly Tailwind
1519
+ // red-400) instead of the danger red used by the design system's
1520
+ // web renderer, tuned to read on the dark card background.
1521
+ errorText: {
1522
+ color: c.errorText,
1523
+ fontSize: 12,
1524
+ fontWeight: '500',
1525
+ marginTop: 8,
1526
+ },
1527
+ submitError: {
1528
+ color: c.errorText,
1529
+ fontSize: 13,
1530
+ fontWeight: '600',
1531
+ textAlign: 'center',
1532
+ paddingHorizontal: 24,
1533
+ marginTop: 4,
1534
+ },
1535
+ // Language switcher — chip row above the header. Only shown when
1536
+ // survey.languages has 2+ entries. Active chip gets the primaryColor
1537
+ // border + 10%-alpha tint (mirrors the choice-option selection
1538
+ // treatment so the language pick reads as "selected".)
1539
+ langSwitcher: {
1540
+ flexDirection: 'row',
1541
+ flexWrap: 'wrap',
1542
+ gap: 6,
1543
+ padding: 12,
1544
+ paddingBottom: 0,
1545
+ paddingRight: 48, // leave room for the close button
1546
+ },
1547
+ langChip: {
1548
+ paddingHorizontal: 10,
1549
+ paddingVertical: 5,
1550
+ borderRadius: 8,
1551
+ borderWidth: 1,
1552
+ borderColor: c.chipBorder,
1553
+ backgroundColor: c.chipBg,
1554
+ },
1555
+ langChipText: {
1556
+ color: c.chipText,
1557
+ fontSize: 11,
1558
+ fontWeight: '600',
1559
+ letterSpacing: 0.5,
1560
+ },
1561
+ // "Please complete on desktop" hint shown for question types that
1562
+ // require heavy native UX (matrix, panelDynamic, signature, etc.).
1563
+ // Phase 4.5 will bring native implementations for the priority ones.
1564
+ desktopHint: {
1565
+ color: c.hintText,
1566
+ fontSize: 13,
1567
+ fontStyle: 'italic',
1568
+ padding: 14,
1569
+ borderRadius: 12,
1570
+ backgroundColor: c.hintBg,
1571
+ borderWidth: 1,
1572
+ borderColor: c.hintBorder,
1573
+ marginTop: 4,
1574
+ },
1575
+ footer: {
1576
+ flexDirection: 'row',
1577
+ paddingLeft: c.cardPaddingLeft ?? c.cardPadding,
1578
+ paddingRight: c.cardPaddingRight ?? c.cardPadding,
1579
+ paddingBottom: c.cardPaddingBottom ?? c.cardPadding,
1580
+ paddingTop: 16,
1581
+ gap: 12,
1582
+ },
1583
+ backButton: {
1584
+ flex: 1,
1585
+ paddingVertical: c.buttonPaddingY,
1586
+ borderRadius: c.buttonRadius,
1587
+ backgroundColor: c.secondaryBg,
1588
+ borderWidth: 1,
1589
+ borderColor: c.secondaryBorder,
1590
+ alignItems: 'center',
1591
+ },
1592
+ backButtonText: {
1593
+ color: c.secondaryText,
1594
+ fontSize: c.buttonFontSize,
1595
+ fontWeight: '600',
1596
+ },
1597
+ voiceMic: {
1598
+ width: 48,
1599
+ // Stretches to the row rather than computing a matching height — the same
1600
+ // padding does not produce the same height for a glyph and a text line, so
1601
+ // the arithmetic version sat proud of the buttons beside it.
1602
+ alignSelf: 'stretch',
1603
+ borderRadius: c.buttonRadius,
1604
+ alignItems: 'center',
1605
+ justifyContent: 'center',
1606
+ flexGrow: 0,
1607
+ flexShrink: 0,
1608
+ },
1609
+ voiceMicGlyph: {
1610
+ fontSize: 20,
1611
+ },
1612
+ nextButton: {
1613
+ flex: 1,
1614
+ paddingVertical: c.buttonPaddingY,
1615
+ borderRadius: c.buttonRadius,
1616
+ backgroundColor: c.primaryBg,
1617
+ alignItems: 'center',
1618
+ },
1619
+ nextButtonFull: {
1620
+ flex: 1,
1621
+ },
1622
+ nextButtonText: {
1623
+ color: c.primaryText,
1624
+ fontSize: c.buttonFontSize,
1625
+ fontWeight: '700',
1626
+ },
1627
+ thankYou: {
1628
+ padding: 48,
1629
+ alignItems: 'center',
1630
+ justifyContent: 'center',
1631
+ },
1632
+ thankYouEmoji: {
1633
+ fontSize: 48,
1634
+ color: c.accent,
1635
+ marginBottom: 16,
1636
+ },
1637
+ thankYouImage: {
1638
+ width: 72,
1639
+ height: 72,
1640
+ marginBottom: 16,
1641
+ },
1642
+ thankYouTitle: {
1643
+ color: c.headingColor,
1644
+ fontSize: 18,
1645
+ fontWeight: '600',
1646
+ textAlign: 'center',
1647
+ lineHeight: 26,
1648
+ },
1649
+ });
1650
+ //# sourceMappingURL=SurveyModal.js.map