@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
|
@@ -3,17 +3,29 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A NEUTRAL prompt ("Enjoying <app>?") over five tappable stars. The honest split:
|
|
5
5
|
* • 5 stars → the native store review (expo-store-review when available, else the
|
|
6
|
-
* configured store URL). Fires `store_review_requested
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* thank-you state,
|
|
6
|
+
* configured store URL). Fires `store_review_requested`, and POSTs a text-less
|
|
7
|
+
* `stars=5` row carrying `meta.store_route` so promoters reach the server too
|
|
8
|
+
* (before 2026-07-16 this route posted nothing — see `pickRating`).
|
|
9
|
+
* • 1-4 stars → a feedback form: the "why this rating" opinion box (~4 lines, MANDATORY
|
|
10
|
+
* as of Malik's 2026-07-16 ruling) and a smaller optional "what can you suggest" box,
|
|
11
|
+
* POSTed to the Wire server (`feedback_text` + `suggestion`). Warm thank-you state,
|
|
12
|
+
* never a dead end.
|
|
13
|
+
*
|
|
14
|
+
* EXACTLY ONE row is POSTed per gate, at the last responsible moment — see `postOnce`. A
|
|
15
|
+
* detractor who abandons instead of typing still has their rating posted (`meta.abandoned`),
|
|
16
|
+
* so forcing the text can never cost us the detractor data it exists to collect.
|
|
12
17
|
*
|
|
13
18
|
* The form NEVER asks for identity (no name / email / contact field): the host already
|
|
14
19
|
* identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
|
|
15
20
|
* the POST body carries alongside the rating and the free text.
|
|
16
21
|
*
|
|
22
|
+
* Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
|
|
23
|
+
* ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
|
|
24
|
+
* thank-you plays the shared `DoneCheck` payoff before the existing 1500ms auto-close.
|
|
25
|
+
* Picking a star fires a haptic through the lazy, optional `expo-haptics` peer — silently
|
|
26
|
+
* absent when the host has not installed it. The popup's own entrance is unchanged: it is
|
|
27
|
+
* the CenteredModal shell's spring, which already animates.
|
|
28
|
+
*
|
|
17
29
|
* Every visual value comes from the OnboardingTheme (useOnboardingTheme). Analytics are
|
|
18
30
|
* callback-based (mirroring the coachmark convention): the host wires `onEvent` to its own
|
|
19
31
|
* analytics. No PII in events — feedback text goes ONLY in the POST body.
|
|
@@ -25,8 +37,11 @@ import React, { useCallback, useMemo, useState } from "react";
|
|
|
25
37
|
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
|
26
38
|
|
|
27
39
|
import { Button } from "../components/Button";
|
|
40
|
+
import { DoneCheck } from "../components/DoneCheck";
|
|
41
|
+
import { ModalPaneHost } from "../components/ModalPaneHost";
|
|
42
|
+
import { playHaptic } from "../haptics/haptics";
|
|
43
|
+
import { mergeThemeOver } from "../theme/mergeThemeOver";
|
|
28
44
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
29
|
-
import type { OnboardingTheme } from "../theme/types";
|
|
30
45
|
import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
|
|
31
46
|
import { buildReviewSubmission, routeRating } from "./decision";
|
|
32
47
|
import { feedbackInputHeights } from "./feedbackForm";
|
|
@@ -34,21 +49,7 @@ import { resolvePresentation } from "./presentation";
|
|
|
34
49
|
import { ReviewModal, type ReviewModalHandle } from "./ReviewModal";
|
|
35
50
|
import { requestStoreReview } from "./storeReview";
|
|
36
51
|
import { submitReview } from "./transport";
|
|
37
|
-
import type { ReviewGateProps } from "./types";
|
|
38
|
-
|
|
39
|
-
const mergeThemeOver = (
|
|
40
|
-
base: OnboardingTheme,
|
|
41
|
-
override?: Partial<OnboardingTheme>,
|
|
42
|
-
): OnboardingTheme => {
|
|
43
|
-
if (!override) return base;
|
|
44
|
-
return {
|
|
45
|
-
colors: { ...base.colors, ...override.colors },
|
|
46
|
-
fonts: { ...base.fonts, ...override.fonts },
|
|
47
|
-
radius: { ...base.radius, ...override.radius },
|
|
48
|
-
spacing: { ...base.spacing, ...override.spacing },
|
|
49
|
-
button: { ...base.button, ...override.button },
|
|
50
|
-
};
|
|
51
|
-
};
|
|
52
|
+
import type { ReviewGateProps, ReviewSubmission } from "./types";
|
|
52
53
|
|
|
53
54
|
type Phase = "rating" | "feedback" | "thanks";
|
|
54
55
|
|
|
@@ -106,6 +107,57 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
106
107
|
onResolved?.();
|
|
107
108
|
}, [onResolved]);
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* EXACTLY ONE review row per gate, ever — the latch that makes "force feedback" safe.
|
|
112
|
+
*
|
|
113
|
+
* WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then
|
|
114
|
+
* post again with the text, enriching the first row. That needs the server to accept a
|
|
115
|
+
* client-supplied id so the second write UPSERTS. It does not, as deployed today:
|
|
116
|
+
* `create_review` mints its own id unconditionally (`app/reviews.py` `_new_id()`) and
|
|
117
|
+
* `CreateReviewRequest` (`app/schemas.py`) has no id field — so two posts = two rows, which
|
|
118
|
+
* double-counts `count` and corrupts `avg`. That is the SAME class of data corruption this
|
|
119
|
+
* whole change exists to fix. And `submitReview` is fire-and-forget (transport.ts: the
|
|
120
|
+
* response is never read), so the kit CANNOT detect a rejection and repair it: a wrong guess
|
|
121
|
+
* fails silently and permanently. So we post once, at the last responsible moment, and never
|
|
122
|
+
* depend on an unmerged/undeployed server capability.
|
|
123
|
+
*/
|
|
124
|
+
const postedRef = React.useRef(false);
|
|
125
|
+
const postOnce = useCallback(
|
|
126
|
+
(body: ReviewSubmission) => {
|
|
127
|
+
if (postedRef.current) return;
|
|
128
|
+
postedRef.current = true;
|
|
129
|
+
submitReview(target, body);
|
|
130
|
+
},
|
|
131
|
+
[target],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The abandonment safety net, and the reason forcing feedback does not COST us detractor data.
|
|
136
|
+
*
|
|
137
|
+
* Malik's ruling: a 1-4 rater must leave text. Implemented naively (block Send, remove Skip),
|
|
138
|
+
* that would LOSE the ratings of everyone who bails rather than types — the detractors we most
|
|
139
|
+
* need — because today the 1-4 branch posts only from `sendFeedback`. So: if the gate goes away
|
|
140
|
+
* while a rating is pending and unposted, we post the rating anyway, with whatever text was
|
|
141
|
+
* typed, flagged `meta.abandoned`. The user is pushed to explain; the rating is never the price.
|
|
142
|
+
*
|
|
143
|
+
* Read through a ref and fired from an unmount-only effect, so it always sees the latest
|
|
144
|
+
* state without re-subscribing (the same ref convention `review_prompt_shown` above uses).
|
|
145
|
+
*/
|
|
146
|
+
const abandonRef = React.useRef<() => void>(() => {});
|
|
147
|
+
abandonRef.current = () => {
|
|
148
|
+
if (postedRef.current || stars < 1) return;
|
|
149
|
+
postOnce(
|
|
150
|
+
buildReviewSubmission({
|
|
151
|
+
stars,
|
|
152
|
+
feedbackText: feedback,
|
|
153
|
+
suggestion,
|
|
154
|
+
sessionId,
|
|
155
|
+
meta: { ...meta, abandoned: true },
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
React.useEffect(() => () => abandonRef.current(), []);
|
|
160
|
+
|
|
109
161
|
const resolveWithDelay = useCallback(() => {
|
|
110
162
|
if (resolveTimer.current) clearTimeout(resolveTimer.current);
|
|
111
163
|
resolveTimer.current = setTimeout(() => {
|
|
@@ -120,20 +172,61 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
120
172
|
const pickRating = useCallback(
|
|
121
173
|
async (value: number) => {
|
|
122
174
|
setStars(value);
|
|
175
|
+
// The tap you feel. Fire-and-forget: an absent peer must never delay the route below.
|
|
176
|
+
playHaptic("selection");
|
|
123
177
|
onEvent?.({ name: "review_rating_selected", id, stars: value });
|
|
124
178
|
if (routeRating(value) === "store") {
|
|
125
179
|
onEvent?.({ name: "store_review_requested", id, stars: value });
|
|
126
|
-
await requestStoreReview(store);
|
|
180
|
+
const route = await requestStoreReview(store);
|
|
181
|
+
// The 5-star row. Until 2026-07-16 this branch POSTed NOTHING: submitReview was
|
|
182
|
+
// reachable only from the feedback phase, which only the 1-4 route enters. Every
|
|
183
|
+
// promoter was silently dropped, so server-side `by_stars["5"]` and `store_routed`
|
|
184
|
+
// were permanently 0 and `avg` averaged detractors only. The four onEvent moments
|
|
185
|
+
// above go to the HOST's analytics, never to Wire, so nothing else covered this.
|
|
186
|
+
//
|
|
187
|
+
// The body is `stars` + `meta` with NO feedback text — exactly the shape the server
|
|
188
|
+
// already documents as the store route ("A 5-star tap posts stars=5 with no text")
|
|
189
|
+
// and the shape its `store_routed` aggregate counts (stars == 5 and not
|
|
190
|
+
// feedback_text). So this is valid against prod as deployed, with no server change.
|
|
191
|
+
//
|
|
192
|
+
// `store_route` records WHICH route we took (native dialog / store URL / neither).
|
|
193
|
+
// Honest ceiling: requestStoreReview is OS-quota'd and reports nothing about whether
|
|
194
|
+
// the user actually left a review — this only ever means we ASKED. Never read it as
|
|
195
|
+
// a review count. We await first so the row carries the route it actually took.
|
|
196
|
+
postOnce(
|
|
197
|
+
buildReviewSubmission({
|
|
198
|
+
stars: value,
|
|
199
|
+
sessionId,
|
|
200
|
+
meta: { ...meta, store_route: route },
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
127
203
|
setPhase("thanks");
|
|
128
204
|
resolveWithDelay();
|
|
129
205
|
} else {
|
|
206
|
+
// 1-4: no post yet. The row lands either from `sendFeedback` (with the forced text) or
|
|
207
|
+
// from the abandonment net above — one row, whichever happens first.
|
|
130
208
|
setPhase("feedback");
|
|
131
209
|
}
|
|
132
210
|
},
|
|
133
|
-
[onEvent, id, store, resolveWithDelay],
|
|
211
|
+
[onEvent, id, store, postOnce, sessionId, meta, resolveWithDelay],
|
|
134
212
|
);
|
|
135
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Malik's ruling (2026-07-16): a 1-4 rater is FORCED to leave feedback. Enforced HERE, on the
|
|
216
|
+
* client, and deliberately NOT on the wire: `feedback_text` stays optional in
|
|
217
|
+
* `CreateReviewRequest`, because the transport is fire-and-forget and every already-shipped
|
|
218
|
+
* client still posts text-less rows — tightening the server would silently 422 them into
|
|
219
|
+
* oblivion. Permissive wire, strict client.
|
|
220
|
+
*
|
|
221
|
+
* Mirrors the questionnaire contract exactly: the opinion box is the mandatory one, the
|
|
222
|
+
* suggestion box stays optional. Trimmed — whitespace is not feedback.
|
|
223
|
+
*/
|
|
224
|
+
const feedbackReady = feedback.trim().length > 0;
|
|
225
|
+
|
|
136
226
|
const sendFeedback = useCallback(() => {
|
|
227
|
+
// The forced-text gate. Also guarded here, not just on the button's `disabled`, so the
|
|
228
|
+
// rule holds no matter who calls this.
|
|
229
|
+
if (!feedbackReady) return;
|
|
137
230
|
// Identity is NEVER collected here — the host already sends user_id / device /
|
|
138
231
|
// session via `sessionId` + `meta`. We POST only the rating + the free text.
|
|
139
232
|
const body = buildReviewSubmission({
|
|
@@ -143,11 +236,12 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
143
236
|
sessionId,
|
|
144
237
|
meta,
|
|
145
238
|
});
|
|
146
|
-
|
|
239
|
+
postOnce(body);
|
|
147
240
|
onEvent?.({ name: "review_feedback_submitted", id, stars });
|
|
241
|
+
playHaptic("success");
|
|
148
242
|
setPhase("thanks");
|
|
149
243
|
resolveWithDelay();
|
|
150
|
-
}, [stars, feedback, suggestion, sessionId, meta,
|
|
244
|
+
}, [feedbackReady, stars, feedback, suggestion, sessionId, meta, postOnce, onEvent, id, resolveWithDelay]);
|
|
151
245
|
|
|
152
246
|
const dismiss = useCallback(() => {
|
|
153
247
|
setPhase("thanks");
|
|
@@ -162,15 +256,6 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
162
256
|
else dismiss();
|
|
163
257
|
}, [mode, dismiss]);
|
|
164
258
|
|
|
165
|
-
const title =
|
|
166
|
-
phase === "feedback"
|
|
167
|
-
? "What should we improve?"
|
|
168
|
-
: phase === "thanks"
|
|
169
|
-
? "Thank you"
|
|
170
|
-
: appName
|
|
171
|
-
? `Enjoying ${appName}?`
|
|
172
|
-
: "How's your experience?";
|
|
173
|
-
|
|
174
259
|
const surface = {
|
|
175
260
|
backgroundColor: t.colors.surface,
|
|
176
261
|
borderRadius: t.radius.lg,
|
|
@@ -183,129 +268,141 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
183
268
|
// padding is folded in so the requested lines are visible inside the padding.
|
|
184
269
|
const feedbackBox = feedbackInputHeights(t.fonts, t.spacing.md);
|
|
185
270
|
|
|
186
|
-
|
|
187
|
-
// the centered, dimmed, animated card surface; in "inline" mode the bare card below does.
|
|
188
|
-
const content = (
|
|
189
|
-
<>
|
|
190
|
-
<Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
|
|
271
|
+
const linkStyle = [captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }];
|
|
191
272
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
</Text>
|
|
218
|
-
</Pressable>
|
|
219
|
-
);
|
|
220
|
-
})}
|
|
221
|
-
</View>
|
|
222
|
-
{dismissible && (
|
|
223
|
-
<Pressable onPress={handleDismissTap} hitSlop={8}>
|
|
224
|
-
<Text style={[captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }]}>
|
|
225
|
-
Not now
|
|
273
|
+
// Each phase is a self-contained PANE, title included, so the heading travels with its
|
|
274
|
+
// content instead of swapping under a static header — one moving surface, not two.
|
|
275
|
+
const ratingPane = (
|
|
276
|
+
<View style={{ gap: t.spacing.md }}>
|
|
277
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>
|
|
278
|
+
{appName ? `Enjoying ${appName}?` : "How's your experience?"}
|
|
279
|
+
</Text>
|
|
280
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
281
|
+
Tap a star to let us know.
|
|
282
|
+
</Text>
|
|
283
|
+
<View style={styles.stars}>
|
|
284
|
+
{[1, 2, 3, 4, 5].map((n) => {
|
|
285
|
+
const filled = n <= (hovered || stars);
|
|
286
|
+
return (
|
|
287
|
+
<Pressable
|
|
288
|
+
key={n}
|
|
289
|
+
onPressIn={() => setHovered(n)}
|
|
290
|
+
onPressOut={() => setHovered(0)}
|
|
291
|
+
onPress={() => void pickRating(n)}
|
|
292
|
+
accessibilityRole="button"
|
|
293
|
+
accessibilityLabel={`${n} star${n > 1 ? "s" : ""}`}
|
|
294
|
+
hitSlop={8}
|
|
295
|
+
>
|
|
296
|
+
<Text style={[styles.star, { color: filled ? t.colors.primary : t.colors.border }]}>
|
|
297
|
+
{filled ? "★" : "☆"}
|
|
226
298
|
</Text>
|
|
227
299
|
</Pressable>
|
|
228
|
-
)
|
|
229
|
-
|
|
300
|
+
);
|
|
301
|
+
})}
|
|
302
|
+
</View>
|
|
303
|
+
{dismissible && (
|
|
304
|
+
<Pressable
|
|
305
|
+
onPress={handleDismissTap}
|
|
306
|
+
hitSlop={8}
|
|
307
|
+
accessibilityRole="button"
|
|
308
|
+
accessibilityLabel="Dismiss the review prompt"
|
|
309
|
+
>
|
|
310
|
+
<Text style={linkStyle}>Not now</Text>
|
|
311
|
+
</Pressable>
|
|
230
312
|
)}
|
|
313
|
+
</View>
|
|
314
|
+
);
|
|
231
315
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
316
|
+
const feedbackPane = (
|
|
317
|
+
<View style={{ gap: t.spacing.md }}>
|
|
318
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>What should we improve?</Text>
|
|
319
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
320
|
+
A few words is plenty — tell us what went wrong and we'll fix it.
|
|
321
|
+
</Text>
|
|
322
|
+
{/* No name / email / contact field: the host already identifies the user via
|
|
323
|
+
sessionId + meta, so we never ask for identity here. As of Malik's 2026-07-16
|
|
324
|
+
ruling the FIRST box is mandatory (mirroring the questionnaire's `opinion`);
|
|
325
|
+
the suggestion box stays optional. */}
|
|
326
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
327
|
+
Why this rating?
|
|
328
|
+
</Text>
|
|
329
|
+
<TextInput
|
|
330
|
+
value={feedback}
|
|
331
|
+
onChangeText={setFeedback}
|
|
332
|
+
placeholder="e.g. too many steps"
|
|
333
|
+
placeholderTextColor={t.colors.textMuted}
|
|
334
|
+
accessibilityLabel="Why this rating?"
|
|
335
|
+
multiline
|
|
336
|
+
textAlignVertical="top"
|
|
337
|
+
style={[
|
|
338
|
+
bodyStyle(t.fonts),
|
|
339
|
+
styles.input,
|
|
340
|
+
{
|
|
341
|
+
color: t.colors.text,
|
|
342
|
+
backgroundColor: t.colors.background,
|
|
343
|
+
borderRadius: t.radius.md,
|
|
344
|
+
borderColor: t.colors.border,
|
|
345
|
+
padding: t.spacing.md,
|
|
346
|
+
minHeight: feedbackBox.minHeight,
|
|
347
|
+
maxHeight: feedbackBox.maxHeight,
|
|
348
|
+
},
|
|
349
|
+
]}
|
|
350
|
+
/>
|
|
351
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
352
|
+
What can we do better? (optional)
|
|
353
|
+
</Text>
|
|
354
|
+
<TextInput
|
|
355
|
+
value={suggestion}
|
|
356
|
+
onChangeText={setSuggestion}
|
|
357
|
+
placeholder="e.g. save my place between steps"
|
|
358
|
+
placeholderTextColor={t.colors.textMuted}
|
|
359
|
+
accessibilityLabel="What can we do better? (optional)"
|
|
360
|
+
style={[
|
|
361
|
+
bodyStyle(t.fonts),
|
|
362
|
+
styles.input,
|
|
363
|
+
{
|
|
364
|
+
color: t.colors.text,
|
|
365
|
+
backgroundColor: t.colors.background,
|
|
366
|
+
borderRadius: t.radius.md,
|
|
367
|
+
borderColor: t.colors.border,
|
|
368
|
+
paddingHorizontal: t.spacing.md,
|
|
369
|
+
paddingVertical: t.spacing.sm,
|
|
370
|
+
minHeight: t.button.height,
|
|
371
|
+
},
|
|
372
|
+
]}
|
|
373
|
+
/>
|
|
374
|
+
<Button
|
|
375
|
+
title="Send feedback"
|
|
376
|
+
onPress={sendFeedback}
|
|
377
|
+
variant="primary"
|
|
378
|
+
fullWidth
|
|
379
|
+
disabled={!feedbackReady}
|
|
380
|
+
/>
|
|
381
|
+
{/* No Skip here — this step is mandatory (Malik, 2026-07-16). The rating itself is
|
|
382
|
+
still never held hostage: bailing out of the gate entirely (backdrop / Android
|
|
383
|
+
back / navigation) posts it through the abandonment net above. We do NOT trap the
|
|
384
|
+
user in the modal; forcing the TEXT is the rule, imprisoning them is not. */}
|
|
385
|
+
</View>
|
|
386
|
+
);
|
|
302
387
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
388
|
+
const thanksPane = (
|
|
389
|
+
<View style={{ gap: t.spacing.md }}>
|
|
390
|
+
<DoneCheck theme={t} />
|
|
391
|
+
<Text style={[headingStyle(t.fonts), styles.centerText, { color: t.colors.text }]}>
|
|
392
|
+
Thank you
|
|
393
|
+
</Text>
|
|
394
|
+
<Text style={[bodyStyle(t.fonts), styles.centerText, { color: t.colors.textMuted }]}>
|
|
395
|
+
We appreciate you. This helps us make it better.
|
|
396
|
+
</Text>
|
|
397
|
+
</View>
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// The phase content, shared by both presentations. In "modal" mode ReviewModal supplies
|
|
401
|
+
// the centered, dimmed, animated card surface; in "inline" mode the bare card below does.
|
|
402
|
+
const content = (
|
|
403
|
+
<ModalPaneHost paneKey={phase}>
|
|
404
|
+
{phase === "rating" ? ratingPane : phase === "feedback" ? feedbackPane : thanksPane}
|
|
405
|
+
</ModalPaneHost>
|
|
309
406
|
);
|
|
310
407
|
|
|
311
408
|
// Default: a centered popup over a dimmed backdrop with a spring/fade entrance. A backdrop
|
|
@@ -331,6 +428,7 @@ const styles = StyleSheet.create({
|
|
|
331
428
|
star: { fontSize: 40, lineHeight: 46 },
|
|
332
429
|
input: { borderWidth: 1 },
|
|
333
430
|
link: { textAlign: "center", textDecorationLine: "underline" },
|
|
431
|
+
centerText: { textAlign: "center" },
|
|
334
432
|
});
|
|
335
433
|
|
|
336
434
|
export default ReviewGate;
|
package/src/reviews/index.ts
CHANGED
|
@@ -50,9 +50,12 @@ export type { FeedbackInputHeights } from "./feedbackForm";
|
|
|
50
50
|
// ─── The 5-star store path (optional peer + URL fallback) ─────────────────────
|
|
51
51
|
export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
|
|
52
52
|
|
|
53
|
-
// ─── Server transport: submit a review
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
// ─── Server transport: submit a review, FETCH THE FIRING DECISION, report app.* events ──
|
|
54
|
+
// `fetchReviewDecision` is the mirror of `fetchQuestionnaireDecision`. Use it instead of
|
|
55
|
+
// hand-rolling a decision fetch: a host-rolled one that collapses `{fire:false}` into undefined
|
|
56
|
+
// makes the server able to turn prompts ON but never OFF (the 2026-07-16 first-session incident).
|
|
57
|
+
export { submitReview, fetchReviewDecision, reportAppEvent } from "./transport";
|
|
58
|
+
export type { FetchReviewDecisionOptions, ReportAppEventOptions } from "./transport";
|
|
56
59
|
|
|
57
60
|
// ─── Gate-key + storage helpers (shared coachmark storage singleton) ──────────
|
|
58
61
|
export {
|
|
@@ -64,6 +67,7 @@ export {
|
|
|
64
67
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
65
68
|
export type {
|
|
66
69
|
ReviewDecision,
|
|
70
|
+
ReviewDecisionResponse,
|
|
67
71
|
RatingRoute,
|
|
68
72
|
ReviewConfig,
|
|
69
73
|
ReviewStoreConfig,
|
package/src/reviews/transport.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
* (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
|
|
4
4
|
* thin fetch wrapper, Bearer tenant key, swallow every error.
|
|
5
5
|
*
|
|
6
|
-
* • submitReview
|
|
7
|
-
* •
|
|
6
|
+
* • submitReview → POST {serverUrl}/v1/reviews (the review row)
|
|
7
|
+
* • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
|
|
8
|
+
* • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
|
|
8
9
|
*
|
|
9
10
|
* `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
|
|
10
11
|
* events through the SAME transport (stored server-side as event_type='app_event',
|
|
11
12
|
* question_key=<name>), which is what the backend review-firing rules evaluate on — and
|
|
12
13
|
* it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
|
|
13
14
|
*/
|
|
14
|
-
import type { ReviewSubmission, ReviewTarget } from "./types";
|
|
15
|
+
import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
|
|
@@ -38,6 +39,79 @@ export const submitReview = (
|
|
|
38
39
|
}
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
/** Options for the best-effort review decision fetch. */
|
|
43
|
+
export interface FetchReviewDecisionOptions {
|
|
44
|
+
/**
|
|
45
|
+
* The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
|
|
46
|
+
* the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
|
|
47
|
+
* identity for this call. (Today's prod still requires `session_id` and answers 422 without it;
|
|
48
|
+
* the server relaxation is unmerged. A 422 is a non-2xx, so it returns null like any other
|
|
49
|
+
* failure, and the local rules stand. Permissive wire: do NOT make this required here.)
|
|
50
|
+
*/
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
/** A stable, non-PII device id. The decision endpoint reads it for cooldown + min-sessions. */
|
|
53
|
+
deviceKey?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort fetch of the SERVER's review firing decision. The mirror of
|
|
58
|
+
* `fetchQuestionnaireDecision`, and the primitive whose absence caused a live incident.
|
|
59
|
+
*
|
|
60
|
+
* ── WHY THIS LIVES IN THE KIT (Malik, 2026-07-16) ────────────────────────────────────────
|
|
61
|
+
*
|
|
62
|
+
* The questionnaire module has always had its decision fetch; reviews never did. So a host
|
|
63
|
+
* hand-rolled one, and got it subtly wrong: its helper collapsed an explicit `{fire:false}`
|
|
64
|
+
* into `undefined`. `decideReview` is `(local, decision) => decision ?? local`, so `undefined`
|
|
65
|
+
* means "the server has no opinion, use the local rules" — and the local timer fired. The
|
|
66
|
+
* server could therefore only ever turn review prompts ON, never OFF. A real user was asked to
|
|
67
|
+
* rate the app ~3 minutes into their FIRST session, having seen nothing yet, and left 1 star.
|
|
68
|
+
*
|
|
69
|
+
* The bug was not that the host was careless. It was that the kit made every host invent this.
|
|
70
|
+
* So the primitive moves here and hosts keep a thin call site.
|
|
71
|
+
*
|
|
72
|
+
* ── THE CONTRACT THAT MATTERS ────────────────────────────────────────────────────────────
|
|
73
|
+
*
|
|
74
|
+
* • 2xx → the FULL `{fire, reason, arm}`, INCLUDING `fire:false`. Never collapse a false
|
|
75
|
+
* into null. That collapse IS the bug: a false must reach `decideReview` intact so
|
|
76
|
+
* it can override the local rules and keep the gate shut.
|
|
77
|
+
* • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
|
|
78
|
+
* the one case where falling back to local rules is correct.
|
|
79
|
+
*
|
|
80
|
+
* Never throws: unreachable, non-2xx (incl. today's 422), bad JSON, or a missing `fetch` all
|
|
81
|
+
* resolve to null. The kit does not call this internally; a host awaits it and passes the
|
|
82
|
+
* result straight to `useReviewGate({ decision })`.
|
|
83
|
+
*
|
|
84
|
+
* const decision = await fetchReviewDecision(target, { deviceKey });
|
|
85
|
+
* const gate = useReviewGate({ config, decision: decision ?? undefined, storage });
|
|
86
|
+
*/
|
|
87
|
+
export const fetchReviewDecision = async (
|
|
88
|
+
target: ReviewTarget | undefined,
|
|
89
|
+
options: FetchReviewDecisionOptions = {},
|
|
90
|
+
): Promise<ReviewDecisionResponse | null> => {
|
|
91
|
+
if (!target?.serverUrl) return null;
|
|
92
|
+
try {
|
|
93
|
+
const base = target.serverUrl.replace(/\/$/, "");
|
|
94
|
+
const params = new URLSearchParams();
|
|
95
|
+
if (options.sessionId) params.set("session_id", options.sessionId);
|
|
96
|
+
if (options.deviceKey) params.set("device_key", options.deviceKey);
|
|
97
|
+
const qs = params.toString();
|
|
98
|
+
const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
|
|
99
|
+
const headers: Record<string, string> = {};
|
|
100
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
101
|
+
const res = await fetch(url, { headers });
|
|
102
|
+
if (!res || !res.ok) return null;
|
|
103
|
+
const json = (await res.json()) as ReviewDecisionResponse | null;
|
|
104
|
+
// A body without a boolean `fire` is not a decision. Guard it explicitly rather than
|
|
105
|
+
// letting `{}` through as a truthy object that `decideReview` would treat as a verdict
|
|
106
|
+
// (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
|
|
107
|
+
if (!json || typeof json.fire !== "boolean") return null;
|
|
108
|
+
return json;
|
|
109
|
+
} catch {
|
|
110
|
+
/* unreachable / non-2xx / bad JSON / missing-fetch → the server has no opinion */
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
41
115
|
/** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
|
|
42
116
|
export interface ReportAppEventOptions {
|
|
43
117
|
/** The onboarding/session id to correlate with, when known. */
|
package/src/reviews/types.ts
CHANGED
|
@@ -19,6 +19,19 @@ export interface ReviewDecision {
|
|
|
19
19
|
reason?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The `GET /v1/reviews/decision` response: `ReviewDecision` plus the additive `arm`.
|
|
24
|
+
*
|
|
25
|
+
* `arm` is the firing experiment's sticky assignment for this device/session. Echo it back in
|
|
26
|
+
* the submission's `meta.firing_arm` and the server keeps it verbatim (`_stamp_firing_arm`:
|
|
27
|
+
* "an arm the kit already echoed is left as-is"); omit it and the server recomputes from the
|
|
28
|
+
* sticky hash. Echoing is what makes per-arm attribution survive a later reweighting of the
|
|
29
|
+
* experiment, so a host running one should pass it through.
|
|
30
|
+
*/
|
|
31
|
+
export interface ReviewDecisionResponse extends ReviewDecision {
|
|
32
|
+
arm?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
/** Where to route a rating: 5 stars → the store, 1-4 → the feedback form. */
|
|
23
36
|
export type RatingRoute = "store" | "feedback";
|
|
24
37
|
|
|
@@ -39,7 +39,7 @@ export const DEFAULT_SESSION_TTL_MS = 3_600_000;
|
|
|
39
39
|
/** Ceiling on the storage read — a hung adapter degrades to a fresh mint, never a stuck gate. */
|
|
40
40
|
const READ_TIMEOUT_MS = 1_500;
|
|
41
41
|
|
|
42
|
-
/** Storage key for an app's cached session, e.g. `wireai:session:
|
|
42
|
+
/** Storage key for an app's cached session, e.g. `wireai:session:acme`. */
|
|
43
43
|
export const sessionStorageKey = (appId: string): string => `wireai:session:${appId}`;
|
|
44
44
|
|
|
45
45
|
type PersistedSession = { id: string; ts: number };
|