@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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/babel.js +101 -0
- package/instrumented/Pressable.d.ts +2 -0
- package/instrumented/Pressable.js +2 -0
- package/instrumented/ScrollView.d.ts +2 -0
- package/instrumented/ScrollView.js +2 -0
- package/instrumented/TextInput.d.ts +2 -0
- package/instrumented/TextInput.js +2 -0
- package/instrumented/TouchableOpacity.d.ts +2 -0
- package/instrumented/TouchableOpacity.js +2 -0
- package/lib/CXPinsight.d.ts +113 -0
- package/lib/CXPinsight.d.ts.map +1 -0
- package/lib/CXPinsight.js +831 -0
- package/lib/CXPinsight.js.map +1 -0
- package/lib/CXPinsightProvider.d.ts +19 -0
- package/lib/CXPinsightProvider.d.ts.map +1 -0
- package/lib/CXPinsightProvider.js +382 -0
- package/lib/CXPinsightProvider.js.map +1 -0
- package/lib/core/autoScreenTracker.d.ts +20 -0
- package/lib/core/autoScreenTracker.d.ts.map +1 -0
- package/lib/core/autoScreenTracker.js +66 -0
- package/lib/core/autoScreenTracker.js.map +1 -0
- package/lib/core/deepLinkTracker.d.ts +17 -0
- package/lib/core/deepLinkTracker.d.ts.map +1 -0
- package/lib/core/deepLinkTracker.js +81 -0
- package/lib/core/deepLinkTracker.js.map +1 -0
- package/lib/core/deviceInfo.d.ts +14 -0
- package/lib/core/deviceInfo.d.ts.map +1 -0
- package/lib/core/deviceInfo.js +166 -0
- package/lib/core/deviceInfo.js.map +1 -0
- package/lib/core/eventQueue.d.ts +23 -0
- package/lib/core/eventQueue.d.ts.map +1 -0
- package/lib/core/eventQueue.js +240 -0
- package/lib/core/eventQueue.js.map +1 -0
- package/lib/core/lifecycle.d.ts +18 -0
- package/lib/core/lifecycle.d.ts.map +1 -0
- package/lib/core/lifecycle.js +127 -0
- package/lib/core/lifecycle.js.map +1 -0
- package/lib/core/mobileRecorder.d.ts +15 -0
- package/lib/core/mobileRecorder.d.ts.map +1 -0
- package/lib/core/mobileRecorder.js +231 -0
- package/lib/core/mobileRecorder.js.map +1 -0
- package/lib/core/network.d.ts +10 -0
- package/lib/core/network.d.ts.map +1 -0
- package/lib/core/network.js +74 -0
- package/lib/core/network.js.map +1 -0
- package/lib/core/networkCapture.d.ts +22 -0
- package/lib/core/networkCapture.d.ts.map +1 -0
- package/lib/core/networkCapture.js +100 -0
- package/lib/core/networkCapture.js.map +1 -0
- package/lib/core/scrollCapture.d.ts +48 -0
- package/lib/core/scrollCapture.d.ts.map +1 -0
- package/lib/core/scrollCapture.js +221 -0
- package/lib/core/scrollCapture.js.map +1 -0
- package/lib/core/session.d.ts +15 -0
- package/lib/core/session.d.ts.map +1 -0
- package/lib/core/session.js +157 -0
- package/lib/core/session.js.map +1 -0
- package/lib/core/storage.d.ts +11 -0
- package/lib/core/storage.d.ts.map +1 -0
- package/lib/core/storage.js +59 -0
- package/lib/core/storage.js.map +1 -0
- package/lib/core/triggerEngine.d.ts +56 -0
- package/lib/core/triggerEngine.d.ts.map +1 -0
- package/lib/core/triggerEngine.js +432 -0
- package/lib/core/triggerEngine.js.map +1 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/instrumented/Pressable.d.ts +9 -0
- package/lib/instrumented/Pressable.d.ts.map +1 -0
- package/lib/instrumented/Pressable.js +96 -0
- package/lib/instrumented/Pressable.js.map +1 -0
- package/lib/instrumented/ScrollView.d.ts +6 -0
- package/lib/instrumented/ScrollView.d.ts.map +1 -0
- package/lib/instrumented/ScrollView.js +101 -0
- package/lib/instrumented/ScrollView.js.map +1 -0
- package/lib/instrumented/TextInput.d.ts +9 -0
- package/lib/instrumented/TextInput.d.ts.map +1 -0
- package/lib/instrumented/TextInput.js +75 -0
- package/lib/instrumented/TextInput.js.map +1 -0
- package/lib/instrumented/TouchableOpacity.d.ts +11 -0
- package/lib/instrumented/TouchableOpacity.d.ts.map +1 -0
- package/lib/instrumented/TouchableOpacity.js +96 -0
- package/lib/instrumented/TouchableOpacity.js.map +1 -0
- package/lib/instrumented/index.d.ts +19 -0
- package/lib/instrumented/index.d.ts.map +1 -0
- package/lib/instrumented/index.js +28 -0
- package/lib/instrumented/index.js.map +1 -0
- package/lib/instrumented/interactionObserver.d.ts +29 -0
- package/lib/instrumented/interactionObserver.d.ts.map +1 -0
- package/lib/instrumented/interactionObserver.js +38 -0
- package/lib/instrumented/interactionObserver.js.map +1 -0
- package/lib/setup/AccessibilityScanner.d.ts +32 -0
- package/lib/setup/AccessibilityScanner.d.ts.map +1 -0
- package/lib/setup/AccessibilityScanner.js +127 -0
- package/lib/setup/AccessibilityScanner.js.map +1 -0
- package/lib/setup/ElementIdentifier.d.ts +65 -0
- package/lib/setup/ElementIdentifier.d.ts.map +1 -0
- package/lib/setup/ElementIdentifier.js +114 -0
- package/lib/setup/ElementIdentifier.js.map +1 -0
- package/lib/setup/ElementRegistry.d.ts +22 -0
- package/lib/setup/ElementRegistry.d.ts.map +1 -0
- package/lib/setup/ElementRegistry.js +47 -0
- package/lib/setup/ElementRegistry.js.map +1 -0
- package/lib/setup/SetupCodeEntry.d.ts +15 -0
- package/lib/setup/SetupCodeEntry.d.ts.map +1 -0
- package/lib/setup/SetupCodeEntry.js +198 -0
- package/lib/setup/SetupCodeEntry.js.map +1 -0
- package/lib/setup/SetupController.d.ts +29 -0
- package/lib/setup/SetupController.d.ts.map +1 -0
- package/lib/setup/SetupController.js +189 -0
- package/lib/setup/SetupController.js.map +1 -0
- package/lib/setup/SetupOverlay.d.ts +10 -0
- package/lib/setup/SetupOverlay.d.ts.map +1 -0
- package/lib/setup/SetupOverlay.js +408 -0
- package/lib/setup/SetupOverlay.js.map +1 -0
- package/lib/setup/TapInterceptor.d.ts +19 -0
- package/lib/setup/TapInterceptor.d.ts.map +1 -0
- package/lib/setup/TapInterceptor.js +98 -0
- package/lib/setup/TapInterceptor.js.map +1 -0
- package/lib/setup/TouchObserver.d.ts +25 -0
- package/lib/setup/TouchObserver.d.ts.map +1 -0
- package/lib/setup/TouchObserver.js +88 -0
- package/lib/setup/TouchObserver.js.map +1 -0
- package/lib/setup/TouchPatcher.d.ts +27 -0
- package/lib/setup/TouchPatcher.d.ts.map +1 -0
- package/lib/setup/TouchPatcher.js +182 -0
- package/lib/setup/TouchPatcher.js.map +1 -0
- package/lib/setup/index.d.ts +13 -0
- package/lib/setup/index.d.ts.map +1 -0
- package/lib/setup/index.js +45 -0
- package/lib/setup/index.js.map +1 -0
- package/lib/setup/useCXPElement.d.ts +25 -0
- package/lib/setup/useCXPElement.d.ts.map +1 -0
- package/lib/setup/useCXPElement.js +33 -0
- package/lib/setup/useCXPElement.js.map +1 -0
- package/lib/survey/SurveyModal.d.ts +10 -0
- package/lib/survey/SurveyModal.d.ts.map +1 -0
- package/lib/survey/SurveyModal.js +1650 -0
- package/lib/survey/SurveyModal.js.map +1 -0
- package/lib/survey/expressionEval.d.ts +10 -0
- package/lib/survey/expressionEval.d.ts.map +1 -0
- package/lib/survey/expressionEval.js +287 -0
- package/lib/survey/expressionEval.js.map +1 -0
- package/lib/survey/frequencyCap.d.ts +15 -0
- package/lib/survey/frequencyCap.d.ts.map +1 -0
- package/lib/survey/frequencyCap.js +116 -0
- package/lib/survey/frequencyCap.js.map +1 -0
- package/lib/survey/handsFreeVoice.d.ts +30 -0
- package/lib/survey/handsFreeVoice.d.ts.map +1 -0
- package/lib/survey/handsFreeVoice.js +312 -0
- package/lib/survey/handsFreeVoice.js.map +1 -0
- package/lib/survey/interpolate.d.ts +6 -0
- package/lib/survey/interpolate.d.ts.map +1 -0
- package/lib/survey/interpolate.js +61 -0
- package/lib/survey/interpolate.js.map +1 -0
- package/lib/survey/pagesFromV1.d.ts +6 -0
- package/lib/survey/pagesFromV1.d.ts.map +1 -0
- package/lib/survey/pagesFromV1.js +83 -0
- package/lib/survey/pagesFromV1.js.map +1 -0
- package/lib/survey/questions/CSATQuestion.d.ts +16 -0
- package/lib/survey/questions/CSATQuestion.d.ts.map +1 -0
- package/lib/survey/questions/CSATQuestion.js +37 -0
- package/lib/survey/questions/CSATQuestion.js.map +1 -0
- package/lib/survey/questions/DateQuestion.d.ts +20 -0
- package/lib/survey/questions/DateQuestion.d.ts.map +1 -0
- package/lib/survey/questions/DateQuestion.js +103 -0
- package/lib/survey/questions/DateQuestion.js.map +1 -0
- package/lib/survey/questions/ImagePickerQuestion.d.ts +33 -0
- package/lib/survey/questions/ImagePickerQuestion.d.ts.map +1 -0
- package/lib/survey/questions/ImagePickerQuestion.js +68 -0
- package/lib/survey/questions/ImagePickerQuestion.js.map +1 -0
- package/lib/survey/questions/ImageQuestion.d.ts +26 -0
- package/lib/survey/questions/ImageQuestion.d.ts.map +1 -0
- package/lib/survey/questions/ImageQuestion.js +24 -0
- package/lib/survey/questions/ImageQuestion.js.map +1 -0
- package/lib/survey/questions/MatrixCell.d.ts +45 -0
- package/lib/survey/questions/MatrixCell.d.ts.map +1 -0
- package/lib/survey/questions/MatrixCell.js +54 -0
- package/lib/survey/questions/MatrixCell.js.map +1 -0
- package/lib/survey/questions/MatrixDropdownQuestion.d.ts +29 -0
- package/lib/survey/questions/MatrixDropdownQuestion.d.ts.map +1 -0
- package/lib/survey/questions/MatrixDropdownQuestion.js +42 -0
- package/lib/survey/questions/MatrixDropdownQuestion.js.map +1 -0
- package/lib/survey/questions/MatrixDynamicQuestion.d.ts +26 -0
- package/lib/survey/questions/MatrixDynamicQuestion.d.ts.map +1 -0
- package/lib/survey/questions/MatrixDynamicQuestion.js +52 -0
- package/lib/survey/questions/MatrixDynamicQuestion.js.map +1 -0
- package/lib/survey/questions/MatrixQuestion.d.ts +38 -0
- package/lib/survey/questions/MatrixQuestion.d.ts.map +1 -0
- package/lib/survey/questions/MatrixQuestion.js +64 -0
- package/lib/survey/questions/MatrixQuestion.js.map +1 -0
- package/lib/survey/questions/MultiTextQuestion.d.ts +19 -0
- package/lib/survey/questions/MultiTextQuestion.d.ts.map +1 -0
- package/lib/survey/questions/MultiTextQuestion.js +64 -0
- package/lib/survey/questions/MultiTextQuestion.js.map +1 -0
- package/lib/survey/questions/MultipleChoiceQuestion.d.ts +25 -0
- package/lib/survey/questions/MultipleChoiceQuestion.d.ts.map +1 -0
- package/lib/survey/questions/MultipleChoiceQuestion.js +111 -0
- package/lib/survey/questions/MultipleChoiceQuestion.js.map +1 -0
- package/lib/survey/questions/NPSQuestion.d.ts +18 -0
- package/lib/survey/questions/NPSQuestion.d.ts.map +1 -0
- package/lib/survey/questions/NPSQuestion.js +62 -0
- package/lib/survey/questions/NPSQuestion.js.map +1 -0
- package/lib/survey/questions/PanelDynamicQuestion.d.ts +50 -0
- package/lib/survey/questions/PanelDynamicQuestion.d.ts.map +1 -0
- package/lib/survey/questions/PanelDynamicQuestion.js +62 -0
- package/lib/survey/questions/PanelDynamicQuestion.js.map +1 -0
- package/lib/survey/questions/RankingQuestion.d.ts +34 -0
- package/lib/survey/questions/RankingQuestion.d.ts.map +1 -0
- package/lib/survey/questions/RankingQuestion.js +85 -0
- package/lib/survey/questions/RankingQuestion.js.map +1 -0
- package/lib/survey/questions/RatingQuestion.d.ts +20 -0
- package/lib/survey/questions/RatingQuestion.d.ts.map +1 -0
- package/lib/survey/questions/RatingQuestion.js +53 -0
- package/lib/survey/questions/RatingQuestion.js.map +1 -0
- package/lib/survey/questions/TextQuestion.d.ts +17 -0
- package/lib/survey/questions/TextQuestion.d.ts.map +1 -0
- package/lib/survey/questions/TextQuestion.js +46 -0
- package/lib/survey/questions/TextQuestion.js.map +1 -0
- package/lib/survey/styleContract.d.ts +230 -0
- package/lib/survey/styleContract.d.ts.map +1 -0
- package/lib/survey/styleContract.js +365 -0
- package/lib/survey/styleContract.js.map +1 -0
- package/lib/survey/themePalette.d.ts +56 -0
- package/lib/survey/themePalette.d.ts.map +1 -0
- package/lib/survey/themePalette.js +63 -0
- package/lib/survey/themePalette.js.map +1 -0
- package/lib/survey/translationLookup.d.ts +4 -0
- package/lib/survey/translationLookup.d.ts.map +1 -0
- package/lib/survey/translationLookup.js +62 -0
- package/lib/survey/translationLookup.js.map +1 -0
- package/lib/survey/validate.d.ts +3 -0
- package/lib/survey/validate.d.ts.map +1 -0
- package/lib/survey/validate.js +159 -0
- package/lib/survey/validate.js.map +1 -0
- package/lib/survey/variablesResolver.d.ts +10 -0
- package/lib/survey/variablesResolver.d.ts.map +1 -0
- package/lib/survey/variablesResolver.js +107 -0
- package/lib/survey/variablesResolver.js.map +1 -0
- package/lib/survey/voiceParsers.d.ts +47 -0
- package/lib/survey/voiceParsers.d.ts.map +1 -0
- package/lib/survey/voiceParsers.js +310 -0
- package/lib/survey/voiceParsers.js.map +1 -0
- package/lib/types.d.ts +349 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +20 -0
- package/lib/types.js.map +1 -0
- package/metro.js +55 -0
- 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(/ /gi, ' ')
|
|
86
|
+
.replace(/&/gi, '&')
|
|
87
|
+
.replace(/</gi, '<')
|
|
88
|
+
.replace(/>/gi, '>')
|
|
89
|
+
.replace(/"/gi, '"')
|
|
90
|
+
.replace(/'/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 so it never shows literal HTML on native.
|
|
1313
|
+
String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
|
|
1314
|
+
.replace(/<[^>]*>/g, '').replace(/ /gi, ' '), evalCtx) }), (() => {
|
|
1315
|
+
// Description is authored as rich HTML; native Text can't render
|
|
1316
|
+
// markup, so strip tags + decode to plain text.
|
|
1317
|
+
const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
|
|
1318
|
+
const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/ /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
|