@wireai/activation 0.7.0 → 0.9.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/AGENTS.md +29 -5
- package/CHANGELOG.md +198 -6
- package/README.md +105 -10
- package/dist/analytics/index.d.mts +12 -8
- package/dist/analytics/index.d.ts +12 -8
- package/dist/analytics/index.js +525 -55
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +525 -55
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +2 -2
- package/dist/coachmarks/index.d.ts +2 -2
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
- package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
- package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
- package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
- package/dist/index.d.mts +620 -13
- package/dist/index.d.ts +620 -13
- package/dist/index.js +1194 -369
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1015 -201
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +69 -23
- package/dist/questionnaire/index.d.ts +69 -23
- package/dist/questionnaire/index.js +643 -172
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +608 -137
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +24 -12
- package/dist/reviews/index.d.ts +24 -12
- package/dist/reviews/index.js +507 -173
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +462 -129
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +2 -2
- package/dist/showcase/index.d.ts +2 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/transport-DzU-TqZz.d.ts +86 -0
- package/dist/transport-f5VVB5hH.d.mts +86 -0
- package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
- package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
- package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
- package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
- package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
- package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
- package/metro/index.d.ts +18 -1
- package/metro/index.js +80 -0
- package/package.json +9 -1
- package/src/WireOnboarding.tsx +4 -0
- package/src/analytics/analyticsFacade.ts +55 -10
- package/src/analytics/currentSession.ts +1 -1
- package/src/cards/CardGridSelectCard.tsx +253 -0
- package/src/cards/ChipSelectCard.tsx +1 -1
- package/src/cards/SelectionCard.tsx +15 -19
- package/src/cards/index.ts +13 -1
- package/src/cards/optionSchema.ts +65 -0
- package/src/coachmarks/types.ts +1 -1
- package/src/components/CardHandoff.tsx +39 -7
- package/src/components/DoneCheck.tsx +94 -0
- package/src/components/ModalPaneHost.tsx +141 -0
- package/src/components/StepDots.tsx +112 -0
- package/src/context/deviceId.ts +43 -0
- package/src/device/appVersion.ts +29 -8
- package/src/device/deviceContext.ts +14 -1
- package/src/device/deviceModel.ts +106 -0
- package/src/features/cache.ts +1 -1
- package/src/haptics/expo-haptics.d.ts +31 -0
- package/src/haptics/haptics.ts +58 -0
- package/src/icons/IconRegistry.tsx +53 -0
- package/src/icons/WireIcon.tsx +85 -0
- package/src/icons/expoIcons.ts +165 -0
- package/src/icons/index.ts +18 -0
- package/src/icons/vocabulary.ts +154 -0
- package/src/index.ts +22 -0
- package/src/questionnaire/QuestionnaireGate.tsx +315 -164
- package/src/questionnaire/decision.ts +4 -2
- package/src/questionnaire/index.ts +3 -2
- package/src/questionnaire/steps.ts +261 -0
- package/src/questionnaire/types.ts +33 -12
- package/src/reviews/ReviewGate.tsx +250 -152
- package/src/reviews/index.ts +7 -3
- package/src/reviews/transport.ts +77 -3
- package/src/reviews/types.ts +13 -0
- package/src/session/persistedSession.ts +1 -1
- package/src/session-analytics/lifecycle.ts +1 -1
- package/src/session-analytics/useSessionStart.ts +2 -2
- package/src/showcase/FeatureShowcase.tsx +1 -1
- package/src/showcase/showcaseColors.ts +1 -1
- package/src/theme/mergeThemeOver.ts +27 -0
- package/src/types.ts +8 -0
- package/dist/transport-BeO_Brcu.d.mts +0 -40
- package/dist/transport-DLpd1v5_.d.ts +0 -40
|
@@ -1,46 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* QuestionnaireGate - the
|
|
2
|
+
* QuestionnaireGate - the multi-step questionnaire popup.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* ONE question per step. Steps 1-2 are skippable; the LAST step is REQUIRED (Malik's
|
|
5
|
+
* device test 2026-07-16: "i want at least something forced"). The user chooses which
|
|
6
|
+
* thing they tell us, not whether they tell us anything. `steps.ts` owns that rule and
|
|
7
|
+
* every transition as pure functions, unit-tested under node:test without mounting React;
|
|
8
|
+
* this component is a thin renderer over that machine.
|
|
9
|
+
*
|
|
10
|
+
* The CHROME (back icon + step dots) is a static header, deliberately NOT part of the
|
|
11
|
+
* sliding pane - see the four notes below. Only the question, its box and its controls move.
|
|
12
|
+
*
|
|
13
|
+
* - No title. The gate used to render `questionnaire.title` as a heading ON EVERY STEP,
|
|
14
|
+
* directly above the step's own question. Two asking-texts stacked, one of them the same
|
|
15
|
+
* string three times, and mid-slide the outgoing and incoming panes each drew their own
|
|
16
|
+
* copy so it was briefly on screen TWICE. Malik saw it on the last step, where the
|
|
17
|
+
* forced question most needs the focus. The question IS the heading now; it renders once.
|
|
18
|
+
* - No "Question 2 of 3" counter. `StepDots` says it without spending a line next to the
|
|
19
|
+
* question, which is what the user is supposed to be reading.
|
|
20
|
+
* - Back is a top-left icon, not a link in the button stack, and it is simply absent on
|
|
21
|
+
* step 1 rather than present-but-dead.
|
|
22
|
+
* - No "Not now". Per-step Skip is the only in-card opt-out. `questionnaire_dismissed`
|
|
23
|
+
* moved to the modal's dismiss path (backdrop tap / Android back) - see `handleDismissed`.
|
|
24
|
+
*
|
|
25
|
+
* The wire shape is UNCHANGED. `buildQuestionnaireSubmission` still builds the exact same
|
|
26
|
+
* `POST /v1/questionnaires/{id}/responses` body from the same three answers; a skipped
|
|
27
|
+
* step is simply an empty answer, which is what the builder already handled.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ This gate now REQUIRES the server to accept an empty `answers.opinion` - step 1 is
|
|
30
|
+
* skippable and the live endpoint still 422s a blank opinion, silently (the transport is
|
|
31
|
+
* fire-and-forget). See the REQUIRED_STEPS note in steps.ts. Server fix, not a client one.
|
|
32
|
+
*
|
|
33
|
+
* Motion: `ModalPaneHost` slides step→step (and mirrors the slide on Back) and resizes the
|
|
34
|
+
* card; `StepDots` fills; `DoneCheck` plays the submit payoff. All shared, all reduce-motion
|
|
35
|
+
* aware. Haptics are lazy + optional (`haptics/haptics.ts`) and silently absent otherwise.
|
|
11
36
|
*
|
|
12
37
|
* The form NEVER asks for identity (no name / email / contact field): the host already
|
|
13
38
|
* identifies the user via `sessionId` + `meta` (user_id, device_key, session_count), which is
|
|
14
39
|
* all the POST body carries alongside the answers. Every visual value comes from the
|
|
15
40
|
* OnboardingTheme; analytics are callback-based (no PII - answers go ONLY in the POST body).
|
|
16
41
|
*/
|
|
17
|
-
import React, { useCallback, useMemo, useState } from "react";
|
|
42
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
18
43
|
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
|
19
44
|
|
|
20
45
|
import { Button } from "../components/Button";
|
|
21
46
|
import { CenteredModal, type CenteredModalHandle } from "../components/CenteredModal";
|
|
47
|
+
import { DoneCheck } from "../components/DoneCheck";
|
|
48
|
+
import { ModalPaneHost } from "../components/ModalPaneHost";
|
|
49
|
+
import { StepDots } from "../components/StepDots";
|
|
50
|
+
import { playHaptic } from "../haptics/haptics";
|
|
22
51
|
import { feedbackInputHeights } from "../reviews/feedbackForm";
|
|
52
|
+
import { mergeThemeOver } from "../theme/mergeThemeOver";
|
|
23
53
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
24
|
-
import type { OnboardingTheme } from "../theme/types";
|
|
25
54
|
import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
|
|
26
55
|
import { buildQuestionnaireSubmission } from "./decision";
|
|
56
|
+
import {
|
|
57
|
+
buildQuestionnaireSteps,
|
|
58
|
+
canGoBack,
|
|
59
|
+
canSkipStep,
|
|
60
|
+
commitStep,
|
|
61
|
+
currentStep,
|
|
62
|
+
goBack,
|
|
63
|
+
initialStepState,
|
|
64
|
+
isLastStep,
|
|
65
|
+
skipStep,
|
|
66
|
+
stashDraft,
|
|
67
|
+
toAnswers,
|
|
68
|
+
type QuestionnaireStepState,
|
|
69
|
+
type StepDrafts,
|
|
70
|
+
} from "./steps";
|
|
27
71
|
import { submitQuestionnaireResponse } from "./transport";
|
|
28
72
|
import type { QuestionnaireGateProps } from "./types";
|
|
29
73
|
|
|
30
|
-
const mergeThemeOver = (
|
|
31
|
-
base: OnboardingTheme,
|
|
32
|
-
override?: Partial<OnboardingTheme>,
|
|
33
|
-
): OnboardingTheme => {
|
|
34
|
-
if (!override) return base;
|
|
35
|
-
return {
|
|
36
|
-
colors: { ...base.colors, ...override.colors },
|
|
37
|
-
fonts: { ...base.fonts, ...override.fonts },
|
|
38
|
-
radius: { ...base.radius, ...override.radius },
|
|
39
|
-
spacing: { ...base.spacing, ...override.spacing },
|
|
40
|
-
button: { ...base.button, ...override.button },
|
|
41
|
-
};
|
|
42
|
-
};
|
|
43
|
-
|
|
44
74
|
type Phase = "form" | "thanks";
|
|
45
75
|
|
|
46
76
|
const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
@@ -56,21 +86,64 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
56
86
|
}) => {
|
|
57
87
|
const active = useOnboardingTheme();
|
|
58
88
|
const t = useMemo(() => mergeThemeOver(active, themeOverride), [active, themeOverride]);
|
|
59
|
-
const modalRef =
|
|
89
|
+
const modalRef = useRef<CenteredModalHandle>(null);
|
|
60
90
|
|
|
61
91
|
const id = questionnaire.id;
|
|
92
|
+
const steps = useMemo(
|
|
93
|
+
() =>
|
|
94
|
+
buildQuestionnaireSteps({
|
|
95
|
+
opinion_label: questionnaire.opinion_label,
|
|
96
|
+
improve_label: questionnaire.improve_label,
|
|
97
|
+
suggestions_label: questionnaire.suggestions_label,
|
|
98
|
+
}),
|
|
99
|
+
[questionnaire.opinion_label, questionnaire.improve_label, questionnaire.suggestions_label],
|
|
100
|
+
);
|
|
101
|
+
|
|
62
102
|
const [phase, setPhase] = useState<Phase>("form");
|
|
63
|
-
const [
|
|
64
|
-
|
|
65
|
-
|
|
103
|
+
const [state, setState] = useState<QuestionnaireStepState>(initialStepState);
|
|
104
|
+
// Mirrored so the press handlers read the live state without re-subscribing every render
|
|
105
|
+
// (the same ref-over-callback idiom the fire-once effect below uses).
|
|
106
|
+
const stateRef = useRef(state);
|
|
107
|
+
stateRef.current = state;
|
|
108
|
+
|
|
109
|
+
// The text for the step ON SCREEN. Deliberately a ref, not state: the inputs stay
|
|
110
|
+
// UNCONTROLLED (`defaultValue`), so a keystroke never round-trips through React and the
|
|
111
|
+
// box never lags or flickers on a low-end device. Each step's input is keyed, so it
|
|
112
|
+
// remounts per step and `defaultValue` restores the earlier answer when the user steps Back.
|
|
113
|
+
const draftRef = useRef("");
|
|
114
|
+
|
|
115
|
+
// ...but the REQUIRED last step does gate Send on the text, and a ref cannot re-render the
|
|
116
|
+
// button. So we mirror ONE bit - "is there non-whitespace text?" - into state, and set it
|
|
117
|
+
// only when that bit FLIPS. Typing a 200-character answer costs exactly one re-render (on
|
|
118
|
+
// the first character), not 200: the middle ground between an uncontrolled box and a
|
|
119
|
+
// control that has to know about its content.
|
|
120
|
+
const [answered, setAnswered] = useState(false);
|
|
121
|
+
const answeredRef = useRef(false);
|
|
122
|
+
|
|
123
|
+
/** Point the draft at `value` and resync the flag. Used on every step transition. */
|
|
124
|
+
const seedDraft = useCallback((value: string) => {
|
|
125
|
+
draftRef.current = value;
|
|
126
|
+
const has = value.trim().length > 0;
|
|
127
|
+
answeredRef.current = has;
|
|
128
|
+
setAnswered(has);
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
/** Typing: always record, re-render only on the empty↔filled flip. */
|
|
132
|
+
const handleChangeText = useCallback((text: string) => {
|
|
133
|
+
draftRef.current = text;
|
|
134
|
+
const has = text.trim().length > 0;
|
|
135
|
+
if (has === answeredRef.current) return;
|
|
136
|
+
answeredRef.current = has;
|
|
137
|
+
setAnswered(has);
|
|
138
|
+
}, []);
|
|
66
139
|
|
|
67
140
|
// Fire questionnaire_prompt_shown exactly ONCE per mount. The callbacks are read through refs
|
|
68
141
|
// (not effect deps) so an inline `onShown`/`onEvent` - recreated every render, as real hosts
|
|
69
142
|
// idiomatically pass - never re-runs the effect. Empty deps + the shownRef guard make this a
|
|
70
143
|
// true fire-once, immune to prop-identity churn.
|
|
71
|
-
const shownRef =
|
|
72
|
-
const onShownRef =
|
|
73
|
-
const onEventRef =
|
|
144
|
+
const shownRef = useRef(false);
|
|
145
|
+
const onShownRef = useRef(onShown);
|
|
146
|
+
const onEventRef = useRef(onEvent);
|
|
74
147
|
onShownRef.current = onShown;
|
|
75
148
|
onEventRef.current = onEvent;
|
|
76
149
|
React.useEffect(() => {
|
|
@@ -81,7 +154,7 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
81
154
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
155
|
}, []);
|
|
83
156
|
|
|
84
|
-
const resolveTimer =
|
|
157
|
+
const resolveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
85
158
|
|
|
86
159
|
React.useEffect(() => {
|
|
87
160
|
return () => {
|
|
@@ -89,149 +162,220 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
89
162
|
};
|
|
90
163
|
}, []);
|
|
91
164
|
|
|
92
|
-
|
|
165
|
+
// Set the instant we POST, so the dismiss path below can tell "the user walked away"
|
|
166
|
+
// from "the modal closed because the run finished".
|
|
167
|
+
const submittedRef = useRef(false);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The modal finished its exit animation. This is now the ONLY source of
|
|
171
|
+
* `questionnaire_dismissed`.
|
|
172
|
+
*
|
|
173
|
+
* WHY it moved: the event used to fire from the in-card "Not now" link, which Malik cut.
|
|
174
|
+
* Deleting the link without moving the event would have quietly zeroed a metric the
|
|
175
|
+
* server counts. Dismissal is still very much reachable - `CenteredModal` dismisses on a
|
|
176
|
+
* backdrop tap and on Android hardware back whenever `dismissible` - so the event belongs
|
|
177
|
+
* on that path, not on one button that happened to trigger it.
|
|
178
|
+
*
|
|
179
|
+
* It also FIXES a hole: backdrop-tap and hardware-back dismissals never fired the event
|
|
180
|
+
* before, so "dismissed" only ever counted the users who found the link. Every real
|
|
181
|
+
* dismissal counts now, which means the number will legitimately RISE - it is a fuller
|
|
182
|
+
* count, not a regression.
|
|
183
|
+
*
|
|
184
|
+
* The submit path closes the modal too (after the thank-you), hence the guard: finishing
|
|
185
|
+
* is not dismissing. Read through `onEventRef` for the same prop-identity reason as the
|
|
186
|
+
* fire-once effect above.
|
|
187
|
+
*/
|
|
188
|
+
const handleDismissed = useCallback(() => {
|
|
189
|
+
if (!submittedRef.current) {
|
|
190
|
+
onEventRef.current?.({ name: "questionnaire_dismissed", id });
|
|
191
|
+
}
|
|
93
192
|
onResolved?.();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
193
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
194
|
+
}, [id, onResolved]);
|
|
195
|
+
|
|
196
|
+
// The run is over: POST the answers and play the payoff. Identity is NEVER collected here -
|
|
197
|
+
// the host already sends user_id / device_key / session via `sessionId` + `meta`.
|
|
198
|
+
const finish = useCallback(
|
|
199
|
+
(drafts: StepDrafts) => {
|
|
200
|
+
const submission = buildQuestionnaireSubmission({
|
|
201
|
+
answers: toAnswers(drafts),
|
|
202
|
+
sessionId,
|
|
203
|
+
meta,
|
|
204
|
+
});
|
|
205
|
+
submitQuestionnaireResponse(target, id, submission);
|
|
206
|
+
submittedRef.current = true;
|
|
207
|
+
onEvent?.({ name: "questionnaire_submitted", id });
|
|
208
|
+
playHaptic("success");
|
|
209
|
+
setPhase("thanks");
|
|
210
|
+
if (resolveTimer.current) clearTimeout(resolveTimer.current);
|
|
211
|
+
resolveTimer.current = setTimeout(() => {
|
|
212
|
+
modalRef.current?.close();
|
|
213
|
+
}, 1500);
|
|
214
|
+
},
|
|
215
|
+
[sessionId, meta, target, id, onEvent],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Apply a machine transition: either submit (last step) or seed the next step's draft.
|
|
219
|
+
// A REFUSED transition (blank text on the required step) returns the same state object,
|
|
220
|
+
// so bail: no haptic, no re-render, nothing to tell the user that the disabled button
|
|
221
|
+
// already told them.
|
|
222
|
+
const applyTransition = useCallback(
|
|
223
|
+
(next: QuestionnaireStepState) => {
|
|
224
|
+
if (next === stateRef.current) return;
|
|
225
|
+
setState(next);
|
|
226
|
+
if (next.done) {
|
|
227
|
+
finish(next.drafts);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
seedDraft(next.drafts[steps[next.index]?.key ?? "opinion"] ?? "");
|
|
231
|
+
playHaptic("light");
|
|
232
|
+
},
|
|
233
|
+
[finish, seedDraft, steps],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const handleNext = useCallback(() => {
|
|
237
|
+
applyTransition(commitStep(steps, stateRef.current, draftRef.current));
|
|
238
|
+
}, [applyTransition, steps]);
|
|
239
|
+
|
|
240
|
+
const handleSkip = useCallback(() => {
|
|
241
|
+
applyTransition(skipStep(steps, stateRef.current));
|
|
242
|
+
}, [applyTransition, steps]);
|
|
243
|
+
|
|
244
|
+
const handleBack = useCallback(() => {
|
|
245
|
+
// Stash the CURRENT step's text before leaving it. The inputs are uncontrolled, so that
|
|
246
|
+
// text lives ONLY in draftRef until something folds it into the drafts; Back without this
|
|
247
|
+
// destroyed it, and the keyed input silently remounted blank when the user stepped
|
|
248
|
+
// forward again. Same primitive `commitStep` uses for Next — one way to record an answer.
|
|
249
|
+
const next = goBack(stashDraft(steps, stateRef.current, draftRef.current));
|
|
250
|
+
setState(next);
|
|
251
|
+
seedDraft(next.drafts[steps[next.index]?.key ?? "opinion"] ?? "");
|
|
252
|
+
playHaptic("light");
|
|
253
|
+
}, [seedDraft, steps]);
|
|
254
|
+
|
|
255
|
+
const step = currentStep(steps, state);
|
|
256
|
+
const last = isLastStep(steps, state.index);
|
|
257
|
+
// The mandatory last step: no Skip control at all, and Send stays disabled until there is
|
|
258
|
+
// text. `answered` tracks the live box; `canSkipStep` is the machine's own read of the
|
|
259
|
+
// same `required` flag it enforces in `commitStep`, so the two can never disagree.
|
|
260
|
+
const canSkip = canSkipStep(steps, state);
|
|
261
|
+
const sendDisabled = (step?.required ?? false) && !answered;
|
|
128
262
|
|
|
129
263
|
// A multiline box ~4 lines tall (capped at ~5), derived from the theme's body line-height so
|
|
130
|
-
// it scales with the app's font config; padding is folded in so the lines are visible.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
padding: t.spacing.md,
|
|
161
|
-
minHeight: opinionBox.minHeight,
|
|
162
|
-
maxHeight: opinionBox.maxHeight,
|
|
163
|
-
},
|
|
164
|
-
]}
|
|
165
|
-
/>
|
|
166
|
-
|
|
167
|
-
{/* OPTIONAL "help us improve" box - never gates Send. */}
|
|
168
|
-
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>{improveLabel}</Text>
|
|
169
|
-
<TextInput
|
|
170
|
-
value={improve}
|
|
171
|
-
onChangeText={setImprove}
|
|
172
|
-
placeholder="e.g. fewer steps to start"
|
|
173
|
-
placeholderTextColor={t.colors.textMuted}
|
|
174
|
-
style={[
|
|
175
|
-
bodyStyle(t.fonts),
|
|
176
|
-
styles.input,
|
|
177
|
-
{
|
|
178
|
-
color: t.colors.text,
|
|
179
|
-
backgroundColor: t.colors.background,
|
|
180
|
-
borderRadius: t.radius.md,
|
|
181
|
-
borderColor: t.colors.border,
|
|
182
|
-
paddingHorizontal: t.spacing.md,
|
|
183
|
-
paddingVertical: t.spacing.sm,
|
|
184
|
-
minHeight: t.button.height,
|
|
185
|
-
},
|
|
186
|
-
]}
|
|
187
|
-
/>
|
|
188
|
-
|
|
189
|
-
{/* OPTIONAL "suggestions" box - never gates Send. */}
|
|
190
|
-
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
191
|
-
{suggestionsLabel}
|
|
192
|
-
</Text>
|
|
193
|
-
<TextInput
|
|
194
|
-
value={suggestions}
|
|
195
|
-
onChangeText={setSuggestions}
|
|
196
|
-
placeholder="e.g. a dark theme"
|
|
197
|
-
placeholderTextColor={t.colors.textMuted}
|
|
198
|
-
style={[
|
|
199
|
-
bodyStyle(t.fonts),
|
|
200
|
-
styles.input,
|
|
201
|
-
{
|
|
202
|
-
color: t.colors.text,
|
|
203
|
-
backgroundColor: t.colors.background,
|
|
204
|
-
borderRadius: t.radius.md,
|
|
205
|
-
borderColor: t.colors.border,
|
|
206
|
-
paddingHorizontal: t.spacing.md,
|
|
207
|
-
paddingVertical: t.spacing.sm,
|
|
208
|
-
minHeight: t.button.height,
|
|
209
|
-
},
|
|
210
|
-
]}
|
|
211
|
-
/>
|
|
212
|
-
|
|
213
|
-
<Button title="Send" onPress={send} variant="primary" fullWidth disabled={!canSend} />
|
|
214
|
-
{dismissible && (
|
|
215
|
-
<Pressable onPress={handleDismissTap} hitSlop={8}>
|
|
216
|
-
<Text style={[captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }]}>
|
|
217
|
-
Not now
|
|
264
|
+
// it scales with the app's font config; padding is folded in so the lines are visible. Every
|
|
265
|
+
// step uses the SAME box, which also keeps the card's height near-steady between steps.
|
|
266
|
+
const answerBox = feedbackInputHeights(t.fonts, t.spacing.md);
|
|
267
|
+
|
|
268
|
+
const linkStyle = [captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }];
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The card's chrome: back icon hard left, dots centred. Handed to `ModalPaneHost`'s
|
|
272
|
+
* static slot, so it is measured with the pane but never slides and never doubles
|
|
273
|
+
* mid-handoff. The two side spacers are equal-flex so the dots sit at the true centre
|
|
274
|
+
* whether or not Back is there — the row must not shift between step 1 and step 2.
|
|
275
|
+
*/
|
|
276
|
+
const header =
|
|
277
|
+
phase === "form" ? (
|
|
278
|
+
<View style={[styles.header, { marginBottom: t.spacing.md }]}>
|
|
279
|
+
<View style={styles.headerSide}>
|
|
280
|
+
{/* Absent on step 1 rather than disabled: there is nothing to go back to, and a
|
|
281
|
+
greyed-out control is just a question the user has to answer. */}
|
|
282
|
+
{canGoBack(state) && (
|
|
283
|
+
<Pressable
|
|
284
|
+
onPress={handleBack}
|
|
285
|
+
hitSlop={12}
|
|
286
|
+
accessibilityRole="button"
|
|
287
|
+
accessibilityLabel="Back to the previous question"
|
|
288
|
+
>
|
|
289
|
+
{/* A Text glyph, not an icon component: `react-native-svg` is not a peer of
|
|
290
|
+
this kit and no vector library is worth forcing every host into a native
|
|
291
|
+
rebuild for one chevron. Same call DoneCheck's ✓ and the review stars made. */}
|
|
292
|
+
<Text style={[headingStyle(t.fonts), styles.backGlyph, { color: t.colors.textMuted }]}>
|
|
293
|
+
‹
|
|
218
294
|
</Text>
|
|
219
295
|
</Pressable>
|
|
220
296
|
)}
|
|
221
|
-
|
|
222
|
-
|
|
297
|
+
</View>
|
|
298
|
+
<StepDots theme={t} count={steps.length} index={state.index} />
|
|
299
|
+
<View style={styles.headerSide} />
|
|
300
|
+
</View>
|
|
301
|
+
) : null;
|
|
302
|
+
|
|
303
|
+
const formPane = step ? (
|
|
304
|
+
<View style={{ gap: t.spacing.md }}>
|
|
305
|
+
{/* The question is the heading. There is no separate title above it — see the file
|
|
306
|
+
header: that was the text Malik saw repeated. */}
|
|
307
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>{step.label}</Text>
|
|
308
|
+
<TextInput
|
|
309
|
+
// Keyed per step so each question gets a fresh input seeded with its own answer.
|
|
310
|
+
key={step.key}
|
|
311
|
+
defaultValue={state.drafts[step.key] ?? ""}
|
|
312
|
+
onChangeText={handleChangeText}
|
|
313
|
+
placeholder={step.placeholder}
|
|
314
|
+
placeholderTextColor={t.colors.textMuted}
|
|
315
|
+
accessibilityLabel={step.label}
|
|
316
|
+
multiline
|
|
317
|
+
textAlignVertical="top"
|
|
318
|
+
style={[
|
|
319
|
+
bodyStyle(t.fonts),
|
|
320
|
+
styles.input,
|
|
321
|
+
{
|
|
322
|
+
color: t.colors.text,
|
|
323
|
+
backgroundColor: t.colors.background,
|
|
324
|
+
borderRadius: t.radius.md,
|
|
325
|
+
borderColor: t.colors.border,
|
|
326
|
+
padding: t.spacing.md,
|
|
327
|
+
minHeight: answerBox.minHeight,
|
|
328
|
+
maxHeight: answerBox.maxHeight,
|
|
329
|
+
},
|
|
330
|
+
]}
|
|
331
|
+
/>
|
|
223
332
|
|
|
224
|
-
{
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
333
|
+
{/* Gated ONLY on the required step. Steps 1-2 send/advance whatever the box holds,
|
|
334
|
+
because skipping is a first-class answer there and there is nothing to gate on. */}
|
|
335
|
+
<Button
|
|
336
|
+
title={last ? "Send" : "Next"}
|
|
337
|
+
onPress={handleNext}
|
|
338
|
+
variant="primary"
|
|
339
|
+
fullWidth
|
|
340
|
+
disabled={sendDisabled}
|
|
341
|
+
/>
|
|
342
|
+
|
|
343
|
+
{/* Skip step — the only in-card opt-out, and gone entirely on the required step
|
|
344
|
+
rather than rendered as a control that would refuse to do anything. */}
|
|
345
|
+
{canSkip && (
|
|
346
|
+
<Pressable
|
|
347
|
+
onPress={handleSkip}
|
|
348
|
+
hitSlop={8}
|
|
349
|
+
accessibilityRole="button"
|
|
350
|
+
accessibilityLabel="Skip this question"
|
|
351
|
+
>
|
|
352
|
+
<Text style={linkStyle}>Skip step</Text>
|
|
353
|
+
</Pressable>
|
|
228
354
|
)}
|
|
229
|
-
|
|
355
|
+
</View>
|
|
356
|
+
) : null;
|
|
357
|
+
|
|
358
|
+
const thanksPane = (
|
|
359
|
+
<View style={{ gap: t.spacing.md }}>
|
|
360
|
+
<DoneCheck theme={t} />
|
|
361
|
+
<Text style={[headingStyle(t.fonts), styles.centerText, { color: t.colors.text }]}>
|
|
362
|
+
Thank you
|
|
363
|
+
</Text>
|
|
364
|
+
<Text style={[bodyStyle(t.fonts), styles.centerText, { color: t.colors.textMuted }]}>
|
|
365
|
+
This helps us make it better.
|
|
366
|
+
</Text>
|
|
367
|
+
</View>
|
|
230
368
|
);
|
|
231
369
|
|
|
232
370
|
return (
|
|
233
|
-
<CenteredModal ref={modalRef} theme={t} dismissible={dismissible} onDismiss={
|
|
234
|
-
|
|
371
|
+
<CenteredModal ref={modalRef} theme={t} dismissible={dismissible} onDismiss={handleDismissed}>
|
|
372
|
+
<ModalPaneHost
|
|
373
|
+
paneKey={phase === "thanks" ? "thanks" : (step?.key ?? "done")}
|
|
374
|
+
direction={state.direction}
|
|
375
|
+
header={header}
|
|
376
|
+
>
|
|
377
|
+
{phase === "thanks" ? thanksPane : formPane}
|
|
378
|
+
</ModalPaneHost>
|
|
235
379
|
</CenteredModal>
|
|
236
380
|
);
|
|
237
381
|
};
|
|
@@ -242,6 +386,13 @@ QuestionnaireGate.displayName = "QuestionnaireGate";
|
|
|
242
386
|
const styles = StyleSheet.create({
|
|
243
387
|
input: { borderWidth: 1 },
|
|
244
388
|
link: { textAlign: "center", textDecorationLine: "underline" },
|
|
389
|
+
centerText: { textAlign: "center" },
|
|
390
|
+
header: { flexDirection: "row", alignItems: "center" },
|
|
391
|
+
// Equal flex on both sides keeps the dots optically centred whether Back is drawn or not.
|
|
392
|
+
// `flex-start` matters: without it the back Pressable stretches across the whole left
|
|
393
|
+
// third of the card, so a tap anywhere near the dots would navigate back.
|
|
394
|
+
headerSide: { flex: 1, alignItems: "flex-start" },
|
|
395
|
+
backGlyph: { includeFontPadding: false },
|
|
245
396
|
});
|
|
246
397
|
|
|
247
398
|
export default QuestionnaireGate;
|
|
@@ -34,8 +34,10 @@ export const decideQuestionnaire = (
|
|
|
34
34
|
): QuestionnaireDecision => decideReview(local, decision);
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Build the `POST /v1/questionnaires/{id}/responses` body. `opinion` is
|
|
38
|
-
*
|
|
37
|
+
* Build the `POST /v1/questionnaires/{id}/responses` body. `opinion` is always sent as a
|
|
38
|
+
* string (trimmed, possibly "" now that its step is skippable — the live server still
|
|
39
|
+
* rejects that, see steps.ts REQUIRED_STEPS); `improve`/`suggestions` are trimmed and sent
|
|
40
|
+
* as `null` when empty. The
|
|
39
41
|
* `meta` bucket carries the non-PII correlation fields (user_id / device_key / session_count),
|
|
40
42
|
* each defaulting to null. Free text lives ONLY here, never in analytics events.
|
|
41
43
|
*/
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @wireai/activation/questionnaire - the
|
|
2
|
+
* @wireai/activation/questionnaire - the multi-step pre-onboarding questionnaire popup.
|
|
3
3
|
*
|
|
4
4
|
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-free. The gate
|
|
5
|
-
* asks
|
|
5
|
+
* asks three questions ONE PER STEP - two skippable, then one REQUIRED last step so every
|
|
6
|
+
* completed run carries at least one real answer -
|
|
6
7
|
* POSTs the answers to the Wire server, and decides WHEN to show via local rules OR a
|
|
7
8
|
* server-provided decision (the AI seam), gated by the `questionnaire` master switch and shown
|
|
8
9
|
* at most once per user per questionnaire id. It reuses the shared `CenteredModal` shell (never
|