@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.
Files changed (94) hide show
  1. package/AGENTS.md +29 -5
  2. package/CHANGELOG.md +198 -6
  3. package/README.md +105 -10
  4. package/dist/analytics/index.d.mts +12 -8
  5. package/dist/analytics/index.d.ts +12 -8
  6. package/dist/analytics/index.js +525 -55
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +525 -55
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +2 -2
  11. package/dist/coachmarks/index.d.ts +2 -2
  12. package/dist/coachmarks/index.js.map +1 -1
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-61dcm3V-.d.ts} +19 -3
  15. package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-Bs2JfTJ8.d.mts} +19 -3
  16. package/dist/{decision-BzbiKwk3.d.mts → decision-Bl_M2y3r.d.mts} +1 -1
  17. package/dist/{decision-plDEOCkt.d.ts → decision-Cau5KmP6.d.ts} +1 -1
  18. package/dist/index.d.mts +620 -13
  19. package/dist/index.d.ts +620 -13
  20. package/dist/index.js +1194 -369
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1015 -201
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/questionnaire/index.d.mts +69 -23
  25. package/dist/questionnaire/index.d.ts +69 -23
  26. package/dist/questionnaire/index.js +643 -172
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs +608 -137
  29. package/dist/questionnaire/index.mjs.map +1 -1
  30. package/dist/reviews/index.d.mts +24 -12
  31. package/dist/reviews/index.d.ts +24 -12
  32. package/dist/reviews/index.js +507 -173
  33. package/dist/reviews/index.js.map +1 -1
  34. package/dist/reviews/index.mjs +462 -129
  35. package/dist/reviews/index.mjs.map +1 -1
  36. package/dist/showcase/index.d.mts +2 -2
  37. package/dist/showcase/index.d.ts +2 -2
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/transport-DzU-TqZz.d.ts +86 -0
  41. package/dist/transport-f5VVB5hH.d.mts +86 -0
  42. package/dist/{types-GL_hQ0TN.d.mts → types-BcmagF6K.d.mts} +1 -1
  43. package/dist/{types-GL_hQ0TN.d.ts → types-BcmagF6K.d.ts} +1 -1
  44. package/dist/{types-BhpXJGlg.d.ts → types-Buj9Lw9t.d.ts} +15 -3
  45. package/dist/{types-CMuOexw0.d.mts → types-CKFhyrMu.d.mts} +1 -1
  46. package/dist/{types-CMuOexw0.d.ts → types-CKFhyrMu.d.ts} +1 -1
  47. package/dist/{types-A6pTxIZV.d.mts → types-CNUqMK0D.d.mts} +15 -3
  48. package/metro/index.d.ts +18 -1
  49. package/metro/index.js +80 -0
  50. package/package.json +9 -1
  51. package/src/WireOnboarding.tsx +4 -0
  52. package/src/analytics/analyticsFacade.ts +55 -10
  53. package/src/analytics/currentSession.ts +1 -1
  54. package/src/cards/CardGridSelectCard.tsx +253 -0
  55. package/src/cards/ChipSelectCard.tsx +1 -1
  56. package/src/cards/SelectionCard.tsx +15 -19
  57. package/src/cards/index.ts +13 -1
  58. package/src/cards/optionSchema.ts +65 -0
  59. package/src/coachmarks/types.ts +1 -1
  60. package/src/components/CardHandoff.tsx +39 -7
  61. package/src/components/DoneCheck.tsx +94 -0
  62. package/src/components/ModalPaneHost.tsx +141 -0
  63. package/src/components/StepDots.tsx +112 -0
  64. package/src/context/deviceId.ts +43 -0
  65. package/src/device/appVersion.ts +29 -8
  66. package/src/device/deviceContext.ts +14 -1
  67. package/src/device/deviceModel.ts +106 -0
  68. package/src/features/cache.ts +1 -1
  69. package/src/haptics/expo-haptics.d.ts +31 -0
  70. package/src/haptics/haptics.ts +58 -0
  71. package/src/icons/IconRegistry.tsx +53 -0
  72. package/src/icons/WireIcon.tsx +85 -0
  73. package/src/icons/expoIcons.ts +165 -0
  74. package/src/icons/index.ts +18 -0
  75. package/src/icons/vocabulary.ts +154 -0
  76. package/src/index.ts +22 -0
  77. package/src/questionnaire/QuestionnaireGate.tsx +315 -164
  78. package/src/questionnaire/decision.ts +4 -2
  79. package/src/questionnaire/index.ts +3 -2
  80. package/src/questionnaire/steps.ts +261 -0
  81. package/src/questionnaire/types.ts +33 -12
  82. package/src/reviews/ReviewGate.tsx +250 -152
  83. package/src/reviews/index.ts +7 -3
  84. package/src/reviews/transport.ts +77 -3
  85. package/src/reviews/types.ts +13 -0
  86. package/src/session/persistedSession.ts +1 -1
  87. package/src/session-analytics/lifecycle.ts +1 -1
  88. package/src/session-analytics/useSessionStart.ts +2 -2
  89. package/src/showcase/FeatureShowcase.tsx +1 -1
  90. package/src/showcase/showcaseColors.ts +1 -1
  91. package/src/theme/mergeThemeOver.ts +27 -0
  92. package/src/types.ts +8 -0
  93. package/dist/transport-BeO_Brcu.d.mts +0 -40
  94. 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
