@cxpinsight/react-native 0.17.8 → 0.18.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/CHANGELOG.md +21 -0
- package/lib/CXPinsight.d.ts +2 -0
- package/lib/CXPinsight.d.ts.map +1 -1
- package/lib/CXPinsight.js +51 -2
- package/lib/CXPinsight.js.map +1 -1
- package/lib/CXPinsightProvider.d.ts.map +1 -1
- package/lib/CXPinsightProvider.js +211 -10
- package/lib/CXPinsightProvider.js.map +1 -1
- package/lib/core/mobileRecorder.d.ts +19 -0
- package/lib/core/mobileRecorder.d.ts.map +1 -1
- package/lib/core/mobileRecorder.js +51 -3
- package/lib/core/mobileRecorder.js.map +1 -1
- package/lib/core/triggerEngine.d.ts +17 -0
- package/lib/core/triggerEngine.d.ts.map +1 -1
- package/lib/core/triggerEngine.js +17 -3
- package/lib/core/triggerEngine.js.map +1 -1
- package/lib/core/viewShot.d.ts +10 -0
- package/lib/core/viewShot.d.ts.map +1 -0
- package/lib/core/viewShot.js +56 -0
- package/lib/core/viewShot.js.map +1 -0
- package/lib/instrumented/interactionObserver.d.ts.map +1 -1
- package/lib/instrumented/interactionObserver.js +32 -9
- package/lib/instrumented/interactionObserver.js.map +1 -1
- package/lib/survey/SurveyModal.d.ts +72 -1
- package/lib/survey/SurveyModal.d.ts.map +1 -1
- package/lib/survey/SurveyModal.js +1162 -392
- package/lib/survey/SurveyModal.js.map +1 -1
- package/lib/survey/fadeGradient.d.ts +35 -0
- package/lib/survey/fadeGradient.d.ts.map +1 -0
- package/lib/survey/fadeGradient.js +68 -0
- package/lib/survey/fadeGradient.js.map +1 -0
- package/lib/survey/styleContract.d.ts +49 -1
- package/lib/survey/styleContract.d.ts.map +1 -1
- package/lib/survey/styleContract.js +76 -17
- package/lib/survey/styleContract.js.map +1 -1
- package/lib/types.d.ts +10 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/package.json +9 -5
|
@@ -33,12 +33,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SWIPE_DISMISS_VELOCITY = exports.SWIPE_DISMISS_PX = exports.SWIPE_CLAIM_PX = void 0;
|
|
36
37
|
exports.stylePlatformSlice = stylePlatformSlice;
|
|
37
38
|
exports.topInsetForHeight = topInsetForHeight;
|
|
38
39
|
exports.resolvePopupTop = resolvePopupTop;
|
|
40
|
+
exports.shouldClaimSwipe = shouldClaimSwipe;
|
|
41
|
+
exports.swipeOffset = swipeOffset;
|
|
42
|
+
exports.swipeRelease = swipeRelease;
|
|
43
|
+
exports.surfaceScrolls = surfaceScrolls;
|
|
39
44
|
exports.SurveyModal = SurveyModal;
|
|
45
|
+
const react_1 = require("react");
|
|
40
46
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
41
|
-
const
|
|
47
|
+
const react_2 = __importStar(require("react"));
|
|
42
48
|
const react_native_1 = require("react-native");
|
|
43
49
|
const types_1 = require("../types");
|
|
44
50
|
const storage = __importStar(require("../core/storage"));
|
|
@@ -55,6 +61,7 @@ const MatrixDynamicQuestion_1 = require("./questions/MatrixDynamicQuestion");
|
|
|
55
61
|
const PanelDynamicQuestion_1 = require("./questions/PanelDynamicQuestion");
|
|
56
62
|
const DateQuestion_1 = require("./questions/DateQuestion");
|
|
57
63
|
const survey_spec_1 = require("@cxpinsight/survey-spec");
|
|
64
|
+
const mobileRecorder_1 = require("../core/mobileRecorder");
|
|
58
65
|
const SignatureQuestion_1 = require("./questions/SignatureQuestion");
|
|
59
66
|
const FileUploadQuestion_1 = require("./questions/FileUploadQuestion");
|
|
60
67
|
const MultiTextQuestion_1 = require("./questions/MultiTextQuestion");
|
|
@@ -69,6 +76,7 @@ const validate_1 = require("./validate");
|
|
|
69
76
|
const pagesFromV1_1 = require("./pagesFromV1");
|
|
70
77
|
const themePalette_1 = require("./themePalette");
|
|
71
78
|
const styleContract_1 = require("./styleContract");
|
|
79
|
+
const fadeGradient_1 = require("./fadeGradient");
|
|
72
80
|
const CXPinsight_1 = require("../CXPinsight");
|
|
73
81
|
// ISO 639-1 codes for right-to-left scripts. When the respondent picks
|
|
74
82
|
// one of these in the language switcher, the modal flips to RTL even if
|
|
@@ -145,10 +153,96 @@ function resolvePopupTop(opts) {
|
|
|
145
153
|
// A card taller than the safe band pins to the top rather than going negative.
|
|
146
154
|
return Math.max(minTop, maxTop > minTop ? Math.min(wanted, maxTop) : minTop);
|
|
147
155
|
}
|
|
156
|
+
/* ── Swipe to dismiss: the decisions, as named rules ───────────────────────
|
|
157
|
+
*
|
|
158
|
+
* A push notification goes away when you flick it, and the mobile display
|
|
159
|
+
* slice drops the ✕ for exactly that reason — so on a notification this
|
|
160
|
+
* gesture is not a convenience, it is the only way out.
|
|
161
|
+
*
|
|
162
|
+
* Every threshold below was wrong at least once, and each one was wrong in a
|
|
163
|
+
* way that surfaced as the same single report: "the notification doesn't swipe
|
|
164
|
+
* away". They live out here as named, testable rules rather than as numbers
|
|
165
|
+
* inside the gesture handler, because that report could not tell them apart.
|
|
166
|
+
*/
|
|
167
|
+
/** Travel, in points, at which a touch stops being a tap and becomes a drag. */
|
|
168
|
+
exports.SWIPE_CLAIM_PX = 8;
|
|
169
|
+
/** Travel at which letting go dismisses, whatever the speed. */
|
|
170
|
+
exports.SWIPE_DISMISS_PX = 40;
|
|
171
|
+
/** Speed at which letting go dismisses, whatever the travel. */
|
|
172
|
+
exports.SWIPE_DISMISS_VELOCITY = 0.3;
|
|
173
|
+
/**
|
|
174
|
+
* Should the card take this gesture off whatever is underneath it?
|
|
175
|
+
*
|
|
176
|
+
* Asked on the CAPTURE phase, which is what makes the whole surface a handle.
|
|
177
|
+
* On the bubble phase the card is consulted only after some descendant has
|
|
178
|
+
* already become the responder — and on a notification that means the action
|
|
179
|
+
* button and nothing else. Touch the title, the thumbnail or the body and no
|
|
180
|
+
* view claims the touch, so the card was never asked and the banner could only
|
|
181
|
+
* be dragged by its button.
|
|
182
|
+
*
|
|
183
|
+
* The threshold is what keeps a button a button: a press that does not travel
|
|
184
|
+
* is never claimed here, so it reaches the button untouched.
|
|
185
|
+
*/
|
|
186
|
+
function shouldClaimSwipe(swipeable, dx, dy) {
|
|
187
|
+
return swipeable && (Math.abs(dx) > exports.SWIPE_CLAIM_PX || Math.abs(dy) > exports.SWIPE_CLAIM_PX);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Where the card sits while the finger is still down.
|
|
191
|
+
*
|
|
192
|
+
* A banner travels off its own edge and no further: dragging a top banner
|
|
193
|
+
* downwards is a pull, not a dismissal, so it does not follow.
|
|
194
|
+
*/
|
|
195
|
+
function swipeOffset(placement, dx, dy) {
|
|
196
|
+
if (Math.abs(dx) > Math.abs(dy))
|
|
197
|
+
return { x: dx, y: 0 };
|
|
198
|
+
const clamped = placement === 'top' ? Math.min(0, dy)
|
|
199
|
+
: placement === 'bottom' ? Math.max(0, dy) : dy;
|
|
200
|
+
return { x: 0, y: clamped };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Does letting go here dismiss the card?
|
|
204
|
+
*
|
|
205
|
+
* 56px with a 0.5 velocity escape assumed the gesture is thrown. A deliberate,
|
|
206
|
+
* slow drag is just as clear an instruction and was being refused: measured on
|
|
207
|
+
* a real attempt, 46px of travel at 0.01 velocity — unmistakably someone
|
|
208
|
+
* pushing the card off screen — sprang back, because it missed both gates.
|
|
209
|
+
*
|
|
210
|
+
* 40px is still far enough that a stray touch cannot reach it (a drag is not
|
|
211
|
+
* even claimed below 8px, and a tap typically travels 2-3), and 0.3 catches a
|
|
212
|
+
* light flick that covers less ground.
|
|
213
|
+
*/
|
|
214
|
+
function swipeRelease(g) {
|
|
215
|
+
const horizontal = Math.abs(g.dx) > Math.abs(g.dy);
|
|
216
|
+
const moved = horizontal ? g.dx : g.dy;
|
|
217
|
+
const speed = Math.abs(horizontal ? g.vx : g.vy);
|
|
218
|
+
return {
|
|
219
|
+
horizontal,
|
|
220
|
+
moved,
|
|
221
|
+
dismiss: Math.abs(moved) >= exports.SWIPE_DISMISS_PX || speed > exports.SWIPE_DISMISS_VELOCITY,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* May the card's body scroll?
|
|
226
|
+
*
|
|
227
|
+
* NOT on a notification, and the reason is the swipe rather than the layout.
|
|
228
|
+
* On iOS that body is a native UIScrollView: once it starts scrolling it owns
|
|
229
|
+
* the touch and the JS responder cannot take it back, so claiming the gesture
|
|
230
|
+
* on the card achieved nothing. The travel went into scrolling content that
|
|
231
|
+
* does not overflow, and the pan saw ~19px at release — under any dismissal
|
|
232
|
+
* threshold worth having. The give-away was the SDK's own capture: ten
|
|
233
|
+
* `scroll` events queued between the card claiming the gesture and the release.
|
|
234
|
+
*
|
|
235
|
+
* Keyed off the INTERACTION, not off whether a thumbnail was composed. A
|
|
236
|
+
* notification with no image is still two lines and a button, and gating this
|
|
237
|
+
* on the image is what left exactly those notifications un-dismissable.
|
|
238
|
+
*/
|
|
239
|
+
function surfaceScrolls(interactionKind) {
|
|
240
|
+
return String(interactionKind || '').toLowerCase() !== 'notify';
|
|
241
|
+
}
|
|
148
242
|
function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
149
|
-
const [currentPage, setCurrentPage] = (0,
|
|
150
|
-
const [responses, setResponses] = (0,
|
|
151
|
-
const [showThankYou, setShowThankYou] = (0,
|
|
243
|
+
const [currentPage, setCurrentPage] = (0, react_2.useState)(0);
|
|
244
|
+
const [responses, setResponses] = (0, react_2.useState)({});
|
|
245
|
+
const [showThankYou, setShowThankYou] = (0, react_2.useState)(false);
|
|
152
246
|
// Back-navigation history. Non-linear jumps via branches mean the
|
|
153
247
|
// previous page is NOT always currentPage - 1; the stack records
|
|
154
248
|
// the pages the respondent actually walked through. Matches the
|
|
@@ -156,55 +250,74 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
156
250
|
// v1.1 multi-flow (spec §28): also records the flow the respondent
|
|
157
251
|
// was on at that point — Back after a fork restores BOTH pieces of
|
|
158
252
|
// state.
|
|
159
|
-
const [history, setHistory] = (0,
|
|
253
|
+
const [history, setHistory] = (0, react_2.useState)([]);
|
|
160
254
|
// Multi-flow state (spec §28). currentFlow tracks which flow the
|
|
161
255
|
// respondent is walking through right now. undefined = root flow.
|
|
162
256
|
// When a fork branch fires we push the origin onto forkStack and
|
|
163
257
|
// switch currentFlow to the branch's target flowId.
|
|
164
|
-
const [currentFlow, setCurrentFlow] = (0,
|
|
165
|
-
const [forkStack, setForkStack] = (0,
|
|
258
|
+
const [currentFlow, setCurrentFlow] = (0, react_2.useState)(undefined);
|
|
259
|
+
const [forkStack, setForkStack] = (0, react_2.useState)([]);
|
|
166
260
|
// Per-question validation errors surfaced on Next / Submit.
|
|
167
261
|
// Populated when a required question has no answer; cleared when
|
|
168
262
|
// the respondent supplies one. Keyed by question id/name so a
|
|
169
263
|
// page with multiple required questions can flag several at once.
|
|
170
264
|
// Web renderer keeps a `touched` set to live-re-validate after the
|
|
171
265
|
// first flag; mobile MVP just clears on updateResponse for now.
|
|
172
|
-
const [errors, setErrors] = (0,
|
|
266
|
+
const [errors, setErrors] = (0, react_2.useState)({});
|
|
173
267
|
// Set when the server rejects a submit (returns false). Shown near the
|
|
174
268
|
// footer; cleared on retry / navigation / dismiss.
|
|
175
|
-
const [submitError, setSubmitError] = (0,
|
|
269
|
+
const [submitError, setSubmitError] = (0, react_2.useState)('');
|
|
176
270
|
// Hands-free voice state. voiceIdxRef walks the current page's questions;
|
|
177
271
|
// voiceDoneRef flips once the last page has been submitted by voice.
|
|
178
|
-
const voiceIdxRef =
|
|
179
|
-
const voiceDoneRef =
|
|
180
|
-
const [voiceActive, setVoiceActive] = (0,
|
|
181
|
-
const [voicePhase, setVoicePhase] = (0,
|
|
182
|
-
const [voiceTranscript, setVoiceTranscript] = (0,
|
|
272
|
+
const voiceIdxRef = react_2.default.useRef(0);
|
|
273
|
+
const voiceDoneRef = react_2.default.useRef(false);
|
|
274
|
+
const [voiceActive, setVoiceActive] = (0, react_2.useState)(false);
|
|
275
|
+
const [voicePhase, setVoicePhase] = (0, react_2.useState)('idle');
|
|
276
|
+
const [voiceTranscript, setVoiceTranscript] = (0, react_2.useState)('');
|
|
183
277
|
// Selected language for the current session. Initialized to
|
|
184
278
|
// survey.defaultLanguage; the useEffect below hydrates from
|
|
185
279
|
// AsyncStorage on mount so a repeat respondent keeps their
|
|
186
280
|
// previous pick. Only restored if that language is still in the
|
|
187
281
|
// current survey's languages[] — an older stored value that's no
|
|
188
282
|
// longer offered silently falls back to defaultLanguage.
|
|
189
|
-
const [currentLanguage, setCurrentLanguageRaw] = (0,
|
|
190
|
-
const setCurrentLanguage = (0,
|
|
283
|
+
const [currentLanguage, setCurrentLanguageRaw] = (0, react_2.useState)(survey.defaultLanguage);
|
|
284
|
+
const setCurrentLanguage = (0, react_2.useCallback)((next) => {
|
|
191
285
|
setCurrentLanguageRaw(next);
|
|
192
286
|
// Fire-and-forget — persistence is a "nice to have", never
|
|
193
287
|
// block the switcher UX on a slow storage write.
|
|
194
288
|
storage.set(types_1.STORAGE_KEYS.SURVEY_LANGUAGE, next).catch(() => { });
|
|
195
289
|
}, []);
|
|
196
|
-
|
|
290
|
+
// THE RECORDING NEEDS A HANDLE ON THIS CARD.
|
|
291
|
+
//
|
|
292
|
+
// A <Modal> is its own UIWindow on iOS, and the host's captureScreen returns
|
|
293
|
+
// the topmost one — so a frame taken while this is up cannot be trusted to
|
|
294
|
+
// contain either the card or the app behind it. Handing the recorder the
|
|
295
|
+
// card's own view removes the guesswork. Registered while it is on screen
|
|
296
|
+
// and cleared on the way out, so no other frame is captured through it.
|
|
297
|
+
const cardViewRef = (0, react_2.useRef)(null);
|
|
298
|
+
(0, react_2.useEffect)(() => {
|
|
299
|
+
(0, mobileRecorder_1.registerSurveyView)(cardViewRef);
|
|
300
|
+
return () => (0, mobileRecorder_1.clearSurveyView)();
|
|
301
|
+
}, []);
|
|
302
|
+
(0, react_2.useEffect)(() => {
|
|
197
303
|
let cancelled = false;
|
|
198
304
|
// Device locale → the survey's matching offered language, when the respondent
|
|
199
305
|
// has no saved pick — so an Arabic device opens the survey in Arabic instead of
|
|
200
306
|
// defaultLanguage. A saved manual switch still wins over the device locale.
|
|
201
307
|
let deviceLang;
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
deviceLang = primary
|
|
308
|
+
const offered = (tag) => {
|
|
309
|
+
const primary = (typeof tag === 'string' ? tag : '').split(/[-_]/)[0].toLowerCase();
|
|
310
|
+
return primary
|
|
206
311
|
? survey.languages?.find((l) => l.split(/[-_]/)[0].toLowerCase() === primary)
|
|
207
312
|
: undefined;
|
|
313
|
+
};
|
|
314
|
+
try {
|
|
315
|
+
// identify({ language }) FIRST, then the device locale. A host that knows
|
|
316
|
+
// its signed-in reader prefers Arabic has said something more reliable
|
|
317
|
+
// than the handset's setting, and it was the one signal never asked for
|
|
318
|
+
// — the same gap theme had, on the other half of the same cascade.
|
|
319
|
+
deviceLang = offered(CXPinsight_1.CXPinsight._getUserIdentity()?.language)
|
|
320
|
+
|| offered((0, deviceInfo_1.getDeviceInfo)().language);
|
|
208
321
|
}
|
|
209
322
|
catch { /* no locale available → stays defaultLanguage */ }
|
|
210
323
|
storage.get(types_1.STORAGE_KEYS.SURVEY_LANGUAGE).then((saved) => {
|
|
@@ -231,7 +344,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
231
344
|
const presentationMode = typeof survey.settings?.presentationMode === 'string'
|
|
232
345
|
? survey.settings.presentationMode
|
|
233
346
|
: undefined;
|
|
234
|
-
const pages = (0,
|
|
347
|
+
const pages = (0, react_2.useMemo)(() => {
|
|
235
348
|
if (survey.rawQuestions) {
|
|
236
349
|
return (0, pagesFromV1_1.pagesFromV1)({ questions: survey.rawQuestions, sections: survey.rawSections }, currentFlow, presentationMode);
|
|
237
350
|
}
|
|
@@ -258,20 +371,24 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
258
371
|
// deviceType), for authors keying on layout tier
|
|
259
372
|
// A change listener re-mounts the attribute context on OS theme toggles
|
|
260
373
|
// so mid-session `{{device.theme}}` conditions stay accurate.
|
|
261
|
-
const [colorScheme, setColorScheme] = (0,
|
|
262
|
-
(0,
|
|
374
|
+
const [colorScheme, setColorScheme] = (0, react_2.useState)(() => react_native_1.Appearance.getColorScheme() ?? undefined);
|
|
375
|
+
(0, react_2.useEffect)(() => {
|
|
263
376
|
const sub = react_native_1.Appearance.addChangeListener((prefs) => {
|
|
264
377
|
setColorScheme(prefs.colorScheme ?? undefined);
|
|
265
378
|
});
|
|
266
379
|
return () => { sub?.remove?.(); };
|
|
267
380
|
}, []);
|
|
268
|
-
const attributeContext = (0,
|
|
381
|
+
const attributeContext = (0, react_2.useMemo)(() => {
|
|
269
382
|
const identity = (CXPinsight_1.CXPinsight._getUserIdentity() ?? {});
|
|
270
383
|
const { id: _omitId, ...userTraits } = identity;
|
|
271
384
|
const raw = (0, deviceInfo_1.getDeviceInfo)();
|
|
272
385
|
const langTag = typeof raw.language === 'string' ? raw.language : 'en';
|
|
273
386
|
const langPrimary = langTag.split(/[-_]/)[0].toLowerCase();
|
|
274
387
|
const screenClass = raw.deviceType || 'mobile';
|
|
388
|
+
// The triggering event, under the same names web exposes — {{eventName}},
|
|
389
|
+
// {{eventData.total}}, {{userProperties.plan}} — so one authored string
|
|
390
|
+
// resolves the same on both runtimes.
|
|
391
|
+
const trig = survey.triggerContext;
|
|
275
392
|
const deviceSignals = {
|
|
276
393
|
...raw,
|
|
277
394
|
language: langPrimary, // override wire language with primary tag
|
|
@@ -284,9 +401,21 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
284
401
|
user: userTraits,
|
|
285
402
|
});
|
|
286
403
|
}, [colorScheme]);
|
|
404
|
+
// Merged OVER the attribute floor rather than into it, because the trigger
|
|
405
|
+
// changes per firing while the floor is memoised on the colour scheme.
|
|
406
|
+
const interpolationFloor = (0, react_2.useMemo)(() => {
|
|
407
|
+
const trig = survey.triggerContext;
|
|
408
|
+
if (!trig)
|
|
409
|
+
return attributeContext;
|
|
410
|
+
return {
|
|
411
|
+
...attributeContext,
|
|
412
|
+
...(trig.eventName != null ? { eventName: trig.eventName } : null),
|
|
413
|
+
...(trig.eventData ? { eventData: trig.eventData } : null),
|
|
414
|
+
};
|
|
415
|
+
}, [attributeContext, survey]);
|
|
287
416
|
const evalCtx = {
|
|
288
417
|
answers: responses,
|
|
289
|
-
variables: (0, variablesResolver_1.resolveVariables)(survey.variables, { sdkIdentifyData: CXPinsight_1.CXPinsight._getUserIdentity() }, responses,
|
|
418
|
+
variables: (0, variablesResolver_1.resolveVariables)(survey.variables, { sdkIdentifyData: CXPinsight_1.CXPinsight._getUserIdentity() }, responses, interpolationFloor),
|
|
290
419
|
};
|
|
291
420
|
// showWhen / hideWhen filter (spec §7). Web renderer semantics:
|
|
292
421
|
// showWhen truthy AND hideWhen falsy → visible. A missing condition
|
|
@@ -357,7 +486,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
357
486
|
// and the React renderer so a Notify/Offer/Comply template behaves the same
|
|
358
487
|
// on a phone as it does on a website. Suppresses the footer below.
|
|
359
488
|
const impliedNow = (0, survey_spec_1.impliedStepActions)(currentQuestions);
|
|
360
|
-
const updateResponse = (0,
|
|
489
|
+
const updateResponse = (0, react_2.useCallback)((name, value) => {
|
|
361
490
|
setResponses((prev) => ({ ...prev, [name]: value }));
|
|
362
491
|
// Live-clear the error entry the moment the new answer passes
|
|
363
492
|
// validation. Re-runs the same validator handleNext would fire,
|
|
@@ -486,6 +615,12 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
486
615
|
return; // keep the survey open for a retry
|
|
487
616
|
}
|
|
488
617
|
setSubmitError('');
|
|
618
|
+
// See the provider: a notification has no thank-you ending. Skipping the
|
|
619
|
+
// state entirely also skips the 2.5s window the card would otherwise sit
|
|
620
|
+
// there re-rendering after the person has already acted.
|
|
621
|
+
if (String(survey.interactionType || '')
|
|
622
|
+
.toLowerCase() === 'notify')
|
|
623
|
+
return;
|
|
489
624
|
setShowThankYou(true);
|
|
490
625
|
const duration = survey.settings?.thankYouDuration ?? 2500;
|
|
491
626
|
setTimeout(() => {
|
|
@@ -508,8 +643,8 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
508
643
|
* them to the problem and they must swipe back through every question before
|
|
509
644
|
* it. Web and link surveys move the cursor; this brings the app in line.
|
|
510
645
|
*/
|
|
511
|
-
const questionRefs =
|
|
512
|
-
const handleNextRef =
|
|
646
|
+
const questionRefs = react_2.default.useRef({});
|
|
647
|
+
const handleNextRef = react_2.default.useRef(() => { });
|
|
513
648
|
const handleNext = () => {
|
|
514
649
|
const pageErrors = validateCurrentPage();
|
|
515
650
|
if (Object.keys(pageErrors).length > 0) {
|
|
@@ -605,7 +740,68 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
605
740
|
};
|
|
606
741
|
// Kept fresh so the memoized pan responder never dismisses through a stale
|
|
607
742
|
// closure — the same reason handleNextRef exists.
|
|
608
|
-
|
|
743
|
+
/**
|
|
744
|
+
* WHAT PRESSING AN IMPLIED ACTION DOES — the press, separated from the thing
|
|
745
|
+
* pressed.
|
|
746
|
+
*
|
|
747
|
+
* This lived inside the action button's own `onPress`, so it could only ever
|
|
748
|
+
* happen if that button was on screen. Hiding it — which is the whole point
|
|
749
|
+
* of handing the press to the card — left the phone with no way to act on
|
|
750
|
+
* the interaction at all, because unlike web the card here was never
|
|
751
|
+
* pressable in the first place. Same defect, one step worse.
|
|
752
|
+
*/
|
|
753
|
+
const pressImplied = (0, react_2.useCallback)((key, a) => {
|
|
754
|
+
// "Remind me later" dismisses instead of answering, so the cadence records
|
|
755
|
+
// a SKIP and brings the interaction back after the author's wait.
|
|
756
|
+
// Answering would record a decision this person explicitly declined to
|
|
757
|
+
// make and retire it against maxSubmissions. It does not follow the
|
|
758
|
+
// action's link either — postponing is not a request to go anywhere.
|
|
759
|
+
if ((0, survey_spec_1.isDeferChoice)(a, survey.interactionType)) {
|
|
760
|
+
// A postponement, not a refusal — the server tallies it separately and
|
|
761
|
+
// the cadence treats it as a skip rather than an answer.
|
|
762
|
+
handleDismissRef.current('defer');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
updateResponse(key, a.value);
|
|
766
|
+
// Deferred a tick so setResponses has flushed before handleNext validates
|
|
767
|
+
// what it just replaced.
|
|
768
|
+
setTimeout(() => handleNextRef.current(), 0);
|
|
769
|
+
// Follow the action's deep link. `myapp://cart` is the point of this on a
|
|
770
|
+
// phone; Linking refuses a scheme the OS has no handler for, so a bad
|
|
771
|
+
// target fails quietly rather than crashing the survey. The href already
|
|
772
|
+
// passed safeActionHref in the shared spec.
|
|
773
|
+
if (a.href) {
|
|
774
|
+
react_native_1.Linking.openURL(a.href).catch(() => { });
|
|
775
|
+
}
|
|
776
|
+
}, [survey, updateResponse]);
|
|
777
|
+
const handleDismissRef = (0, react_2.useRef)(() => { });
|
|
778
|
+
// A PRESS ON A CONTROL IS A PRESS, NOT A DRAG.
|
|
779
|
+
//
|
|
780
|
+
// The card's swipe reads raw touch events, and those BUBBLE — a touch that
|
|
781
|
+
// begins on the action button reaches the card too, so the card followed the
|
|
782
|
+
// finger while someone was simply pressing "Resume". That is the whole
|
|
783
|
+
// surface behaving as a handle in the one place it must not.
|
|
784
|
+
//
|
|
785
|
+
// `onPressIn`, NOT `onTouchStart`: TouchableOpacity enumerates the props it
|
|
786
|
+
// passes to its underlying view and onTouchStart is not among them, so a
|
|
787
|
+
// handler put there is accepted by the type (it extends ViewProps) and then
|
|
788
|
+
// silently dropped at runtime. onPressIn is consumed by Pressability itself,
|
|
789
|
+
// which is why it arrives.
|
|
790
|
+
//
|
|
791
|
+
// Written so the ORDER of the two events cannot matter, because it is not
|
|
792
|
+
// guaranteed: if the press registers first the card never starts a swipe, and
|
|
793
|
+
// if the card gets there first the press takes the swipe back — safely,
|
|
794
|
+
// because at that point the finger has not travelled and nothing has moved.
|
|
795
|
+
const pressingControl = (0, react_2.useRef)(false);
|
|
796
|
+
const touchSwipe = (0, react_2.useRef)(null);
|
|
797
|
+
const swipeExempt = {
|
|
798
|
+
onPressIn: () => {
|
|
799
|
+
pressingControl.current = true;
|
|
800
|
+
if (touchSwipe.current && !touchSwipe.current.moved)
|
|
801
|
+
touchSwipe.current = null;
|
|
802
|
+
},
|
|
803
|
+
onPressOut: () => { pressingControl.current = false; },
|
|
804
|
+
};
|
|
609
805
|
/**
|
|
610
806
|
* Whether a compliance interaction currently lets the respondent leave.
|
|
611
807
|
*
|
|
@@ -614,7 +810,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
614
810
|
* would treat "Continue" as consent. Mirrors the web SDK and the survey
|
|
615
811
|
* service; the shared gate makes the decision, this only finds its inputs.
|
|
616
812
|
*/
|
|
617
|
-
const complianceVerdict = (0,
|
|
813
|
+
const complianceVerdict = (0, react_2.useMemo)(() => {
|
|
618
814
|
const qs = (survey.rawQuestions ?? []);
|
|
619
815
|
const acts = (0, survey_spec_1.impliedStepActions)(qs) || [];
|
|
620
816
|
let action = null;
|
|
@@ -635,7 +831,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
635
831
|
compliance: sv.compliance,
|
|
636
832
|
});
|
|
637
833
|
}, [survey, responses]);
|
|
638
|
-
const handleDismiss = () => {
|
|
834
|
+
const handleDismiss = (reason) => {
|
|
639
835
|
// A compliance check that closes on request is not a compliance check.
|
|
640
836
|
// Say why instead of appearing broken — being held with no explanation is
|
|
641
837
|
// indistinguishable from the surface having frozen.
|
|
@@ -654,14 +850,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
654
850
|
setSubmitError('');
|
|
655
851
|
setCurrentFlow(undefined);
|
|
656
852
|
setForkStack([]);
|
|
657
|
-
onDismiss();
|
|
853
|
+
onDismiss(reason);
|
|
658
854
|
};
|
|
659
|
-
handleDismissRef.current =
|
|
660
|
-
if (__DEV__) { /* eslint-disable-next-line no-console */
|
|
661
|
-
console.log('[CXP-SW] handleDismiss INVOKED');
|
|
662
|
-
}
|
|
663
|
-
return handleDismiss();
|
|
664
|
-
});
|
|
855
|
+
handleDismissRef.current = handleDismiss;
|
|
665
856
|
// Normalize the v1.1 options[] {value,label} and the legacy
|
|
666
857
|
// choices[] (string | {value,text}) into a single shape that
|
|
667
858
|
// MultipleChoiceQuestion accepts. hasOther appends a synthetic
|
|
@@ -866,43 +1057,46 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
866
1057
|
// can handle.
|
|
867
1058
|
const actions = (0, survey_spec_1.impliedActions)(question, (t) => (0, interpolate_1.interpolatePlain)(t, evalCtx));
|
|
868
1059
|
const list = actions.length ? actions : [{ value: 'acknowledged', label: 'Continue' }];
|
|
1060
|
+
// THE CARD MAY BE CARRYING THE ACTION ALONE.
|
|
1061
|
+
//
|
|
1062
|
+
// One rule, in the shared spec, so the three renderers cannot disagree
|
|
1063
|
+
// about when a button disappears. It refuses the hide unless the whole
|
|
1064
|
+
// card can take the press, so this can never leave a surface with
|
|
1065
|
+
// something to say and no way to say yes to it.
|
|
1066
|
+
if (!(0, survey_spec_1.showActionButton)(display, list))
|
|
1067
|
+
return null;
|
|
869
1068
|
// Consent choices render identically — see impliedEqualProminence.
|
|
870
1069
|
const equal = (0, survey_spec_1.impliedEqualProminence)([question]);
|
|
871
|
-
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.impliedActions, children: list.map((a, i) => ((0,
|
|
1070
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.impliedActions, children: list.map((a, i) => ((0, react_1.createElement)(react_native_1.TouchableOpacity, { ...swipeExempt, key: a.value || String(i),
|
|
872
1071
|
// The press is both the answer and the Next — the footer is
|
|
873
1072
|
// hidden on this step. Deferred a tick so setResponses has
|
|
874
1073
|
// flushed before handleNext validates what it just replaced.
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
// this person explicitly declined to make and retire it
|
|
880
|
-
// against maxSubmissions. It does not follow the action's
|
|
881
|
-
// link either — postponing is not a request to go anywhere.
|
|
882
|
-
// Through isDeferChoice, not the raw flag: it is false for a
|
|
883
|
-
// compliance check, and a compliance check you postpone by
|
|
884
|
-
// pressing a button is one you never have to answer.
|
|
885
|
-
if ((0, survey_spec_1.isDeferChoice)(a, survey.interactionType)) {
|
|
886
|
-
handleDismissRef.current();
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
889
|
-
updateResponse(key, a.value);
|
|
890
|
-
setTimeout(() => handleNextRef.current(), 0);
|
|
891
|
-
// Follow the action's deep link. `myapp://cart` is the point
|
|
892
|
-
// of this on a phone; Linking refuses a scheme the OS has no
|
|
893
|
-
// handler for, so a bad target fails quietly rather than
|
|
894
|
-
// crashing the survey. The href already passed
|
|
895
|
-
// safeActionHref in the shared spec.
|
|
896
|
-
if (a.href) {
|
|
897
|
-
react_native_1.Linking.openURL(a.href).catch(() => { });
|
|
898
|
-
}
|
|
899
|
-
}, accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
|
|
1074
|
+
// The press is both the answer and the Next — the footer is
|
|
1075
|
+
// hidden on this step. Its body is pressImplied, so the CARD
|
|
1076
|
+
// can do exactly the same thing when this button is not drawn.
|
|
1077
|
+
onPress: () => pressImplied(key, a), accessibilityRole: "button", accessibilityState: { selected: val === a.value }, style: [
|
|
900
1078
|
styles.impliedBtn,
|
|
1079
|
+
// BESIDE THE TEXT, THE BUTTON IS AS WIDE AS ITS LABEL.
|
|
1080
|
+
//
|
|
1081
|
+
// impliedBtn is built for the full-width layout underneath the
|
|
1082
|
+
// content: flexGrow 1 over a 140pt basis, so two actions share
|
|
1083
|
+
// the row evenly. Beside the body that basis is most of the
|
|
1084
|
+
// card, and every point it takes is a point the text wraps
|
|
1085
|
+
// into. Here it grows from nothing, and its side padding is
|
|
1086
|
+
// halved — a banner action is a word or two, and 18pt a side
|
|
1087
|
+
// was sized for a button standing on its own.
|
|
1088
|
+
notifyBeside && {
|
|
1089
|
+
flexGrow: 0,
|
|
1090
|
+
flexBasis: 'auto',
|
|
1091
|
+
paddingHorizontal: Math.max(6, Math.round(contract.buttonPaddingX / 2)),
|
|
1092
|
+
},
|
|
901
1093
|
// The first action is the affirmative one. An author who
|
|
902
1094
|
// lists "Decline" first gets it emphasised — their call.
|
|
903
1095
|
(i === 0 && !equal) ? styles.impliedPrimary : styles.impliedSecondary,
|
|
1096
|
+
(i === 0 && !equal) && authoredBtnBorder ? { borderColor: authoredBtnBorder } : null,
|
|
904
1097
|
val === a.value && styles.impliedChosen,
|
|
905
|
-
]
|
|
1098
|
+
] },
|
|
1099
|
+
(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: (i === 0 && !equal) ? styles.impliedPrimaryLabel : styles.impliedSecondaryLabel, children: a.label })))) }));
|
|
906
1100
|
},
|
|
907
1101
|
'signature': () => {
|
|
908
1102
|
if (!(0, SignatureQuestion_1.isSignatureAvailable)())
|
|
@@ -1003,10 +1197,10 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1003
1197
|
// A ref carries the latest render values so the (stable) controller always
|
|
1004
1198
|
// reads fresh page/question state — commit advances the page and awaits the
|
|
1005
1199
|
// re-render before the session reads the next question.
|
|
1006
|
-
const voiceRef =
|
|
1200
|
+
const voiceRef = react_2.default.useRef({ currentQuestions: [], isLastPage: false, language: 'en', intro: '', outro: '', updateResponse: () => { }, handleNext: () => { } });
|
|
1007
1201
|
const voiceLangM = currentLanguage || survey.defaultLanguage || 'en';
|
|
1008
1202
|
// Voice-only viability across the whole survey (all pages' questions).
|
|
1009
|
-
const voiceAudit = (0,
|
|
1203
|
+
const voiceAudit = (0, react_2.useMemo)(() => (0, voiceParsers_1.auditVoiceQuestions)(pages.flatMap((p) => p.elements)), [pages]);
|
|
1010
1204
|
const voiceIntroM = [
|
|
1011
1205
|
String(survey.title || '').replace(/<[^>]*>/g, '').trim(),
|
|
1012
1206
|
String(survey.description || '').replace(/<[^>]*>/g, '').trim(),
|
|
@@ -1014,7 +1208,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1014
1208
|
].filter(Boolean).join('. ');
|
|
1015
1209
|
const voiceOutroM = String((survey.settings?.thankYouMessage) || survey.thankYouMessage || '').replace(/<[^>]*>/g, '').trim();
|
|
1016
1210
|
voiceRef.current = { currentQuestions, isLastPage, language: voiceLangM, intro: voiceIntroM, outro: voiceOutroM, updateResponse, handleNext };
|
|
1017
|
-
const buildVoiceController = (0,
|
|
1211
|
+
const buildVoiceController = (0, react_2.useCallback)(() => {
|
|
1018
1212
|
const advance = () => {
|
|
1019
1213
|
voiceIdxRef.current++;
|
|
1020
1214
|
const v = voiceRef.current;
|
|
@@ -1053,7 +1247,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1053
1247
|
onStop: () => { setVoiceActive(false); setVoicePhase('idle'); setVoiceTranscript(''); },
|
|
1054
1248
|
};
|
|
1055
1249
|
}, [currentLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1056
|
-
const toggleVoice = (0,
|
|
1250
|
+
const toggleVoice = (0, react_2.useCallback)(() => {
|
|
1057
1251
|
if ((0, handsFreeVoice_1.isVoiceActive)()) {
|
|
1058
1252
|
(0, handsFreeVoice_1.stopVoice)();
|
|
1059
1253
|
return;
|
|
@@ -1070,9 +1264,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1070
1264
|
}, [buildVoiceController, voiceAudit.blocking.length]);
|
|
1071
1265
|
// Reset the walker when the page changes via MANUAL navigation (voice manages
|
|
1072
1266
|
// its own index across its advances); always stop voice on unmount.
|
|
1073
|
-
(0,
|
|
1267
|
+
(0, react_2.useEffect)(() => { if (!(0, handsFreeVoice_1.isVoiceActive)())
|
|
1074
1268
|
voiceIdxRef.current = 0; }, [currentPage, currentFlow]);
|
|
1075
|
-
(0,
|
|
1269
|
+
(0, react_2.useEffect)(() => () => { (0, handsFreeVoice_1.stopVoice)(); }, []);
|
|
1076
1270
|
const voiceOn = (0, handsFreeVoice_1.isVoiceSupported)() && survey.voiceMode !== false;
|
|
1077
1271
|
const showVoiceMic = voiceOn && !(history.length > 0 || currentPage > 0);
|
|
1078
1272
|
// branding.typography.fontFamily overrides the system font on every
|
|
@@ -1090,10 +1284,21 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1090
1284
|
// application — see the note in the web SDK's appDefaults().
|
|
1091
1285
|
const _detached = Boolean(survey.settings?.themeDetached);
|
|
1092
1286
|
const _asdMobile = _detached ? {} : (survey.applicationSurveyDefaults || {});
|
|
1287
|
+
// The platform slice of branding.style, read early because the font is
|
|
1288
|
+
// resolved above the block that computes it for everything else.
|
|
1289
|
+
const _authoredStyleFont = stylePlatformSlice(survey.branding?.style).fontFamily;
|
|
1093
1290
|
const _fontByLang = survey.branding?.typography?.fontByLanguage;
|
|
1094
1291
|
const _langPrimary = (currentLanguage || '').split('-')[0] || '';
|
|
1095
1292
|
const fontFamily = (_fontByLang && ((currentLanguage ? _fontByLang[currentLanguage] : undefined) || _fontByLang[_langPrimary])) ||
|
|
1096
1293
|
survey.branding?.typography?.fontFamily ||
|
|
1294
|
+
// …and branding.style.fontFamily, which is what the component editor's
|
|
1295
|
+
// Font family control actually writes. Web has read it all along
|
|
1296
|
+
// (resolveSurveyStyle.fontFamily); mobile read only the older typography
|
|
1297
|
+
// field, so the control moved the preview and left the phone alone.
|
|
1298
|
+
//
|
|
1299
|
+
// AFTER typography, not before: a survey that already set the older field
|
|
1300
|
+
// keeps rendering exactly as it does today.
|
|
1301
|
+
(typeof _authoredStyleFont === 'string' && _authoredStyleFont.trim() ? _authoredStyleFont.trim() : undefined) ||
|
|
1097
1302
|
_asdMobile.font ||
|
|
1098
1303
|
undefined;
|
|
1099
1304
|
const fontStyle = fontFamily ? { fontFamily } : undefined;
|
|
@@ -1164,8 +1369,6 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1164
1369
|
})();
|
|
1165
1370
|
const scaled = (n) => Math.round(n * baseScale);
|
|
1166
1371
|
const fs = baseScale !== 1;
|
|
1167
|
-
const titleFS = fs ? { fontSize: scaled(20) } : undefined;
|
|
1168
|
-
const descFS = fs ? { fontSize: scaled(14) } : undefined;
|
|
1169
1372
|
const qTitleFS = fs ? { fontSize: scaled(15) } : undefined;
|
|
1170
1373
|
const qDescFS = fs ? { fontSize: scaled(13) } : undefined;
|
|
1171
1374
|
const thankYouFS = fs ? { fontSize: scaled(18) } : undefined;
|
|
@@ -1223,6 +1426,10 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1223
1426
|
comf; // 'comfortable' or undefined
|
|
1224
1427
|
const padHeader = density ? { padding: dens(16, 24, 32), paddingBottom: dens(8, 12, 16) } : undefined;
|
|
1225
1428
|
const padBody = density ? { paddingHorizontal: dens(16, 24, 32) } : undefined;
|
|
1429
|
+
// A picture band sits in the same column as the text — the header's
|
|
1430
|
+
// horizontal padding, without its vertical padding (the band carries its own
|
|
1431
|
+
// margin on the side that faces the words).
|
|
1432
|
+
const padBand = density ? { paddingHorizontal: dens(16, 24, 32) } : undefined;
|
|
1226
1433
|
const padFooter = density ? { padding: dens(16, 24, 32), paddingTop: dens(12, 16, 20) } : undefined;
|
|
1227
1434
|
const gapQBlock = density ? { marginBottom: dens(12, 20, 28) } : undefined;
|
|
1228
1435
|
// Theme palette (spec §24). Dark is the SDK default; light flips
|
|
@@ -1242,23 +1449,36 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1242
1449
|
// changes mid-session re-render via the useState/Appearance listener
|
|
1243
1450
|
// above (attributeContext is memoized on colorScheme too).
|
|
1244
1451
|
const authorTheme = survey.branding?.theme;
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1452
|
+
// WHO DECIDES LIGHT OR DARK — the spec answers it, so the phone and the
|
|
1453
|
+
// browser cannot rank the five candidates differently. They did, and the
|
|
1454
|
+
// same card came out white here and dark there. See resolveTheme for why
|
|
1455
|
+
// the device's own setting is last.
|
|
1456
|
+
//
|
|
1457
|
+
// An experiment variation still overrules the lot: it is not a preference,
|
|
1458
|
+
// it is the thing being measured.
|
|
1459
|
+
const _themeDecision = (0, survey_spec_1.resolveThemeDecision)({
|
|
1460
|
+
pinned: authorTheme,
|
|
1461
|
+
user: CXPinsight_1.CXPinsight._getUserIdentity()?.theme,
|
|
1462
|
+
declared: CXPinsight_1.CXPinsight._getConfig()?.theme,
|
|
1463
|
+
appDefault: survey.branding?.themeDefault,
|
|
1464
|
+
device: colorScheme,
|
|
1465
|
+
});
|
|
1466
|
+
// Under `debug`, say WHICH candidate won. From the outside every tier
|
|
1467
|
+
// produces the same two words, so "why is this card light?" is otherwise
|
|
1468
|
+
// answered by guessing — which is how it got answered wrongly twice.
|
|
1469
|
+
if (CXPinsight_1.CXPinsight._getConfig()?.debug) {
|
|
1470
|
+
// eslint-disable-next-line no-console
|
|
1471
|
+
console.log('[CXPinsight] card theme:', _themeDecision.theme, 'from', _themeDecision.from, JSON.stringify({
|
|
1472
|
+
pinned: authorTheme ?? null,
|
|
1473
|
+
user: CXPinsight_1.CXPinsight._getUserIdentity()?.theme ?? null,
|
|
1474
|
+
declared: CXPinsight_1.CXPinsight._getConfig()?.theme ?? null,
|
|
1475
|
+
appDefault: survey.branding?.themeDefault ?? null,
|
|
1476
|
+
device: colorScheme ?? null,
|
|
1477
|
+
}));
|
|
1478
|
+
}
|
|
1257
1479
|
const resolvedTheme = activeOverrides?.theme === 'light' || activeOverrides?.theme === 'dark'
|
|
1258
1480
|
? activeOverrides.theme
|
|
1259
|
-
:
|
|
1260
|
-
? authorTheme
|
|
1261
|
-
: (_userTheme || _initTheme || (colorScheme === 'light' ? 'light' : colorScheme === 'dark' ? 'dark' : 'dark'));
|
|
1481
|
+
: _themeDecision.theme;
|
|
1262
1482
|
const isLight = resolvedTheme === 'light';
|
|
1263
1483
|
// Layer palette overrides: SDK-init (host app) > survey.branding.palette
|
|
1264
1484
|
// > built-in defaults. themePalette applies precedence per-key so a
|
|
@@ -1292,7 +1512,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1292
1512
|
// returns a fresh object each call, so an unmemoized value would give
|
|
1293
1513
|
// `styles` a new identity every render and call StyleSheet.create() every
|
|
1294
1514
|
// time — which leaks entries in RN's style registry over a long session.
|
|
1295
|
-
const contract = (0,
|
|
1515
|
+
const contract = (0, react_2.useMemo)(() => (0, styleContract_1.resolveStyleContract)({
|
|
1296
1516
|
theme: resolvedTheme,
|
|
1297
1517
|
primaryColor,
|
|
1298
1518
|
// survey.branding.palette is written by SurveyPaletteEditor in the
|
|
@@ -1319,7 +1539,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1319
1539
|
[resolvedTheme, primaryColor,
|
|
1320
1540
|
JSON.stringify(surveyPaletteOverride), JSON.stringify(initPaletteOverride),
|
|
1321
1541
|
JSON.stringify(survey.branding?.style)]);
|
|
1322
|
-
const styles = (0,
|
|
1542
|
+
const styles = (0, react_2.useMemo)(() => makeStyles(contract), [contract]);
|
|
1323
1543
|
const themeCard = isLight ? { backgroundColor: palette.card, borderColor: palette.cardBorder } : undefined;
|
|
1324
1544
|
const themeTextPrimary = isLight ? { color: palette.textPrimary } : undefined;
|
|
1325
1545
|
const themeTextSecondary = isLight ? { color: palette.textSecondary } : undefined;
|
|
@@ -1346,7 +1566,14 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1346
1566
|
// and (since the parallel fix) the web SDK, and died here. Precedence matches
|
|
1347
1567
|
// the other two exactly: survey ▶ application ▶ built-in, field by field, with
|
|
1348
1568
|
// '' meaning inherit rather than override-with-nothing.
|
|
1349
|
-
|
|
1569
|
+
// DEFINED AFTER THE CONTRACT, because they now read it. These override
|
|
1570
|
+
// the contract sizes when typography.baseFontSize is set, so they have to
|
|
1571
|
+
// scale the AUTHORED size — scaling a literal threw the author's heading
|
|
1572
|
+
// and body sizes away the moment the coarse control was touched.
|
|
1573
|
+
// (they were scaled(20) and scaled(14), literals.)
|
|
1574
|
+
const titleFS = fs ? { fontSize: scaled(contract.fontSizeHeading) } : undefined;
|
|
1575
|
+
const descFS = fs ? { fontSize: scaled(contract.fontSizeDescription) } : undefined;
|
|
1576
|
+
const display = (0, react_2.useMemo)(() => {
|
|
1350
1577
|
const a = _asdMobile;
|
|
1351
1578
|
// Both layers may store display per platform — web and mobile positions are
|
|
1352
1579
|
// different decisions, and a survey that could only store one made editing
|
|
@@ -1423,10 +1650,355 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1423
1650
|
const allQuestions = (survey.rawQuestions
|
|
1424
1651
|
|| survey.questions
|
|
1425
1652
|
|| []);
|
|
1426
|
-
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1653
|
+
// FOUND BY TYPE, NOT BY THE ID THE BUILDER HAPPENS TO WRITE.
|
|
1654
|
+
//
|
|
1655
|
+
// `notify_image` is one authoring tool's convention, and matching it meant an
|
|
1656
|
+
// offer carrying exactly the same image question composed nothing — the
|
|
1657
|
+
// picture fell through to the question list full-bleed, which is the shape
|
|
1658
|
+
// that started this.
|
|
1659
|
+
const cardImageQ = allQuestions.find((q) => String(q?.type ?? '').toLowerCase() === 'image');
|
|
1660
|
+
// WHERE THE PICTURE GOES — asked once, in the spec, so the phone and the
|
|
1661
|
+
// preview cannot answer it differently. 'none' leaves it to the question
|
|
1662
|
+
// flow, which is still right for an ask: there the image is content the
|
|
1663
|
+
// respondent is being shown, not decoration on a card.
|
|
1664
|
+
const imgD = (0, survey_spec_1.imageDesign)(display, interactionKind, !!cardImageQ?.imageUrl);
|
|
1665
|
+
const imgLayout = imgD.layout;
|
|
1666
|
+
const composesImage = imgLayout !== 'none';
|
|
1667
|
+
const besideImage = imgLayout === 'beside';
|
|
1668
|
+
// THE BANNER'S TYPE SCALE BELONGS TO THE BANNER, NOT TO ITS PICTURE.
|
|
1669
|
+
//
|
|
1670
|
+
// This was keyed off having an image, so a notification without one got the
|
|
1671
|
+
// survey's 20px heading while the preview gave every notification 15px — the
|
|
1672
|
+
// `.cxp-notify` narrowing on web has never cared whether there is a picture.
|
|
1673
|
+
const isNotifyBanner = interactionKind === 'notify';
|
|
1674
|
+
// A BAND — as wide as the card, above the message or below it. Its height is
|
|
1675
|
+
// imageHeight, NOT imageSize: the square's 44 is the right answer to a
|
|
1676
|
+
// different question, and reusing it turned "Above" into a 44px strip.
|
|
1677
|
+
/**
|
|
1678
|
+
* A COVER-CROPPED PICTURE WITH A FOCAL POINT — measured, because React
|
|
1679
|
+
* Native has no object-position.
|
|
1680
|
+
*
|
|
1681
|
+
* The web SDK writes one CSS declaration for this. Here the only exact way
|
|
1682
|
+
* is to work it out: ask the file for its natural size, ask the box for its
|
|
1683
|
+
* laid-out size, scale the way `cover` would, and shift by the part of the
|
|
1684
|
+
* overflow the author asked to keep. Approximating it with a fixed
|
|
1685
|
+
* translation would be a control that is nearly right, which in an editor is
|
|
1686
|
+
* worse than one that is absent.
|
|
1687
|
+
*
|
|
1688
|
+
* Falls back to plain `cover` until both measurements arrive, so the first
|
|
1689
|
+
* paint is the centre crop every renderer already does rather than a blank.
|
|
1690
|
+
*/
|
|
1691
|
+
const FocalImage = ({ uri, alt, fit, focusX, focusY, radius }) => {
|
|
1692
|
+
const [box, setBox] = (0, react_2.useState)({ w: 0, h: 0 });
|
|
1693
|
+
const [nat, setNat] = (0, react_2.useState)({ w: 0, h: 0 });
|
|
1694
|
+
(0, react_2.useEffect)(() => {
|
|
1695
|
+
if (!uri || fit !== 'cover')
|
|
1696
|
+
return;
|
|
1697
|
+
let alive = true;
|
|
1698
|
+
// Never throws for a bad URI — it calls the failure handler, and then we
|
|
1699
|
+
// simply stay on the centre crop.
|
|
1700
|
+
react_native_1.Image.getSize(uri, (w, h) => { if (alive)
|
|
1701
|
+
setNat({ w, h }); }, () => { });
|
|
1702
|
+
return () => { alive = false; };
|
|
1703
|
+
}, [uri, fit]);
|
|
1704
|
+
const centred = fit !== 'cover' || !box.w || !box.h || !nat.w || !nat.h
|
|
1705
|
+
|| (focusX === 50 && focusY === 50);
|
|
1706
|
+
let inner = null;
|
|
1707
|
+
if (!centred) {
|
|
1708
|
+
const scale = Math.max(box.w / nat.w, box.h / nat.h);
|
|
1709
|
+
const w = nat.w * scale;
|
|
1710
|
+
const h = nat.h * scale;
|
|
1711
|
+
// What cover hides, and how much of it the author wants on each side.
|
|
1712
|
+
inner = {
|
|
1713
|
+
width: w,
|
|
1714
|
+
height: h,
|
|
1715
|
+
left: -(w - box.w) * (focusX / 100),
|
|
1716
|
+
top: -(h - box.h) * (focusY / 100),
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: '100%', height: '100%', overflow: 'hidden', borderRadius: radius }, onLayout: (e) => {
|
|
1720
|
+
const { width, height } = e.nativeEvent.layout;
|
|
1721
|
+
setBox((p) => (Math.abs(p.w - width) > 1 || Math.abs(p.h - height) > 1
|
|
1722
|
+
? { w: width, h: height } : p));
|
|
1723
|
+
}, children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri }, accessibilityLabel: alt || undefined, accessible: !!alt, resizeMode: centred ? fit : 'cover', style: inner
|
|
1724
|
+
? { position: 'absolute', ...inner }
|
|
1725
|
+
: { width: '100%', height: '100%' } }) }));
|
|
1726
|
+
};
|
|
1727
|
+
const cardImageBand = (imgLayout === 'above' || imgLayout === 'below') ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
1728
|
+
// A band narrower than the card can sit left, centre or right — which
|
|
1729
|
+
// is what Alignment means once Width is not always 100%.
|
|
1730
|
+
width: `${imgD.width}%`,
|
|
1731
|
+
height: contract.imageHeight,
|
|
1732
|
+
borderRadius: contract.imageRadius,
|
|
1733
|
+
// Margin only on the side facing the text, so choosing "Below" does not
|
|
1734
|
+
// leave a gap above the picture as well as under it.
|
|
1735
|
+
marginBottom: imgLayout === 'above' ? 12 : 0,
|
|
1736
|
+
marginTop: imgLayout === 'above' ? 0 : 12,
|
|
1737
|
+
alignSelf: imgD.align === 'start' ? 'flex-start'
|
|
1738
|
+
: imgD.align === 'end' ? 'flex-end' : 'center',
|
|
1739
|
+
overflow: 'hidden',
|
|
1740
|
+
}, children: (0, jsx_runtime_1.jsx)(FocalImage, { uri: cardImageQ?.imageUrl, alt: cardImageQ?.alt, fit: imgD.fit, focusX: imgD.focusX, focusY: imgD.focusY, radius: contract.imageRadius }) })) : null;
|
|
1741
|
+
/**
|
|
1742
|
+
* THE SEAM, SOFTENED — without a gradient primitive.
|
|
1743
|
+
*
|
|
1744
|
+
* React Native has none, and `react-native-svg` is an OPTIONAL peer this SDK
|
|
1745
|
+
* cannot depend on. So the fade is built from what every build has: a short
|
|
1746
|
+
* stack of absolutely-positioned Views in the card's own colour, each a
|
|
1747
|
+
* little more opaque than the last. Eight steps is enough that the banding
|
|
1748
|
+
* is invisible at the sizes a card uses, and it costs eight empty Views.
|
|
1749
|
+
*
|
|
1750
|
+
* The alternative was a Fade control that worked in the preview and did
|
|
1751
|
+
* nothing here, which is the thing this codebase keeps having to undo.
|
|
1752
|
+
*/
|
|
1753
|
+
const fadeSteps = (towards) => {
|
|
1754
|
+
if (!imgD.fade)
|
|
1755
|
+
return null;
|
|
1756
|
+
const span = Math.max(2, Math.min(96, imgD.fade)) / 100;
|
|
1757
|
+
const vertical = towards === 'top' || towards === 'bottom';
|
|
1758
|
+
// A REAL GRADIENT WHERE THE APP HAS ONE. react-native-svg is an optional
|
|
1759
|
+
// peer, already loaded this way by the signature question, and it draws
|
|
1760
|
+
// the thing rather than approximating it.
|
|
1761
|
+
if ((0, fadeGradient_1.hasGradient)()) {
|
|
1762
|
+
const { Svg, Defs, LinearGradient, Stop, Rect } = fadeGradient_1.SvgParts;
|
|
1763
|
+
// Runs FROM transparent TO the card colour, so the solid end is the edge
|
|
1764
|
+
// the words are on.
|
|
1765
|
+
const x1 = vertical ? '0' : (towards === 'right' ? '0' : '1');
|
|
1766
|
+
const x2 = vertical ? '0' : (towards === 'right' ? '1' : '0');
|
|
1767
|
+
const y1 = vertical ? (towards === 'bottom' ? '0' : '1') : '0';
|
|
1768
|
+
const y2 = vertical ? (towards === 'bottom' ? '1' : '0') : '0';
|
|
1769
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: {
|
|
1770
|
+
position: 'absolute',
|
|
1771
|
+
...(vertical
|
|
1772
|
+
? { left: 0, right: 0, height: `${span * 100}%`,
|
|
1773
|
+
...(towards === 'bottom' ? { bottom: 0 } : { top: 0 }) }
|
|
1774
|
+
: { top: 0, bottom: 0, width: `${span * 100}%`,
|
|
1775
|
+
...(towards === 'right' ? { right: 0 } : { left: 0 }) }),
|
|
1776
|
+
}, children: (0, jsx_runtime_1.jsxs)(Svg, { width: "100%", height: "100%", children: [(0, jsx_runtime_1.jsx)(Defs, { children: (0, jsx_runtime_1.jsxs)(LinearGradient, { id: "cxpFade", x1: x1, y1: y1, x2: x2, y2: y2, children: [(0, jsx_runtime_1.jsx)(Stop, { offset: "0", stopColor: contract.card, stopOpacity: "0" }), (0, jsx_runtime_1.jsx)(Stop, { offset: "1", stopColor: contract.card, stopOpacity: "1" })] }) }), (0, jsx_runtime_1.jsx)(Rect, { x: "0", y: "0", width: "100%", height: "100%", fill: "url(#cxpFade)" })] }) }));
|
|
1777
|
+
}
|
|
1778
|
+
// NO SVG IN THIS APP — bands. SLICES, side by side, not rectangles stacked
|
|
1779
|
+
// on top of each other.
|
|
1780
|
+
//
|
|
1781
|
+
// They used to be stacked: band i was `span * (i+1)/n` long, every one
|
|
1782
|
+
// anchored to the same edge, each with a solved opacity. The arithmetic
|
|
1783
|
+
// was right for a stack and the geometry was backwards — the LARGEST
|
|
1784
|
+
// rectangle got the HIGHEST opacity, and band 27 was the full span at
|
|
1785
|
+
// opacity 1/(28-27) = 1. A single opaque card-coloured block over the
|
|
1786
|
+
// whole fade, with twenty-seven invisible bands underneath it. Reported,
|
|
1787
|
+
// exactly, as "not a gradient, just a solid dark cover over the image".
|
|
1788
|
+
//
|
|
1789
|
+
// Slices cannot do that to themselves. Each covers one band of the span
|
|
1790
|
+
// and nothing overlaps, so the alpha at that distance IS the alpha you
|
|
1791
|
+
// write: 1 at the edge the words are on, 0 where the picture takes over.
|
|
1792
|
+
// At 28 steps each one is a 3.6% change, which is under what the eye
|
|
1793
|
+
// picks out against a photograph.
|
|
1794
|
+
const pctOf = (v) => `${v * 100}%`;
|
|
1795
|
+
const thickness = pctOf(span / fadeGradient_1.FADE_STEPS);
|
|
1796
|
+
return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: Array.from({ length: fadeGradient_1.FADE_STEPS }, (_, i) => {
|
|
1797
|
+
const offset = pctOf((span * i) / fadeGradient_1.FADE_STEPS);
|
|
1798
|
+
// Sampled mid-slice, so the ramp is centred on the band rather than
|
|
1799
|
+
// starting a half-step early.
|
|
1800
|
+
const alpha = 1 - (i + 0.5) / fadeGradient_1.FADE_STEPS;
|
|
1801
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: {
|
|
1802
|
+
position: 'absolute',
|
|
1803
|
+
...(towards === 'bottom' ? { left: 0, right: 0, bottom: offset, height: thickness }
|
|
1804
|
+
: towards === 'top' ? { left: 0, right: 0, top: offset, height: thickness }
|
|
1805
|
+
: towards === 'right' ? { top: 0, bottom: 0, right: offset, width: thickness }
|
|
1806
|
+
: { top: 0, bottom: 0, left: offset, width: thickness }),
|
|
1807
|
+
backgroundColor: contract.card,
|
|
1808
|
+
opacity: alpha,
|
|
1809
|
+
} }, i));
|
|
1810
|
+
}) }));
|
|
1811
|
+
};
|
|
1812
|
+
// THE SMALL PRINT, RESOLVED ONCE.
|
|
1813
|
+
//
|
|
1814
|
+
// rewardTerms falls back to the legacy `offer_terms` content question so an
|
|
1815
|
+
// offer made before this keeps its conditions — and that question is still
|
|
1816
|
+
// in the flow, so without the filter below it rendered a SECOND time
|
|
1817
|
+
// underneath, one copy styled as small print and one not. Reported as
|
|
1818
|
+
// duplicated and dimmed. The picture solved the same problem with the same
|
|
1819
|
+
// shape; this needed it too.
|
|
1820
|
+
const cardTerms = (0, survey_spec_1.rewardTerms)(survey);
|
|
1821
|
+
// HALF THE CARD — the picture bleeds to the card's own edges and the words
|
|
1822
|
+
// take the rest. Not a band with a big height: the difference is the bleed,
|
|
1823
|
+
// and it is what makes the image read as the offer rather than as an
|
|
1824
|
+
// illustration of it. `top` turns the row into a column, which is the same
|
|
1825
|
+
// layout rotated and the one a narrow phone usually wants.
|
|
1826
|
+
// HOISTED so a bleeding picture can go above it. On a half card the image
|
|
1827
|
+
// runs to the card's top edge, and a language row rendered before it pushed
|
|
1828
|
+
// the picture down behind a band of empty card — which reads as "only part
|
|
1829
|
+
// of the image is showing" and is invisible in the web preview, because the
|
|
1830
|
+
// web popup has no switcher at all (it takes the browser's language).
|
|
1831
|
+
const langSwitcher = survey.languages && survey.languages.length > 1 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
1832
|
+
styles.langSwitcher,
|
|
1833
|
+
isRtl && { paddingRight: 12, paddingLeft: 48, flexDirection: 'row-reverse' },
|
|
1834
|
+
], children: survey.languages.map((code) => {
|
|
1835
|
+
const isActive = code === currentLanguage;
|
|
1836
|
+
return ((0, react_1.createElement)(react_native_1.TouchableOpacity, { ...swipeExempt, key: code, onPress: () => setCurrentLanguage(code), activeOpacity: 0.7, style: [
|
|
1837
|
+
styles.langChip,
|
|
1838
|
+
// Light theme: the chip's default white-on-white border/fill
|
|
1839
|
+
// is invisible on the light card — repaint from the palette.
|
|
1840
|
+
isLight && { borderColor: palette.inputBorder, backgroundColor: palette.input },
|
|
1841
|
+
isActive && { borderColor: primaryColor, backgroundColor: `${primaryColor}1A` },
|
|
1842
|
+
] },
|
|
1843
|
+
(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
|
|
1844
|
+
styles.langChipText,
|
|
1845
|
+
isLight && { color: palette.textSecondary },
|
|
1846
|
+
isActive && { color: primaryColor },
|
|
1847
|
+
], children: code.toUpperCase() })));
|
|
1848
|
+
}) })) : null;
|
|
1849
|
+
// A SIDE SPLIT IS A SIDE SPLIT, on the phone too.
|
|
1850
|
+
//
|
|
1851
|
+
// This used to rotate: Left and Right both became a band across the top, on
|
|
1852
|
+
// the reasoning that half of a ~360pt card leaves the headline about 150pt
|
|
1853
|
+
// and that is a column of two-word lines rather than a layout. That is a
|
|
1854
|
+
// fair worry and it was not mine to settle by overruling the control — an
|
|
1855
|
+
// author who picks Left and gets a top band has been told something false
|
|
1856
|
+
// about their own card. Share is theirs to set: at 30% the words still get
|
|
1857
|
+
// ~250pt, and if 70% looks cramped that is visible in the frame and fixable
|
|
1858
|
+
// in one drag.
|
|
1859
|
+
const halfSide = imgLayout === 'half' && (imgD.side === 'left' || imgD.side === 'right');
|
|
1860
|
+
// SHARE IN POINTS, NOT PER CENT. The card's width is decided in JS a few
|
|
1861
|
+
// lines up, so the column can be told what it is instead of being handed a
|
|
1862
|
+
// percentage and left to negotiate with a `flex: 1` sibling — which is a
|
|
1863
|
+
// conversation it was losing: Share at 33 was arriving on screen at 13.
|
|
1864
|
+
const cardWidth = isFullscreen
|
|
1865
|
+
? SCREEN_WIDTH
|
|
1866
|
+
: maxWidth !== undefined
|
|
1867
|
+
? Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, maxWidth)
|
|
1868
|
+
: Math.min(SCREEN_WIDTH - 40, 420);
|
|
1869
|
+
// SHARE MEASURED THE WAY THE WEB CARD MEASURES IT.
|
|
1870
|
+
//
|
|
1871
|
+
// Web puts the picture in the row INSIDE the card's padding — `flex: 0 0
|
|
1872
|
+
// split%` of the content box — and then bleeds it back out to the card's
|
|
1873
|
+
// edge with a negative margin. So the width you see is a share of the
|
|
1874
|
+
// content, plus the padding it reclaims on the outer side. Taking a share of
|
|
1875
|
+
// the whole card instead makes the phone's picture narrower than the frame's
|
|
1876
|
+
// by exactly one padding, which is a column of text roughly one word narrower
|
|
1877
|
+
// — and a description that fits two lines in the preview wrapping onto three.
|
|
1878
|
+
const halfPadL = contract.cardPaddingLeft ?? contract.cardPadding;
|
|
1879
|
+
const halfPadR = contract.cardPaddingRight ?? contract.cardPadding;
|
|
1880
|
+
const halfOuter = imgD.side === 'right' ? halfPadR : halfPadL;
|
|
1881
|
+
const halfWidth = Math.round(((cardWidth - halfPadL - halfPadR) * imgD.split) / 100) + halfOuter;
|
|
1882
|
+
const cardImageHalf = imgLayout === 'half' ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: {
|
|
1883
|
+
position: 'relative',
|
|
1884
|
+
overflow: 'hidden',
|
|
1885
|
+
// HEIGHT ALONE DECIDES THIS, and Share does not touch it.
|
|
1886
|
+
//
|
|
1887
|
+
// Share used to scale it (split/50), so an authored Height of 225 with
|
|
1888
|
+
// Share at 23 quietly became 103 and the picture came back as a strip.
|
|
1889
|
+
// Nobody would guess that from a control called "Share", and across the
|
|
1890
|
+
// top of the card there is no width to share in the first place.
|
|
1891
|
+
// NO NEGATIVE MARGINS. The web card carries its padding on the content
|
|
1892
|
+
// element, so bleeding to the edge there means pulling BACK by it. This
|
|
1893
|
+
// card carries none — padding lives on the header, the body and the
|
|
1894
|
+
// footer — so the same negative margins pushed the picture 16 to 32
|
|
1895
|
+
// points outside the card on three sides, where `overflow: hidden`
|
|
1896
|
+
// clipped it. The picture is already at the card's edge here.
|
|
1897
|
+
...(halfSide
|
|
1898
|
+
// Beside the words: Share is a width, and the height is whatever the
|
|
1899
|
+
// words next to it come to — `stretch`, exactly as the web card's
|
|
1900
|
+
// `align-self: stretch` does it.
|
|
1901
|
+
? { width: halfWidth,
|
|
1902
|
+
// It must not be renegotiated either: a flex sibling may not
|
|
1903
|
+
// shrink it, and nothing may grow it.
|
|
1904
|
+
flexGrow: 0, flexShrink: 0, alignSelf: 'stretch' }
|
|
1905
|
+
// Across the top: full width, and Height alone decides the depth.
|
|
1906
|
+
: { width: '100%', height: contract.imageHeight, marginBottom: 12 }),
|
|
1907
|
+
}, children: [halfSide ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: react_native_1.StyleSheet.absoluteFill, children: (0, jsx_runtime_1.jsx)(FocalImage, { uri: cardImageQ?.imageUrl, alt: cardImageQ?.alt, fit: imgD.fit, focusX: imgD.focusX, focusY: imgD.focusY }) })) : ((0, jsx_runtime_1.jsx)(FocalImage, { uri: cardImageQ?.imageUrl, alt: cardImageQ?.alt, fit: imgD.fit, focusX: imgD.focusX, focusY: imgD.focusY })), imgD.scrim ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: [react_native_1.StyleSheet.absoluteFill,
|
|
1908
|
+
{ backgroundColor: contract.card, opacity: imgD.scrim / 100 }] })) : null, fadeSteps(halfSide ? (imgD.side === 'left' ? 'right' : 'left') : 'bottom')] })) : null;
|
|
1909
|
+
// BEHIND EVERYTHING, WITH THE CARD'S OWN COLOUR PAINTED BACK OVER IT.
|
|
1910
|
+
//
|
|
1911
|
+
// Absolute children drawn FIRST, because React Native paints siblings in
|
|
1912
|
+
// order and has no dependable negative z-index — so "behind" is a position
|
|
1913
|
+
// in the tree, not a number. Without the scrim the author has no control that
|
|
1914
|
+
// would make white text readable on a pale photograph, so it is authored
|
|
1915
|
+
// rather than assumed, and defaults to something visible.
|
|
1916
|
+
const cardImageBack = imgLayout === 'background' ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: cardImageQ?.imageUrl }, style: react_native_1.StyleSheet.absoluteFill, resizeMode: "cover", accessible: false, importantForAccessibility: "no" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: [
|
|
1917
|
+
react_native_1.StyleSheet.absoluteFill,
|
|
1918
|
+
{ backgroundColor: contract.card, opacity: imgD.scrim / 100 },
|
|
1919
|
+
] })] })) : null;
|
|
1920
|
+
// THE BUTTON SITS BESIDE THE TEXT WHEN THE AUTHOR SAID SO — ON A PHONE TOO.
|
|
1921
|
+
//
|
|
1922
|
+
// `buttonAlign` is the notification's "Where it sits": 'full' is below the
|
|
1923
|
+
// content, anything else is next to it. Mobile ignored it completely and
|
|
1924
|
+
// always rendered the action down in the question list, so the control moved
|
|
1925
|
+
// the web preview and nothing on the device.
|
|
1926
|
+
//
|
|
1927
|
+
// No fall-back to stacking when the label is long: beside means beside, and
|
|
1928
|
+
// the text takes whatever width is left. An author who wants the full width
|
|
1929
|
+
// for their headline has the other option to choose.
|
|
1930
|
+
const notifyBeside = interactionKind === 'notify'
|
|
1931
|
+
&& String(display?.buttonAlign || 'right') !== 'full';
|
|
1932
|
+
const notifyActionQ = notifyBeside
|
|
1933
|
+
? currentQuestions.find((q) => String(q?.type ?? '').toLowerCase() === 'implied')
|
|
1934
|
+
: undefined;
|
|
1935
|
+
// THE CARD DOES THE BUTTON'S WORK — when there is no button.
|
|
1936
|
+
//
|
|
1937
|
+
// Web has had this for a while and the phone never did, so hiding the action
|
|
1938
|
+
// button here removed the action entirely: no button, no card press, and on a
|
|
1939
|
+
// notification the ✕ is off too. Reported as a notification nothing could
|
|
1940
|
+
// dismiss, which is exactly what it was.
|
|
1941
|
+
//
|
|
1942
|
+
// Both halves of the rule come from the spec, so the two renderers cannot
|
|
1943
|
+
// disagree about when the card takes over: cardIsClickable insists on exactly
|
|
1944
|
+
// ONE action — the notification shape, which excludes every consent choice,
|
|
1945
|
+
// because a stray tap anywhere on a two-action takeover must never
|
|
1946
|
+
// manufacture agreement.
|
|
1947
|
+
const cardImpliedQ = currentQuestions.find((q) => String(q?.type ?? '').toLowerCase() === 'implied');
|
|
1948
|
+
const cardImpliedList = cardImpliedQ
|
|
1949
|
+
? (() => {
|
|
1950
|
+
const acts = (0, survey_spec_1.impliedActions)(cardImpliedQ, (t) => (0, interpolate_1.interpolatePlain)(t, evalCtx));
|
|
1951
|
+
return acts.length ? acts : [{ value: 'acknowledged', label: 'Continue' }];
|
|
1952
|
+
})()
|
|
1953
|
+
: [];
|
|
1954
|
+
const cardTakesPress = !!cardImpliedQ
|
|
1955
|
+
&& !(0, survey_spec_1.showActionButton)(display, cardImpliedList)
|
|
1956
|
+
&& (0, survey_spec_1.cardIsClickable)(display, cardImpliedList);
|
|
1957
|
+
const cardImpliedKey = String(cardImpliedQ?.name
|
|
1958
|
+
?? cardImpliedQ?.id ?? '');
|
|
1959
|
+
// AN EMPTY FOOTER IS STILL A FOOTER — 16pt on top and a card padding below.
|
|
1960
|
+
//
|
|
1961
|
+
// All three of its children can be suppressed at once, and on a notification
|
|
1962
|
+
// they are: no voice mic, no Back on a single page, and the primary hidden
|
|
1963
|
+
// because an implied step's own actions ARE its Next. What was left was a
|
|
1964
|
+
// padded empty row, which is the blank strip under a notification on the
|
|
1965
|
+
// phone that the preview does not have — web renders no footer at all there.
|
|
1966
|
+
//
|
|
1967
|
+
// Asked as "has anything to draw", not "is this a notification", so the same
|
|
1968
|
+
// answer holds for any step that happens to suppress all three.
|
|
1969
|
+
const footerHasContent = showVoiceMic
|
|
1970
|
+
|| (display?.showBack !== false && (history.length > 0 || currentPage > 0))
|
|
1971
|
+
|| impliedNow.length === 0;
|
|
1972
|
+
// The room the ✕ needs, on the line it actually overlaps. Sized from the
|
|
1973
|
+
// button itself, so shrinking it gives the headline the space back.
|
|
1974
|
+
// WHETHER THE ✕ IS DRAWN, asked once and used twice — by the mark itself and
|
|
1975
|
+
// by the gesture that replaces it.
|
|
1976
|
+
//
|
|
1977
|
+
// This read `showClose !== false` in both places, so an unset notification
|
|
1978
|
+
// kept its ✕ here and lost it in the preview, and — because this display is a
|
|
1979
|
+
// plain merge that leaves the value undefined — never enabled the swipe
|
|
1980
|
+
// either. One authored survey, dismissable two different ways depending on
|
|
1981
|
+
// which screen you were looking at.
|
|
1982
|
+
const closeShown = (0, survey_spec_1.showsClose)(display, interactionKind, survey.delivery);
|
|
1983
|
+
const closeReserve = !closeShown ? null : {
|
|
1984
|
+
[isRtl ? 'paddingLeft' : 'paddingRight']: contract.closeSize + contract.closeInset + 8,
|
|
1985
|
+
};
|
|
1986
|
+
// Whether the BODY may scroll is a different question from whether a
|
|
1987
|
+
// thumbnail was composed: see surfaceScrolls.
|
|
1988
|
+
const bodyScrolls = surfaceScrolls(interactionKind);
|
|
1989
|
+
// AND WHEN IT DOES NOT SCROLL, IT IS NOT A SCROLLVIEW.
|
|
1990
|
+
//
|
|
1991
|
+
// `scrollEnabled={false}` was not enough, and on iOS that is not a cosmetic
|
|
1992
|
+
// distinction: the prop only tells a real UIScrollView to stop scrolling, it
|
|
1993
|
+
// does not take it out of the touch path. A UIScrollView cancels touches it
|
|
1994
|
+
// inherits from inert content while leaving a Touchable's own touch alone,
|
|
1995
|
+
// and that asymmetry is exactly the reported "it only swipes while I am
|
|
1996
|
+
// pressing the button" — the button is the one child that owns its touch, so
|
|
1997
|
+
// the flick survived there and nowhere else on the card.
|
|
1998
|
+
//
|
|
1999
|
+
// Rendering a plain View removes the competitor rather than asking it to
|
|
2000
|
+
// stand down. styles.body carries no flex, so the two lay out identically.
|
|
2001
|
+
const BodyContainer = (bodyScrolls ? react_native_1.ScrollView : react_native_1.View);
|
|
1430
2002
|
// A NOTIFICATION'S TYPE SITS ONE SIZE DOWN from a survey's — web says so
|
|
1431
2003
|
// explicitly (notifyFsHeading/notifyFsDesc, defaulting to 15/13), and mobile
|
|
1432
2004
|
// had no equivalent: it used the survey title and a hardcoded 14 description,
|
|
@@ -1434,6 +2006,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1434
2006
|
// type there. An authored size still wins, on both.
|
|
1435
2007
|
const _authoredStyle = stylePlatformSlice(survey.branding?.style);
|
|
1436
2008
|
const _num = (v) => (typeof v === 'number' && v > 0 ? v : undefined);
|
|
2009
|
+
// Only an AUTHORED border draws — see styles.impliedPrimary.
|
|
2010
|
+
const authoredBtnBorder = typeof _authoredStyle.primaryBorder === 'string'
|
|
2011
|
+
&& _authoredStyle.primaryBorder.trim() ? String(_authoredStyle.primaryBorder).trim() : null;
|
|
1437
2012
|
// SHARED VALUE, PROPORTIONATE RENDERING.
|
|
1438
2013
|
//
|
|
1439
2014
|
// `fontSizeHeading` is stored once for every surface, so a size chosen while
|
|
@@ -1449,7 +2024,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1449
2024
|
const REFERENCE_CARD_PT = 480;
|
|
1450
2025
|
const notifyCardW = Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, typeof maxWidth === 'number' ? maxWidth : SCREEN_WIDTH);
|
|
1451
2026
|
const authoredHeadingPt = _num(_authoredStyle.fontSizeHeading);
|
|
1452
|
-
const notifyTitleFS =
|
|
2027
|
+
const notifyTitleFS = isNotifyBanner
|
|
1453
2028
|
? {
|
|
1454
2029
|
fontSize: authoredHeadingPt != null
|
|
1455
2030
|
// Floored so a very narrow card cannot shrink the headline below the
|
|
@@ -1458,8 +2033,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1458
2033
|
: 15,
|
|
1459
2034
|
}
|
|
1460
2035
|
: null;
|
|
1461
|
-
const notifyDescFS =
|
|
1462
|
-
?
|
|
2036
|
+
const notifyDescFS = isNotifyBanner
|
|
2037
|
+
? (() => {
|
|
2038
|
+
// The banner's own type size, and a line height DERIVED from it rather
|
|
2039
|
+
// than written in — otherwise the Line height control works on a survey
|
|
2040
|
+
// and silently does nothing on the notification, which is the surface
|
|
2041
|
+
// most likely to be tuned.
|
|
2042
|
+
const fs = _num(_authoredStyle.fontSizeDescription) ?? 13;
|
|
2043
|
+
return { fontSize: fs, lineHeight: Math.round(fs * contract.lineHeightBody) };
|
|
2044
|
+
})()
|
|
2045
|
+
: null;
|
|
1463
2046
|
const rawPos = display?.position;
|
|
1464
2047
|
const placement = rawPos === 'top' ? 'top'
|
|
1465
2048
|
: rawPos === 'bottom' ? 'bottom'
|
|
@@ -1490,7 +2073,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1490
2073
|
const hasPopupY = !isNaN(popupY);
|
|
1491
2074
|
const effPlacement = hasPopupY ? 'top' : placement;
|
|
1492
2075
|
// Measured card height; the authored height is the first-frame stand-in.
|
|
1493
|
-
const [measuredCardH, setMeasuredCardH] = (0,
|
|
2076
|
+
const [measuredCardH, setMeasuredCardH] = (0, react_2.useState)(0);
|
|
1494
2077
|
const authoredH = Number.parseFloat(String(display?.height ?? ''));
|
|
1495
2078
|
const estCardH = measuredCardH || (Number.isFinite(authoredH) && authoredH > 0 ? authoredH : 220);
|
|
1496
2079
|
const overlayPlacement = {
|
|
@@ -1515,10 +2098,31 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1515
2098
|
// is what the card actually uses.
|
|
1516
2099
|
const needsTopSlide = animationType === 'slide'
|
|
1517
2100
|
&& ((isSheet && placement === 'top') || (!isDrawer && !isFullscreen && effPlacement === 'top'));
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
2101
|
+
// WHERE IT ENTERS FROM, and how long it takes.
|
|
2102
|
+
//
|
|
2103
|
+
// `animationFrom` names the EDGE IT COMES FROM; the travel is its opposite,
|
|
2104
|
+
// so entering from the left means moving right. 'auto' keeps the old
|
|
2105
|
+
// behaviour exactly — its own nearest edge — so no existing survey moves.
|
|
2106
|
+
//
|
|
2107
|
+
// RN's <Modal animationType="slide"> only knows bottom-up and has no duration,
|
|
2108
|
+
// so anything the author actually chose has to be animated here instead. We
|
|
2109
|
+
// take over only when they HAVE chosen: an explicit edge, an explicit
|
|
2110
|
+
// duration, or the top case that already needed its own animation.
|
|
2111
|
+
const _disp = display;
|
|
2112
|
+
const animFromRaw = String(_disp?.animationFrom || 'auto').toLowerCase();
|
|
2113
|
+
const animEdgeAuthored = ['top', 'right', 'bottom', 'left'].includes(animFromRaw);
|
|
2114
|
+
const _msRaw = Number(_disp?.animationDuration);
|
|
2115
|
+
const animMsAuthored = Number.isFinite(_msRaw) && _msRaw > 0;
|
|
2116
|
+
const animMs = animMsAuthored ? Math.max(80, Math.min(1000, Math.round(_msRaw))) : 280;
|
|
2117
|
+
const slideEdge = animEdgeAuthored
|
|
2118
|
+
? animFromRaw
|
|
2119
|
+
: (effPlacement === 'top' ? 'top' : 'bottom');
|
|
2120
|
+
const customSlide = animationType === 'slide'
|
|
2121
|
+
&& (needsTopSlide || animEdgeAuthored || animMsAuthored);
|
|
2122
|
+
const modalAnimation = customSlide ? 'none' : animationType;
|
|
2123
|
+
const enterAnim = (0, react_2.useRef)(new react_native_1.Animated.Value(1)).current;
|
|
2124
|
+
(0, react_2.useEffect)(() => {
|
|
2125
|
+
if (!customSlide)
|
|
1522
2126
|
return;
|
|
1523
2127
|
if (!visible) {
|
|
1524
2128
|
enterAnim.setValue(0);
|
|
@@ -1527,13 +2131,13 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1527
2131
|
enterAnim.setValue(0);
|
|
1528
2132
|
react_native_1.Animated.timing(enterAnim, {
|
|
1529
2133
|
toValue: 1,
|
|
1530
|
-
duration:
|
|
2134
|
+
duration: animMs,
|
|
1531
2135
|
easing: react_native_1.Easing.out(react_native_1.Easing.cubic),
|
|
1532
2136
|
// Transform and opacity only, so this runs off the JS thread and does not
|
|
1533
2137
|
// stutter while the survey's first page is still laying out.
|
|
1534
2138
|
useNativeDriver: true,
|
|
1535
2139
|
}).start();
|
|
1536
|
-
}, [visible,
|
|
2140
|
+
}, [visible, customSlide, animMs, enterAnim]);
|
|
1537
2141
|
// ── Swipe to dismiss ────────────────────────────────────────────────
|
|
1538
2142
|
//
|
|
1539
2143
|
// A push notification goes away when you flick it. The mobile display slice
|
|
@@ -1543,100 +2147,124 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1543
2147
|
// canSwipeToDismiss requires a popup and vetoes `mustShow`, which is what
|
|
1544
2148
|
// keeps a compliance takeover — showClose:false for the OPPOSITE reason —
|
|
1545
2149
|
// from ever becoming flickable.
|
|
1546
|
-
|
|
1547
|
-
//
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
//
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
handleDismissRef.current();
|
|
1628
|
-
});
|
|
1629
|
-
return;
|
|
2150
|
+
// RESOLVED, not raw: canSwipeToDismiss asks for `showClose === false`, and an
|
|
2151
|
+
// unset notification is exactly the case that has to reach it as false.
|
|
2152
|
+
const swipeable = (0, survey_spec_1.canSwipeToDismiss)({ ...(display || {}), showClose: !closeShown }, survey.delivery);
|
|
2153
|
+
// ── TEMPORARY: swipe diagnosis ──────────────────────────────────────────
|
|
2154
|
+
//
|
|
2155
|
+
// The report ("it only swipes while I press the button") has survived three
|
|
2156
|
+
// fixes, each aimed at a different candidate. These five lines separate the
|
|
2157
|
+
// candidates in ONE pass on a device, because the order they appear in is
|
|
2158
|
+
// itself the answer:
|
|
2159
|
+
//
|
|
2160
|
+
// no `touch` at all → the gesture never reaches the card; the problem
|
|
2161
|
+
// is hit-testing, not the responder
|
|
2162
|
+
// `touch` but no `ask` → the card is not in the negotiation
|
|
2163
|
+
// `ask claim=false` → dx/dy never crossed the claim threshold
|
|
2164
|
+
// `ask claim=true`, no `grant` → something else won the responder
|
|
2165
|
+
// `grant` then `terminate` → it was taken back mid-drag
|
|
2166
|
+
// `grant` then `release` → read the numbers; it is the threshold
|
|
2167
|
+
//
|
|
2168
|
+
// Remove once the cause is known.
|
|
2169
|
+
const swipeXY = (0, react_2.useRef)(new react_native_1.Animated.ValueXY({ x: 0, y: 0 })).current;
|
|
2170
|
+
// RAW TOUCH, NOT PanResponder.
|
|
2171
|
+
//
|
|
2172
|
+
// PanResponder was the idiomatic choice and it does not work here. Measured
|
|
2173
|
+
// on the device: `onTouchStart` fires on this card for a touch anywhere on
|
|
2174
|
+
// it, and `onMoveShouldSetPanResponderCapture` is then never called even
|
|
2175
|
+
// once — the card receives the touch and is never asked for the gesture. The
|
|
2176
|
+
// one place the gesture did work was the action button, because a Touchable
|
|
2177
|
+
// claims the responder itself and the negotiation that follows does reach
|
|
2178
|
+
// this card.
|
|
2179
|
+
//
|
|
2180
|
+
// So the swipe is driven by the events we can prove arrive. Touch events
|
|
2181
|
+
// bubble from whatever was touched up to this card independently of the
|
|
2182
|
+
// responder system, which is exactly the property the gesture needs: the
|
|
2183
|
+
// whole surface becomes the handle without anything having to win a
|
|
2184
|
+
// negotiation first.
|
|
2185
|
+
//
|
|
2186
|
+
// Nothing here claims the responder, so a press on the action button is
|
|
2187
|
+
// untouched — it presses. A press that travels moves the card as well, and
|
|
2188
|
+
// past the dismissal threshold the card leaves and takes the press with it.
|
|
2189
|
+
const onCardTouchStart = (e) => {
|
|
2190
|
+
engagedRef.current = true;
|
|
2191
|
+
const n = e.nativeEvent;
|
|
2192
|
+
// TRACKED FOR A TAP TOO, not only for a drag. This returned early unless
|
|
2193
|
+
// the card could be swiped, so on a card that only needed to be PRESSED
|
|
2194
|
+
// nothing was ever recorded and the press could not be recognised.
|
|
2195
|
+
if (!swipeable && !cardTakesPress)
|
|
2196
|
+
return;
|
|
2197
|
+
// A control already owns this touch — it is a press, not a drag.
|
|
2198
|
+
if (pressingControl.current)
|
|
2199
|
+
return;
|
|
2200
|
+
touchSwipe.current = { x0: n.pageX, y0: n.pageY, t0: Date.now(), moved: false };
|
|
2201
|
+
};
|
|
2202
|
+
const onCardTouchMove = (e) => {
|
|
2203
|
+
const st = touchSwipe.current;
|
|
2204
|
+
if (!st)
|
|
2205
|
+
return;
|
|
2206
|
+
const n = e.nativeEvent;
|
|
2207
|
+
const dx = n.pageX - st.x0;
|
|
2208
|
+
const dy = n.pageY - st.y0;
|
|
2209
|
+
// Below the claim threshold this is still a press, so the card stays put.
|
|
2210
|
+
if (!st.moved && !shouldClaimSwipe(true, dx, dy))
|
|
2211
|
+
return;
|
|
2212
|
+
// Travelled far enough to be a drag. On a card that cannot be swiped that
|
|
2213
|
+
// only means "this was not a tap" — the card must not follow the finger.
|
|
2214
|
+
st.moved = true;
|
|
2215
|
+
if (!swipeable)
|
|
2216
|
+
return;
|
|
2217
|
+
swipeXY.setValue(swipeOffset(placement, dx, dy));
|
|
2218
|
+
};
|
|
2219
|
+
const onCardTouchEnd = (e) => {
|
|
2220
|
+
const st = touchSwipe.current;
|
|
2221
|
+
touchSwipe.current = null;
|
|
2222
|
+
pressingControl.current = false;
|
|
2223
|
+
if (!st)
|
|
2224
|
+
return;
|
|
2225
|
+
// A TAP ON THE CARD IS THE BUTTON'S PRESS. It never travelled, so it was
|
|
2226
|
+
// never a drag — and the threshold that decides is the same one the swipe
|
|
2227
|
+
// uses, which is what keeps a deliberate flick from also counting as a tap.
|
|
2228
|
+
if (!st.moved) {
|
|
2229
|
+
if (cardTakesPress && cardImpliedList.length) {
|
|
2230
|
+
pressImplied(cardImpliedKey, cardImpliedList[0]);
|
|
1630
2231
|
}
|
|
1631
|
-
|
|
1632
|
-
}
|
|
1633
|
-
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
if (!swipeable)
|
|
2235
|
+
return;
|
|
2236
|
+
const n = e.nativeEvent;
|
|
2237
|
+
const dx = n.pageX - st.x0;
|
|
2238
|
+
const dy = n.pageY - st.y0;
|
|
2239
|
+
// px/ms, the same unit PanResponder reports, so the tuned threshold carries
|
|
2240
|
+
// over unchanged. Floored so a very fast flick cannot divide by ~0.
|
|
2241
|
+
const dt = Math.max(16, Date.now() - st.t0);
|
|
2242
|
+
const { dismiss, horizontal, moved } = swipeRelease({ dx, dy, vx: dx / dt, vy: dy / dt });
|
|
2243
|
+
if (dismiss) {
|
|
2244
|
+
react_native_1.Animated.timing(swipeXY, {
|
|
2245
|
+
toValue: horizontal
|
|
2246
|
+
? { x: moved < 0 ? -SCREEN_WIDTH : SCREEN_WIDTH, y: 0 }
|
|
2247
|
+
: { x: 0, y: moved < 0 ? -SCREEN_HEIGHT : SCREEN_HEIGHT },
|
|
2248
|
+
duration: 180, useNativeDriver: true,
|
|
2249
|
+
}).start(() => { swipeXY.setValue({ x: 0, y: 0 }); handleDismissRef.current('swipe'); });
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
|
|
2253
|
+
};
|
|
2254
|
+
const onCardTouchCancel = () => {
|
|
2255
|
+
const st = touchSwipe.current;
|
|
2256
|
+
touchSwipe.current = null;
|
|
2257
|
+
pressingControl.current = false;
|
|
2258
|
+
if (!st || !st.moved)
|
|
2259
|
+
return;
|
|
2260
|
+
react_native_1.Animated.spring(swipeXY, { toValue: { x: 0, y: 0 }, useNativeDriver: true, bounciness: 6 }).start();
|
|
2261
|
+
};
|
|
1634
2262
|
// Auto-dismiss. Same rule as web (autoDismissMs vetoes fullscreen and
|
|
1635
2263
|
// mustShow, clamps to 2s). Cancelled for good once the respondent touches
|
|
1636
2264
|
// anything — a timer that fires after someone has started answering would
|
|
1637
2265
|
// pull the survey out from under them.
|
|
1638
2266
|
// Which ending this interaction gets — see the render block below.
|
|
1639
|
-
const endingKind = (0,
|
|
2267
|
+
const endingKind = (0, react_2.useMemo)(() => {
|
|
1640
2268
|
const sv = survey;
|
|
1641
2269
|
const qs = (survey.rawQuestions ?? []);
|
|
1642
2270
|
const acts = (0, survey_spec_1.impliedStepActions)(qs) || [];
|
|
@@ -1652,14 +2280,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1652
2280
|
});
|
|
1653
2281
|
}, [survey, responses]);
|
|
1654
2282
|
const dismissMs = (0, survey_spec_1.autoDismissMs)(display, survey.delivery);
|
|
1655
|
-
const engagedRef = (0,
|
|
1656
|
-
(0,
|
|
2283
|
+
const engagedRef = (0, react_2.useRef)(false);
|
|
2284
|
+
(0, react_2.useEffect)(() => {
|
|
1657
2285
|
if (!visible || !dismissMs)
|
|
1658
2286
|
return;
|
|
1659
2287
|
engagedRef.current = false;
|
|
1660
2288
|
const t = setTimeout(() => {
|
|
2289
|
+
// NOBODY HOME IS NOT A DISMISSAL — the card left on a timer. Named so
|
|
2290
|
+
// the cadence does not spend an attempt on a showing nobody saw.
|
|
1661
2291
|
if (!engagedRef.current)
|
|
1662
|
-
handleDismissRef.current();
|
|
2292
|
+
handleDismissRef.current('auto');
|
|
1663
2293
|
}, dismissMs);
|
|
1664
2294
|
return () => clearTimeout(t);
|
|
1665
2295
|
}, [visible, dismissMs]);
|
|
@@ -1668,22 +2298,30 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1668
2298
|
// REPLACES an earlier one rather than merging. That was harmless while only
|
|
1669
2299
|
// sheets animated themselves, but a notification is both top-placed and
|
|
1670
2300
|
// swipeable, so the entrance translate would have been silently dropped.
|
|
1671
|
-
const enterStyle =
|
|
1672
|
-
? {
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
2301
|
+
const enterStyle = customSlide
|
|
2302
|
+
? (() => {
|
|
2303
|
+
// Its own size is not known before layout, so it travels a screen third
|
|
2304
|
+
// (or a width third sideways) — far enough to read as entering from
|
|
2305
|
+
// off-screen, short enough that the easing still lands crisply.
|
|
2306
|
+
const dY = SCREEN_HEIGHT / 3;
|
|
2307
|
+
const dX = SCREEN_WIDTH / 3;
|
|
2308
|
+
const OFFSET = {
|
|
2309
|
+
top: { x: 0, y: -dY }, bottom: { x: 0, y: dY },
|
|
2310
|
+
left: { x: -dX, y: 0 }, right: { x: dX, y: 0 },
|
|
2311
|
+
};
|
|
2312
|
+
const off = OFFSET[slideEdge] || OFFSET.bottom;
|
|
2313
|
+
const travel = (v) => enterAnim.interpolate({ inputRange: [0, 1], outputRange: [v, 0] });
|
|
2314
|
+
return {
|
|
2315
|
+
opacity: enterAnim,
|
|
2316
|
+
transform: [
|
|
2317
|
+
...(off.y !== 0 ? [{ translateY: travel(off.y) }] : []),
|
|
2318
|
+
...(off.x !== 0 ? [{ translateX: travel(off.x) }] : []),
|
|
2319
|
+
// The swipe transform rides LAST and on the same array — a second
|
|
2320
|
+
// `transform` entry would replace this one rather than compose.
|
|
2321
|
+
...(swipeable ? swipeXY.getTranslateTransform() : []),
|
|
2322
|
+
],
|
|
2323
|
+
};
|
|
2324
|
+
})()
|
|
1687
2325
|
: null;
|
|
1688
2326
|
const sheetRadius = cornerRadius !== undefined ? cornerRadius : 16;
|
|
1689
2327
|
const sheetStyle = isSheet ? {
|
|
@@ -1854,9 +2492,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1854
2492
|
* At zero it stops rendering rather than sitting at 0:00 beside a button that
|
|
1855
2493
|
* no longer works.
|
|
1856
2494
|
*/
|
|
1857
|
-
const [nowTs, setNowTs] = (0,
|
|
2495
|
+
const [nowTs, setNowTs] = (0, react_2.useState)(() => Date.now());
|
|
1858
2496
|
const countdown = (0, survey_spec_1.deadlineCountdown)(survey, display, nowTs);
|
|
1859
|
-
(0,
|
|
2497
|
+
(0, react_2.useEffect)(() => {
|
|
1860
2498
|
if (!countdown)
|
|
1861
2499
|
return undefined;
|
|
1862
2500
|
// Ticking every second only while the TEXT CONTAINS SECONDS, which
|
|
@@ -1877,7 +2515,19 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1877
2515
|
styles.progressBarFill,
|
|
1878
2516
|
{ width: `${((currentPage + 1) / totalPages) * 100}%`, backgroundColor: primaryColor },
|
|
1879
2517
|
] }) })] })) : null;
|
|
1880
|
-
return ((0, jsx_runtime_1.jsx)(react_native_1.Modal, { visible: visible, transparent: true, animationType: modalAnimation,
|
|
2518
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Modal, { visible: visible, transparent: true, animationType: modalAnimation,
|
|
2519
|
+
// Android's back gesture. Wrapped rather than passed: the handler now
|
|
2520
|
+
// takes a reason and this prop hands it a native event, which would
|
|
2521
|
+
// arrive as the reason and be recorded as one.
|
|
2522
|
+
onRequestClose: () => handleDismiss('close'), children: (0, jsx_runtime_1.jsxs)(react_native_1.KeyboardAvoidingView
|
|
2523
|
+
// The OVERLAY, not the card — a screen-shaped image with the card in
|
|
2524
|
+
// its real position, rather than a card-shaped one the replay player
|
|
2525
|
+
// would stretch across the whole device frame.
|
|
2526
|
+
, {
|
|
2527
|
+
// The OVERLAY, not the card — a screen-shaped image with the card in
|
|
2528
|
+
// its real position, rather than a card-shaped one the replay player
|
|
2529
|
+
// would stretch across the whole device frame.
|
|
2530
|
+
ref: (v) => { cardViewRef.current = v; }, pointerEvents: isPopupOverlay ? 'box-none' : 'auto', style: [
|
|
1881
2531
|
styles.overlay,
|
|
1882
2532
|
// Transparent for a popup — the app behind it is not being taken over.
|
|
1883
2533
|
isPopupOverlay && { backgroundColor: 'transparent' },
|
|
@@ -1888,36 +2538,49 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1888
2538
|
// A sheet sits flush against its edge, so the overlay loses its gutter.
|
|
1889
2539
|
isSheet && { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 },
|
|
1890
2540
|
isFullscreen && styles.overlayFullscreen,
|
|
1891
|
-
], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View
|
|
2541
|
+
], behavior: react_native_1.Platform.OS === 'ios' ? 'padding' : 'height', children: [(0, jsx_runtime_1.jsxs)(react_native_1.Animated.View
|
|
2542
|
+
// Explicit, because the overlay above is `box-none` on a popup so the
|
|
2543
|
+
// host app keeps its touches. That makes hit-testing on this subtree
|
|
2544
|
+
// worth stating rather than inheriting: the card must be a target, or
|
|
2545
|
+
// a flick that starts on the title falls straight through it.
|
|
2546
|
+
, {
|
|
2547
|
+
// Explicit, because the overlay above is `box-none` on a popup so the
|
|
2548
|
+
// host app keeps its touches. That makes hit-testing on this subtree
|
|
2549
|
+
// worth stating rather than inheriting: the card must be a target, or
|
|
2550
|
+
// a flick that starts on the title falls straight through it.
|
|
2551
|
+
pointerEvents: "auto", onTouchStart: onCardTouchStart, onTouchMove: onCardTouchMove, onTouchEnd: onCardTouchEnd, onTouchCancel: onCardTouchCancel,
|
|
1892
2552
|
// Feeds the authored-coordinate maths above. Only tracked when a
|
|
1893
2553
|
// coordinate is actually in play, and only on a real change, so the
|
|
1894
2554
|
// common case costs nothing and this cannot loop.
|
|
1895
2555
|
onLayout: hasPopupY ? (e) => {
|
|
1896
2556
|
const h = Math.round(e.nativeEvent.layout.height);
|
|
1897
2557
|
setMeasuredCardH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
|
|
1898
|
-
} : undefined,
|
|
2558
|
+
} : undefined, style: [
|
|
1899
2559
|
styles.card,
|
|
1900
2560
|
themeCard,
|
|
1901
2561
|
enterStyle,
|
|
1902
|
-
swipeable && !
|
|
2562
|
+
swipeable && !customSlide && { transform: swipeXY.getTranslateTransform() },
|
|
1903
2563
|
maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, maxWidth), maxWidth },
|
|
1904
2564
|
cornerRadius !== undefined && !isFullscreen && { borderRadius: cornerRadius },
|
|
1905
2565
|
// cardFullscreen fills; an explicit content height/width below OVERRIDES
|
|
1906
2566
|
// the fill so a fullscreen survey can be contained to that size.
|
|
1907
2567
|
isFullscreen && styles.cardFullscreen,
|
|
2568
|
+
// The background picture is a child, so the card must clip it or it
|
|
2569
|
+
// squares off the corners the author rounded.
|
|
2570
|
+
imgLayout === 'background' && { overflow: 'hidden' },
|
|
1908
2571
|
sheetStyle,
|
|
1909
2572
|
heightStyle,
|
|
1910
2573
|
widthStyle,
|
|
1911
2574
|
safePad,
|
|
1912
|
-
], children: [
|
|
2575
|
+
], children: [cardImageBack, closeShown && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt, style: [
|
|
1913
2576
|
styles.closeButton,
|
|
1914
|
-
isRtl ? { left:
|
|
1915
|
-
], onPress: handleDismiss, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.
|
|
2577
|
+
isRtl ? { left: contract.closeInset } : { right: contract.closeInset },
|
|
2578
|
+
], onPress: () => handleDismiss('close'), activeOpacity: 0.7, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.closeGlyph, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '45deg' }] }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.closeBar, { transform: [{ rotate: '-45deg' }] }] })] }) })), showThankYou && endingKind !== 'thanks' && (0, survey_spec_1.showsConfirmation)(survey) ? (() => {
|
|
1916
2579
|
const rw = (0, survey_spec_1.rewardToShow)(survey);
|
|
1917
2580
|
const comp = survey.compliance;
|
|
1918
2581
|
if (endingKind === 'fulfil' && rw) {
|
|
1919
2582
|
const exp = rw.expiresAt ? new Date(rw.expiresAt) : null;
|
|
1920
|
-
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children:
|
|
2583
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: (0, survey_spec_1.confirmationText)(survey, 'It’s yours') }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { selectable: true, style: [styles.rewardCode, fontStyle, themeTextPrimary], children: rw.value }), !!rw.whereValid && ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: rw.whereValid })), !!exp && ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: ["Expires ", exp.toLocaleString()] }))] }));
|
|
1921
2584
|
}
|
|
1922
2585
|
if (endingKind === 'hold') {
|
|
1923
2586
|
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, accessibilityLiveRegion: "polite", children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, fontStyle, themeTextPrimary], children: comp?.reviewRequired ? 'Under review' : 'Not completed' }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouSub, fontStyle, themeTextSecondary], children: comp?.blockedMessage
|
|
@@ -1940,157 +2603,189 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1940
2603
|
const imgSrc = firstImageSrc(rawThankYou);
|
|
1941
2604
|
const thankYouText = stripHtml(rawThankYou);
|
|
1942
2605
|
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.thankYou, children: [imgSrc ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: imgSrc }, style: styles.thankYouImage, resizeMode: "contain" })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouEmoji, { color: primaryColor }], children: "\u2713" })), thankYouText ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.thankYouTitle, dirText, fontStyle, themeTextPrimary, thankYouFS], children: thankYouText })) : null] }));
|
|
1943
|
-
})() : ((0, jsx_runtime_1.jsxs)(
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
//
|
|
1969
|
-
(
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
//
|
|
2023
|
-
//
|
|
2024
|
-
//
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
//
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
//
|
|
2085
|
-
//
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
//
|
|
2090
|
-
//
|
|
2091
|
-
//
|
|
2092
|
-
|
|
2093
|
-
|
|
2606
|
+
})() : ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: halfSide
|
|
2607
|
+
? { flexDirection: imgD.side === 'right' ? 'row-reverse' : 'row',
|
|
2608
|
+
alignItems: 'stretch' }
|
|
2609
|
+
: undefined, children: [imgLayout === 'half' ? cardImageHalf : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: halfSide
|
|
2610
|
+
? {
|
|
2611
|
+
flex: 1,
|
|
2612
|
+
// minWidth 0, or one long unbroken word in the headline
|
|
2613
|
+
// refuses to shrink and shoves the picture off its share.
|
|
2614
|
+
minWidth: 0,
|
|
2615
|
+
// THE WORDS SIT 12 FROM THE PICTURE, as they do on the web
|
|
2616
|
+
// card — `.cxp-half-text` carries `padding-left:12px` and
|
|
2617
|
+
// nothing else, because the card's own padding is already
|
|
2618
|
+
// spent on the far side. Here the header, body and footer
|
|
2619
|
+
// each carry the full card padding, so the text column
|
|
2620
|
+
// started a whole padding further in and came out 12pt
|
|
2621
|
+
// narrower than the frame beside it. That is about one word
|
|
2622
|
+
// per line — enough to wrap a two-line description onto
|
|
2623
|
+
// three, which is exactly how it was reported.
|
|
2624
|
+
...(imgD.side === 'right'
|
|
2625
|
+
? { marginRight: 12 - halfPadR }
|
|
2626
|
+
: { marginLeft: 12 - halfPadL }),
|
|
2627
|
+
}
|
|
2628
|
+
: undefined, children: [langSwitcher, imgLayout === 'above' ? (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padBand, children: cardImageBand }) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
|
|
2629
|
+
styles.header,
|
|
2630
|
+
padHeader,
|
|
2631
|
+
// No ✕ reservation here in either direction — the title carries
|
|
2632
|
+
// it now (closeReserve), so the row below is free for the action.
|
|
2633
|
+
// Thumbnail beside the text, as `.cxp-notify-row` does on web.
|
|
2634
|
+
// A row for the thumbnail, and equally for a button that sits
|
|
2635
|
+
// beside the text — a notification with no image still needs
|
|
2636
|
+
// one if its action is next to the content.
|
|
2637
|
+
(besideImage || notifyBeside) && { flexDirection: 'row', alignItems: 'center' },
|
|
2638
|
+
], children: [((survey.branding?.showLogo !== false && survey.branding?.logo)
|
|
2639
|
+
|| survey.branding?.brandName) && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
|
|
2640
|
+
styles.brandRow,
|
|
2641
|
+
// Logo position, same rule as the other two renderers.
|
|
2642
|
+
(survey.branding?.logoAlign === 'center'
|
|
2643
|
+
? { justifyContent: 'center' }
|
|
2644
|
+
: survey.branding?.logoAlign === 'right'
|
|
2645
|
+
? { justifyContent: 'flex-end' }
|
|
2646
|
+
: null),
|
|
2647
|
+
], 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] })), besideImage ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: cardImageQ?.imageUrl }, accessibilityLabel: cardImageQ?.alt || undefined, accessible: !!cardImageQ?.alt, style: {
|
|
2648
|
+
width: contract.imageSize,
|
|
2649
|
+
height: contract.imageSize,
|
|
2650
|
+
borderRadius: contract.imageRadius,
|
|
2651
|
+
marginRight: 12,
|
|
2652
|
+
flexShrink: 0,
|
|
2653
|
+
// A square beside the text sits level with the headline
|
|
2654
|
+
// it belongs to unless the author says otherwise.
|
|
2655
|
+
alignSelf: imgD.align === 'start' ? 'flex-start'
|
|
2656
|
+
: imgD.align === 'end' ? 'flex-end' : 'center',
|
|
2657
|
+
}, resizeMode: imgD.fit })) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: (besideImage || notifyBeside) ? { flex: 1, minWidth: 0 } : undefined, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.title, dirText, fontStyle, themeTextPrimary, titleFS, notifyTitleFS,
|
|
2658
|
+
closeReserve,
|
|
2659
|
+
survey.titleAlign
|
|
2660
|
+
? { textAlign: survey.titleAlign }
|
|
2661
|
+
: null], children: (0, interpolate_1.interpolatePlain)(
|
|
2662
|
+
// Title is plain text; strip any stray rich-text markup +
|
|
2663
|
+
// decode so it never shows literal HTML on native.
|
|
2664
|
+
String((0, translationLookup_1.surveyText)(survey, currentLanguage, 'title', survey.title) || '')
|
|
2665
|
+
.replace(/<[^>]*>/g, '').replace(/ /gi, ' '), evalCtx) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: notifyBeside ? {
|
|
2666
|
+
flexDirection: 'row',
|
|
2667
|
+
alignItems: 'center',
|
|
2668
|
+
justifyContent: 'space-between',
|
|
2669
|
+
} : undefined, children: [(() => {
|
|
2670
|
+
// Description is authored as rich HTML; native Text can't render
|
|
2671
|
+
// markup, so strip tags + decode to plain text.
|
|
2672
|
+
const descRaw = (0, translationLookup_1.surveyText)(survey, currentLanguage, 'description', survey.description);
|
|
2673
|
+
const desc = String(descRaw || '').replace(/<[^>]*>/g, '').replace(/ /gi, ' ').trim();
|
|
2674
|
+
return desc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.description, dirText, fontStyle, themeTextSecondary, descFS, notifyDescFS,
|
|
2675
|
+
// Takes the width the button leaves, and no more.
|
|
2676
|
+
notifyBeside && { flex: 1, minWidth: 0, marginTop: 2 }], children: (0, interpolate_1.interpolatePlain)(desc, evalCtx) })) : null;
|
|
2677
|
+
})(), notifyActionQ ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flexShrink: 0, marginLeft: 12 }, children: renderQuestion(notifyActionQ) })) : null] })] }), deadlineEl, (() => {
|
|
2678
|
+
const terms = cardTerms;
|
|
2679
|
+
return terms ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
|
|
2680
|
+
styles.description, dirText, fontStyle, themeTextMuted,
|
|
2681
|
+
{ fontSize: Math.round((_num(_authoredStyle.fontSizeDescription) ?? 14) * 0.85),
|
|
2682
|
+
marginTop: 6 },
|
|
2683
|
+
], children: (0, interpolate_1.interpolatePlain)(terms, evalCtx) })) : null;
|
|
2684
|
+
})(), showProgressTop && progressEl] }), imgLayout === 'below' ? (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padBand, children: cardImageBand }) : null, (0, jsx_runtime_1.jsx)(BodyContainer, { style: [styles.body, padBody, fsContent], showsVerticalScrollIndicator: false, children: currentQuestions.filter((q) => {
|
|
2685
|
+
// The card composes the picture itself — beside the text, in
|
|
2686
|
+
// a band, or behind everything. Leaving it here too would
|
|
2687
|
+
// render it twice, the second time full-bleed.
|
|
2688
|
+
if (composesImage && cardImageQ && q === cardImageQ)
|
|
2689
|
+
return false;
|
|
2690
|
+
// Drawn as the offer's conditions above — see cardTerms.
|
|
2691
|
+
if (cardTerms && String(q?.id ?? '') === 'offer_terms'
|
|
2692
|
+
&& String(q?.type ?? '').toLowerCase() === 'content')
|
|
2693
|
+
return false;
|
|
2694
|
+
// Drawn up in the header row instead — leaving it here too
|
|
2695
|
+
// would render the action twice.
|
|
2696
|
+
if (notifyActionQ && q === notifyActionQ)
|
|
2697
|
+
return false;
|
|
2698
|
+
return true;
|
|
2699
|
+
}).map((q) => {
|
|
2700
|
+
// v1.1 uses `id`+`required`; legacy uses `name`+`isRequired`.
|
|
2701
|
+
// The provider normalizes both onto the same object so
|
|
2702
|
+
// either lookup works.
|
|
2703
|
+
const rowKey = q.id || q.name;
|
|
2704
|
+
// Through forValidation, so requiredWhen counts. A question
|
|
2705
|
+
// that becomes required part-way through was previously
|
|
2706
|
+
// marked with nothing at all.
|
|
2707
|
+
const isRequired = !!forValidation(q).required
|
|
2708
|
+
|| q.required || q.isRequired;
|
|
2709
|
+
// An implied question has no prompt: its ACTIONS are the UI,
|
|
2710
|
+
// and its `text` is bookkeeping the author never meant to
|
|
2711
|
+
// show. Web renders the whole block display:none and lifts
|
|
2712
|
+
// the buttons into the nav bar; here the buttons render in
|
|
2713
|
+
// place, so only the label and description are suppressed.
|
|
2714
|
+
// Without this the stray authoring text ("tte") appeared as a
|
|
2715
|
+
// question above the notification's own button.
|
|
2716
|
+
const isImplied = String(q?.type ?? '').toLowerCase() === 'implied';
|
|
2717
|
+
const qLabel = (0, interpolate_1.interpolatePlain)((0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'text', q.title), evalCtx);
|
|
2718
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { ref: (el) => { questionRefs.current[rowKey] = el; }, style: [
|
|
2719
|
+
styles.questionBlock, gapQBlock,
|
|
2720
|
+
// A NOTIFICATION'S ACTION IS NOT A QUESTION IN A LIST.
|
|
2721
|
+
//
|
|
2722
|
+
// questionBlock carries `marginBottom: sectionGap` so
|
|
2723
|
+
// consecutive questions breathe. On a notification the
|
|
2724
|
+
// only "question" is the implied action, so that gap
|
|
2725
|
+
// landed UNDER the button as dead space at the bottom of
|
|
2726
|
+
// the card — the reported extra room the preview does not
|
|
2727
|
+
// have. Web spends 12px above the action and nothing
|
|
2728
|
+
// below it (`.cxp-notify-action-below`); this matches.
|
|
2729
|
+
isNotifyBanner && { marginTop: 12, marginBottom: 0 },
|
|
2730
|
+
], children: [!(0, survey_spec_1.showsQuestionLabel)(q) ? null : ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: [styles.questionTitle, dirText, fontStyle, themeTextPrimary, qTitleFS],
|
|
2731
|
+
// The asterisk below is decoration: a screen reader
|
|
2732
|
+
// reads it as "star", or skips it, so a required
|
|
2733
|
+
// question sounded exactly like an optional one until
|
|
2734
|
+
// submitting failed. The label says the word.
|
|
2735
|
+
accessibilityLabel: isRequired ? `${qLabel}, required` : qLabel, 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: " *" })] })), (() => {
|
|
2736
|
+
if (isImplied)
|
|
2737
|
+
return null;
|
|
2738
|
+
const qDesc = (0, translationLookup_1.questionText)(survey, currentLanguage, rowKey, 'description', q.description);
|
|
2739
|
+
return qDesc ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.questionDesc, dirText, fontStyle, themeTextMuted, qDescFS], children: (0, interpolate_1.interpolatePlain)(qDesc, evalCtx) })) : null;
|
|
2740
|
+
})(), renderQuestion(q), errors[rowKey] ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.errorText, dirText, fontStyle],
|
|
2741
|
+
// Announced when it appears, and reachable afterwards:
|
|
2742
|
+
// a message that is only spoken once leaves someone who
|
|
2743
|
+
// moves back to the field with no way to find out what
|
|
2744
|
+
// was wrong with their answer.
|
|
2745
|
+
accessibilityRole: "alert", accessibilityLiveRegion: "polite", children: errors[rowKey] })) : null] }, rowKey));
|
|
2746
|
+
}) }), submitError ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.submitError, dirText, fontStyle],
|
|
2747
|
+
// Announced, not merely displayed. A respondent who cannot see
|
|
2748
|
+
// the panel is otherwise held with no way to discover why.
|
|
2749
|
+
accessibilityRole: "alert", accessibilityLiveRegion: "polite", children: submitError })) : null, showProgressBottom && (0, jsx_runtime_1.jsx)(react_native_1.View, { style: padFooter, children: progressEl }), footerHasContent && (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [
|
|
2750
|
+
styles.footer,
|
|
2751
|
+
padFooter,
|
|
2752
|
+
// Flip button order for RTL so the primary (Next / Submit)
|
|
2753
|
+
// action sits on the left where an Arabic reader
|
|
2754
|
+
// expects to find the forward affordance in their
|
|
2755
|
+
// text-flow direction.
|
|
2756
|
+
isRtl && { flexDirection: 'row-reverse' },
|
|
2757
|
+
footerAlign,
|
|
2758
|
+
], children: [showVoiceMic && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt, style: [
|
|
2759
|
+
styles.voiceMic,
|
|
2760
|
+
{ backgroundColor: voiceActive ? '#ef4444' : primaryColor },
|
|
2761
|
+
buttonAlign === 'right' && { marginRight: 'auto' },
|
|
2762
|
+
], 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, { ...swipeExempt, style: [
|
|
2763
|
+
styles.backButton,
|
|
2764
|
+
isLight && { backgroundColor: palette.input, borderColor: palette.inputBorder },
|
|
2765
|
+
btnAutoWidth,
|
|
2766
|
+
// Right-align parity with web: Back hugs the left edge while
|
|
2767
|
+
// Next/Submit sits on the right (web uses margin-right:auto).
|
|
2768
|
+
buttonAlign === 'right' && { marginRight: 'auto' },
|
|
2769
|
+
], onPress: handleBack, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.backButtonText, fontStyle, themeTextSecondary, buttonFS], children: "Back" }) })), impliedNow.length > 0 ? null : (() => {
|
|
2770
|
+
// Reflect the actual outcome of pressing Next: if a
|
|
2771
|
+
// branch resolves to __end__ we're submitting even
|
|
2772
|
+
// when we're not on the last page. Cheap to evaluate
|
|
2773
|
+
// here — same call handleNext will make.
|
|
2774
|
+
const target = resolveNextTarget();
|
|
2775
|
+
// Reflect the actual outcome of pressing Next. A __end__
|
|
2776
|
+
// branch always submits. A fork branch or a page/linear-
|
|
2777
|
+
// next never submits. Otherwise: last-page + no fork
|
|
2778
|
+
// frame left on the stack = submit.
|
|
2779
|
+
const willSubmit = target?.kind === 'end' ||
|
|
2780
|
+
(target == null && isLastPage && forkStack.length === 0);
|
|
2781
|
+
const isSingleButton = history.length === 0 && currentPage === 0;
|
|
2782
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt,
|
|
2783
|
+
// Full-width ONLY when alignment is unset (native default) or
|
|
2784
|
+
// explicitly 'full'. When the author picked right/center/left,
|
|
2785
|
+
// respect it even on the first page (was forced full-width,
|
|
2786
|
+
// so mobile ignored the setting on page 1 — unlike web).
|
|
2787
|
+
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' }) }));
|
|
2788
|
+
})()] })] })] }))] }), (0, jsx_runtime_1.jsx)(handsFreeVoice_1.VoiceOverlay, { visible: voiceActive, phase: voicePhase, transcript: voiceTranscript, onStop: () => (0, handsFreeVoice_1.stopVoice)() })] }) }));
|
|
2094
2789
|
}
|
|
2095
2790
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = react_native_1.Dimensions.get('window');
|
|
2096
2791
|
/** Web draws a popup at `width:90vw`; the card matches so both read the same. */
|
|
@@ -2108,7 +2803,9 @@ const CARD_VIEWPORT_FRACTION = 0.9;
|
|
|
2108
2803
|
const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
2109
2804
|
overlay: {
|
|
2110
2805
|
flex: 1,
|
|
2111
|
-
|
|
2806
|
+
// The scrim, not the page. See SurveyStyleContract.overlay — this used to
|
|
2807
|
+
// read `ground`, which web spends on the widget surface instead.
|
|
2808
|
+
backgroundColor: c.overlay,
|
|
2112
2809
|
justifyContent: 'center',
|
|
2113
2810
|
alignItems: 'center',
|
|
2114
2811
|
padding: 20,
|
|
@@ -2117,7 +2814,12 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2117
2814
|
width: Math.min(SCREEN_WIDTH - 40, 420),
|
|
2118
2815
|
maxHeight: '85%',
|
|
2119
2816
|
backgroundColor: c.card,
|
|
2120
|
-
|
|
2817
|
+
// FOUR CORNERS, each falling back to the single value — so unlinking one
|
|
2818
|
+
// corner cannot silently square the other three.
|
|
2819
|
+
borderTopLeftRadius: c.cardRadiusTopLeft ?? c.cardRadius,
|
|
2820
|
+
borderTopRightRadius: c.cardRadiusTopRight ?? c.cardRadius,
|
|
2821
|
+
borderBottomRightRadius: c.cardRadiusBottomRight ?? c.cardRadius,
|
|
2822
|
+
borderBottomLeftRadius: c.cardRadiusBottomLeft ?? c.cardRadius,
|
|
2121
2823
|
borderWidth: 1,
|
|
2122
2824
|
borderColor: c.cardBorder,
|
|
2123
2825
|
overflow: 'hidden',
|
|
@@ -2137,20 +2839,35 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2137
2839
|
},
|
|
2138
2840
|
closeButton: {
|
|
2139
2841
|
position: 'absolute',
|
|
2140
|
-
|
|
2842
|
+
// From the contract now — see SurveyStyleContract.closeSize. At the old
|
|
2843
|
+
// hardcoded 12 the circle sat a third of the way into the header's own
|
|
2844
|
+
// padding, so it read as part of the text block rather than as the card's
|
|
2845
|
+
// own control; 8 is the default it ships with.
|
|
2846
|
+
top: c.closeInset,
|
|
2141
2847
|
// left / right applied inline based on survey.direction — see
|
|
2142
2848
|
// the closeButton render site above.
|
|
2143
2849
|
zIndex: 10,
|
|
2144
|
-
width:
|
|
2145
|
-
height:
|
|
2146
|
-
borderRadius:
|
|
2850
|
+
width: c.closeSize,
|
|
2851
|
+
height: c.closeSize,
|
|
2852
|
+
borderRadius: c.closeRadius,
|
|
2147
2853
|
backgroundColor: c.closeBg,
|
|
2148
2854
|
alignItems: 'center',
|
|
2149
2855
|
justifyContent: 'center',
|
|
2150
2856
|
},
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2857
|
+
// The two bars that make the cross. Sized off the button, not off a font.
|
|
2858
|
+
// Sized off the disc, so a smaller button keeps its proportions.
|
|
2859
|
+
closeGlyph: {
|
|
2860
|
+
width: Math.round(c.closeSize * 0.38),
|
|
2861
|
+
height: Math.round(c.closeSize * 0.38),
|
|
2862
|
+
alignItems: 'center',
|
|
2863
|
+
justifyContent: 'center',
|
|
2864
|
+
},
|
|
2865
|
+
closeBar: {
|
|
2866
|
+
position: 'absolute',
|
|
2867
|
+
width: Math.round(c.closeSize * 0.38),
|
|
2868
|
+
height: 1.5,
|
|
2869
|
+
borderRadius: 1,
|
|
2870
|
+
backgroundColor: c.closeText,
|
|
2154
2871
|
},
|
|
2155
2872
|
header: {
|
|
2156
2873
|
// Per-side padding maps onto the three stacked regions this modal already
|
|
@@ -2160,9 +2877,15 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2160
2877
|
paddingTop: c.cardPaddingTop ?? c.cardPadding,
|
|
2161
2878
|
paddingLeft: c.cardPaddingLeft ?? c.cardPadding,
|
|
2162
2879
|
paddingBottom: 12,
|
|
2163
|
-
//
|
|
2164
|
-
//
|
|
2165
|
-
|
|
2880
|
+
// THE ✕'s ROOM IS THE TITLE'S PROBLEM, NOT THE HEADER'S.
|
|
2881
|
+
//
|
|
2882
|
+
// This reserved 48pt on the whole header so a title could not slide under
|
|
2883
|
+
// the close button. That was right while the header held only text — and
|
|
2884
|
+
// wrong once a notification's action moved up into it, because the
|
|
2885
|
+
// reservation then squeezed the BUTTON as well, and moving the ✕ visibly
|
|
2886
|
+
// shifted an action sitting nowhere near it. Web has always reserved on the
|
|
2887
|
+
// title alone (`titlePad`); this now matches.
|
|
2888
|
+
paddingRight: c.cardPaddingRight ?? c.cardPadding,
|
|
2166
2889
|
},
|
|
2167
2890
|
// Branding row above the title — logo image + optional brand name
|
|
2168
2891
|
// caption. Center-aligned; the logo is capped small enough that
|
|
@@ -2185,6 +2908,14 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2185
2908
|
marginTop: 6,
|
|
2186
2909
|
},
|
|
2187
2910
|
title: {
|
|
2911
|
+
// Unset is transparent — see SurveyStyleContract.titleBackground.
|
|
2912
|
+
...(c.titleBackground ? {
|
|
2913
|
+
backgroundColor: c.titleBackground,
|
|
2914
|
+
paddingHorizontal: 8,
|
|
2915
|
+
paddingVertical: 4,
|
|
2916
|
+
borderRadius: 4,
|
|
2917
|
+
overflow: 'hidden',
|
|
2918
|
+
} : null),
|
|
2188
2919
|
color: c.headingColor,
|
|
2189
2920
|
fontSize: c.fontSizeHeading,
|
|
2190
2921
|
// 600, matching web's `.cxp-widget-title`. At 700 the same string is about
|
|
@@ -2195,10 +2926,26 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2195
2926
|
fontWeight: '600',
|
|
2196
2927
|
},
|
|
2197
2928
|
description: {
|
|
2929
|
+
// A RATIO, from the contract. Web multiplies lineHeightBody by the font
|
|
2930
|
+
// size; mobile had 20 written in, so the builder's Line height control
|
|
2931
|
+
// moved one renderer and not the other. Rounded because RN wants points.
|
|
2932
|
+
lineHeight: Math.round(c.fontSizeDescription * c.lineHeightBody),
|
|
2933
|
+
...(c.descriptionBackground ? {
|
|
2934
|
+
backgroundColor: c.descriptionBackground,
|
|
2935
|
+
paddingHorizontal: 8,
|
|
2936
|
+
paddingVertical: 4,
|
|
2937
|
+
borderRadius: 4,
|
|
2938
|
+
overflow: 'hidden',
|
|
2939
|
+
} : null),
|
|
2198
2940
|
color: c.descriptionColor,
|
|
2199
|
-
|
|
2941
|
+
// FROM THE CONTRACT, like the title two rules up. This was 14, written in,
|
|
2942
|
+
// so `fontSizeDescription` reached the web card and the notification's own
|
|
2943
|
+
// scale and nothing else: an offer authored at heading 11 / body 10 drew
|
|
2944
|
+
// the heading at 11 and the body at 14, which is a body LARGER than its
|
|
2945
|
+
// own headline. The authored numbers were never wrong; one of the two was
|
|
2946
|
+
// simply not being read.
|
|
2947
|
+
fontSize: c.fontSizeDescription,
|
|
2200
2948
|
marginTop: 6,
|
|
2201
|
-
lineHeight: 20,
|
|
2202
2949
|
},
|
|
2203
2950
|
progress: {
|
|
2204
2951
|
color: c.progressText,
|
|
@@ -2291,7 +3038,14 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2291
3038
|
// weight rather than looking like an option list.
|
|
2292
3039
|
impliedActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 4 },
|
|
2293
3040
|
impliedBtn: {
|
|
2294
|
-
|
|
3041
|
+
// flexShrink so a long label narrows the button rather than pushing the
|
|
3042
|
+
// text off the card when the action sits beside the content.
|
|
3043
|
+
flexGrow: 1, flexShrink: 1, flexBasis: 140, minWidth: 0,
|
|
3044
|
+
// ALL THREE SIZE CONTROLS, from the contract. Back and Next have read
|
|
3045
|
+
// buttonPaddingY and buttonFontSize throughout; this button carried its own
|
|
3046
|
+
// literals, so the builder's Size group moved the preview and left the
|
|
3047
|
+
// action a notification actually offers exactly where it was.
|
|
3048
|
+
paddingVertical: c.buttonPaddingY, paddingHorizontal: c.buttonPaddingX,
|
|
2295
3049
|
// From the contract, like every other button. A literal 8 here meant an
|
|
2296
3050
|
// authored button radius applied to Next and Back but not to the action a
|
|
2297
3051
|
// notification actually offers.
|
|
@@ -2300,11 +3054,27 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2300
3054
|
// The brand accent, not a hardcoded blue. `#2b6cb0` ignored primaryColor
|
|
2301
3055
|
// entirely, so a notification's only action was off-brand on every survey —
|
|
2302
3056
|
// the same button renders in the authored accent on web.
|
|
3057
|
+
// borderColor too: the button carries a 1px border (transparent by default),
|
|
3058
|
+
// so an authored border colour has somewhere to land.
|
|
3059
|
+
// NO borderColor HERE. `primaryBorder` is derived from the brand colour when
|
|
3060
|
+
// none is authored, so setting it unconditionally drew a border on every
|
|
3061
|
+
// button — while web draws one only when the author asked for it. The
|
|
3062
|
+
// authored value is applied at the render site instead, where "authored" can
|
|
3063
|
+
// still be told apart from "derived". impliedBtn's transparent edge stands
|
|
3064
|
+
// otherwise, so the 1px reserves its space without showing.
|
|
2303
3065
|
impliedPrimary: { backgroundColor: c.primaryBg },
|
|
2304
|
-
|
|
3066
|
+
// THE SECOND BUTTON READS THE CONTRACT, like the first.
|
|
3067
|
+
//
|
|
3068
|
+
// The contract has carried secondaryBg/Text/Border all along and this drew
|
|
3069
|
+
// from `transparent` and the INPUT border instead — so an offer's decline
|
|
3070
|
+
// action was the one thing on the card an author could not colour, while the
|
|
3071
|
+
// panel showed a single button's settings for a card with two.
|
|
3072
|
+
impliedSecondary: { backgroundColor: c.secondaryBg, borderColor: c.secondaryBorder },
|
|
2305
3073
|
impliedChosen: { opacity: 0.75 },
|
|
2306
|
-
|
|
2307
|
-
|
|
3074
|
+
// fontSize from the contract for the same reason — its default IS 15, so an
|
|
3075
|
+
// unstyled notification renders identically and an authored one now moves.
|
|
3076
|
+
impliedPrimaryLabel: { color: c.primaryText, fontWeight: '600', fontSize: c.buttonFontSize },
|
|
3077
|
+
impliedSecondaryLabel: { color: c.secondaryText, fontWeight: '600', fontSize: c.buttonFontSize },
|
|
2308
3078
|
desktopHint: {
|
|
2309
3079
|
color: c.hintText,
|
|
2310
3080
|
fontSize: 13,
|