@cxpinsight/react-native 0.17.7 → 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 +235 -17
- 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 +1206 -330
- 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,7 +850,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
654
850
|
setSubmitError('');
|
|
655
851
|
setCurrentFlow(undefined);
|
|
656
852
|
setForkStack([]);
|
|
657
|
-
onDismiss();
|
|
853
|
+
onDismiss(reason);
|
|
658
854
|
};
|
|
659
855
|
handleDismissRef.current = handleDismiss;
|
|
660
856
|
// Normalize the v1.1 options[] {value,label} and the legacy
|
|
@@ -861,43 +1057,46 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
861
1057
|
// can handle.
|
|
862
1058
|
const actions = (0, survey_spec_1.impliedActions)(question, (t) => (0, interpolate_1.interpolatePlain)(t, evalCtx));
|
|
863
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;
|
|
864
1068
|
// Consent choices render identically — see impliedEqualProminence.
|
|
865
1069
|
const equal = (0, survey_spec_1.impliedEqualProminence)([question]);
|
|
866
|
-
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),
|
|
867
1071
|
// The press is both the answer and the Next — the footer is
|
|
868
1072
|
// hidden on this step. Deferred a tick so setResponses has
|
|
869
1073
|
// flushed before handleNext validates what it just replaced.
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
// this person explicitly declined to make and retire it
|
|
875
|
-
// against maxSubmissions. It does not follow the action's
|
|
876
|
-
// link either — postponing is not a request to go anywhere.
|
|
877
|
-
// Through isDeferChoice, not the raw flag: it is false for a
|
|
878
|
-
// compliance check, and a compliance check you postpone by
|
|
879
|
-
// pressing a button is one you never have to answer.
|
|
880
|
-
if ((0, survey_spec_1.isDeferChoice)(a, survey.interactionType)) {
|
|
881
|
-
handleDismissRef.current();
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
updateResponse(key, a.value);
|
|
885
|
-
setTimeout(() => handleNextRef.current(), 0);
|
|
886
|
-
// Follow the action's deep link. `myapp://cart` is the point
|
|
887
|
-
// of this on a phone; Linking refuses a scheme the OS has no
|
|
888
|
-
// handler for, so a bad target fails quietly rather than
|
|
889
|
-
// crashing the survey. The href already passed
|
|
890
|
-
// safeActionHref in the shared spec.
|
|
891
|
-
if (a.href) {
|
|
892
|
-
react_native_1.Linking.openURL(a.href).catch(() => { });
|
|
893
|
-
}
|
|
894
|
-
}, 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: [
|
|
895
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
|
+
},
|
|
896
1093
|
// The first action is the affirmative one. An author who
|
|
897
1094
|
// lists "Decline" first gets it emphasised — their call.
|
|
898
1095
|
(i === 0 && !equal) ? styles.impliedPrimary : styles.impliedSecondary,
|
|
1096
|
+
(i === 0 && !equal) && authoredBtnBorder ? { borderColor: authoredBtnBorder } : null,
|
|
899
1097
|
val === a.value && styles.impliedChosen,
|
|
900
|
-
]
|
|
1098
|
+
] },
|
|
1099
|
+
(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: (i === 0 && !equal) ? styles.impliedPrimaryLabel : styles.impliedSecondaryLabel, children: a.label })))) }));
|
|
901
1100
|
},
|
|
902
1101
|
'signature': () => {
|
|
903
1102
|
if (!(0, SignatureQuestion_1.isSignatureAvailable)())
|
|
@@ -998,10 +1197,10 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
998
1197
|
// A ref carries the latest render values so the (stable) controller always
|
|
999
1198
|
// reads fresh page/question state — commit advances the page and awaits the
|
|
1000
1199
|
// re-render before the session reads the next question.
|
|
1001
|
-
const voiceRef =
|
|
1200
|
+
const voiceRef = react_2.default.useRef({ currentQuestions: [], isLastPage: false, language: 'en', intro: '', outro: '', updateResponse: () => { }, handleNext: () => { } });
|
|
1002
1201
|
const voiceLangM = currentLanguage || survey.defaultLanguage || 'en';
|
|
1003
1202
|
// Voice-only viability across the whole survey (all pages' questions).
|
|
1004
|
-
const voiceAudit = (0,
|
|
1203
|
+
const voiceAudit = (0, react_2.useMemo)(() => (0, voiceParsers_1.auditVoiceQuestions)(pages.flatMap((p) => p.elements)), [pages]);
|
|
1005
1204
|
const voiceIntroM = [
|
|
1006
1205
|
String(survey.title || '').replace(/<[^>]*>/g, '').trim(),
|
|
1007
1206
|
String(survey.description || '').replace(/<[^>]*>/g, '').trim(),
|
|
@@ -1009,7 +1208,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1009
1208
|
].filter(Boolean).join('. ');
|
|
1010
1209
|
const voiceOutroM = String((survey.settings?.thankYouMessage) || survey.thankYouMessage || '').replace(/<[^>]*>/g, '').trim();
|
|
1011
1210
|
voiceRef.current = { currentQuestions, isLastPage, language: voiceLangM, intro: voiceIntroM, outro: voiceOutroM, updateResponse, handleNext };
|
|
1012
|
-
const buildVoiceController = (0,
|
|
1211
|
+
const buildVoiceController = (0, react_2.useCallback)(() => {
|
|
1013
1212
|
const advance = () => {
|
|
1014
1213
|
voiceIdxRef.current++;
|
|
1015
1214
|
const v = voiceRef.current;
|
|
@@ -1048,7 +1247,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1048
1247
|
onStop: () => { setVoiceActive(false); setVoicePhase('idle'); setVoiceTranscript(''); },
|
|
1049
1248
|
};
|
|
1050
1249
|
}, [currentLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1051
|
-
const toggleVoice = (0,
|
|
1250
|
+
const toggleVoice = (0, react_2.useCallback)(() => {
|
|
1052
1251
|
if ((0, handsFreeVoice_1.isVoiceActive)()) {
|
|
1053
1252
|
(0, handsFreeVoice_1.stopVoice)();
|
|
1054
1253
|
return;
|
|
@@ -1065,9 +1264,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1065
1264
|
}, [buildVoiceController, voiceAudit.blocking.length]);
|
|
1066
1265
|
// Reset the walker when the page changes via MANUAL navigation (voice manages
|
|
1067
1266
|
// its own index across its advances); always stop voice on unmount.
|
|
1068
|
-
(0,
|
|
1267
|
+
(0, react_2.useEffect)(() => { if (!(0, handsFreeVoice_1.isVoiceActive)())
|
|
1069
1268
|
voiceIdxRef.current = 0; }, [currentPage, currentFlow]);
|
|
1070
|
-
(0,
|
|
1269
|
+
(0, react_2.useEffect)(() => () => { (0, handsFreeVoice_1.stopVoice)(); }, []);
|
|
1071
1270
|
const voiceOn = (0, handsFreeVoice_1.isVoiceSupported)() && survey.voiceMode !== false;
|
|
1072
1271
|
const showVoiceMic = voiceOn && !(history.length > 0 || currentPage > 0);
|
|
1073
1272
|
// branding.typography.fontFamily overrides the system font on every
|
|
@@ -1085,10 +1284,21 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1085
1284
|
// application — see the note in the web SDK's appDefaults().
|
|
1086
1285
|
const _detached = Boolean(survey.settings?.themeDetached);
|
|
1087
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;
|
|
1088
1290
|
const _fontByLang = survey.branding?.typography?.fontByLanguage;
|
|
1089
1291
|
const _langPrimary = (currentLanguage || '').split('-')[0] || '';
|
|
1090
1292
|
const fontFamily = (_fontByLang && ((currentLanguage ? _fontByLang[currentLanguage] : undefined) || _fontByLang[_langPrimary])) ||
|
|
1091
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) ||
|
|
1092
1302
|
_asdMobile.font ||
|
|
1093
1303
|
undefined;
|
|
1094
1304
|
const fontStyle = fontFamily ? { fontFamily } : undefined;
|
|
@@ -1159,8 +1369,6 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1159
1369
|
})();
|
|
1160
1370
|
const scaled = (n) => Math.round(n * baseScale);
|
|
1161
1371
|
const fs = baseScale !== 1;
|
|
1162
|
-
const titleFS = fs ? { fontSize: scaled(20) } : undefined;
|
|
1163
|
-
const descFS = fs ? { fontSize: scaled(14) } : undefined;
|
|
1164
1372
|
const qTitleFS = fs ? { fontSize: scaled(15) } : undefined;
|
|
1165
1373
|
const qDescFS = fs ? { fontSize: scaled(13) } : undefined;
|
|
1166
1374
|
const thankYouFS = fs ? { fontSize: scaled(18) } : undefined;
|
|
@@ -1218,6 +1426,10 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1218
1426
|
comf; // 'comfortable' or undefined
|
|
1219
1427
|
const padHeader = density ? { padding: dens(16, 24, 32), paddingBottom: dens(8, 12, 16) } : undefined;
|
|
1220
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;
|
|
1221
1433
|
const padFooter = density ? { padding: dens(16, 24, 32), paddingTop: dens(12, 16, 20) } : undefined;
|
|
1222
1434
|
const gapQBlock = density ? { marginBottom: dens(12, 20, 28) } : undefined;
|
|
1223
1435
|
// Theme palette (spec §24). Dark is the SDK default; light flips
|
|
@@ -1237,23 +1449,36 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1237
1449
|
// changes mid-session re-render via the useState/Appearance listener
|
|
1238
1450
|
// above (attributeContext is memoized on colorScheme too).
|
|
1239
1451
|
const authorTheme = survey.branding?.theme;
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
+
}
|
|
1252
1479
|
const resolvedTheme = activeOverrides?.theme === 'light' || activeOverrides?.theme === 'dark'
|
|
1253
1480
|
? activeOverrides.theme
|
|
1254
|
-
:
|
|
1255
|
-
? authorTheme
|
|
1256
|
-
: (_userTheme || _initTheme || (colorScheme === 'light' ? 'light' : colorScheme === 'dark' ? 'dark' : 'dark'));
|
|
1481
|
+
: _themeDecision.theme;
|
|
1257
1482
|
const isLight = resolvedTheme === 'light';
|
|
1258
1483
|
// Layer palette overrides: SDK-init (host app) > survey.branding.palette
|
|
1259
1484
|
// > built-in defaults. themePalette applies precedence per-key so a
|
|
@@ -1287,7 +1512,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1287
1512
|
// returns a fresh object each call, so an unmemoized value would give
|
|
1288
1513
|
// `styles` a new identity every render and call StyleSheet.create() every
|
|
1289
1514
|
// time — which leaks entries in RN's style registry over a long session.
|
|
1290
|
-
const contract = (0,
|
|
1515
|
+
const contract = (0, react_2.useMemo)(() => (0, styleContract_1.resolveStyleContract)({
|
|
1291
1516
|
theme: resolvedTheme,
|
|
1292
1517
|
primaryColor,
|
|
1293
1518
|
// survey.branding.palette is written by SurveyPaletteEditor in the
|
|
@@ -1314,7 +1539,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1314
1539
|
[resolvedTheme, primaryColor,
|
|
1315
1540
|
JSON.stringify(surveyPaletteOverride), JSON.stringify(initPaletteOverride),
|
|
1316
1541
|
JSON.stringify(survey.branding?.style)]);
|
|
1317
|
-
const styles = (0,
|
|
1542
|
+
const styles = (0, react_2.useMemo)(() => makeStyles(contract), [contract]);
|
|
1318
1543
|
const themeCard = isLight ? { backgroundColor: palette.card, borderColor: palette.cardBorder } : undefined;
|
|
1319
1544
|
const themeTextPrimary = isLight ? { color: palette.textPrimary } : undefined;
|
|
1320
1545
|
const themeTextSecondary = isLight ? { color: palette.textSecondary } : undefined;
|
|
@@ -1341,7 +1566,14 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1341
1566
|
// and (since the parallel fix) the web SDK, and died here. Precedence matches
|
|
1342
1567
|
// the other two exactly: survey ▶ application ▶ built-in, field by field, with
|
|
1343
1568
|
// '' meaning inherit rather than override-with-nothing.
|
|
1344
|
-
|
|
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)(() => {
|
|
1345
1577
|
const a = _asdMobile;
|
|
1346
1578
|
// Both layers may store display per platform — web and mobile positions are
|
|
1347
1579
|
// different decisions, and a survey that could only store one made editing
|
|
@@ -1376,6 +1608,26 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1376
1608
|
const animationType = display?.animation === 'fade' ? 'fade' : display?.animation === 'none' ? 'none' : 'slide';
|
|
1377
1609
|
const isFullscreen = display?.mode === 'fullscreen';
|
|
1378
1610
|
const isDrawer = display?.mode === 'modal';
|
|
1611
|
+
/**
|
|
1612
|
+
* A popup does NOT dim the app, and does not take its touches.
|
|
1613
|
+
*
|
|
1614
|
+
* Web has said so for a while — the overlay is `transparent` with
|
|
1615
|
+
* `pointer-events:none` for a popup, and only a modal gets rgba(0,0,0,0.45).
|
|
1616
|
+
* Mobile painted `c.ground` (rgba(0,0,0,0.6) dark, 0.4 light) over the whole
|
|
1617
|
+
* screen for EVERY mode, so a push-style banner dimmed the app behind it and
|
|
1618
|
+
* blocked every tap that was not on the card.
|
|
1619
|
+
*
|
|
1620
|
+
* Visible in the recordings: the same login screen captured 47 seconds apart
|
|
1621
|
+
* is bright at `start` and washed out once a card is up — the lime button
|
|
1622
|
+
* renders dull olive. That darkened frame is what the replay shows, so the
|
|
1623
|
+
* "grey composite" in the timeline and this are one bug.
|
|
1624
|
+
*
|
|
1625
|
+
* box-none rather than none: the container stops capturing touches, the card
|
|
1626
|
+
* inside still receives them. `none` here would make the survey itself
|
|
1627
|
+
* untouchable, which is how web pairs `pointer-events:none` on the overlay
|
|
1628
|
+
* with `auto` on the card.
|
|
1629
|
+
*/
|
|
1630
|
+
const isPopupOverlay = !isFullscreen && !isDrawer;
|
|
1379
1631
|
// Position — previously unread here, which is why a phone could never show a
|
|
1380
1632
|
// bottom sheet, the one presentation users actually expect on mobile.
|
|
1381
1633
|
//
|
|
@@ -1398,10 +1650,355 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1398
1650
|
const allQuestions = (survey.rawQuestions
|
|
1399
1651
|
|| survey.questions
|
|
1400
1652
|
|| []);
|
|
1401
|
-
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
|
|
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);
|
|
1405
2002
|
// A NOTIFICATION'S TYPE SITS ONE SIZE DOWN from a survey's — web says so
|
|
1406
2003
|
// explicitly (notifyFsHeading/notifyFsDesc, defaulting to 15/13), and mobile
|
|
1407
2004
|
// had no equivalent: it used the survey title and a hardcoded 14 description,
|
|
@@ -1409,10 +2006,43 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1409
2006
|
// type there. An authored size still wins, on both.
|
|
1410
2007
|
const _authoredStyle = stylePlatformSlice(survey.branding?.style);
|
|
1411
2008
|
const _num = (v) => (typeof v === 'number' && v > 0 ? v : undefined);
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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;
|
|
2012
|
+
// SHARED VALUE, PROPORTIONATE RENDERING.
|
|
2013
|
+
//
|
|
2014
|
+
// `fontSizeHeading` is stored once for every surface, so a size chosen while
|
|
2015
|
+
// looking at the desktop frame is the size the phone gets too. 24 sits
|
|
2016
|
+
// comfortably in a 485px desktop card and overflows a ~354pt phone one, which
|
|
2017
|
+
// is why the headline wrapped here and not in the preview.
|
|
2018
|
+
//
|
|
2019
|
+
// The number stays shared — one value, as authored — and each surface renders
|
|
2020
|
+
// it in proportion to the card it actually has. Web is unchanged (its card is
|
|
2021
|
+
// at or above the reference), and only a card narrower than the reference
|
|
2022
|
+
// scales down. An unstyled notification keeps web's own banner default of 15
|
|
2023
|
+
// rather than being scaled, because 15 is already a banner size.
|
|
2024
|
+
const REFERENCE_CARD_PT = 480;
|
|
2025
|
+
const notifyCardW = Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, typeof maxWidth === 'number' ? maxWidth : SCREEN_WIDTH);
|
|
2026
|
+
const authoredHeadingPt = _num(_authoredStyle.fontSizeHeading);
|
|
2027
|
+
const notifyTitleFS = isNotifyBanner
|
|
2028
|
+
? {
|
|
2029
|
+
fontSize: authoredHeadingPt != null
|
|
2030
|
+
// Floored so a very narrow card cannot shrink the headline below the
|
|
2031
|
+
// body text and invert the hierarchy.
|
|
2032
|
+
? Math.max(14, Math.round(authoredHeadingPt * Math.min(1, notifyCardW / REFERENCE_CARD_PT)))
|
|
2033
|
+
: 15,
|
|
2034
|
+
}
|
|
2035
|
+
: null;
|
|
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;
|
|
1416
2046
|
const rawPos = display?.position;
|
|
1417
2047
|
const placement = rawPos === 'top' ? 'top'
|
|
1418
2048
|
: rawPos === 'bottom' ? 'bottom'
|
|
@@ -1443,7 +2073,7 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1443
2073
|
const hasPopupY = !isNaN(popupY);
|
|
1444
2074
|
const effPlacement = hasPopupY ? 'top' : placement;
|
|
1445
2075
|
// Measured card height; the authored height is the first-frame stand-in.
|
|
1446
|
-
const [measuredCardH, setMeasuredCardH] = (0,
|
|
2076
|
+
const [measuredCardH, setMeasuredCardH] = (0, react_2.useState)(0);
|
|
1447
2077
|
const authoredH = Number.parseFloat(String(display?.height ?? ''));
|
|
1448
2078
|
const estCardH = measuredCardH || (Number.isFinite(authoredH) && authoredH > 0 ? authoredH : 220);
|
|
1449
2079
|
const overlayPlacement = {
|
|
@@ -1468,10 +2098,31 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1468
2098
|
// is what the card actually uses.
|
|
1469
2099
|
const needsTopSlide = animationType === 'slide'
|
|
1470
2100
|
&& ((isSheet && placement === 'top') || (!isDrawer && !isFullscreen && effPlacement === 'top'));
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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)
|
|
1475
2126
|
return;
|
|
1476
2127
|
if (!visible) {
|
|
1477
2128
|
enterAnim.setValue(0);
|
|
@@ -1480,13 +2131,13 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1480
2131
|
enterAnim.setValue(0);
|
|
1481
2132
|
react_native_1.Animated.timing(enterAnim, {
|
|
1482
2133
|
toValue: 1,
|
|
1483
|
-
duration:
|
|
2134
|
+
duration: animMs,
|
|
1484
2135
|
easing: react_native_1.Easing.out(react_native_1.Easing.cubic),
|
|
1485
2136
|
// Transform and opacity only, so this runs off the JS thread and does not
|
|
1486
2137
|
// stutter while the survey's first page is still laying out.
|
|
1487
2138
|
useNativeDriver: true,
|
|
1488
2139
|
}).start();
|
|
1489
|
-
}, [visible,
|
|
2140
|
+
}, [visible, customSlide, animMs, enterAnim]);
|
|
1490
2141
|
// ── Swipe to dismiss ────────────────────────────────────────────────
|
|
1491
2142
|
//
|
|
1492
2143
|
// A push notification goes away when you flick it. The mobile display slice
|
|
@@ -1496,55 +2147,124 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1496
2147
|
// canSwipeToDismiss requires a popup and vetoes `mustShow`, which is what
|
|
1497
2148
|
// keeps a compliance takeover — showClose:false for the OPPOSITE reason —
|
|
1498
2149
|
// from ever becoming flickable.
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
const
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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]);
|
|
1538
2231
|
}
|
|
1539
|
-
|
|
1540
|
-
}
|
|
1541
|
-
|
|
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
|
+
};
|
|
1542
2262
|
// Auto-dismiss. Same rule as web (autoDismissMs vetoes fullscreen and
|
|
1543
2263
|
// mustShow, clamps to 2s). Cancelled for good once the respondent touches
|
|
1544
2264
|
// anything — a timer that fires after someone has started answering would
|
|
1545
2265
|
// pull the survey out from under them.
|
|
1546
2266
|
// Which ending this interaction gets — see the render block below.
|
|
1547
|
-
const endingKind = (0,
|
|
2267
|
+
const endingKind = (0, react_2.useMemo)(() => {
|
|
1548
2268
|
const sv = survey;
|
|
1549
2269
|
const qs = (survey.rawQuestions ?? []);
|
|
1550
2270
|
const acts = (0, survey_spec_1.impliedStepActions)(qs) || [];
|
|
@@ -1560,14 +2280,16 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1560
2280
|
});
|
|
1561
2281
|
}, [survey, responses]);
|
|
1562
2282
|
const dismissMs = (0, survey_spec_1.autoDismissMs)(display, survey.delivery);
|
|
1563
|
-
const engagedRef = (0,
|
|
1564
|
-
(0,
|
|
2283
|
+
const engagedRef = (0, react_2.useRef)(false);
|
|
2284
|
+
(0, react_2.useEffect)(() => {
|
|
1565
2285
|
if (!visible || !dismissMs)
|
|
1566
2286
|
return;
|
|
1567
2287
|
engagedRef.current = false;
|
|
1568
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.
|
|
1569
2291
|
if (!engagedRef.current)
|
|
1570
|
-
handleDismissRef.current();
|
|
2292
|
+
handleDismissRef.current('auto');
|
|
1571
2293
|
}, dismissMs);
|
|
1572
2294
|
return () => clearTimeout(t);
|
|
1573
2295
|
}, [visible, dismissMs]);
|
|
@@ -1576,22 +2298,30 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1576
2298
|
// REPLACES an earlier one rather than merging. That was harmless while only
|
|
1577
2299
|
// sheets animated themselves, but a notification is both top-placed and
|
|
1578
2300
|
// swipeable, so the entrance translate would have been silently dropped.
|
|
1579
|
-
const enterStyle =
|
|
1580
|
-
? {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
+
})()
|
|
1595
2325
|
: null;
|
|
1596
2326
|
const sheetRadius = cornerRadius !== undefined ? cornerRadius : 16;
|
|
1597
2327
|
const sheetStyle = isSheet ? {
|
|
@@ -1762,9 +2492,9 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1762
2492
|
* At zero it stops rendering rather than sitting at 0:00 beside a button that
|
|
1763
2493
|
* no longer works.
|
|
1764
2494
|
*/
|
|
1765
|
-
const [nowTs, setNowTs] = (0,
|
|
2495
|
+
const [nowTs, setNowTs] = (0, react_2.useState)(() => Date.now());
|
|
1766
2496
|
const countdown = (0, survey_spec_1.deadlineCountdown)(survey, display, nowTs);
|
|
1767
|
-
(0,
|
|
2497
|
+
(0, react_2.useEffect)(() => {
|
|
1768
2498
|
if (!countdown)
|
|
1769
2499
|
return undefined;
|
|
1770
2500
|
// Ticking every second only while the TEXT CONTAINS SECONDS, which
|
|
@@ -1785,8 +2515,22 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1785
2515
|
styles.progressBarFill,
|
|
1786
2516
|
{ width: `${((currentPage + 1) / totalPages) * 100}%`, backgroundColor: primaryColor },
|
|
1787
2517
|
] }) })] })) : null;
|
|
1788
|
-
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: [
|
|
1789
2531
|
styles.overlay,
|
|
2532
|
+
// Transparent for a popup — the app behind it is not being taken over.
|
|
2533
|
+
isPopupOverlay && { backgroundColor: 'transparent' },
|
|
1790
2534
|
overlayPlacement,
|
|
1791
2535
|
// The authored coordinate, resolved to points. Before the sheet and
|
|
1792
2536
|
// fullscreen overrides so those keep winning.
|
|
@@ -1794,36 +2538,49 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1794
2538
|
// A sheet sits flush against its edge, so the overlay loses its gutter.
|
|
1795
2539
|
isSheet && { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 },
|
|
1796
2540
|
isFullscreen && styles.overlayFullscreen,
|
|
1797
|
-
], 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,
|
|
1798
2552
|
// Feeds the authored-coordinate maths above. Only tracked when a
|
|
1799
2553
|
// coordinate is actually in play, and only on a real change, so the
|
|
1800
2554
|
// common case costs nothing and this cannot loop.
|
|
1801
2555
|
onLayout: hasPopupY ? (e) => {
|
|
1802
2556
|
const h = Math.round(e.nativeEvent.layout.height);
|
|
1803
2557
|
setMeasuredCardH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
|
|
1804
|
-
} : undefined,
|
|
2558
|
+
} : undefined, style: [
|
|
1805
2559
|
styles.card,
|
|
1806
2560
|
themeCard,
|
|
1807
2561
|
enterStyle,
|
|
1808
|
-
swipeable && !
|
|
2562
|
+
swipeable && !customSlide && { transform: swipeXY.getTranslateTransform() },
|
|
1809
2563
|
maxWidth !== undefined && !isFullscreen && { width: Math.min(SCREEN_WIDTH * CARD_VIEWPORT_FRACTION, maxWidth), maxWidth },
|
|
1810
2564
|
cornerRadius !== undefined && !isFullscreen && { borderRadius: cornerRadius },
|
|
1811
2565
|
// cardFullscreen fills; an explicit content height/width below OVERRIDES
|
|
1812
2566
|
// the fill so a fullscreen survey can be contained to that size.
|
|
1813
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' },
|
|
1814
2571
|
sheetStyle,
|
|
1815
2572
|
heightStyle,
|
|
1816
2573
|
widthStyle,
|
|
1817
2574
|
safePad,
|
|
1818
|
-
], children: [
|
|
2575
|
+
], children: [cardImageBack, closeShown && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { ...swipeExempt, style: [
|
|
1819
2576
|
styles.closeButton,
|
|
1820
|
-
isRtl ? { left:
|
|
1821
|
-
], 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) ? (() => {
|
|
1822
2579
|
const rw = (0, survey_spec_1.rewardToShow)(survey);
|
|
1823
2580
|
const comp = survey.compliance;
|
|
1824
2581
|
if (endingKind === 'fulfil' && rw) {
|
|
1825
2582
|
const exp = rw.expiresAt ? new Date(rw.expiresAt) : null;
|
|
1826
|
-
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()] }))] }));
|
|
1827
2584
|
}
|
|
1828
2585
|
if (endingKind === 'hold') {
|
|
1829
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
|
|
@@ -1846,145 +2603,189 @@ function SurveyModal({ survey, visible, onSubmit, onDismiss }) {
|
|
|
1846
2603
|
const imgSrc = firstImageSrc(rawThankYou);
|
|
1847
2604
|
const thankYouText = stripHtml(rawThankYou);
|
|
1848
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] }));
|
|
1849
|
-
})() : ((0, jsx_runtime_1.jsxs)(
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
//
|
|
1875
|
-
(
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
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)() })] }) }));
|
|
1988
2789
|
}
|
|
1989
2790
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = react_native_1.Dimensions.get('window');
|
|
1990
2791
|
/** Web draws a popup at `width:90vw`; the card matches so both read the same. */
|
|
@@ -2002,7 +2803,9 @@ const CARD_VIEWPORT_FRACTION = 0.9;
|
|
|
2002
2803
|
const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
2003
2804
|
overlay: {
|
|
2004
2805
|
flex: 1,
|
|
2005
|
-
|
|
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,
|
|
2006
2809
|
justifyContent: 'center',
|
|
2007
2810
|
alignItems: 'center',
|
|
2008
2811
|
padding: 20,
|
|
@@ -2011,7 +2814,12 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2011
2814
|
width: Math.min(SCREEN_WIDTH - 40, 420),
|
|
2012
2815
|
maxHeight: '85%',
|
|
2013
2816
|
backgroundColor: c.card,
|
|
2014
|
-
|
|
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,
|
|
2015
2823
|
borderWidth: 1,
|
|
2016
2824
|
borderColor: c.cardBorder,
|
|
2017
2825
|
overflow: 'hidden',
|
|
@@ -2031,20 +2839,35 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2031
2839
|
},
|
|
2032
2840
|
closeButton: {
|
|
2033
2841
|
position: 'absolute',
|
|
2034
|
-
|
|
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,
|
|
2035
2847
|
// left / right applied inline based on survey.direction — see
|
|
2036
2848
|
// the closeButton render site above.
|
|
2037
2849
|
zIndex: 10,
|
|
2038
|
-
width:
|
|
2039
|
-
height:
|
|
2040
|
-
borderRadius:
|
|
2850
|
+
width: c.closeSize,
|
|
2851
|
+
height: c.closeSize,
|
|
2852
|
+
borderRadius: c.closeRadius,
|
|
2041
2853
|
backgroundColor: c.closeBg,
|
|
2042
2854
|
alignItems: 'center',
|
|
2043
2855
|
justifyContent: 'center',
|
|
2044
2856
|
},
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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,
|
|
2048
2871
|
},
|
|
2049
2872
|
header: {
|
|
2050
2873
|
// Per-side padding maps onto the three stacked regions this modal already
|
|
@@ -2054,9 +2877,15 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2054
2877
|
paddingTop: c.cardPaddingTop ?? c.cardPadding,
|
|
2055
2878
|
paddingLeft: c.cardPaddingLeft ?? c.cardPadding,
|
|
2056
2879
|
paddingBottom: 12,
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
|
|
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,
|
|
2060
2889
|
},
|
|
2061
2890
|
// Branding row above the title — logo image + optional brand name
|
|
2062
2891
|
// caption. Center-aligned; the logo is capped small enough that
|
|
@@ -2079,6 +2908,14 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2079
2908
|
marginTop: 6,
|
|
2080
2909
|
},
|
|
2081
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),
|
|
2082
2919
|
color: c.headingColor,
|
|
2083
2920
|
fontSize: c.fontSizeHeading,
|
|
2084
2921
|
// 600, matching web's `.cxp-widget-title`. At 700 the same string is about
|
|
@@ -2089,10 +2926,26 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2089
2926
|
fontWeight: '600',
|
|
2090
2927
|
},
|
|
2091
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),
|
|
2092
2940
|
color: c.descriptionColor,
|
|
2093
|
-
|
|
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,
|
|
2094
2948
|
marginTop: 6,
|
|
2095
|
-
lineHeight: 20,
|
|
2096
2949
|
},
|
|
2097
2950
|
progress: {
|
|
2098
2951
|
color: c.progressText,
|
|
@@ -2185,7 +3038,14 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2185
3038
|
// weight rather than looking like an option list.
|
|
2186
3039
|
impliedActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 4 },
|
|
2187
3040
|
impliedBtn: {
|
|
2188
|
-
|
|
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,
|
|
2189
3049
|
// From the contract, like every other button. A literal 8 here meant an
|
|
2190
3050
|
// authored button radius applied to Next and Back but not to the action a
|
|
2191
3051
|
// notification actually offers.
|
|
@@ -2194,11 +3054,27 @@ const makeStyles = (c) => react_native_1.StyleSheet.create({
|
|
|
2194
3054
|
// The brand accent, not a hardcoded blue. `#2b6cb0` ignored primaryColor
|
|
2195
3055
|
// entirely, so a notification's only action was off-brand on every survey —
|
|
2196
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.
|
|
2197
3065
|
impliedPrimary: { backgroundColor: c.primaryBg },
|
|
2198
|
-
|
|
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 },
|
|
2199
3073
|
impliedChosen: { opacity: 0.75 },
|
|
2200
|
-
|
|
2201
|
-
|
|
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 },
|
|
2202
3078
|
desktopHint: {
|
|
2203
3079
|
color: c.hintText,
|
|
2204
3080
|
fontSize: 13,
|