- * • 1-4 stars a feather-light feedback form: TWO optional inputs, the "why this
8
- * rating" opinion box (~4 lines) and a smaller "what can you suggest" box, POSTed
9
- * to the Wire server (`feedback_text` + `suggestion`). The rating is the only
10
- * mandatory action, so Send works with either box, both, or neither. Warm
11
- * thank-you state, never a dead end.
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
- submitReview(target, body);
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, target, onEvent, id, resolveWithDelay]);
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
- // The phase content, shared by both presentations. In "modal" mode ReviewModal supplies
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
- {phase === "rating" && (
193
- <>
194
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
195
- Tap a star to let us know.
196
- </Text>
197
- <View style={styles.stars}>
198
- {[1, 2, 3, 4, 5].map((n) => {
199
- const filled = n <= (hovered || stars);
200
- return (
201
- <Pressable
202
- key={n}
203
- onPressIn={() => setHovered(n)}
204
- onPressOut={() => setHovered(0)}
205
- onPress={() => void pickRating(n)}
206
- accessibilityRole="button"
207
- accessibilityLabel={`${n} star${n > 1 ? "s" : ""}`}
208
- hitSlop={8}
209
- >
210
- <Text
211
- style={[
212
- styles.star,
213
- { color: filled ? t.colors.primary : t.colors.border },
214
- ]}
215
- >
216
- {filled ? "★" : "☆"}
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
- {phase === "feedback" && (
233
- <>
234
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
235
- A few words is plenty. Both boxes are optional, so send whatever you like.
236
- </Text>
237
- {/* Two OPTIONAL inputs. No name / email / contact field: the host already
238
- identifies the user via sessionId + meta, so we never ask for identity
239
- here. Send stays enabled whether the user fills one box, both, or neither
240
- (the rating is the only mandatory action). */}
241
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
242
- Why this rating? (optional)
243
- </Text>
244
- <TextInput
245
- value={feedback}
246
- onChangeText={setFeedback}
247
- placeholder="e.g. too many steps"
248
- placeholderTextColor={t.colors.textMuted}
249
- multiline
250
- textAlignVertical="top"
251
- style={[
252
- bodyStyle(t.fonts),
253
- styles.input,
254
- {
255
- color: t.colors.text,
256
- backgroundColor: t.colors.background,
257
- borderRadius: t.radius.md,
258
- borderColor: t.colors.border,
259
- padding: t.spacing.md,
260
- minHeight: feedbackBox.minHeight,
261
- maxHeight: feedbackBox.maxHeight,
262
- },
263
- ]}
264
- />
265
- <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
266
- What can we do better? (optional)
267
- </Text>
268
- <TextInput
269
- value={suggestion}
270
- onChangeText={setSuggestion}
271
- placeholder="e.g. save my place between steps"
272
- placeholderTextColor={t.colors.textMuted}
273
- style={[
274
- bodyStyle(t.fonts),
275
- styles.input,
276
- {
277
- color: t.colors.text,
278
- backgroundColor: t.colors.background,
279
- borderRadius: t.radius.md,
280
- borderColor: t.colors.border,
281
- paddingHorizontal: t.spacing.md,
282
- paddingVertical: t.spacing.sm,
283
- minHeight: t.button.height,
284
- },
285
- ]}
286
- />
287
- <Button
288
- title="Send feedback"
289
- onPress={sendFeedback}
290
- variant="primary"
291
- fullWidth
292
- />
293
- {dismissible && (
294
- <Pressable onPress={handleDismissTap} hitSlop={8}>
295
- <Text style={[captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }]}>
296
- Skip
297
- </Text>
298
- </Pressable>
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
- {phase === "thanks" && (
304
- <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>
305
- We appreciate you. This helps us make it better.
306
- </Text>
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;
@@ -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 + report generic app.* events ──────────
54
- export { submitReview, reportAppEvent } from "./transport";
55
- export type { ReportAppEventOptions } from "./transport";
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,
@@ -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 → POST {serverUrl}/v1/reviews (the 1-4 feedback body)
7
- * • reportAppEvent POST {serverUrl}/v1/events (generic app.* namespace)
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. */
@@ -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:myelino`. */
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 };